Compare commits

...

26 Commits

Author SHA1 Message Date
Shadowfacts 798e0c0cf1 Bump build number and update changelog 2024-04-15 22:40:54 -04:00
Shadowfacts 3f370945e6 Fix linker errors when building in release mode 2024-04-15 22:30:20 -04:00
Shadowfacts a759731eba Fix push notifications not working when account ID contains slashes 2024-04-15 22:19:24 -04:00
Shadowfacts 405d5def7c Disable non-stack navigation on Max iPhones 2024-04-15 11:33:52 -04:00
Shadowfacts 1f9806d02f Fix preferences post preview background on macOS 2024-04-15 11:04:33 -04:00
Shadowfacts c43c951b92 Enable iPad multi-column navigation by default 2024-04-15 11:00:36 -04:00
Shadowfacts 00c44c612f Fix feature flag preference decoding with old flags 2024-04-15 10:55:43 -04:00
Shadowfacts e5c4fceacd Add CustomCodablePreferenceKey 2024-04-15 10:50:08 -04:00
Shadowfacts 70227a7fa1 Add MigratablePreferenceKey protocol 2024-04-15 10:37:02 -04:00
Shadowfacts cb5488dcaa Reorganize preference keys to match Preferences 2024-04-15 09:50:49 -04:00
Shadowfacts 910e18fb5e Fix compiling for visionOS 2024-04-15 09:49:42 -04:00
Shadowfacts 66af946766 Use uniform deployment targets from project settings 2024-04-15 09:41:53 -04:00
Shadowfacts 6784ed7fdf Remove in-app Safari settings on macOS
Closes #469
2024-04-15 09:34:44 -04:00
Shadowfacts 66f0ba6891 Add icons for Preferences sections 2024-04-15 00:13:04 -04:00
Shadowfacts ee7bf5138c Tweak iCloud status appearance in advanced prefs 2024-04-15 00:13:04 -04:00
Shadowfacts c32181818a Use image for code formatting option 2024-04-15 00:13:04 -04:00
Shadowfacts 4665df228d More preferences reorganizing 2024-04-15 00:13:04 -04:00
Shadowfacts c7a56a9f61 Reorganize appearance prefs, add mock status preview 2024-04-14 14:11:43 -04:00
Shadowfacts 39251b9aa2 Fix TuskerTests not compiling 2024-04-14 13:37:10 -04:00
Shadowfacts db534e5993 Fix About screen link labels not being aligned 2024-04-13 23:19:28 -04:00
Shadowfacts e94bee4fc8 Fix a handful of strict concurrency warnings 2024-04-13 23:06:30 -04:00
Shadowfacts 216e58e5ec Merge branch 'prefs-refactor' into develop 2024-04-13 22:39:49 -04:00
Shadowfacts a4d13ad03b Only migrate changed preferences 2024-04-13 22:36:42 -04:00
Shadowfacts 05cfecb797 Fix push notifications on Pleroma/Akkoma and older Mastodon versions 2024-04-13 18:59:42 -04:00
Shadowfacts 132fcfa099 Refactor preferences 2024-04-13 18:44:43 -04:00
Shadowfacts 475b9911b1 Add privacy manifest to notification extension 2024-04-13 11:11:26 -04:00
77 changed files with 2189 additions and 769 deletions

View File

@ -1,5 +1,20 @@
# Changelog # Changelog
## 2024.2 (121)
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
Features/Improvements:
- iPadOS: Enable multi-column navigation
- Add post preview to Appearance preferences
- Consolidate Media preferences section with Appearance
- Add icons to Preferences sections
Bugfixes:
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
- Fix push notifications not working with certain accounts
- Fix links on About screen not being aligned
- macOS: Remove non-functional in-app Safari preferences
## 2024.2 (120) ## 2024.2 (120)
This build adds push notifications, which can be enabled in Preferences -> Notifications. This build adds push notifications, which can be enabled in Preferences -> Notifications.

View File

@ -301,6 +301,7 @@ extension MainActor {
@available(iOS, obsoleted: 17.0) @available(iOS, obsoleted: 17.0)
@available(watchOS, obsoleted: 10.0) @available(watchOS, obsoleted: 10.0)
@available(tvOS, obsoleted: 17.0) @available(tvOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T { static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body) return try MainActor.assumeIsolated(body)

View File

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

View File

@ -181,13 +181,8 @@ class ToolbarController: ViewController {
private var formatButtons: some View { private var formatButtons: some View {
ForEach(StatusFormat.allCases, id: \.rawValue) { format in ForEach(StatusFormat.allCases, id: \.rawValue) { format in
Button(action: controller.formatAction(format)) { Button(action: controller.formatAction(format)) {
if let imageName = format.imageName { Image(systemName: format.imageName)
Image(systemName: imageName)
.font(.system(size: imageSize)) .font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
} }
.accessibilityLabel(format.accessibilityLabel) .accessibilityLabel(format.accessibilityLabel)
.padding(5) .padding(5)

View File

@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
} }
} }
var imageName: String? { var imageName: String {
switch self { switch self {
case .italics: case .italics:
return "italic" return "italic"
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
return "bold" return "bold"
case .strikethrough: case .strikethrough:
return "strikethrough" return "strikethrough"
default: case .code:
return nil return "chevron.left.forwardslash.chevron.right"
}
}
var title: (String, [NSAttributedString.Key: Any])? {
if self == .code {
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
} else {
return nil
} }
} }

View File

@ -259,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
if range.length > 0 { if range.length > 0 {
let formatMenu = suggestedActions[index] as! UIMenu let formatMenu = suggestedActions[index] as! UIMenu
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
var image: UIImage? return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
if let imageName = fmt.imageName {
image = UIImage(systemName: imageName)
}
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
self?.applyFormat(fmt) self?.applyFormat(fmt)
} }
}) })

View File

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

View File

@ -25,6 +25,17 @@ public struct Attachment: Codable, Sendable {
], nil)) ], nil))
} }
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
self.id = id
self.kind = kind
self.url = url
self.remoteURL = remoteURL
self.previewURL = previewURL
self.meta = meta
self.description = description
self.blurHash = blurHash
}
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(String.self, forKey: .id) self.id = try container.decode(String.self, forKey: .id)

View File

@ -26,6 +26,38 @@ public struct Card: Codable, Sendable {
/// Only present when returned from the trending links endpoint /// Only present when returned from the trending links endpoint
public let history: [History]? public let history: [History]?
public init(
url: WebURL,
title: String,
description: String,
image: WebURL? = nil,
kind: Card.Kind,
authorName: String? = nil,
authorURL: WebURL? = nil,
providerName: String? = nil,
providerURL: WebURL? = nil,
html: String? = nil,
width: Int? = nil,
height: Int? = nil,
blurhash: String? = nil,
history: [History]? = nil
) {
self.url = url
self.title = title
self.description = description
self.image = image
self.kind = kind
self.authorName = authorName
self.authorURL = authorURL
self.providerName = providerName
self.providerURL = providerURL
self.html = html
self.width = width
self.height = height
self.blurhash = blurhash
self.history = history
}
public init(from decoder: Decoder) throws { public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)

View File

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

View File

@ -71,7 +71,7 @@ class PushManagerImpl: _PushManager {
private func endpointURL(deviceToken: Data, accountID: String) -> URL { private func endpointURL(deviceToken: Data, accountID: String) -> URL {
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)! var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)" endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
return endpoint.url! return endpoint.url!
} }

View File

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

View File

@ -9,8 +9,10 @@ import SwiftUI
public struct AsyncPicker<V: Hashable, Content: View>: View { public struct AsyncPicker<V: Hashable, Content: View>: View {
let titleKey: LocalizedStringKey let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent") @available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool let labelHidden: Bool
#endif
let alignment: Alignment let alignment: Alignment
@Binding var value: V @Binding var value: V
let onChange: (V) async -> Bool let onChange: (V) async -> Bool
@ -19,7 +21,9 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) { public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, alignment: Alignment = .center, value: Binding<V>, onChange: @escaping (V) async -> Bool, @ViewBuilder content: () -> Content) {
self.titleKey = titleKey self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden self.labelHidden = labelHidden
#endif
self.alignment = alignment self.alignment = alignment
self._value = value self._value = value
self.onChange = onChange self.onChange = onChange
@ -27,6 +31,11 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
} }
public var body: some View { public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
picker
}
#else
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
LabeledContent(titleKey) { LabeledContent(titleKey) {
picker picker
@ -40,6 +49,7 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
picker picker
} }
} }
#endif
} }
private var picker: some View { private var picker: some View {

View File

@ -10,19 +10,28 @@ import SwiftUI
public struct AsyncToggle: View { public struct AsyncToggle: View {
let titleKey: LocalizedStringKey let titleKey: LocalizedStringKey
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent") @available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
let labelHidden: Bool let labelHidden: Bool
#endif
@Binding var mode: Mode @Binding var mode: Mode
let onChange: (Bool) async -> Bool let onChange: (Bool) async -> Bool
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) { public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
self.titleKey = titleKey self.titleKey = titleKey
#if !os(visionOS)
self.labelHidden = labelHidden self.labelHidden = labelHidden
#endif
self._mode = mode self._mode = mode
self.onChange = onChange self.onChange = onChange
} }
public var body: some View { public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
toggleOrSpinner
}
#else
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
LabeledContent(titleKey) { LabeledContent(titleKey) {
toggleOrSpinner toggleOrSpinner
@ -36,6 +45,7 @@ public struct AsyncToggle: View {
toggleOrSpinner toggleOrSpinner
} }
} }
#endif
} }
@ViewBuilder @ViewBuilder

View File

@ -0,0 +1,23 @@
{
"pins" : [
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
"version" : "1.2.1"
}
},
{
"identity" : "swift-url",
"kind" : "remoteSourceControl",
"location" : "https://github.com/karwa/swift-url.git",
"state" : {
"branch" : "main",
"revision" : "01ad5a103d14839a68c55ee556513e5939008e9e"
}
}
],
"version" : 2
}

View File

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

View File

@ -0,0 +1,282 @@
//
// Coding.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
private protocol PreferenceProtocol {
associatedtype Key: PreferenceKey
var storedValue: Key.Value? { get }
init()
}
extension Preference: PreferenceProtocol {
}
struct PreferenceCoding<Wrapped: Codable>: Codable {
let wrapped: Wrapped
init(wrapped: Wrapped) {
self.wrapped = wrapped
}
init(from decoder: any Decoder) throws {
self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder))
}
func encode(to encoder: any Encoder) throws {
try wrapped.encode(to: PreferenceEncoder(wrapped: encoder))
}
}
private struct PreferenceDecoder: Decoder {
let wrapped: any Decoder
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var userInfo: [CodingUserInfoKey : Any] {
wrapped.userInfo
}
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey {
KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type)))
}
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
throw Error.onlyKeyedContainerSupported
}
func singleValueContainer() throws -> any SingleValueDecodingContainer {
throw Error.onlyKeyedContainerSupported
}
enum Error: Swift.Error {
case onlyKeyedContainerSupported
}
}
private struct PreferenceDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
let wrapped: KeyedDecodingContainer<Key>
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var allKeys: [Key] {
wrapped.allKeys
}
func contains(_ key: Key) -> Bool {
wrapped.contains(key)
}
func decodeNil(forKey key: Key) throws -> Bool {
try wrapped.decodeNil(forKey: key)
}
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: String.Type, forKey key: Key) throws -> String {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
try wrapped.decode(type, forKey: key)
}
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
try wrapped.decode(type, forKey: key)
}
func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
if let type = type as? any PreferenceProtocol.Type,
!contains(key) {
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
P() as! T
}
return _openExistential(type, do: makePreference)
}
return try wrapped.decode(type, forKey: key)
}
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
try wrapped.nestedContainer(keyedBy: type, forKey: key)
}
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
try wrapped.nestedUnkeyedContainer(forKey: key)
}
func superDecoder() throws -> any Decoder {
try wrapped.superDecoder()
}
func superDecoder(forKey key: Key) throws -> any Decoder {
try wrapped.superDecoder(forKey: key)
}
}
private struct PreferenceEncoder: Encoder {
let wrapped: any Encoder
var codingPath: [any CodingKey] {
wrapped.codingPath
}
var userInfo: [CodingUserInfoKey : Any] {
wrapped.userInfo
}
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type)))
}
func unkeyedContainer() -> any UnkeyedEncodingContainer {
fatalError("Only keyed containers supported")
}
func singleValueContainer() -> any SingleValueEncodingContainer {
fatalError("Only keyed containers supported")
}
}
private struct PreferenceEncodingContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
var wrapped: KeyedEncodingContainer<Key>
var codingPath: [any CodingKey] {
wrapped.codingPath
}
mutating func encodeNil(forKey key: Key) throws {
try wrapped.encodeNil(forKey: key)
}
mutating func encode(_ value: Bool, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: String, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Double, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Float, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int8, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int16, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int32, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: Int64, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt8, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt16, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt32, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode(_ value: UInt64, forKey key: Key) throws {
try wrapped.encode(value, forKey: key)
}
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
if let value = value as? any PreferenceProtocol,
value.storedValue == nil {
return
}
try wrapped.encode(value, forKey: key)
}
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
wrapped.nestedContainer(keyedBy: keyType, forKey: key)
}
mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
wrapped.nestedUnkeyedContainer(forKey: key)
}
mutating func superEncoder() -> any Encoder {
wrapped.superEncoder()
}
mutating func superEncoder(forKey key: Key) -> any Encoder {
wrapped.superEncoder(forKey: key)
}
}

View File

@ -0,0 +1,28 @@
//
// AdvancedKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import Pachyderm
struct StatusContentTypeKey: MigratablePreferenceKey {
static var defaultValue: StatusContentType { .plain }
}
struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {
static var defaultValue: Set<FeatureFlag> { [] }
static func encode(value: Set<FeatureFlag>, to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.map(\.rawValue))
}
static func decode(from decoder: any Decoder) throws -> Set<FeatureFlag>? {
let container = try decoder.singleValueContainer()
let names = try container.decode([String].self)
return Set(names.compactMap(FeatureFlag.init(rawValue:)))
}
}

View File

@ -0,0 +1,49 @@
//
// AppearanceKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
public struct ThemeKey: MigratablePreferenceKey {
public static var defaultValue: Theme { .unspecified }
}
public struct AccentColorKey: MigratablePreferenceKey {
public static var defaultValue: AccentColor { .default }
}
struct AvatarStyleKey: MigratablePreferenceKey {
static var defaultValue: AvatarStyle { .roundRect }
}
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
static var defaultValue: [StatusSwipeAction] { [.favorite, .reblog] }
}
struct TrailingSwipeActionsKey: MigratablePreferenceKey {
static var defaultValue: [StatusSwipeAction] { [.reply, .share] }
}
public struct WidescreenNavigationModeKey: MigratablePreferenceKey {
public static var defaultValue: WidescreenNavigationMode { .multiColumn }
public static func shouldMigrate(oldValue: WidescreenNavigationMode) -> Bool {
oldValue != .splitScreen
}
}
struct AttachmentBlurModeKey: MigratablePreferenceKey {
static var defaultValue: AttachmentBlurMode { .useStatusSetting }
static func didSet(in store: PreferenceStore, newValue: AttachmentBlurMode) {
if newValue == .always {
store.blurMediaBehindContentWarning = true
} else if newValue == .never {
store.blurMediaBehindContentWarning = false
}
}
}

View File

@ -0,0 +1,40 @@
//
// BehaviorKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct OppositeCollapseKeywordsKey: MigratablePreferenceKey {
static var defaultValue: [String] { [] }
}
struct ConfirmReblogKey: MigratablePreferenceKey {
static var defaultValue: Bool {
#if os(visionOS)
true
#else
false
#endif
}
}
struct TimelineSyncModeKey: MigratablePreferenceKey {
static var defaultValue: TimelineSyncMode { .icloud }
}
struct InAppSafariKey: MigratablePreferenceKey {
static var defaultValue: Bool {
#if targetEnvironment(macCatalyst) || os(visionOS)
false
#else
if ProcessInfo.processInfo.isiOSAppOnMac {
false
} else {
true
}
#endif
}
}

View File

@ -0,0 +1,16 @@
//
// CommonKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public struct TrueKey: MigratablePreferenceKey {
public static var defaultValue: Bool { true }
}
public struct FalseKey: MigratablePreferenceKey {
public static var defaultValue: Bool { false }
}

View File

@ -0,0 +1,20 @@
//
// ComposingKeys.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
struct PostVisibilityKey: MigratablePreferenceKey {
static var defaultValue: PostVisibility { .serverDefault }
}
struct ReplyVisibilityKey: MigratablePreferenceKey {
static var defaultValue: ReplyVisibility { .sameAsPost }
}
struct ContentWarningCopyModeKey: MigratablePreferenceKey {
static var defaultValue: ContentWarningCopyMode { .asIs }
}

View File

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

View File

@ -0,0 +1,205 @@
//
// LegacyPreferences.swift
// TuskerPreferences
//
// Created by Shadowfacts on 8/28/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
public final class LegacyPreferences: Decodable {
init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
} else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
} else {
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
}
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
}
// MARK: Appearance
@Published public var theme = UIUserInterfaceStyle.unspecified
@Published public var pureBlackDarkMode = true
@Published public var accentColor = AccentColor.default
@Published public var avatarStyle = AvatarStyle.roundRect
@Published public var hideCustomEmojiInUsernames = false
@Published public var showIsStatusReplyIcon = false
@Published public var alwaysShowStatusVisibilityIcon = false
@Published public var hideActionsInTimeline = false
@Published public var showLinkPreviews = true
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published public var mentionReblogger = false
@Published public var useTwitterKeyboard = false
// MARK: Media
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting
@Published public var blurMediaBehindContentWarning = true
@Published public var automaticallyPlayGifs = true
@Published public var showUncroppedMediaInline = true
@Published public var showAttachmentBadges = true
@Published public var attachmentAltBadgeInverted = false
// MARK: Behavior
@Published public var openLinksInApps = true
@Published public var useInAppSafari = true
@Published public var inAppSafariAutomaticReaderMode = false
@Published public var expandAllContentWarnings = false
@Published public var collapseLongPosts = true
@Published public var oppositeCollapseKeywords: [String] = []
@Published public var confirmBeforeReblog = false
@Published public var timelineStateRestoration = true
@Published public var timelineSyncMode = TimelineSyncMode.icloud
@Published public var hideReblogsInTimelines = false
@Published public var hideRepliesInTimelines = false
// MARK: Digital Wellness
@Published public var showFavoriteAndReblogCounts = true
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
@Published public var grayscaleImages = false
@Published public var disableInfiniteScrolling = false
@Published public var hideTrends = false
// MARK: Advanced
@Published public var statusContentType: StatusContentType = .plain
@Published public var reportErrorsAutomatically = true
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
// MARK:
@Published public var hasShownLocalTimelineDescription = false
@Published public var hasShownFederatedTimelineDescription = false
private enum CodingKeys: String, CodingKey {
case theme
case pureBlackDarkMode
case accentColor
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions
case trailingStatusSwipeActions
case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility
case defaultReplyVisibility
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case useTwitterKeyboard
case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case attachmentAltBadgeInverted
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideTrends = "hideDiscover"
case statusContentType
case reportErrorsAutomatically
case enabledFeatureFlags
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
}
}
extension UIUserInterfaceStyle: Codable {}

View File

@ -0,0 +1,106 @@
//
// PreferenceStore+Migrate.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
extension PreferenceStore {
func migrate(from legacy: LegacyPreferences) {
var 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: \.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),
]
#if !targetEnvironment(macCatalyst) && !os(visionOS)
migrations.append(contentsOf: [
Migration(from: \.useInAppSafari, to: \.$useInAppSafari),
Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode),
] as [any MigrationProtocol])
#endif
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: MigratablePreferenceKey>: 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 Key.shouldMigrate(oldValue: value) {
Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value)
}
}
}
private extension UIUserInterfaceStyle {
var theme: Theme {
switch self {
case .light:
.light
case .dark:
.dark
default:
.unspecified
}
}
}

View File

@ -0,0 +1,101 @@
//
// 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 keyType = Key.self as? any CustomCodablePreferenceKey.Type {
self.storedValue = try keyType.decode(from: decoder) as! Key.Value?
} else if let container = try? decoder.singleValueContainer() {
self.storedValue = try? container.decode(Key.Value.self)
}
}
func encode(to encoder: any Encoder) throws {
if let storedValue {
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
func encode<K: CustomCodablePreferenceKey>(_: K.Type) throws {
try K.encode(value: storedValue as! K.Value, to: encoder)
}
return try _openExistential(keyType, do: encode)
} else {
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)
}
}
public struct PreferencePublisher<Key: PreferenceKey>: Publisher {
public typealias Output = Key.Value
public typealias Failure = Never
let preference: Preference<Key>
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
}
}

View File

@ -0,0 +1,35 @@
//
// 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 {
public static func didSet(in store: PreferenceStore, newValue: Value) {}
}
protocol MigratablePreferenceKey: PreferenceKey where Value: Equatable {
static func shouldMigrate(oldValue: Value) -> Bool
}
extension MigratablePreferenceKey {
static func shouldMigrate(oldValue: Value) -> Bool {
oldValue != defaultValue
}
}
protocol CustomCodablePreferenceKey: PreferenceKey {
static func encode(value: Value, to encoder: any Encoder) throws
static func decode(from decoder: any Decoder) throws -> Value?
}

View File

@ -0,0 +1,77 @@
//
// 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
@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: 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: Behavior
@Preference<TrueKey> public var openLinksInApps
@Preference<InAppSafariKey> public var useInAppSafari
@Preference<FalseKey> public var inAppSafariAutomaticReaderMode
@Preference<FalseKey> public var expandAllContentWarnings
@Preference<TrueKey> public var collapseLongPosts
@Preference<OppositeCollapseKeywordsKey> public var oppositeCollapseKeywords
@Preference<ConfirmReblogKey> public var confirmBeforeReblog
@Preference<TrueKey> public var timelineStateRestoration
@Preference<TimelineSyncModeKey> public var timelineSyncMode
@Preference<FalseKey> public var hideReblogsInTimelines
@Preference<FalseKey> public var hideRepliesInTimelines
// MARK: Digital Wellness
@Preference<TrueKey> public var showFavoriteAndReblogCounts
@Preference<NotificationsModeKey> public var defaultNotificationsMode
@Preference<FalseKey> public var grayscaleImages
@Preference<FalseKey> public var disableInfiniteScrolling
@Preference<FalseKey> public var hideTrends
// MARK: Advanced
@Preference<StatusContentTypeKey> public var statusContentType
@Preference<TrueKey> public var reportErrorsAutomatically
@Preference<FeatureFlagsKey> public var enabledFeatureFlags
// MARK: Internal
@Preference<FalseKey> public var hasShownLocalTimelineDescription
@Preference<FalseKey> public var hasShownFederatedTimelineDescription
}
extension PreferenceStore {
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
}

View File

@ -2,430 +2,42 @@
// Preferences.swift // Preferences.swift
// TuskerPreferences // TuskerPreferences
// //
// Created by Shadowfacts on 8/28/18. // Created by Shadowfacts on 4/12/24.
// Copyright © 2018 Shadowfacts. All rights reserved.
// //
import UIKit import Foundation
import Pachyderm
import Combine
public final class Preferences: Codable, ObservableObject {
public struct Preferences {
@MainActor @MainActor
public static var shared: Preferences = load() public static let shared: PreferenceStore = load()
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")! private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist") private static var legacyURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
private static var preferencesURL = appGroupDirectory.appendingPathComponent("preferences.v2").appendingPathExtension("plist")
private static var nonAppGroupURL = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
private init() {}
@MainActor @MainActor
public static func save() { public static func save() {
let encoder = PropertyListEncoder() let encoder = PropertyListEncoder()
let data = try? encoder.encode(shared) let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
try? data?.write(to: archiveURL, options: .noFileProtection) try? data?.write(to: preferencesURL, options: .noFileProtection)
} }
public static func load() -> Preferences { private static func load() -> PreferenceStore {
let decoder = PropertyListDecoder() let decoder = PropertyListDecoder()
if let data = try? Data(contentsOf: archiveURL), if let data = try? Data(contentsOf: preferencesURL),
let preferences = try? decoder.decode(Preferences.self, from: data) { let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
return preferences return store.wrapped
} } else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
return Preferences() let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
} let store = PreferenceStore()
store.migrate(from: legacy)
@MainActor return store
public static func migrate(from url: URL) -> Result<Void, any Error> {
do {
try? FileManager.default.removeItem(at: archiveURL)
try FileManager.default.moveItem(at: url, to: archiveURL)
} catch {
return .failure(error)
}
shared = load()
return .success(())
}
private init() {}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
self.defaultPostVisibility = .visibility(existing)
} else { } else {
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility) return PreferenceStore()
}
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
} else {
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
}
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(theme, forKey: .theme)
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
try container.encode(accentColor, forKey: .accentColor)
try container.encode(avatarStyle, forKey: .avatarStyle)
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
try container.encode(mentionReblogger, forKey: .mentionReblogger)
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
try container.encode(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted)
try container.encode(openLinksInApps, forKey: .openLinksInApps)
try container.encode(useInAppSafari, forKey: .useInAppSafari)
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
try container.encode(grayscaleImages, forKey: .grayscaleImages)
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
try container.encode(hideTrends, forKey: .hideTrends)
try container.encode(statusContentType, forKey: .statusContentType)
try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically)
try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags)
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
}
// MARK: Appearance
@Published public var theme = UIUserInterfaceStyle.unspecified
@Published public var pureBlackDarkMode = true
@Published public var accentColor = AccentColor.default
@Published public var avatarStyle = AvatarStyle.roundRect
@Published public var hideCustomEmojiInUsernames = false
@Published public var showIsStatusReplyIcon = false
@Published public var alwaysShowStatusVisibilityIcon = false
@Published public var hideActionsInTimeline = false
@Published public var showLinkPreviews = true
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
@Published public var underlineTextLinks = false
@Published public var showAttachmentsInTimeline = true
// MARK: Composing
@Published public var defaultPostVisibility = PostVisibility.serverDefault
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
@Published public var requireAttachmentDescriptions = false
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
@Published public var mentionReblogger = false
@Published public var useTwitterKeyboard = false
// MARK: Media
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
didSet {
if attachmentBlurMode == .always {
blurMediaBehindContentWarning = true
} else if attachmentBlurMode == .never {
blurMediaBehindContentWarning = false
}
}
}
@Published public var blurMediaBehindContentWarning = true
@Published public var automaticallyPlayGifs = true
@Published public var showUncroppedMediaInline = true
@Published public var showAttachmentBadges = true
@Published public var attachmentAltBadgeInverted = false
// MARK: Behavior
@Published public var openLinksInApps = true
@Published public var useInAppSafari = true
@Published public var inAppSafariAutomaticReaderMode = false
@Published public var expandAllContentWarnings = false
@Published public var collapseLongPosts = true
@Published public var oppositeCollapseKeywords: [String] = []
@Published public var confirmBeforeReblog = false
@Published public var timelineStateRestoration = true
@Published public var timelineSyncMode = TimelineSyncMode.icloud
@Published public var hideReblogsInTimelines = false
@Published public var hideRepliesInTimelines = false
// MARK: Digital Wellness
@Published public var showFavoriteAndReblogCounts = true
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
@Published public var grayscaleImages = false
@Published public var disableInfiniteScrolling = false
@Published public var hideTrends = false
// MARK: Advanced
@Published public var statusContentType: StatusContentType = .plain
@Published public var reportErrorsAutomatically = true
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
// MARK:
@Published public var hasShownLocalTimelineDescription = false
@Published public var hasShownFederatedTimelineDescription = false
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
private enum CodingKeys: String, CodingKey {
case theme
case pureBlackDarkMode
case accentColor
case avatarStyle
case hideCustomEmojiInUsernames
case showIsStatusReplyIcon
case alwaysShowStatusVisibilityIcon
case hideActionsInTimeline
case showLinkPreviews
case leadingStatusSwipeActions
case trailingStatusSwipeActions
case widescreenNavigationMode
case underlineTextLinks
case showAttachmentsInTimeline
case defaultPostVisibility
case defaultReplyVisibility
case requireAttachmentDescriptions
case contentWarningCopyMode
case mentionReblogger
case useTwitterKeyboard
case blurAllMedia // only used for migration
case attachmentBlurMode
case blurMediaBehindContentWarning
case automaticallyPlayGifs
case showUncroppedMediaInline
case showAttachmentBadges
case attachmentAltBadgeInverted
case openLinksInApps
case useInAppSafari
case inAppSafariAutomaticReaderMode
case expandAllContentWarnings
case collapseLongPosts
case oppositeCollapseKeywords
case confirmBeforeReblog
case timelineStateRestoration
case timelineSyncMode
case hideReblogsInTimelines
case hideRepliesInTimelines
case showFavoriteAndReblogCounts
case defaultNotificationsType
case grayscaleImages
case disableInfiniteScrolling
case hideTrends = "hideDiscover"
case statusContentType
case reportErrorsAutomatically
case enabledFeatureFlags
case hasShownLocalTimelineDescription
case hasShownFederatedTimelineDescription
}
}
extension Preferences {
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting
case always
case never
public var displayName: String {
switch self {
case .useStatusSetting:
return "Default"
case .always:
return "Always"
case .never:
return "Never"
}
} }
} }
} }
extension UIUserInterfaceStyle: Codable {}
extension Preferences {
public enum AccentColor: String, Codable, CaseIterable {
case `default`
case purple
case indigo
case blue
case cyan
case teal
case mint
case green
// case yellow
case orange
case red
case pink
// case brown
public var color: UIColor? {
switch self {
case .default:
return nil
case .blue:
return .systemBlue
// case .brown:
// return .systemBrown
case .cyan:
return .systemCyan
case .green:
return .systemGreen
case .indigo:
return .systemIndigo
case .mint:
return .systemMint
case .orange:
return .systemOrange
case .pink:
return .systemPink
case .purple:
return .systemPurple
case .red:
return .systemRed
case .teal:
return .systemTeal
// case .yellow:
// return .systemYellow
}
}
public var name: String {
switch self {
case .default:
return "Default"
case .blue:
return "Blue"
// case .brown:
// return "Brown"
case .cyan:
return "Cyan"
case .green:
return "Green"
case .indigo:
return "Indigo"
case .mint:
return "Mint"
case .orange:
return "Orange"
case .pink:
return "Pink"
case .purple:
return "Purple"
case .red:
return "Red"
case .teal:
return "Teal"
// case .yellow:
// return "Yellow"
}
}
}
}
extension Preferences {
public enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}
}
extension Preferences {
public enum FeatureFlag: String, Codable {
case iPadMultiColumn = "ipad-multi-column"
case iPadBrowserNavigation = "ipad-browser-navigation"
}
}
extension Preferences {
public enum WidescreenNavigationMode: String, Codable {
case stack
case splitScreen
case multiColumn
}
}

View File

@ -0,0 +1,86 @@
//
// AccentColor.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import UIKit
public enum AccentColor: String, Codable, CaseIterable {
case `default`
case purple
case indigo
case blue
case cyan
case teal
case mint
case green
// case yellow
case orange
case red
case pink
// case brown
public var color: UIColor? {
switch self {
case .default:
return nil
case .blue:
return .systemBlue
// case .brown:
// return .systemBrown
case .cyan:
return .systemCyan
case .green:
return .systemGreen
case .indigo:
return .systemIndigo
case .mint:
return .systemMint
case .orange:
return .systemOrange
case .pink:
return .systemPink
case .purple:
return .systemPurple
case .red:
return .systemRed
case .teal:
return .systemTeal
// case .yellow:
// return .systemYellow
}
}
public var name: String {
switch self {
case .default:
return "Default"
case .blue:
return "Blue"
// case .brown:
// return "Brown"
case .cyan:
return "Cyan"
case .green:
return "Green"
case .indigo:
return "Indigo"
case .mint:
return "Mint"
case .orange:
return "Orange"
case .pink:
return "Pink"
case .purple:
return "Purple"
case .red:
return "Red"
case .teal:
return "Teal"
// case .yellow:
// return "Yellow"
}
}
}

View File

@ -0,0 +1,25 @@
//
// AttachmentBlurMode.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
case useStatusSetting
case always
case never
public var displayName: String {
switch self {
case .useStatusSetting:
return "Default"
case .always:
return "Always"
case .never:
return "Never"
}
}
}

View File

@ -0,0 +1,12 @@
//
// FeatureFlag.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum FeatureFlag: String, Codable {
case iPadBrowserNavigation = "ipad-browser-navigation"
}

View File

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

View File

@ -0,0 +1,24 @@
//
// Theme.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
public enum Theme: String, Codable {
case unspecified, light, dark
public var userInterfaceStyle: UIUserInterfaceStyle {
switch self {
case .unspecified:
.unspecified
case .light:
.light
case .dark:
.dark
}
}
}

View File

@ -0,0 +1,13 @@
//
// TimelineSyncMode.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum TimelineSyncMode: String, Codable {
case mastodon
case icloud
}

View File

@ -0,0 +1,14 @@
//
// WidescreenNavigationMode.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
public enum WidescreenNavigationMode: String, Codable {
case stack
case splitScreen
case multiColumn
}

View File

@ -0,0 +1,128 @@
//
// 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<Key: PreferenceKey>: Codable, ObservableObject {
private var _test = Preference<Key>()
// the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually
var test: Key.Value {
get {
Preference.get(enclosingInstance: self, storage: \._test)
}
set {
Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue)
}
}
var testPublisher: some Publisher<Key.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<Key>.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<TestKey>>.self, from: Data("""
{"test": true}
""".utf8)).wrapped
XCTAssertEqual(present.test, true)
let absent = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
{}
""".utf8)).wrapped
XCTAssertEqual(absent.test, false)
}
func testEncoding() throws {
let store = TestStore<TestKey>()
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<TestKey>()
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])
}
func testCustomCodable() throws {
struct Key: CustomCodablePreferenceKey {
static let defaultValue = 1
static func encode(value: Int, to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(2)
}
static func decode(from decoder: any Decoder) throws -> Int? {
3
}
}
let store = TestStore<Key>()
store.test = 123
let encoder = JSONEncoder()
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
{"test":2}
""")
let decoder = JSONDecoder()
let present = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
{"test":2}
""".utf8)).wrapped
XCTAssertEqual(present.test, 3)
let absent = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
{}
""".utf8)).wrapped
XCTAssertEqual(absent.test, 1)
}
}

View File

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

View File

@ -11,7 +11,6 @@
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */; }; 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */; };
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; }; 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0450531E22B0097E00100BA2 /* Timline+UI.swift */; };
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.swift */; }; 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4022B2FFB10021BD04 /* PreferencesView.swift */; };
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4222B301470021BD04 /* AppearancePrefsView.swift */; };
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; };
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; };
@ -170,7 +169,6 @@
D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; }; D67C57AD21E265FC00C3118B /* LargeAccountDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */; };
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; }; D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */; };
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; }; D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */; };
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68015412401A74600D6103B /* MediaPrefsView.swift */; };
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; }; D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D6246E32290053414F /* StatusActivityItemSource.swift */; };
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; }; D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */; };
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; }; D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */; };
@ -291,6 +289,8 @@
D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; }; D6C3F4FB299035650009FCFF /* TrendsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4FA299035650009FCFF /* TrendsViewController.swift */; };
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */; }; D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.swift */; };
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; }; D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -435,7 +435,6 @@
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPrefsView.swift; sourceTree = "<group>"; }; 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdvancedPrefsView.swift; sourceTree = "<group>"; };
0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = "<group>"; }; 0450531E22B0097E00100BA2 /* Timline+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timline+UI.swift"; sourceTree = "<group>"; };
04586B4022B2FFB10021BD04 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; }; 04586B4022B2FFB10021BD04 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
04586B4222B301470021BD04 /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; }; 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = "<group>"; };
04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = "<group>"; };
@ -592,7 +591,6 @@
D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; }; D67C57AC21E265FC00C3118B /* LargeAccountDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeAccountDetailView.swift; sourceTree = "<group>"; };
D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; }; D67C57AE21E28EAD00C3118B /* Array+Uniques.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Uniques.swift"; sourceTree = "<group>"; };
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; }; D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposingPrefsView.swift; sourceTree = "<group>"; };
D68015412401A74600D6103B /* MediaPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPrefsView.swift; sourceTree = "<group>"; };
D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; }; D681E4D6246E32290053414F /* StatusActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActivityItemSource.swift; sourceTree = "<group>"; };
D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; }; D681E4D8246E346E0053414F /* AccountActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountActivityItemSource.swift; sourceTree = "<group>"; };
D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; }; D68232F62464F4FD00325FB8 /* ComposeDrawingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeDrawingViewController.swift; sourceTree = "<group>"; };
@ -713,6 +711,9 @@
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>"; };
D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearancePrefsView.swift; sourceTree = "<group>"; };
D6C4532E2BCB873400E26A0E /* MockStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStatusView.swift; 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>";
@ -1168,17 +1170,14 @@
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */, D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
04586B4022B2FFB10021BD04 /* PreferencesView.swift */, 04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */, D64B96802BC3279D002C8990 /* PrefsAccountView.swift */,
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */,
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */, 0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */, D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */, D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
D68015412401A74600D6103B /* MediaPrefsView.swift */,
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */, D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */, 0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */, D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */, D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
D6C4532B2BCB86A100E26A0E /* Appearance */,
D64B96822BC3892B002C8990 /* Notifications */, D64B96822BC3892B002C8990 /* Notifications */,
D60089172981FEA4005B4D00 /* Tip Jar */, D60089172981FEA4005B4D00 /* Tip Jar */,
D68A76EF2953910A001DA1B3 /* About */, D68A76EF2953910A001DA1B3 /* About */,
@ -1483,6 +1482,17 @@
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6C4532B2BCB86A100E26A0E /* Appearance */ = {
isa = PBXGroup;
children = (
D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */,
D6C4532E2BCB873400E26A0E /* MockStatusView.swift */,
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */,
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
);
path = Appearance;
sourceTree = "<group>";
};
D6C693FA2162FE5D007D6A6D /* Utilities */ = { D6C693FA2162FE5D007D6A6D /* Utilities */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2100,6 +2110,7 @@
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */, D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */, D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */, D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */, D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */, D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */, 0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
@ -2288,7 +2299,6 @@
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */, D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */, D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */, D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */, D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */, D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */,
D69261272BB3BA610023152C /* Box.swift in Sources */, D69261272BB3BA610023152C /* Box.swift in Sources */,
@ -2335,7 +2345,6 @@
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */, D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */, D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */, D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
@ -2357,6 +2366,7 @@
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */, D65B4B562971F98300DABDFB /* ReportView.swift in Sources */,
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */, D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */, D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
@ -2480,7 +2490,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2490,11 +2499,12 @@
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension; PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2,7";
}; };
name = Debug; name = Debug;
}; };
@ -2512,7 +2522,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2522,10 +2531,11 @@
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension; PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2,7";
}; };
name = Release; name = Release;
}; };
@ -2543,7 +2553,6 @@
INFOPLIST_FILE = NotificationExtension/Info.plist; INFOPLIST_FILE = NotificationExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension; INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2553,10 +2562,11 @@
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension; PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES; SKIP_INSTALL = YES;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2,7";
}; };
name = Dist; name = Dist;
}; };
@ -2608,7 +2618,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -2616,6 +2626,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES; SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
XROS_DEPLOYMENT_TARGET = 1.1;
}; };
name = Dist; name = Dist;
}; };
@ -2631,7 +2642,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2698,8 +2708,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2727,7 +2735,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2756,7 +2763,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2785,7 +2791,6 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension; INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved."; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2856,7 +2861,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@ -2864,6 +2869,7 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES; SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
XROS_DEPLOYMENT_TARGET = 1.1;
}; };
name = Debug; name = Debug;
}; };
@ -2915,7 +2921,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.1; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -2923,6 +2929,7 @@
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES; SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
VALIDATE_PRODUCT = YES; VALIDATE_PRODUCT = YES;
XROS_DEPLOYMENT_TARGET = 1.1;
}; };
name = Release; name = Release;
}; };
@ -2938,7 +2945,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -2970,7 +2976,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = Tusker/Info.plist; INFOPLIST_FILE = Tusker/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3078,8 +3083,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -3104,8 +3107,6 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)"; CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
INFOPLIST_FILE = OpenInTusker/Info.plist; INFOPLIST_FILE = OpenInTusker/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",

View File

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

View File

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

View File

@ -26,7 +26,11 @@ class SaveToPhotosActivity: UIActivity {
// Just using the symbol image directly causes it to be stretched. // Just using the symbol image directly causes it to be stretched.
let symbol = UIImage(systemName: "square.and.arrow.down", withConfiguration: UIImage.SymbolConfiguration(scale: .large))! let symbol = UIImage(systemName: "square.and.arrow.down", withConfiguration: UIImage.SymbolConfiguration(scale: .large))!
let format = UIGraphicsImageRendererFormat() let format = UIGraphicsImageRendererFormat()
#if os(visionOS)
format.scale = 2
#else
format.scale = UIScreen.main.scale format.scale = UIScreen.main.scale
#endif
return UIGraphicsImageRenderer(size: CGSize(width: 76, height: 76), format: format).image { ctx in return UIGraphicsImageRenderer(size: CGSize(width: 76, height: 76), format: format).image { ctx in
let rect = AVMakeRect(aspectRatio: symbol.size, insideRect: CGRect(x: 0, y: 0, width: 76, height: 76)) let rect = AVMakeRect(aspectRatio: symbol.size, insideRect: CGRect(x: 0, y: 0, width: 76, height: 76))
symbol.draw(in: rect) symbol.draw(in: rect)

View File

@ -54,21 +54,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
} }
} }
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
#if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
}
}
// make sure the persistent container is initialized on the main thread // make sure the persistent container is initialized on the main thread
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere // otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
_ = DraftsPersistentContainer.shared _ = DraftsPersistentContainer.shared
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist") let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist") let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) { for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {
@ -184,7 +175,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
private func initializePushNotifications() { private func initializePushNotifications() {
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
Task { Task {
#if canImport(Sentry)
PushManager.captureError = { SentrySDK.capture(error: $0) } PushManager.captureError = { SentrySDK.capture(error: $0) }
#endif
await PushManager.shared.updateIfNecessary(updateSubscription: { await PushManager.shared.updateIfNecessary(updateSubscription: {
guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else { guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else {
return false return false

View File

@ -51,6 +51,7 @@ public extension MainActor {
@available(iOS, obsoleted: 17.0) @available(iOS, obsoleted: 17.0)
@available(watchOS, obsoleted: 10.0) @available(watchOS, obsoleted: 10.0)
@available(tvOS, obsoleted: 17.0) @available(tvOS, obsoleted: 17.0)
@available(visionOS 1.0, *)
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T { static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) { if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
return try MainActor.assumeIsolated(body) return try MainActor.assumeIsolated(body)

View File

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

View File

@ -18,7 +18,9 @@ class VideoControlsViewController: UIViewController {
}() }()
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float @Box private var playbackSpeed: Float
#endif
private lazy var muteButton = MuteButton().configure { private lazy var muteButton = MuteButton().configure {
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside) $0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
@ -44,8 +46,13 @@ class VideoControlsViewController: UIViewController {
private lazy var optionsButton = MenuButton { [unowned self] in private lazy var optionsButton = MenuButton { [unowned self] in
let imageName: String let imageName: String
#if os(visionOS)
let playbackSpeed = player.defaultRate
#else
let playbackSpeed = self.playbackSpeed
#endif
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
switch self.playbackSpeed { switch playbackSpeed {
case 0.5: case 0.5:
imageName = "gauge.with.dots.needle.0percent" imageName = "gauge.with.dots.needle.0percent"
case 1: case 1:
@ -61,8 +68,12 @@ class VideoControlsViewController: UIViewController {
imageName = "speedometer" imageName = "speedometer"
} }
let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in let speedMenu = UIMenu(title: "Playback Speed", image: UIImage(systemName: imageName), children: PlaybackSpeed.allCases.map { speed in
UIAction(title: speed.displayName, state: self.playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in UIAction(title: speed.displayName, state: playbackSpeed == speed.rate ? .on : .off) { [unowned self] _ in
#if os(visionOS)
self.player.defaultRate = speed.rate
#else
self.playbackSpeed = speed.rate self.playbackSpeed = speed.rate
#endif
if self.player.rate > 0 { if self.player.rate > 0 {
self.player.rate = speed.rate self.player.rate = speed.rate
} }
@ -90,12 +101,20 @@ class VideoControlsViewController: UIViewController {
private var scrubbingTargetTime: CMTime? private var scrubbingTargetTime: CMTime?
private var isSeeking = false private var isSeeking = false
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) { init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player self.player = player
self._playbackSpeed = playbackSpeed self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -170,7 +189,11 @@ class VideoControlsViewController: UIViewController {
@objc private func scrubbingEnded() { @objc private func scrubbingEnded() {
scrubbingChanged() scrubbingChanged()
if wasPlayingWhenScrubbingStarted { if wasPlayingWhenScrubbingStarted {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed player.rate = playbackSpeed
#endif
} }
} }

View File

@ -17,8 +17,10 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
private var item: AVPlayerItem private var item: AVPlayerItem
let player: AVPlayer let player: AVPlayer
#if !os(visionOS)
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate") @available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
@Box private var playbackSpeed: Float = 1 @Box private var playbackSpeed: Float = 1
#endif
private var isGrayscale: Bool private var isGrayscale: Bool
@ -125,7 +127,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
player.replaceCurrentItem(with: item) player.replaceCurrentItem(with: item)
updateItemObservations() updateItemObservations()
if isPlaying { if isPlaying {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed player.rate = playbackSpeed
#endif
} }
} }
} }
@ -142,12 +148,20 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
[VideoActivityItemSource(asset: item.asset, url: url)] [VideoActivityItemSource(asset: item.asset, url: url)]
} }
#if os(visionOS)
private lazy var overlayVC = VideoOverlayViewController(player: player)
#else
private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed) private lazy var overlayVC = VideoOverlayViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
var contentOverlayAccessoryViewController: UIViewController? { var contentOverlayAccessoryViewController: UIViewController? {
overlayVC overlayVC
} }
#if os(visionOS)
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player)
#else
private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed) private(set) lazy var bottomControlsAccessoryViewController: UIViewController? = VideoControlsViewController(player: player, playbackSpeed: _playbackSpeed)
#endif
func setControlsVisible(_ visible: Bool, animated: Bool) { func setControlsVisible(_ visible: Bool, animated: Bool) {
overlayVC.setVisible(visible) overlayVC.setVisible(visible)

View File

@ -15,7 +15,9 @@ class VideoOverlayViewController: UIViewController {
private static let pauseImage = UIImage(systemName: "pause.fill")! private static let pauseImage = UIImage(systemName: "pause.fill")!
private let player: AVPlayer private let player: AVPlayer
#if !os(visionOS)
@Box private var playbackSpeed: Float @Box private var playbackSpeed: Float
#endif
private var dimmingView: UIView! private var dimmingView: UIView!
private var controlsStack: UIStackView! private var controlsStack: UIStackView!
@ -24,11 +26,18 @@ class VideoOverlayViewController: UIViewController {
private var rateObservation: NSKeyValueObservation? private var rateObservation: NSKeyValueObservation?
#if os(visionOS)
init(player: AVPlayer) {
self.player = player
super.init(nibName: nil, bundle: nil)
}
#else
init(player: AVPlayer, playbackSpeed: Box<Float>) { init(player: AVPlayer, playbackSpeed: Box<Float>) {
self.player = player self.player = player
self._playbackSpeed = playbackSpeed self._playbackSpeed = playbackSpeed
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
#endif
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
@ -97,7 +106,11 @@ class VideoOverlayViewController: UIViewController {
if player.rate > 0 { if player.rate > 0 {
player.rate = 0 player.rate = 0
} else { } else {
#if os(visionOS)
player.play()
#else
player.rate = playbackSpeed player.rate = playbackSpeed
#endif
} }
} }

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import Combine import Combine
import TuskerPreferences
class MainSplitViewController: UISplitViewController { class MainSplitViewController: UISplitViewController {
@ -21,7 +22,7 @@ class MainSplitViewController: UISplitViewController {
private var tabBarViewController: MainTabBarViewController! private var tabBarViewController: MainTabBarViewController!
private var navigationMode: Preferences.WidescreenNavigationMode! private var navigationMode: WidescreenNavigationMode!
private var secondaryNavController: NavigationControllerProtocol! { private var secondaryNavController: NavigationControllerProtocol! {
viewController(for: .secondary) as? NavigationControllerProtocol viewController(for: .secondary) as? NavigationControllerProtocol
} }
@ -65,6 +66,8 @@ class MainSplitViewController: UISplitViewController {
} }
let nav: UIViewController let nav: UIViewController
let visionIdiom = UIUserInterfaceIdiom(rawValue: 6)
if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) {
navigationMode = Preferences.shared.widescreenNavigationMode navigationMode = Preferences.shared.widescreenNavigationMode
switch navigationMode! { switch navigationMode! {
case .stack: case .stack:
@ -74,6 +77,10 @@ class MainSplitViewController: UISplitViewController {
case .multiColumn: case .multiColumn:
nav = MultiColumnNavigationController() nav = MultiColumnNavigationController()
} }
} else {
navigationMode = .stack
nav = EnhancedNavigationViewController()
}
setViewController(nav, for: .secondary) setViewController(nav, for: .secondary)
// don't unnecesarily construct a content VC unless the we're in actually split mode // don't unnecesarily construct a content VC unless the we're in actually split mode
@ -113,8 +120,10 @@ class MainSplitViewController: UISplitViewController {
.store(in: &cancellables) .store(in: &cancellables)
} }
private func updateNavigationMode(_ mode: Preferences.WidescreenNavigationMode) { private func updateNavigationMode(_ mode: WidescreenNavigationMode) {
guard mode != navigationMode else { let visionIdiom = UIUserInterfaceIdiom(rawValue: 6)
guard [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom),
mode != navigationMode else {
return return
} }
navigationMode = mode navigationMode = mode

View File

@ -59,7 +59,12 @@ struct AboutView: View {
} }
} label: { } label: {
HStack { HStack {
Label("Get Support", systemImage: "envelope") Label {
Text("Get Support")
} icon: {
Image(systemName: "envelope")
.foregroundStyle(.tint)
}
Spacer() Spacer()
if isGettingLogData { if isGettingLogData {
ProgressView() ProgressView()
@ -75,7 +80,6 @@ struct AboutView: View {
Label("Issue Tracker", systemImage: "checklist") Label("Issue Tracker", systemImage: "checklist")
} }
} }
.labelStyle(AboutLinksLabelStyle())
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
@ -100,7 +104,9 @@ struct AboutView: View {
private var appIcon: some View { private var appIcon: some View {
VStack { VStack {
AppIconView() Image("AboutIcon")
.resizable()
.clipShape(RoundedRectangle(cornerRadius: 256 / 6.4))
.shadow(radius: 6, y: 3) .shadow(radius: 6, y: 3)
.frame(width: 256, height: 256) .frame(width: 256, height: 256)
@ -121,20 +127,6 @@ struct AboutView: View {
} }
} }
private struct AppIconView: UIViewRepresentable {
func makeUIView(context: Context) -> UIImageView {
let view = UIImageView(image: UIImage(named: "AboutIcon"))
view.contentMode = .scaleAspectFit
view.layer.cornerRadius = 256 / 6.4
view.layer.cornerCurve = .continuous
view.layer.masksToBounds = true
return view
}
func updateUIView(_ uiView: UIImageView, context: Context) {
}
}
private struct MailSheet: UIViewControllerRepresentable { private struct MailSheet: UIViewControllerRepresentable {
typealias UIViewControllerType = MFMailComposeViewController typealias UIViewControllerType = MFMailComposeViewController
@ -174,15 +166,6 @@ private struct MailSheet: UIViewControllerRepresentable {
} }
} }
private struct AboutLinksLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .lastTextBaseline, spacing: 8) {
configuration.icon
configuration.title
}
}
}
struct AboutView_Previews: PreviewProvider { struct AboutView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
AboutView() AboutView()

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import Pachyderm
import CoreData import CoreData
import CloudKit import CloudKit
import UserAccounts import UserAccounts
import TuskerPreferences
struct AdvancedPrefsView : View { struct AdvancedPrefsView : View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -41,7 +42,7 @@ struct AdvancedPrefsView : View {
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
Button("Enable") { Button("Enable") {
if let flag = Preferences.FeatureFlag(rawValue: featureFlagName) { if let flag = FeatureFlag(rawValue: featureFlagName) {
preferences.enabledFeatureFlags.insert(flag) preferences.enabledFeatureFlags.insert(flag)
} }
} }
@ -82,6 +83,23 @@ struct AdvancedPrefsView : View {
HStack { HStack {
Text("iCloud Status") Text("iCloud Status")
Spacer() Spacer()
cloudKitStatusLabel
.foregroundStyle(.secondary)
}
}
.appGroupedListRowBackground()
.task {
do {
let status = try await CKContainer.default().accountStatus()
self.cloudKitStatus = status
} catch {
Logging.general.error("Unable to get CloudKit status: \(String(describing: error))")
}
}
}
@ViewBuilder
private var cloudKitStatusLabel: some View {
switch cloudKitStatus { switch cloudKitStatus {
case nil: case nil:
EmptyView() EmptyView()
@ -99,17 +117,6 @@ struct AdvancedPrefsView : View {
Text(String(describing: cloudKitStatus!)) Text(String(describing: cloudKitStatus!))
} }
} }
}
.appGroupedListRowBackground()
.task {
do {
let status = try await CKContainer.default().accountStatus()
self.cloudKitStatus = status
} catch {
Logging.general.error("Unable to get CloudKit status: \(String(describing: error))")
}
}
}
var errorReportingSection: some View { var errorReportingSection: some View {
Section { Section {

View File

@ -1,3 +1,4 @@
//
// AppearancePrefsView.swift // AppearancePrefsView.swift
// Tusker // Tusker
// //
@ -7,9 +8,11 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TuskerPreferences
struct AppearancePrefsView : View { struct AppearancePrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject private var preferences = Preferences.shared
@Environment(\.colorScheme) private var colorScheme
private var appearanceChangePublisher: some Publisher<Void, Never> { private var appearanceChangePublisher: some Publisher<Void, Never> {
preferences.$theme preferences.$theme
@ -21,13 +24,7 @@ struct AppearancePrefsView : View {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
} }
private var useCircularAvatars: Binding<Bool> = Binding(get: { private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
Preferences.shared.avatarStyle == .circle
}) {
Preferences.shared.avatarStyle = $0 ? .circle : .roundRect
}
private let accentColorsAndImages: [(Preferences.AccentColor, UIImage?)] = Preferences.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, *) {
@ -46,21 +43,46 @@ struct AppearancePrefsView : View {
List { List {
themeSection themeSection
interfaceSection interfaceSection
Section("Post Preview") {
MockStatusView()
.padding(.top, 8)
.padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 8 : 4)
}
.listRowBackground(mockStatusBackground)
accountsSection accountsSection
postsSection postsSection
mediaSection
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self) .appGroupedListBackground(container: PreferencesNavigationController.self)
.navigationBarTitle(Text("Appearance")) .navigationTitle("Appearance")
}
private var mockStatusBackground: Color? {
#if targetEnvironment(macCatalyst)
nil
#else
if ProcessInfo.processInfo.isiOSAppOnMac {
nil
} else if !preferences.pureBlackDarkMode {
.appBackground
} else if colorScheme == .dark {
.black
} else {
.white
}
#endif
} }
private var themeSection: some View { private var themeSection: some View {
Section { Section {
#if !os(visionOS) #if !os(visionOS)
Picker(selection: $preferences.theme, label: Text("Theme")) { Picker(selection: $preferences.theme, label: Text("Theme")) {
Text("Use System Theme").tag(UIUserInterfaceStyle.unspecified) Text("Use System Theme").tag(Theme.unspecified)
Text("Light").tag(UIUserInterfaceStyle.light) Text("Light").tag(Theme.light)
Text("Dark").tag(UIUserInterfaceStyle.dark) Text("Dark").tag(Theme.dark)
} }
// macOS system dark mode isn't pure black, so this isn't necessary // macOS system dark mode isn't pure black, so this isn't necessary
@ -72,7 +94,7 @@ struct AppearancePrefsView : View {
#endif #endif
Picker(selection: $preferences.accentColor, label: Text("Accent Color")) { Picker(selection: $preferences.accentColor, label: Text("Accent Color")) {
ForEach(accentColorsAndImages, id: \.0.rawValue) { (color, image) in ForEach(Self.accentColorsAndImages, id: \.0.rawValue) { (color, image) in
HStack { HStack {
Text(color.name) Text(color.name)
if let image { if let image {
@ -102,8 +124,12 @@ struct AppearancePrefsView : View {
} }
private var accountsSection: some View { private var accountsSection: some View {
Section(header: Text("Accounts")) { Section("Accounts") {
Toggle(isOn: useCircularAvatars) { Toggle(isOn: Binding(get: {
preferences.avatarStyle == .circle
}, set: {
preferences.avatarStyle = $0 ? .circle : .roundRect
})) {
Text("Use Circular Avatars") Text("Use Circular Avatars")
} }
Toggle(isOn: $preferences.hideCustomEmojiInUsernames) { Toggle(isOn: $preferences.hideCustomEmojiInUsernames) {
@ -114,7 +140,7 @@ struct AppearancePrefsView : View {
} }
private var postsSection: some View { private var postsSection: some View {
Section(header: Text("Posts")) { Section("Posts") {
Toggle(isOn: $preferences.showIsStatusReplyIcon) { Toggle(isOn: $preferences.showIsStatusReplyIcon) {
Text("Show Status Reply Icons") Text("Show Status Reply Icons")
} }
@ -146,6 +172,41 @@ struct AppearancePrefsView : View {
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
private var mediaSection: some View {
Section("Media") {
Picker(selection: $preferences.attachmentBlurMode) {
ForEach(AttachmentBlurMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
} label: {
Text("Blur Media")
}
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
Text("Blur Media Behind Content Warning")
}
.disabled(preferences.attachmentBlurMode != .useStatusSetting)
Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs")
}
Toggle(isOn: $preferences.showUncroppedMediaInline) {
Text("Show Uncropped Media Inline")
}
Toggle(isOn: $preferences.showAttachmentBadges) {
Text("Show GIF/\(Text("Alt").font(.body.lowercaseSmallCaps())) Badges")
}
Toggle(isOn: $preferences.attachmentAltBadgeInverted) {
Text("Show Badge when Missing \(Text("Alt").font(.body.lowercaseSmallCaps()))")
}
.disabled(!preferences.showAttachmentBadges)
}
.appGroupedListRowBackground()
}
} }
#if DEBUG #if DEBUG

View File

@ -0,0 +1,270 @@
//
// MockStatusView.swift
// Tusker
//
// Created by Shadowfacts on 4/13/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
import WebURL
struct MockStatusView: View {
@ObservedObject private var preferences = Preferences.shared
@ScaledMetric(relativeTo: .body) private var attachmentsLabelHeight = 17
var body: some View {
HStack(alignment: .top, spacing: 8) {
VStack(spacing: 4) {
Image("AboutIcon")
.resizable()
.clipShape(RoundedRectangle(cornerRadius: preferences.avatarStyle.cornerRadiusFraction * 50))
.frame(width: 50, height: 50)
MockMetaIndicatorsView()
Spacer()
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
MockDisplayNameLabel()
Text(verbatim: "@tusker@example.com")
.foregroundStyle(.secondary)
.font(.body.weight(.light))
.lineLimit(1)
.truncationMode(.tail)
.layoutPriority(-100)
Spacer()
Text("1h")
.foregroundStyle(.secondary)
.font(.body.weight(.light))
}
MockStatusContentView()
if preferences.showLinkPreviews {
MockStatusCardView()
.frame(height: StatusContentContainer.cardViewHeight)
}
MockAttachmentsContainerView()
.aspectRatio(preferences.showAttachmentsInTimeline ? 16/9 : nil, contentMode: .fill)
.frame(height: preferences.showAttachmentsInTimeline ? nil : attachmentsLabelHeight)
.padding(.bottom, preferences.showAttachmentsInTimeline && preferences.hideActionsInTimeline ? 8 : 0)
if !preferences.hideActionsInTimeline {
MockStatusActionButtons()
}
}
.layoutPriority(100)
}
}
}
private struct MockMetaIndicatorsView: UIViewRepresentable {
@ObservedObject private var preferences = Preferences.shared
func makeUIView(context: Context) -> StatusMetaIndicatorsView {
let view = StatusMetaIndicatorsView()
view.primaryAxis = .vertical
view.secondaryAxisAlignment = .trailing
return view
}
func updateUIView(_ uiView: StatusMetaIndicatorsView, context: Context) {
var indicators: StatusMetaIndicatorsView.Indicator = []
if preferences.showIsStatusReplyIcon {
indicators.insert(.reply)
}
if preferences.alwaysShowStatusVisibilityIcon {
indicators.insert(.visibility)
}
uiView.setIndicators(indicators, visibility: .public)
}
}
private struct MockDisplayNameLabel: View {
@ObservedObject private var preferences = Preferences.shared
@ScaledMetric(relativeTo: .body) private var emojiSize = 17
@State var textWithImage = Text("Tusker")
var body: some View {
displayName
.font(.body.weight(.semibold))
// don't let the height change depending on whether emojis are present or not
.frame(height: emojiSize)
.task(id: emojiSize) {
let size = CGSize(width: emojiSize, height: emojiSize)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
let bounds = CGRect(origin: .zero, size: size)
UIBezierPath(roundedRect: bounds, cornerRadius: 2).addClip()
UIImage(named: "AboutIcon")!.draw(in: bounds)
}
textWithImage = Text("Tusker \(Image(uiImage: image))")
}
}
private var displayName: Text {
if preferences.hideCustomEmojiInUsernames {
Text("Tusker")
} else {
textWithImage
}
}
}
private struct MockStatusContentView: View {
@ObservedObject private var preferences = Preferences.shared
var body: some View {
Text("This is an example post so you can check out how things look.\n\nThanks for using \(link)!")
.lineLimit(nil)
}
private var link: Text {
Text("Tusker")
.foregroundColor(.accentColor)
.underline(preferences.underlineTextLinks)
}
}
private struct MockStatusCardView: UIViewRepresentable {
func makeUIView(context: Context) -> StatusCardView {
let view = StatusCardView()
view.isUserInteractionEnabled = false
let card = Card(
url: WebURL("https://vaccor.space/tusker")!,
title: "Tusker",
description: "Tusker is an iOS app for Mastodon",
image: WebURL("https://vaccor.space/tusker/img/icon.png")!,
kind: .link
)
view.updateUI(card: card, sensitive: false)
return view
}
func updateUIView(_ uiView: StatusCardView, context: Context) {
}
}
private actor MockAttachmentsGenerator {
static let shared = MockAttachmentsGenerator()
private var attachmentURLs: [URL]?
func getAttachmentURLs(displayScale: CGFloat) -> [URL] {
if let attachmentURLs,
attachmentURLs.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) {
return attachmentURLs
}
let size = CGSize(width: 200, height: 200)
let bounds = CGRect(origin: .zero, size: size)
let format = UIGraphicsImageRendererFormat()
format.scale = displayScale
let renderer = UIGraphicsImageRenderer(size: size, format: format)
let firstImage = renderer.image { ctx in
UIColor(red: 0x56 / 255, green: 0x03 / 255, blue: 0xad / 255, alpha: 1).setFill()
ctx.fill(bounds)
ctx.cgContext.concatenate(CGAffineTransform(1, 0, -0.5, 1, 0, 0))
for x in 0..<9 {
UIColor(red: 0x83 / 255, green: 0x67 / 255, blue: 0xc7 / 255, alpha: 1).setFill()
ctx.fill(CGRect(x: CGFloat(x) * 30 + 20, y: 0, width: 15, height: bounds.height))
}
}
let secondImage = renderer.image { ctx in
UIColor(red: 0x00 / 255, green: 0x43 / 255, blue: 0x85 / 255, alpha: 1).setFill()
ctx.fill(bounds)
UIColor(red: 0x05 / 255, green: 0xb2 / 255, blue: 0xdc / 255, alpha: 1).setFill()
for y in 0..<4 {
for x in 0..<5 {
let rect = CGRect(x: x * 45 - 5, y: y * 50 + 15, width: 20, height: 20)
ctx.cgContext.fillEllipse(in: rect)
}
}
UIColor(red: 0x08 / 255, green: 0x7c / 255, blue: 0xa7 / 255, alpha: 1).setFill()
for y in 0..<5 {
for x in 0..<4 {
let rect = CGRect(x: CGFloat(x) * 45 + 22.5, y: CGFloat(y) * 50 - 5, width: 10, height: 10)
ctx.cgContext.fillEllipse(in: rect)
}
}
}
let tempDirectory = FileManager.default.temporaryDirectory
let firstURL = tempDirectory.appendingPathComponent("\(UUID().description)", conformingTo: .png)
let secondURL = tempDirectory.appendingPathComponent("\(UUID().description)", conformingTo: .png)
do {
try firstImage.pngData()!.write(to: firstURL)
try secondImage.pngData()!.write(to: secondURL)
attachmentURLs = [firstURL, secondURL]
return [firstURL, secondURL]
} catch {
return []
}
}
}
private struct MockAttachmentsContainerView: View {
@State private var attachments: [Attachment] = []
@Environment(\.displayScale) private var displayScale
var body: some View {
MockAttachmentsContainerRepresentable(attachments: attachments)
.task {
let attachmentURLs = await MockAttachmentsGenerator.shared.getAttachmentURLs(displayScale: displayScale)
self.attachments = [
.init(id: "1", kind: .image, url: attachmentURLs[0], description: "test"),
.init(id: "2", kind: .image, url: attachmentURLs[1], description: nil),
]
}
}
}
private struct MockAttachmentsContainerRepresentable: UIViewRepresentable {
let attachments: [Attachment]
@ObservedObject private var preferences = Preferences.shared
func makeUIView(context: Context) -> AttachmentsContainerView {
let view = AttachmentsContainerView()
view.isUserInteractionEnabled = false
return view
}
func updateUIView(_ uiView: AttachmentsContainerView, context: Context) {
uiView.updateUI(attachments: attachments, labelOnly: !preferences.showAttachmentsInTimeline)
uiView.contentHidden = preferences.attachmentBlurMode == .always
for attachmentView in uiView.attachmentViews.allObjects {
attachmentView.updateBadges()
}
}
}
private struct MockStatusActionButtons: View {
var body: some View {
HStack(spacing: 0) {
Image(systemName: "arrowshape.turn.up.left.fill")
.foregroundStyle(.tint)
Spacer()
Image(systemName: "star.fill")
.foregroundStyle(.tint)
Spacer()
Image(systemName: "repeat")
.foregroundStyle(.yellow)
Spacer()
Image(systemName: "ellipsis")
.foregroundStyle(.tint)
Spacer()
}
}
}
#Preview {
MockStatusView()
.frame(height: 300)
}

View File

@ -8,63 +8,76 @@
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
@State private var startAnimation = PassthroughSubject<Void, Never>() @State private var startAnimation = CurrentValueSubject<Bool, Never>(false)
private var startAnimationSignal: some Publisher<Void, Never> {
startAnimation.filter { $0 }.removeDuplicates().map { _ in () }
}
var body: some View { var body: some View {
HStack { HStack {
Spacer() Spacer()
OptionView<StackNavigationPreview>( OptionView(
content: StackNavigationPreview.self,
value: .stack, value: .stack,
selection: $preferences.widescreenNavigationMode, selection: $preferences.widescreenNavigationMode,
startAnimation: startAnimation startAnimation: startAnimationSignal
) { ) {
Text("Stack") Text("Stack")
} }
Spacer(minLength: 32) Spacer(minLength: 32)
OptionView<SplitNavigationPreview>( OptionView(
content: SplitNavigationPreview.self,
value: .splitScreen, value: .splitScreen,
selection: $preferences.widescreenNavigationMode, selection: $preferences.widescreenNavigationMode,
startAnimation: startAnimation startAnimation: startAnimationSignal
) { ) {
Text("Split Screen") Text("Split Screen")
} }
if preferences.hasFeatureFlag(.iPadMultiColumn) {
Spacer(minLength: 32) Spacer(minLength: 32)
OptionView<MultiColumnNavigationPreview>( OptionView(
content: MultiColumnNavigationPreview.self,
value: .multiColumn, value: .multiColumn,
selection: $preferences.widescreenNavigationMode, selection: $preferences.widescreenNavigationMode,
startAnimation: startAnimation startAnimation: startAnimationSignal
) { ) {
Text("Multi-Column") Text("Multi-Column")
} }
}
Spacer() Spacer()
} }
.frame(height: 100) .frame(height: 100)
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
startAnimation.send() startAnimation.send(true)
} }
} }
} }
} }
private struct OptionView<Content: NavigationModePreview>: View { private struct OptionView<Content: NavigationModePreview, P: Publisher<Void, Never>>: View {
let value: Preferences.WidescreenNavigationMode let value: WidescreenNavigationMode
@Binding var selection: Preferences.WidescreenNavigationMode @Binding var selection: WidescreenNavigationMode
let startAnimation: PassthroughSubject<Void, Never> let startAnimation: P
@ViewBuilder let label: Text let label: Text
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
init(content _: Content.Type, value: WidescreenNavigationMode, selection: Binding<WidescreenNavigationMode>, startAnimation: P, @ViewBuilder label: () -> Text) {
self.value = value
self._selection = selection
self.startAnimation = startAnimation
self.label = label()
}
private var selected: Bool { private var selected: Bool {
selection == value selection == value
} }
@ -83,7 +96,7 @@ private struct OptionView<Content: NavigationModePreview>: View {
} }
private var preview: some View { private var preview: some View {
NavigationModeRepresentable<Content>(startAnimation: startAnimation) NavigationModeRepresentable(content: Content.self, startAnimation: startAnimation)
.clipShape(RoundedRectangle(cornerRadius: 12.5, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 12.5, style: .continuous))
.overlay { .overlay {
RoundedRectangle(cornerRadius: 12.5, style: .continuous) RoundedRectangle(cornerRadius: 12.5, style: .continuous)
@ -105,11 +118,15 @@ private struct WideCapsule: Shape {
@MainActor @MainActor
private protocol NavigationModePreview: UIView { private protocol NavigationModePreview: UIView {
init(startAnimation: PassthroughSubject<Void, Never>) init(startAnimation: some Publisher<Void, Never>)
} }
private struct NavigationModeRepresentable<UIViewType: NavigationModePreview>: UIViewRepresentable { private struct NavigationModeRepresentable<UIViewType: NavigationModePreview, P: Publisher<Void, Never>>: UIViewRepresentable {
let startAnimation: PassthroughSubject<Void, Never> let startAnimation: P
init(content _: UIViewType.Type, startAnimation: P) {
self.startAnimation = startAnimation
}
func makeUIView(context: Context) -> UIViewType { func makeUIView(context: Context) -> UIViewType {
UIViewType(startAnimation: startAnimation) UIViewType(startAnimation: startAnimation)
@ -127,7 +144,7 @@ private final class StackNavigationPreview: UIView, NavigationModePreview {
private let destinationView = UIView() private let destinationView = UIView()
private var cancellable: AnyCancellable? private var cancellable: AnyCancellable?
init(startAnimation: PassthroughSubject<Void, Never>) { init(startAnimation: some Publisher<Void, Never>) {
super.init(frame: .zero) super.init(frame: .zero)
backgroundColor = .appBackground backgroundColor = .appBackground
@ -202,7 +219,7 @@ private final class SplitNavigationPreview: UIView, NavigationModePreview {
private var cellStackTrailingConstraint: NSLayoutConstraint! private var cellStackTrailingConstraint: NSLayoutConstraint!
private var cancellable: AnyCancellable? private var cancellable: AnyCancellable?
init(startAnimation: PassthroughSubject<Void, Never>) { init(startAnimation: some Publisher<Void, Never>) {
super.init(frame: .zero) super.init(frame: .zero)
backgroundColor = .appBackground backgroundColor = .appBackground
@ -296,7 +313,7 @@ private final class MultiColumnNavigationPreview: UIView, NavigationModePreview
private var startedAnimation = false private var startedAnimation = false
init(startAnimation: PassthroughSubject<Void, Never>) { init(startAnimation: some Publisher<Void, Never>) {
super.init(frame: .zero) super.init(frame: .zero)
backgroundColor = .appSecondaryBackground backgroundColor = .appSecondaryBackground

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import Pachyderm import Pachyderm
import TuskerPreferences
struct BehaviorPrefsView: View { struct BehaviorPrefsView: View {
@ObservedObject var preferences = Preferences.shared @ObservedObject var preferences = Preferences.shared
@ -39,8 +40,8 @@ struct BehaviorPrefsView: View {
} }
Picker(selection: $preferences.timelineSyncMode) { Picker(selection: $preferences.timelineSyncMode) {
Text("iCloud").tag(Preferences.TimelineSyncMode.icloud) Text("iCloud").tag(TimelineSyncMode.icloud)
Text("Mastodon").tag(Preferences.TimelineSyncMode.mastodon) Text("Mastodon").tag(TimelineSyncMode.mastodon)
} label: { } label: {
Text("Sync Timeline Position via") Text("Sync Timeline Position via")
} }
@ -57,13 +58,15 @@ struct BehaviorPrefsView: View {
Toggle(isOn: $preferences.openLinksInApps) { Toggle(isOn: $preferences.openLinksInApps) {
Text("Open Links in Apps") Text("Open Links in Apps")
} }
#if !os(visionOS) #if !targetEnvironment(macCatalyst) && !os(visionOS)
if !ProcessInfo.processInfo.isiOSAppOnMac {
Toggle(isOn: $preferences.useInAppSafari) { Toggle(isOn: $preferences.useInAppSafari) {
Text("Use In-App Safari") Text("Use In-App Safari")
} }
Toggle(isOn: $preferences.inAppSafariAutomaticReaderMode) { Toggle(isOn: $preferences.inAppSafariAutomaticReaderMode) {
Text("Always Use Reader Mode in In-App Safari") Text("Always Use Reader Mode in In-App Safari")
}.disabled(!preferences.useInAppSafari) }.disabled(!preferences.useInAppSafari)
}
#endif #endif
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()

View File

@ -1,63 +0,0 @@
//
// MediaPrefsView.swift
// Tusker
//
// Created by Shadowfacts on 2/22/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import SwiftUI
struct MediaPrefsView: View {
@ObservedObject var preferences = Preferences.shared
var body: some View {
List {
viewingSection
}
.listStyle(.insetGrouped)
.appGroupedListBackground(container: PreferencesNavigationController.self)
.navigationBarTitle("Media")
}
var viewingSection: some View {
Section(header: Text("Viewing")) {
Picker(selection: $preferences.attachmentBlurMode) {
ForEach(Preferences.AttachmentBlurMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
} label: {
Text("Blur Media")
}
Toggle(isOn: $preferences.blurMediaBehindContentWarning) {
Text("Blur Media Behind Content Warning")
}
.disabled(preferences.attachmentBlurMode != .useStatusSetting)
Toggle(isOn: $preferences.automaticallyPlayGifs) {
Text("Automatically Play GIFs")
}
Toggle(isOn: $preferences.showUncroppedMediaInline) {
Text("Show Uncropped Media Inline")
}
Toggle(isOn: $preferences.showAttachmentBadges) {
Text("Show GIF/\(Text("Alt").font(.body.lowercaseSmallCaps())) Badges")
}
Toggle(isOn: $preferences.attachmentAltBadgeInverted) {
Text("Show Badge when Missing \(Text("Alt").font(.body.lowercaseSmallCaps()))")
}
.disabled(!preferences.showAttachmentBadges)
}
.appGroupedListRowBackground()
}
}
struct MediaPrefsView_Previews: PreviewProvider {
static var previews: some View {
MediaPrefsView()
}
}

View File

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

View File

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

View File

@ -13,12 +13,13 @@ import TuskerComponents
struct PushSubscriptionView: View { struct PushSubscriptionView: View {
let account: UserAccountInfo let account: UserAccountInfo
let mastodonController: MastodonController
let subscription: PushSubscription? let subscription: PushSubscription?
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
var body: some View { var body: some View {
if let subscription { if let subscription {
PushSubscriptionSettingsView(account: account, subscription: subscription, updateSubscription: updateSubscription) PushSubscriptionSettingsView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
} else { } else {
Text("No notifications") Text("No notifications")
.font(.callout) .font(.callout)
@ -29,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

View File

@ -23,7 +23,6 @@ struct PreferencesView: View {
var body: some View { var body: some View {
List { List {
accountsSection accountsSection
notificationsSection
preferencesSection preferencesSection
aboutSection aboutSection
} }
@ -92,36 +91,27 @@ struct PreferencesView: View {
.appGroupedListRowBackground() .appGroupedListRowBackground()
} }
private var notificationsSection: some View {
Section {
NavigationLink(isActive: $navigationState.showNotificationPreferences) {
NotificationsPrefsView()
} label: {
Text("Notifications")
}
}
.appGroupedListRowBackground()
}
private var preferencesSection: some View { private var preferencesSection: some View {
Section { Section {
NavigationLink(destination: AppearancePrefsView()) { NavigationLink(destination: AppearancePrefsView()) {
Text("Appearance") PreferenceSectionLabel(title: "Appearance", systemImageName: "textformat", backgroundColor: .indigo)
}
NavigationLink(destination: ComposingPrefsView()) {
Text("Composing")
}
NavigationLink(destination: MediaPrefsView()) {
Text("Media")
} }
NavigationLink(destination: BehaviorPrefsView()) { NavigationLink(destination: BehaviorPrefsView()) {
Text("Behavior") PreferenceSectionLabel(title: "Behavior", systemImageName: "flowchart.fill", backgroundColor: .green)
}
NavigationLink(isActive: $navigationState.showNotificationPreferences) {
NotificationsPrefsView()
} label: {
PreferenceSectionLabel(title: "Notifications", systemImageName: "bell.fill", backgroundColor: .red)
}
NavigationLink(destination: ComposingPrefsView()) {
PreferenceSectionLabel(title: "Composing", systemImageName: "pencil", backgroundColor: .blue)
} }
NavigationLink(destination: WellnessPrefsView()) { NavigationLink(destination: WellnessPrefsView()) {
Text("Digital Wellness") PreferenceSectionLabel(title: "Digital Wellness", systemImageName: "brain.fill", backgroundColor: .purple)
} }
NavigationLink(destination: AdvancedPrefsView()) { NavigationLink(destination: AdvancedPrefsView()) {
Text("Advanced") PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray)
} }
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
@ -129,14 +119,28 @@ struct PreferencesView: View {
private var aboutSection: some View { private var aboutSection: some View {
Section { Section {
NavigationLink("About") { NavigationLink {
AboutView() AboutView()
} label: {
Label {
Text("About")
} icon: {
Image("AboutIcon")
.resizable()
.clipShape(RoundedRectangle(cornerRadius: 6))
.frame(width: 30, height: 30)
} }
NavigationLink("Tip Jar") { }
NavigationLink {
TipJarView() TipJarView()
} label: {
// TODO: custom tip jar icon?
PreferenceSectionLabel(title: "Tip Jar", systemImageName: "dollarsign.square.fill", backgroundColor: .yellow)
} }
NavigationLink("Acknowledgements") { NavigationLink {
AcknowledgementsView() AcknowledgementsView()
} label: {
PreferenceSectionLabel(title: "Acknowledgements", systemImageName: "doc.text.fill", backgroundColor: .gray)
} }
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
@ -147,6 +151,24 @@ struct PreferencesView: View {
} }
} }
private struct PreferenceSectionLabel: View {
let title: LocalizedStringKey
let systemImageName: String
let backgroundColor: Color
var body: some View {
Label {
Text(title)
} icon: {
Image(systemName: systemImageName)
.imageScale(.medium)
.foregroundStyle(.white)
.frame(width: 30, height: 30)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 6))
}
}
}
//#if DEBUG //#if DEBUG
//struct PreferencesView_Previews : PreviewProvider { //struct PreferencesView_Previews : PreviewProvider {
// static var previews: some View { // static var previews: some View {

View File

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

View File

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

View File

@ -47,10 +47,11 @@ extension TuskerNavigationDelegate {
func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) { func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) {
func openSafari() { func openSafari() {
#if os(visionOS) #if targetEnvironment(macCatalyst) || os(visionOS)
UIApplication.shared.open(url) UIApplication.shared.open(url)
#else #else
if Preferences.shared.useInAppSafari, if !ProcessInfo.processInfo.isiOSAppOnMac,
Preferences.shared.useInAppSafari,
url.scheme == "https" || url.scheme == "http" { url.scheme == "https" || url.scheme == "http" {
let config = SFSafariViewController.Configuration() let config = SFSafariViewController.Configuration()
config.entersReaderIfAvailable = Preferences.shared.inAppSafariAutomaticReaderMode config.entersReaderIfAvailable = Preferences.shared.inAppSafariAutomaticReaderMode

View File

@ -111,6 +111,10 @@ class AttachmentView: GIFImageView {
} }
} }
func updateBadges() {
createBadgesView(getBadges())
}
@objc private func gifPlaybackModeChanged() { @objc private func gifPlaybackModeChanged() {
// NSProcessInfoPowerStateDidChange is sometimes fired on a background thread // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread
DispatchQueue.main.async { DispatchQueue.main.async {
@ -370,6 +374,8 @@ class AttachmentView: GIFImageView {
return return
} }
self.badgeContainer?.removeFromSuperview()
let stack = UIStackView() let stack = UIStackView()
self.badgeContainer = stack self.badgeContainer = stack
stack.axis = .horizontal stack.axis = .horizontal

View File

@ -72,21 +72,35 @@ class AttachmentsContainerView: UIView {
func updateUI(attachments: [Attachment], labelOnly: Bool = false) { func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
let newTokens = attachments.map { AttachmentToken(attachment: $0) } let newTokens = attachments.map { AttachmentToken(attachment: $0) }
guard !labelOnly else { guard labelOnly != (label != nil) || self.attachmentTokens != newTokens else {
self.attachments = attachments self.attachments = attachments
self.attachmentTokens = newTokens self.attachmentTokens = newTokens
updateLabel(attachments: attachments)
return
}
guard self.attachmentTokens != newTokens else {
self.isHidden = attachments.isEmpty self.isHidden = attachments.isEmpty
if labelOnly && !attachments.isEmpty {
updateLabel(attachments: attachments)
} else {
label?.removeFromSuperview()
label = nil
}
return return
} }
self.attachments = attachments self.attachments = attachments
self.attachmentTokens = newTokens self.attachmentTokens = newTokens
if labelOnly {
if !attachments.isEmpty {
updateLabel(attachments: attachments)
} else {
label?.removeFromSuperview()
label = nil
}
return
} else {
label?.removeFromSuperview()
label = nil
}
removeAttachmentViews() removeAttachmentViews()
hideButtonView?.isHidden = false hideButtonView?.isHidden = false

View File

@ -29,7 +29,9 @@ class GifvController {
self.isGrayscale = Preferences.shared.grayscaleImages self.isGrayscale = Preferences.shared.grayscaleImages
player.isMuted = true player.isMuted = true
#if !os(visionOS)
player.preventsDisplaySleepDuringVideoPlayback = false player.preventsDisplaySleepDuringVideoPlayback = false
#endif
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item) NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)

View File

@ -20,8 +20,8 @@ class StatusCardView: UIView {
private var statusID: String? private var statusID: String?
private(set) var card: Card? private(set) var card: Card?
private let activeBackgroundColor = UIColor.secondarySystemFill private static let activeBackgroundColor = UIColor.secondarySystemFill
private let inactiveBackgroundColor = UIColor.secondarySystemBackground private static let inactiveBackgroundColor = UIColor.secondarySystemBackground
private var isGrayscale = false private var isGrayscale = false
@ -107,7 +107,7 @@ class StatusCardView: UIView {
hStack.clipsToBounds = true hStack.clipsToBounds = true
hStack.layer.borderWidth = 0.5 hStack.layer.borderWidth = 0.5
hStack.layer.cornerCurve = .continuous hStack.layer.cornerCurve = .continuous
hStack.backgroundColor = inactiveBackgroundColor hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
updateBorderColor() updateBorderColor()
addSubview(hStack) addSubview(hStack)
@ -173,8 +173,12 @@ class StatusCardView: UIView {
return return
} }
updateUI(card: card, sensitive: status.sensitive)
}
func updateUI(card: Card, sensitive: Bool) {
if let image = card.image { if let image = card.image {
if status.sensitive { if sensitive {
if let blurhash = card.blurhash { if let blurhash = card.blurhash {
imageView.blurImage = false imageView.blurImage = false
imageView.showOnlyBlurHash(blurhash, for: URL(image)!) imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
@ -219,7 +223,7 @@ class StatusCardView: UIView {
} }
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = activeBackgroundColor hStack.backgroundColor = StatusCardView.activeBackgroundColor
setNeedsDisplay() setNeedsDisplay()
} }
@ -227,7 +231,7 @@ class StatusCardView: UIView {
} }
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = inactiveBackgroundColor hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
setNeedsDisplay() setNeedsDisplay()
if let card = card, let delegate = navigationDelegate { if let card = card, let delegate = navigationDelegate {
@ -236,7 +240,7 @@ class StatusCardView: UIView {
} }
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) { override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
hStack.backgroundColor = inactiveBackgroundColor hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
setNeedsDisplay() setNeedsDisplay()
} }

View File

@ -80,20 +80,35 @@ class StatusMetaIndicatorsView: UIView {
} }
statusID = status.id statusID = status.id
var images: [UIImage] = [] var indicators: Indicator = []
if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil { if allowedIndicators.contains(.reply) && Preferences.shared.showIsStatusReplyIcon && status.inReplyToID != nil {
images.append(UIImage(systemName: "bubble.left.and.bubble.right")!) indicators.insert(.reply)
} }
if allowedIndicators.contains(.visibility) && Preferences.shared.alwaysShowStatusVisibilityIcon { if allowedIndicators.contains(.visibility) && Preferences.shared.alwaysShowStatusVisibilityIcon {
images.append(UIImage(systemName: status.visibility.unfilledImageName)!) indicators.insert(.visibility)
} }
if allowedIndicators.contains(.localOnly) && status.localOnly { if allowedIndicators.contains(.localOnly) && status.localOnly {
images.append(UIImage(named: "link.broken")!) indicators.insert(.localOnly)
} }
setIndicators(indicators, visibility: status.visibility)
}
// Used by MockStatusView
func setIndicators(_ indicators: Indicator, visibility: Visibility) {
var images: [UIImage] = []
if indicators.contains(.reply) {
images.append(UIImage(systemName: "bubble.left.and.bubble.right")!)
}
if indicators.contains(.visibility) {
images.append(UIImage(systemName: visibility.unfilledImageName)!)
}
if indicators.contains(.localOnly) {
images.append(UIImage(named: "link.broken")!)
}
let views = images.map { let views = images.map {
let v = UIImageView(image: $0) let v = UIImageView(image: $0)
v.translatesAutoresizingMaskIntoConstraints = false v.translatesAutoresizingMaskIntoConstraints = false

View File

@ -47,30 +47,4 @@ class AttributedStringHelperTests: XCTestCase {
XCTAssertEqual(d, NSAttributedString(string: "abc")) XCTAssertEqual(d, NSAttributedString(string: "abc"))
} }
func testCollapsingWhitespace() {
var str = NSAttributedString(string: "test 1\n")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 1\n"))
str = NSAttributedString(string: "test 2 \n")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 2\n"))
str = NSAttributedString(string: "test 3\n ")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 3\n"))
str = NSAttributedString(string: "test 4 \n ")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 4\n"))
str = NSAttributedString(string: "test 5 \n blah")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "test 5\nblah"))
str = NSAttributedString(string: "\ntest 6")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 6"))
str = NSAttributedString(string: " \ntest 7")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 7"))
str = NSAttributedString(string: " \n test 8")
XCTAssertEqual(str.collapsingWhitespace(), NSAttributedString(string: "\ntest 8"))
}
} }

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.2 MARKETING_VERSION = 2024.2
CURRENT_PROJECT_VERSION = 120 CURRENT_PROJECT_VERSION = 121
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev