From 132fcfa099ea3a1a901ed61d6d335812d69d79da Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 13 Apr 2024 18:44:43 -0400 Subject: [PATCH 1/2] Refactor preferences --- Packages/TuskerPreferences/Package.resolved | 23 + Packages/TuskerPreferences/Package.swift | 4 + .../Sources/TuskerPreferences/Coding.swift | 279 +++++++++++ .../TuskerPreferences/Keys/AdvancedKeys.swift | 17 + .../Keys/AppearanceKeys.swift | 33 ++ .../TuskerPreferences/Keys/BehaviorKeys.swift | 26 ++ .../TuskerPreferences/Keys/CommonKeys.swift | 16 + .../Keys/ComposingKeys.swift | 20 + .../Keys/DigitalWellnessKeys.swift | 12 + .../TuskerPreferences/Keys/MediaKeys.swift | 20 + .../Legacy/LegacyPreferences.swift | 205 ++++++++ .../Legacy/PreferenceStore+Migrate.swift | 72 +++ .../TuskerPreferences/Preference.swift | 83 ++++ .../TuskerPreferences/PreferenceKey.swift | 20 + .../TuskerPreferences/PreferenceStore.swift | 79 ++++ .../TuskerPreferences/Preferences.swift | 438 +----------------- .../Supporting Types/AccentColor.swift | 86 ++++ .../Supporting Types/AttachmentBlurMode.swift | 25 + .../{ => Supporting Types}/AvatarStyle.swift | 0 .../ContentWarningCopyMode.swift | 0 .../Supporting Types/FeatureFlag.swift | 13 + .../NotificationsMode.swift | 0 .../PostVisibility.swift | 0 .../StatusSwipeAction.swift | 0 .../Supporting Types/Theme.swift | 24 + .../Supporting Types/TimelineSyncMode.swift | 13 + .../WidescreenNavigationMode.swift | 14 + .../PreferenceStoreTests.swift | 100 ++++ Tusker/AppDelegate.swift | 11 +- Tusker/Scenes/TuskerSceneDelegate.swift | 2 +- .../Main/MainSplitViewController.swift | 5 +- .../Preferences/AdvancedPrefsView.swift | 3 +- .../Preferences/AppearancePrefsView.swift | 3 +- .../Preferences/BehaviorPrefsView.swift | 5 +- .../Screens/Preferences/MediaPrefsView.swift | 3 +- .../WidescreenNavigationPrefsView.swift | 5 +- 36 files changed, 1225 insertions(+), 434 deletions(-) create mode 100644 Packages/TuskerPreferences/Package.resolved create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AppearanceKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/BehaviorKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/CommonKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/ComposingKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/DigitalWellnessKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/MediaKeys.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/LegacyPreferences.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceKey.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceStore.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AccentColor.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AttachmentBlurMode.swift rename Packages/TuskerPreferences/Sources/TuskerPreferences/{ => Supporting Types}/AvatarStyle.swift (100%) rename Packages/TuskerPreferences/Sources/TuskerPreferences/{ => Supporting Types}/ContentWarningCopyMode.swift (100%) create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift rename Packages/TuskerPreferences/Sources/TuskerPreferences/{ => Supporting Types}/NotificationsMode.swift (100%) rename Packages/TuskerPreferences/Sources/TuskerPreferences/{ => Supporting Types}/PostVisibility.swift (100%) rename Packages/TuskerPreferences/Sources/TuskerPreferences/{ => Supporting Types}/StatusSwipeAction.swift (100%) create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/Theme.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/TimelineSyncMode.swift create mode 100644 Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/WidescreenNavigationMode.swift create mode 100644 Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift diff --git a/Packages/TuskerPreferences/Package.resolved b/Packages/TuskerPreferences/Package.resolved new file mode 100644 index 00000000..da259aea --- /dev/null +++ b/Packages/TuskerPreferences/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "025bcb1165deab2e20d4eaba79967ce73013f496", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-url", + "kind" : "remoteSourceControl", + "location" : "https://github.com/karwa/swift-url.git", + "state" : { + "branch" : "main", + "revision" : "01ad5a103d14839a68c55ee556513e5939008e9e" + } + } + ], + "version" : 2 +} diff --git a/Packages/TuskerPreferences/Package.swift b/Packages/TuskerPreferences/Package.swift index f536c5ae..7392f931 100644 --- a/Packages/TuskerPreferences/Package.swift +++ b/Packages/TuskerPreferences/Package.swift @@ -24,5 +24,9 @@ let package = Package( name: "TuskerPreferences", dependencies: ["Pachyderm"] ), + .testTarget( + name: "TuskerPreferencesTests", + dependencies: ["TuskerPreferences"] + ) ] ) diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift new file mode 100644 index 00000000..ed06c64e --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift @@ -0,0 +1,279 @@ +// +// Coding.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +private protocol PreferenceProtocol { + associatedtype Key: PreferenceKey + var storedValue: Key.Value? { get } + init() +} + +struct PreferenceCoding: Codable { + let wrapped: Wrapped + + init(wrapped: Wrapped) { + self.wrapped = wrapped + } + + init(from decoder: any Decoder) throws { + self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder)) + } + + func encode(to encoder: any Encoder) throws { + try wrapped.encode(to: PreferenceEncoder(wrapped: encoder)) + } +} + +private struct PreferenceDecoder: Decoder { + let wrapped: any Decoder + + var codingPath: [any CodingKey] { + wrapped.codingPath + } + + var userInfo: [CodingUserInfoKey : Any] { + wrapped.userInfo + } + + func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer where Key : CodingKey { + KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type))) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw Error.onlyKeyedContainerSupported + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + throw Error.onlyKeyedContainerSupported + } + + enum Error: Swift.Error { + case onlyKeyedContainerSupported + } +} + +private struct PreferenceDecodingContainer: KeyedDecodingContainerProtocol { + let wrapped: KeyedDecodingContainer + + var codingPath: [any CodingKey] { + wrapped.codingPath + } + + var allKeys: [Key] { + wrapped.allKeys + } + + func contains(_ key: Key) -> Bool { + wrapped.contains(key) + } + + func decodeNil(forKey key: Key) throws -> Bool { + try wrapped.decodeNil(forKey: key) + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + try wrapped.decode(type, forKey: key) + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable { + if let type = type as? any PreferenceProtocol.Type, + !contains(key) { + func makePreference(_: P.Type) -> T { + P() as! T + } + return _openExistential(type, do: makePreference) + } + return try wrapped.decode(type, forKey: key) + } + + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer where NestedKey : CodingKey { + try wrapped.nestedContainer(keyedBy: type, forKey: key) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { + try wrapped.nestedUnkeyedContainer(forKey: key) + } + + func superDecoder() throws -> any Decoder { + try wrapped.superDecoder() + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + try wrapped.superDecoder(forKey: key) + } +} + +private struct PreferenceEncoder: Encoder { + let wrapped: any Encoder + + var codingPath: [any CodingKey] { + wrapped.codingPath + } + + var userInfo: [CodingUserInfoKey : Any] { + wrapped.userInfo + } + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { + KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type))) + } + + func unkeyedContainer() -> any UnkeyedEncodingContainer { + fatalError("Only keyed containers supported") + } + + func singleValueContainer() -> any SingleValueEncodingContainer { + fatalError("Only keyed containers supported") + } +} + +private struct PreferenceEncodingContainer: KeyedEncodingContainerProtocol { + var wrapped: KeyedEncodingContainer + + var codingPath: [any CodingKey] { + wrapped.codingPath + } + + mutating func encodeNil(forKey key: Key) throws { + try wrapped.encodeNil(forKey: key) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: String, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Float, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Int8, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Int16, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Int32, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: Int64, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: UInt, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: UInt8, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: UInt16, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: UInt32, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: UInt64, forKey key: Key) throws { + try wrapped.encode(value, forKey: key) + } + + mutating func encode(_ value: T, forKey key: Key) throws where T : Encodable { + if let value = value as? any PreferenceProtocol, + value.storedValue == nil { + return + } + try wrapped.encode(value, forKey: key) + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { + wrapped.nestedContainer(keyedBy: keyType, forKey: key) + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer { + wrapped.nestedUnkeyedContainer(forKey: key) + } + + mutating func superEncoder() -> any Encoder { + wrapped.superEncoder() + } + + mutating func superEncoder(forKey key: Key) -> any Encoder { + wrapped.superEncoder(forKey: key) + } + + +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift new file mode 100644 index 00000000..a8608d30 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AdvancedKeys.swift @@ -0,0 +1,17 @@ +// +// AdvancedKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation +import Pachyderm + +struct StatusContentTypeKey: PreferenceKey { + static var defaultValue: StatusContentType { .plain } +} + +struct FeatureFlagsKey: PreferenceKey { + static var defaultValue: Set { [] } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AppearanceKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AppearanceKeys.swift new file mode 100644 index 00000000..d84a53f9 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/AppearanceKeys.swift @@ -0,0 +1,33 @@ +// +// AppearanceKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation +import UIKit + +struct ThemeKey: PreferenceKey { + static var defaultValue: Theme { .unspecified } +} + +struct AccentColorKey: PreferenceKey { + static var defaultValue: AccentColor { .default } +} + +struct AvatarStyleKey: PreferenceKey { + static var defaultValue: AvatarStyle { .roundRect } +} + +struct LeadingSwipeActionsKey: PreferenceKey { + static var defaultValue: [StatusSwipeAction] { [.favorite, .reblog] } +} + +struct TrailingSwipeActionsKey: PreferenceKey { + static var defaultValue: [StatusSwipeAction] { [.reply, .share] } +} + +struct WidescreenNavigationModeKey: PreferenceKey { + static var defaultValue: WidescreenNavigationMode { .splitScreen } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/BehaviorKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/BehaviorKeys.swift new file mode 100644 index 00000000..1e66c23e --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/BehaviorKeys.swift @@ -0,0 +1,26 @@ +// +// BehaviorKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +struct OppositeCollapseKeywordsKey: PreferenceKey { + static var defaultValue: [String] { [] } +} + +struct ConfirmReblogKey: PreferenceKey { + static var defaultValue: Bool { + #if os(visionOS) + true + #else + false + #endif + } +} + +struct TimelineSyncModeKey: PreferenceKey { + static var defaultValue: TimelineSyncMode { .icloud } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/CommonKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/CommonKeys.swift new file mode 100644 index 00000000..1bf8a1ad --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/CommonKeys.swift @@ -0,0 +1,16 @@ +// +// CommonKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +struct TrueKey: PreferenceKey { + static var defaultValue: Bool { true } +} + +struct FalseKey: PreferenceKey { + static var defaultValue: Bool { false } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/ComposingKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/ComposingKeys.swift new file mode 100644 index 00000000..736c9ef8 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/ComposingKeys.swift @@ -0,0 +1,20 @@ +// +// ComposingKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +struct PostVisibilityKey: PreferenceKey { + static var defaultValue: PostVisibility { .serverDefault } +} + +struct ReplyVisibilityKey: PreferenceKey { + static var defaultValue: ReplyVisibility { .sameAsPost } +} + +struct ContentWarningCopyModeKey: PreferenceKey { + static var defaultValue: ContentWarningCopyMode { .asIs } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/DigitalWellnessKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/DigitalWellnessKeys.swift new file mode 100644 index 00000000..c1ae4086 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/DigitalWellnessKeys.swift @@ -0,0 +1,12 @@ +// +// DigitalWellnessKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +struct NotificationsModeKey: PreferenceKey { + static var defaultValue: NotificationsMode { .allNotifications } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/MediaKeys.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/MediaKeys.swift new file mode 100644 index 00000000..f7c3d548 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Keys/MediaKeys.swift @@ -0,0 +1,20 @@ +// +// MediaKeys.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +struct AttachmentBlurModeKey: PreferenceKey { + static var defaultValue: AttachmentBlurMode { .useStatusSetting } + + static func didSet(in store: PreferenceStore, newValue: AttachmentBlurMode) { + if newValue == .always { + store.blurMediaBehindContentWarning = true + } else if newValue == .never { + store.blurMediaBehindContentWarning = false + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/LegacyPreferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/LegacyPreferences.swift new file mode 100644 index 00000000..46ae08fe --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/LegacyPreferences.swift @@ -0,0 +1,205 @@ +// +// LegacyPreferences.swift +// TuskerPreferences +// +// Created by Shadowfacts on 8/28/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm +import Combine + +public final class LegacyPreferences: Decodable { + + init() {} + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) + self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true + self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default + self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle) + self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames) + self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon) + self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon) + self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false + self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true + self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions + self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions + self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode + self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false + self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true + + 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) + self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) + self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false + + if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) { + self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting + } else { + self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode) + } + self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true + self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) + self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true + self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true + self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false + + self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) + self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) + self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) + self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false + self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true + self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] + self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false + self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true + self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud + self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false + self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false + + self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) + self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) + self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false + self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false + self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false + + self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) + self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true + let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? [] + self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init)) + + self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false + self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false + } + + // MARK: Appearance + @Published public var theme = UIUserInterfaceStyle.unspecified + @Published public var pureBlackDarkMode = true + @Published public var accentColor = AccentColor.default + @Published public var avatarStyle = AvatarStyle.roundRect + @Published public var hideCustomEmojiInUsernames = false + @Published public var showIsStatusReplyIcon = false + @Published public var alwaysShowStatusVisibilityIcon = false + @Published public var hideActionsInTimeline = false + @Published public var showLinkPreviews = true + @Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog] + @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] + private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen + @Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode + @Published public var underlineTextLinks = false + @Published public var showAttachmentsInTimeline = true + + // MARK: Composing + @Published public var defaultPostVisibility = PostVisibility.serverDefault + @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost + @Published public var requireAttachmentDescriptions = false + @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs + @Published public var mentionReblogger = false + @Published public var useTwitterKeyboard = false + + // MARK: Media + @Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting + @Published public var blurMediaBehindContentWarning = true + @Published public var automaticallyPlayGifs = true + @Published public var showUncroppedMediaInline = true + @Published public var showAttachmentBadges = true + @Published public var attachmentAltBadgeInverted = false + + // MARK: Behavior + @Published public var openLinksInApps = true + @Published public var useInAppSafari = true + @Published public var inAppSafariAutomaticReaderMode = false + @Published public var expandAllContentWarnings = false + @Published public var collapseLongPosts = true + @Published public var oppositeCollapseKeywords: [String] = [] + @Published public var confirmBeforeReblog = false + @Published public var timelineStateRestoration = true + @Published public var timelineSyncMode = TimelineSyncMode.icloud + @Published public var hideReblogsInTimelines = false + @Published public var hideRepliesInTimelines = false + + // MARK: Digital Wellness + @Published public var showFavoriteAndReblogCounts = true + @Published public var defaultNotificationsMode = NotificationsMode.allNotifications + @Published public var grayscaleImages = false + @Published public var disableInfiniteScrolling = false + @Published public var hideTrends = false + + // MARK: Advanced + @Published public var statusContentType: StatusContentType = .plain + @Published public var reportErrorsAutomatically = true + @Published public var enabledFeatureFlags: Set = [] + + // MARK: + @Published public var hasShownLocalTimelineDescription = false + @Published public var hasShownFederatedTimelineDescription = false + + private enum CodingKeys: String, CodingKey { + case theme + case pureBlackDarkMode + case accentColor + case avatarStyle + case hideCustomEmojiInUsernames + case showIsStatusReplyIcon + case alwaysShowStatusVisibilityIcon + case hideActionsInTimeline + case showLinkPreviews + case leadingStatusSwipeActions + case trailingStatusSwipeActions + case widescreenNavigationMode + case underlineTextLinks + case showAttachmentsInTimeline + + case defaultPostVisibility + case defaultReplyVisibility + case requireAttachmentDescriptions + case contentWarningCopyMode + case mentionReblogger + case useTwitterKeyboard + + case blurAllMedia // only used for migration + case attachmentBlurMode + case blurMediaBehindContentWarning + case automaticallyPlayGifs + case showUncroppedMediaInline + case showAttachmentBadges + case attachmentAltBadgeInverted + + case openLinksInApps + case useInAppSafari + case inAppSafariAutomaticReaderMode + case expandAllContentWarnings + case collapseLongPosts + case oppositeCollapseKeywords + case confirmBeforeReblog + case timelineStateRestoration + case timelineSyncMode + case hideReblogsInTimelines + case hideRepliesInTimelines + + case showFavoriteAndReblogCounts + case defaultNotificationsType + case grayscaleImages + case disableInfiniteScrolling + case hideTrends = "hideDiscover" + + case statusContentType + case reportErrorsAutomatically + case enabledFeatureFlags + + case hasShownLocalTimelineDescription + case hasShownFederatedTimelineDescription + } + +} + +extension UIUserInterfaceStyle: Codable {} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift new file mode 100644 index 00000000..1858e2b8 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift @@ -0,0 +1,72 @@ +// +// PreferenceStore+Migrate.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +extension PreferenceStore { + func migrate(from legacy: LegacyPreferences) { + self.theme = switch legacy.theme { + case .light: .light + case .dark: .dark + default: .unspecified + } + self.pureBlackDarkMode = legacy.pureBlackDarkMode + self.accentColor = legacy.accentColor + self.avatarStyle = legacy.avatarStyle + self.hideCustomEmojiInUsernames = legacy.hideCustomEmojiInUsernames + self.showIsStatusReplyIcon = legacy.showIsStatusReplyIcon + self.alwaysShowStatusVisibilityIcon = legacy.alwaysShowStatusVisibilityIcon + self.hideActionsInTimeline = legacy.hideActionsInTimeline + self.showLinkPreviews = legacy.showLinkPreviews + self.leadingStatusSwipeActions = legacy.leadingStatusSwipeActions + self.trailingStatusSwipeActions = legacy.trailingStatusSwipeActions + if legacy.widescreenNavigationMode != .splitScreen { + self.widescreenNavigationMode = legacy.widescreenNavigationMode + } + self.underlineTextLinks = legacy.underlineTextLinks + self.showAttachmentsInTimeline = legacy.showAttachmentsInTimeline + + self.defaultPostVisibility = legacy.defaultPostVisibility + self.defaultReplyVisibility = legacy.defaultReplyVisibility + self.requireAttachmentDescriptions = legacy.requireAttachmentDescriptions + self.contentWarningCopyMode = legacy.contentWarningCopyMode + self.mentionReblogger = legacy.mentionReblogger + self.useTwitterKeyboard = legacy.useTwitterKeyboard + + self.attachmentBlurMode = legacy.attachmentBlurMode + self.blurMediaBehindContentWarning = legacy.blurMediaBehindContentWarning + self.automaticallyPlayGifs = legacy.automaticallyPlayGifs + self.showUncroppedMediaInline = legacy.showUncroppedMediaInline + self.showAttachmentBadges = legacy.showAttachmentBadges + self.attachmentAltBadgeInverted = legacy.attachmentAltBadgeInverted + + self.openLinksInApps = legacy.openLinksInApps + self.useInAppSafari = legacy.useInAppSafari + self.inAppSafariAutomaticReaderMode = legacy.inAppSafariAutomaticReaderMode + self.expandAllContentWarnings = legacy.expandAllContentWarnings + self.collapseLongPosts = legacy.collapseLongPosts + self.oppositeCollapseKeywords = legacy.oppositeCollapseKeywords + self.confirmBeforeReblog = legacy.confirmBeforeReblog + self.timelineStateRestoration = legacy.timelineStateRestoration + self.timelineSyncMode = legacy.timelineSyncMode + self.hideReblogsInTimelines = legacy.hideReblogsInTimelines + self.hideRepliesInTimelines = legacy.hideRepliesInTimelines + + self.showFavoriteAndReblogCounts = legacy.showFavoriteAndReblogCounts + self.defaultNotificationsMode = legacy.defaultNotificationsMode + self.grayscaleImages = legacy.grayscaleImages + self.disableInfiniteScrolling = legacy.disableInfiniteScrolling + self.hideTrends = legacy.hideTrends + + self.statusContentType = legacy.statusContentType + self.reportErrorsAutomatically = legacy.reportErrorsAutomatically + self.enabledFeatureFlags = legacy.enabledFeatureFlags + + self.hasShownLocalTimelineDescription = legacy.hasShownLocalTimelineDescription + self.hasShownFederatedTimelineDescription = legacy.hasShownFederatedTimelineDescription + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift new file mode 100644 index 00000000..d15a6c54 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift @@ -0,0 +1,83 @@ +// +// Preference.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation +import Combine + +// TODO: once we target iOS 17, use Observable for this +@propertyWrapper +final class Preference: Codable { + @Published private(set) var storedValue: Key.Value? + + var wrappedValue: Key.Value { + get { + storedValue ?? Key.defaultValue + } + set { + fatalError("unreachable") + } + } + + init() { + self.storedValue = nil + } + + init(from decoder: any Decoder) throws { + if let container = try? decoder.singleValueContainer() { + self.storedValue = try? container.decode(Key.Value.self) + } + } + + func encode(to encoder: any Encoder) throws { + if let storedValue { + var container = encoder.singleValueContainer() + try container.encode(storedValue) + } + } + + static subscript( + _enclosingInstance instance: PreferenceStore, + wrapped wrappedKeyPath: ReferenceWritableKeyPath, + storage storageKeyPath: ReferenceWritableKeyPath + ) -> Key.Value { + get { + get(enclosingInstance: instance, wrapped: wrappedKeyPath, storage: storageKeyPath) + } + set { + set(enclosingInstance: instance, wrapped: wrappedKeyPath, storage: storageKeyPath, newValue: newValue) + Key.didSet(in: instance, newValue: newValue) + } + } + + // for testing only + @inline(__always) + static func get( + enclosingInstance: Enclosing, + wrapped: ReferenceWritableKeyPath, + storage: ReferenceWritableKeyPath + ) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher { + let pref = enclosingInstance[keyPath: storage] + return pref.storedValue ?? Key.defaultValue + } + + // for testing only + @inline(__always) + static func set( + enclosingInstance: Enclosing, + wrapped: ReferenceWritableKeyPath, + storage: ReferenceWritableKeyPath, + newValue: Key.Value + ) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher { + enclosingInstance.objectWillChange.send() + let pref = enclosingInstance[keyPath: storage] + pref.storedValue = newValue + } + + var projectedValue: some Publisher { + $storedValue.map { $0 ?? Key.defaultValue } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceKey.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceKey.swift new file mode 100644 index 00000000..4a153c5a --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceKey.swift @@ -0,0 +1,20 @@ +// +// PreferenceKey.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/12/24. +// + +import Foundation + +public protocol PreferenceKey { + associatedtype Value: Codable + + static var defaultValue: Value { get } + + static func didSet(in store: PreferenceStore, newValue: Value) +} + +extension PreferenceKey { + static func didSet(in store: PreferenceStore, newValue: Value) {} +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceStore.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceStore.swift new file mode 100644 index 00000000..3b7a42b4 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/PreferenceStore.swift @@ -0,0 +1,79 @@ +// +// PreferenceStore.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/12/24. +// + +import Foundation +import UIKit +import Pachyderm + +public final class PreferenceStore: ObservableObject, Codable { + // MARK: Appearance + @Preference public var theme + @Preference public var pureBlackDarkMode + @Preference public var accentColor + @Preference public var avatarStyle + @Preference public var hideCustomEmojiInUsernames + @Preference public var showIsStatusReplyIcon + @Preference public var alwaysShowStatusVisibilityIcon + @Preference public var hideActionsInTimeline + @Preference public var showLinkPreviews + @Preference public var leadingStatusSwipeActions + @Preference public var trailingStatusSwipeActions + @Preference public var widescreenNavigationMode + @Preference public var underlineTextLinks + @Preference public var showAttachmentsInTimeline + + // MARK: Composing + @Preference public var defaultPostVisibility + @Preference public var defaultReplyVisibility + @Preference public var requireAttachmentDescriptions + @Preference public var contentWarningCopyMode + @Preference public var mentionReblogger + @Preference public var useTwitterKeyboard + + // MARK: Media + @Preference public var attachmentBlurMode + @Preference public var blurMediaBehindContentWarning + @Preference public var automaticallyPlayGifs + @Preference public var showUncroppedMediaInline + @Preference public var showAttachmentBadges + @Preference public var attachmentAltBadgeInverted + + // MARK: Behavior + @Preference public var openLinksInApps + @Preference public var useInAppSafari + @Preference public var inAppSafariAutomaticReaderMode + @Preference public var expandAllContentWarnings + @Preference public var collapseLongPosts + @Preference public var oppositeCollapseKeywords + @Preference public var confirmBeforeReblog + @Preference public var timelineStateRestoration + @Preference public var timelineSyncMode + @Preference public var hideReblogsInTimelines + @Preference public var hideRepliesInTimelines + + // MARK: Digital Wellness + @Preference public var showFavoriteAndReblogCounts + @Preference public var defaultNotificationsMode + @Preference public var grayscaleImages + @Preference public var disableInfiniteScrolling + @Preference public var hideTrends + + // MARK: Advanced + @Preference public var statusContentType + @Preference public var reportErrorsAutomatically + @Preference public var enabledFeatureFlags + + // MARK: Internal + @Preference public var hasShownLocalTimelineDescription + @Preference public var hasShownFederatedTimelineDescription +} + +extension PreferenceStore { + public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool { + enabledFeatureFlags.contains(flag) + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift index 80e86902..1274f303 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preferences.swift @@ -2,430 +2,40 @@ // Preferences.swift // TuskerPreferences // -// Created by Shadowfacts on 8/28/18. -// Copyright © 2018 Shadowfacts. All rights reserved. +// Created by Shadowfacts on 4/12/24. // -import UIKit -import Pachyderm -import Combine +import Foundation -public final class Preferences: Codable, ObservableObject { - - @MainActor - public static var shared: Preferences = load() +public struct Preferences { + public static let shared: PreferenceStore = load() private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! - private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") - - @MainActor - public static func save() { - let encoder = PropertyListEncoder() - let data = try? encoder.encode(shared) - try? data?.write(to: archiveURL, options: .noFileProtection) - } - - public static func load() -> Preferences { - let decoder = PropertyListDecoder() - if let data = try? Data(contentsOf: archiveURL), - let preferences = try? decoder.decode(Preferences.self, from: data) { - return preferences - } - return Preferences() - } - - @MainActor - public static func migrate(from url: URL) -> Result { - do { - try? FileManager.default.removeItem(at: archiveURL) - try FileManager.default.moveItem(at: url, to: archiveURL) - } catch { - return .failure(error) - } - shared = load() - return .success(()) - } + private static var legacyURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") + private static var preferencesURL = appGroupDirectory.appendingPathComponent("preferences.v2").appendingPathExtension("plist") + private static var nonAppGroupURL = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") private init() {} - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme) - self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true - self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default - self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle) - self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames) - self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon) - self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon) - self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false - self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true - self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions - self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions - self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode - self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false - self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true - - if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) { - self.defaultPostVisibility = .visibility(existing) + public static func save() { + let encoder = PropertyListEncoder() + let data = try? encoder.encode(PreferenceCoding(wrapped: shared)) + try? data?.write(to: preferencesURL, options: .noFileProtection) + } + + private static func load() -> PreferenceStore { + let decoder = PropertyListDecoder() + if let data = try? Data(contentsOf: preferencesURL), + let store = try? decoder.decode(PreferenceCoding.self, from: data) { + return store.wrapped + } else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)), + let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) { + let store = PreferenceStore() + store.migrate(from: legacy) + return store } 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) - self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger) - self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false - - if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) { - self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting - } else { - self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode) - } - self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true - self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs) - self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true - self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true - self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false - - self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps) - self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari) - self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode) - self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false - self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true - self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? [] - self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false - self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true - self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud - self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false - self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false - - self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) - self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) - self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false - self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false - self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false - - self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) - self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true - let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? [] - self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init)) - - self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false - self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(theme, forKey: .theme) - try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode) - try container.encode(accentColor, forKey: .accentColor) - try container.encode(avatarStyle, forKey: .avatarStyle) - try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames) - try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon) - try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon) - try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline) - try container.encode(showLinkPreviews, forKey: .showLinkPreviews) - try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions) - try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions) - try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode) - try container.encode(underlineTextLinks, forKey: .underlineTextLinks) - try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline) - - try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility) - try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility) - try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions) - try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode) - try container.encode(mentionReblogger, forKey: .mentionReblogger) - try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard) - - try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode) - try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning) - try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs) - try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline) - try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges) - try container.encode(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted) - - try container.encode(openLinksInApps, forKey: .openLinksInApps) - try container.encode(useInAppSafari, forKey: .useInAppSafari) - try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode) - try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings) - try container.encode(collapseLongPosts, forKey: .collapseLongPosts) - try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords) - try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog) - try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration) - try container.encode(timelineSyncMode, forKey: .timelineSyncMode) - try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines) - try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines) - - try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) - try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) - try container.encode(grayscaleImages, forKey: .grayscaleImages) - try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling) - try container.encode(hideTrends, forKey: .hideTrends) - - try container.encode(statusContentType, forKey: .statusContentType) - try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically) - try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags) - - try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription) - try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription) - } - - // MARK: Appearance - @Published public var theme = UIUserInterfaceStyle.unspecified - @Published public var pureBlackDarkMode = true - @Published public var accentColor = AccentColor.default - @Published public var avatarStyle = AvatarStyle.roundRect - @Published public var hideCustomEmojiInUsernames = false - @Published public var showIsStatusReplyIcon = false - @Published public var alwaysShowStatusVisibilityIcon = false - @Published public var hideActionsInTimeline = false - @Published public var showLinkPreviews = true - @Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog] - @Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share] - private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen - @Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode - @Published public var underlineTextLinks = false - @Published public var showAttachmentsInTimeline = true - - // MARK: Composing - @Published public var defaultPostVisibility = PostVisibility.serverDefault - @Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost - @Published public var requireAttachmentDescriptions = false - @Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs - @Published public var mentionReblogger = false - @Published public var useTwitterKeyboard = false - - // MARK: Media - @Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting { - didSet { - if attachmentBlurMode == .always { - blurMediaBehindContentWarning = true - } else if attachmentBlurMode == .never { - blurMediaBehindContentWarning = false - } - } - } - @Published public var blurMediaBehindContentWarning = true - @Published public var automaticallyPlayGifs = true - @Published public var showUncroppedMediaInline = true - @Published public var showAttachmentBadges = true - @Published public var attachmentAltBadgeInverted = false - - // MARK: Behavior - @Published public var openLinksInApps = true - @Published public var useInAppSafari = true - @Published public var inAppSafariAutomaticReaderMode = false - @Published public var expandAllContentWarnings = false - @Published public var collapseLongPosts = true - @Published public var oppositeCollapseKeywords: [String] = [] - @Published public var confirmBeforeReblog = false - @Published public var timelineStateRestoration = true - @Published public var timelineSyncMode = TimelineSyncMode.icloud - @Published public var hideReblogsInTimelines = false - @Published public var hideRepliesInTimelines = false - - // MARK: Digital Wellness - @Published public var showFavoriteAndReblogCounts = true - @Published public var defaultNotificationsMode = NotificationsMode.allNotifications - @Published public var grayscaleImages = false - @Published public var disableInfiniteScrolling = false - @Published public var hideTrends = false - - // MARK: Advanced - @Published public var statusContentType: StatusContentType = .plain - @Published public var reportErrorsAutomatically = true - @Published public var enabledFeatureFlags: Set = [] - - // MARK: - @Published public var hasShownLocalTimelineDescription = false - @Published public var hasShownFederatedTimelineDescription = false - - public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool { - enabledFeatureFlags.contains(flag) - } - - private enum CodingKeys: String, CodingKey { - case theme - case pureBlackDarkMode - case accentColor - case avatarStyle - case hideCustomEmojiInUsernames - case showIsStatusReplyIcon - case alwaysShowStatusVisibilityIcon - case hideActionsInTimeline - case showLinkPreviews - case leadingStatusSwipeActions - case trailingStatusSwipeActions - case widescreenNavigationMode - case underlineTextLinks - case showAttachmentsInTimeline - - case defaultPostVisibility - case defaultReplyVisibility - case requireAttachmentDescriptions - case contentWarningCopyMode - case mentionReblogger - case useTwitterKeyboard - - case blurAllMedia // only used for migration - case attachmentBlurMode - case blurMediaBehindContentWarning - case automaticallyPlayGifs - case showUncroppedMediaInline - case showAttachmentBadges - case attachmentAltBadgeInverted - - case openLinksInApps - case useInAppSafari - case inAppSafariAutomaticReaderMode - case expandAllContentWarnings - case collapseLongPosts - case oppositeCollapseKeywords - case confirmBeforeReblog - case timelineStateRestoration - case timelineSyncMode - case hideReblogsInTimelines - case hideRepliesInTimelines - - case showFavoriteAndReblogCounts - case defaultNotificationsType - case grayscaleImages - case disableInfiniteScrolling - case hideTrends = "hideDiscover" - - case statusContentType - case reportErrorsAutomatically - case enabledFeatureFlags - - case hasShownLocalTimelineDescription - case hasShownFederatedTimelineDescription - } - -} - -extension Preferences { - public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { - case useStatusSetting - case always - case never - - public var displayName: String { - switch self { - case .useStatusSetting: - return "Default" - case .always: - return "Always" - case .never: - return "Never" - } + return PreferenceStore() } } } - -extension UIUserInterfaceStyle: Codable {} - -extension Preferences { - public enum AccentColor: String, Codable, CaseIterable { - case `default` - case purple - case indigo - case blue - case cyan - case teal - case mint - case green -// case yellow - case orange - case red - case pink -// case brown - - public var color: UIColor? { - switch self { - case .default: - return nil - case .blue: - return .systemBlue -// case .brown: -// return .systemBrown - case .cyan: - return .systemCyan - case .green: - return .systemGreen - case .indigo: - return .systemIndigo - case .mint: - return .systemMint - case .orange: - return .systemOrange - case .pink: - return .systemPink - case .purple: - return .systemPurple - case .red: - return .systemRed - case .teal: - return .systemTeal -// case .yellow: -// return .systemYellow - } - } - - public var name: String { - switch self { - case .default: - return "Default" - case .blue: - return "Blue" -// case .brown: -// return "Brown" - case .cyan: - return "Cyan" - case .green: - return "Green" - case .indigo: - return "Indigo" - case .mint: - return "Mint" - case .orange: - return "Orange" - case .pink: - return "Pink" - case .purple: - return "Purple" - case .red: - return "Red" - case .teal: - return "Teal" -// case .yellow: -// return "Yellow" - } - } - } -} - -extension Preferences { - public enum TimelineSyncMode: String, Codable { - case mastodon - case icloud - } -} - -extension Preferences { - public enum FeatureFlag: String, Codable { - case iPadMultiColumn = "ipad-multi-column" - case iPadBrowserNavigation = "ipad-browser-navigation" - } -} - -extension Preferences { - public enum WidescreenNavigationMode: String, Codable { - case stack - case splitScreen - case multiColumn - } -} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AccentColor.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AccentColor.swift new file mode 100644 index 00000000..d3df3a9e --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AccentColor.swift @@ -0,0 +1,86 @@ +// +// AccentColor.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import UIKit + +public enum AccentColor: String, Codable, CaseIterable { + case `default` + case purple + case indigo + case blue + case cyan + case teal + case mint + case green +// case yellow + case orange + case red + case pink +// case brown + + public var color: UIColor? { + switch self { + case .default: + return nil + case .blue: + return .systemBlue +// case .brown: +// return .systemBrown + case .cyan: + return .systemCyan + case .green: + return .systemGreen + case .indigo: + return .systemIndigo + case .mint: + return .systemMint + case .orange: + return .systemOrange + case .pink: + return .systemPink + case .purple: + return .systemPurple + case .red: + return .systemRed + case .teal: + return .systemTeal +// case .yellow: +// return .systemYellow + } + } + + public var name: String { + switch self { + case .default: + return "Default" + case .blue: + return "Blue" +// case .brown: +// return "Brown" + case .cyan: + return "Cyan" + case .green: + return "Green" + case .indigo: + return "Indigo" + case .mint: + return "Mint" + case .orange: + return "Orange" + case .pink: + return "Pink" + case .purple: + return "Purple" + case .red: + return "Red" + case .teal: + return "Teal" +// case .yellow: +// return "Yellow" + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AttachmentBlurMode.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AttachmentBlurMode.swift new file mode 100644 index 00000000..5fb8808d --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AttachmentBlurMode.swift @@ -0,0 +1,25 @@ +// +// AttachmentBlurMode.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +public enum AttachmentBlurMode: Codable, Hashable, CaseIterable { + case useStatusSetting + case always + case never + + public var displayName: String { + switch self { + case .useStatusSetting: + return "Default" + case .always: + return "Always" + case .never: + return "Never" + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/AvatarStyle.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AvatarStyle.swift similarity index 100% rename from Packages/TuskerPreferences/Sources/TuskerPreferences/AvatarStyle.swift rename to Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/AvatarStyle.swift diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/ContentWarningCopyMode.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/ContentWarningCopyMode.swift similarity index 100% rename from Packages/TuskerPreferences/Sources/TuskerPreferences/ContentWarningCopyMode.swift rename to Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/ContentWarningCopyMode.swift diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift new file mode 100644 index 00000000..9a060804 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/FeatureFlag.swift @@ -0,0 +1,13 @@ +// +// FeatureFlag.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +public enum FeatureFlag: String, Codable { + case iPadMultiColumn = "ipad-multi-column" + case iPadBrowserNavigation = "ipad-browser-navigation" +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/NotificationsMode.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/NotificationsMode.swift similarity index 100% rename from Packages/TuskerPreferences/Sources/TuskerPreferences/NotificationsMode.swift rename to Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/NotificationsMode.swift diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/PostVisibility.swift similarity index 100% rename from Packages/TuskerPreferences/Sources/TuskerPreferences/PostVisibility.swift rename to Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/PostVisibility.swift diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/StatusSwipeAction.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/StatusSwipeAction.swift similarity index 100% rename from Packages/TuskerPreferences/Sources/TuskerPreferences/StatusSwipeAction.swift rename to Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/StatusSwipeAction.swift diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/Theme.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/Theme.swift new file mode 100644 index 00000000..4248c523 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/Theme.swift @@ -0,0 +1,24 @@ +// +// Theme.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation +import UIKit + +public enum Theme: String, Codable { + case unspecified, light, dark + + public var userInterfaceStyle: UIUserInterfaceStyle { + switch self { + case .unspecified: + .unspecified + case .light: + .light + case .dark: + .dark + } + } +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/TimelineSyncMode.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/TimelineSyncMode.swift new file mode 100644 index 00000000..4d9ea78f --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/TimelineSyncMode.swift @@ -0,0 +1,13 @@ +// +// TimelineSyncMode.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +public enum TimelineSyncMode: String, Codable { + case mastodon + case icloud +} diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/WidescreenNavigationMode.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/WidescreenNavigationMode.swift new file mode 100644 index 00000000..d3eeb607 --- /dev/null +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Supporting Types/WidescreenNavigationMode.swift @@ -0,0 +1,14 @@ +// +// WidescreenNavigationMode.swift +// TuskerPreferences +// +// Created by Shadowfacts on 4/13/24. +// + +import Foundation + +public enum WidescreenNavigationMode: String, Codable { + case stack + case splitScreen + case multiColumn +} diff --git a/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift b/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift new file mode 100644 index 00000000..8ee6f19f --- /dev/null +++ b/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift @@ -0,0 +1,100 @@ +// +// PreferenceStoreTests.swift +// TuskerPreferencesTests +// +// Created by Shadowfacts on 4/12/24. +// + +import XCTest +@testable import TuskerPreferences +import Combine + +final class PreferenceStoreTests: XCTestCase { + + struct TestKey: PreferenceKey { + static let defaultValue = false + } + + final class TestStore: Codable, ObservableObject { + private var _test = Preference() + + // the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually + var test: Bool { + get { + Preference.get(enclosingInstance: self, wrapped: \.test, storage: \._test) + } + set { + Preference.set(enclosingInstance: self, wrapped: \.test, storage: \._test, newValue: newValue) + } + } + + var testPublisher: some Publisher { + _test.projectedValue + } + + init() { + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self._test = try container.decode(Preference.self, forKey: .test) + } + + enum CodingKeys: CodingKey { + case test + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self._test, forKey: .test) + } + } + + func testDecoding() throws { + let decoder = JSONDecoder() + let present = try decoder.decode(PreferenceCoding.self, from: Data(""" +{"test": true} +""".utf8)).wrapped + XCTAssertEqual(present.test, true) + let absent = try decoder.decode(PreferenceCoding.self, from: Data(""" +{} +""".utf8)).wrapped + XCTAssertEqual(absent.test, false) + } + + func testEncoding() throws { + let store = TestStore() + let encoder = JSONEncoder() + XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """ +{} +""") + store.test = true + XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """ +{"test":true} +""") + store.test = false + XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """ +{"test":false} +""") + } + + func testPublisher() { + let topLevel = expectation(description: "top level publisher") + let specificPref = expectation(description: "preference publisher") + // initial and on change + specificPref.expectedFulfillmentCount = 2 + let store = TestStore() + var cancellables = Set() + store.objectWillChange.sink { + topLevel.fulfill() + // fires on will change + XCTAssertEqual(store.test, false) + }.store(in: &cancellables) + store.testPublisher.sink { _ in + specificPref.fulfill() + }.store(in: &cancellables) + store.test = true + wait(for: [topLevel, specificPref]) + } + +} diff --git a/Tusker/AppDelegate.swift b/Tusker/AppDelegate.swift index ccb90925..9977e820 100644 --- a/Tusker/AppDelegate.swift +++ b/Tusker/AppDelegate.swift @@ -54,21 +54,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") - if FileManager.default.fileExists(atPath: oldPreferencesFile.path) { - if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) { - #if canImport(Sentry) - SentrySDK.capture(error: error) - #endif - } - } - // make sure the persistent container is initialized on the main thread // otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere _ = DraftsPersistentContainer.shared DispatchQueue.global(qos: .userInitiated).async { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist") for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) { diff --git a/Tusker/Scenes/TuskerSceneDelegate.swift b/Tusker/Scenes/TuskerSceneDelegate.swift index a23691e6..16b77be5 100644 --- a/Tusker/Scenes/TuskerSceneDelegate.swift +++ b/Tusker/Scenes/TuskerSceneDelegate.swift @@ -33,7 +33,7 @@ extension TuskerSceneDelegate { func applyAppearancePreferences() { guard let window else { return } - window.overrideUserInterfaceStyle = Preferences.shared.theme + window.overrideUserInterfaceStyle = Preferences.shared.theme.userInterfaceStyle window.tintColor = Preferences.shared.accentColor.color #if os(visionOS) window.traitOverrides.pureBlackDarkMode = Preferences.shared.pureBlackDarkMode diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 8ac40b2b..96ad5425 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -8,6 +8,7 @@ import UIKit import Combine +import TuskerPreferences class MainSplitViewController: UISplitViewController { @@ -21,7 +22,7 @@ class MainSplitViewController: UISplitViewController { private var tabBarViewController: MainTabBarViewController! - private var navigationMode: Preferences.WidescreenNavigationMode! + private var navigationMode: WidescreenNavigationMode! private var secondaryNavController: NavigationControllerProtocol! { viewController(for: .secondary) as? NavigationControllerProtocol } @@ -113,7 +114,7 @@ class MainSplitViewController: UISplitViewController { .store(in: &cancellables) } - private func updateNavigationMode(_ mode: Preferences.WidescreenNavigationMode) { + private func updateNavigationMode(_ mode: WidescreenNavigationMode) { guard mode != navigationMode else { return } diff --git a/Tusker/Screens/Preferences/AdvancedPrefsView.swift b/Tusker/Screens/Preferences/AdvancedPrefsView.swift index 638185a5..6d756e0f 100644 --- a/Tusker/Screens/Preferences/AdvancedPrefsView.swift +++ b/Tusker/Screens/Preferences/AdvancedPrefsView.swift @@ -10,6 +10,7 @@ import Pachyderm import CoreData import CloudKit import UserAccounts +import TuskerPreferences struct AdvancedPrefsView : View { @ObservedObject var preferences = Preferences.shared @@ -41,7 +42,7 @@ struct AdvancedPrefsView : View { Button("Cancel", role: .cancel) {} Button("Enable") { - if let flag = Preferences.FeatureFlag(rawValue: featureFlagName) { + if let flag = FeatureFlag(rawValue: featureFlagName) { preferences.enabledFeatureFlags.insert(flag) } } diff --git a/Tusker/Screens/Preferences/AppearancePrefsView.swift b/Tusker/Screens/Preferences/AppearancePrefsView.swift index 409c2086..9ee53759 100644 --- a/Tusker/Screens/Preferences/AppearancePrefsView.swift +++ b/Tusker/Screens/Preferences/AppearancePrefsView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import TuskerPreferences struct AppearancePrefsView : View { @ObservedObject var preferences = Preferences.shared @@ -27,7 +28,7 @@ struct AppearancePrefsView : View { Preferences.shared.avatarStyle = $0 ? .circle : .roundRect } - private let accentColorsAndImages: [(Preferences.AccentColor, UIImage?)] = Preferences.AccentColor.allCases.map { color in + private let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in var image: UIImage? if let color = color.color { if #available(iOS 16.0, *) { diff --git a/Tusker/Screens/Preferences/BehaviorPrefsView.swift b/Tusker/Screens/Preferences/BehaviorPrefsView.swift index a2d9ea58..57b6c98f 100644 --- a/Tusker/Screens/Preferences/BehaviorPrefsView.swift +++ b/Tusker/Screens/Preferences/BehaviorPrefsView.swift @@ -7,6 +7,7 @@ import SwiftUI import Pachyderm +import TuskerPreferences struct BehaviorPrefsView: View { @ObservedObject var preferences = Preferences.shared @@ -39,8 +40,8 @@ struct BehaviorPrefsView: View { } Picker(selection: $preferences.timelineSyncMode) { - Text("iCloud").tag(Preferences.TimelineSyncMode.icloud) - Text("Mastodon").tag(Preferences.TimelineSyncMode.mastodon) + Text("iCloud").tag(TimelineSyncMode.icloud) + Text("Mastodon").tag(TimelineSyncMode.mastodon) } label: { Text("Sync Timeline Position via") } diff --git a/Tusker/Screens/Preferences/MediaPrefsView.swift b/Tusker/Screens/Preferences/MediaPrefsView.swift index ed5ca75f..270acd98 100644 --- a/Tusker/Screens/Preferences/MediaPrefsView.swift +++ b/Tusker/Screens/Preferences/MediaPrefsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TuskerPreferences struct MediaPrefsView: View { @ObservedObject var preferences = Preferences.shared @@ -23,7 +24,7 @@ struct MediaPrefsView: View { var viewingSection: some View { Section(header: Text("Viewing")) { Picker(selection: $preferences.attachmentBlurMode) { - ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in + ForEach(AttachmentBlurMode.allCases, id: \.self) { mode in Text(mode.displayName).tag(mode) } } label: { diff --git a/Tusker/Screens/Preferences/WidescreenNavigationPrefsView.swift b/Tusker/Screens/Preferences/WidescreenNavigationPrefsView.swift index 912fa19c..6f765838 100644 --- a/Tusker/Screens/Preferences/WidescreenNavigationPrefsView.swift +++ b/Tusker/Screens/Preferences/WidescreenNavigationPrefsView.swift @@ -8,6 +8,7 @@ import SwiftUI import Combine +import TuskerPreferences struct WidescreenNavigationPrefsView: View { @ObservedObject private var preferences = Preferences.shared @@ -59,8 +60,8 @@ struct WidescreenNavigationPrefsView: View { } private struct OptionView: View { - let value: Preferences.WidescreenNavigationMode - @Binding var selection: Preferences.WidescreenNavigationMode + let value: WidescreenNavigationMode + @Binding var selection: WidescreenNavigationMode let startAnimation: PassthroughSubject @ViewBuilder let label: Text @Environment(\.colorScheme) private var colorScheme From a4d13ad03bce3d0b094fb95a427b7f94309a69e5 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 13 Apr 2024 22:36:42 -0400 Subject: [PATCH 2/2] Only migrate changed preferences --- .../Sources/TuskerPreferences/Coding.swift | 3 + .../Legacy/PreferenceStore+Migrate.swift | 146 +++++++++++------- .../TuskerPreferences/Preference.swift | 27 ++-- .../PreferenceStoreTests.swift | 4 +- 4 files changed, 111 insertions(+), 69 deletions(-) diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift index ed06c64e..10094bcd 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Coding.swift @@ -13,6 +13,9 @@ private protocol PreferenceProtocol { init() } +extension Preference: PreferenceProtocol { +} + struct PreferenceCoding: Codable { let wrapped: Wrapped diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift index 1858e2b8..43d87a71 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Legacy/PreferenceStore+Migrate.swift @@ -6,67 +6,97 @@ // import Foundation +import UIKit extension PreferenceStore { func migrate(from legacy: LegacyPreferences) { - self.theme = switch legacy.theme { - case .light: .light - case .dark: .dark - default: .unspecified + let migrations: [any MigrationProtocol] = [ + Migration(from: \.theme.theme, to: \.$theme), + Migration(from: \.pureBlackDarkMode, to: \.$pureBlackDarkMode), + Migration(from: \.accentColor, to: \.$accentColor), + Migration(from: \.avatarStyle, to: \.$avatarStyle), + Migration(from: \.hideCustomEmojiInUsernames, to: \.$hideCustomEmojiInUsernames), + Migration(from: \.showIsStatusReplyIcon, to: \.$showIsStatusReplyIcon), + Migration(from: \.alwaysShowStatusVisibilityIcon, to: \.$alwaysShowStatusVisibilityIcon), + Migration(from: \.hideActionsInTimeline, to: \.$hideActionsInTimeline), + Migration(from: \.showLinkPreviews, to: \.$showLinkPreviews), + Migration(from: \.leadingStatusSwipeActions, to: \.$leadingStatusSwipeActions), + Migration(from: \.trailingStatusSwipeActions, to: \.$trailingStatusSwipeActions), + Migration(from: \.widescreenNavigationMode, to: \.$widescreenNavigationMode), + Migration(from: \.underlineTextLinks, to: \.$underlineTextLinks), + Migration(from: \.showAttachmentsInTimeline, to: \.$showAttachmentsInTimeline), + + Migration(from: \.defaultPostVisibility, to: \.$defaultPostVisibility), + Migration(from: \.defaultReplyVisibility, to: \.$defaultReplyVisibility), + Migration(from: \.requireAttachmentDescriptions, to: \.$requireAttachmentDescriptions), + Migration(from: \.contentWarningCopyMode, to: \.$contentWarningCopyMode), + Migration(from: \.mentionReblogger, to: \.$mentionReblogger), + Migration(from: \.useTwitterKeyboard, to: \.$useTwitterKeyboard), + + Migration(from: \.attachmentBlurMode, to: \.$attachmentBlurMode), + Migration(from: \.blurMediaBehindContentWarning, to: \.$blurMediaBehindContentWarning), + Migration(from: \.automaticallyPlayGifs, to: \.$automaticallyPlayGifs), + Migration(from: \.showUncroppedMediaInline, to: \.$showUncroppedMediaInline), + Migration(from: \.showAttachmentBadges, to: \.$showAttachmentBadges), + Migration(from: \.attachmentAltBadgeInverted, to: \.$attachmentAltBadgeInverted), + + Migration(from: \.openLinksInApps, to: \.$openLinksInApps), + Migration(from: \.useInAppSafari, to: \.$useInAppSafari), + Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode), + Migration(from: \.expandAllContentWarnings, to: \.$expandAllContentWarnings), + Migration(from: \.collapseLongPosts, to: \.$collapseLongPosts), + Migration(from: \.oppositeCollapseKeywords, to: \.$oppositeCollapseKeywords), + Migration(from: \.confirmBeforeReblog, to: \.$confirmBeforeReblog), + Migration(from: \.timelineStateRestoration, to: \.$timelineStateRestoration), + Migration(from: \.timelineSyncMode, to: \.$timelineSyncMode), + Migration(from: \.hideReblogsInTimelines, to: \.$hideReblogsInTimelines), + Migration(from: \.hideRepliesInTimelines, to: \.$hideRepliesInTimelines), + + Migration(from: \.showFavoriteAndReblogCounts, to: \.$showFavoriteAndReblogCounts), + Migration(from: \.defaultNotificationsMode, to: \.$defaultNotificationsMode), + Migration(from: \.grayscaleImages, to: \.$grayscaleImages), + Migration(from: \.disableInfiniteScrolling, to: \.$disableInfiniteScrolling), + Migration(from: \.hideTrends, to: \.$hideTrends), + + Migration(from: \.statusContentType, to: \.$statusContentType), + Migration(from: \.reportErrorsAutomatically, to: \.$reportErrorsAutomatically), + Migration(from: \.enabledFeatureFlags, to: \.$enabledFeatureFlags), + + Migration(from: \.hasShownLocalTimelineDescription, to: \.$hasShownLocalTimelineDescription), + Migration(from: \.hasShownFederatedTimelineDescription, to: \.$hasShownFederatedTimelineDescription), + ] + + for migration in migrations { + migration.migrate(from: legacy, to: self) + } + } +} + +private protocol MigrationProtocol { + func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) +} + +private struct Migration: MigrationProtocol where Key.Value: Equatable { + let from: KeyPath + let to: KeyPath> + + func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) { + let value = legacy[keyPath: from] + if value != Key.defaultValue { + Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value) + } + } +} + +private extension UIUserInterfaceStyle { + var theme: Theme { + switch self { + case .light: + .light + case .dark: + .dark + default: + .unspecified } - self.pureBlackDarkMode = legacy.pureBlackDarkMode - self.accentColor = legacy.accentColor - self.avatarStyle = legacy.avatarStyle - self.hideCustomEmojiInUsernames = legacy.hideCustomEmojiInUsernames - self.showIsStatusReplyIcon = legacy.showIsStatusReplyIcon - self.alwaysShowStatusVisibilityIcon = legacy.alwaysShowStatusVisibilityIcon - self.hideActionsInTimeline = legacy.hideActionsInTimeline - self.showLinkPreviews = legacy.showLinkPreviews - self.leadingStatusSwipeActions = legacy.leadingStatusSwipeActions - self.trailingStatusSwipeActions = legacy.trailingStatusSwipeActions - if legacy.widescreenNavigationMode != .splitScreen { - self.widescreenNavigationMode = legacy.widescreenNavigationMode - } - self.underlineTextLinks = legacy.underlineTextLinks - self.showAttachmentsInTimeline = legacy.showAttachmentsInTimeline - - self.defaultPostVisibility = legacy.defaultPostVisibility - self.defaultReplyVisibility = legacy.defaultReplyVisibility - self.requireAttachmentDescriptions = legacy.requireAttachmentDescriptions - self.contentWarningCopyMode = legacy.contentWarningCopyMode - self.mentionReblogger = legacy.mentionReblogger - self.useTwitterKeyboard = legacy.useTwitterKeyboard - - self.attachmentBlurMode = legacy.attachmentBlurMode - self.blurMediaBehindContentWarning = legacy.blurMediaBehindContentWarning - self.automaticallyPlayGifs = legacy.automaticallyPlayGifs - self.showUncroppedMediaInline = legacy.showUncroppedMediaInline - self.showAttachmentBadges = legacy.showAttachmentBadges - self.attachmentAltBadgeInverted = legacy.attachmentAltBadgeInverted - - self.openLinksInApps = legacy.openLinksInApps - self.useInAppSafari = legacy.useInAppSafari - self.inAppSafariAutomaticReaderMode = legacy.inAppSafariAutomaticReaderMode - self.expandAllContentWarnings = legacy.expandAllContentWarnings - self.collapseLongPosts = legacy.collapseLongPosts - self.oppositeCollapseKeywords = legacy.oppositeCollapseKeywords - self.confirmBeforeReblog = legacy.confirmBeforeReblog - self.timelineStateRestoration = legacy.timelineStateRestoration - self.timelineSyncMode = legacy.timelineSyncMode - self.hideReblogsInTimelines = legacy.hideReblogsInTimelines - self.hideRepliesInTimelines = legacy.hideRepliesInTimelines - - self.showFavoriteAndReblogCounts = legacy.showFavoriteAndReblogCounts - self.defaultNotificationsMode = legacy.defaultNotificationsMode - self.grayscaleImages = legacy.grayscaleImages - self.disableInfiniteScrolling = legacy.disableInfiniteScrolling - self.hideTrends = legacy.hideTrends - - self.statusContentType = legacy.statusContentType - self.reportErrorsAutomatically = legacy.reportErrorsAutomatically - self.enabledFeatureFlags = legacy.enabledFeatureFlags - - self.hasShownLocalTimelineDescription = legacy.hasShownLocalTimelineDescription - self.hasShownFederatedTimelineDescription = legacy.hasShownFederatedTimelineDescription } } diff --git a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift index d15a6c54..9d96aec8 100644 --- a/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift +++ b/Packages/TuskerPreferences/Sources/TuskerPreferences/Preference.swift @@ -45,10 +45,10 @@ final class Preference: Codable { storage storageKeyPath: ReferenceWritableKeyPath ) -> Key.Value { get { - get(enclosingInstance: instance, wrapped: wrappedKeyPath, storage: storageKeyPath) + get(enclosingInstance: instance, storage: storageKeyPath) } set { - set(enclosingInstance: instance, wrapped: wrappedKeyPath, storage: storageKeyPath, newValue: newValue) + set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue) Key.didSet(in: instance, newValue: newValue) } } @@ -57,8 +57,7 @@ final class Preference: Codable { @inline(__always) static func get( enclosingInstance: Enclosing, - wrapped: ReferenceWritableKeyPath, - storage: ReferenceWritableKeyPath + storage: KeyPath ) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher { let pref = enclosingInstance[keyPath: storage] return pref.storedValue ?? Key.defaultValue @@ -68,16 +67,26 @@ final class Preference: Codable { @inline(__always) static func set( enclosingInstance: Enclosing, - wrapped: ReferenceWritableKeyPath, - storage: ReferenceWritableKeyPath, + storage: KeyPath, newValue: Key.Value ) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher { enclosingInstance.objectWillChange.send() let pref = enclosingInstance[keyPath: storage] pref.storedValue = newValue } - - var projectedValue: some Publisher { - $storedValue.map { $0 ?? Key.defaultValue } + + var projectedValue: PreferencePublisher { + .init(preference: self) + } +} + +struct PreferencePublisher: Publisher { + typealias Output = Key.Value + typealias Failure = Never + + let preference: Preference + + func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input { + preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber) } } diff --git a/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift b/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift index 8ee6f19f..1527dbec 100644 --- a/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift +++ b/Packages/TuskerPreferences/Tests/TuskerPreferencesTests/PreferenceStoreTests.swift @@ -21,10 +21,10 @@ final class PreferenceStoreTests: XCTestCase { // the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually var test: Bool { get { - Preference.get(enclosingInstance: self, wrapped: \.test, storage: \._test) + Preference.get(enclosingInstance: self, storage: \._test) } set { - Preference.set(enclosingInstance: self, wrapped: \.test, storage: \._test, newValue: newValue) + Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue) } }