Compare commits
26 Commits
7825ccbb3d
...
798e0c0cf1
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 798e0c0cf1 | |
Shadowfacts | 3f370945e6 | |
Shadowfacts | a759731eba | |
Shadowfacts | 405d5def7c | |
Shadowfacts | 1f9806d02f | |
Shadowfacts | c43c951b92 | |
Shadowfacts | 00c44c612f | |
Shadowfacts | e5c4fceacd | |
Shadowfacts | 70227a7fa1 | |
Shadowfacts | cb5488dcaa | |
Shadowfacts | 910e18fb5e | |
Shadowfacts | 66af946766 | |
Shadowfacts | 6784ed7fdf | |
Shadowfacts | 66f0ba6891 | |
Shadowfacts | ee7bf5138c | |
Shadowfacts | c32181818a | |
Shadowfacts | 4665df228d | |
Shadowfacts | c7a56a9f61 | |
Shadowfacts | 39251b9aa2 | |
Shadowfacts | db534e5993 | |
Shadowfacts | e94bee4fc8 | |
Shadowfacts | 216e58e5ec | |
Shadowfacts | a4d13ad03b | |
Shadowfacts | 05cfecb797 | |
Shadowfacts | 132fcfa099 | |
Shadowfacts | 475b9911b1 |
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,5 +1,20 @@
|
|||
# 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)
|
||||
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||
|
||||
|
|
|
@ -301,6 +301,7 @@ extension MainActor {
|
|||
@available(iOS, obsoleted: 17.0)
|
||||
@available(watchOS, obsoleted: 10.0)
|
||||
@available(tvOS, obsoleted: 17.0)
|
||||
@available(visionOS 1.0, *)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||
return try MainActor.assumeIsolated(body)
|
||||
|
|
|
@ -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>
|
|
@ -181,13 +181,8 @@ class ToolbarController: ViewController {
|
|||
private var formatButtons: some View {
|
||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||
Button(action: controller.formatAction(format)) {
|
||||
if let imageName = format.imageName {
|
||||
Image(systemName: imageName)
|
||||
.font(.system(size: imageSize))
|
||||
} else if let (str, attrs) = format.title {
|
||||
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||
Text(AttributedString(str, attributes: container))
|
||||
}
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
|
|
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .italics:
|
||||
return "italic"
|
||||
|
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
|
|||
return "bold"
|
||||
case .strikethrough:
|
||||
return "strikethrough"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
if self == .code {
|
||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||
} else {
|
||||
return nil
|
||||
case .code:
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -259,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
var image: UIImage?
|
||||
if let imageName = fmt.imageName {
|
||||
image = UIImage(systemName: imageName)
|
||||
}
|
||||
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
|
||||
self?.applyFormat(fmt)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -184,6 +184,31 @@ public final class InstanceFeatures: ObservableObject {
|
|||
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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -25,6 +25,17 @@ public struct Attachment: Codable, Sendable {
|
|||
], 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 {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
|
|
|
@ -26,6 +26,38 @@ public struct Card: Codable, Sendable {
|
|||
/// Only present when returned from the trending links endpoint
|
||||
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 {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
|
|
@ -9,11 +9,11 @@
|
|||
import Foundation
|
||||
|
||||
public struct PushSubscription: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let endpoint: URL
|
||||
public let serverKey: String
|
||||
public let alerts: Alerts
|
||||
public let policy: Policy
|
||||
public var id: String
|
||||
public var endpoint: URL
|
||||
public var serverKey: String
|
||||
public var alerts: Alerts
|
||||
public var policy: Policy
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
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.serverKey = try container.decode(String.self, forKey: .serverKey)
|
||||
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> {
|
||||
|
@ -96,6 +97,21 @@ extension PushSubscription {
|
|||
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 {
|
||||
case mention
|
||||
case status
|
||||
|
|
|
@ -71,7 +71,7 @@ class PushManagerImpl: _PushManager {
|
|||
|
||||
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
|
||||
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)"
|
||||
return endpoint.url!
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ public struct PushSubscription {
|
|||
self.policy = policy
|
||||
}
|
||||
|
||||
public enum Policy: String, CaseIterable, Identifiable {
|
||||
public enum Policy: String, CaseIterable, Identifiable, Sendable {
|
||||
case all, followed, followers
|
||||
|
||||
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 status = Alerts(rawValue: 1 << 1)
|
||||
public static let reblog = Alerts(rawValue: 1 << 2)
|
||||
|
|
|
@ -9,8 +9,10 @@ import SwiftUI
|
|||
|
||||
public struct AsyncPicker<V: Hashable, Content: View>: View {
|
||||
let titleKey: LocalizedStringKey
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||
let labelHidden: Bool
|
||||
#endif
|
||||
let alignment: Alignment
|
||||
@Binding var value: V
|
||||
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) {
|
||||
self.titleKey = titleKey
|
||||
#if !os(visionOS)
|
||||
self.labelHidden = labelHidden
|
||||
#endif
|
||||
self.alignment = alignment
|
||||
self._value = value
|
||||
self.onChange = onChange
|
||||
|
@ -27,6 +31,11 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
|
|||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(visionOS)
|
||||
LabeledContent(titleKey) {
|
||||
picker
|
||||
}
|
||||
#else
|
||||
if #available(iOS 16.0, *) {
|
||||
LabeledContent(titleKey) {
|
||||
picker
|
||||
|
@ -40,6 +49,7 @@ public struct AsyncPicker<V: Hashable, Content: View>: View {
|
|||
picker
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var picker: some View {
|
||||
|
|
|
@ -10,19 +10,28 @@ import SwiftUI
|
|||
|
||||
public struct AsyncToggle: View {
|
||||
let titleKey: LocalizedStringKey
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0, message: "Switch to LabeledContent")
|
||||
let labelHidden: Bool
|
||||
#endif
|
||||
@Binding var mode: Mode
|
||||
let onChange: (Bool) async -> Bool
|
||||
|
||||
public init(_ titleKey: LocalizedStringKey, labelHidden: Bool = false, mode: Binding<Mode>, onChange: @escaping (Bool) async -> Bool) {
|
||||
self.titleKey = titleKey
|
||||
#if !os(visionOS)
|
||||
self.labelHidden = labelHidden
|
||||
#endif
|
||||
self._mode = mode
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
#if os(visionOS)
|
||||
LabeledContent(titleKey) {
|
||||
toggleOrSpinner
|
||||
}
|
||||
#else
|
||||
if #available(iOS 16.0, *) {
|
||||
LabeledContent(titleKey) {
|
||||
toggleOrSpinner
|
||||
|
@ -36,6 +45,7 @@ public struct AsyncToggle: View {
|
|||
toggleOrSpinner
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-url",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/karwa/swift-url.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "01ad5a103d14839a68c55ee556513e5939008e9e"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -24,5 +24,9 @@ let package = Package(
|
|||
name: "TuskerPreferences",
|
||||
dependencies: ["Pachyderm"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "TuskerPreferencesTests",
|
||||
dependencies: ["TuskerPreferences"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -0,0 +1,282 @@
|
|||
//
|
||||
// Coding.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private protocol PreferenceProtocol {
|
||||
associatedtype Key: PreferenceKey
|
||||
var storedValue: Key.Value? { get }
|
||||
init()
|
||||
}
|
||||
|
||||
extension Preference: PreferenceProtocol {
|
||||
}
|
||||
|
||||
struct PreferenceCoding<Wrapped: Codable>: Codable {
|
||||
let wrapped: Wrapped
|
||||
|
||||
init(wrapped: Wrapped) {
|
||||
self.wrapped = wrapped
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
try wrapped.encode(to: PreferenceEncoder(wrapped: encoder))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceDecoder: Decoder {
|
||||
let wrapped: any Decoder
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var userInfo: [CodingUserInfoKey : Any] {
|
||||
wrapped.userInfo
|
||||
}
|
||||
|
||||
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey {
|
||||
KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type)))
|
||||
}
|
||||
|
||||
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
|
||||
throw Error.onlyKeyedContainerSupported
|
||||
}
|
||||
|
||||
func singleValueContainer() throws -> any SingleValueDecodingContainer {
|
||||
throw Error.onlyKeyedContainerSupported
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case onlyKeyedContainerSupported
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
|
||||
let wrapped: KeyedDecodingContainer<Key>
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var allKeys: [Key] {
|
||||
wrapped.allKeys
|
||||
}
|
||||
|
||||
func contains(_ key: Key) -> Bool {
|
||||
wrapped.contains(key)
|
||||
}
|
||||
|
||||
func decodeNil(forKey key: Key) throws -> Bool {
|
||||
try wrapped.decodeNil(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: String.Type, forKey key: Key) throws -> String {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
|
||||
if let type = type as? any PreferenceProtocol.Type,
|
||||
!contains(key) {
|
||||
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
|
||||
P() as! T
|
||||
}
|
||||
return _openExistential(type, do: makePreference)
|
||||
}
|
||||
return try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||
try wrapped.nestedContainer(keyedBy: type, forKey: key)
|
||||
}
|
||||
|
||||
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
|
||||
try wrapped.nestedUnkeyedContainer(forKey: key)
|
||||
}
|
||||
|
||||
func superDecoder() throws -> any Decoder {
|
||||
try wrapped.superDecoder()
|
||||
}
|
||||
|
||||
func superDecoder(forKey key: Key) throws -> any Decoder {
|
||||
try wrapped.superDecoder(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceEncoder: Encoder {
|
||||
let wrapped: any Encoder
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var userInfo: [CodingUserInfoKey : Any] {
|
||||
wrapped.userInfo
|
||||
}
|
||||
|
||||
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
|
||||
KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type)))
|
||||
}
|
||||
|
||||
func unkeyedContainer() -> any UnkeyedEncodingContainer {
|
||||
fatalError("Only keyed containers supported")
|
||||
}
|
||||
|
||||
func singleValueContainer() -> any SingleValueEncodingContainer {
|
||||
fatalError("Only keyed containers supported")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceEncodingContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
|
||||
var wrapped: KeyedEncodingContainer<Key>
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
mutating func encodeNil(forKey key: Key) throws {
|
||||
try wrapped.encodeNil(forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Bool, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: String, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Double, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Float, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int8, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int16, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int32, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int64, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt8, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt16, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt32, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt64, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
|
||||
if let value = value as? any PreferenceProtocol,
|
||||
value.storedValue == nil {
|
||||
return
|
||||
}
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||
wrapped.nestedContainer(keyedBy: keyType, forKey: key)
|
||||
}
|
||||
|
||||
mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
|
||||
wrapped.nestedUnkeyedContainer(forKey: key)
|
||||
}
|
||||
|
||||
mutating func superEncoder() -> any Encoder {
|
||||
wrapped.superEncoder()
|
||||
}
|
||||
|
||||
mutating func superEncoder(forKey key: Key) -> any Encoder {
|
||||
wrapped.superEncoder(forKey: key)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,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:)))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -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 }
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// DigitalWellnessKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationsModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: NotificationsMode { .allNotifications }
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
//
|
||||
// LegacyPreferences.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
public final class LegacyPreferences: Decodable {
|
||||
|
||||
init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
||||
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
||||
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
|
||||
|
||||
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
||||
self.defaultPostVisibility = .visibility(existing)
|
||||
} else {
|
||||
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
||||
}
|
||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||
} else {
|
||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||
}
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
||||
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
||||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
||||
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||
|
||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
||||
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
||||
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
||||
|
||||
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
||||
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
@Published public var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published public var pureBlackDarkMode = true
|
||||
@Published public var accentColor = AccentColor.default
|
||||
@Published public var avatarStyle = AvatarStyle.roundRect
|
||||
@Published public var hideCustomEmojiInUsernames = false
|
||||
@Published public var showIsStatusReplyIcon = false
|
||||
@Published public var alwaysShowStatusVisibilityIcon = false
|
||||
@Published public var hideActionsInTimeline = false
|
||||
@Published public var showLinkPreviews = true
|
||||
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||
@Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode
|
||||
@Published public var underlineTextLinks = false
|
||||
@Published public var showAttachmentsInTimeline = true
|
||||
|
||||
// MARK: Composing
|
||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||
@Published public var requireAttachmentDescriptions = false
|
||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published public var mentionReblogger = false
|
||||
@Published public var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting
|
||||
@Published public var blurMediaBehindContentWarning = true
|
||||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
@Published public var attachmentAltBadgeInverted = false
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
@Published public var useInAppSafari = true
|
||||
@Published public var inAppSafariAutomaticReaderMode = false
|
||||
@Published public var expandAllContentWarnings = false
|
||||
@Published public var collapseLongPosts = true
|
||||
@Published public var oppositeCollapseKeywords: [String] = []
|
||||
@Published public var confirmBeforeReblog = false
|
||||
@Published public var timelineStateRestoration = true
|
||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published public var hideReblogsInTimelines = false
|
||||
@Published public var hideRepliesInTimelines = false
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published public var showFavoriteAndReblogCounts = true
|
||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published public var grayscaleImages = false
|
||||
@Published public var disableInfiniteScrolling = false
|
||||
@Published public var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published public var statusContentType: StatusContentType = .plain
|
||||
@Published public var reportErrorsAutomatically = true
|
||||
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
||||
|
||||
// MARK:
|
||||
@Published public var hasShownLocalTimelineDescription = false
|
||||
@Published public var hasShownFederatedTimelineDescription = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case pureBlackDarkMode
|
||||
case accentColor
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case showLinkPreviews
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
case widescreenNavigationMode
|
||||
case underlineTextLinks
|
||||
case showAttachmentsInTimeline
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia // only used for migration
|
||||
case attachmentBlurMode
|
||||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
case attachmentAltBadgeInverted
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
case grayscaleImages
|
||||
case disableInfiniteScrolling
|
||||
case hideTrends = "hideDiscover"
|
||||
|
||||
case statusContentType
|
||||
case reportErrorsAutomatically
|
||||
case enabledFeatureFlags
|
||||
|
||||
case hasShownLocalTimelineDescription
|
||||
case hasShownFederatedTimelineDescription
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
|
@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -2,430 +2,42 @@
|
|||
// Preferences.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
public final class Preferences: Codable, ObservableObject {
|
||||
import Foundation
|
||||
|
||||
public struct Preferences {
|
||||
@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 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
|
||||
public static func save() {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
|
||||
try? data?.write(to: preferencesURL, options: .noFileProtection)
|
||||
}
|
||||
|
||||
public static func load() -> Preferences {
|
||||
private static func load() -> PreferenceStore {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let preferences = try? decoder.decode(Preferences.self, from: data) {
|
||||
return preferences
|
||||
}
|
||||
return Preferences()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func migrate(from url: URL) -> Result<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)
|
||||
if let data = try? Data(contentsOf: preferencesURL),
|
||||
let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
|
||||
return store.wrapped
|
||||
} else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
|
||||
let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
|
||||
let store = PreferenceStore()
|
||||
store.migrate(from: legacy)
|
||||
return store
|
||||
} else {
|
||||
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
||||
}
|
||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||
} else {
|
||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||
}
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
||||
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
||||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
||||
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||
|
||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
||||
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
||||
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
||||
|
||||
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
||||
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
|
||||
try container.encode(accentColor, forKey: .accentColor)
|
||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
||||
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
|
||||
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
||||
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
||||
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
|
||||
|
||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
||||
|
||||
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
|
||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
|
||||
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
|
||||
try container.encode(attachmentAltBadgeInverted, forKey: .attachmentAltBadgeInverted)
|
||||
|
||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
||||
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
|
||||
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
|
||||
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
|
||||
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
|
||||
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
|
||||
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
|
||||
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
|
||||
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
|
||||
|
||||
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||
try container.encode(grayscaleImages, forKey: .grayscaleImages)
|
||||
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
|
||||
try container.encode(hideTrends, forKey: .hideTrends)
|
||||
|
||||
try container.encode(statusContentType, forKey: .statusContentType)
|
||||
try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically)
|
||||
try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags)
|
||||
|
||||
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
|
||||
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
@Published public var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published public var pureBlackDarkMode = true
|
||||
@Published public var accentColor = AccentColor.default
|
||||
@Published public var avatarStyle = AvatarStyle.roundRect
|
||||
@Published public var hideCustomEmojiInUsernames = false
|
||||
@Published public var showIsStatusReplyIcon = false
|
||||
@Published public var alwaysShowStatusVisibilityIcon = false
|
||||
@Published public var hideActionsInTimeline = false
|
||||
@Published public var showLinkPreviews = true
|
||||
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
||||
@Published public var underlineTextLinks = false
|
||||
@Published public var showAttachmentsInTimeline = true
|
||||
|
||||
// MARK: Composing
|
||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||
@Published public var requireAttachmentDescriptions = false
|
||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published public var mentionReblogger = false
|
||||
@Published public var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
|
||||
didSet {
|
||||
if attachmentBlurMode == .always {
|
||||
blurMediaBehindContentWarning = true
|
||||
} else if attachmentBlurMode == .never {
|
||||
blurMediaBehindContentWarning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public var blurMediaBehindContentWarning = true
|
||||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
@Published public var attachmentAltBadgeInverted = false
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
@Published public var useInAppSafari = true
|
||||
@Published public var inAppSafariAutomaticReaderMode = false
|
||||
@Published public var expandAllContentWarnings = false
|
||||
@Published public var collapseLongPosts = true
|
||||
@Published public var oppositeCollapseKeywords: [String] = []
|
||||
@Published public var confirmBeforeReblog = false
|
||||
@Published public var timelineStateRestoration = true
|
||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published public var hideReblogsInTimelines = false
|
||||
@Published public var hideRepliesInTimelines = false
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published public var showFavoriteAndReblogCounts = true
|
||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published public var grayscaleImages = false
|
||||
@Published public var disableInfiniteScrolling = false
|
||||
@Published public var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published public var statusContentType: StatusContentType = .plain
|
||||
@Published public var reportErrorsAutomatically = true
|
||||
@Published public var enabledFeatureFlags: Set<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"
|
||||
}
|
||||
return PreferenceStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
||||
|
||||
extension Preferences {
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadMultiColumn = "ipad-multi-column"
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// AccentColor.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// AttachmentBlurMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// FeatureFlag.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
|
@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
|
|||
case serverDefault
|
||||
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 {
|
||||
switch self {
|
||||
|
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
|
|||
case sameAsPost
|
||||
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
|
||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Theme.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public enum Theme: String, Codable {
|
||||
case unspecified, light, dark
|
||||
|
||||
public var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .unspecified:
|
||||
.unspecified
|
||||
case .light:
|
||||
.light
|
||||
case .dark:
|
||||
.dark
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// TimelineSyncMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// WidescreenNavigationMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
|
@ -0,0 +1,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)
|
||||
}
|
||||
|
||||
}
|
|
@ -8,7 +8,8 @@
|
|||
import Foundation
|
||||
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()
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0427033922B31269000D31B6 /* AdvancedPrefsView.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 */; };
|
||||
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04586B4222B301470021BD04 /* AppearancePrefsView.swift */; };
|
||||
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
|
||||
04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.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 */; };
|
||||
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */ = {isa = PBXBuildFile; fileRef = D67C57AE21E28EAD00C3118B /* Array+Uniques.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 */; };
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D681E4D8246E346E0053414F /* AccountActivityItemSource.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 */; };
|
||||
D6C3F5172991C1A00009FCFF /* View+AppListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5162991C1A00009FCFF /* View+AppListStyle.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 */; };
|
||||
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -592,7 +591,6 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -713,6 +711,9 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -1009,6 +1010,7 @@
|
|||
D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */,
|
||||
D630C3D32BC61B6100208903 /* NotificationService.swift */,
|
||||
D630C3D52BC61B6100208903 /* Info.plist */,
|
||||
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
path = NotificationExtension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1168,17 +1170,14 @@
|
|||
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */,
|
||||
04586B4022B2FFB10021BD04 /* PreferencesView.swift */,
|
||||
D64B96802BC3279D002C8990 /* PrefsAccountView.swift */,
|
||||
04586B4222B301470021BD04 /* AppearancePrefsView.swift */,
|
||||
D61F75892932E1FC00C0B37F /* SwipeActionsPrefsView.swift */,
|
||||
D6958F3C2AA383D90062FE52 /* WidescreenNavigationPrefsView.swift */,
|
||||
0427033722B30F5F000D31B6 /* BehaviorPrefsView.swift */,
|
||||
D6B17254254F88B800128392 /* OppositeCollapseKeywordsView.swift */,
|
||||
D680153F2401A6BA00D6103B /* ComposingPrefsView.swift */,
|
||||
D68015412401A74600D6103B /* MediaPrefsView.swift */,
|
||||
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */,
|
||||
0427033922B31269000D31B6 /* AdvancedPrefsView.swift */,
|
||||
D67895BF246870DE00D4CD9E /* LocalAccountAvatarView.swift */,
|
||||
D68A76ED295369C7001DA1B3 /* AcknowledgementsView.swift */,
|
||||
D6C4532B2BCB86A100E26A0E /* Appearance */,
|
||||
D64B96822BC3892B002C8990 /* Notifications */,
|
||||
D60089172981FEA4005B4D00 /* Tip Jar */,
|
||||
D68A76EF2953910A001DA1B3 /* About */,
|
||||
|
@ -1483,6 +1482,17 @@
|
|||
path = Views;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -2100,6 +2110,7 @@
|
|||
D61F759B29384F9C00C0B37F /* FilterMO.swift in Sources */,
|
||||
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
|
||||
D6934F342BA8D65A002B1C8D /* ImageGalleryDataSource.swift in Sources */,
|
||||
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */,
|
||||
D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */,
|
||||
D6BC74862AFC4772000DD603 /* SuggestedProfileCardView.swift in Sources */,
|
||||
0450531F22B0097E00100BA2 /* Timline+UI.swift in Sources */,
|
||||
|
@ -2288,7 +2299,6 @@
|
|||
D60089192981FEBA005B4D00 /* ConfettiView.swift in Sources */,
|
||||
D61F759929384D4D00C0B37F /* CustomizeTimelinesView.swift in Sources */,
|
||||
D61F759F29385AD800C0B37F /* SemiCaseSensitiveComparator.swift in Sources */,
|
||||
04586B4322B301470021BD04 /* AppearancePrefsView.swift in Sources */,
|
||||
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
|
||||
D646DCD42A0729440059ECEB /* ActionNotificationGroupCollectionViewCell.swift in Sources */,
|
||||
D69261272BB3BA610023152C /* Box.swift in Sources */,
|
||||
|
@ -2335,7 +2345,6 @@
|
|||
D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */,
|
||||
D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */,
|
||||
D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */,
|
||||
D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */,
|
||||
D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */,
|
||||
D6E77D09286D25FA00D8B732 /* TrendingHashtagCollectionViewCell.swift in Sources */,
|
||||
D6D79F292A0D596B00AB2315 /* StatusEditHistoryViewController.swift in Sources */,
|
||||
|
@ -2357,6 +2366,7 @@
|
|||
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */,
|
||||
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
|
||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
|
||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||
|
@ -2480,7 +2490,6 @@
|
|||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2490,11 +2499,12 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -2512,7 +2522,6 @@
|
|||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2522,10 +2531,11 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -2543,7 +2553,6 @@
|
|||
INFOPLIST_FILE = NotificationExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NotificationExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2553,10 +2562,11 @@
|
|||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Tusker.NotificationExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
||||
};
|
||||
name = Dist;
|
||||
};
|
||||
|
@ -2608,7 +2618,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2616,6 +2626,7 @@
|
|||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Dist;
|
||||
};
|
||||
|
@ -2631,7 +2642,6 @@
|
|||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2698,8 +2708,6 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2727,7 +2735,6 @@
|
|||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2756,7 +2763,6 @@
|
|||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2785,7 +2791,6 @@
|
|||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2023 Shadowfacts. All rights reserved.";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2856,7 +2861,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
|
@ -2864,6 +2869,7 @@
|
|||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -2915,7 +2921,7 @@
|
|||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2923,6 +2929,7 @@
|
|||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -2938,7 +2945,6 @@
|
|||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -2970,7 +2976,6 @@
|
|||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = Tusker/Info.plist;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -3078,8 +3083,6 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
@ -3104,8 +3107,6 @@
|
|||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||
INFOPLIST_FILE = OpenInTusker/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.1;
|
||||
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 14.3;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
|
|
@ -23,10 +23,10 @@ class LogoutService {
|
|||
|
||||
func run() {
|
||||
let accountInfo = self.accountInfo
|
||||
Task.detached {
|
||||
if await PushManager.shared.pushSubscription(account: accountInfo) != nil {
|
||||
Task.detached { @MainActor in
|
||||
if PushManager.shared.pushSubscription(account: accountInfo) != nil {
|
||||
_ = 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()
|
||||
}
|
||||
|
|
|
@ -33,7 +33,13 @@ extension MastodonController {
|
|||
|
||||
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))
|
||||
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 {
|
||||
|
|
|
@ -26,7 +26,11 @@ class SaveToPhotosActivity: UIActivity {
|
|||
// 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 format = UIGraphicsImageRendererFormat()
|
||||
#if os(visionOS)
|
||||
format.scale = 2
|
||||
#else
|
||||
format.scale = UIScreen.main.scale
|
||||
#endif
|
||||
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))
|
||||
symbol.draw(in: rect)
|
||||
|
|
|
@ -54,21 +54,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
|
||||
if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
|
||||
#if canImport(Sentry)
|
||||
SentrySDK.capture(error: error)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the persistent container is initialized on the main thread
|
||||
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
|
||||
_ = DraftsPersistentContainer.shared
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {
|
||||
|
@ -184,7 +175,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
private func initializePushNotifications() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
Task {
|
||||
#if canImport(Sentry)
|
||||
PushManager.captureError = { SentrySDK.capture(error: $0) }
|
||||
#endif
|
||||
await PushManager.shared.updateIfNecessary(updateSubscription: {
|
||||
guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else {
|
||||
return false
|
||||
|
|
|
@ -51,6 +51,7 @@ public extension MainActor {
|
|||
@available(iOS, obsoleted: 17.0)
|
||||
@available(watchOS, obsoleted: 10.0)
|
||||
@available(tvOS, obsoleted: 17.0)
|
||||
@available(visionOS 1.0, *)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||
return try MainActor.assumeIsolated(body)
|
||||
|
|
|
@ -33,7 +33,7 @@ extension TuskerSceneDelegate {
|
|||
|
||||
func applyAppearancePreferences() {
|
||||
guard let window else { return }
|
||||
window.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window.overrideUserInterfaceStyle = Preferences.shared.theme.userInterfaceStyle
|
||||
window.tintColor = Preferences.shared.accentColor.color
|
||||
#if os(visionOS)
|
||||
window.traitOverrides.pureBlackDarkMode = Preferences.shared.pureBlackDarkMode
|
||||
|
|
|
@ -18,7 +18,9 @@ class VideoControlsViewController: UIViewController {
|
|||
}()
|
||||
|
||||
private let player: AVPlayer
|
||||
#if !os(visionOS)
|
||||
@Box private var playbackSpeed: Float
|
||||
#endif
|
||||
|
||||
private lazy var muteButton = MuteButton().configure {
|
||||
$0.addTarget(self, action: #selector(muteButtonPressed), for: .touchUpInside)
|
||||
|
@ -44,8 +46,13 @@ class VideoControlsViewController: UIViewController {
|
|||
|
||||
private lazy var optionsButton = MenuButton { [unowned self] in
|
||||
let imageName: String
|
||||
#if os(visionOS)
|
||||
let playbackSpeed = player.defaultRate
|
||||
#else
|
||||
let playbackSpeed = self.playbackSpeed
|
||||
#endif
|
||||
if #available(iOS 17.0, *) {
|
||||
switch self.playbackSpeed {
|
||||
switch playbackSpeed {
|
||||
case 0.5:
|
||||
imageName = "gauge.with.dots.needle.0percent"
|
||||
case 1:
|
||||
|
@ -61,8 +68,12 @@ class VideoControlsViewController: UIViewController {
|
|||
imageName = "speedometer"
|
||||
}
|
||||
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
|
||||
#endif
|
||||
if self.player.rate > 0 {
|
||||
self.player.rate = speed.rate
|
||||
}
|
||||
|
@ -90,12 +101,20 @@ class VideoControlsViewController: UIViewController {
|
|||
private var scrubbingTargetTime: CMTime?
|
||||
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>) {
|
||||
self.player = player
|
||||
self._playbackSpeed = playbackSpeed
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
#endif
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
|
@ -170,7 +189,11 @@ class VideoControlsViewController: UIViewController {
|
|||
@objc private func scrubbingEnded() {
|
||||
scrubbingChanged()
|
||||
if wasPlayingWhenScrubbingStarted {
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,8 +17,10 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
|||
private var item: AVPlayerItem
|
||||
let player: AVPlayer
|
||||
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0, message: "Use AVPlayer.defaultRate")
|
||||
@Box private var playbackSpeed: Float = 1
|
||||
#endif
|
||||
|
||||
private var isGrayscale: Bool
|
||||
|
||||
|
@ -125,7 +127,11 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
|||
player.replaceCurrentItem(with: item)
|
||||
updateItemObservations()
|
||||
if isPlaying {
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -142,12 +148,20 @@ class VideoGalleryContentViewController: UIViewController, GalleryContentViewCon
|
|||
[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)
|
||||
#endif
|
||||
var contentOverlayAccessoryViewController: UIViewController? {
|
||||
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)
|
||||
#endif
|
||||
|
||||
func setControlsVisible(_ visible: Bool, animated: Bool) {
|
||||
overlayVC.setVisible(visible)
|
||||
|
|
|
@ -15,7 +15,9 @@ class VideoOverlayViewController: UIViewController {
|
|||
private static let pauseImage = UIImage(systemName: "pause.fill")!
|
||||
|
||||
private let player: AVPlayer
|
||||
#if !os(visionOS)
|
||||
@Box private var playbackSpeed: Float
|
||||
#endif
|
||||
|
||||
private var dimmingView: UIView!
|
||||
private var controlsStack: UIStackView!
|
||||
|
@ -24,11 +26,18 @@ class VideoOverlayViewController: UIViewController {
|
|||
|
||||
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>) {
|
||||
self.player = player
|
||||
self._playbackSpeed = playbackSpeed
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
#endif
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
|
@ -97,7 +106,11 @@ class VideoOverlayViewController: UIViewController {
|
|||
if player.rate > 0 {
|
||||
player.rate = 0
|
||||
} else {
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
class MainSplitViewController: UISplitViewController {
|
||||
|
||||
|
@ -21,7 +22,7 @@ class MainSplitViewController: UISplitViewController {
|
|||
|
||||
private var tabBarViewController: MainTabBarViewController!
|
||||
|
||||
private var navigationMode: Preferences.WidescreenNavigationMode!
|
||||
private var navigationMode: WidescreenNavigationMode!
|
||||
private var secondaryNavController: NavigationControllerProtocol! {
|
||||
viewController(for: .secondary) as? NavigationControllerProtocol
|
||||
}
|
||||
|
@ -65,14 +66,20 @@ class MainSplitViewController: UISplitViewController {
|
|||
}
|
||||
|
||||
let nav: UIViewController
|
||||
navigationMode = Preferences.shared.widescreenNavigationMode
|
||||
switch navigationMode! {
|
||||
case .stack:
|
||||
let visionIdiom = UIUserInterfaceIdiom(rawValue: 6)
|
||||
if [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom) {
|
||||
navigationMode = Preferences.shared.widescreenNavigationMode
|
||||
switch navigationMode! {
|
||||
case .stack:
|
||||
nav = EnhancedNavigationViewController()
|
||||
case .splitScreen:
|
||||
nav = SplitNavigationController()
|
||||
case .multiColumn:
|
||||
nav = MultiColumnNavigationController()
|
||||
}
|
||||
} else {
|
||||
navigationMode = .stack
|
||||
nav = EnhancedNavigationViewController()
|
||||
case .splitScreen:
|
||||
nav = SplitNavigationController()
|
||||
case .multiColumn:
|
||||
nav = MultiColumnNavigationController()
|
||||
}
|
||||
setViewController(nav, for: .secondary)
|
||||
|
||||
|
@ -113,8 +120,10 @@ class MainSplitViewController: UISplitViewController {
|
|||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func updateNavigationMode(_ mode: Preferences.WidescreenNavigationMode) {
|
||||
guard mode != navigationMode else {
|
||||
private func updateNavigationMode(_ mode: WidescreenNavigationMode) {
|
||||
let visionIdiom = UIUserInterfaceIdiom(rawValue: 6)
|
||||
guard [visionIdiom, .pad, .mac].contains(UIDevice.current.userInterfaceIdiom),
|
||||
mode != navigationMode else {
|
||||
return
|
||||
}
|
||||
navigationMode = mode
|
||||
|
|
|
@ -59,7 +59,12 @@ struct AboutView: View {
|
|||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Get Support", systemImage: "envelope")
|
||||
Label {
|
||||
Text("Get Support")
|
||||
} icon: {
|
||||
Image(systemName: "envelope")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
Spacer()
|
||||
if isGettingLogData {
|
||||
ProgressView()
|
||||
|
@ -75,7 +80,6 @@ struct AboutView: View {
|
|||
Label("Issue Tracker", systemImage: "checklist")
|
||||
}
|
||||
}
|
||||
.labelStyle(AboutLinksLabelStyle())
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
|
@ -100,7 +104,9 @@ struct AboutView: View {
|
|||
|
||||
private var appIcon: some View {
|
||||
VStack {
|
||||
AppIconView()
|
||||
Image("AboutIcon")
|
||||
.resizable()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 256 / 6.4))
|
||||
.shadow(radius: 6, y: 3)
|
||||
.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 {
|
||||
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 {
|
||||
static var previews: some View {
|
||||
AboutView()
|
||||
|
|
|
@ -107,7 +107,7 @@ struct FlipEffect: GeometryEffect {
|
|||
}
|
||||
|
||||
private struct WidthPrefKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static let defaultValue: CGFloat = 0
|
||||
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = nextValue()
|
||||
|
|
|
@ -292,7 +292,7 @@ private extension AttributeScopes {
|
|||
private enum HeadingLevelAttributes: CodableAttributedStringKey, MarkdownDecodableAttributedStringKey {
|
||||
public typealias Value = Int
|
||||
|
||||
public static var name = "headingLevel"
|
||||
public static let name = "headingLevel"
|
||||
}
|
||||
|
||||
private extension AttributeDynamicLookup {
|
||||
|
|
|
@ -10,6 +10,7 @@ import Pachyderm
|
|||
import CoreData
|
||||
import CloudKit
|
||||
import UserAccounts
|
||||
import TuskerPreferences
|
||||
|
||||
struct AdvancedPrefsView : View {
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
|
@ -41,7 +42,7 @@ struct AdvancedPrefsView : View {
|
|||
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button("Enable") {
|
||||
if let flag = Preferences.FeatureFlag(rawValue: featureFlagName) {
|
||||
if let flag = FeatureFlag(rawValue: featureFlagName) {
|
||||
preferences.enabledFeatureFlags.insert(flag)
|
||||
}
|
||||
}
|
||||
|
@ -82,22 +83,8 @@ struct AdvancedPrefsView : View {
|
|||
HStack {
|
||||
Text("iCloud Status")
|
||||
Spacer()
|
||||
switch cloudKitStatus {
|
||||
case nil:
|
||||
EmptyView()
|
||||
case .available:
|
||||
Text("Available")
|
||||
case .couldNotDetermine:
|
||||
Text("Could not determine")
|
||||
case .noAccount:
|
||||
Text("No account")
|
||||
case .restricted:
|
||||
Text("Restricted")
|
||||
case .temporarilyUnavailable:
|
||||
Text("Temporarily Unavailable")
|
||||
@unknown default:
|
||||
Text(String(describing: cloudKitStatus!))
|
||||
}
|
||||
cloudKitStatusLabel
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
|
@ -111,6 +98,26 @@ struct AdvancedPrefsView : View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cloudKitStatusLabel: some View {
|
||||
switch cloudKitStatus {
|
||||
case nil:
|
||||
EmptyView()
|
||||
case .available:
|
||||
Text("Available")
|
||||
case .couldNotDetermine:
|
||||
Text("Could not determine")
|
||||
case .noAccount:
|
||||
Text("No account")
|
||||
case .restricted:
|
||||
Text("Restricted")
|
||||
case .temporarilyUnavailable:
|
||||
Text("Temporarily Unavailable")
|
||||
@unknown default:
|
||||
Text(String(describing: cloudKitStatus!))
|
||||
}
|
||||
}
|
||||
|
||||
var errorReportingSection: some View {
|
||||
Section {
|
||||
Toggle("Report Errors Automatically", isOn: $preferences.reportErrorsAutomatically)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
//
|
||||
// AppearancePrefsView.swift
|
||||
// Tusker
|
||||
//
|
||||
|
@ -7,9 +8,11 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
struct AppearancePrefsView : View {
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
struct AppearancePrefsView: View {
|
||||
@ObservedObject private var preferences = Preferences.shared
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var appearanceChangePublisher: some Publisher<Void, Never> {
|
||||
preferences.$theme
|
||||
|
@ -21,13 +24,7 @@ struct AppearancePrefsView : View {
|
|||
.receive(on: DispatchQueue.main)
|
||||
}
|
||||
|
||||
private var useCircularAvatars: Binding<Bool> = Binding(get: {
|
||||
Preferences.shared.avatarStyle == .circle
|
||||
}) {
|
||||
Preferences.shared.avatarStyle = $0 ? .circle : .roundRect
|
||||
}
|
||||
|
||||
private let accentColorsAndImages: [(Preferences.AccentColor, UIImage?)] = Preferences.AccentColor.allCases.map { color in
|
||||
private static let accentColorsAndImages: [(AccentColor, UIImage?)] = AccentColor.allCases.map { color in
|
||||
var image: UIImage?
|
||||
if let color = color.color {
|
||||
if #available(iOS 16.0, *) {
|
||||
|
@ -46,21 +43,46 @@ struct AppearancePrefsView : View {
|
|||
List {
|
||||
themeSection
|
||||
interfaceSection
|
||||
|
||||
Section("Post Preview") {
|
||||
MockStatusView()
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 8 : 4)
|
||||
}
|
||||
.listRowBackground(mockStatusBackground)
|
||||
|
||||
accountsSection
|
||||
postsSection
|
||||
mediaSection
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.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 {
|
||||
Section {
|
||||
#if !os(visionOS)
|
||||
Picker(selection: $preferences.theme, label: Text("Theme")) {
|
||||
Text("Use System Theme").tag(UIUserInterfaceStyle.unspecified)
|
||||
Text("Light").tag(UIUserInterfaceStyle.light)
|
||||
Text("Dark").tag(UIUserInterfaceStyle.dark)
|
||||
Text("Use System Theme").tag(Theme.unspecified)
|
||||
Text("Light").tag(Theme.light)
|
||||
Text("Dark").tag(Theme.dark)
|
||||
}
|
||||
|
||||
// macOS system dark mode isn't pure black, so this isn't necessary
|
||||
|
@ -72,7 +94,7 @@ struct AppearancePrefsView : View {
|
|||
#endif
|
||||
|
||||
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 {
|
||||
Text(color.name)
|
||||
if let image {
|
||||
|
@ -102,8 +124,12 @@ struct AppearancePrefsView : View {
|
|||
}
|
||||
|
||||
private var accountsSection: some View {
|
||||
Section(header: Text("Accounts")) {
|
||||
Toggle(isOn: useCircularAvatars) {
|
||||
Section("Accounts") {
|
||||
Toggle(isOn: Binding(get: {
|
||||
preferences.avatarStyle == .circle
|
||||
}, set: {
|
||||
preferences.avatarStyle = $0 ? .circle : .roundRect
|
||||
})) {
|
||||
Text("Use Circular Avatars")
|
||||
}
|
||||
Toggle(isOn: $preferences.hideCustomEmojiInUsernames) {
|
||||
|
@ -114,7 +140,7 @@ struct AppearancePrefsView : View {
|
|||
}
|
||||
|
||||
private var postsSection: some View {
|
||||
Section(header: Text("Posts")) {
|
||||
Section("Posts") {
|
||||
Toggle(isOn: $preferences.showIsStatusReplyIcon) {
|
||||
Text("Show Status Reply Icons")
|
||||
}
|
||||
|
@ -146,6 +172,41 @@ struct AppearancePrefsView : View {
|
|||
}
|
||||
.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
|
|
@ -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)
|
||||
}
|
|
@ -8,43 +8,49 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
struct WidescreenNavigationPrefsView: View {
|
||||
@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 {
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
OptionView<StackNavigationPreview>(
|
||||
OptionView(
|
||||
content: StackNavigationPreview.self,
|
||||
value: .stack,
|
||||
selection: $preferences.widescreenNavigationMode,
|
||||
startAnimation: startAnimation
|
||||
startAnimation: startAnimationSignal
|
||||
) {
|
||||
Text("Stack")
|
||||
}
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OptionView<SplitNavigationPreview>(
|
||||
OptionView(
|
||||
content: SplitNavigationPreview.self,
|
||||
value: .splitScreen,
|
||||
selection: $preferences.widescreenNavigationMode,
|
||||
startAnimation: startAnimation
|
||||
startAnimation: startAnimationSignal
|
||||
) {
|
||||
Text("Split Screen")
|
||||
}
|
||||
|
||||
if preferences.hasFeatureFlag(.iPadMultiColumn) {
|
||||
Spacer(minLength: 32)
|
||||
Spacer(minLength: 32)
|
||||
|
||||
OptionView<MultiColumnNavigationPreview>(
|
||||
value: .multiColumn,
|
||||
selection: $preferences.widescreenNavigationMode,
|
||||
startAnimation: startAnimation
|
||||
) {
|
||||
Text("Multi-Column")
|
||||
}
|
||||
OptionView(
|
||||
content: MultiColumnNavigationPreview.self,
|
||||
value: .multiColumn,
|
||||
selection: $preferences.widescreenNavigationMode,
|
||||
startAnimation: startAnimationSignal
|
||||
) {
|
||||
Text("Multi-Column")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
@ -52,19 +58,26 @@ struct WidescreenNavigationPrefsView: View {
|
|||
.frame(height: 100)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
||||
startAnimation.send()
|
||||
startAnimation.send(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OptionView<Content: NavigationModePreview>: View {
|
||||
let value: Preferences.WidescreenNavigationMode
|
||||
@Binding var selection: Preferences.WidescreenNavigationMode
|
||||
let startAnimation: PassthroughSubject<Void, Never>
|
||||
@ViewBuilder let label: Text
|
||||
private struct OptionView<Content: NavigationModePreview, P: Publisher<Void, Never>>: View {
|
||||
let value: WidescreenNavigationMode
|
||||
@Binding var selection: WidescreenNavigationMode
|
||||
let startAnimation: P
|
||||
let label: Text
|
||||
@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 {
|
||||
selection == value
|
||||
}
|
||||
|
@ -83,7 +96,7 @@ private struct OptionView<Content: NavigationModePreview>: View {
|
|||
}
|
||||
|
||||
private var preview: some View {
|
||||
NavigationModeRepresentable<Content>(startAnimation: startAnimation)
|
||||
NavigationModeRepresentable(content: Content.self, startAnimation: startAnimation)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12.5, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12.5, style: .continuous)
|
||||
|
@ -105,11 +118,15 @@ private struct WideCapsule: Shape {
|
|||
|
||||
@MainActor
|
||||
private protocol NavigationModePreview: UIView {
|
||||
init(startAnimation: PassthroughSubject<Void, Never>)
|
||||
init(startAnimation: some Publisher<Void, Never>)
|
||||
}
|
||||
|
||||
private struct NavigationModeRepresentable<UIViewType: NavigationModePreview>: UIViewRepresentable {
|
||||
let startAnimation: PassthroughSubject<Void, Never>
|
||||
private struct NavigationModeRepresentable<UIViewType: NavigationModePreview, P: Publisher<Void, Never>>: UIViewRepresentable {
|
||||
let startAnimation: P
|
||||
|
||||
init(content _: UIViewType.Type, startAnimation: P) {
|
||||
self.startAnimation = startAnimation
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIViewType {
|
||||
UIViewType(startAnimation: startAnimation)
|
||||
|
@ -127,7 +144,7 @@ private final class StackNavigationPreview: UIView, NavigationModePreview {
|
|||
private let destinationView = UIView()
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(startAnimation: PassthroughSubject<Void, Never>) {
|
||||
init(startAnimation: some Publisher<Void, Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = .appBackground
|
||||
|
@ -202,7 +219,7 @@ private final class SplitNavigationPreview: UIView, NavigationModePreview {
|
|||
private var cellStackTrailingConstraint: NSLayoutConstraint!
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(startAnimation: PassthroughSubject<Void, Never>) {
|
||||
init(startAnimation: some Publisher<Void, Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = .appBackground
|
||||
|
@ -296,7 +313,7 @@ private final class MultiColumnNavigationPreview: UIView, NavigationModePreview
|
|||
|
||||
private var startedAnimation = false
|
||||
|
||||
init(startAnimation: PassthroughSubject<Void, Never>) {
|
||||
init(startAnimation: some Publisher<Void, Never>) {
|
||||
super.init(frame: .zero)
|
||||
|
||||
backgroundColor = .appSecondaryBackground
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerPreferences
|
||||
|
||||
struct BehaviorPrefsView: View {
|
||||
@ObservedObject var preferences = Preferences.shared
|
||||
|
@ -39,8 +40,8 @@ struct BehaviorPrefsView: View {
|
|||
}
|
||||
|
||||
Picker(selection: $preferences.timelineSyncMode) {
|
||||
Text("iCloud").tag(Preferences.TimelineSyncMode.icloud)
|
||||
Text("Mastodon").tag(Preferences.TimelineSyncMode.mastodon)
|
||||
Text("iCloud").tag(TimelineSyncMode.icloud)
|
||||
Text("Mastodon").tag(TimelineSyncMode.mastodon)
|
||||
} label: {
|
||||
Text("Sync Timeline Position via")
|
||||
}
|
||||
|
@ -57,13 +58,15 @@ struct BehaviorPrefsView: View {
|
|||
Toggle(isOn: $preferences.openLinksInApps) {
|
||||
Text("Open Links in Apps")
|
||||
}
|
||||
#if !os(visionOS)
|
||||
Toggle(isOn: $preferences.useInAppSafari) {
|
||||
Text("Use In-App Safari")
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
Toggle(isOn: $preferences.useInAppSafari) {
|
||||
Text("Use In-App Safari")
|
||||
}
|
||||
Toggle(isOn: $preferences.inAppSafariAutomaticReaderMode) {
|
||||
Text("Always Use Reader Mode in In-App Safari")
|
||||
}.disabled(!preferences.useInAppSafari)
|
||||
}
|
||||
Toggle(isOn: $preferences.inAppSafariAutomaticReaderMode) {
|
||||
Text("Always Use Reader Mode in In-App Safari")
|
||||
}.disabled(!preferences.useInAppSafari)
|
||||
#endif
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -13,7 +13,6 @@ import PushNotifications
|
|||
import TuskerComponents
|
||||
|
||||
struct NotificationsPrefsView: View {
|
||||
@State private var error: NotificationsSetupError?
|
||||
@ObservedObject private var userAccounts = UserAccountsManager.shared
|
||||
|
||||
var body: some View {
|
||||
|
@ -48,14 +47,3 @@ struct NotificationsPrefsView: View {
|
|||
.navigationTitle("Notifications")
|
||||
}
|
||||
}
|
||||
|
||||
private enum NotificationsSetupError: LocalizedError {
|
||||
case requestingAuthorization(any Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .requestingAuthorization(let error):
|
||||
"Notifications authorization request failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import TuskerComponents
|
|||
|
||||
struct PushInstanceSettingsView: View {
|
||||
let account: UserAccountInfo
|
||||
let mastodonController: MastodonController
|
||||
@State private var mode: AsyncToggle.Mode
|
||||
@State private var error: Error?
|
||||
@State private var subscription: PushNotifications.PushSubscription?
|
||||
|
@ -22,6 +23,7 @@ struct PushInstanceSettingsView: View {
|
|||
@MainActor
|
||||
init(account: UserAccountInfo) {
|
||||
self.account = account
|
||||
self.mastodonController = .getForAccount(account)
|
||||
let subscription = PushManager.shared.pushSubscription(account: account)
|
||||
self.subscription = subscription
|
||||
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:))
|
||||
.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
|
||||
Button("OK") {}
|
||||
|
|
|
@ -13,12 +13,13 @@ import TuskerComponents
|
|||
|
||||
struct PushSubscriptionView: View {
|
||||
let account: UserAccountInfo
|
||||
let mastodonController: MastodonController
|
||||
let subscription: PushSubscription?
|
||||
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> Bool
|
||||
|
||||
var body: some View {
|
||||
if let subscription {
|
||||
PushSubscriptionSettingsView(account: account, subscription: subscription, updateSubscription: updateSubscription)
|
||||
PushSubscriptionSettingsView(account: account, mastodonController: mastodonController, subscription: subscription, updateSubscription: updateSubscription)
|
||||
} else {
|
||||
Text("No notifications")
|
||||
.font(.callout)
|
||||
|
@ -29,28 +30,25 @@ struct PushSubscriptionView: View {
|
|||
|
||||
private struct PushSubscriptionSettingsView: View {
|
||||
let account: UserAccountInfo
|
||||
let mastodonController: MastodonController
|
||||
let subscription: PushSubscription
|
||||
let updateSubscription: (PushSubscription.Alerts, PushSubscription.Policy) async -> 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 {
|
||||
VStack {
|
||||
alertsToggles
|
||||
|
||||
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
|
||||
await updateSubscription(subscription.alerts, newPolicy)
|
||||
} content: {
|
||||
ForEach(PushSubscription.Policy.allCases) {
|
||||
Text($0.displayName).tag($0)
|
||||
if mastodonController.instanceFeatures.pushNotificationPolicy {
|
||||
AsyncPicker("From", alignment: .trailing, value: .constant(subscription.policy)) { newPolicy in
|
||||
await updateSubscription(subscription.alerts, newPolicy)
|
||||
} content: {
|
||||
ForEach(PushSubscription.Policy.allCases) {
|
||||
Text($0.displayName).tag($0)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
// this is the default value of the alignment guide, but this modifier is loading bearing
|
||||
.alignmentGuide(.prefsAvatar, computeValue: { dimension in
|
||||
|
@ -63,18 +61,35 @@ private struct PushSubscriptionSettingsView: View {
|
|||
private var alertsToggles: some View {
|
||||
GroupBox("Get notifications for") {
|
||||
VStack {
|
||||
toggle("All", alert: [.mention, .favorite, .reblog, .follow, .followRequest, .poll, .update])
|
||||
toggle("All", alert: allSupportedAlertTypes)
|
||||
toggle("Mentions", alert: .mention)
|
||||
toggle("Favorites", alert: .favorite)
|
||||
toggle("Reblogs", alert: .reblog)
|
||||
toggle("Follows", alert: [.follow, .followRequest])
|
||||
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
|
||||
toggle("Follows", alert: [.follow, .followRequest])
|
||||
} else {
|
||||
toggle("Follows", alert: .follow)
|
||||
}
|
||||
toggle("Polls finishing", alert: .poll)
|
||||
toggle("Edits", alert: .update)
|
||||
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
|
||||
toggle("Edits", alert: .update)
|
||||
}
|
||||
// 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 {
|
||||
let binding: Binding<AsyncToggle.Mode> = Binding {
|
||||
isLoading[alert] == true ? .loading : subscription.alerts.contains(alert) ? .on : .off
|
||||
|
|
|
@ -23,7 +23,6 @@ struct PreferencesView: View {
|
|||
var body: some View {
|
||||
List {
|
||||
accountsSection
|
||||
notificationsSection
|
||||
preferencesSection
|
||||
aboutSection
|
||||
}
|
||||
|
@ -92,36 +91,27 @@ struct PreferencesView: View {
|
|||
.appGroupedListRowBackground()
|
||||
}
|
||||
|
||||
private var notificationsSection: some View {
|
||||
Section {
|
||||
NavigationLink(isActive: $navigationState.showNotificationPreferences) {
|
||||
NotificationsPrefsView()
|
||||
} label: {
|
||||
Text("Notifications")
|
||||
}
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
|
||||
private var preferencesSection: some View {
|
||||
Section {
|
||||
NavigationLink(destination: AppearancePrefsView()) {
|
||||
Text("Appearance")
|
||||
}
|
||||
NavigationLink(destination: ComposingPrefsView()) {
|
||||
Text("Composing")
|
||||
}
|
||||
NavigationLink(destination: MediaPrefsView()) {
|
||||
Text("Media")
|
||||
PreferenceSectionLabel(title: "Appearance", systemImageName: "textformat", backgroundColor: .indigo)
|
||||
}
|
||||
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()) {
|
||||
Text("Digital Wellness")
|
||||
PreferenceSectionLabel(title: "Digital Wellness", systemImageName: "brain.fill", backgroundColor: .purple)
|
||||
}
|
||||
NavigationLink(destination: AdvancedPrefsView()) {
|
||||
Text("Advanced")
|
||||
PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray)
|
||||
}
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
|
@ -129,14 +119,28 @@ struct PreferencesView: View {
|
|||
|
||||
private var aboutSection: some View {
|
||||
Section {
|
||||
NavigationLink("About") {
|
||||
NavigationLink {
|
||||
AboutView()
|
||||
} label: {
|
||||
Label {
|
||||
Text("About")
|
||||
} icon: {
|
||||
Image("AboutIcon")
|
||||
.resizable()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.frame(width: 30, height: 30)
|
||||
}
|
||||
}
|
||||
NavigationLink("Tip Jar") {
|
||||
NavigationLink {
|
||||
TipJarView()
|
||||
} label: {
|
||||
// TODO: custom tip jar icon?
|
||||
PreferenceSectionLabel(title: "Tip Jar", systemImageName: "dollarsign.square.fill", backgroundColor: .yellow)
|
||||
}
|
||||
NavigationLink("Acknowledgements") {
|
||||
NavigationLink {
|
||||
AcknowledgementsView()
|
||||
} label: {
|
||||
PreferenceSectionLabel(title: "Acknowledgements", systemImageName: "doc.text.fill", backgroundColor: .gray)
|
||||
}
|
||||
}
|
||||
.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
|
||||
//struct PreferencesView_Previews : PreviewProvider {
|
||||
// static var previews: some View {
|
||||
|
|
|
@ -59,7 +59,7 @@ struct ConfettiView: View {
|
|||
}
|
||||
|
||||
private struct SizeKey: PreferenceKey {
|
||||
static var defaultValue: CGSize = .zero
|
||||
static let defaultValue: CGSize = .zero
|
||||
static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
|
||||
value = nextValue()
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ struct TipJarView: View {
|
|||
Text(error.localizedDescription)
|
||||
})
|
||||
.task {
|
||||
updatesObserver = Task.detached {
|
||||
updatesObserver = Task.detached { @MainActor in
|
||||
await observeTransactionUpdates()
|
||||
}
|
||||
do {
|
||||
|
@ -95,6 +95,7 @@ struct TipJarView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func observeTransactionUpdates() async {
|
||||
for await verificationResult in StoreKit.Transaction.updates {
|
||||
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 {
|
||||
isPurchasing = true
|
||||
let result: Product.PurchaseResult
|
||||
|
@ -229,7 +231,7 @@ extension HorizontalAlignment {
|
|||
}
|
||||
|
||||
private struct ButtonWidthKey: PreferenceKey {
|
||||
static var defaultValue: CGFloat = 0
|
||||
static let defaultValue: CGFloat = 0
|
||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
||||
value = max(value, nextValue())
|
||||
}
|
||||
|
|
|
@ -47,10 +47,11 @@ extension TuskerNavigationDelegate {
|
|||
|
||||
func selected(url: URL, allowResolveStatuses: Bool = true, allowUniversalLinks: Bool = true) {
|
||||
func openSafari() {
|
||||
#if os(visionOS)
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
UIApplication.shared.open(url)
|
||||
#else
|
||||
if Preferences.shared.useInAppSafari,
|
||||
if !ProcessInfo.processInfo.isiOSAppOnMac,
|
||||
Preferences.shared.useInAppSafari,
|
||||
url.scheme == "https" || url.scheme == "http" {
|
||||
let config = SFSafariViewController.Configuration()
|
||||
config.entersReaderIfAvailable = Preferences.shared.inAppSafariAutomaticReaderMode
|
||||
|
|
|
@ -111,6 +111,10 @@ class AttachmentView: GIFImageView {
|
|||
}
|
||||
}
|
||||
|
||||
func updateBadges() {
|
||||
createBadgesView(getBadges())
|
||||
}
|
||||
|
||||
@objc private func gifPlaybackModeChanged() {
|
||||
// NSProcessInfoPowerStateDidChange is sometimes fired on a background thread
|
||||
DispatchQueue.main.async {
|
||||
|
@ -370,6 +374,8 @@ class AttachmentView: GIFImageView {
|
|||
return
|
||||
}
|
||||
|
||||
self.badgeContainer?.removeFromSuperview()
|
||||
|
||||
let stack = UIStackView()
|
||||
self.badgeContainer = stack
|
||||
stack.axis = .horizontal
|
||||
|
|
|
@ -72,21 +72,35 @@ class AttachmentsContainerView: UIView {
|
|||
func updateUI(attachments: [Attachment], labelOnly: Bool = false) {
|
||||
let newTokens = attachments.map { AttachmentToken(attachment: $0) }
|
||||
|
||||
guard !labelOnly else {
|
||||
guard labelOnly != (label != nil) || self.attachmentTokens != newTokens else {
|
||||
self.attachments = attachments
|
||||
self.attachmentTokens = newTokens
|
||||
updateLabel(attachments: attachments)
|
||||
return
|
||||
}
|
||||
|
||||
guard self.attachmentTokens != newTokens else {
|
||||
self.isHidden = attachments.isEmpty
|
||||
if labelOnly && !attachments.isEmpty {
|
||||
updateLabel(attachments: attachments)
|
||||
} else {
|
||||
label?.removeFromSuperview()
|
||||
label = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
self.attachments = attachments
|
||||
self.attachmentTokens = newTokens
|
||||
|
||||
if labelOnly {
|
||||
if !attachments.isEmpty {
|
||||
updateLabel(attachments: attachments)
|
||||
} else {
|
||||
label?.removeFromSuperview()
|
||||
label = nil
|
||||
}
|
||||
return
|
||||
} else {
|
||||
label?.removeFromSuperview()
|
||||
label = nil
|
||||
}
|
||||
|
||||
removeAttachmentViews()
|
||||
hideButtonView?.isHidden = false
|
||||
|
||||
|
|
|
@ -29,7 +29,9 @@ class GifvController {
|
|||
self.isGrayscale = Preferences.shared.grayscaleImages
|
||||
|
||||
player.isMuted = true
|
||||
#if !os(visionOS)
|
||||
player.preventsDisplaySleepDuringVideoPlayback = false
|
||||
#endif
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
|
|
|
@ -20,8 +20,8 @@ class StatusCardView: UIView {
|
|||
private var statusID: String?
|
||||
private(set) var card: Card?
|
||||
|
||||
private let activeBackgroundColor = UIColor.secondarySystemFill
|
||||
private let inactiveBackgroundColor = UIColor.secondarySystemBackground
|
||||
private static let activeBackgroundColor = UIColor.secondarySystemFill
|
||||
private static let inactiveBackgroundColor = UIColor.secondarySystemBackground
|
||||
|
||||
private var isGrayscale = false
|
||||
|
||||
|
@ -107,7 +107,7 @@ class StatusCardView: UIView {
|
|||
hStack.clipsToBounds = true
|
||||
hStack.layer.borderWidth = 0.5
|
||||
hStack.layer.cornerCurve = .continuous
|
||||
hStack.backgroundColor = inactiveBackgroundColor
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
updateBorderColor()
|
||||
|
||||
addSubview(hStack)
|
||||
|
@ -173,8 +173,12 @@ class StatusCardView: UIView {
|
|||
return
|
||||
}
|
||||
|
||||
updateUI(card: card, sensitive: status.sensitive)
|
||||
}
|
||||
|
||||
func updateUI(card: Card, sensitive: Bool) {
|
||||
if let image = card.image {
|
||||
if status.sensitive {
|
||||
if sensitive {
|
||||
if let blurhash = card.blurhash {
|
||||
imageView.blurImage = false
|
||||
imageView.showOnlyBlurHash(blurhash, for: URL(image)!)
|
||||
|
@ -219,7 +223,7 @@ class StatusCardView: UIView {
|
|||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = activeBackgroundColor
|
||||
hStack.backgroundColor = StatusCardView.activeBackgroundColor
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
|
@ -227,7 +231,7 @@ class StatusCardView: UIView {
|
|||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = inactiveBackgroundColor
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
setNeedsDisplay()
|
||||
|
||||
if let card = card, let delegate = navigationDelegate {
|
||||
|
@ -236,7 +240,7 @@ class StatusCardView: UIView {
|
|||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
hStack.backgroundColor = inactiveBackgroundColor
|
||||
hStack.backgroundColor = StatusCardView.inactiveBackgroundColor
|
||||
setNeedsDisplay()
|
||||
}
|
||||
|
||||
|
|
|
@ -80,20 +80,35 @@ class StatusMetaIndicatorsView: UIView {
|
|||
}
|
||||
statusID = status.id
|
||||
|
||||
var images: [UIImage] = []
|
||||
var indicators: Indicator = []
|
||||
|
||||
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 {
|
||||
images.append(UIImage(systemName: status.visibility.unfilledImageName)!)
|
||||
indicators.insert(.visibility)
|
||||
}
|
||||
|
||||
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 v = UIImageView(image: $0)
|
||||
v.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
|
|
@ -47,30 +47,4 @@ class AttributedStringHelperTests: XCTestCase {
|
|||
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"))
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
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_BUILD_SUFFIX_Debug=-dev
|
||||
|
|
Loading…
Reference in New Issue