// // MastodonController.swift // Tusker // // Created by Shadowfacts on 8/15/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import Foundation import Pachyderm import Combine import UserAccounts import InstanceFeatures import Sentry import ComposeUI class MastodonController: ObservableObject { static private(set) var all = [UserAccountInfo: MastodonController]() @available(*, message: "do something less dumb") static var first: MastodonController { all.first!.value } static func getForAccount(_ account: UserAccountInfo) -> MastodonController { if let controller = all[account] { return controller } else { let controller = MastodonController(instanceURL: account.instanceURL) controller.accountInfo = account controller.client.clientID = account.clientID controller.client.clientSecret = account.clientSecret controller.client.accessToken = account.accessToken all[account] = controller return controller } } static func removeForAccount(_ account: UserAccountInfo) { all.removeValue(forKey: account) } static func resetAll() { all = [:] } private let transient: Bool private(set) nonisolated lazy var persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient) let instanceURL: URL var accountInfo: UserAccountInfo? var accountPreferences: AccountPreferences! let client: Client! let instanceFeatures = InstanceFeatures() @Published private(set) var account: Account! @Published private(set) var instance: Instance! @Published private(set) var nodeInfo: NodeInfo! @Published private(set) var lists: [List] = [] @Published private(set) var customEmojis: [Emoji]? @Published private(set) var followedHashtags: [FollowedHashtag] = [] @Published private(set) var filters: [FilterMO] = [] private var cancellables = Set() private var pendingOwnInstanceRequestCallbacks = [(Result) -> Void]() private var ownInstanceRequest: URLSessionTask? var loggedIn: Bool { accountInfo != nil } init(instanceURL: URL, transient: Bool = false) { self.instanceURL = instanceURL self.accountInfo = nil self.client = Client(baseURL: instanceURL, session: .appDefault) self.transient = transient $instance .combineLatest($nodeInfo) .compactMap { (instance, nodeInfo) in if let instance { return (instance, nodeInfo) } else { return nil } } .sink { [unowned self] (instance, nodeInfo) in self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo) setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo) } .store(in: &cancellables) instanceFeatures.featuresUpdated .filter { [unowned self] _ in self.instanceFeatures.canFollowHashtags && self.followedHashtags.isEmpty } .sink { [unowned self] _ in Task { await self.loadFollowedHashtags() } } .store(in: &cancellables) } @discardableResult func run(_ request: Request, completion: @escaping Client.Callback) -> URLSessionTask? { return client.run(request, completion: completion) } func runResponse(_ request: Request) async -> Response { let response = await withCheckedContinuation({ continuation in client.run(request) { response in continuation.resume(returning: response) } }) return response } func run(_ request: Request) async throws -> (Result, Pagination?) { let response = await runResponse(request) try Task.checkCancellation() switch response { case .failure(let error): throw error case .success(let result, let pagination): return (result, pagination) } } /// - Returns: A tuple of client ID and client secret. func registerApp() async throws -> (String, String) { if let clientID = client.clientID, let clientSecret = client.clientSecret { return (clientID, clientSecret) } else { let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: [.read, .write, .follow]) { response in switch response { case .failure(let error): continuation.resume(throwing: error) case .success(let app, _): continuation.resume(returning: app) } } }) self.client.clientID = app.clientID self.client.clientSecret = app.clientSecret return (app.clientID, app.clientSecret) } } /// - Returns: The access token func authorize(authorizationCode: String) async throws -> String { return try await withCheckedThrowingContinuation({ continuation in client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth") { response in switch response { case .failure(let error): continuation.resume(throwing: error) case .success(let settings, _): self.client.accessToken = settings.accessToken continuation.resume(returning: settings.accessToken) } } }) } @MainActor func initialize() { // we want this to happen immediately, and synchronously so that the filters (which don't change that often) // are available when Filterers are constructed loadCachedFilters() loadAccountPreferences() lists = loadCachedLists() NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator) .receive(on: DispatchQueue.main) .sink { [unowned self] _ in self.loadAccountPreferences() } .store(in: &cancellables) Task { do { async let ownAccount = try getOwnAccount() async let ownInstance = try getOwnInstance() _ = try await (ownAccount, ownInstance) loadLists() _ = await loadFilters() } catch { Logging.general.error("MastodonController initialization failed: \(String(describing: error))") } } } @MainActor private func loadAccountPreferences() { if let existing = try? persistentContainer.viewContext.fetch(AccountPreferences.fetchRequest(account: accountInfo!)).first { accountPreferences = existing } else { accountPreferences = AccountPreferences.default(account: accountInfo!, context: persistentContainer.viewContext) persistentContainer.save(context: persistentContainer.viewContext) } } func getOwnAccount(completion: ((Result) -> Void)? = nil) { if account != nil { completion?(.success(account)) } else { let request = Client.getSelfAccount() run(request) { response in switch response { case let .failure(error): completion?(.failure(error)) case let .success(account, _): DispatchQueue.main.async { self.account = account } self.persistentContainer.backgroundContext.perform { if let accountMO = self.persistentContainer.account(for: account.id, in: self.persistentContainer.backgroundContext) { accountMO.updateFrom(apiAccount: account, container: self.persistentContainer) } else { // the first time the user's account is added to the store, // increment its reference count so that it's never removed self.persistentContainer.addOrUpdate(account: account) } completion?(.success(account)) } } } } } func getOwnAccount() async throws -> Account { if let account = account { return account } else { return try await withCheckedThrowingContinuation({ continuation in self.getOwnAccount { result in continuation.resume(with: result) } }) } } func getOwnInstance(completion: ((Instance) -> Void)? = nil) { getOwnInstanceInternal(retryAttempt: 0) { if case let .success(instance) = $0 { completion?(instance) } } } @MainActor func getOwnInstance() async throws -> Instance { return try await withCheckedThrowingContinuation({ continuation in getOwnInstanceInternal(retryAttempt: 0) { result in continuation.resume(with: result) } }) } private func getOwnInstanceInternal(retryAttempt: Int, completion: ((Result) -> Void)?) { // this is main thread only to prevent concurrent access to ownInstanceRequest and pendingOwnInstanceRequestCallbacks assert(Thread.isMainThread) if let instance = self.instance { completion?(.success(instance)) } else { if let completion = completion { pendingOwnInstanceRequestCallbacks.append(completion) } if ownInstanceRequest == nil { let request = Client.getInstance() ownInstanceRequest = run(request) { (response) in switch response { case .failure(let error): let delay: DispatchTimeInterval switch retryAttempt { case 0: delay = .seconds(1) case 1: delay = .seconds(5) case 2: delay = .seconds(30) case 3: delay = .seconds(60) default: // if we've failed four times, just give up :/ for completion in self.pendingOwnInstanceRequestCallbacks { completion(.failure(error)) } self.pendingOwnInstanceRequestCallbacks = [] return } DispatchQueue.main.asyncAfter(deadline: .now() + delay) { // completion is nil because in this invocation of getOwnInstanceInternal we've already added it to the pending callbacks array self.getOwnInstanceInternal(retryAttempt: retryAttempt + 1, completion: nil) } case let .success(instance, _): DispatchQueue.main.async { self.ownInstanceRequest = nil self.instance = instance for completion in self.pendingOwnInstanceRequestCallbacks { completion(.success(instance)) } self.pendingOwnInstanceRequestCallbacks = [] } } } client.nodeInfo { result in switch result { case let .failure(error): print("Unable to get node info: \(error)") case let .success(nodeInfo, _): DispatchQueue.main.async { self.nodeInfo = nodeInfo } } } } } } func getCustomEmojis(completion: @escaping ([Emoji]) -> Void) { if let emojis = self.customEmojis { completion(emojis) } else { let request = Client.getCustomEmoji() run(request) { (response) in if case let .success(emojis, _) = response { DispatchQueue.main.async { self.customEmojis = emojis } completion(emojis) } else { completion([]) } } } } private func loadLists() { let req = Client.getLists() run(req) { response in if case .success(let lists, _) = response { DispatchQueue.main.async { self.lists = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } let context = self.persistentContainer.backgroundContext context.perform { for list in lists { if let existing = try? context.fetch(ListMO.fetchRequest(id: list.id)).first { existing.updateFrom(apiList: list) } else { _ = ListMO(apiList: list, context: context) } } self.persistentContainer.save(context: context) } } } } private func loadCachedLists() -> [List] { let req = ListMO.fetchRequest() guard let lists = try? persistentContainer.viewContext.fetch(req) else { return [] } return lists.map { List(id: $0.id, title: $0.title) }.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title)) } func getCachedList(id: String) -> List? { let req = ListMO.fetchRequest(id: id) return (try? persistentContainer.viewContext.fetch(req).first).flatMap { List(id: $0.id, title: $0.title) } } @MainActor func addedList(_ list: List) { var new = self.lists new.append(list) new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @MainActor func deletedList(_ list: List) { self.lists.removeAll(where: { $0.id == list.id }) } @MainActor func renamedList(_ list: List) { var new = self.lists if let index = new.firstIndex(where: { $0.id == list.id }) { new[index] = list } new.sort(using: SemiCaseSensitiveComparator.keyPath(\.title)) self.lists = new } @MainActor private func loadFollowedHashtags() async { updateFollowedHashtags() let req = Client.getFollowedHashtags() if let (hashtags, _) = try? await run(req) { self.persistentContainer.updateFollowedHashtags(hashtags) { if case .success(let hashtags) = $0 { self.followedHashtags = hashtags } } } } @MainActor func updateFollowedHashtags() { followedHashtags = (try? persistentContainer.viewContext.fetch(FollowedHashtag.fetchRequest())) ?? [] } @MainActor func loadFilters() async { var apiFilters: [AnyFilter]? if instanceFeatures.filtersV2 { let req = Client.getFiltersV2() if let (filters, _) = try? await run(req) { apiFilters = filters.map { .v2($0) } } } else { let req = Client.getFiltersV1() if let (filters, _) = try? await run(req) { apiFilters = filters.map { .v1($0) } } } if let apiFilters { self.persistentContainer.updateFilters(apiFilters) { if case .success(let filters) = $0 { self.filters = filters } } } } @MainActor private func loadCachedFilters() { filters = (try? persistentContainer.viewContext.fetch(FilterMO.fetchRequest())) ?? [] } func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft { var acctsToMention = [String]() var visibility = inReplyToID != nil ? Preferences.shared.defaultReplyVisibility.resolved : Preferences.shared.defaultPostVisibility var localOnly = false var contentWarning = "" if let inReplyToID = inReplyToID, let inReplyTo = persistentContainer.status(for: inReplyToID) { acctsToMention.append(inReplyTo.account.acct) acctsToMention.append(contentsOf: inReplyTo.mentions.map(\.acct)) visibility = min(visibility, inReplyTo.visibility) localOnly = instanceFeatures.localOnlyPosts && inReplyTo.localOnly if !inReplyTo.spoilerText.isEmpty { switch Preferences.shared.contentWarningCopyMode { case .doNotCopy: break case .asIs: contentWarning = inReplyTo.spoilerText case .prependRe: if inReplyTo.spoilerText.lowercased().starts(with: "re:") { contentWarning = inReplyTo.spoilerText } else { contentWarning = "re: \(inReplyTo.spoilerText)" } } } } if let mentioningAcct = mentioningAcct { acctsToMention.append(mentioningAcct) } if let ownAccount = self.account { acctsToMention.removeAll(where: { $0 == ownAccount.acct }) } acctsToMention = acctsToMention.uniques() let draft = Draft( accountID: accountInfo!.id, text: text ?? acctsToMention.map { "@\($0) " }.joined(), contentWarning: contentWarning, inReplyToID: inReplyToID, visibility: visibility, localOnly: localOnly ) DraftsManager.shared.add(draft) return draft } } private func setInstanceBreadcrumb(instance: Instance, nodeInfo: NodeInfo?) { let crumb = Breadcrumb(level: .info, category: "MastodonController") crumb.data = [ "instance": [ "version": instance.version ], ] if let nodeInfo { crumb.data!["nodeInfo"] = [ "software": nodeInfo.software.name, "version": nodeInfo.software.version, ] } SentrySDK.addBreadcrumb(crumb) }