diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index be8f6ca1a..f03c055ec 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -66,7 +66,7 @@ final public class SceneCoordinator { } let domain = authentication.domain let userID = authentication.userID - let isSuccess = try await AuthenticationServiceProvider.shared.activeMastodonUser(domain: domain, userID: userID) + let isSuccess = AuthenticationServiceProvider.shared.activateUser(userID, inDomain: domain) guard isSuccess else { return } self.setup() @@ -94,7 +94,7 @@ final public class SceneCoordinator { // show notification related content guard let type = Mastodon.Entity.Notification.NotificationType(rawValue: pushNotification.notificationType) else { return } guard let authenticationBox = self.authenticationBox else { return } - guard let me = authenticationBox.authentication.account() else { return } + guard let me = authenticationBox.cachedAccount else { return } let notificationID = String(pushNotification.notificationID) switch type { diff --git a/Mastodon/Diffable/User/UserSection.swift b/Mastodon/Diffable/User/UserSection.swift index d0de6d305..1c58feac0 100644 --- a/Mastodon/Diffable/User/UserSection.swift +++ b/Mastodon/Diffable/User/UserSection.swift @@ -37,7 +37,7 @@ extension UserSection { case .account(let account, let relationship): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: UserTableViewCell.self), for: indexPath) as! UserTableViewCell - guard let me = authenticationBox.authentication.account() else { return cell } + guard let me = authenticationBox.cachedAccount else { return cell } cell.userView.setButtonState(.loading) cell.configure( diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index b7ff7f3c2..23b5c1c36 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -83,10 +83,8 @@ extension DataSourceFacade { do { let account = try await APIService.shared.accountInfo( - domain: domain, - userID: accountID, - authorization: provider.authenticationBox.userAuthorization - ).value + provider.authenticationBox + ) provider.coordinator.hideLoading() @@ -103,7 +101,7 @@ extension DataSourceFacade { ) async { provider.coordinator.showLoading() - guard let me = provider.authenticationBox.authentication.account(), + guard let me = provider.authenticationBox.cachedAccount, let relationship = try? await APIService.shared.relationship(forAccounts: [account], authenticationBox: provider.authenticationBox).value.first else { return provider.coordinator.hideLoading() } diff --git a/Mastodon/Scene/Account/AccountListViewModel.swift b/Mastodon/Scene/Account/AccountListViewModel.swift index f68d7b566..db6af86ea 100644 --- a/Mastodon/Scene/Account/AccountListViewModel.swift +++ b/Mastodon/Scene/Account/AccountListViewModel.swift @@ -104,7 +104,7 @@ extension AccountListViewModel { authentication: MastodonAuthentication, activeAuthentication: MastodonAuthentication ) { - guard let account = authentication.account() else { return } + guard let account = authentication.cachedAccount() else { return } // avatar cell.avatarButton.avatarImageView.configure(with: account.avatarImageURL()) diff --git a/Mastodon/Scene/Account/AccountViewController.swift b/Mastodon/Scene/Account/AccountViewController.swift index c942915ed..7a93db267 100644 --- a/Mastodon/Scene/Account/AccountViewController.swift +++ b/Mastodon/Scene/Account/AccountViewController.swift @@ -144,7 +144,7 @@ extension AccountListViewController: UITableViewDelegate { case .authentication(let record): assert(Thread.isMainThread) Task { @MainActor in - let isActive = try await AuthenticationServiceProvider.shared.activeMastodonUser(domain: record.domain, userID: record.userID) + let isActive = AuthenticationServiceProvider.shared.activateUser(record.userID, inDomain: record.domain) guard isActive else { return } self.coordinator.setup() } // end Task diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index fc04a4a31..bb0ba6e02 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -355,7 +355,7 @@ extension HomeTimelineViewController { let userDoesntFollowPeople: Bool if let authenticationBox = self?.authenticationBox, - let me = authenticationBox.authentication.account() { + let me = authenticationBox.cachedAccount { userDoesntFollowPeople = me.followersCount == 0 } else { userDoesntFollowPeople = true diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 5b8e13803..71d52770e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -96,7 +96,7 @@ final class HomeTimelineViewModel: NSObject { self.authenticationBox = authenticationBox self.dataController = FeedDataController(context: context, authenticationBox: authenticationBox) super.init() - self.dataController.records = (try? FileManager.default.cachedHomeTimeline(for: authenticationBox).map { + self.dataController.records = (try? PersistenceManager.shared.cachedTimeline(.homeTimeline(authenticationBox)).map { MastodonFeed.fromStatus($0, kind: .home) }) ?? [] diff --git a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift index b296bcd90..219bd0bd2 100644 --- a/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift +++ b/Mastodon/Scene/Notification/NotificationView/NotificationView+Configuration.swift @@ -189,7 +189,7 @@ extension NotificationView { notificationTypeIndicatorLabel.reset() } - if let me = authenticationBox.authentication.account() { + if let me = authenticationBox.cachedAccount { let isMyself = (author == me) let isMuting: Bool let isBlocking: Bool diff --git a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift index fdaafa579..bb6785c16 100644 --- a/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift +++ b/Mastodon/Scene/Onboarding/Login/MastodonLoginViewController.swift @@ -125,8 +125,7 @@ class MastodonLoginViewController: UIViewController, NeedsDependency { .authenticated.sink { (domain, account) in Task { @MainActor in do { - _ = try await AuthenticationServiceProvider.shared.activeMastodonUser(domain: domain, userID: account.id) - FileManager.default.store(account: account, forUserID: MastodonUserIdentifier(domain: domain, userID: account.id)) + AuthenticationServiceProvider.shared.activateUser(account.id, inDomain: domain) self.coordinator.setup() } catch { diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index eb31f5a7a..d41c45722 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -151,8 +151,8 @@ extension MastodonPickServerViewController { .authenticated .asyncMap { domain, user -> Result in do { - let result = try await AuthenticationServiceProvider.shared.activeMastodonUser(domain: domain, userID: user.id) - return .success(result) + let activated = AuthenticationServiceProvider.shared.activateUser(user.id, inDomain: domain) + return .success(activated) } catch { return .failure(error) } diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index 1cf82e4f7..80fe8ac34 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -209,42 +209,9 @@ extension AuthenticationViewModel { .authentications .insert(authentication, at: 0) - FileManager.default.store(account: account, forUserID: authentication.userIdentifier()) return response } .eraseToAnyPublisher() } - - static func verifyAndSaveAuthentication( - context: AppContext, - domain: String, - clientID: String, - clientSecret: String, - userToken: String - ) async throws -> Mastodon.Entity.Account { - let authorization = Mastodon.API.OAuth.Authorization(accessToken: userToken) - - let account = try await APIService.shared.accountVerifyCredentials( - domain: domain, - authorization: authorization - ) - - let authentication = MastodonAuthentication.createFrom(domain: domain, - userID: account.id, - username: account.username, - appAccessToken: userToken, // TODO: swap app token - userAccessToken: userToken, - clientID: clientID, - clientSecret: clientSecret, - accountCreatedAt: account.createdAt) - - AuthenticationServiceProvider.shared - .authentications - .insert(authentication, at: 0) - - FileManager.default.store(account: account, forUserID: authentication.userIdentifier()) - - return account - } } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 08cf3fe1a..cd805cbac 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -653,9 +653,8 @@ extension ProfileViewController { viewModel.profileAboutViewModel.fields = updatedAccount.mastodonFields } - if let updatedMe = try? await APIService.shared.authenticatedUserInfo(authenticationBox: viewModel.authenticationBox).value { + if let updatedMe = try? await APIService.shared.authenticatedUserInfo(authenticationBox: viewModel.authenticationBox) { viewModel.me = updatedMe - FileManager.default.store(account: updatedMe, forUserID: viewModel.authenticationBox.authentication.userIdentifier()) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -1097,10 +1096,9 @@ extension ProfileViewController { } else if viewModel.account == viewModel.me { // update my profile Task { - if let updatedMe = try? await APIService.shared.authenticatedUserInfo(authenticationBox: viewModel.authenticationBox).value { + if let updatedMe = try? await APIService.shared.authenticatedUserInfo(authenticationBox: viewModel.authenticationBox) { viewModel.me = updatedMe viewModel.account = updatedMe - FileManager.default.store(account: updatedMe, forUserID: viewModel.authenticationBox.authentication.userIdentifier()) } viewModel.isUpdating = false diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index dad74376f..e149ef048 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -181,7 +181,6 @@ class ProfileViewModel: NSObject { let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) return APIService.shared.accountVerifyCredentials(domain: domain, authorization: authorization) .tryMap { response in - FileManager.default.store(account: response.value, forUserID: mastodonAuthentication.userIdentifier()) return response }.eraseToAnyPublisher() } @@ -233,8 +232,6 @@ extension ProfileViewModel { query: query, authorization: authorization ) - - FileManager.default.store(account: response.value, forUserID: authenticationBox.authentication.userIdentifier()) NotificationCenter.default.post(name: .userFetched, object: nil) return response diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 24f089b36..c094d1e9e 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -86,7 +86,7 @@ class MainTabBarController: UITabBarController { homeTimelineViewController.viewModel = HomeTimelineViewModel(context: context, authenticationBox: authenticationBox) searchViewController.viewModel = SearchViewModel(context: context, authenticationBox: authenticationBox) - if let account = authenticationBox.authentication.account() { + if let account = authenticationBox.cachedAccount { meProfileViewController.viewModel = ProfileViewModel(context: context, authenticationBox: authenticationBox, account: account, relationship: nil, me: account) } } @@ -112,7 +112,7 @@ extension MainTabBarController { willSet { if let profileView = (newValue as? UINavigationController)?.topViewController as? ProfileViewController{ guard let authenticationBox, - let account = authenticationBox.authentication.account() else { return } + let account = authenticationBox.cachedAccount else { return } profileView.viewModel = ProfileViewModel(context: self.context, authenticationBox: authenticationBox, account: account, relationship: nil, me: account) } } @@ -204,7 +204,7 @@ extension MainTabBarController { .sink { [weak self] _ in guard let self, let authenticationBox, - let account = authenticationBox.authentication.account() else { return } + let account = authenticationBox.cachedAccount else { return } self.avatarURL = account.avatarImageURL() @@ -381,7 +381,6 @@ extension MainTabBarController { Task { @MainActor in let profileResponse = try await APIService.shared.authenticatedUserInfo(authenticationBox: authenticationBox) - FileManager.default.store(account: profileResponse.value, forUserID: authenticationBox.authentication.userIdentifier()) } } } diff --git a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift index c7fd375be..48681981f 100644 --- a/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift +++ b/Mastodon/Scene/Root/Sidebar/SidebarViewModel.swift @@ -76,7 +76,7 @@ extension SidebarViewModel { let imageURL: URL? switch item { case .me: - let account = self.authenticationBox?.authentication.account() + let account = self.authenticationBox?.authentication.cachedAccount() imageURL = account?.avatarImageURL() case .home, .search, .compose, .notifications: // no custom avatar for other tabs @@ -134,7 +134,7 @@ extension SidebarViewModel { } .store(in: &cell.disposeBag) case .me: - guard let account = self.authenticationBox?.authentication.account() else { return } + guard let account = self.authenticationBox?.authentication.cachedAccount() else { return } let currentUserDisplayName = account.displayNameWithFallback cell.accessibilityHint = L10n.Scene.AccountList.tabBarHint(currentUserDisplayName) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift index aa681157b..9d7a27a5e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultSection.swift @@ -44,7 +44,7 @@ extension SearchResultSection { case .account(let account, let relationship): let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as! UserTableViewCell - guard let me = authenticationBox.authentication.account() else { return cell } + guard let me = authenticationBox.cachedAccount else { return cell } cell.userView.setButtonState(.loading) cell.configure( diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index b84bcecc7..91d01b640 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -139,7 +139,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { switch (profile, statusID) { case (profile, nil): Task { - guard let me = authenticationBox.authentication.account() else { return } + guard let me = authenticationBox.cachedAccount else { return } guard let account = try await APIService.shared.search( query: .init(q: incomingURL.absoluteString, type: .accounts, resolve: true), @@ -208,9 +208,8 @@ extension SceneDelegate { return false } - let _isActive = try? await AuthenticationServiceProvider.shared.activeMastodonUser( - domain: authentication.domain, - userID: authentication.userID + let _isActive = AuthenticationServiceProvider.shared.activateUser(authentication.userID, + inDomain: authentication.domain ) guard _isActive == true else { @@ -283,7 +282,7 @@ extension SceneDelegate { Task { do { - guard let me = authenticationBox.authentication.account() else { return } + guard let me = authenticationBox.cachedAccount else { return } guard let account = try await APIService.shared.search( query: .init(q: components[1], type: .accounts, resolve: true), diff --git a/MastodonIntent/Model/Account+Fetch.swift b/MastodonIntent/Model/Account+Fetch.swift index fa90d9a85..ab16e1ffc 100644 --- a/MastodonIntent/Model/Account+Fetch.swift +++ b/MastodonIntent/Model/Account+Fetch.swift @@ -16,7 +16,7 @@ extension Account { @MainActor static func fetch() async throws -> [Account] { let accounts = AuthenticationServiceProvider.shared.authentications.compactMap { mastodonAuthentication -> Account? in - guard let authenticatedAccount = mastodonAuthentication.account() else { + guard let authenticatedAccount = mastodonAuthentication.cachedAccount() else { return nil } let account = Account( diff --git a/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift b/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift index 1a6555d81..d27511bb7 100644 --- a/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift +++ b/MastodonSDK/Sources/MastodonCore/Authentication/MastodonAuthenticationBox.swift @@ -30,6 +30,11 @@ public struct MastodonAuthenticationBox: UserIdentifier { public init(authentication: MastodonAuthentication) { self.authentication = authentication } + + @MainActor + public var cachedAccount: Mastodon.Entity.Account? { + return authentication.cachedAccount() + } } public class MastodonAccountInMemoryCache { diff --git a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift index f0075eb3c..e22f12295 100644 --- a/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift +++ b/MastodonSDK/Sources/MastodonCore/AuthenticationServiceProvider.swift @@ -101,13 +101,16 @@ public class AuthenticationServiceProvider: ObservableObject { authentications.removeAll(where: { $0 == authentication }) } - func activateAuthentication(in domain: String, for userID: String) { + public func activateUser(_ userID: String, inDomain domain: String) -> Bool { + var found = false authentications = authentications.map { authentication in guard authentication.domain == domain, authentication.userID == userID else { return authentication } + found = true return authentication.updating(activatedAt: Date()) } + return found } func getAuthentication(in domain: String, for userID: String) -> MastodonAuthentication? { @@ -141,16 +144,6 @@ public extension AuthenticationServiceProvider { } } - func activeMastodonUser(domain: String, userID: String) async throws -> Bool { - var isActive = false - - AuthenticationServiceProvider.shared.activateAuthentication(in: domain, for: userID) - - isActive = true - - return isActive - } - func signOutMastodonUser(authentication: MastodonAuthentication) async throws { try await AuthenticationServiceProvider.shared.delete(authentication: authentication) _ = try await APIService.shared.cancelSubscription(domain: authentication.domain, authorization: authentication.authorization) @@ -230,11 +223,7 @@ public extension AuthenticationServiceProvider { // it when we need it to display on the home timeline. // We need this (also) for the Account-list, but it might be the wrong place. App Startup might be more appropriate for authentication in authentications { - guard let account = try? await APIService.shared.accountInfo(domain: authentication.domain, - userID: authentication.userID, - authorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)).value else { continue } - - FileManager.default.store(account: account, forUserID: authentication.userIdentifier()) + guard let account = try? await APIService.shared.accountInfo(MastodonAuthenticationBox(authentication: authentication)) else { continue } } NotificationCenter.default.post(name: .userFetched, object: nil) diff --git a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift index fb98263cb..4b2abcc45 100644 --- a/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift +++ b/MastodonSDK/Sources/MastodonCore/MastodonAuthentication.swift @@ -136,14 +136,9 @@ public struct MastodonAuthentication: Codable, Hashable, UserIdentifier { ) } - public func account() -> Mastodon.Entity.Account? { - - let account = FileManager - .default - .accounts(for: self.userIdentifier()) - .first(where: { $0.id == userID }) - - return account + @MainActor + public func cachedAccount() -> Mastodon.Entity.Account? { + return PersistenceManager.shared.cachedAccount(for: self) } public func userIdentifier() -> MastodonUserIdentifier { diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift b/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift deleted file mode 100644 index 2da26738f..000000000 --- a/MastodonSDK/Sources/MastodonCore/Persistence/FileManager+Account.swift +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright © 2023 Mastodon gGmbH. All rights reserved. - -import Foundation -import MastodonSDK - -public extension FileManager { - func store(account: Mastodon.Entity.Account, forUserID userID: UserIdentifier) { - var accounts = accounts(for: userID) - - if let index = accounts.firstIndex(of: account) { - accounts.remove(at: index) - } - - accounts.append(account) - - storeJSON(accounts, userID: userID) - } - - func accounts(for userId: UserIdentifier) -> [Mastodon.Entity.Account] { - guard let sharedDirectory else { assert(false); return [] } - - let accountPath = Persistence.accounts(userId).filepath(baseURL: sharedDirectory) - - guard let data = try? Data(contentsOf: accountPath) else { return [] } - - let jsonDecoder = JSONDecoder() - jsonDecoder.dateDecodingStrategy = .iso8601 - - do { - let accounts = try jsonDecoder.decode([Mastodon.Entity.Account].self, from: data) - assert(accounts.count > 0) - return accounts - } catch { - return [] - } - - } -} - -private extension FileManager { - private func storeJSON(_ encodable: Encodable, userID: UserIdentifier) { - guard let sharedDirectory else { return } - - let jsonEncoder = JSONEncoder() - jsonEncoder.dateEncodingStrategy = .iso8601 - do { - let data = try jsonEncoder.encode(encodable) - - let accountsPath = Persistence.accounts( userID).filepath(baseURL: sharedDirectory) - try data.write(to: accountsPath) - } catch { - debugPrint(error.localizedDescription) - } - - } - -} diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/PersistenceManager.swift b/MastodonSDK/Sources/MastodonCore/Persistence/PersistenceManager.swift index 33a808930..1eb6557dc 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/PersistenceManager.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/PersistenceManager.swift @@ -7,6 +7,7 @@ import Combine import CoreData import CoreDataStack +import MastodonSDK @MainActor public class PersistenceManager { @@ -37,7 +38,125 @@ public class PersistenceManager { .store(in: &disposeBag) } - func newTaskContext() -> NSManagedObjectContext { + public func newTaskContext() -> NSManagedObjectContext { return coreDataStack.newTaskContext() } + + public func cachedTimeline(_ timeline: Persistence) throws -> [MastodonStatus] { + return try FileManager.default.cached(timeline: timeline).map(MastodonStatus.fromEntity) + } + + public func cachedAccount(for authentication: MastodonAuthentication) -> Mastodon.Entity.Account? { + let account = FileManager + .default + .accounts(for: authentication.userIdentifier()) + .first(where: { $0.id == authentication.userID }) + return account + } + + public func cacheAccount(_ account: Mastodon.Entity.Account, for authenticationBox: MastodonAuthenticationBox) { + FileManager.default.store(account: account, forUserID: authenticationBox.authentication.userIdentifier()) + } +} + +private extension FileManager { + static let cacheItemsLimit: Int = 100 // max number of items to cache + + func cached(timeline: Persistence) throws -> [T] { + guard let cachesDirectory else { return [] } + + let filePath = timeline.filepath(baseURL: cachesDirectory) + + guard let data = try? Data(contentsOf: filePath) else { return [] } + + do { + let items = try JSONDecoder().decode([T].self, from: data) + + return items + } catch { + return [] + } + } + + + func cache(_ items: [T], timeline: Persistence) { + guard let cachesDirectory else { return } + + let processableItems: [T] + if items.count > Self.cacheItemsLimit { + processableItems = items.dropLast(items.count - Self.cacheItemsLimit) + } else { + processableItems = items + } + + do { + let data = try JSONEncoder().encode(processableItems) + + let filePath = timeline.filepath(baseURL: cachesDirectory) + try data.write(to: filePath) + } catch { + debugPrint(error.localizedDescription) + } + } + + func invalidate(timeline: Persistence) { + guard let cachesDirectory else { return } + + let filePath = timeline.filepath(baseURL: cachesDirectory) + + try? removeItem(at: filePath) + } +} + +private extension FileManager { + func store(account: Mastodon.Entity.Account, forUserID userID: UserIdentifier) { + var accounts = accounts(for: userID) + + if let index = accounts.firstIndex(of: account) { + accounts.remove(at: index) + } + + accounts.append(account) + + storeJSON(accounts, userID: userID) + } + + func accounts(for userId: UserIdentifier) -> [Mastodon.Entity.Account] { + guard let sharedDirectory else { assert(false); return [] } + + let accountPath = Persistence.accounts(userId).filepath(baseURL: sharedDirectory) + + guard let data = try? Data(contentsOf: accountPath) else { return [] } + + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .iso8601 + + do { + let accounts = try jsonDecoder.decode([Mastodon.Entity.Account].self, from: data) + assert(accounts.count > 0) + return accounts + } catch { + return [] + } + + } +} + +private extension FileManager { + private func storeJSON(_ encodable: Encodable, userID: UserIdentifier) { + guard let sharedDirectory else { return } + + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .iso8601 + do { + let data = try jsonEncoder.encode(encodable) + + let accountsPath = Persistence.accounts( userID).filepath(baseURL: sharedDirectory) + try data.write(to: accountsPath) + } catch { + debugPrint(error.localizedDescription) + } + + } + } diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift index eb30609c9..651b1a37f 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService+Account.swift @@ -14,27 +14,23 @@ import MastodonSDK extension APIService { public func authenticatedUserInfo( authenticationBox: MastodonAuthenticationBox - ) async throws -> Mastodon.Response.Content { - try await accountInfo( + ) async throws -> Mastodon.Entity.Account { + let authenticated = try await accountInfo(authenticationBox) + return authenticated + } + + public func accountInfo(_ authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Entity.Account { + let account = try await Mastodon.API.Account.accountInfo( + session: session, domain: authenticationBox.domain, userID: authenticationBox.userID, authorization: authenticationBox.userAuthorization - ) - } - - public func accountInfo( - domain: String, - userID: Mastodon.Entity.Account.ID, - authorization: Mastodon.API.OAuth.Authorization - ) async throws -> Mastodon.Response.Content { - let response = try await Mastodon.API.Account.accountInfo( - session: session, - domain: domain, - userID: userID, - authorization: authorization - ).singleOutput() + ).singleOutput().value - return response + PersistenceManager.shared.cacheAccount(account, for: authenticationBox) + + return account } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift index f940c3d9d..e3abb468f 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/Notification/NotificationService.swift @@ -94,7 +94,7 @@ extension NotificationService { var items: [UIApplicationShortcutItem] = [] for authentication in AuthenticationServiceProvider.shared.authentications { - guard let account = authentication.account() else { continue } + guard let account = authentication.cachedAccount() else { continue } let accessToken = authentication.userAccessToken let count = UserDefaults.shared.getNotificationCountWithAccessToken(accessToken: accessToken) guard count > 0 else { continue } diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 4b9d6dd76..f993c5eff 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -156,7 +156,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.visibility = { // default private when user locked var visibility: Mastodon.Entity.Status.Visibility = { - guard let author = authenticationBox.authentication.account() else { + guard let author = authenticationBox.cachedAccount else { return .public } return author.locked ? .private : .public @@ -195,7 +195,7 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { switch destination { case .reply(let record): let status = record.entity - let author = authenticationBox.authentication.account() + let author = authenticationBox.cachedAccount var mentionAccts: [String] = [] if author?.id != status.account.id { @@ -306,7 +306,7 @@ extension ComposeContentViewModel { // bind author $authenticationBox .sink { [weak self] authenticationBox in - guard let self, let account = authenticationBox.authentication.account() else { return } + guard let self, let account = authenticationBox.cachedAccount else { return } self.avatarURL = account.avatarImageURL() @@ -575,7 +575,7 @@ extension ComposeContentViewModel { public func statusPublisher() throws -> StatusPublisher { - guard authenticationBox.authentication.account() != nil else { + guard authenticationBox.cachedAccount != nil else { throw AppError.badAuthentication } @@ -624,7 +624,7 @@ extension ComposeContentViewModel { guard case let .editStatus(status, _) = composeContext else { return nil } // author - guard let author = authenticationBox.authentication.account() else { + guard let author = authenticationBox.cachedAccount else { throw AppError.badAuthentication } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index ce9a7d01f..df5cc2681 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -88,7 +88,7 @@ extension StatusView { private func configureHeader(status: MastodonStatus) { if status.entity.reblogged == true, let authenticationBox = viewModel.authenticationBox, - let account = authenticationBox.authentication.account() { + let account = authenticationBox.cachedAccount { let name = account.displayNameWithFallback let emojis = account.emojis diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift index 3caf21737..a87c9b71a 100644 --- a/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift @@ -71,12 +71,12 @@ struct FollowersCountWidget: Widget { private extension FollowersCountWidgetProvider { func loadCurrentEntry(for configuration: FollowersCountIntent, in context: Context, completion: @escaping (FollowersCountEntry) -> Void) { - Task { + Task { @MainActor in - await AuthenticationServiceProvider.shared.prepareForUse() + AuthenticationServiceProvider.shared.prepareForUse() guard - let authBox = await AuthenticationServiceProvider.shared.activeAuthentication + let authBox = AuthenticationServiceProvider.shared.activeAuthentication else { guard !context.isPreview else { return completion(.placeholder) @@ -85,7 +85,7 @@ private extension FollowersCountWidgetProvider { } guard - let desiredAccount = configuration.account ?? authBox.authentication.account()?.acctWithDomain + let desiredAccount = configuration.account ?? authBox.cachedAccount?.acctWithDomain else { return completion(.unconfigured) } diff --git a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift index 635f9f8ad..08e864947 100644 --- a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift +++ b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift @@ -71,12 +71,12 @@ struct MultiFollowersCountWidget: Widget { private extension MultiFollowersCountWidgetProvider { func loadCurrentEntry(for configuration: MultiFollowersCountIntent, in context: Context, completion: @escaping (MultiFollowersCountEntry) -> Void) { - Task { + Task { @MainActor in - await AuthenticationServiceProvider.shared.prepareForUse() + AuthenticationServiceProvider.shared.prepareForUse() guard - let authBox = await AuthenticationServiceProvider.shared.activeAuthentication + let authBox = AuthenticationServiceProvider.shared.activeAuthentication else { guard !context.isPreview else { return completion(.placeholder) @@ -88,7 +88,7 @@ private extension MultiFollowersCountWidgetProvider { if let configuredAccounts = configuration.accounts?.compactMap({ $0 }) { desiredAccounts = configuredAccounts - } else if let currentlyLoggedInAccount = authBox.authentication.account()?.acctWithDomain { + } else if let currentlyLoggedInAccount = authBox.cachedAccount?.acctWithDomain { desiredAccounts = [currentlyLoggedInAccount] } else { return completion(.unconfigured)