Compare commits

...

7 Commits

52 changed files with 1393 additions and 489 deletions

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>1C8F.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
</dict>
</array>
</dict>
</plist>

View File

@ -184,6 +184,31 @@ public final class InstanceFeatures: ObservableObject {
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil)) hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
} }
public var pushNotificationTypeStatus: Bool {
hasMastodonVersion(3, 3, 0)
}
public var pushNotificationTypeFollowRequest: Bool {
hasMastodonVersion(3, 1, 0)
}
public var pushNotificationTypeUpdate: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pushNotificationPolicy: Bool {
hasMastodonVersion(3, 5, 0)
}
public var pushNotificationPolicyMissingFromResponse: Bool {
switch instanceType {
case .mastodon(_, let version):
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
default:
return false
}
}
public init() { public init() {
} }

View File

@ -9,11 +9,11 @@
import Foundation import Foundation
public struct PushSubscription: Decodable, Sendable { public struct PushSubscription: Decodable, Sendable {
public let id: String public var id: String
public let endpoint: URL public var endpoint: URL
public let serverKey: String public var serverKey: String
public let alerts: Alerts public var alerts: Alerts
public let policy: Policy public var policy: Policy
public init(from decoder: any Decoder) throws { public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
@ -27,7 +27,8 @@ public struct PushSubscription: Decodable, Sendable {
self.endpoint = try container.decode(URL.self, forKey: .endpoint) self.endpoint = try container.decode(URL.self, forKey: .endpoint)
self.serverKey = try container.decode(String.self, forKey: .serverKey) self.serverKey = try container.decode(String.self, forKey: .serverKey)
self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts) self.alerts = try container.decode(PushSubscription.Alerts.self, forKey: .alerts)
self.policy = try container.decode(PushSubscription.Policy.self, forKey: .policy) // added in mastodon 4.1.0
self.policy = try container.decodeIfPresent(PushSubscription.Policy.self, forKey: .policy) ?? .all
} }
public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> { public static func create(endpoint: URL, publicKey: Data, authSecret: Data, alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
@ -96,6 +97,21 @@ extension PushSubscription {
self.update = update self.update = update
} }
public init(from decoder: any Decoder) throws {
let container: KeyedDecodingContainer<PushSubscription.Alerts.CodingKeys> = try decoder.container(keyedBy: PushSubscription.Alerts.CodingKeys.self)
self.mention = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.mention)
// status added in mastodon 3.3.0
self.status = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.status) ?? false
self.reblog = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.reblog)
self.follow = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.follow)
// follow_request added in 3.1.0
self.followRequest = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.followRequest) ?? false
self.favourite = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.favourite)
self.poll = try container.decode(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.poll)
// update added in mastodon 3.5.0
self.update = try container.decodeIfPresent(Bool.self, forKey: PushSubscription.Alerts.CodingKeys.update) ?? false
}
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case mention case mention
case status case status

View File

@ -53,7 +53,7 @@ public struct PushSubscription {
self.policy = policy self.policy = policy
} }
public enum Policy: String, CaseIterable, Identifiable { public enum Policy: String, CaseIterable, Identifiable, Sendable {
case all, followed, followers case all, followed, followers
public var id: some Hashable { public var id: some Hashable {
@ -61,7 +61,7 @@ public struct PushSubscription {
} }
} }
public struct Alerts: OptionSet, Hashable { public struct Alerts: OptionSet, Hashable, Sendable {
public static let mention = Alerts(rawValue: 1 << 0) public static let mention = Alerts(rawValue: 1 << 0)
public static let status = Alerts(rawValue: 1 << 1) public static let status = Alerts(rawValue: 1 << 1)
public static let reblog = Alerts(rawValue: 1 << 2) public static let reblog = Alerts(rawValue: 1 << 2)

View File

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

View File

@ -24,5 +24,9 @@ let package = Package(
name: "TuskerPreferences", name: "TuskerPreferences",
dependencies: ["Pachyderm"] dependencies: ["Pachyderm"]
), ),
.testTarget(
name: "TuskerPreferencesTests",
dependencies: ["TuskerPreferences"]
)
] ]
) )

View File

@ -0,0 +1,282 @@
//
// Coding.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
private protocol PreferenceProtocol {
associatedtype Key: PreferenceKey
var storedValue: Key.Value? { get }
init()
}
extension Preference: PreferenceProtocol {
}
struct PreferenceCoding<Wrapped: Codable>: 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<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> 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<Key: CodingKey>: KeyedDecodingContainerProtocol {
let wrapped: KeyedDecodingContainer<Key>
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<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
if let type = type as? any PreferenceProtocol.Type,
!contains(key) {
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
P() as! T
}
return _openExistential(type, do: makePreference)
}
return try wrapped.decode(type, forKey: key)
}
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> 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<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> 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<Key: CodingKey>: KeyedEncodingContainerProtocol {
var wrapped: KeyedEncodingContainer<Key>
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<T>(_ 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<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> 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)
}
}

View File

@ -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<FeatureFlag> { [] }
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
//
// DigitalWellnessKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct NotificationsModeKey: PreferenceKey {
static var defaultValue: NotificationsMode { .allNotifications }
}

View File

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

View File

@ -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<FeatureFlag> = []
// 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 {}

View File

@ -0,0 +1,102 @@
//
// PreferenceStore+Migrate.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
extension PreferenceStore {
func migrate(from legacy: LegacyPreferences) {
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<Key: PreferenceKey>: MigrationProtocol where Key.Value: Equatable {
let from: KeyPath<LegacyPreferences, Key.Value>
let to: KeyPath<PreferenceStore, PreferencePublisher<Key>>
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
}
}
}

View File

@ -0,0 +1,92 @@
//
// 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<Key: PreferenceKey>: 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<PreferenceStore, Key.Value>,
storage storageKeyPath: ReferenceWritableKeyPath<PreferenceStore, Preference>
) -> Key.Value {
get {
get(enclosingInstance: instance, storage: storageKeyPath)
}
set {
set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue)
Key.didSet(in: instance, newValue: newValue)
}
}
// for testing only
@inline(__always)
static func get<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>
) -> 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<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>,
newValue: Key.Value
) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
enclosingInstance.objectWillChange.send()
let pref = enclosingInstance[keyPath: storage]
pref.storedValue = newValue
}
var projectedValue: PreferencePublisher<Key> {
.init(preference: self)
}
}
struct PreferencePublisher<Key: PreferenceKey>: Publisher {
typealias Output = Key.Value
typealias Failure = Never
let preference: Preference<Key>
func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
}
}

View File

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

View File

@ -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<ThemeKey> public var theme
@Preference<TrueKey> public var pureBlackDarkMode
@Preference<AccentColorKey> public var accentColor
@Preference<AvatarStyleKey> public var avatarStyle
@Preference<FalseKey> public var hideCustomEmojiInUsernames
@Preference<FalseKey> public var showIsStatusReplyIcon
@Preference<FalseKey> public var alwaysShowStatusVisibilityIcon
@Preference<FalseKey> public var hideActionsInTimeline
@Preference<TrueKey> public var showLinkPreviews
@Preference<LeadingSwipeActionsKey> public var leadingStatusSwipeActions
@Preference<TrailingSwipeActionsKey> public var trailingStatusSwipeActions
@Preference<WidescreenNavigationModeKey> public var widescreenNavigationMode
@Preference<FalseKey> public var underlineTextLinks
@Preference<TrueKey> public var showAttachmentsInTimeline
// MARK: Composing
@Preference<PostVisibilityKey> public var defaultPostVisibility
@Preference<ReplyVisibilityKey> public var defaultReplyVisibility
@Preference<FalseKey> public var requireAttachmentDescriptions
@Preference<ContentWarningCopyModeKey> public var contentWarningCopyMode
@Preference<FalseKey> public var mentionReblogger
@Preference<FalseKey> public var useTwitterKeyboard
// MARK: Media
@Preference<AttachmentBlurModeKey> public var attachmentBlurMode
@Preference<TrueKey> public var blurMediaBehindContentWarning
@Preference<TrueKey> public var automaticallyPlayGifs
@Preference<TrueKey> public var showUncroppedMediaInline
@Preference<TrueKey> public var showAttachmentBadges
@Preference<FalseKey> public var attachmentAltBadgeInverted
// MARK: Behavior
@Preference<TrueKey> public var openLinksInApps
@Preference<TrueKey> public var useInAppSafari
@Preference<FalseKey> public var inAppSafariAutomaticReaderMode
@Preference<FalseKey> public var expandAllContentWarnings
@Preference<TrueKey> public var collapseLongPosts
@Preference<OppositeCollapseKeywordsKey> public var oppositeCollapseKeywords
@Preference<ConfirmReblogKey> public var confirmBeforeReblog
@Preference<TrueKey> public var timelineStateRestoration
@Preference<TimelineSyncModeKey> public var timelineSyncMode
@Preference<FalseKey> public var hideReblogsInTimelines
@Preference<FalseKey> public var hideRepliesInTimelines
// MARK: Digital Wellness
@Preference<TrueKey> public var showFavoriteAndReblogCounts
@Preference<NotificationsModeKey> public var defaultNotificationsMode
@Preference<FalseKey> public var grayscaleImages
@Preference<FalseKey> public var disableInfiniteScrolling
@Preference<FalseKey> public var hideTrends
// MARK: Advanced
@Preference<StatusContentTypeKey> public var statusContentType
@Preference<TrueKey> public var reportErrorsAutomatically
@Preference<FeatureFlagsKey> public var enabledFeatureFlags
// MARK: Internal
@Preference<FalseKey> public var hasShownLocalTimelineDescription
@Preference<FalseKey> public var hasShownFederatedTimelineDescription
}
extension PreferenceStore {
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
}

View File

@ -2,430 +2,42 @@
// Preferences.swift // Preferences.swift
// TuskerPreferences // TuskerPreferences
// //
// Created by Shadowfacts on 8/28/18. // Created by Shadowfacts on 4/12/24.
// Copyright © 2018 Shadowfacts. All rights reserved.
// //
import UIKit import Foundation
import Pachyderm
import Combine
public final class Preferences: Codable, ObservableObject {
public struct Preferences {
@MainActor @MainActor
public static var shared: Preferences = load() public static let shared: PreferenceStore = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! 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 appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") 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() {}
@MainActor @MainActor
public static func save() { public static func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared) let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
try? data?.write(to: archiveURL, options: .noFileProtection) try? data?.write(to: preferencesURL, options: .noFileProtection)
} }
public static func load() -> Preferences { private static func load() -> PreferenceStore {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: preferencesURL),
let preferences = try? decoder.decode(Preferences.self, from: data) { let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
return preferences return store.wrapped
} } else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
return Preferences() let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
} let store = PreferenceStore()
store.migrate(from: legacy)
@MainActor return store
public static func migrate(from url: URL) -> Result<Void, any Error> {
do {
try? FileManager.default.removeItem(at: archiveURL)
try FileManager.default.moveItem(at: url, to: archiveURL)
} catch {
return .failure(error)
}
shared = load()
return .success(())
}
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)
} else { } else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) return PreferenceStore()
}
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<FeatureFlag> = []
// 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"
}
} }
} }
} }
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
}
}

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
case serverDefault case serverDefault
case visibility(Visibility) case visibility(Visibility)
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) } public private(set) static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
switch self { switch self {
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
case sameAsPost case sameAsPost
case visibility(Visibility) case visibility(Visibility)
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) } public private(set) static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
@MainActor @MainActor
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility { public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {

View File

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

View File

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

View File

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

View File

@ -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<TestKey>()
// the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually
var test: Bool {
get {
Preference.get(enclosingInstance: self, storage: \._test)
}
set {
Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue)
}
}
var testPublisher: some Publisher<TestKey.Value, Never> {
_test.projectedValue
}
init() {
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self._test = try container.decode(Preference<TestKey>.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<TestStore>.self, from: Data("""
{"test": true}
""".utf8)).wrapped
XCTAssertEqual(present.test, true)
let absent = try decoder.decode(PreferenceCoding<TestStore>.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<AnyCancellable>()
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])
}
}

View File

@ -8,7 +8,8 @@
import Foundation import Foundation
import Combine import Combine
public class UserAccountsManager: ObservableObject { // Sendability: UserDefaults is not marked Sendable, but is documented as being thread safe
public final class UserAccountsManager: ObservableObject, @unchecked Sendable {
public static let shared = UserAccountsManager() public static let shared = UserAccountsManager()

View File

@ -713,6 +713,7 @@
D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; }; D6C3F4FA299035650009FCFF /* TrendsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendsViewController.swift; sourceTree = "<group>"; };
D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = "<group>"; }; D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+AppListStyle.swift"; sourceTree = "<group>"; };
D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = "<group>"; }; D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineJumpButton.swift; sourceTree = "<group>"; };
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -1009,6 +1010,7 @@
D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */, D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */,
D630C3D32BC61B6100208903 /* NotificationService.swift */, D630C3D32BC61B6100208903 /* NotificationService.swift */,
D630C3D52BC61B6100208903 /* Info.plist */, D630C3D52BC61B6100208903 /* Info.plist */,
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */,
); );
path = NotificationExtension; path = NotificationExtension;
sourceTree = "<group>"; sourceTree = "<group>";

View File

@ -23,10 +23,10 @@ class LogoutService {
func run() { func run() {
let accountInfo = self.accountInfo let accountInfo = self.accountInfo
Task.detached { Task.detached { @MainActor in
if await PushManager.shared.pushSubscription(account: accountInfo) != nil { if PushManager.shared.pushSubscription(account: accountInfo) != nil {
_ = try? await self.mastodonController.run(Pachyderm.PushSubscription.delete()) _ = try? await self.mastodonController.run(Pachyderm.PushSubscription.delete())
await PushManager.shared.removeSubscription(account: accountInfo) PushManager.shared.removeSubscription(account: accountInfo)
} }
try? await self.mastodonController.client.revokeAccessToken() try? await self.mastodonController.client.revokeAccessToken()
} }

View File

@ -33,7 +33,13 @@ extension MastodonController {
func updatePushSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async throws -> Pachyderm.PushSubscription { func updatePushSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async throws -> Pachyderm.PushSubscription {
let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy)) let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy))
return try await run(req).0 var result = try await run(req).0
if instanceFeatures.pushNotificationPolicyMissingFromResponse {
// see https://github.com/mastodon/mastodon/issues/23145
// so just assume if the request was successful that it worked
result.policy = .init(policy)
}
return result
} }
func deletePushSubscription() async throws { func deletePushSubscription() async throws {

View File

@ -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 // 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 // otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
_ = DraftsPersistentContainer.shared _ = DraftsPersistentContainer.shared
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.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) { for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {

View File

@ -33,7 +33,7 @@ extension TuskerSceneDelegate {
func applyAppearancePreferences() { func applyAppearancePreferences() {
guard let window else { return } guard let window else { return }
window.overrideUserInterfaceStyle = Preferences.shared.theme window.overrideUserInterfaceStyle = Preferences.shared.theme.userInterfaceStyle
window.tintColor = Preferences.shared.accentColor.color window.tintColor = Preferences.shared.accentColor.color
#if os(visionOS) #if os(visionOS)
window.traitOverrides.pureBlackDarkMode = Preferences.shared.pureBlackDarkMode window.traitOverrides.pureBlackDarkMode = Preferences.shared.pureBlackDarkMode

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Combine import Combine
import TuskerPreferences
class MainSplitViewController: UISplitViewController { class MainSplitViewController: UISplitViewController {
@ -21,7 +22,7 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController! private var tabBarViewController: MainTabBarViewController!
private var navigationMode: Preferences.WidescreenNavigationMode! private var navigationMode: WidescreenNavigationMode!
private var secondaryNavController: NavigationControllerProtocol! { private var secondaryNavController: NavigationControllerProtocol! {
viewController(for: .secondary) as? NavigationControllerProtocol viewController(for: .secondary) as? NavigationControllerProtocol
} }
@ -113,7 +114,7 @@ class MainSplitViewController: UISplitViewController {
.store(in: &cancellables) .store(in: &cancellables)
} }
private func updateNavigationMode(_ mode: Preferences.WidescreenNavigationMode) { private func updateNavigationMode(_ mode: WidescreenNavigationMode) {
guard mode != navigationMode else { guard mode != navigationMode else {
return return
} }

View File

@ -59,7 +59,12 @@ struct AboutView: View {
} }
} label: { } label: {
HStack { HStack {
Label("Get Support", systemImage: "envelope") Label {
Text("Get Support")
} icon: {
Image(systemName: "envelope")
.foregroundStyle(.tint)
}
Spacer() Spacer()
if isGettingLogData { if isGettingLogData {
ProgressView() ProgressView()
@ -75,7 +80,6 @@ struct AboutView: View {
Label("Issue Tracker", systemImage: "checklist") Label("Issue Tracker", systemImage: "checklist")
} }
} }
.labelStyle(AboutLinksLabelStyle())
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
@ -174,15 +178,6 @@ private struct MailSheet: UIViewControllerRepresentable {
} }
} }
private struct AboutLinksLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .lastTextBaseline, spacing: 8) {
configuration.icon
configuration.title
}
}
}
struct AboutView_Previews: PreviewProvider { struct AboutView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AboutView() AboutView()

View File

@ -107,7 +107,7 @@ struct FlipEffect: GeometryEffect {
} }
private struct WidthPrefKey: PreferenceKey { private struct WidthPrefKey: PreferenceKey {
static var defaultValue: CGFloat = 0 static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue() value = nextValue()

View File

@ -292,7 +292,7 @@ private extension AttributeScopes {
private enum HeadingLevelAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey { private enum HeadingLevelAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
public typealias Value = Int public typealias Value = Int
public static var name = "headingLevel" public static let name = "headingLevel"
} }
private extension AttributeDynamicLookup { private extension AttributeDynamicLookup {

View File

@ -10,6 +10,7 @@ import Pachyderm
import CoreData import CoreData
import CloudKit import CloudKit
import UserAccounts import UserAccounts
import TuskerPreferences
struct AdvancedPrefsView : View { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -41,7 +42,7 @@ struct AdvancedPrefsView : View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Enable") { Button("Enable") {
if let flag = Preferences.FeatureFlag(rawValue: featureFlagName) { if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag) preferences.enabledFeatureFlags.insert(flag)
} }
} }

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TuskerPreferences
struct AppearancePrefsView : View { struct AppearancePrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -27,7 +28,7 @@ struct AppearancePrefsView : View {
Preferences.shared.avatarStyle = $0 ? .circle : .roundRect 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? var image: UIImage?
if let color = color.color { if let color = color.color {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerPreferences
struct BehaviorPrefsView: View { struct BehaviorPrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -39,8 +40,8 @@ struct BehaviorPrefsView: View {
} }
Picker(selection: $preferences.timelineSyncMode) { Picker(selection: $preferences.timelineSyncMode) {
Text("iCloud").tag(Preferences.TimelineSyncMode.icloud) Text("iCloud").tag(TimelineSyncMode.icloud)
Text("Mastodon").tag(Preferences.TimelineSyncMode.mastodon) Text("Mastodon").tag(TimelineSyncMode.mastodon)
} label: { } label: {
Text("Sync Timeline Position via") Text("Sync Timeline Position via")
} }

View File

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TuskerPreferences
struct MediaPrefsView: View { struct MediaPrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -23,7 +24,7 @@ struct MediaPrefsView: View {
var viewingSection: some View { var viewingSection: some View {
Section(header: Text("Viewing")) { Section(header: Text("Viewing")) {
Picker(selection: $preferences.attachmentBlurMode) { Picker(selection: $preferences.attachmentBlurMode) {
ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in ForEach(AttachmentBlurMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode) Text(mode.displayName).tag(mode)
} }
} label: { } label: {

View File

@ -13,7 +13,6 @@ import PushNotifications
import TuskerComponents import TuskerComponents
struct NotificationsPrefsView: View { struct NotificationsPrefsView: View {
@State private var error: NotificationsSetupError?
@ObservedObject private var userAccounts = UserAccountsManager.shared @ObservedObject private var userAccounts = UserAccountsManager.shared
var body: some View { var body: some View {
@ -48,14 +47,3 @@ struct NotificationsPrefsView: View {
.navigationTitle("Notifications") .navigationTitle("Notifications")
} }
} }
private enum NotificationsSetupError: LocalizedError {
case requestingAuthorization(any Error)
var errorDescription: String? {
switch self {
case .requestingAuthorization(let error):
"Notifications authorization request failed: \(error.localizedDescription)"
}
}
}

View File

@ -14,6 +14,7 @@ import TuskerComponents
struct PushInstanceSettingsView: View { struct PushInstanceSettingsView: View {
let account: UserAccountInfo let account: UserAccountInfo
let mastodonController: MastodonController
@State private var mode: AsyncToggle.Mode @State private var mode: AsyncToggle.Mode
@State private var error: Error? @State private var error: Error?
@State private var subscription: PushNotifications.PushSubscription? @State private var subscription: PushNotifications.PushSubscription?
@ -22,6 +23,7 @@ struct PushInstanceSettingsView: View {
@MainActor @MainActor
init(account: UserAccountInfo) { init(account: UserAccountInfo) {
self.account = account self.account = account
self.mastodonController = .getForAccount(account)
let subscription = PushManager.shared.pushSubscription(account: account) let subscription = PushManager.shared.pushSubscription(account: account)
self.subscription = subscription self.subscription = subscription
self.mode = subscription == nil ? .off : .on self.mode = subscription == nil ? .off : .on
@ -35,7 +37,7 @@ struct PushInstanceSettingsView: View {
AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:)) AsyncToggle("\(account.instanceURL.host!) notifications enabled", labelHidden: true, mode: $mode, onChange: updateNotificationsEnabled(enabled:))
.labelsHidden() .labelsHidden()
} }
PushSubscriptionView(account: account, subscription: subscription, updateSubscription: updateSubscription) PushSubscriptionView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
} }
.alertWithData("An Error Occurred", data: $error) { data in .alertWithData("An Error Occurred", data: $error) { data in
Button("OK") {} Button("OK") {}

View File

@ -13,12 +13,13 @@ import TuskerComponents
struct PushSubscriptionView: View { struct PushSubscriptionView: View {
let account: UserAccountInfo let account: UserAccountInfo
let mastodonController: MastodonController
let subscription: PushSubscription? let subscription: PushSubscription?
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
var body: some View { var body: some View {
if let subscription { if let subscription {
PushSubscriptionSettingsView(account: account, subscription: subscription, updateSubscription: updateSubscription) PushSubscriptionSettingsView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
} else { } else {
Text("No notifications") Text("No notifications")
.font(.callout) .font(.callout)
@ -29,28 +30,25 @@ struct PushSubscriptionView: View {
private struct PushSubscriptionSettingsView: View { private struct PushSubscriptionSettingsView: View {
let account: UserAccountInfo let account: UserAccountInfo
let mastodonController: MastodonController
let subscription: PushSubscription let subscription: PushSubscription
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
@State private var isLoading: [PushSubscription.Alerts: Bool] = [:] @State private var isLoading: [PushSubscription.Alerts: Bool] = [:]
init(account: UserAccountInfo, subscription: PushSubscription, updateSubscription: @escaping (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool) {
self.account = account
self.subscription = subscription
self.updateSubscription = updateSubscription
}
var body: some View { var body: some View {
VStack { VStack {
alertsToggles alertsToggles
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in if mastodonController.instanceFeatures.pushNotificationPolicy {
await updateSubscription(subscription.alerts, newPolicy) AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
} content: { await updateSubscription(subscription.alerts, newPolicy)
ForEach(PushSubscription.Policy.allCases) { } content: {
Text($0.displayName).tag($0) ForEach(PushSubscription.Policy.allCases) {
Text($0.displayName).tag($0)
}
} }
.pickerStyle(.menu)
} }
.pickerStyle(.menu)
} }
// this is the default value of the alignment guide, but this modifier is loading bearing // this is the default value of the alignment guide, but this modifier is loading bearing
.alignmentGuide(.prefsAvatar, computeValue: { dimension in .alignmentGuide(.prefsAvatar, computeValue: { dimension in
@ -63,18 +61,35 @@ private struct PushSubscriptionSettingsView: View {
private var alertsToggles: some View { private var alertsToggles: some View {
GroupBox("Get notifications for") { GroupBox("Get notifications for") {
VStack { VStack {
toggle("All", alert: [.mention, .favorite, .reblog, .follow, .followRequest, .poll, .update]) toggle("All", alert: allSupportedAlertTypes)
toggle("Mentions", alert: .mention) toggle("Mentions", alert: .mention)
toggle("Favorites", alert: .favorite) toggle("Favorites", alert: .favorite)
toggle("Reblogs", alert: .reblog) toggle("Reblogs", alert: .reblog)
toggle("Follows", alert: [.follow, .followRequest]) if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
toggle("Follows", alert: [.follow, .followRequest])
} else {
toggle("Follows", alert: .follow)
}
toggle("Polls finishing", alert: .poll) toggle("Polls finishing", alert: .poll)
toggle("Edits", alert: .update) if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
toggle("Edits", alert: .update)
}
// status notifications not supported until we can enable/disable them in the app // status notifications not supported until we can enable/disable them in the app
} }
} }
} }
private var allSupportedAlertTypes: PushSubscription.Alerts {
var alerts: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
alerts.insert(.followRequest)
}
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
alerts.insert(.update)
}
return alerts
}
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View { private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {
let binding: Binding<AsyncToggle.Mode> = Binding { let binding: Binding<AsyncToggle.Mode> = Binding {
isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off

View File

@ -59,7 +59,7 @@ struct ConfettiView: View {
} }
private struct SizeKey: PreferenceKey { private struct SizeKey: PreferenceKey {
static var defaultValue: CGSize = .zero static let defaultValue: CGSize = .zero
static func reduce(value: inout CGSize, nextValue: () -> CGSize) { static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
value = nextValue() value = nextValue()
} }

View File

@ -44,7 +44,7 @@ struct TipJarView: View {
Text(error.localizedDescription) Text(error.localizedDescription)
}) })
.task { .task {
updatesObserver = Task.detached { updatesObserver = Task.detached { @MainActor in
await observeTransactionUpdates() await observeTransactionUpdates()
} }
do { do {
@ -95,6 +95,7 @@ struct TipJarView: View {
} }
} }
@MainActor
private func observeTransactionUpdates() async { private func observeTransactionUpdates() async {
for await verificationResult in StoreKit.Transaction.updates { for await verificationResult in StoreKit.Transaction.updates {
guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else { guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else {
@ -175,6 +176,7 @@ private struct TipRow: View {
} }
} }
@MainActor
private func purchase() async { private func purchase() async {
isPurchasing = true isPurchasing = true
let result: Product.PurchaseResult let result: Product.PurchaseResult
@ -229,7 +231,7 @@ extension HorizontalAlignment {
} }
private struct ButtonWidthKey: PreferenceKey { private struct ButtonWidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0 static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue()) value = max(value, nextValue())
} }

View File

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TuskerPreferences
struct WidescreenNavigationPrefsView: View { struct WidescreenNavigationPrefsView: View {
@ObservedObject private var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
@ -59,8 +60,8 @@ struct WidescreenNavigationPrefsView: View {
} }
private struct OptionView<Content: NavigationModePreview>: View { private struct OptionView<Content: NavigationModePreview>: View {
let value: Preferences.WidescreenNavigationMode let value: WidescreenNavigationMode
@Binding var selection: Preferences.WidescreenNavigationMode @Binding var selection: WidescreenNavigationMode
let startAnimation: PassthroughSubject<Void, Never> let startAnimation: PassthroughSubject<Void, Never>
@ViewBuilder let label: Text @ViewBuilder let label: Text
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme