Compare commits
7 Commits
7825ccbb3d
...
db534e5993
Author | SHA1 | Date |
---|---|---|
Shadowfacts | db534e5993 | |
Shadowfacts | e94bee4fc8 | |
Shadowfacts | 216e58e5ec | |
Shadowfacts | a4d13ad03b | |
Shadowfacts | 05cfecb797 | |
Shadowfacts | 132fcfa099 | |
Shadowfacts | 475b9911b1 |
|
@ -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>
|
|
@ -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() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -24,5 +24,9 @@ let package = Package(
|
||||||
name: "TuskerPreferences",
|
name: "TuskerPreferences",
|
||||||
dependencies: ["Pachyderm"]
|
dependencies: ["Pachyderm"]
|
||||||
),
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "TuskerPreferencesTests",
|
||||||
|
dependencies: ["TuskerPreferences"]
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
|
@ -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> { [] }
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
//
|
||||||
|
// DigitalWellnessKeys.swift
|
||||||
|
// TuskerPreferences
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 4/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationsModeKey: PreferenceKey {
|
||||||
|
static var defaultValue: NotificationsMode { .allNotifications }
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
|
@ -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 {
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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>";
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, *) {
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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") {}
|
||||||
|
|
|
@ -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,20 +30,16 @@ 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
|
||||||
|
|
||||||
|
if mastodonController.instanceFeatures.pushNotificationPolicy {
|
||||||
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
|
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
|
||||||
await updateSubscription(subscription.alerts, newPolicy)
|
await updateSubscription(subscription.alerts, newPolicy)
|
||||||
} content: {
|
} content: {
|
||||||
|
@ -52,6 +49,7 @@ private struct PushSubscriptionSettingsView: View {
|
||||||
}
|
}
|
||||||
.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
|
||||||
dimension[.leading]
|
dimension[.leading]
|
||||||
|
@ -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)
|
||||||
|
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
|
||||||
toggle("Follows", alert: [.follow, .followRequest])
|
toggle("Follows", alert: [.follow, .followRequest])
|
||||||
|
} else {
|
||||||
|
toggle("Follows", alert: .follow)
|
||||||
|
}
|
||||||
toggle("Polls finishing", alert: .poll)
|
toggle("Polls finishing", alert: .poll)
|
||||||
|
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
|
||||||
toggle("Edits", alert: .update)
|
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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue