Anonymous browsing improvements

This commit is contained in:
Justin Mazzocchi 2020-09-08 22:40:49 -07:00
parent 8229eecc3a
commit 335a006f45
No known key found for this signature in database
GPG Key ID: E223E6937AAFB01C
18 changed files with 192 additions and 107 deletions

View File

@ -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()

View File

@ -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?

View File

@ -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,

View File

@ -33,11 +33,12 @@ public struct IdentityDatabase {
}
public extension IdentityDatabase {
func createIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> {
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
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()

View File

@ -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?

View File

@ -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";

View File

@ -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 {

View File

@ -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
}
}
}

View File

@ -32,45 +32,61 @@ public extension AllIdentitiesService {
try IdentityService(id: id, database: database, environment: environment)
}
func createIdentity(id: UUID, instanceURL: URL) -> AnyPublisher<Never, Error> {
database.createIdentity(id: id, url: instanceURL)
}
func createIdentity(id: UUID, url: URL, authenticated: Bool) -> AnyPublisher<Never, Error> {
let secrets = Secrets(identityID: id, keychain: environment.keychain)
func authorizeAndCreateIdentity(id: UUID, url: URL) -> AnyPublisher<Never, Error> {
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<Never, Error> {
let secrets = Secrets(identityID: identity.id, keychain: environment.keychain)
let mastodonAPIClient = MastodonAPIClient(session: environment.session, instanceURL: identity.url)
func deleteIdentity(id: UUID) -> AnyPublisher<Never, Error> {
database.deleteIdentity(id: id)
.collect()
.tryMap { _ -> AnyPublisher<Never, Error> 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()
}

View File

@ -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 }

View File

@ -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<Never, Never> 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 }

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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()
}
}
}
}

View File

@ -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)

View File

@ -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"
}