Tusker/Tusker/API/MastodonController.swift

622 lines
22 KiB
Swift

//
// 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
#if canImport(Sentry)
import Sentry
#endif
import ComposeUI
import OSLog
private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
final class MastodonController: ObservableObject, Sendable {
@MainActor
static private(set) var all = [UserAccountInfo: MastodonController]()
@MainActor
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account] {
return controller
} else {
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
all[account] = controller
return controller
}
}
@MainActor
static func removeForAccount(_ account: UserAccountInfo) {
all.removeValue(forKey: account)
}
@MainActor
static func resetAll() {
all = [:]
}
private let transient: Bool
nonisolated let persistentContainer: MastodonCachePersistentStore// = MastodonCachePersistentStore(for: accountInfo, transient: transient)
let instanceURL: URL
let accountInfo: UserAccountInfo?
@MainActor
private(set) var accountPreferences: AccountPreferences!
private(set) var client: Client!
let instanceFeatures = InstanceFeatures()
@MainActor @Published private(set) var account: AccountMO?
@MainActor @Published private(set) var instance: InstanceV1?
@MainActor @Published private var instanceV2: InstanceV2?
@MainActor @Published private(set) var instanceInfo: InstanceInfo!
@MainActor @Published private(set) var nodeInfo: NodeInfo!
@MainActor @Published private(set) var lists: [List] = []
@MainActor @Published private(set) var customEmojis: [Emoji]?
@MainActor @Published private(set) var followedHashtags: [FollowedHashtag] = []
@MainActor @Published private(set) var filters: [FilterMO] = []
private var cancellables = Set<AnyCancellable>()
@MainActor
private var fetchOwnInstanceTask: Task<InstanceV1, any Error>?
@MainActor
private var fetchOwnAccountTask: Task<MainThreadBox<AccountMO>, any Error>?
@MainActor
private var fetchCustomEmojisTask: Task<[Emoji], Never>?
var loggedIn: Bool {
accountInfo != nil
}
// main-actor b/c fetchActiveAccountID and fetchActiveInstance use the viewContext
@MainActor
init(instanceURL: URL, accountInfo: UserAccountInfo?) {
self.instanceURL = instanceURL
self.accountInfo = accountInfo
self.client = Client(baseURL: instanceURL, session: .appDefault)
self.transient = accountInfo == nil
self.persistentContainer = MastodonCachePersistentStore(for: accountInfo, transient: transient)
self.client.clientID = accountInfo?.clientID
self.client.clientSecret = accountInfo?.clientSecret
self.client.accessToken = accountInfo?.accessToken
if !transient {
fetchActiveAccount()
fetchActiveInstance()
}
$instanceInfo
.compactMap { $0 }
.combineLatest($nodeInfo)
.sink { [unowned self] (instance, nodeInfo) in
self.instanceFeatures.update(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
#if canImport(Sentry)
$instanceInfo
.compactMap { $0 }
.removeDuplicates(by: { $0.version == $1.version })
.combineLatest($nodeInfo.removeDuplicates())
.sink { (instance, nodeInfo) in
setInstanceBreadcrumb(instance: instance, nodeInfo: nodeInfo)
}
.store(in: &cancellables)
#endif
$instance
.compactMap { $0 }
.combineLatest($instanceV2)
.sink {[unowned self] (instance, v2) in
var info = InstanceInfo(v1: instance)
if let v2 {
info.update(v2: v2)
}
self.instanceInfo = info
self.updateActiveInstance(from: info)
}
.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)
}
@MainActor
convenience init(instanceURL: URL, transient: Bool) {
precondition(transient, "account info must be provided if transient is false")
self.init(instanceURL: instanceURL, accountInfo: nil)
}
@discardableResult
func run<Result>(_ request: Request<Result>, completion: @escaping Client.Callback<Result>) -> URLSessionTask? {
return client.run(request, completion: completion)
}
func runResponse<Result>(_ request: Request<Result>) async -> Response<Result> {
let response = await withCheckedContinuation({ continuation in
client.run(request) { response in
continuation.resume(returning: response)
}
})
return response
}
func run<Result: Sendable>(_ request: Request<Result>) 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: oauthScopes) { 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", scopes: oauthScopes) { 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() {
precondition(!transient, "Cannot initialize transient MastodonController")
// 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 {
_ = try await getOwnAccount()
} catch {
logger.error("Fetch own account failed: \(String(describing: error))")
}
}
let instanceTask = Task {
do {
_ = try await getOwnInstance()
if instanceFeatures.hasMastodonVersion(4, 0, 0) {
_ = try? await getOwnInstanceV2()
}
} catch {
logger.error("Fetch instance failed: \(String(describing: error))")
}
}
Task {
_ = await instanceTask.value
await loadLists()
}
Task {
_ = await instanceTask.value
_ = await loadFilters()
}
Task {
_ = await instanceTask.value
await loadServerPreferences()
}
}
@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)
}
}
@MainActor
func getOwnAccount() async throws -> AccountMO {
if let account {
return account
} else if let fetchOwnAccountTask {
return try await fetchOwnAccountTask.value.value
} else {
let task = Task {
let account = try await run(Client.getSelfAccount()).0
let context = persistentContainer.viewContext
// this closure is declared separately so we can tell the compiler it's Sendable
let performBlock: @MainActor @Sendable () -> MainThreadBox<AccountMO> = {
let accountMO: AccountMO
if let existing = self.persistentContainer.account(for: account.id, in: context) {
accountMO = existing
existing.updateFrom(apiAccount: account, container: self.persistentContainer)
} else {
accountMO = self.persistentContainer.addOrUpdateSynchronously(account: account, in: context)
}
// TODO: is AccountMO.active used anywhere?
accountMO.active = true
self.account = accountMO
return MainThreadBox(value: accountMO)
}
// it's safe to remove the MainActor annotation, because this is the view context
return await context.perform(unsafeBitCast(performBlock, to: (@Sendable () -> MainThreadBox<AccountMO>).self))
}
fetchOwnAccountTask = task
return try await task.value.value
}
}
func getOwnInstance(completion: (@Sendable (InstanceV1) -> Void)? = nil) {
Task.detached {
let ownInstance = try? await self.getOwnInstance()
if let ownInstance {
completion?(ownInstance)
}
}
}
@MainActor
func getOwnInstance() async throws -> InstanceV1 {
if let instance {
return instance
} else if let fetchOwnInstanceTask {
return try await fetchOwnInstanceTask.value
} else {
let task = Task {
let instance = try await retrying("Fetch Own Instance") {
try await run(Client.getInstanceV1()).0
}
self.instance = instance
Task {
await fetchNodeInfo()
}
return instance
}
fetchOwnInstanceTask = task
return try await task.value
}
}
@MainActor
private func fetchNodeInfo() async {
if let nodeInfo = try? await client.nodeInfo() {
self.nodeInfo = nodeInfo
}
}
private func retrying<T: Sendable>(_ label: StaticString, action: @Sendable () async throws -> T) async throws -> T {
for attempt in 0..<4 {
do {
return try await action()
} catch {
let seconds = UInt64(truncating: pow(2, attempt) as NSNumber)
logger.error("\(label, privacy: .public) failed, waiting \(seconds, privacy: .public)s before retrying. Reason: \(String(describing: error))")
try! await Task.sleep(nanoseconds: seconds * NSEC_PER_SEC)
}
}
return try await action()
}
@MainActor
private func getOwnInstanceV2() async throws {
self.instanceV2 = try await client.run(Client.getInstanceV2()).0
}
// MainActor because the accountPreferences instance is bound to the view context
@MainActor
private func loadServerPreferences() async {
guard instanceFeatures.hasServerPreferences,
let (prefs, _) = try? await run(Client.getPreferences()) else {
return
}
accountPreferences!.serverDefaultLanguage = prefs.postingDefaultLanguage
accountPreferences!.serverDefaultVisibility = prefs.postingDefaultVisibility
accountPreferences!.serverDefaultFederation = prefs.postingDefaultFederation ?? true
if let accountInfo {
UserAccountsManager.shared.updateServerPreferences(accountInfo, defaultLanguage: prefs.postingDefaultLanguage, defaultVisibility: prefs.postingDefaultVisibility.rawValue, defaultFederation: prefs.postingDefaultFederation)
}
}
private func updateActiveInstance(from info: InstanceInfo) {
persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first {
existing.update(from: info)
} else {
let new = ActiveInstance(context: context)
new.update(from: info)
}
if context.hasChanges {
try? context.save()
}
}
}
@MainActor
private func fetchActiveAccount() {
let req = AccountMO.fetchRequest()
req.predicate = NSPredicate(format: "active = YES")
if let activeAccount = try? persistentContainer.viewContext.fetch(req).first {
account = activeAccount
}
}
@MainActor
private func fetchActiveInstance() {
if let activeInstance = try? persistentContainer.viewContext.fetch(ActiveInstance.fetchRequest()).first {
let info = InstanceInfo(activeInstance: activeInstance)
self.instanceInfo = info
}
}
@MainActor
func getCustomEmojis() async -> [Emoji] {
if let customEmojis {
return customEmojis
} else if let fetchCustomEmojisTask {
return await fetchCustomEmojisTask.value
} else {
let task = Task {
let emojis = (try? await run(Client.getCustomEmoji()).0) ?? []
customEmojis = emojis
return emojis
}
fetchCustomEmojisTask = task
return await task.value
}
}
private func loadLists() async {
let req = Client.getLists()
guard let (lists, _) = try? await run(req) else {
return
}
let sorted = lists.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
await MainActor.run {
self.lists = sorted
}
let context = persistentContainer.backgroundContext
await 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(\.apiList).sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
}
func getCachedList(id: String) -> List? {
let req = ListMO.fetchRequest(id: id)
return (try? persistentContainer.viewContext.fetch(req).first).map(\.apiList)
}
@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 updatedList(_ 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())) ?? []
}
@MainActor
func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
var acctsToMention = [String]()
var visibility = if inReplyToID != nil {
Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
} else {
Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility)
}
var localOnly = instanceFeatures.localOnlyPosts && !accountPreferences!.serverDefaultFederation
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()
return DraftsPersistentContainer.shared.createDraft(
accountID: accountInfo!.id,
text: text ?? acctsToMention.map { "@\($0) " }.joined(),
contentWarning: contentWarning,
inReplyToID: inReplyToID,
visibility: visibility,
language: accountPreferences!.serverDefaultLanguage,
localOnly: localOnly
)
}
@MainActor
func createDraft(editing status: StatusMO, source: StatusSource) -> Draft {
precondition(status.id == source.id)
let draft = DraftsPersistentContainer.shared.createEditDraft(
accountID: accountInfo!.id,
source: source,
inReplyToID: status.inReplyToID,
visibility: status.visibility,
localOnly: status.localOnly,
attachments: status.attachments,
poll: status.poll
)
return draft
}
}
#if canImport(Sentry)
private func setInstanceBreadcrumb(instance: InstanceInfo, 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)
}
#endif