Use server preferences for default visibility and language

Closes #282
This commit is contained in:
Shadowfacts 2023-10-27 14:58:15 -05:00
parent dfc8234908
commit 6e5e0c3bb5
11 changed files with 212 additions and 49 deletions

View File

@ -81,6 +81,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
contentWarning: String, contentWarning: String,
inReplyToID: String?, inReplyToID: String?,
visibility: Visibility, visibility: Visibility,
language: String?,
localOnly: Bool localOnly: Bool
) -> Draft { ) -> Draft {
let draft = Draft(context: viewContext) let draft = Draft(context: viewContext)
@ -92,6 +93,7 @@ public class DraftsPersistentContainer: NSPersistentContainer {
draft.contentWarningEnabled = !contentWarning.isEmpty draft.contentWarningEnabled = !contentWarning.isEmpty
draft.inReplyToID = inReplyToID draft.inReplyToID = inReplyToID
draft.visibility = visibility draft.visibility = visibility
draft.language = language
draft.localOnly = localOnly draft.localOnly = localOnly
save() save()
return draft return draft

View File

@ -171,6 +171,10 @@ public class InstanceFeatures: ObservableObject {
hasMastodonVersion(4, 2, 0) hasMastodonVersion(4, 2, 0)
} }
public var hasServerPreferences: Bool {
hasMastodonVersion(2, 8, 0)
}
public init() { public init() {
} }

View File

@ -105,6 +105,20 @@ public class Client {
return task return task
} }
@discardableResult
public func run<Result: Sendable>(_ request: Request<Result>) 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<Result>(request: Request<Result>) -> URLRequest? { func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
components.path = request.endpoint.path components.path = request.endpoint.path
@ -225,6 +239,10 @@ public class Client {
return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis") return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
} }
public static func getPreferences() -> Request<Preferences> {
return Request(method: .get, path: "/api/v1/preferences")
}
// MARK: - Accounts // MARK: - Accounts
public static func getAccount(id: String) -> Request<Account> { public static func getAccount(id: String) -> Request<Account> {
return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)") return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")

View File

@ -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"
}
}

View File

@ -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
}
}
}

View File

@ -63,7 +63,11 @@ public final class Preferences: Codable, ObservableObject {
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false 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.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions) self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode) self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
@ -180,7 +184,7 @@ public final class Preferences: Codable, ObservableObject {
@Published public var underlineTextLinks = false @Published public var underlineTextLinks = false
// MARK: Composing // MARK: Composing
@Published public var defaultPostVisibility = Visibility.public @Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false @Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs @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 { extension Preferences {
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting case useStatusSetting

View File

@ -12,6 +12,7 @@ import ComposeUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import TuskerPreferences import TuskerPreferences
import Combine import Combine
import Pachyderm
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
@ -50,21 +51,26 @@ class ShareViewController: UIViewController {
} }
private func createDraft(account: UserAccountInfo) async -> Draft { 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( let draft = DraftsPersistentContainer.shared.createDraft(
accountID: account.id, accountID: account.id,
text: text, text: await text,
contentWarning: "", contentWarning: "",
inReplyToID: nil, inReplyToID: nil,
visibility: Preferences.shared.defaultPostVisibility, visibility: visibility,
language: serverPrefs?.postingDefaultLanguage,
localOnly: false localOnly: false
) )
for attachment in attachments { for attachment in await attachments {
DraftsPersistentContainer.shared.viewContext.insert(attachment) DraftsPersistentContainer.shared.viewContext.insert(attachment)
} }
draft.draftAttachments = attachments draft.draftAttachments = await attachments
return draft return draft
} }

View File

@ -193,6 +193,8 @@ class MastodonController: ObservableObject {
@MainActor @MainActor
func initialize() { 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) // we want this to happen immediately, and synchronously so that the filters (which don't change that often)
// are available when Filterers are constructed // are available when Filterers are constructed
loadCachedFilters() loadCachedFilters()
@ -217,6 +219,7 @@ class MastodonController: ObservableObject {
loadLists() loadLists()
_ = await loadFilters() _ = await loadFilters()
await loadServerPreferences()
} catch { } catch {
Logging.general.error("MastodonController initialization failed: \(String(describing: error))") 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) { private func updateActiveInstance(from instance: Instance) {
persistentContainer.performBackgroundTask { context in persistentContainer.performBackgroundTask { context in
if let existing = try? context.fetch(ActiveInstance.fetchRequest()).first { 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 { func createDraft(inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) -> Draft {
var acctsToMention = [String]() 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 localOnly = false
var contentWarning = "" var contentWarning = ""
@ -559,6 +577,7 @@ class MastodonController: ObservableObject {
contentWarning: contentWarning, contentWarning: contentWarning,
inReplyToID: inReplyToID, inReplyToID: inReplyToID,
visibility: visibility, visibility: visibility,
language: accountPreferences!.serverDefaultLanguage,
localOnly: localOnly localOnly: localOnly
) )
} }

View File

@ -24,10 +24,21 @@ public final class AccountPreferences: NSManagedObject {
@NSManaged public var accountID: String @NSManaged public var accountID: String
@NSManaged var createdAt: Date @NSManaged var createdAt: Date
@NSManaged var pinnedTimelinesData: Data? @NSManaged var pinnedTimelinesData: Data?
@NSManaged var serverDefaultLanguage: String?
@NSManaged private var serverDefaultVisibilityString: String?
@LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines) @LazilyDecoding(from: \AccountPreferences.pinnedTimelinesData, fallback: AccountPreferences.defaultPinnedTimelines)
var pinnedTimelines: [PinnedTimeline] var pinnedTimelines: [PinnedTimeline]
var serverDefaultVisibility: Visibility? {
get {
serverDefaultVisibilityString.flatMap(Visibility.init(rawValue:))
}
set {
serverDefaultVisibilityString = newValue?.rawValue
}
}
static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences { static func `default`(account: UserAccountInfo, context: NSManagedObjectContext) -> AccountPreferences {
let prefs = AccountPreferences(context: context) let prefs = AccountPreferences(context: context)
prefs.accountID = account.id prefs.accountID = account.id

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D49" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="23A344" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="AccountMO" syncable="YES"> <entity name="Account" representedClassName="AccountMO" syncable="YES">
<attribute name="acct" attributeType="String"/> <attribute name="acct" attributeType="String"/>
<attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="active" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@ -33,6 +33,8 @@
<attribute name="accountID" optional="YES" attributeType="String"/> <attribute name="accountID" optional="YES" attributeType="String"/>
<attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/> <attribute name="pinnedTimelinesData" optional="YES" attributeType="Binary"/>
<attribute name="serverDefaultLanguage" optional="YES" attributeType="String"/>
<attribute name="serverDefaultVisibilityString" optional="YES" attributeType="String"/>
</entity> </entity>
<entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES"> <entity name="ActiveInstance" representedClassName="ActiveInstance" syncable="YES">
<attribute name="configurationData" optional="YES" attributeType="Binary"/> <attribute name="configurationData" optional="YES" attributeType="Binary"/>
@ -155,4 +157,4 @@
<memberEntity name="Account"/> <memberEntity name="Account"/>
<memberEntity name="ActiveInstance"/> <memberEntity name="ActiveInstance"/>
</configuration> </configuration>
</model> </model>

View File

@ -28,9 +28,11 @@ struct ComposingPrefsView: View {
var visibilitySection: some View { var visibilitySection: some View {
Section { Section {
Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) { Picker(selection: $preferences.defaultPostVisibility, label: Text("Default Visibility")) {
ForEach(Visibility.allCases, id: \.self) { visibility in ForEach(PostVisibility.allCases, id: \.self) { visibility in
HStack { HStack {
Image(systemName: visibility.imageName) if let imageName = visibility.imageName {
Image(systemName: imageName)
}
Text(visibility.displayName) Text(visibility.displayName)
} }
.tag(visibility) .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 // 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")) { Picker(selection: $preferences.defaultReplyVisibility, label: Text("Reply Visibility")) {
ForEach(Preferences.ReplyVisibility.allCases, id: \.self) { visibility in ForEach(ReplyVisibility.allCases, id: \.self) { visibility in
HStack { HStack {
if let imageName = visibility.imageName { if let imageName = visibility.imageName {
Image(systemName: imageName) Image(systemName: imageName)