diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index 81ad495..f8667de 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -87,7 +87,9 @@ public extension ContentDatabase { try Timeline.list(list).save($0) } - try Timeline.filter(!(Timeline.nonLists.map(\.id) + lists.map(\.id)).contains(Column("id"))).deleteAll($0) + try Timeline + .filter(!(Timeline.authenticatedDefaults.map(\.id) + lists.map(\.id)).contains(Column("id"))) + .deleteAll($0) } .ignoreOutput() .eraseToAnyPublisher() @@ -155,7 +157,7 @@ public extension ContentDatabase { } func listsObservation() -> AnyPublisher<[Timeline], Error> { - ValueObservation.tracking(Timeline.filter(!Timeline.nonLists.map(\.id).contains(Column("id"))) + ValueObservation.tracking(Timeline.filter(!Timeline.authenticatedDefaults.map(\.id).contains(Column("id"))) .order(Column("listTitle").collating(.localizedCaseInsensitiveCompare).asc) .fetchAll) .removeDuplicates() diff --git a/DB/Sources/DB/Entities/Identity.swift b/DB/Sources/DB/Entities/Identity.swift index 19e6083..11e0462 100644 --- a/DB/Sources/DB/Entities/Identity.swift +++ b/DB/Sources/DB/Entities/Identity.swift @@ -6,6 +6,7 @@ import Mastodon public struct Identity: Codable, Hashable, Identifiable { public let id: UUID public let url: URL + public let authenticated: Bool public let lastUsedAt: Date public let preferences: Identity.Preferences public let instance: Identity.Instance? diff --git a/DB/Sources/DB/Extensions/Identity+Internal.swift b/DB/Sources/DB/Extensions/Identity+Internal.swift index fe7b565..de8a61d 100644 --- a/DB/Sources/DB/Extensions/Identity+Internal.swift +++ b/DB/Sources/DB/Extensions/Identity+Internal.swift @@ -8,6 +8,7 @@ extension Identity { self.init( id: result.identity.id, url: result.identity.url, + authenticated: result.identity.authenticated, lastUsedAt: result.identity.lastUsedAt, preferences: result.identity.preferences, instance: result.instance, diff --git a/DB/Sources/DB/Identity/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift index 589af87..986cdae 100644 --- a/DB/Sources/DB/Identity/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -33,11 +33,12 @@ public struct IdentityDatabase { } public extension IdentityDatabase { - func createIdentity(id: UUID, url: URL) -> AnyPublisher { + func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher { databaseQueue.writePublisher( updates: IdentityRecord( id: id, url: url, + authenticated: authenticated, lastUsedAt: Date(), preferences: Identity.Preferences(), instanceURI: nil, @@ -161,7 +162,7 @@ public extension IdentityDatabase { func identitiesObservation() -> AnyPublisher<[Identity], Error> { ValueObservation.tracking(Self.identitiesRequest().fetchAll) .removeDuplicates() - .publisher(in: databaseQueue, scheduling: .immediate) + .publisher(in: databaseQueue) .map { $0.map(Identity.init(result:)) } .eraseToAnyPublisher() } @@ -173,7 +174,7 @@ public extension IdentityDatabase { .limit(9) .fetchAll) .removeDuplicates() - .publisher(in: databaseQueue, scheduling: .immediate) + .publisher(in: databaseQueue) .map { $0.map(Identity.init(result:)) } .eraseToAnyPublisher() } @@ -230,6 +231,7 @@ private extension IdentityDatabase { try db.create(table: "identityRecord", ifNotExists: true) { t in t.column("id", .text).notNull().primaryKey(onConflict: .replace) t.column("url", .text).notNull() + t.column("authenticated", .boolean).notNull() t.column("lastUsedAt", .datetime).notNull() t.column("instanceURI", .text) .indexed() diff --git a/DB/Sources/DB/Identity/IdentityRecord.swift b/DB/Sources/DB/Identity/IdentityRecord.swift index 0fe60a7..0181743 100644 --- a/DB/Sources/DB/Identity/IdentityRecord.swift +++ b/DB/Sources/DB/Identity/IdentityRecord.swift @@ -7,6 +7,7 @@ import Mastodon struct IdentityRecord: Codable, Hashable, FetchableRecord, PersistableRecord { let id: UUID let url: URL + let authenticated: Bool let lastUsedAt: Date let preferences: Identity.Preferences let instanceURI: String? diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index f85cffd..7774361 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -8,6 +8,8 @@ "secondary-navigation.manage-accounts" = "Manage Accounts"; "secondary-navigation.lists" = "Lists"; "secondary-navigation.preferences" = "Preferences"; +"identities.accounts" = "Accounts"; +"identities.browsing-anonymously" = "Browsing Anonymously"; "lists.new-list-title" = "New List Title"; "preferences" = "Preferences"; "preferences.posting-reading" = "Posting and Reading"; diff --git a/Mastodon/Sources/Mastodon/Entities/Timeline.swift b/Mastodon/Sources/Mastodon/Entities/Timeline.swift index 9b7f822..f045f90 100644 --- a/Mastodon/Sources/Mastodon/Entities/Timeline.swift +++ b/Mastodon/Sources/Mastodon/Entities/Timeline.swift @@ -11,7 +11,8 @@ public enum Timeline: Hashable { } public extension Timeline { - static let nonLists: [Timeline] = [.home, .local, .federated] + static let unauthenticatedDefaults: [Timeline] = [.local, .federated] + static let authenticatedDefaults: [Timeline] = [.home, .local, .federated] } extension Timeline: Identifiable { diff --git a/Secrets/Sources/Secrets/Secrets.swift b/Secrets/Sources/Secrets/Secrets.swift index 33a1a31..dae3ac0 100644 --- a/Secrets/Sources/Secrets/Secrets.swift +++ b/Secrets/Sources/Secrets/Secrets.swift @@ -35,7 +35,7 @@ public extension Secrets { } } -enum SecretsError: Error { +public enum SecretsError: Error { case itemAbsent } @@ -89,15 +89,19 @@ public extension Secrets { return "x'\(passphraseData.base16EncodedString(options: [.uppercase]))'" } - func deleteAllItems() throws { + func deleteAllItems() { for item in Secrets.Item.allCases { - switch item.kind { - case .genericPassword: - try keychain.deleteGenericPassword( - account: scopedKey(item: item), - service: Self.keychainServiceName) - case .key: - try keychain.deleteKey(applicationTag: scopedKey(item: item)) + do { + switch item.kind { + case .genericPassword: + try keychain.deleteGenericPassword( + account: scopedKey(item: item), + service: Self.keychainServiceName) + case .key: + try keychain.deleteKey(applicationTag: scopedKey(item: item)) + } + } catch { + // no-op } } } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift index 0dd8025..5548375 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/AllIdentitiesService.swift @@ -32,45 +32,61 @@ public extension AllIdentitiesService { try IdentityService(id: id, database: database, environment: environment) } - func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher { - database.createIdentity(id: id, url: instanceURL) - } + func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher { + let secrets = Secrets(identityID: id, keychain: environment.keychain) - func authorizeAndCreateIdentity(id: UUID, url: URL) -> AnyPublisher { - AuthenticationService(url: url, environment: environment) - .authenticate() - .tryMap { - let secrets = Secrets(identityID: id, keychain: environment.keychain) + do { + try secrets.setInstanceURL(url) + } catch { + return Fail(error: error).eraseToAnyPublisher() + } - try secrets.setInstanceURL(url) - try secrets.setClientID($0.clientId) - try secrets.setClientSecret($0.clientSecret) - try secrets.setAccessToken($1.accessToken) - } - .flatMap { database.createIdentity(id: id, url: url) } + let createIdentityPublisher = database.createIdentity( + id: id, + url: url, + authenticated: authenticated) .ignoreOutput() .eraseToAnyPublisher() + + if authenticated { + return AuthenticationService(url: url, environment: environment).authenticate() + .tryMap { + try secrets.setClientID($0.clientId) + try secrets.setClientSecret($0.clientSecret) + try secrets.setAccessToken($1.accessToken) + } + .flatMap { createIdentityPublisher } + .eraseToAnyPublisher() + } else { + return createIdentityPublisher + } } - func deleteIdentity(_ identity: Identity) -> AnyPublisher { - let secrets = Secrets(identityID: identity.id, keychain: environment.keychain) - let mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: identity.url) + func deleteIdentity(id: UUID) -> AnyPublisher { + database.deleteIdentity(id: id) + .collect() + .tryMap { _ -> AnyPublisher in + try ContentDatabase.delete(forIdentityID: id) - return database.deleteIdentity(id: identity.id) - .collect() - .tryMap { _ in - DeletionEndpoint.oauthRevoke( - token: try secrets.getAccessToken(), - clientID: try secrets.getClientID(), - clientSecret: try secrets.getClientSecret()) + let secrets = Secrets(identityID: id, keychain: environment.keychain) + + defer { secrets.deleteAllItems() } + + do { + return MastodonAPIClient( + session: environment.session, + instanceURL: try secrets.getInstanceURL()) + .request(DeletionEndpoint.oauthRevoke( + token: try secrets.getAccessToken(), + clientID: try secrets.getClientID(), + clientSecret: try secrets.getClientSecret())) + .ignoreOutput() + .eraseToAnyPublisher() + } catch { + return Empty().eraseToAnyPublisher() + } } - .flatMap(mastodonAPIClient.request) - .collect() - .tryMap { _ in - try secrets.deleteAllItems() - try ContentDatabase.delete(forIdentityID: identity.id) - } - .ignoreOutput() + .flatMap { $0 } .eraseToAnyPublisher() } diff --git a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift index cd603dc..eae675d 100644 --- a/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift +++ b/ViewModels/Sources/PreviewViewModels/PreviewViewModels.swift @@ -25,7 +25,7 @@ let db: IdentityDatabase = { try! secrets.setInstanceURL(url) try! secrets.setAccessToken(UUID().uuidString) - _ = db.createIdentity(id: id, url: url) + _ = db.createIdentity(id: id, url: url, authenticated: true) .receive(on: ImmediateScheduler.shared) .sink { _ in } receiveValue: { _ in } diff --git a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift index 83ea701..6a7441b 100644 --- a/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift +++ b/ViewModels/Sources/ViewModels/AddIdentityViewModel.swift @@ -33,7 +33,7 @@ public extension AddIdentityViewModel { return } - allIdentitiesService.authorizeAndCreateIdentity(id: identityID, url: instanceURL) + allIdentitiesService.createIdentity(id: identityID, url: instanceURL, authenticated: true) .receive(on: DispatchQueue.main) .catch { [weak self] error -> Empty in if case AuthenticationError.canceled = error { @@ -70,7 +70,7 @@ public extension AddIdentityViewModel { } // TODO: Ensure instance has not disabled public preview - allIdentitiesService.createIdentity(id: identityID, instanceURL: instanceURL) + allIdentitiesService.createIdentity(id: identityID, url: instanceURL, authenticated: false) .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { [weak self] in guard let self = self, case .finished = $0 else { return } diff --git a/ViewModels/Sources/ViewModels/IdentitiesViewModel.swift b/ViewModels/Sources/ViewModels/IdentitiesViewModel.swift index 30ccaa0..2e620d2 100644 --- a/ViewModels/Sources/ViewModels/IdentitiesViewModel.swift +++ b/ViewModels/Sources/ViewModels/IdentitiesViewModel.swift @@ -6,7 +6,8 @@ import ServiceLayer public final class IdentitiesViewModel: ObservableObject { public let currentIdentityID: UUID - @Published public var identities = [Identity]() + @Published public var authenticated = [Identity]() + @Published public var unauthenticated = [Identity]() @Published public var alertItem: AlertItem? private let identification: Identification @@ -16,8 +17,13 @@ public final class IdentitiesViewModel: ObservableObject { self.identification = identification currentIdentityID = identification.identity.id - identification.service.identitiesObservation() + let observation = identification.service.identitiesObservation() .assignErrorsToAlertItem(to: \.alertItem, on: self) - .assign(to: &$identities) + .share() + + observation.map { $0.filter { $0.authenticated } } + .assign(to: &$authenticated) + observation.map { $0.filter { !$0.authenticated } } + .assign(to: &$unauthenticated) } } diff --git a/ViewModels/Sources/ViewModels/RootViewModel.swift b/ViewModels/Sources/ViewModels/RootViewModel.swift index 97969db..a2b8bef 100644 --- a/ViewModels/Sources/ViewModels/RootViewModel.swift +++ b/ViewModels/Sources/ViewModels/RootViewModel.swift @@ -70,8 +70,8 @@ public extension RootViewModel { .store(in: &cancellables) } - func deleteIdentity(_ identity: Identity) { - allIdentitiesService.deleteIdentity(identity) + func deleteIdentity(id: UUID) { + allIdentitiesService.deleteIdentity(id: id) .sink { _ in } receiveValue: { _ in } .store(in: &cancellables) } diff --git a/ViewModels/Sources/ViewModels/TabNavigationViewModel.swift b/ViewModels/Sources/ViewModels/TabNavigationViewModel.swift index 72e7048..bb9c140 100644 --- a/ViewModels/Sources/ViewModels/TabNavigationViewModel.swift +++ b/ViewModels/Sources/ViewModels/TabNavigationViewModel.swift @@ -8,8 +8,8 @@ import ServiceLayer public final class TabNavigationViewModel: ObservableObject { @Published public private(set) var identity: Identity @Published public private(set) var recentIdentities = [Identity]() - @Published public var timeline = Timeline.home - @Published public private(set) var timelinesAndLists = Timeline.nonLists + @Published public var timeline: Timeline + @Published public private(set) var timelinesAndLists: [Timeline] @Published public var presentingSecondaryNavigation = false @Published public var alertItem: AlertItem? public var selectedTab: Tab? = .timelines @@ -20,20 +20,34 @@ public final class TabNavigationViewModel: ObservableObject { public init(identification: Identification) { self.identification = identification identity = identification.identity - identification.$identity.dropFirst().assign(to: &$identity) + timeline = identification.service.isAuthorized ? .home : .local + timelinesAndLists = identification.service.isAuthorized + ? Timeline.authenticatedDefaults + : Timeline.unauthenticatedDefaults + identification.$identity.dropFirst().assign(to: &$identity) identification.service.recentIdentitiesObservation() .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$recentIdentities) - identification.service.listsObservation() - .map { Timeline.nonLists + $0 } - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .assign(to: &$timelinesAndLists) + if identification.service.isAuthorized { + identification.service.listsObservation() + .map { Timeline.authenticatedDefaults + $0 } + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .assign(to: &$timelinesAndLists) + } } } public extension TabNavigationViewModel { + var tabs: [Tab] { + if identification.service.isAuthorized { + return Tab.allCases + } else { + return [.timelines, .explore] + } + } + var timelineSubtitle: String { switch timeline { case .home, .list: @@ -43,28 +57,16 @@ public extension TabNavigationViewModel { } } - func systemImageName(timeline: Timeline) -> String { - switch timeline { - case .home: return "house" - case .local: return "person.3" - case .federated: return "globe" - case .list: return "scroll" - case .tag: return "number" - } - } - func refreshIdentity() { if identification.service.isAuthorized { identification.service.verifyCredentials() .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } .store(in: &cancellables) - identification.service.refreshLists() .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } .store(in: &cancellables) - identification.service.refreshFilters() .assignErrorsToAlertItem(to: \.alertItem, on: self) .sink { _ in } @@ -92,7 +94,7 @@ public extension TabNavigationViewModel { public extension TabNavigationViewModel { enum Tab: CaseIterable { case timelines - case search + case explore case notifications case messages } diff --git a/Views/AddIdentityView.swift b/Views/AddIdentityView.swift index 6ff6099..940b2eb 100644 --- a/Views/AddIdentityView.swift +++ b/Views/AddIdentityView.swift @@ -19,11 +19,11 @@ struct AddIdentityView: View { } else { Button("add-identity.log-in", action: viewModel.logInTapped) + Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped) + .frame(maxWidth: .infinity, alignment: .center) } } .frame(maxWidth: .infinity, alignment: .center) - Button("add-identity.browse-anonymously", action: viewModel.browseAnonymouslyTapped) - .frame(maxWidth: .infinity, alignment: .center) } .alertItem($viewModel.alertItem) .onReceive(viewModel.addedIdentityID) { id in diff --git a/Views/IdentitiesView.swift b/Views/IdentitiesView.swift index 690ca67..0ed5baf 100644 --- a/Views/IdentitiesView.swift +++ b/Views/IdentitiesView.swift @@ -1,6 +1,7 @@ // Copyright © 2020 Metabolist. All rights reserved. import KingfisherSwiftUI +import struct ServiceLayer.Identity import SwiftUI import ViewModels @@ -18,9 +19,26 @@ struct IdentitiesView: View { Label("add", systemImage: "plus.circle") }) } - Section { + section(title: "identities.accounts", identities: viewModel.authenticated) + section(title: "identities.browsing-anonymously", identities: viewModel.unauthenticated) + } + .toolbar { + ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) { + EditButton() + } + } + } +} + +private extension IdentitiesView { + @ViewBuilder + func section(title: LocalizedStringKey, identities: [Identity]) -> some View { + if identities.isEmpty { + EmptyView() + } else { + Section(header: Text(title)) { List { - ForEach(viewModel.identities) { identity in + ForEach(identities) { identity in Button { withAnimation { rootViewModel.newIdentitySelected(id: identity.id) @@ -31,15 +49,26 @@ struct IdentitiesView: View { options: .downsampled(dimension: 40, scaleFactor: displayScale)) VStack(alignment: .leading, spacing: 0) { Spacer() - if let account = identity.account { - CustomEmojiText( - text: account.displayName, - emoji: account.emojis, - textStyle: .headline) + if identity.authenticated { + if let account = identity.account { + CustomEmojiText( + text: account.displayName, + emoji: account.emojis, + textStyle: .headline) + } + Text(identity.handle) + .font(.subheadline) + .foregroundColor(.secondary) + } else { + Text(identity.handle) + .font(.headline) + .foregroundColor(.secondary) + if let instance = identity.instance { + Text(instance.uri) + .font(.subheadline) + .foregroundColor(.secondary) + } } - Text(identity.handle) - .font(.subheadline) - .foregroundColor(.secondary) Spacer() } Spacer() @@ -54,16 +83,11 @@ struct IdentitiesView: View { .onDelete { guard let index = $0.first else { return } - rootViewModel.deleteIdentity(viewModel.identities[index]) + rootViewModel.deleteIdentity(id: identities[index].id) } } } } - .toolbar { - ToolbarItem(placement: ToolbarItemPlacement.navigationBarTrailing) { - EditButton() - } - } } } diff --git a/Views/SecondaryNavigationView.swift b/Views/SecondaryNavigationView.swift index f63a7a3..382d189 100644 --- a/Views/SecondaryNavigationView.swift +++ b/Views/SecondaryNavigationView.swift @@ -21,17 +21,30 @@ struct SecondaryNavigationView: View { KFImage(tabNavigationViewModel.identity.image, options: .downsampled(dimension: 50, scaleFactor: displayScale)) VStack(alignment: .leading) { - if let account = tabNavigationViewModel.identity.account { - CustomEmojiText( - text: account.displayName, - emoji: account.emojis, - textStyle: .headline) + if tabNavigationViewModel.identity.authenticated { + if let account = tabNavigationViewModel.identity.account { + CustomEmojiText( + text: account.displayName, + emoji: account.emojis, + textStyle: .headline) + } + Text(tabNavigationViewModel.identity.handle) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.5) + } else { + Text(tabNavigationViewModel.identity.handle) + .font(.headline) + if let instance = tabNavigationViewModel.identity.instance { + Text(instance.uri) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.5) + } } - Text(tabNavigationViewModel.identity.handle) - .font(.subheadline) - .foregroundColor(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.5) + Spacer() Text("secondary-navigation.manage-accounts") .font(.subheadline) diff --git a/Views/TabNavigationView.swift b/Views/TabNavigationView.swift index 437f1b3..e1833b6 100644 --- a/Views/TabNavigationView.swift +++ b/Views/TabNavigationView.swift @@ -12,7 +12,7 @@ struct TabNavigationView: View { var body: some View { TabView(selection: $viewModel.selectedTab) { - ForEach(TabNavigationViewModel.Tab.allCases) { tab in + ForEach(viewModel.tabs) { tab in NavigationView { view(tab: tab) } @@ -65,11 +65,11 @@ private extension TabNavigationView { viewModel.timeline = timeline } label: { Label(timeline.title, - systemImage: viewModel.systemImageName(timeline: timeline)) + systemImage: timeline.systemImageName) } } } label: { - Image(systemName: viewModel.systemImageName(timeline: viewModel.timeline)) + Image(systemName: viewModel.timeline.systemImageName) }) default: Text(tab.title) } @@ -118,13 +118,23 @@ private extension Timeline { return "#" + tag } } + + var systemImageName: String { + switch self { + case .home: return "house" + case .local: return "person.3" + case .federated: return "globe" + case .list: return "scroll" + case .tag: return "number" + } + } } extension TabNavigationViewModel.Tab { var title: String { switch self { case .timelines: return "Timelines" - case .search: return "Search" + case .explore: return "Explore" case .notifications: return "Notifications" case .messages: return "Messages" } @@ -133,7 +143,7 @@ extension TabNavigationViewModel.Tab { var systemImageName: String { switch self { case .timelines: return "newspaper" - case .search: return "magnifyingglass" + case .explore: return "magnifyingglass" case .notifications: return "bell" case .messages: return "envelope" }