diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index 7199b1e..66d2813 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -118,12 +118,16 @@ extension AppEnvironment { webAuthSessionType: SuccessfulStubbingWebAuthSession.self) } +extension IdentifiedEnvironment { + static let development = try! IdentifiedEnvironment(identityID: devIdentityID, appEnvironment: .development) +} + extension RootViewModel { static let development = RootViewModel(environment: .development) } extension MainNavigationViewModel { - static let development = try! MainNavigationViewModel(identityID: devIdentityID, environment: .development) + static let development = MainNavigationViewModel(environment: .development) } extension SettingsViewModel { diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index d12c4ed..2a1f07b 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + D0091B6824DC10B30040E8D2 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0091B6724DC10B30040E8D2 /* PreferencesView.swift */; }; + D0091B6924DC10B30040E8D2 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0091B6724DC10B30040E8D2 /* PreferencesView.swift */; }; + D0091B6B24DC10CE0040E8D2 /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0091B6A24DC10CE0040E8D2 /* PreferencesViewModel.swift */; }; + D0091B6C24DC10CE0040E8D2 /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0091B6A24DC10CE0040E8D2 /* PreferencesViewModel.swift */; }; D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D047FA8524C3E21000AF17C5 /* MetatextApp.swift */; }; D047FAB224C3E21200AF17C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D047FA8724C3E21200AF17C5 /* Assets.xcassets */; }; @@ -91,6 +95,14 @@ D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FA24CC359D003BD330 /* AlertItem.swift */; }; D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; }; D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */; }; + D0CD847324DBDEC700CF380C /* MastodonPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */; }; + D0CD847424DBDEC700CF380C /* MastodonPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */; }; + D0CD847624DBDF3C00CF380C /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847524DBDF3C00CF380C /* Status.swift */; }; + D0CD847724DBDF3C00CF380C /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847524DBDF3C00CF380C /* Status.swift */; }; + D0CD847C24DBEA9F00CF380C /* Unknowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847B24DBEA9F00CF380C /* Unknowable.swift */; }; + D0CD847D24DBEA9F00CF380C /* Unknowable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847B24DBEA9F00CF380C /* Unknowable.swift */; }; + D0CD847F24DBF1BB00CF380C /* PreferencesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847E24DBF1BB00CF380C /* PreferencesEndpoint.swift */; }; + D0CD848024DBF1BB00CF380C /* PreferencesEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CD847E24DBF1BB00CF380C /* PreferencesEndpoint.swift */; }; D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */; }; D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */; }; D0DB6F0924C65AC000D965FE /* AddIdentityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */; }; @@ -151,6 +163,8 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + D0091B6724DC10B30040E8D2 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + D0091B6A24DC10CE0040E8D2 /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = ""; }; D047FA8524C3E21000AF17C5 /* MetatextApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetatextApp.swift; sourceTree = ""; }; D047FA8724C3E21200AF17C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -199,6 +213,10 @@ D0BEC95024CA2B7E00E864C4 /* TabNavigation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TabNavigation.swift; sourceTree = ""; }; D0C963FA24CC359D003BD330 /* AlertItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertItem.swift; sourceTree = ""; }; D0C963FD24CC3812003BD330 /* Publisher+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Publisher+Extensions.swift"; sourceTree = ""; }; + D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPreferences.swift; sourceTree = ""; }; + D0CD847524DBDF3C00CF380C /* Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Status.swift; sourceTree = ""; }; + D0CD847B24DBEA9F00CF380C /* Unknowable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Unknowable.swift; sourceTree = ""; }; + D0CD847E24DBF1BB00CF380C /* PreferencesEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesEndpoint.swift; sourceTree = ""; }; D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityView.swift; sourceTree = ""; }; D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModel.swift; sourceTree = ""; }; D0DC174524CFEC2000A75C65 /* StubbingURLProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StubbingURLProtocol.swift; sourceTree = ""; }; @@ -332,14 +350,17 @@ D052BBCC24D750A100A80A7A /* AppEnvironment.swift */, D0ED1BD624CF94B200B4899C /* Application.swift */, D0666A4424C6BC0A00F3F04B /* DatabaseError.swift */, + D052BBCE24D750C000A80A7A /* Defaults.swift */, D0666A5324C6C3E500F3F04B /* Emoji.swift */, D0666A4A24C6C37700F3F04B /* Identity.swift */, D0666A4124C6BB7B00F3F04B /* IdentityDatabase.swift */, D0666A4D24C6C39600F3F04B /* Instance.swift */, D0DC177324D0B58800A75C65 /* Keychain.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */, - D052BBCE24D750C000A80A7A /* Defaults.swift */, + D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, D0666A7124C6E0D300F3F04B /* Secrets.swift */, + D0CD847524DBDF3C00CF380C /* Status.swift */, + D0CD847B24DBEA9F00CF380C /* Unknowable.swift */, ); path = Model; sourceTree = ""; @@ -364,6 +385,7 @@ children = ( D0DB6EF324C5228A00D965FE /* AddIdentityView.swift */, D06BAB5024D942CF0081B8FD /* IdentitiesView.swift */, + D0091B6724DC10B30040E8D2 /* PreferencesView.swift */, D0BEC93A24C96FD500E864C4 /* RootView.swift */, D04FD73224D48F37007D572D /* SettingsView.swift */, D0BEC94924CA231200E864C4 /* TimelineView.swift */, @@ -390,6 +412,7 @@ D0DB6F0824C65AC000D965FE /* AddIdentityViewModel.swift */, D06BAB4D24D942BC0081B8FD /* IdentitiesViewModel.swift */, D052BBDF24D805E300A80A7A /* MainNavigationViewModel.swift */, + D0091B6A24DC10CE0040E8D2 /* PreferencesViewModel.swift */, D0BEC93724C9632800E864C4 /* RootViewModel.swift */, D04FD73524D49506007D572D /* SettingsViewModel.swift */, D0BEC94624CA22C400E864C4 /* TimelineViewModel.swift */, @@ -457,6 +480,7 @@ D0ED1BCA24CF744200B4899C /* MastodonClient.swift */, D0ED1BCD24CF768200B4899C /* MastodonEndpoint.swift */, D0ED1BD024CF779B00B4899C /* MastodonTarget.swift */, + D0CD847E24DBF1BB00CF380C /* PreferencesEndpoint.swift */, ); path = "Mastodon API"; sourceTree = ""; @@ -652,6 +676,7 @@ files = ( D04FD73924D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */, D0DB6F0924C65AC000D965FE /* AddIdentityViewModel.swift in Sources */, + D0CD847324DBDEC700CF380C /* MastodonPreferences.swift in Sources */, D0ED1BD724CF94B200B4899C /* Application.swift in Sources */, D047FAAE24C3E21200AF17C5 /* MetatextApp.swift in Sources */, D0BEC94724CA22C400E864C4 /* TimelineViewModel.swift in Sources */, @@ -672,6 +697,7 @@ D0ED1BC124CED48800B4899C /* HTTPClient.swift in Sources */, D0666A4524C6BC0A00F3F04B /* DatabaseError.swift in Sources */, D0ED1BDD24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */, + D0CD847F24DBF1BB00CF380C /* PreferencesEndpoint.swift in Sources */, D0666A4B24C6C37700F3F04B /* Identity.swift in Sources */, D0666A5424C6C3E500F3F04B /* Emoji.swift in Sources */, D0DC175524D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, @@ -689,6 +715,7 @@ D0C963FB24CC359D003BD330 /* AlertItem.swift in Sources */, D0DC174624CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D0DC174D24CFF1F100A75C65 /* Stubbing.swift in Sources */, + D0091B6B24DC10CE0040E8D2 /* PreferencesViewModel.swift in Sources */, D0666A5724C6C63400F3F04B /* MastodonDecoder.swift in Sources */, D0DB6EF424C5228A00D965FE /* AddIdentityView.swift in Sources */, D0DC177424D0B58800A75C65 /* Keychain.swift in Sources */, @@ -706,8 +733,11 @@ D0DC175B24D0154F00A75C65 /* MastodonAPI.swift in Sources */, D0ED1BD124CF779B00B4899C /* MastodonTarget.swift in Sources */, D065F53E24D3D20300741304 /* InstanceEndpoint.swift in Sources */, + D0CD847C24DBEA9F00CF380C /* Unknowable.swift in Sources */, D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */, D0ED1BCB24CF744200B4899C /* MastodonClient.swift in Sources */, + D0091B6824DC10B30040E8D2 /* PreferencesView.swift in Sources */, + D0CD847624DBDF3C00CF380C /* Status.swift in Sources */, D052BBE024D805E300A80A7A /* MainNavigationViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -718,6 +748,7 @@ files = ( D04FD73A24D4A7B4007D572D /* AccountEndpoint+Stubbing.swift in Sources */, D0DB6F0A24C65AC000D965FE /* AddIdentityViewModel.swift in Sources */, + D0CD847424DBDEC700CF380C /* MastodonPreferences.swift in Sources */, D0ED1BD824CF94B200B4899C /* Application.swift in Sources */, D047FAAF24C3E21200AF17C5 /* MetatextApp.swift in Sources */, D0BEC94824CA22C400E864C4 /* TimelineViewModel.swift in Sources */, @@ -738,6 +769,7 @@ D0ED1BC224CED48800B4899C /* HTTPClient.swift in Sources */, D0666A4624C6BC0A00F3F04B /* DatabaseError.swift in Sources */, D0ED1BDE24CF982600B4899C /* AccessTokenEndpoint.swift in Sources */, + D0CD848024DBF1BB00CF380C /* PreferencesEndpoint.swift in Sources */, D0666A4C24C6C37700F3F04B /* Identity.swift in Sources */, D0666A5524C6C3E500F3F04B /* Emoji.swift in Sources */, D0DC175624D00F0A00A75C65 /* AccessTokenEndpoint+Stubbing.swift in Sources */, @@ -755,6 +787,7 @@ D0C963FC24CC359D003BD330 /* AlertItem.swift in Sources */, D0DC174724CFEC2000A75C65 /* StubbingURLProtocol.swift in Sources */, D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */, + D0091B6C24DC10CE0040E8D2 /* PreferencesViewModel.swift in Sources */, D0666A5824C6C63400F3F04B /* MastodonDecoder.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D0DC177524D0B58800A75C65 /* Keychain.swift in Sources */, @@ -772,8 +805,11 @@ D0DC175C24D0154F00A75C65 /* MastodonAPI.swift in Sources */, D0ED1BD224CF779B00B4899C /* MastodonTarget.swift in Sources */, D065F53F24D3D20300741304 /* InstanceEndpoint.swift in Sources */, + D0CD847D24DBEA9F00CF380C /* Unknowable.swift in Sources */, D0666A7024C6DFB300F3F04B /* AccessToken.swift in Sources */, D0ED1BCC24CF744200B4899C /* MastodonClient.swift in Sources */, + D0091B6924DC10B30040E8D2 /* PreferencesView.swift in Sources */, + D0CD847724DBDF3C00CF380C /* Status.swift in Sources */, D052BBE124D805E300A80A7A /* MainNavigationViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Shared/Extensions/Publisher+Extensions.swift b/Shared/Extensions/Publisher+Extensions.swift index 35a3e30..7a35c60 100644 --- a/Shared/Extensions/Publisher+Extensions.swift +++ b/Shared/Extensions/Publisher+Extensions.swift @@ -7,12 +7,12 @@ extension Publisher { func assignErrorsToAlertItem( to keyPath: ReferenceWritableKeyPath, on object: Root) -> AnyPublisher { - self.catch { [weak object] error -> AnyPublisher in + self.catch { [weak object] error -> Empty in DispatchQueue.main.async { object?[keyPath: keyPath] = AlertItem(error: error) } - return Empty().eraseToAnyPublisher() + return Empty() } .eraseToAnyPublisher() } diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift index ed5cba0..d47f776 100644 --- a/Shared/Model/AppEnvironment.swift +++ b/Shared/Model/AppEnvironment.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import Foundation +import Combine struct AppEnvironment { let URLSessionConfiguration: URLSessionConfiguration @@ -9,3 +10,41 @@ struct AppEnvironment { let secrets: Secrets let webAuthSessionType: WebAuthSession.Type } + +class IdentifiedEnvironment { + @Published var identity: Identity + let observationErrors: AnyPublisher + let networkClient: MastodonClient + let appEnvironment: AppEnvironment + + private var cancellables = Set() + private let observationErrorsInput = PassthroughSubject() + + init(identityID: String, appEnvironment: AppEnvironment) throws { + self.appEnvironment = appEnvironment + observationErrors = observationErrorsInput.eraseToAnyPublisher() + networkClient = MastodonClient(configuration: appEnvironment.URLSessionConfiguration) + networkClient.accessToken = try appEnvironment.secrets.item(.accessToken, forIdentityID: identityID) + + let observation = appEnvironment.identityDatabase.identityObservation(id: identityID).share() + + var initialIdentity: Identity? + + observation.first().sink( + receiveCompletion: { _ in }, + receiveValue: { initialIdentity = $0 }) + .store(in: &cancellables) + + guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } + + self.identity = identity + networkClient.instanceURL = identity.url + + observation.catch { [weak self] error -> Empty in + self?.observationErrorsInput.send(error) + + return Empty() + } + .assign(to: &$identity) + } +} diff --git a/Shared/Model/Identity.swift b/Shared/Model/Identity.swift index fe03393..e31175f 100644 --- a/Shared/Model/Identity.swift +++ b/Shared/Model/Identity.swift @@ -6,6 +6,7 @@ struct Identity: Codable, Hashable, Identifiable { let id: String let url: URL let lastUsedAt: Date + let preferences: Identity.Preferences let instance: Identity.Instance? let account: Identity.Account? } @@ -28,6 +29,16 @@ extension Identity { let header: URL let headerStatic: URL } + + struct Preferences: Codable, Hashable { + var useServerPostingPreferences = true + var postingDefaultVisibility = Status.Visibility.public + var postingDefaultSensitive = false + var postingDefaultLanguage: String? + var useServerReadingPreferences = true + var readingExpandMedia = MastodonPreferences.ExpandMedia.default + var readingExpandSpoilers = false + } } extension Identity { diff --git a/Shared/Model/IdentityDatabase.swift b/Shared/Model/IdentityDatabase.swift index d792501..e845aae 100644 --- a/Shared/Model/IdentityDatabase.swift +++ b/Shared/Model/IdentityDatabase.swift @@ -36,6 +36,7 @@ extension IdentityDatabase { id: id, url: url, lastUsedAt: Date(), + preferences: Identity.Preferences(), instanceURI: nil).save) .eraseToAnyPublisher() } @@ -150,6 +151,7 @@ private extension IdentityDatabase { t.column("instanceURI", .text) .indexed() .references("instance", column: "uri") + t.column("preferences", .blob).notNull() } try db.create(table: "account", ifNotExists: true) { t in @@ -175,6 +177,7 @@ private struct StoredIdentity: Codable, Hashable, TableRecord, FetchableRecord, let id: String let url: URL let lastUsedAt: Date + let preferences: Identity.Preferences let instanceURI: String? } @@ -203,6 +206,7 @@ private extension Identity { id: result.identity.id, url: result.identity.url, lastUsedAt: result.identity.lastUsedAt, + preferences: result.identity.preferences, instance: result.instance, account: result.account) } diff --git a/Shared/Model/MastodonPreferences.swift b/Shared/Model/MastodonPreferences.swift new file mode 100644 index 0000000..85957c4 --- /dev/null +++ b/Shared/Model/MastodonPreferences.swift @@ -0,0 +1,28 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct MastodonPreferences: Codable { + enum CodingKeys: String, CodingKey { + case postingDefaultVisibility = "posting:default:visibility" + case postingDefaultSensitive = "posting:default:sensitive" + case postingDefaultLanguage = "posting:default:language" + case readingExpandMedia = "reading:expand:media" + case readingExpandSpoilers = "reading:expand:spoilers" + } + + let postingDefaultVisibility: Status.Visibility + let postingDefaultSensitive: Bool + let postingDefaultLanguage: String? + let readingExpandMedia: ExpandMedia + let readingExpandSpoilers: Bool +} + +extension MastodonPreferences { + enum ExpandMedia: String, Codable, Unknowable { + case `default` + case showAll + case hideAll + case unknown + } +} diff --git a/Shared/Model/Status.swift b/Shared/Model/Status.swift new file mode 100644 index 0000000..fb92eb7 --- /dev/null +++ b/Shared/Model/Status.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +struct Status { + enum Visibility: String, Codable, Unknowable { + case `public` + case unlisted + case `private` + case direct + case unknown + } +} diff --git a/Shared/Model/Unknowable.swift b/Shared/Model/Unknowable.swift new file mode 100644 index 0000000..37c4a2f --- /dev/null +++ b/Shared/Model/Unknowable.swift @@ -0,0 +1,17 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +protocol Unknowable: RawRepresentable, CaseIterable where RawValue: Equatable { + static var unknown: RawValue { get } +} + +extension Unknowable { + init(rawValue: RawValue) { + self = Self.allCases.first { $0.rawValue == rawValue } ?? Self(rawValue: Self.unknown) + } +} + +extension Unknowable where RawValue == String { + static var unknown: String { "unknown" } +} diff --git a/Shared/Networking/Mastodon API/PreferencesEndpoint.swift b/Shared/Networking/Mastodon API/PreferencesEndpoint.swift new file mode 100644 index 0000000..c903da3 --- /dev/null +++ b/Shared/Networking/Mastodon API/PreferencesEndpoint.swift @@ -0,0 +1,23 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +enum PreferencesEndpoint { + case preferences +} + +extension PreferencesEndpoint: MastodonEndpoint { + typealias ResultType = MastodonPreferences + + var pathComponentsInContext: [String] { + switch self { + case .preferences: return ["instance"] + } + } + + var method: HTTPMethod { + switch self { + case .preferences: return .get + } + } +} diff --git a/Shared/View Models/IdentitiesViewModel.swift b/Shared/View Models/IdentitiesViewModel.swift index 507375a..f441bed 100644 --- a/Shared/View Models/IdentitiesViewModel.swift +++ b/Shared/View Models/IdentitiesViewModel.swift @@ -8,24 +8,15 @@ class IdentitiesViewModel: ObservableObject { @Published var identities = [Identity]() @Published var alertItem: AlertItem? - private let environment: AppEnvironment + private let environment: IdentifiedEnvironment private var cancellables = Set() - init(identity: Published, environment: AppEnvironment) { - _identity = identity + init(environment: IdentifiedEnvironment) { self.environment = environment + identity = environment.identity - environment.identityDatabase.identitiesObservation() + environment.appEnvironment.identityDatabase.identitiesObservation() .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$identities) } } - -extension IdentitiesViewModel { - func identitySelected(id: String) { - environment.identityDatabase.updateLastUsedAt(identityID: id) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink(receiveValue: {}) - .store(in: &cancellables) - } -} diff --git a/Shared/View Models/MainNavigationViewModel.swift b/Shared/View Models/MainNavigationViewModel.swift index 70b7ef9..5b7be67 100644 --- a/Shared/View Models/MainNavigationViewModel.swift +++ b/Shared/View Models/MainNavigationViewModel.swift @@ -10,39 +10,21 @@ class MainNavigationViewModel: ObservableObject { @Published var alertItem: AlertItem? var selectedTab: Tab? = .timelines - private let environment: AppEnvironment - private let networkClient: MastodonClient + private let environment: IdentifiedEnvironment private var cancellables = Set() - init(identityID: String, environment: AppEnvironment) throws { + init(environment: IdentifiedEnvironment) { self.environment = environment - networkClient = MastodonClient(configuration: environment.URLSessionConfiguration) + identity = environment.identity + environment.$identity.dropFirst().assign(to: &$identity) - let observation = environment.identityDatabase.identityObservation(id: identityID).share() - var initialIdentity: Identity? - - observation.first().sink( - receiveCompletion: { _ in }, - receiveValue: { initialIdentity = $0 }) - .store(in: &cancellables) - - guard let identity = initialIdentity else { throw IdentityDatabaseError.identityNotFound } - - self.identity = identity - networkClient.instanceURL = identity.url - - do { - networkClient.accessToken = try environment.secrets.item(.accessToken, forIdentityID: identity.id) - } catch { - alertItem = AlertItem(error: error) - } - - observation.assignErrorsToAlertItem(to: \.alertItem, on: self).assign(to: &$identity) - environment.identityDatabase.recentIdentitiesObservation(excluding: identityID) + environment.appEnvironment.identityDatabase + .recentIdentitiesObservation(excluding: environment.identity.id) .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$recentIdentities) - environment.identityDatabase.updateLastUsedAt(identityID: identityID) + environment.appEnvironment.identityDatabase + .updateLastUsedAt(identityID: environment.identity.id) .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink(receiveValue: {}) .store(in: &cancellables) @@ -53,25 +35,25 @@ extension MainNavigationViewModel { func refreshIdentity() { let id = identity.id - if networkClient.accessToken != nil { - networkClient.request(AccountEndpoint.verifyCredentials) + if environment.networkClient.accessToken != nil { + environment.networkClient.request(AccountEndpoint.verifyCredentials) .map { ($0, id) } - .flatMap(environment.identityDatabase.updateAccount) + .flatMap(environment.appEnvironment.identityDatabase.updateAccount) .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink(receiveValue: {}) .store(in: &cancellables) } - networkClient.request(InstanceEndpoint.instance) + environment.networkClient.request(InstanceEndpoint.instance) .map { ($0, id) } - .flatMap(environment.identityDatabase.updateInstance) + .flatMap(environment.appEnvironment.identityDatabase.updateInstance) .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink(receiveValue: {}) .store(in: &cancellables) } func settingsViewModel() -> SettingsViewModel { - SettingsViewModel(identity: _identity, environment: environment) + SettingsViewModel(environment: environment) } } diff --git a/Shared/View Models/PreferencesViewModel.swift b/Shared/View Models/PreferencesViewModel.swift new file mode 100644 index 0000000..742dd52 --- /dev/null +++ b/Shared/View Models/PreferencesViewModel.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +class PreferencesViewModel: ObservableObject { + @Published var preferences: Identity.Preferences + + private let environment: IdentifiedEnvironment + + init(environment: IdentifiedEnvironment) { + self.environment = environment + preferences = environment.identity.preferences + environment.$identity.map(\.preferences).assign(to: &$preferences) + } +} diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index 036eff2..232493a 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -4,24 +4,13 @@ import Foundation import Combine class RootViewModel: ObservableObject { - @Published private(set) var mainNavigationViewModel: MainNavigationViewModel? - - @Published private var identityID: String? + @Published private(set) var identityID: String? private let environment: AppEnvironment private var cancellables = Set() init(environment: AppEnvironment) { self.environment = environment identityID = environment.identityDatabase.mostRecentlyUsedIdentityID - - $identityID - .tryMap { - guard let id = $0 else { return nil } - - return try MainNavigationViewModel(identityID: id, environment: environment) - } - .replaceError(with: nil) - .assign(to: &$mainNavigationViewModel) } } @@ -31,12 +20,23 @@ extension RootViewModel { } func addIdentityViewModel() -> AddIdentityViewModel { - let addAccountViewModel = AddIdentityViewModel(environment: environment) + AddIdentityViewModel(environment: environment) + } - addAccountViewModel.addedIdentityID - .sink(receiveValue: newIdentitySelected(id:)) - .store(in: &cancellables) + func mainNavigationViewModel(identityID: String) -> MainNavigationViewModel? { + let identifiedEnvironment: IdentifiedEnvironment - return addAccountViewModel + do { + identifiedEnvironment = try IdentifiedEnvironment(identityID: identityID, appEnvironment: environment) + } catch { + return nil + } + + identifiedEnvironment.observationErrors + .receive(on: RunLoop.main) + .map { [weak self] _ in self?.environment.identityDatabase.mostRecentlyUsedIdentityID } + .assign(to: &$identityID) + + return MainNavigationViewModel(environment: identifiedEnvironment) } } diff --git a/Shared/View Models/SettingsViewModel.swift b/Shared/View Models/SettingsViewModel.swift index 7d28ea7..3905037 100644 --- a/Shared/View Models/SettingsViewModel.swift +++ b/Shared/View Models/SettingsViewModel.swift @@ -4,16 +4,17 @@ import Foundation class SettingsViewModel: ObservableObject { @Published private(set) var identity: Identity - private let environment: AppEnvironment + private let environment: IdentifiedEnvironment - init(identity: Published, environment: AppEnvironment) { - _identity = identity + init(environment: IdentifiedEnvironment) { self.environment = environment + identity = environment.identity + environment.$identity.dropFirst().assign(to: &$identity) } } extension SettingsViewModel { func identitiesViewModel() -> IdentitiesViewModel { - IdentitiesViewModel(identity: _identity, environment: environment) + IdentitiesViewModel(environment: environment) } } diff --git a/Shared/Views/AddIdentityView.swift b/Shared/Views/AddIdentityView.swift index e128e62..80f9a63 100644 --- a/Shared/Views/AddIdentityView.swift +++ b/Shared/Views/AddIdentityView.swift @@ -4,6 +4,7 @@ import SwiftUI struct AddIdentityView: View { @StateObject var viewModel: AddIdentityViewModel + @EnvironmentObject var rootViewModel: RootViewModel var body: some View { Form { @@ -32,6 +33,11 @@ struct AddIdentityView: View { } .paddingIfMac() .alertItem($viewModel.alertItem) + .onReceive(viewModel.addedIdentityID) { id in + withAnimation { + rootViewModel.newIdentitySelected(id: id) + } + } } } diff --git a/Shared/Views/IdentitiesView.swift b/Shared/Views/IdentitiesView.swift index 24771fd..a9f173e 100644 --- a/Shared/Views/IdentitiesView.swift +++ b/Shared/Views/IdentitiesView.swift @@ -18,7 +18,9 @@ struct IdentitiesView: View { Section { List(viewModel.identities) { identity in Button(identity.handle) { - rootViewModel.newIdentitySelected(id: identity.id) + withAnimation { + rootViewModel.newIdentitySelected(id: identity.id) + } } } } diff --git a/Shared/Views/PreferencesView.swift b/Shared/Views/PreferencesView.swift new file mode 100644 index 0000000..d84e6c9 --- /dev/null +++ b/Shared/Views/PreferencesView.swift @@ -0,0 +1,15 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import SwiftUI + +struct PreferencesView: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +struct PreferencesView_Previews: PreviewProvider { + static var previews: some View { + PreferencesView() + } +} diff --git a/Shared/Views/RootView.swift b/Shared/Views/RootView.swift index cdf01e2..112aee6 100644 --- a/Shared/Views/RootView.swift +++ b/Shared/Views/RootView.swift @@ -6,13 +6,16 @@ struct RootView: View { @StateObject var viewModel: RootViewModel var body: some View { - ZStack { - if let mainNavigationViewModel = viewModel.mainNavigationViewModel { - Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel) - .environmentObject(viewModel) - } else { - AddIdentityView(viewModel: viewModel.addIdentityViewModel()) - } + if let id = viewModel.identityID, + let mainNavigationViewModel = viewModel.mainNavigationViewModel(identityID: id) { + Self.mainNavigation(mainNavigationViewModel: mainNavigationViewModel) + .id(id) + .environmentObject(viewModel) + .transition(.opacity) + } else { + AddIdentityView(viewModel: viewModel.addIdentityViewModel()) + .environmentObject(viewModel) + .transition(.opacity) } } } diff --git a/Tests/View Models/RootViewModelTests.swift b/Tests/View Models/RootViewModelTests.swift index 03673fd..68458b1 100644 --- a/Tests/View Models/RootViewModelTests.swift +++ b/Tests/View Models/RootViewModelTests.swift @@ -6,19 +6,26 @@ import CombineExpectations @testable import Metatext class RootViewModelTests: XCTestCase { + var cancellables = Set() + func testAddIdentity() throws { let sut = RootViewModel(environment: .fresh()) - let recorder = sut.$mainNavigationViewModel.record() + let recorder = sut.$identityID.record() XCTAssertNil(try wait(for: recorder.next(), timeout: 1)) let addIdentityViewModel = sut.addIdentityViewModel() + addIdentityViewModel.addedIdentityID + .sink(receiveValue: sut.newIdentitySelected(id:)) + .store(in: &cancellables) + addIdentityViewModel.urlFieldText = "https://mastodon.social" addIdentityViewModel.goTapped() - let mainNavigationViewModel = try wait(for: recorder.next(), timeout: 1)! + let identityID = try wait(for: recorder.next(), timeout: 1)! - XCTAssertNotNil(mainNavigationViewModel) + XCTAssertNotNil(identityID) + XCTAssertNotNil(sut.mainNavigationViewModel(identityID: identityID)) } } diff --git a/iOS/TabNavigation.swift b/iOS/TabNavigation.swift index 8dedf70..eb36410 100644 --- a/iOS/TabNavigation.swift +++ b/iOS/TabNavigation.swift @@ -26,8 +26,8 @@ struct TabNavigation: View { SettingsView(viewModel: viewModel.settingsViewModel()) .environmentObject(rootViewModel) } - .onReceive(rootViewModel.$mainNavigationViewModel.map { _ in ()}, - perform: viewModel.refreshIdentity) + .alertItem($viewModel.alertItem) + .onAppear(perform: viewModel.refreshIdentity) .onReceive(NotificationCenter.default .publisher(for: UIScene.willEnterForegroundNotification) .map { _ in () },