From 6e5e0c3bb55c320ac0ba5e310b0365be2bfba91d Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 27 Oct 2023 14:58:15 -0500 Subject: [PATCH] Use server preferences for default visibility and language Closes #282 --- .../CoreData/DraftsPersistentContainer.swift | 2 + .../InstanceFeatures/InstanceFeatures.swift | 4 + .../Pachyderm/Sources/Pachyderm/Client.swift | 18 ++++ .../Sources/Pachyderm/Model/Preferences.swift | 34 +++++++ .../TuskerPreferences/PostVisibility.swift | 97 +++++++++++++++++++ .../TuskerPreferences/Preferences.swift | 44 ++------- ShareExtension/ShareViewController.swift | 16 ++- Tusker/API/MastodonController.swift | 21 +++- Tusker/CoreData/AccountPreferences.swift | 11 +++ .../Tusker.xcdatamodel/contents | 6 +- .../Preferences/ComposingPrefsView.swift | 8 +- 11 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift diff --git a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift index c87ad5bf..a6c1342d 100644 --- a/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift +++ b/Packages/ComposeUI/Sources/ComposeUI/CoreData/DraftsPersistentContainer.swift @@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { contentWarning: String, inReplyToID: String?, visibility: Visibility, + language: String?, localOnly: Bool ) -> Draft { let draft = Draft(context: viewContext) @@ -92,6 +93,7 @@ public class DraftsPersistentContainer: NSPersistentContainer { draft.contentWarningEnabled = !contentWarning.isEmpty draft.inReplyToID = inReplyToID draft.visibility = visibility + draft.language = language draft.localOnly = localOnly save() return draft diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 0c2187af..9219f9aa 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -171,6 +171,10 @@ public class InstanceFeatures: ObservableObject { hasMastodonVersion(4, 2, 0) } + public var hasServerPreferences: Bool { + hasMastodonVersion(2, 8, 0) + } + public init() { } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Client.swift b/Packages/Pachyderm/Sources/Pachyderm/Client.swift index 88c9bf91..f4bed14c 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Client.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Client.swift @@ -105,6 +105,20 @@ public class Client { return task } + @discardableResult + public func run(_ request: Request) async throws -> (Result, Pagination?) { + return try await withCheckedThrowingContinuation { continuation in + run(request) { response in + switch response { + case .failure(let error): + continuation.resume(throwing: error) + case .success(let result, let pagination): + continuation.resume(returning: (result, pagination)) + } + } + } + } + func createURLRequest(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.endpoint.path @@ -225,6 +239,10 @@ public class Client { return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") } + public static func getPreferences() -> Request { + return Request(method: .get, path: "/api/v1/preferences") + } + // MARK: - Accounts public static func getAccount(id: String) -> Request { return Request(method: .get, path: "/api/v1/accounts/\(id)") diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift new file mode 100644 index 00000000..14e1e87d --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Preferences.swift @@ -0,0 +1,34 @@ +// +// Preferences.swift +// Pachyderm +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation + +public struct Preferences: Codable, Sendable { + public let postingDefaultVisibility: Visibility + public let postingDefaultSensitive: Bool + public let postingDefaultLanguage: String + public let readingExpandMedia: ExpandMedia + public let readingExpandSpoilers: Bool + public let readingAutoplayGifs: Bool + + enum CodingKeys: String, CodingKey { + case postingDefaultVisibility = "posting:default:visibility" + case postingDefaultSensitive = "posting:default:sensitive" + case postingDefaultLanguage = "posting:default:language" + case readingExpandMedia = "reading:expand:media" + case readingExpandSpoilers = "reading:expand:spoilers" + case readingAutoplayGifs = "reading:autoplay:gifs" + } +} + +extension Preferences { + public enum ExpandMedia: String, Codable, Sendable { + case `default` + case always = "show_all" + case never = "hide_all" + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift new file mode 100644 index 00000000..c0a7eb8d --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift @@ -0,0 +1,97 @@ +// +// PostVisibility.swift +// TuskerPreferences +// +// Created by Shadowfacts on 10/26/23. +// + +import Foundation +import Pachyderm + +public enum PostVisibility: Codable, Hashable, CaseIterable { + case serverDefault + case visibility(Visibility) + + public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .serverDefault: + // If the server doesn't have a default visibility preference, we fallback to public. + // This isn't ideal, but I don't want to add a separate preference for "Default Post Visibility Fallback" :/ + serverDefault ?? .public + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .serverDefault: + await serverDefault() ?? .public + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .serverDefault: + return "Account Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .serverDefault: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} + +public enum ReplyVisibility: Codable, Hashable, CaseIterable { + case sameAsPost + case visibility(Visibility) + + public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } + + public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { + switch self { + case .sameAsPost: + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public func resolved(withServerDefault serverDefault: () async -> Visibility?) async -> Visibility { + switch self { + case .sameAsPost: + await Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverDefault) + case .visibility(let vis): + vis + } + } + + public var displayName: String { + switch self { + case .sameAsPost: + return "Same as Default" + case .visibility(let vis): + return vis.displayName + } + } + + public var imageName: String? { + switch self { + case .sameAsPost: + return nil + case .visibility(let vis): + return vis.imageName + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index b5fcce2e..6a099dd1 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -63,7 +63,11 @@ public final class Preferences: Codable, ObservableObject { self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false - self.defaultPostVisibility = try container.decode(Visibility.self, forKey: .defaultPostVisibility) + if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { + self.defaultPostVisibility = .visibility(existing) + } else { + self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) + } self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) @@ -180,7 +184,7 @@ public final class Preferences: Codable, ObservableObject { @Published public var underlineTextLinks = false // MARK: Composing - @Published public var defaultPostVisibility = Visibility.public + @Published public var defaultPostVisibility = PostVisibility.serverDefault @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var requireAttachmentDescriptions = false @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @@ -292,42 +296,6 @@ public final class Preferences: Codable, ObservableObject { } -extension Preferences { - public enum ReplyVisibility: Codable, Hashable, CaseIterable { - case sameAsPost - case visibility(Visibility) - - public static var allCases: [Preferences.ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } - - public var resolved: Visibility { - switch self { - case .sameAsPost: - return Preferences.shared.defaultPostVisibility - case .visibility(let vis): - return vis - } - } - - public var displayName: String { - switch self { - case .sameAsPost: - return "Same as Default" - case .visibility(let vis): - return vis.displayName - } - } - - public var imageName: String? { - switch self { - case .sameAsPost: - return nil - case .visibility(let vis): - return vis.imageName - } - } - } -} - extension Preferences { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { case useStatusSetting diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 022fdefd..f79f0613 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -12,6 +12,7 @@ import ComposeUI import UniformTypeIdentifiers import TuskerPreferences import Combine +import Pachyderm class ShareViewController: UIViewController { @@ -50,21 +51,26 @@ class ShareViewController: UIViewController { } private func createDraft(account: UserAccountInfo) async -> Draft { - let (text, attachments) = await getDraftConfigurationFromExtensionContext() + async let (text, attachments) = getDraftConfigurationFromExtensionContext() + + // TODO: I really don't like that there's a network request in the hot path here, but we don't have easy access to AccountPreferences :/ + let serverPrefs = try? await Client(baseURL: account.instanceURL, accessToken: account.accessToken).run(Client.getPreferences()).0 + let visibility = Preferences.shared.defaultPostVisibility.resolved(withServerDefault: serverPrefs?.postingDefaultVisibility) let draft = DraftsPersistentContainer.shared.createDraft( accountID: account.id, - text: text, + text: await text, contentWarning: "", inReplyToID: nil, - visibility: Preferences.shared.defaultPostVisibility, + visibility: visibility, + language: serverPrefs?.postingDefaultLanguage, localOnly: false ) - for attachment in attachments { + for attachment in await attachments { DraftsPersistentContainer.shared.viewContext.insert(attachment) } - draft.draftAttachments = attachments + draft.draftAttachments = await attachments return draft } diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index a2c8174c..66f9c4a8 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -193,6 +193,8 @@ class MastodonController: ObservableObject { @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() @@ -217,6 +219,7 @@ class MastodonController: ObservableObject { loadLists() _ = await loadFilters() + await loadServerPreferences() } catch { Logging.general.error("MastodonController initialization failed: \(String(describing: error))") } @@ -358,6 +361,17 @@ class MastodonController: ObservableObject { } } + // 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 + } + private func updateActiveInstance(from instance: Instance) { persistentContainer.performBackgroundTask { context in if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { @@ -519,7 +533,11 @@ class MastodonController: ObservableObject { 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 visibility = if inReplyToID != nil { + Preferences.shared.defaultReplyVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } else { + Preferences.shared.defaultPostVisibility.resolved(withServerDefault: accountPreferences!.serverDefaultVisibility) + } var localOnly = false var contentWarning = "" @@ -559,6 +577,7 @@ class MastodonController: ObservableObject { contentWarning: contentWarning, inReplyToID: inReplyToID, visibility: visibility, + language: accountPreferences!.serverDefaultLanguage, localOnly: localOnly ) } diff --git a/Tusker/CoreData/AccountPreferences.swift b/Tusker/CoreData/AccountPreferences.swift index c8c0b388..98151be7 100644 --- a/Tusker/CoreData/AccountPreferences.swift +++ b/Tusker/CoreData/AccountPreferences.swift @@ -24,10 +24,21 @@ public final class AccountPreferences: NSManagedObject { @NSManaged public var accountID: String @NSManaged var createdAt: Date @NSManaged var pinnedTimelinesData: Data? + @NSManaged var serverDefaultLanguage: String? + @NSManaged private var serverDefaultVisibilityString: String? @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) var pinnedTimelines: [PinnedTimeline] + var serverDefaultVisibility: Visibility? { + get { + serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:)) + } + set { + serverDefaultVisibilityString = newValue?.rawValue + } + } + static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { let prefs = AccountPreferences(context: context) prefs.accountID = account.id diff --git a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents index 092b622f..64963f9f 100644 --- a/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents +++ b/Tusker/CoreData/Tusker.xcdatamodeld/Tusker.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -33,6 +33,8 @@ + + @@ -155,4 +157,4 @@ - \ No newline at end of file + diff --git a/Tusker/Screens/Preferences/ComposingPrefsView.swift b/Tusker/Screens/Preferences/ComposingPrefsView.swift index 49ec21a5..0c15152b 100644 --- a/Tusker/Screens/Preferences/ComposingPrefsView.swift +++ b/Tusker/Screens/Preferences/ComposingPrefsView.swift @@ -28,9 +28,11 @@ struct ComposingPrefsView: View { var visibilitySection: some View { Section { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { - ForEach(Visibility.allCases, id: \.self) { visibility in + ForEach(PostVisibility.allCases, id: \.self) { visibility in HStack { - Image(systemName: visibility.imageName) + if let imageName = visibility.imageName { + Image(systemName: imageName) + } Text(visibility.displayName) } .tag(visibility) @@ -38,7 +40,7 @@ struct ComposingPrefsView: View { // navbar title on the ForEach is currently incorrectly applied when the picker is not expanded, see FB6838291 } Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) { - ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in + ForEach(ReplyVisibility.allCases, id: \.self) { visibility in HStack { if let imageName = visibility.imageName { Image(systemName: imageName)