diff --git a/DB/Sources/DB/Content/CompositionRecord.swift b/DB/Sources/DB/Content/CompositionRecord.swift new file mode 100644 index 0000000..cf3bc17 --- /dev/null +++ b/DB/Sources/DB/Content/CompositionRecord.swift @@ -0,0 +1,9 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +struct CompositionRecord: Codable, FetchableRecord, PersistableRecord { + let id: Composition.Id + let text: String +} diff --git a/DB/Sources/DB/Entities/Composition.swift b/DB/Sources/DB/Entities/Composition.swift new file mode 100644 index 0000000..4715021 --- /dev/null +++ b/DB/Sources/DB/Entities/Composition.swift @@ -0,0 +1,24 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import GRDB + +public class Composition { + public let id: Id + public var text: String + + public init(id: Id, text: String) { + self.id = id + self.text = text + } +} + +public extension Composition { + typealias Id = UUID +} + +extension Composition { + convenience init(record: CompositionRecord) { + self.init(id: record.id, text: record.text) + } +} diff --git a/DB/Sources/DB/Identity/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift index f76ea82..669e045 100644 --- a/DB/Sources/DB/Identity/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -182,6 +182,17 @@ public extension IdentityDatabase { .eraseToAnyPublisher() } + func authenticatedIdentitiesPublisher() -> AnyPublisher<[Identity], Error> { + ValueObservation.tracking( + IdentityInfo.request(IdentityRecord.order(IdentityRecord.Columns.lastUsedAt.desc)) + .filter(IdentityRecord.Columns.authenticated == true && IdentityRecord.Columns.pending == false) + .fetchAll) + .removeDuplicates() + .publisher(in: databaseWriter) + .map { $0.map(Identity.init(info:)) } + .eraseToAnyPublisher() + } + func immediateMostRecentlyUsedIdentityIdPublisher() -> AnyPublisher { ValueObservation.tracking( IdentityRecord.select(IdentityRecord.Columns.id) @@ -199,6 +210,17 @@ public extension IdentityDatabase { .map { $0.map(Identity.init(info:)) } .eraseToAnyPublisher() } + + func mostRecentAuthenticatedIdentity() throws -> Identity? { + guard let info = try databaseWriter.read( + IdentityInfo.request(IdentityRecord.order(IdentityRecord.Columns.lastUsedAt.desc)) + .filter(IdentityRecord.Columns.authenticated == true + && IdentityRecord.Columns.pending == false) + .fetchOne) + else { return nil } + + return Identity(info: info) + } } private extension IdentityDatabase { diff --git a/Data Sources/NewStatusDataSource.swift b/Data Sources/NewStatusDataSource.swift new file mode 100644 index 0000000..075894e --- /dev/null +++ b/Data Sources/NewStatusDataSource.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +final class NewStatusDataSource: UICollectionViewDiffableDataSource { + init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) { + let registration = UICollectionView.CellRegistration { + $0.viewModel = $2 + } + + super.init(collectionView: collectionView) { collectionView, indexPath, _ in + collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: viewModelProvider(indexPath)) + } + } +} diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index d55d53e..5c97b7d 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -49,7 +49,15 @@ D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E5291257C53B600FA2C5F /* NewStatusViewController.swift */; }; D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */; }; D08E52CC257C80E300FA2C5F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D0C7D45724F76169001EBDBB /* Localizable.strings */; }; - D08E52D2257C811200FA2C5F /* ShareExtensionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */; }; + D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */; }; + D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; }; + D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */; }; + D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; }; + D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */; }; + D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; + D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08E52ED257D757100FA2C5F /* CompositionView.swift */; }; + D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; }; + D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */; }; D0A1F4F7252E7D4B004435BF /* TableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */; }; D0A7AC7325748BFF00E4E8AB /* ReportStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */; }; D0B32F50250B373600311912 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B32F4F250B373600311912 /* RegistrationView.swift */; }; @@ -97,6 +105,11 @@ D0F0B126251A90F400942152 /* AccountListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B125251A90F400942152 /* AccountListCell.swift */; }; D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B12D251A97E400942152 /* TableViewController.swift */; }; D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */; }; + D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; }; + D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */; }; + D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D01C6FAB252024BD003D0300 /* Array+Extensions.swift */; }; + D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = D0F2D53F25818C4B00986197 /* Kingfisher */; }; + D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; }; D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C8E253686F9003EF1EB /* PlayerView.swift */; }; D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */; }; /* End PBXBuildFile section */ @@ -185,7 +198,10 @@ D08E529B257C58D600FA2C5F /* NewStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusView.swift; sourceTree = ""; }; D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionNavigationViewController.swift; sourceTree = ""; }; D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareErrorViewController.swift; sourceTree = ""; }; - D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareExtensionError.swift; sourceTree = ""; }; + D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ShareExtensionError+Extensions.swift"; sourceTree = ""; }; + D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionListCell.swift; sourceTree = ""; }; + D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionContentConfiguration.swift; sourceTree = ""; }; + D08E52ED257D757100FA2C5F /* CompositionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionView.swift; sourceTree = ""; }; D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewDataSource.swift; sourceTree = ""; }; D0A7AC7225748BFF00E4E8AB /* ReportStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportStatusView.swift; sourceTree = ""; }; D0AD03552505814D0085A466 /* Base16 */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Base16; sourceTree = ""; }; @@ -242,6 +258,7 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountListCell.swift; sourceTree = ""; }; D0F0B12D251A97E400942152 /* TableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewController.swift; sourceTree = ""; }; D0F0B135251AA12700942152 /* CollectionItem+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionItem+Extensions.swift"; sourceTree = ""; }; + D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewStatusDataSource.swift; sourceTree = ""; }; D0FE1C8E253686F9003EF1EB /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; D0FE1C9725368A9D003EF1EB /* PlayerCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCache.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -268,6 +285,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D0F2D54025818C4B00986197 /* Kingfisher in Frameworks */, D08E52B8257C62D500FA2C5F /* ViewModels in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -373,7 +391,7 @@ D08E5273257C36CA00FA2C5F /* Info.plist */, D08E5277257C36CB00FA2C5F /* Share Extension.entitlements */, D08E52C6257C7AEE00FA2C5F /* ShareErrorViewController.swift */, - D08E52D1257C811200FA2C5F /* ShareExtensionError.swift */, + D08E52D1257C811200FA2C5F /* ShareExtensionError+Extensions.swift */, D08E52A5257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift */, ); path = "Share Extension"; @@ -383,6 +401,7 @@ isa = PBXGroup; children = ( D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, + D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */, ); path = "Data Sources"; sourceTree = ""; @@ -405,6 +424,9 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, + D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */, + D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */, + D08E52ED257D757100FA2C5F /* CompositionView.swift */, D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, D00702352555F4C500F38136 /* ConversationContentConfiguration.swift */, D00702282555E51200F38136 /* ConversationListCell.swift */, @@ -577,6 +599,7 @@ name = "Share Extension"; packageProductDependencies = ( D08E52B7257C62D500FA2C5F /* ViewModels */, + D0F2D53F25818C4B00986197 /* Kingfisher */, ); productName = "Share Extension"; productReference = D08E526C257C36CA00FA2C5F /* Share Extension.appex */; @@ -715,6 +738,7 @@ files = ( D0C7D4A324F7616A001EBDBB /* TabNavigationView.swift in Sources */, D02E1F95250B13210071AD56 /* SafariView.swift in Sources */, + D0F2D4D1257EE84400986197 /* NewStatusDataSource.swift in Sources */, D00702292555E51200F38136 /* ConversationListCell.swift in Sources */, D03B1B2A253818F3008F964B /* MediaPreferencesView.swift in Sources */, D0C7D49C24F7616A001EBDBB /* RootView.swift in Sources */, @@ -722,8 +746,10 @@ D0B32F50250B373600311912 /* RegistrationView.swift in Sources */, D08B8D612540DE3B00B1EBEF /* ZoomDismissalInteractionController.swift in Sources */, D036AA07254B6118009094DF /* NotificationView.swift in Sources */, + D08E52EE257D757100FA2C5F /* CompositionView.swift in Sources */, D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */, D0FE1C9825368A9D003EF1EB /* PlayerCache.swift in Sources */, + D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, @@ -743,6 +769,7 @@ D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, D0B5FE9B251583DB00478838 /* ProfileCollection+Extensions.swift in Sources */, + D08E52DC257D742B00FA2C5F /* CompositionListCell.swift in Sources */, D0C7D49E24F7616A001EBDBB /* SecondaryNavigationView.swift in Sources */, D08B8D602540DE3B00B1EBEF /* ZoomAnimator.swift in Sources */, D08E5292257C53B600FA2C5F /* NewStatusViewController.swift in Sources */, @@ -804,8 +831,16 @@ files = ( D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */, D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */, - D08E52D2257C811200FA2C5F /* ShareExtensionError.swift in Sources */, + D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */, + D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */, + D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */, + D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */, + D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */, + D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */, + D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */, + D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */, + D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1237,6 +1272,11 @@ isa = XCSwiftPackageProductDependency; productName = ViewModels; }; + D0F2D53F25818C4B00986197 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = D06B492124D4611300642749 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = D047FA8024C3E21000AF17C5 /* Project object */; diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift b/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift index c2bf3ad..e67ae52 100644 --- a/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift +++ b/ServiceLayer/Sources/ServiceLayer/Entities/AppEnvironment.swift @@ -8,13 +8,14 @@ import Mastodon import UserNotifications public struct AppEnvironment { + public let uuid: () -> UUID + let session: URLSession let webAuthSessionType: WebAuthSession.Type let keychain: Keychain.Type let userDefaults: UserDefaults let userNotificationClient: UserNotificationClient let reduceMotion: () -> Bool - let uuid: () -> UUID let inMemoryContent: Bool let fixtureDatabase: IdentityDatabase? diff --git a/ServiceLayer/Sources/ServiceLayer/Entities/Composition.swift b/ServiceLayer/Sources/ServiceLayer/Entities/Composition.swift new file mode 100644 index 0000000..b4af700 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Entities/Composition.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import DB + +public typealias Composition = DB.Composition diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index 01de9cf..fa7224a 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -39,6 +39,14 @@ public extension AllIdentitiesService { database.immediateMostRecentlyUsedIdentityIdPublisher() } + func authenticatedIdentitiesPublisher() -> AnyPublisher<[Identity], Error> { + database.authenticatedIdentitiesPublisher() + } + + func mostRecentAuthenticatedIdentity() throws -> Identity? { + try database.mostRecentAuthenticatedIdentity() + } + func createIdentity(url: URL, kind: IdentityCreation) -> AnyPublisher { let id = environment.uuid() let secrets = Secrets(identityId: id, keychain: environment.keychain) diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 9af703a..46842e6 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -228,10 +228,6 @@ public extension IdentityService { func domainBlocksService() -> DomainBlocksService { DomainBlocksService(mastodonAPIClient: mastodonAPIClient) } - - func newStatusService() -> NewStatusService { - NewStatusService(id: id, identityDatabase: identityDatabase, environment: environment) - } } private extension IdentityService { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/NewStatusService.swift b/ServiceLayer/Sources/ServiceLayer/Services/NewStatusService.swift deleted file mode 100644 index b87ad3e..0000000 --- a/ServiceLayer/Sources/ServiceLayer/Services/NewStatusService.swift +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright © 2020 Metabolist. All rights reserved. - -import Combine -import DB -import Foundation -import Mastodon -import MastodonAPI -import Secrets - -public struct NewStatusService { - private var id: Identity.Id - private let identityDatabase: IdentityDatabase - private let environment: AppEnvironment - - public init(id: Identity.Id, identityDatabase: IdentityDatabase, environment: AppEnvironment) { - self.id = id - self.identityDatabase = identityDatabase - self.environment = environment - } -} - -extension NewStatusService { - func mastodonAPIClient() throws -> MastodonAPIClient { - let secrets = Secrets( - identityId: id, - keychain: environment.keychain) - let mastodonAPIClient = MastodonAPIClient( - session: environment.session, - instanceURL: try secrets.getInstanceURL()) - - mastodonAPIClient.accessToken = try secrets.getAccessToken() - - return mastodonAPIClient - } -} diff --git a/Share Extension/ShareExtensionError.swift b/Share Extension/ShareExtensionError+Extensions.swift similarity index 74% rename from Share Extension/ShareExtensionError.swift rename to Share Extension/ShareExtensionError+Extensions.swift index 125604b..371d960 100644 --- a/Share Extension/ShareExtensionError.swift +++ b/Share Extension/ShareExtensionError+Extensions.swift @@ -1,13 +1,10 @@ // Copyright © 2020 Metabolist. All rights reserved. import Foundation - -enum ShareExtensionError: Error { - case noAccountFound -} +import ViewModels extension ShareExtensionError: LocalizedError { - var errorDescription: String? { + public var errorDescription: String? { switch self { case .noAccountFound: return NSLocalizedString("share-extension-error.no-account-found", comment: "") diff --git a/Share Extension/ShareExtensionNavigationViewController.swift b/Share Extension/ShareExtensionNavigationViewController.swift index 871fdc7..293a709 100644 --- a/Share Extension/ShareExtensionNavigationViewController.swift +++ b/Share Extension/ShareExtensionNavigationViewController.swift @@ -7,20 +7,26 @@ import ViewModels @objc(ShareExtensionNavigationViewController) class ShareExtensionNavigationViewController: UINavigationController { + private let viewModel = ShareExtensionNavigationViewModel( + environment: .live( + userNotificationCenter: .current(), + reduceMotion: { UIAccessibility.isReduceMotionEnabled })) override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - let viewModel: NewStatusViewModel + let newStatusViewModel: NewStatusViewModel do { - viewModel = try newStatusViewModel() + newStatusViewModel = try viewModel.newStatusViewModel() } catch { setViewControllers([ShareErrorViewController(error: error)], animated: false) return } - setViewControllers([NewStatusViewController(viewModel: viewModel)], animated: false) + setViewControllers( + [NewStatusViewController(viewModel: newStatusViewModel, isShareExtension: true)], + animated: false) } @available(*, unavailable) @@ -28,23 +34,3 @@ class ShareExtensionNavigationViewController: UINavigationController { fatalError("init(coder:) has not been implemented") } } - -private extension ShareExtensionNavigationViewController { - func newStatusViewModel() throws -> NewStatusViewModel { - let environment = AppEnvironment.live( - userNotificationCenter: .current(), - reduceMotion: { UIAccessibility.isReduceMotionEnabled }) - let allIdentitiesService = try AllIdentitiesService(environment: environment) - - var recentId: Identity.Id? - - _ = allIdentitiesService.immediateMostRecentlyUsedIdentityIdPublisher() - .sink { _ in } receiveValue: { recentId = $0 } - - guard let id = recentId else { throw ShareExtensionError.noAccountFound } - - let newStatusService = try allIdentitiesService.identityService(id: id).newStatusService() - - return NewStatusViewModel(service: newStatusService) - } -} diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index bf8bc6c..6b877ac 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -1,15 +1,35 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine +import Kingfisher import UIKit import ViewModels -class NewStatusViewController: UIViewController { +class NewStatusViewController: UICollectionViewController { private let viewModel: NewStatusViewModel + private let isShareExtension: Bool + private var cancellables = Set() - init(viewModel: NewStatusViewModel) { + private lazy var dataSource: NewStatusDataSource = { + .init(collectionView: collectionView, viewModelProvider: viewModel.viewModel(indexPath:)) + }() + + init(viewModel: NewStatusViewModel, isShareExtension: Bool) { self.viewModel = viewModel + self.isShareExtension = isShareExtension - super.init(nibName: nil, bundle: nil) + let configuration = UICollectionLayoutListConfiguration(appearance: .plain) + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + + super.init(collectionViewLayout: layout) + + viewModel.$identification + .sink { [weak self] in + guard let self = self else { return } + + self.setupBarButtonItems(identification: $0) + } + .store(in: &cancellables) } @available(*, unavailable) @@ -20,18 +40,103 @@ class NewStatusViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + collectionView.dataSource = dataSource + view.backgroundColor = .systemBackground - navigationItem.leftBarButtonItem = .init( - systemItem: .close, - primaryAction: UIAction { [weak self] _ in self?.extensionContext?.completeRequest(returningItems: nil) }) + setupBarButtonItems(identification: viewModel.identification) + + viewModel.$compositionViewModels.sink { [weak self] in + self?.dataSource.apply([$0.map(\.composition.id)].snapshot()) { + DispatchQueue.main.async { + if let collectionView = self?.collectionView, + collectionView.indexPathsForSelectedItems?.isEmpty ?? false { + collectionView.selectItem( + at: collectionView.indexPathsForVisibleItems.first, + animated: false, + scrollPosition: .top) + } + } + } + } + .store(in: &cancellables) } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) - parent?.navigationItem.leftBarButtonItem = .init( + setupBarButtonItems(identification: viewModel.identification) + } + + override func collectionView(_ collectionView: UICollectionView, + willDisplay cell: UICollectionViewCell, + forItemAt indexPath: IndexPath) { + ((cell as? CompositionListCell)?.contentView as? CompositionView)?.textView.delegate = self + } + + func setupBarButtonItems(identification: Identification) { + let target = isShareExtension ? self : parent + let closeButton = UIBarButtonItem( systemItem: .close, - primaryAction: UIAction { [weak self] _ in self?.presentingViewController?.dismiss(animated: true) }) + primaryAction: UIAction { [weak self] _ in self?.dismiss() }) + + target?.navigationItem.leftBarButtonItem = closeButton + target?.navigationItem.titleView = viewModel.canChangeIdentity + ? changeIdentityButton(identification: identification) + : nil + } + + func dismiss() { + if isShareExtension { + extensionContext?.completeRequest(returningItems: nil) + } else { + presentingViewController?.dismiss(animated: true) + } + } +} + +extension NewStatusViewController: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + collectionView.collectionViewLayout.invalidateLayout() + } +} + +private extension NewStatusViewController { + func changeIdentityButton(identification: Identification) -> UIButton { + let changeIdentityButton = UIButton() + let downsampled = KingfisherOptionsInfo.downsampled( + dimension: .barButtonItemDimension, + scaleFactor: UIScreen.main.scale) + + let menuItems = viewModel.authenticatedIdentities + .filter { $0.id != identification.identity.id } + .map { identity in + UIDeferredMenuElement { completion in + let action = UIAction(title: identity.handle) { [weak self] _ in + self?.viewModel.setIdentity(identity) + } + + if let image = identity.image { + KingfisherManager.shared.retrieveImage(with: image, options: downsampled) { + if case let .success(value) = $0 { + action.image = value.image + } + + completion([action]) + } + } else { + completion([action]) + } + } + } + + changeIdentityButton.kf.setImage( + with: identification.identity.image, + for: .normal, + options: downsampled) + changeIdentityButton.showsMenuAsPrimaryAction = true + changeIdentityButton.menu = UIMenu(children: menuItems) + + return changeIdentityButton } } diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index 14d3fae..3166815 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -91,11 +91,7 @@ public extension DomainBlocksViewModel { } public extension NewStatusViewModel { - static let preview = NewStatusViewModel( - service: .init( - id: identityId, - identityDatabase: db, - environment: environment)) + static let preview = RootViewModel.preview.newStatusViewModel(identification: .preview) } // swiftlint:enable force_try diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift new file mode 100644 index 0000000..320ab75 --- /dev/null +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class CompositionViewModel: ObservableObject { + public let composition: Composition + @Published public private(set) var identification: Identification + + init(composition: Composition, + identification: Identification, + identificationPublisher: AnyPublisher) { + self.composition = composition + self.identification = identification + identificationPublisher.assign(to: &$identification) + } +} diff --git a/ViewModels/Sources/ViewModels/Entities/Composition.swift b/ViewModels/Sources/ViewModels/Entities/Composition.swift new file mode 100644 index 0000000..092e4d4 --- /dev/null +++ b/ViewModels/Sources/ViewModels/Entities/Composition.swift @@ -0,0 +1,5 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import ServiceLayer + +public typealias Composition = ServiceLayer.Composition diff --git a/ViewModels/Sources/ViewModels/NavigationViewModel.swift b/ViewModels/Sources/ViewModels/NavigationViewModel.swift index 923d042..d1625b7 100644 --- a/ViewModels/Sources/ViewModels/NavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/NavigationViewModel.swift @@ -153,10 +153,6 @@ public extension NavigationViewModel { collectionService: identification.service.service(timeline: .bookmarks), identification: identification) } - - func newStatusViewModel() -> NewStatusViewModel { - NewStatusViewModel(service: identification.service.newStatusService()) - } } extension NavigationViewModel.Tab: Identifiable { diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index bf496c6..9cb9ff7 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -6,9 +6,53 @@ import Mastodon import ServiceLayer public final class NewStatusViewModel: ObservableObject { - private let service: NewStatusService + @Published public private(set) var compositionViewModels = [CompositionViewModel]() + @Published public private(set) var identification: Identification + @Published public private(set) var authenticatedIdentities = [Identity]() + @Published public var canChangeIdentity = true + @Published public var alertItem: AlertItem? - public init(service: NewStatusService) { - self.service = service + private let allIdentitiesService: AllIdentitiesService + private let environment: AppEnvironment + private var cancellables = Set() + + public init(allIdentitiesService: AllIdentitiesService, + identification: Identification, + environment: AppEnvironment) { + self.allIdentitiesService = allIdentitiesService + self.identification = identification + self.environment = environment + compositionViewModels = [CompositionViewModel( + composition: .init(id: environment.uuid(), text: ""), + identification: identification, + identificationPublisher: $identification.eraseToAnyPublisher())] + allIdentitiesService.authenticatedIdentitiesPublisher() + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$authenticatedIdentities) + } +} + +public extension NewStatusViewModel { + func viewModel(indexPath: IndexPath) -> CompositionViewModel { + compositionViewModels[indexPath.row] + } + + func setIdentity(_ identity: Identity) { + let identityService: IdentityService + + do { + identityService = try allIdentitiesService.identityService(id: identity.id) + } catch { + alertItem = AlertItem(error: error) + + return + } + + identification = Identification( + identity: identity, + publisher: identityService.identityPublisher(immediate: false) + .assignErrorsToAlertItem(to: \.alertItem, on: self), + service: identityService, + environment: environment) } } diff --git a/ViewModels/Sources/ViewModels/RootViewModel.swift b/ViewModels/Sources/ViewModels/RootViewModel.swift index 6c2a626..6806a9d 100644 --- a/ViewModels/Sources/ViewModels/RootViewModel.swift +++ b/ViewModels/Sources/ViewModels/RootViewModel.swift @@ -57,6 +57,13 @@ public extension RootViewModel { allIdentitiesService: allIdentitiesService, instanceURLService: InstanceURLService(environment: environment)) } + + func newStatusViewModel(identification: Identification) -> NewStatusViewModel { + NewStatusViewModel( + allIdentitiesService: allIdentitiesService, + identification: identification, + environment: environment) + } } private extension RootViewModel { diff --git a/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift b/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift new file mode 100644 index 0000000..1f05754 --- /dev/null +++ b/ViewModels/Sources/ViewModels/ShareExtensionNavigationViewModel.swift @@ -0,0 +1,41 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import ServiceLayer + +public enum ShareExtensionError: Error { + case noAccountFound +} + +public final class ShareExtensionNavigationViewModel: ObservableObject { + @Published public var alertItem: AlertItem? + + private let environment: AppEnvironment + + public init(environment: AppEnvironment) { + self.environment = environment + } +} + +public extension ShareExtensionNavigationViewModel { + func newStatusViewModel() throws -> NewStatusViewModel { + let allIdentitiesService = try AllIdentitiesService(environment: environment) + + guard let identity = try allIdentitiesService.mostRecentAuthenticatedIdentity() + else { throw ShareExtensionError.noAccountFound } + + let identityService = try allIdentitiesService.identityService(id: identity.id) + let identification = Identification( + identity: identity, + publisher: identityService.identityPublisher(immediate: false) + .assignErrorsToAlertItem(to: \.alertItem, on: self), + service: identityService, + environment: environment) + + return NewStatusViewModel( + allIdentitiesService: allIdentitiesService, + identification: identification, + environment: environment) + } +} diff --git a/Views/CompositionContentConfiguration.swift b/Views/CompositionContentConfiguration.swift new file mode 100644 index 0000000..41ca270 --- /dev/null +++ b/Views/CompositionContentConfiguration.swift @@ -0,0 +1,18 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +struct CompositionContentConfiguration { + let viewModel: CompositionViewModel +} + +extension CompositionContentConfiguration: UIContentConfiguration { + func makeContentView() -> UIView & UIContentView { + CompositionView(configuration: self) + } + + func updated(for state: UIConfigurationState) -> CompositionContentConfiguration { + self + } +} diff --git a/Views/CompositionListCell.swift b/Views/CompositionListCell.swift new file mode 100644 index 0000000..4d8dd83 --- /dev/null +++ b/Views/CompositionListCell.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +class CompositionListCell: UICollectionViewListCell { + var viewModel: CompositionViewModel? + + override func updateConfiguration(using state: UICellConfigurationState) { + guard let viewModel = viewModel else { return } + + contentConfiguration = CompositionContentConfiguration(viewModel: viewModel).updated(for: state) + backgroundConfiguration = UIBackgroundConfiguration.clear().updated(for: state) + } + + override var isSelected: Bool { + didSet { + if isSelected { + (contentView as? CompositionView)?.textView.becomeFirstResponder() + } + } + } +} diff --git a/Views/CompositionView.swift b/Views/CompositionView.swift new file mode 100644 index 0000000..b69ab55 --- /dev/null +++ b/Views/CompositionView.swift @@ -0,0 +1,81 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Kingfisher +import UIKit + +class CompositionView: UIView { + let avatarImageView = UIImageView() + let textView = UITextView() + + private var compositionConfiguration: CompositionContentConfiguration + private var cancellables = Set() + + init(configuration: CompositionContentConfiguration) { + self.compositionConfiguration = configuration + + super.init(frame: .zero) + + initialSetup() + applyCompositionConfiguration() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension CompositionView: UIContentView { + var configuration: UIContentConfiguration { + get { compositionConfiguration } + set { + guard let compositionConfiguration = newValue as? CompositionContentConfiguration else { return } + + self.compositionConfiguration = compositionConfiguration + + applyCompositionConfiguration() + } + } +} + +private extension CompositionView { + func initialSetup() { + addSubview(avatarImageView) + avatarImageView.translatesAutoresizingMaskIntoConstraints = false + avatarImageView.layer.cornerRadius = .avatarDimension / 2 + avatarImageView.clipsToBounds = true + + let stackView = UIStackView() + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + + stackView.addArrangedSubview(textView) + textView.isScrollEnabled = false + textView.adjustsFontForContentSizeCategory = true + textView.font = .preferredFont(forTextStyle: .body) + textView.textContainer.lineFragmentPadding = 0 + + NSLayoutConstraint.activate([ + avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension), + avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension), + avatarImageView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + avatarImageView.leadingAnchor.constraint(equalTo: readableContentGuide.leadingAnchor), + avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: readableContentGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing), + stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), + stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor) + ]) + + compositionConfiguration.viewModel.$identification.map(\.identity.image) + .sink { [weak self] in self?.avatarImageView.kf.setImage(with: $0) } + .store(in: &cancellables) + } + + func applyCompositionConfiguration() { + + } +} diff --git a/Views/NewStatusView.swift b/Views/NewStatusView.swift index 569016f..1a09cc0 100644 --- a/Views/NewStatusView.swift +++ b/Views/NewStatusView.swift @@ -7,7 +7,7 @@ struct NewStatusView: UIViewControllerRepresentable { let viewModelClosure: () -> NewStatusViewModel func makeUIViewController(context: Context) -> NewStatusViewController { - NewStatusViewController(viewModel: viewModelClosure()) + NewStatusViewController(viewModel: viewModelClosure(), isShareExtension: false) } func updateUIViewController(_ uiViewController: NewStatusViewController, context: Context) { diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index e4b2bb5..07660da 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -41,9 +41,11 @@ struct TabNavigationView: View { EmptyView() .fullScreenCover(isPresented: $viewModel.presentingNewStatus) { NavigationView { - NewStatusView(viewModelClosure: viewModel.newStatusViewModel) - .edgesIgnoringSafeArea(.all) - .navigationBarTitleDisplayMode(.inline) + NewStatusView { + rootViewModel.newStatusViewModel(identification: viewModel.identification) + } + .edgesIgnoringSafeArea(.all) + .navigationBarTitleDisplayMode(.inline) } .navigationViewStyle(StackNavigationViewStyle()) .environmentObject(viewModel) @@ -137,7 +139,9 @@ private extension TabNavigationView { viewModel.presentingSecondaryNavigation.toggle() } label: { KFImage(viewModel.identification.identity.image, - options: .downsampled(dimension: 28, scaleFactor: displayScale)) + options: .downsampled( + dimension: .barButtonItemDimension, + scaleFactor: displayScale)) .placeholder { Image(systemName: "gear") } .renderingMode(.original) .contextMenu(ContextMenu { @@ -149,7 +153,9 @@ private extension TabNavigationView { title: { Text(recentIdentity.handle) }, icon: { KFImage(recentIdentity.image, - options: .downsampled(dimension: 28, scaleFactor: displayScale)) + options: .downsampled( + dimension: .barButtonItemDimension, + scaleFactor: displayScale)) .renderingMode(.original) }) } diff --git a/Views/ViewConstants.swift b/Views/ViewConstants.swift index 6cd64a5..ef0a18f 100644 --- a/Views/ViewConstants.swift +++ b/Views/ViewConstants.swift @@ -10,6 +10,7 @@ extension CGFloat { static let avatarDimension: Self = 50 static let hairline = 1 / UIScreen.main.scale static let minimumButtonDimension: Self = 44 + static let barButtonItemDimension: Self = 28 } extension TimeInterval {