Multi accounts

This commit is contained in:
Thomas Ricouard 2022-12-30 08:36:22 +01:00
parent dd5a6a8b45
commit 3a076492a1
16 changed files with 146 additions and 30 deletions

View file

@ -12,6 +12,8 @@
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; }; 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F5295AE04800DE16D0 /* Tabs.swift */; };
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; }; 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */; };
9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; }; 9F2B92FC295DA94500DE16D0 /* InstanceInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */; };
9F2B92FF295EB87100DE16D0 /* AppAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */; };
9F2B9301295EB8A100DE16D0 /* AppAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */; };
9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; }; 9F35DB44294F9A7D00B3281A /* Status in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB43294F9A7D00B3281A /* Status */; };
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; }; 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F35DB4629506F6600B3281A /* NotificationTab.swift */; };
9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; }; 9F35DB4A29506FA100B3281A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9F35DB4929506FA100B3281A /* Notifications */; };
@ -40,6 +42,8 @@
9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; }; 9F2B92F5295AE04800DE16D0 /* Tabs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tabs.swift; sourceTree = "<group>"; };
9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = "<group>"; }; 9F2B92F9295DA7D700DE16D0 /* AddAccountsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountsView.swift; sourceTree = "<group>"; };
9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = "<group>"; }; 9F2B92FB295DA94500DE16D0 /* InstanceInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceInfoView.swift; sourceTree = "<group>"; };
9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountView.swift; sourceTree = "<group>"; };
9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAccountViewModel.swift; sourceTree = "<group>"; };
9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = "<group>"; }; 9F35DB42294F9A2900B3281A /* Status */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Status; path = Packages/Status; sourceTree = "<group>"; };
9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = "<group>"; }; 9F35DB45294FA04C00B3281A /* DesignSystem */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = DesignSystem; path = Packages/DesignSystem; sourceTree = "<group>"; };
9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = "<group>"; }; 9F35DB4629506F6600B3281A /* NotificationTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTab.swift; sourceTree = "<group>"; };
@ -120,6 +124,8 @@
children = ( children = (
9FAE4AD029379AD600772766 /* AppAccount.swift */, 9FAE4AD029379AD600772766 /* AppAccount.swift */,
9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */, 9FAE4AD22937A0C600772766 /* AppAccountsManager.swift */,
9F2B92FE295EB87100DE16D0 /* AppAccountView.swift */,
9F2B9300295EB8A100DE16D0 /* AppAccountViewModel.swift */,
); );
path = AppAccounts; path = AppAccounts;
sourceTree = "<group>"; sourceTree = "<group>";
@ -269,9 +275,11 @@
9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */, 9F35DB4C2952005C00B3281A /* AccountTab.swift in Sources */,
9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */, 9FAE4ACB293783B000772766 /* SettingsTab.swift in Sources */,
9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */, 9FAE4AD32937A0C600772766 /* AppAccountsManager.swift in Sources */,
9F2B92FF295EB87100DE16D0 /* AppAccountView.swift in Sources */,
9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */, 9F2B92F6295AE04800DE16D0 /* Tabs.swift in Sources */,
9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */, 9F398AB329360A4C00A889F2 /* TimelineTab.swift in Sources */,
9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */, 9F398AA62935FE8A00A889F2 /* AppRouteur.swift in Sources */,
9F2B9301295EB8A100DE16D0 /* AppAccountViewModel.swift in Sources */,
9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */, 9FBFE63D292A715500C250E9 /* IceCubesApp.swift in Sources */,
9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */, 9F2B92FA295DA7D700DE16D0 /* AddAccountsView.swift in Sources */,
9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */, 9F35DB4729506F6600B3281A /* NotificationTab.swift in Sources */,

View file

@ -4,10 +4,14 @@ import Network
import KeychainSwift import KeychainSwift
import Models import Models
struct AppAccount: Codable { struct AppAccount: Codable, Identifiable {
let server: String let server: String
let oauthToken: OauthToken? let oauthToken: OauthToken?
var id: String {
key
}
var key: String { var key: String {
if let oauthToken { if let oauthToken {
return "\(server):\(oauthToken.createdAt)" return "\(server):\(oauthToken.createdAt)"

View file

@ -0,0 +1,37 @@
import SwiftUI
import DesignSystem
struct AppAccountView: View {
@EnvironmentObject var appAccounts: AppAccountsManager
@StateObject var viewModel: AppAccountViewModel
var body: some View {
HStack {
if let account = viewModel.account {
ZStack(alignment: .topTrailing) {
AvatarView(url: account.avatar)
if viewModel.appAccount.id == appAccounts.currentAccount.id {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.offset(x: 5, y: -5)
}
}
}
VStack(alignment: .leading) {
Text(viewModel.appAccount.server)
.font(.headline)
if let account = viewModel.account {
Text(account.displayName)
Text(account.username)
.font(.footnote)
.foregroundColor(.gray)
}
}
}
.onAppear {
Task {
await viewModel.fetchAccount()
}
}
}
}

View file

@ -0,0 +1,24 @@
import SwiftUI
import Models
import Network
@MainActor
public class AppAccountViewModel: ObservableObject {
let appAccount: AppAccount
let client: Client
@Published var account: Account?
init(appAccount: AppAccount) {
self.appAccount = appAccount
self.client = .init(server: appAccount.server, oauthToken: appAccount.oauthToken)
}
func fetchAccount() async {
do {
account = try await client.get(endpoint: Accounts.verifyCredentials)
} catch {
print(error)
}
}
}

View file

@ -2,8 +2,11 @@ import SwiftUI
import Network import Network
class AppAccountsManager: ObservableObject { class AppAccountsManager: ObservableObject {
@AppStorage("latestCurrentAccountKey") static public var latestCurrentAccountKey: String = ""
@Published var currentAccount: AppAccount { @Published var currentAccount: AppAccount {
didSet { didSet {
Self.latestCurrentAccountKey = currentAccount.id
currentClient = .init(server: currentAccount.server, currentClient = .init(server: currentAccount.server,
oauthToken: currentAccount.oauthToken) oauthToken: currentAccount.oauthToken)
} }
@ -16,10 +19,15 @@ class AppAccountsManager: ObservableObject {
do { do {
let keychainAccounts = try AppAccount.retrieveAll() let keychainAccounts = try AppAccount.retrieveAll()
availableAccounts = keychainAccounts availableAccounts = keychainAccounts
defaultAccount = keychainAccounts.last ?? defaultAccount if let currentAccount = keychainAccounts.first(where: { $0.id == Self.latestCurrentAccountKey }) {
} catch {} defaultAccount = currentAccount
} else {
defaultAccount = keychainAccounts.last ?? defaultAccount
}
} catch {
availableAccounts = [defaultAccount]
}
currentAccount = defaultAccount currentAccount = defaultAccount
availableAccounts = [defaultAccount]
currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken) currentClient = .init(server: defaultAccount.server, oauthToken: defaultAccount.oauthToken)
} }
@ -27,12 +35,15 @@ class AppAccountsManager: ObservableObject {
do { do {
try account.save() try account.save()
currentAccount = account currentAccount = account
availableAccounts.append(account)
} catch { } } catch { }
} }
func delete(account: AppAccount) { func delete(account: AppAccount) {
availableAccounts.removeAll(where: { $0.id == account.id })
account.delete() account.delete()
AppAccount.deleteAll() if currentAccount.id == account.id {
currentAccount = AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil) currentAccount = availableAccounts.first ?? AppAccount(server: IceCubesApp.defaultServer, oauthToken: nil)
}
} }
} }

View file

@ -20,6 +20,7 @@ struct AccountTab: View {
.toolbar { .toolbar {
statusEditorToolbarItem(routeurPath: routeurPath) statusEditorToolbarItem(routeurPath: routeurPath)
} }
.id(account.id)
} else { } else {
AccountDetailView(account: .placeholder()) AccountDetailView(account: .placeholder())
.redacted(reason: .placeholder) .redacted(reason: .placeholder)

View file

@ -7,6 +7,7 @@ import Env
import Network import Network
struct ExploreTab: View { struct ExploreTab: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab

View file

@ -7,6 +7,7 @@ import Notifications
struct NotificationsTab: View { struct NotificationsTab: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var watcher: StreamWatcher @EnvironmentObject private var watcher: StreamWatcher
@EnvironmentObject private var currentAccount: CurrentAccount
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
@ -18,6 +19,7 @@ struct NotificationsTab: View {
.toolbar { .toolbar {
statusEditorToolbarItem(routeurPath: routeurPath) statusEditorToolbarItem(routeurPath: routeurPath)
} }
.id(currentAccount.account?.id)
} }
.onAppear { .onAppear {
routeurPath.client = client routeurPath.client = client

View file

@ -8,7 +8,6 @@ import DesignSystem
struct SettingsTabs: View { struct SettingsTabs: View {
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var currentInstance: CurrentInstance @EnvironmentObject private var currentInstance: CurrentInstance
@EnvironmentObject private var appAccountsManager: AppAccountsManager @EnvironmentObject private var appAccountsManager: AppAccountsManager
@EnvironmentObject private var theme: Theme @EnvironmentObject private var theme: Theme
@ -30,7 +29,6 @@ struct SettingsTabs: View {
} }
.task { .task {
if appAccountsManager.currentAccount.oauthToken != nil { if appAccountsManager.currentAccount.oauthToken != nil {
await currentAccount.fetchCurrentAccount()
await currentInstance.fetchCurrentInstance() await currentInstance.fetchCurrentInstance()
} }
} }
@ -38,19 +36,21 @@ struct SettingsTabs: View {
private var accountsSection: some View { private var accountsSection: some View {
Section("Account") { Section("Account") {
if let accountData = currentAccount.account { ForEach(appAccountsManager.availableAccounts) { account in
HStack { HStack {
AvatarView(url: accountData.avatar) AppAccountView(viewModel: .init(appAccount: account))
VStack(alignment: .leading) { }
Text(appAccountsManager.currentAccount.server) .onTapGesture {
.font(.headline) withAnimation {
Text(accountData.displayName) appAccountsManager.currentAccount = account
Text(accountData.username)
.font(.footnote)
.foregroundColor(.gray)
} }
} }
signOutButton }
.onDelete { indexSet in
if let index = indexSet.first {
let account = appAccountsManager.availableAccounts[index]
appAccountsManager.delete(account: account)
}
} }
addAccountButton addAccountButton
} }

View file

@ -5,6 +5,7 @@ import Network
import Combine import Combine
struct TimelineTab: View { struct TimelineTab: View {
@EnvironmentObject private var currentAccount: CurrentAccount
@EnvironmentObject private var client: Client @EnvironmentObject private var client: Client
@StateObject private var routeurPath = RouterPath() @StateObject private var routeurPath = RouterPath()
@Binding var popToRootTab: Tab @Binding var popToRootTab: Tab
@ -23,6 +24,7 @@ struct TimelineTab: View {
} }
} }
} }
.id(currentAccount.account?.id)
} }
.onAppear { .onAppear {
routeurPath.client = client routeurPath.client = client

View file

@ -85,6 +85,7 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
async let featuredTags: [FeaturedTag] = client.get(endpoint: Accounts.featuredTags(id: accountId)) async let featuredTags: [FeaturedTag] = client.get(endpoint: Accounts.featuredTags(id: accountId))
async let familliarFollowers: [FamilliarAccounts] = client.get(endpoint: Accounts.familiarFollowers(withAccount: accountId)) async let familliarFollowers: [FamilliarAccounts] = client.get(endpoint: Accounts.familiarFollowers(withAccount: accountId))
let loadedAccount = try await account let loadedAccount = try await account
self.account = loadedAccount
self.featuredTags = try await featuredTags self.featuredTags = try await featuredTags
self.featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt } self.featuredTags.sort { $0.statusesCountInt > $1.statusesCountInt }
self.fields = loadedAccount.fields self.fields = loadedAccount.fields
@ -96,10 +97,13 @@ class AccountDetailViewModel: ObservableObject, StatusesFetcher {
self.relationship = relationships.first self.relationship = relationships.first
self.familliarFollowers = try await familliarFollowers.first?.accounts ?? [] self.familliarFollowers = try await familliarFollowers.first?.accounts ?? []
} }
self.account = loadedAccount
accountState = .data(account: loadedAccount) accountState = .data(account: loadedAccount)
} catch { } catch {
accountState = .error(error: error) if let account {
accountState = .data(account: account)
} else {
accountState = .error(error: error)
}
} }
} }

View file

@ -35,7 +35,6 @@ public struct ExploreView: View {
} }
.task { .task {
viewModel.client = client viewModel.client = client
guard !viewModel.isLoaded else { return }
await viewModel.fetchTrending() await viewModel.fetchTrending()
} }
.refreshable { .refreshable {

View file

@ -4,7 +4,18 @@ import Network
@MainActor @MainActor
class ExploreViewModel: ObservableObject { class ExploreViewModel: ObservableObject {
var client: Client? var client: Client? {
didSet {
if oldValue != client {
isLoaded = false
results = [:]
trendingTags = []
trendingLinks = []
trendingStatuses = []
suggestedAccounts = []
}
}
}
enum Token: String, Identifiable { enum Token: String, Identifiable {
case user = "@user" case user = "@user"
@ -56,8 +67,6 @@ class ExploreViewModel: ObservableObject {
func fetchTrending() async { func fetchTrending() async {
guard let client else { return } guard let client else { return }
do { do {
isLoaded = false
async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions) async let suggestedAccounts: [Account] = client.get(endpoint: Accounts.suggestions)
async let trendingTags: [Tag] = client.get(endpoint: Trends.tags) async let trendingTags: [Tag] = client.get(endpoint: Trends.tags)
async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses) async let trendingStatuses: [Status] = client.get(endpoint: Trends.statuses)
@ -71,7 +80,9 @@ class ExploreViewModel: ObservableObject {
self.suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: self.suggestedAccounts.map{ $0.id })) self.suggestedAccountsRelationShips = try await client.get(endpoint: Accounts.relationships(ids: self.suggestedAccounts.map{ $0.id }))
isLoaded = true isLoaded = true
} catch { } } catch {
isLoaded = true
}
} }
func search() { func search() {

View file

@ -108,7 +108,7 @@ extension Models.Notification.NotificationType {
case .status: case .status:
return "posted a status" return "posted a status"
case .mention: case .mention:
return "mentionned you" return "mentioned you"
case .reblog: case .reblog:
return "boosted" return "boosted"
case .follow: case .follow:

View file

@ -19,7 +19,13 @@ class NotificationsViewModel: ObservableObject {
case mentions = "Mentions" case mentions = "Mentions"
} }
var client: Client? var client: Client? {
didSet {
if oldValue != client {
notifications = []
}
}
}
@Published var state: State = .loading @Published var state: State = .loading
@Published var tab: Tab = .all { @Published var tab: Tab = .all {
didSet { didSet {

View file

@ -6,7 +6,13 @@ import Env
@MainActor @MainActor
class TimelineViewModel: ObservableObject, StatusesFetcher { class TimelineViewModel: ObservableObject, StatusesFetcher {
var client: Client? var client: Client? {
didSet {
if oldValue != client {
statuses = []
}
}
}
// Internal source of truth for a timeline. // Internal source of truth for a timeline.
private var statuses: [Status] = [] private var statuses: [Status] = []
@ -66,14 +72,14 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
pendingStatuses = [] pendingStatuses = []
statusesState = .loading statusesState = .loading
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil, minId: nil)) statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil, maxId: nil, minId: nil))
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
} else if let first = statuses.first { } else if let first = statuses.first {
var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 10) var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 10)
if userIntent || !pendingStatusesEnabled { if userIntent || !pendingStatusesEnabled {
pendingStatuses = [] pendingStatuses = []
statuses.insert(contentsOf: newStatuses, at: 0) statuses.insert(contentsOf: newStatuses, at: 0)
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
} }
} else { } else {
newStatuses = newStatuses.filter { status in newStatuses = newStatuses.filter { status in