Compare commits
92 Commits
2024.2-120
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc | |
Shadowfacts | 2ccf028bc2 | |
Shadowfacts | 3eeffada1f | |
Shadowfacts | 0499255be7 | |
Shadowfacts | f909c1da10 | |
Shadowfacts | 81543965ae | |
Shadowfacts | 96d42756d5 | |
Shadowfacts | f6e57d664f | |
Shadowfacts | c33be1cbf3 | |
Shadowfacts | 6d99156bd9 | |
Shadowfacts | ca764811ed | |
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c | |
Shadowfacts | b89df3f27b | |
Shadowfacts | 4ecc16a93b | |
Shadowfacts | 8960873ff3 | |
Shadowfacts | 043a708515 | |
Shadowfacts | c6b230414e | |
Shadowfacts | f5e9f66f76 | |
Shadowfacts | ee5f9a62ff | |
Shadowfacts | a92cf8c812 | |
Shadowfacts | 756874949a | |
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 |
|
@ -1,3 +1,20 @@
|
|||
## 2024.2
|
||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||
|
||||
Features/Improvements:
|
||||
- Push notifications
|
||||
- Add post preview to Appearance preferences
|
||||
- Show instance announcements in Notifications tab
|
||||
- Add subscription option to Tip Jar
|
||||
- iPadOS: Multi-column navigation
|
||||
- Pleroma/Akkoma: Emoji reaction notifications
|
||||
|
||||
Bugfixes:
|
||||
- Fix fetching server info on some instances
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- macOS: Remove in-app Safari preferences
|
||||
- Pleroma: Handle posts with missing creation date
|
||||
|
||||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
|
|
67
CHANGELOG.md
67
CHANGELOG.md
|
@ -1,5 +1,72 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.3 (127)
|
||||
Bugfixes:
|
||||
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix dismissing gallery when presented from sheet
|
||||
- Fix potential crash in multi-column interface
|
||||
- Fix crash when opening push notification while sheet presented
|
||||
- Fix being able to block your own domain
|
||||
- Fix links in profile fields with other text not being interactable
|
||||
- Fix excessive CPU use immediately after app launch
|
||||
- Fix timeline failing to load when one status is malformed
|
||||
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||
- iPadOS: Fix search token suggestions not having pointer effect
|
||||
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||
|
||||
## 2024.3 (126)
|
||||
Bugfixes:
|
||||
- Fix an issue displaying post HTML in certain edge cases
|
||||
- Fix crash when video attachment playback ends
|
||||
- Fix excessive CPU usage when scrubbing video attachment
|
||||
- Fix video attachment thubmnails being flipped on Compose screen
|
||||
- Pleroma: Fix editing attachment descriptions not working
|
||||
|
||||
## 2024.2 (124)
|
||||
Features/Improvements:
|
||||
- Add subscription option to Tip Jar
|
||||
|
||||
Bugfixes:
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- Fix tapping follow request push notification not working
|
||||
- Pleroma: Handle posts with missing creation dates
|
||||
|
||||
## 2024.2 (122)
|
||||
Features/Improvements:
|
||||
- Show instance announcements in Notifications
|
||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue fetching server info on some instances
|
||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||
|
||||
## 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.
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
mutableContent.body = notification.body
|
||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||
mutableContent.userInfo["accountID"] = accountID
|
||||
mutableContent.targetContentIdentifier = accountID
|
||||
|
||||
let task = Task {
|
||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||
|
@ -109,6 +110,12 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
kindStr = "📊 Poll finished"
|
||||
case .update:
|
||||
kindStr = "✏️ Edited"
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
kindStr = "\(emoji) Reacted"
|
||||
} else {
|
||||
kindStr = nil
|
||||
}
|
||||
default:
|
||||
kindStr = nil
|
||||
}
|
||||
|
@ -301,6 +308,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>
|
|
@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
|
|||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
|
|||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -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)
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
} else if let (str, attrs) = format.title {
|
||||
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||
Text(AttributedString(str, attributes: container))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
|
||||
container.addSubview(to.view)
|
||||
container.addSubview(from.view)
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
|
|
|
@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
|
||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||
instanceType.isPleroma(.akkoma(nil))
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var composeDirectStatuses: Bool {
|
||||
|
@ -184,6 +184,51 @@ 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 var instanceAnnouncements: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var emojiReactionNotifications: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var muteNotifications: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var blockDomains: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var hideReblogs: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
|
@ -305,6 +350,14 @@ extension InstanceFeatures {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPixelfed: Bool {
|
||||
if case .pixelfed = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum MastodonType {
|
||||
|
|
|
@ -42,7 +42,7 @@ public struct Client: Sendable {
|
|||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -204,8 +204,8 @@ public struct Client: Sendable {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -456,14 +456,13 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -491,7 +490,7 @@ public struct Client: Sendable {
|
|||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
|
|
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||
self.note = try container.decode(String.self, forKey: .note)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// Announcement.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 4/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
|
||||
public let id: String
|
||||
public let content: String
|
||||
public let startsAt: Date?
|
||||
public let endsAt: Date?
|
||||
public let allDay: Bool
|
||||
public let publishedAt: Date
|
||||
public let updatedAt: Date
|
||||
public let read: Bool?
|
||||
public let mentions: [Account]
|
||||
public let statuses: [Status]
|
||||
public let tags: [Hashtag]
|
||||
public let emojis: [Emoji]
|
||||
public var reactions: [Reaction]
|
||||
|
||||
public static func all() -> Request<[Announcement]> {
|
||||
return Request(method: .get, path: "/api/v1/announcements")
|
||||
}
|
||||
|
||||
public static func dismiss(id: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
|
||||
}
|
||||
|
||||
public static func react(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
public static func unreact(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case content
|
||||
case startsAt = "starts_at"
|
||||
case endsAt = "ends_at"
|
||||
case allDay = "all_day"
|
||||
case publishedAt = "published_at"
|
||||
case updatedAt = "updated_at"
|
||||
case read
|
||||
case mentions
|
||||
case statuses
|
||||
case tags
|
||||
case emojis
|
||||
case reactions
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Account: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let url: WebURL
|
||||
public let acct: String
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Status: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let url: WebURL
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Reaction: Decodable, Sendable, Hashable {
|
||||
public let name: String
|
||||
public var count: Int
|
||||
public var me: Bool?
|
||||
public let url: URL?
|
||||
public let staticURL: URL?
|
||||
|
||||
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.me = me
|
||||
self.url = url
|
||||
self.staticURL = staticURL
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case count
|
||||
case me
|
||||
case url
|
||||
case staticURL = "static_url"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
|
|||
}
|
||||
}
|
||||
|
||||
extension Emoji: Equatable {
|
||||
extension Emoji: Equatable, Hashable {
|
||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(shortcode)
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ extension InstanceV2 {
|
|||
public struct Thumbnail: Decodable, Sendable {
|
||||
public let url: String
|
||||
public let blurhash: String?
|
||||
public let versions: ThumbnailVersions
|
||||
public let versions: ThumbnailVersions?
|
||||
}
|
||||
|
||||
public struct ThumbnailVersions: Decodable, Sendable {
|
||||
|
@ -120,6 +120,6 @@ extension InstanceV2 {
|
|||
extension InstanceV2 {
|
||||
public struct Contact: Decodable, Sendable {
|
||||
public let email: String
|
||||
public let account: Account
|
||||
public let account: Account?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
|
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
|
|||
public let createdAt: Date
|
||||
public let account: Account
|
||||
public let status: Status?
|
||||
// Only present for pleroma emoji reactions
|
||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||
public let emoji: String?
|
||||
public let emojiURL: WebURL?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
|
|||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
|
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
|
|||
case createdAt = "created_at"
|
||||
case account
|
||||
case status
|
||||
case emoji
|
||||
case emojiURL = "emoji_url"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +61,7 @@ extension Notification {
|
|||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
@ -43,6 +44,7 @@ public struct PushSubscription: Decodable, Sendable {
|
|||
"data[alerts][favourite]" => alerts.favourite,
|
||||
"data[alerts][poll]" => alerts.poll,
|
||||
"data[alerts][update]" => alerts.update,
|
||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||
"data[policy]" => policy.rawValue,
|
||||
]))
|
||||
}
|
||||
|
@ -57,6 +59,7 @@ public struct PushSubscription: Decodable, Sendable {
|
|||
"data[alerts][favourite]" => alerts.favourite,
|
||||
"data[alerts][poll]" => alerts.poll,
|
||||
"data[alerts][update]" => alerts.update,
|
||||
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
|
||||
"data[policy]" => policy.rawValue,
|
||||
]))
|
||||
}
|
||||
|
@ -84,8 +87,19 @@ extension PushSubscription {
|
|||
public let favourite: Bool
|
||||
public let poll: Bool
|
||||
public let update: Bool
|
||||
public let emojiReaction: Bool
|
||||
|
||||
public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
|
||||
public init(
|
||||
mention: Bool,
|
||||
status: Bool,
|
||||
reblog: Bool,
|
||||
follow: Bool,
|
||||
followRequest: Bool,
|
||||
favourite: Bool,
|
||||
poll: Bool,
|
||||
update: Bool,
|
||||
emojiReaction: Bool
|
||||
) {
|
||||
self.mention = mention
|
||||
self.status = status
|
||||
self.reblog = reblog
|
||||
|
@ -94,6 +108,24 @@ extension PushSubscription {
|
|||
self.favourite = favourite
|
||||
self.poll = poll
|
||||
self.update = update
|
||||
self.emojiReaction = emojiReaction
|
||||
}
|
||||
|
||||
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
|
||||
// pleroma/akkoma only
|
||||
self.emojiReaction = try container.decodeIfPresent(Bool.self, forKey: .emojiReaction) ?? false
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
@ -105,6 +137,7 @@ extension PushSubscription {
|
|||
case favourite
|
||||
case poll
|
||||
case update
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
|||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||
// not supported on pixelfed
|
||||
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||
// not supported on pixelfed
|
||||
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
||||
// not supported on pixelfed
|
||||
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [Status]
|
||||
public let statuses: [TryDecode<Status>]
|
||||
public let hashtags: [Hashtag]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
|
@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
|
||||
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
||||
public static func delete(_ statusID: String) -> Request<Empty> {
|
||||
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
|
||||
}
|
||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
func request(range: RequestRange) -> Request<[Status]> {
|
||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||
if case .public(true) = self {
|
||||
request.queryParameters.append("local" => true)
|
||||
}
|
||||
|
|
|
@ -7,17 +7,18 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
||||
public private(set) var notifications: [Notification]
|
||||
public let id: String
|
||||
public let kind: Notification.Kind
|
||||
public let kind: Kind
|
||||
|
||||
public init?(notifications: [Notification]) {
|
||||
public init?(notifications: [Notification], kind: Kind) {
|
||||
guard !notifications.isEmpty else { return nil }
|
||||
self.notifications = notifications
|
||||
self.id = notifications.first!.id
|
||||
self.kind = notifications.first!.kind
|
||||
self.kind = kind
|
||||
}
|
||||
|
||||
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
|
||||
|
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
notifications.append(contentsOf: group.notifications)
|
||||
}
|
||||
|
||||
private static func groupKind(for notification: Notification) -> Kind {
|
||||
switch notification.kind {
|
||||
case .mention:
|
||||
return .mention
|
||||
case .reblog:
|
||||
return .reblog
|
||||
case .favourite:
|
||||
return .favourite
|
||||
case .follow:
|
||||
return .follow
|
||||
case .followRequest:
|
||||
return .followRequest
|
||||
case .poll:
|
||||
return .poll
|
||||
case .update:
|
||||
return .update
|
||||
case .status:
|
||||
return .status
|
||||
case .emojiReaction:
|
||||
if let emoji = notification.emoji {
|
||||
return .emojiReaction(emoji, notification.emojiURL)
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
case .unknown:
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
var groups = [NotificationGroup]()
|
||||
for notification in notifications {
|
||||
let groupKind = groupKind(for: notification)
|
||||
|
||||
if allowedTypes.contains(notification.kind) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
|
||||
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
|
||||
groups[groups.count - 1].append(notification)
|
||||
continue
|
||||
} else if groups.count >= 2 {
|
||||
let secondToLastGroup = groups[groups.count - 2]
|
||||
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
|
||||
if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
|
||||
groups[groups.count - 2].append(notification)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups.append(NotificationGroup(notifications: [notification])!)
|
||||
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
|
||||
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
|
||||
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
|
||||
}
|
||||
|
||||
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
|
||||
|
@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
var second = second
|
||||
merged.reserveCapacity(second.count)
|
||||
while let firstGroupFromSecond = second.first,
|
||||
allowedTypes.contains(firstGroupFromSecond.kind) {
|
||||
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
|
||||
|
||||
second.removeFirst()
|
||||
|
||||
guard let lastGroup = merged.last,
|
||||
allowedTypes.contains(lastGroup.kind) else {
|
||||
allowedTypes.contains(lastGroup.kind.notificationKind) else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
break
|
||||
}
|
||||
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
|
||||
if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) {
|
||||
merged[merged.count - 1].append(group: firstGroupFromSecond)
|
||||
} else if merged.count >= 2 {
|
||||
let secondToLastGroup = merged[merged.count - 2]
|
||||
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
|
||||
if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
|
||||
merged[merged.count - 2].append(group: firstGroupFromSecond)
|
||||
} else {
|
||||
merged.append(firstGroupFromSecond)
|
||||
|
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
|
|||
return merged
|
||||
}
|
||||
|
||||
public enum Kind: Sendable, Equatable {
|
||||
case mention
|
||||
case reblog
|
||||
case favourite
|
||||
case follow
|
||||
case followRequest
|
||||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction(String, WebURL?)
|
||||
case unknown
|
||||
|
||||
var notificationKind: Notification.Kind {
|
||||
switch self {
|
||||
case .mention:
|
||||
.mention
|
||||
case .reblog:
|
||||
.reblog
|
||||
case .favourite:
|
||||
.favourite
|
||||
case .follow:
|
||||
.follow
|
||||
case .followRequest:
|
||||
.followRequest
|
||||
case .poll:
|
||||
.poll
|
||||
case .update:
|
||||
.update
|
||||
case .status:
|
||||
.status
|
||||
case .emojiReaction(_, _):
|
||||
.emojiReaction
|
||||
case .unknown:
|
||||
.unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// TryDecode.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TryDecode<T: Decodable>: Decodable {
|
||||
case error(String)
|
||||
case value(T)
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
do {
|
||||
self = .value(try T(from: decoder))
|
||||
} catch {
|
||||
self = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public var value: T? {
|
||||
if case .value(let value) = self {
|
||||
value
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TryDecode: Sendable where T: Sendable {
|
||||
}
|
|
@ -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!
|
||||
}
|
||||
|
@ -104,6 +104,7 @@ class PushManagerImpl: _PushManager {
|
|||
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||
guard newEndpoint != $0.endpoint else {
|
||||
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
|
||||
return $0
|
||||
}
|
||||
var copy = $0
|
||||
|
|
|
@ -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)
|
||||
|
@ -70,6 +70,7 @@ public struct PushSubscription {
|
|||
public static let favorite = Alerts(rawValue: 1 << 5)
|
||||
public static let poll = Alerts(rawValue: 1 << 6)
|
||||
public static let update = Alerts(rawValue: 1 << 7)
|
||||
public static let emojiReaction = Alerts(rawValue: 1 << 8)
|
||||
|
||||
public let rawValue: Int
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// FuzzyMatcher.swift
|
||||
// ComposeUI
|
||||
// TuskerComponents
|
||||
//
|
||||
// Created by Shadowfacts on 10/10/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct FuzzyMatcher {
|
||||
public struct FuzzyMatcher {
|
||||
|
||||
private init() {}
|
||||
|
||||
|
@ -21,7 +21,7 @@ struct FuzzyMatcher {
|
|||
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
||||
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
let pattern = pattern.lowercased()
|
||||
let str = str.lowercased()
|
||||
|
|
@ -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,82 @@
|
|||
//
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
|
||||
self[keyPath: preferenceKeyPath].preference.wrappedValue
|
||||
}
|
||||
}
|
|
@ -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
|
||||
return PreferenceStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public var blurMediaBehindContentWarning = true
|
||||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
@Published public var attachmentAltBadgeInverted = false
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
@Published public var useInAppSafari = true
|
||||
@Published public var inAppSafariAutomaticReaderMode = false
|
||||
@Published public var expandAllContentWarnings = false
|
||||
@Published public var collapseLongPosts = true
|
||||
@Published public var oppositeCollapseKeywords: [String] = []
|
||||
@Published public var confirmBeforeReblog = false
|
||||
@Published public var timelineStateRestoration = true
|
||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published public var hideReblogsInTimelines = false
|
||||
@Published public var hideRepliesInTimelines = false
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published public var showFavoriteAndReblogCounts = true
|
||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published public var grayscaleImages = false
|
||||
@Published public var disableInfiniteScrolling = false
|
||||
@Published public var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published public var statusContentType: StatusContentType = .plain
|
||||
@Published public var reportErrorsAutomatically = true
|
||||
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
||||
|
||||
// MARK:
|
||||
@Published public var hasShownLocalTimelineDescription = false
|
||||
@Published public var hasShownFederatedTimelineDescription = false
|
||||
|
||||
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
||||
enabledFeatureFlags.contains(flag)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case pureBlackDarkMode
|
||||
case accentColor
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case showLinkPreviews
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
case widescreenNavigationMode
|
||||
case underlineTextLinks
|
||||
case showAttachmentsInTimeline
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia // only used for migration
|
||||
case attachmentBlurMode
|
||||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
case attachmentAltBadgeInverted
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
case grayscaleImages
|
||||
case disableInfiniteScrolling
|
||||
case hideTrends = "hideDiscover"
|
||||
|
||||
case statusContentType
|
||||
case reportErrorsAutomatically
|
||||
case enabledFeatureFlags
|
||||
|
||||
case hasShownLocalTimelineDescription
|
||||
case hasShownFederatedTimelineDescription
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
||||
|
||||
extension Preferences {
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadMultiColumn = "ipad-multi-column"
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// AccentColor.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// AttachmentBlurMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,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 */; };
|
||||
|
@ -37,6 +36,7 @@
|
|||
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; };
|
||||
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
|
||||
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
|
||||
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */; };
|
||||
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
|
||||
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
|
||||
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
|
||||
|
@ -75,6 +75,7 @@
|
|||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
|
||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
|
||||
|
@ -96,7 +97,7 @@
|
|||
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
|
||||
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
|
||||
D630C3D42BC61B6100208903 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3D32BC61B6100208903 /* NotificationService.swift */; };
|
||||
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
|
||||
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
|
||||
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
|
||||
|
@ -170,7 +171,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 */; };
|
||||
|
@ -229,6 +229,12 @@
|
|||
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
|
||||
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; };
|
||||
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
|
||||
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */; };
|
||||
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */; };
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */; };
|
||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; };
|
||||
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; };
|
||||
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; };
|
||||
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
|
||||
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
|
||||
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
|
||||
|
@ -253,6 +259,7 @@
|
|||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
|
||||
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
|
||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
|
||||
|
@ -291,6 +298,9 @@
|
|||
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 */; };
|
||||
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
|
||||
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 +445,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>"; };
|
||||
|
@ -461,6 +470,7 @@
|
|||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
|
||||
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
|
||||
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
|
||||
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationViewController.swift; sourceTree = "<group>"; };
|
||||
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -498,6 +508,7 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
|
||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
||||
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
|
||||
|
@ -592,7 +603,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>"; };
|
||||
|
@ -651,6 +661,12 @@
|
|||
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
|
||||
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
|
||||
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsHostingController.swift; sourceTree = "<group>"; };
|
||||
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsView.swift; sourceTree = "<group>"; };
|
||||
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementListRow.swift; sourceTree = "<group>"; };
|
||||
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
|
||||
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
|
||||
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
|
||||
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
|
||||
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
|
||||
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
|
||||
|
@ -672,6 +688,7 @@
|
|||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
|
||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
|
||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -713,6 +730,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 +1029,7 @@
|
|||
D630C3D92BC61B6100208903 /* NotificationExtension.entitlements */,
|
||||
D630C3D32BC61B6100208903 /* NotificationService.swift */,
|
||||
D630C3D52BC61B6100208903 /* Info.plist */,
|
||||
D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
path = NotificationExtension;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1032,6 +1053,7 @@
|
|||
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1052,6 +1074,7 @@
|
|||
children = (
|
||||
D65B4B89297879DE00DABDFB /* Account Follows */,
|
||||
D6A3BC822321F69400FD64D5 /* Account List */,
|
||||
D698F4472BCEE2320054DB14 /* Announcements */,
|
||||
D641C787213DD862004B4513 /* Compose */,
|
||||
D641C785213DD83B004B4513 /* Conversation */,
|
||||
D6F2E960249E772F005846BB /* Crash Reporter */,
|
||||
|
@ -1148,6 +1171,7 @@
|
|||
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
|
||||
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
|
||||
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */,
|
||||
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */,
|
||||
);
|
||||
path = Notifications;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1168,17 +1192,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 */,
|
||||
|
@ -1350,6 +1371,19 @@
|
|||
path = About;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D698F4472BCEE2320054DB14 /* Announcements */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */,
|
||||
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */,
|
||||
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */,
|
||||
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */,
|
||||
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */,
|
||||
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */,
|
||||
);
|
||||
path = Announcements;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6A3BC822321F69400FD64D5 /* Account List */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -1483,6 +1517,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 = (
|
||||
|
@ -1717,6 +1762,7 @@
|
|||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1944,6 +1990,7 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -2100,6 +2147,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 */,
|
||||
|
@ -2118,6 +2166,7 @@
|
|||
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
|
||||
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
|
||||
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
|
||||
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
|
||||
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
|
||||
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
|
||||
|
@ -2148,8 +2197,10 @@
|
|||
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
|
||||
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
|
||||
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
|
||||
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */,
|
||||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
|
||||
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
|
||||
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */,
|
||||
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
|
||||
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
|
||||
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
|
||||
|
@ -2177,6 +2228,7 @@
|
|||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
|
||||
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
|
||||
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
|
||||
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
|
||||
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
|
||||
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
|
||||
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
|
||||
|
@ -2191,6 +2243,7 @@
|
|||
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */,
|
||||
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
|
||||
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
|
||||
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */,
|
||||
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
|
||||
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
|
||||
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
|
||||
|
@ -2288,7 +2341,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 */,
|
||||
|
@ -2313,8 +2365,10 @@
|
|||
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
|
||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
|
||||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
|
||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
|
||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||
|
@ -2335,7 +2389,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,9 +2410,12 @@
|
|||
D65B4B562971F98300DABDFB /* ReportView.swift in Sources */,
|
||||
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
|
||||
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
|
||||
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
|
||||
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */,
|
||||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
|
||||
);
|
||||
|
@ -2480,7 +2536,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 +2545,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 +2568,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 +2577,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 +2599,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 +2608,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 +2664,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 +2672,7 @@
|
|||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Dist;
|
||||
};
|
||||
|
@ -2631,7 +2688,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 +2754,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 +2781,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 +2809,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 +2837,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 +2907,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 +2915,7 @@
|
|||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
@ -2915,7 +2967,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 +2975,7 @@
|
|||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_OPTIMIZE_OBJECT_LIFETIME = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
XROS_DEPLOYMENT_TARGET = 1.1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
|
@ -2938,7 +2991,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 +3022,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 +3129,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 +3153,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",
|
||||
|
@ -3202,7 +3249,7 @@
|
|||
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.2.3;
|
||||
version = 0.2.4;
|
||||
};
|
||||
};
|
||||
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {
|
||||
|
|
|
@ -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 {
|
||||
|
@ -52,7 +58,8 @@ private extension Pachyderm.PushSubscription.Alerts {
|
|||
followRequest: alerts.contains(.followRequest),
|
||||
favourite: alerts.contains(.favorite),
|
||||
poll: alerts.contains(.poll),
|
||||
update: alerts.contains(.update)
|
||||
update: alerts.contains(.update),
|
||||
emojiReaction: alerts.contains(.emojiReaction)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// RemoveProfileSuggestionService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/1/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
@MainActor
|
||||
class RemoveProfileSuggestionService {
|
||||
private let accountID: String
|
||||
private let mastodonController: MastodonController
|
||||
private let presenter: any TuskerNavigationDelegate
|
||||
private let completionHandler: @MainActor () -> Void
|
||||
|
||||
init(accountID: String, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate, completionHandler: @MainActor @escaping () -> Void) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
self.presenter = presenter
|
||||
self.completionHandler = completionHandler
|
||||
}
|
||||
|
||||
func run() async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
completionHandler()
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: presenter) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.run()
|
||||
}
|
||||
self.presenter.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -192,7 +185,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
let mastodonController = MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.updatePushSubscription(subscription: $0)
|
||||
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
|
||||
PushManager.logger.info("Updated push subscription \(result.id, privacy: .public) on \(mastodonController.instanceURL) with endpoint \($0.endpoint, privacy: .public)")
|
||||
PushManager.logger.debug("New push subscription: \(String(describing: result))")
|
||||
return true
|
||||
} catch {
|
||||
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
|
||||
|
@ -296,13 +290,19 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
|||
// if the scene is already active, then we animate the account switching if necessary
|
||||
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||
|
||||
rootViewController.select(route: .notifications, animated: false)
|
||||
rootViewController.select(route: .notifications, animated: false) {
|
||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||
}
|
||||
} else {
|
||||
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||
if #available(iOS 17.0, *) {
|
||||
let request = UISceneSessionActivationRequest(userActivity: activity)
|
||||
UIApplication.shared.activateSceneSession(for: request)
|
||||
} else {
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
}
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "face.smiling.badge.plus.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 232.5-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
|
||||
<!--glyph: "", point size: 100.0, font version: "19.2d2e1", template writer version: "128"-->
|
||||
<style>.monochrome-0 {-sfsymbols-motion-group:1}
|
||||
.monochrome-1 {-sfsymbols-motion-group:1}
|
||||
.monochrome-2 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.monochrome-3 {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.monochrome-4 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.multicolor-0:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-1:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-2:tintColor {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.multicolor-3:systemGreenColor {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.multicolor-4:white {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.hierarchical-0:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-1:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-2:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.hierarchical-3:primary {-sfsymbols-motion-group:0}
|
||||
.hierarchical-4:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
|
||||
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
|
||||
</style>
|
||||
<g id="Notes">
|
||||
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 263 1933)">
|
||||
<path d="m46.2402 4.15039c21.5332 0 39.4531-17.8711 39.4531-39.4043s-17.9688-39.4043-39.502-39.4043c-21.4844 0-39.3555 17.8711-39.3555 39.4043s17.9199 39.4043 39.4043 39.4043Zm0-7.42188c-17.7246 0-31.8848-14.209-31.8848-31.9824s14.1113-31.9824 31.8359-31.9824c17.7734 0 32.0312 14.209 32.0312 31.9824s-14.209 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
|
||||
<path d="m58.5449 14.5508c27.2461 0 49.8047-22.6074 49.8047-49.8047 0-27.2461-22.6074-49.8047-49.8535-49.8047-27.1973 0-49.7559 22.5586-49.7559 49.8047 0 27.1973 22.6074 49.8047 49.8047 49.8047Zm0-8.30078c-23.0469 0-41.4551-18.457-41.4551-41.5039s18.3594-41.5039 41.4062-41.5039 41.5527 18.457 41.5527 41.5039-18.457 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
|
||||
<path d="m74.8535 28.3203c34.8145 0 63.623-28.8086 63.623-63.5742 0-34.8145-28.8574-63.623-63.6719-63.623-34.7656 0-63.5254 28.8086-63.5254 63.623 0 34.7656 28.8086 63.5742 63.5742 63.5742Zm0-9.08203c-30.1758 0-54.4434-24.3164-54.4434-54.4922 0-30.2246 24.2188-54.4922 54.3945-54.4922 30.2246 0 54.541 24.2676 54.541 54.4922 0 30.1758-24.2676 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 776 1933)">
|
||||
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
|
||||
</g>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
|
||||
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
|
||||
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
|
||||
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.5.0</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 15 or greater</text>
|
||||
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from </text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
|
||||
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.649" x2="515.649" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="603.773" x2="603.773" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1403.58" x2="1403.58" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Regular-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="1496.11" x2="1496.11" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2884.57" x2="2884.57" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Black-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="2982.23" x2="2982.23" y1="600.785" y2="720.121"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Black-S" transform="matrix(1 0 0 1 2884.57 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M48.8281 6.78711C71.9727 6.78711 90.8203-12.0605 90.8203-35.2051C90.8203-58.3496 71.9727-77.1973 48.8281-77.1973C25.6836-77.1973 6.83594-58.3496 6.83594-35.2051C6.83594-12.0605 25.6836 6.78711 48.8281 6.78711ZM48.8281-7.37305C33.4473-7.37305 20.9961-19.8242 20.9961-35.2051C20.9961-50.5859 33.4473-63.0371 48.8281-63.0371C64.209-63.0371 76.6602-50.5859 76.6602-35.2051C76.6602-19.8242 64.209-7.37305 48.8281-7.37305Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M48.8281-18.1641C56.9824-18.1641 62.4512-23.5352 62.4512-26.1719C62.4512-27.1973 61.4258-27.6855 60.4004-27.2461C57.4707-25.9766 54.3945-24.2676 48.8281-24.2676C43.2617-24.2676 40.0879-25.8789 37.2559-27.2461C36.2305-27.7344 35.2051-27.1973 35.2051-26.1719C35.2051-23.5352 40.625-18.1641 48.8281-18.1641ZM37.793-38.916C40.0879-38.916 42.0898-40.9668 42.0898-43.7988C42.0898-46.6797 40.0879-48.7305 37.793-48.7305C35.498-48.7305 33.5938-46.6797 33.5938-43.7988C33.5938-40.9668 35.498-38.916 37.793-38.916ZM59.8145-38.916C62.0605-38.916 64.1113-40.9668 64.1113-43.7988C64.1113-46.6797 62.0605-48.7305 59.8145-48.7305C57.4707-48.7305 55.5664-46.6797 55.5664-43.7988C55.5664-40.9668 57.4707-38.916 59.8145-38.916Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M87.8941 20.2949C102.836 20.2949 115.189 7.89255 115.189-7.04885C115.189-21.9903 102.836-34.2949 87.8941-34.2949C72.9527-34.2949 60.5992-21.9903 60.5992-7.04885C60.5992 7.89255 72.9527 20.2949 87.8941 20.2949Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M87.8941 13.9473C99.3688 13.9473 108.842 4.37695 108.842-7.04885C108.842-18.4746 99.3688-27.9472 87.8941-27.9472C76.4195-27.9472 66.9468-18.4746 66.9468-7.04885C66.9468 4.37695 76.4195 13.9473 87.8941 13.9473Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M87.8941 7.01365C85.5503 7.01365 83.9878 5.45115 83.9878 3.15625L83.9878-3.09375L77.8355-3.09375C75.5406-3.09375 73.9292-4.65625 73.9292-7.00005C73.9292-9.34375 75.4429-10.9062 77.8355-10.9062L83.9878-10.9062L83.9878-17.0097C83.9878-19.3047 85.5503-20.9161 87.8941-20.9161C90.2378-20.9161 91.8003-19.4023 91.8003-17.0097L91.8003-10.9062L98.0018-10.9062C100.297-10.9062 101.859-9.34375 101.859-7.00005C101.859-4.65625 100.297-3.09375 98.0018-3.09375L91.8003-3.09375L91.8003 3.15625C91.8003 5.45115 90.2378 7.01365 87.8941 7.01365Z"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1 0 0 1 1403.58 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M46.2402 4.15039C67.7734 4.15039 85.6934-13.7207 85.6934-35.2539C85.6934-56.7871 67.7246-74.6582 46.1914-74.6582C24.707-74.6582 6.83594-56.7871 6.83594-35.2539C6.83594-13.7207 24.7559 4.15039 46.2402 4.15039ZM46.2402-3.27148C28.5156-3.27148 14.3555-17.4805 14.3555-35.2539C14.3555-53.0273 28.4668-67.2363 46.1914-67.2363C63.9648-67.2363 78.2227-53.0273 78.2227-35.2539C78.2227-17.4805 64.0137-3.27148 46.2402-3.27148Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M46.1914-15.8691C54.3457-15.8691 59.8145-21.2402 59.8145-23.877C59.8145-24.8535 58.8379-25.3418 57.8613-24.9512C54.9805-23.584 51.8066-21.875 46.1914-21.875C40.625-21.875 37.4512-23.584 34.5703-24.9512C33.5938-25.3418 32.6172-24.8535 32.6172-23.877C32.6172-21.2402 38.0859-15.8691 46.1914-15.8691ZM34.9121-38.5742C37.4512-38.5742 39.6973-40.7715 39.6973-43.9453C39.6973-47.2168 37.4512-49.4141 34.9121-49.4141C32.4219-49.4141 30.2246-47.2168 30.2246-43.9453C30.2246-40.7715 32.4219-38.5742 34.9121-38.5742ZM57.5195-38.5742C60.0586-38.5742 62.2559-40.7715 62.2559-43.9453C62.2559-47.2168 60.0586-49.4141 57.5195-49.4141C54.9805-49.4141 52.832-47.2168 52.832-43.9453C52.832-40.7715 54.9805-38.5742 57.5195-38.5742Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M83.277 18.3906C97.0956 18.3906 108.668 6.8184 108.668-7C108.668-20.916 97.1926-32.3906 83.277-32.3906C69.3121-32.3906 57.8864-20.916 57.8864-7C57.8864 6.9649 69.3121 18.3906 83.277 18.3906Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M83.277 12.6777C93.9216 12.6777 102.955 3.7422 102.955-7C102.955-17.791 94.0676-26.6777 83.277-26.6777C72.486-26.6777 63.5504-17.791 63.5504-7C63.5504 3.8399 72.486 12.6777 83.277 12.6777Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M83.277 5.2559C81.7145 5.2559 80.7379 4.2305 80.7379 2.7168L80.7379-4.4609L73.5602-4.4609C72.0465-4.4609 71.0211-5.4863 71.0211-7C71.0211-8.5137 72.0465-9.5391 73.5602-9.5391L80.7379-9.5391L80.7379-16.7168C80.7379-18.2305 81.7145-19.2559 83.277-19.2559C84.7906-19.2559 85.816-18.2305 85.816-16.7168L85.816-9.5391L92.9936-9.5391C94.5076-9.5391 95.4836-8.5137 95.4836-7C95.4836-5.4863 94.5076-4.4609 92.9936-4.4609L85.816-4.4609L85.816 2.7168C85.816 4.2305 84.7906 5.2559 83.277 5.2559Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.649 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M44.0606 1.97072C64.5039 1.97072 81.2886-14.8105 81.2886-35.2539C81.2886-55.6973 64.5005-72.4785 44.0571-72.4785C23.5718-72.4785 6.83594-55.6973 6.83594-35.2539C6.83594-14.8105 23.5752 1.97072 44.0606 1.97072ZM44.0606-0.274438C24.7466-0.274438 9.04252-15.9365 9.04252-35.2539C9.04252-54.5713 24.7432-70.2334 44.0571-70.2334C63.3745-70.2334 79.04-54.5713 79.04-35.2539C79.04-15.9365 63.3779-0.274438 44.0606-0.274438Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M44.0571-17.0044C51.3032-17.0044 56.3633-22.103 56.3633-24.2856C56.3633-24.7627 55.9317-24.8423 55.6362-24.5879C53.4365-22.4487 49.4453-20.6489 44.0571-20.6489C38.6724-20.6489 34.7266-22.4941 32.4815-24.5879C32.186-24.8423 31.7544-24.7627 31.7544-24.2856C31.7544-22.103 36.8145-17.0044 44.0571-17.0044ZM32.5054-39.0283C34.4541-39.0283 36.2007-41.0439 36.2007-43.5366C36.2007-45.9907 34.4995-48.0064 32.5054-48.0064C30.5147-48.0064 28.8169-45.9907 28.8169-43.5366C28.8169-41.0439 30.5601-39.0283 32.5054-39.0283ZM55.6123-39.0283C57.5611-39.0283 59.3042-41.0439 59.3042-43.5366C59.3042-45.9907 57.6065-48.0064 55.6123-48.0064C53.6182-48.0064 51.9238-45.9907 51.9238-43.5366C51.9238-41.0439 53.6636-39.0283 55.6123-39.0283Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M79.3341 14.3492C90.9727 14.3492 100.684 4.72955 100.684-6.99995C100.684-18.7362 91.0247-28.3492 79.3341-28.3492C67.6397-28.3492 57.9395-18.6908 57.9395-6.99995C57.9395 4.73985 67.6397 14.3492 79.3341 14.3492Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M79.3341 11.2701C89.2977 11.2701 97.6037 3.06105 97.6037-6.99995C97.6037-17.0644 89.3527-25.2699 79.3341-25.2699C69.315-25.2699 61.0152-17.019 61.0152-6.99995C61.0152 3.06795 69.315 11.2701 79.3341 11.2701Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M79.3341 4.89265C78.3619 4.89265 77.794 4.23055 77.794 3.35255L77.794-5.50535L68.9361-5.50535C68.149-5.50535 67.4415-6.03115 67.4415-6.99995C67.4415-7.96875 68.149-8.54005 68.9361-8.54005L77.794-8.54005L77.794-17.3071C77.794-18.1396 78.3619-18.8471 79.3341-18.8471C80.2574-18.8471 80.8287-18.1396 80.8287-17.3071L80.8287-8.54005L89.6407-8.54005C90.4737-8.54005 91.1327-7.96875 91.1327-6.99995C91.1327-6.03115 90.4737-5.50535 89.6407-5.50535L80.8287-5.50535L80.8287 3.35255C80.8287 4.23055 80.2574 4.89265 79.3341 4.89265Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 22 KiB |
|
@ -96,7 +96,7 @@ final class ImageCache: @unchecked Sendable {
|
|||
}
|
||||
|
||||
private func fetch(url: URL) async -> FetchResult {
|
||||
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
|
||||
guard let (data, _) = try? await URLSession.appDefault.data(from: url) else {
|
||||
return .none
|
||||
}
|
||||
guard let image = UIImage(data: data) else {
|
||||
|
|
|
@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
return context
|
||||
}()
|
||||
|
||||
private var lastRemoteChangeToken: NSPersistentHistoryToken?
|
||||
|
||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
||||
// would need to audit existing uses to make sure everything happens on the main thread
|
||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
||||
|
@ -124,12 +122,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
|
||||
// migrate saved data from local store to cloud store
|
||||
// this can be removed pre-app store release
|
||||
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
|
||||
group.enter()
|
||||
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||
if FileManager.default.fileExists(atPath: defaultPath.path) {
|
||||
group.enter()
|
||||
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
|
||||
defaultDesc.configuration = "Default"
|
||||
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||
defaultPSC.persistentStoreDescriptions = [defaultDesc]
|
||||
defaultPSC.loadPersistentStores { _, error in
|
||||
|
@ -189,9 +188,11 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
viewContext.name = "View"
|
||||
|
||||
if accountInfo != nil {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||
}
|
||||
}
|
||||
|
||||
func save(context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else {
|
||||
|
@ -520,22 +521,48 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
|
||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
guard let accountInfo,
|
||||
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
return
|
||||
}
|
||||
remoteChangesBackgroundContext.perform {
|
||||
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
|
||||
self.remoteChangesBackgroundContext.perform {
|
||||
defer {
|
||||
self.lastRemoteChangeToken = token
|
||||
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
|
||||
}
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
||||
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
|
||||
let transactions = result.result as? [NSPersistentHistoryTransaction],
|
||||
!transactions.isEmpty {
|
||||
let transactions: [NSPersistentHistoryTransaction]
|
||||
do {
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
|
||||
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
|
||||
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
|
||||
} else {
|
||||
logger.error("Unexpectedly non-NSPersistentHistoryResult")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
|
||||
return
|
||||
}
|
||||
if !transactions.isEmpty {
|
||||
self.processPersistentHistoryTransactions(transactions)
|
||||
}
|
||||
|
||||
// NB: We deliberately do not purge old persistent history.
|
||||
// Doing so causes the CoreData+CloudKit integration to replay all of
|
||||
// the server's changes on initialization, which takes a long time
|
||||
// and produces a bunch of intermediate UI updates we don't want.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
|
||||
logger.info("Processing \(transactions.count) persistent history transactions")
|
||||
var changedHashtags = false
|
||||
var changedInstances = false
|
||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||
var changedAccountPrefs = false
|
||||
outer: for transaction in transactions {
|
||||
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
|
||||
for change in transaction.changes ?? [] {
|
||||
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||
changedHashtags = true
|
||||
|
@ -573,8 +600,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// PersistentHistoryTokenStore.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import UserAccounts
|
||||
|
||||
struct PersistentHistoryTokenStore {
|
||||
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
|
||||
|
||||
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
|
||||
|
||||
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
|
||||
|
||||
private static func load() throws -> [String: NSPersistentHistoryToken]? {
|
||||
let data = try Data(contentsOf: storeURL)
|
||||
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
|
||||
return unarchived as? [String: NSPersistentHistoryToken]
|
||||
}
|
||||
|
||||
private static func save() throws {
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
|
||||
try data.write(to: PersistentHistoryTokenStore.storeURL)
|
||||
}
|
||||
|
||||
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
|
||||
queue.async {
|
||||
completion(tokens[account.id])
|
||||
}
|
||||
}
|
||||
|
||||
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
|
||||
queue.async {
|
||||
tokens[account.id] = token
|
||||
try? save()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -8,26 +8,11 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
extension View {
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
if applyBackground {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type) -> some View {
|
||||
self.modifier(AppGroupedListBackground(container: container))
|
||||
}
|
||||
|
||||
func appGroupedListRowBackground() -> some View {
|
||||
|
@ -35,11 +20,45 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
private struct AppGroupedListBackground: ViewModifier {
|
||||
let container: any UIAppearanceContainer.Type
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.pureBlackDarkMode) private var environmentPureBlackDarkMode
|
||||
|
||||
private var pureBlackDarkMode: Bool {
|
||||
// using @PreferenceObserving just does not work for this, so try the environment key when available
|
||||
// if it's not available, the color won't update automatically, but it will be correct when the view is created
|
||||
if #available(iOS 17.0, *) {
|
||||
environmentPureBlackDarkMode
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
|
||||
if #available(iOS 16.0, *) {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@PreferenceObserving(\.$pureBlackDarkMode) private var pureBlackDarkMode
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.listRowBackground(Color.appGroupedCellBackground)
|
||||
} else {
|
||||
|
@ -47,3 +66,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
|
||||
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
let keyPath: PrefKeyPath
|
||||
@StateObject private var observer: Observer
|
||||
|
||||
init(_ keyPath: PrefKeyPath) {
|
||||
self.keyPath = keyPath
|
||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||
}
|
||||
|
||||
var wrappedValue: Key.Value {
|
||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class Observer: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(keyPath: PrefKeyPath) {
|
||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -143,3 +143,33 @@ extension UIMutableTraits {
|
|||
set { self[PureBlackDarkModeTrait.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17.0, *)
|
||||
private struct PureBlackDarkModeKey: UITraitBridgedEnvironmentKey {
|
||||
static let defaultValue: Bool = false
|
||||
|
||||
static func read(from traitCollection: UITraitCollection) -> Bool {
|
||||
traitCollection[PureBlackDarkModeTrait.self]
|
||||
}
|
||||
|
||||
static func write(to mutableTraits: inout any UIMutableTraits, value: Bool) {
|
||||
mutableTraits[PureBlackDarkModeTrait.self] = value
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var pureBlackDarkMode: Bool {
|
||||
get {
|
||||
if #available(iOS 17.0, *) {
|
||||
self[PureBlackDarkModeKey.self]
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
set {
|
||||
if #available(iOS 17.0, *) {
|
||||
self[PureBlackDarkModeKey.self] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,6 @@ extension StatusSwipeAction {
|
|||
protocol StatusSwipeActionContainer: UIView {
|
||||
var mastodonController: MastodonController! { get }
|
||||
var navigationDelegate: any TuskerNavigationDelegate { get }
|
||||
var toastableViewController: ToastableViewController? { get }
|
||||
|
||||
var canReblog: Bool { get }
|
||||
|
||||
|
|
|
@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
|||
}
|
||||
launchActivity = activity
|
||||
|
||||
let account: UserAccountInfo
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
if let activityAccount = UserActivityManager.getAccount(from: activity) {
|
||||
account = activityAccount
|
||||
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
|
||||
|
|
|
@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
|||
return
|
||||
}
|
||||
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
let controller: MastodonController
|
||||
let draft: Draft?
|
||||
|
|
|
@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||
}
|
||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
}
|
||||
}
|
||||
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||
|
@ -191,9 +193,11 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
if let activity = launchActivity {
|
||||
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
if activity.isStateRestorationActivity {
|
||||
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||
} else if activity.activityType != UserActivityType.mainScene.rawValue {
|
||||
|
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
window!.windowScene!.title = account.instanceURL.host!
|
||||
}
|
||||
|
||||
let newRoot = createAppUI()
|
||||
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id)
|
||||
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
let direction: AccountSwitchingContainerViewController.AnimationDirection
|
||||
if animated,
|
||||
|
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
direction = .none
|
||||
}
|
||||
container.setRoot(newRoot, for: account, animating: direction)
|
||||
container.setRoot(createAppUI, for: account, animating: direction)
|
||||
} else {
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,6 +253,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
LogoutService(accountInfo: account).run()
|
||||
if UserAccountsManager.shared.onboardingComplete {
|
||||
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
container.removeAccount(account)
|
||||
}
|
||||
} else {
|
||||
window!.rootViewController = createOnboardingUI()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
//
|
||||
// AddReactionView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
struct AddReactionView: View {
|
||||
let mastodonController: MastodonController
|
||||
let addReaction: (Reaction) async throws -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ScaledMetric private var emojiSize = 30
|
||||
@State private var allEmojis: [Emoji] = []
|
||||
@State private var emojisBySection: [String: [Emoji]] = [:]
|
||||
@State private var query = ""
|
||||
@State private var error: (any Error)?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView(.vertical) {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
|
||||
if query.count == 1 {
|
||||
Section {
|
||||
AddReactionButton {
|
||||
await doAddReaction(.emoji(query))
|
||||
} label: {
|
||||
Text(query)
|
||||
.font(.system(size: 25))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
|
||||
Section {
|
||||
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
|
||||
AddReactionButton {
|
||||
await doAddReaction(.custom(emoji))
|
||||
} label: {
|
||||
CustomEmojiImageView(emoji: emoji)
|
||||
.frame(height: emojiSize)
|
||||
.accessibilityLabel(emoji.shortcode)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
if !section.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.searchPresentationToolbarBehaviorIfAvailable()
|
||||
.onChange(of: query) { _ in
|
||||
updateFilteredEmojis()
|
||||
}
|
||||
.navigationTitle("Add Reaction")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(role: .cancel) {
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationViewStyle(.stack)
|
||||
.mediumPresentationDetentIfAvailable()
|
||||
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
|
||||
Button("OK") {}
|
||||
}, message: { error in
|
||||
Text(error.localizedDescription)
|
||||
})
|
||||
.task {
|
||||
allEmojis = await mastodonController.getCustomEmojis()
|
||||
updateFilteredEmojis()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateFilteredEmojis() {
|
||||
let filteredEmojis = if !query.isEmpty {
|
||||
allEmojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
|
||||
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
|
||||
}
|
||||
.filter(\.1.matched)
|
||||
.sorted { $0.1.score > $1.1.score }
|
||||
.map(\.0)
|
||||
} else {
|
||||
allEmojis
|
||||
}
|
||||
|
||||
var shortcodes = Set<String>()
|
||||
var newEmojis = [Emoji]()
|
||||
var newEmojisBySection = [String: [Emoji]]()
|
||||
for emoji in filteredEmojis where !shortcodes.contains(emoji.shortcode) {
|
||||
newEmojis.append(emoji)
|
||||
shortcodes.insert(emoji.shortcode)
|
||||
|
||||
let category = emoji.category ?? ""
|
||||
if newEmojisBySection.keys.contains(category) {
|
||||
newEmojisBySection[category]!.append(emoji)
|
||||
} else {
|
||||
newEmojisBySection[category] = [emoji]
|
||||
}
|
||||
}
|
||||
emojisBySection = newEmojisBySection
|
||||
}
|
||||
|
||||
private func doAddReaction(_ reaction: Reaction) async {
|
||||
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
do {
|
||||
try await addReaction(reaction)
|
||||
dismiss()
|
||||
} catch {
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
enum Reaction {
|
||||
case emoji(String)
|
||||
case custom(Emoji)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddReactionButton<Label: View>: View {
|
||||
let addReaction: () async -> Void
|
||||
@ViewBuilder let label: Label
|
||||
@State private var isLoading = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
isLoading = true
|
||||
Task {
|
||||
await addReaction()
|
||||
isLoading = false
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
label
|
||||
.opacity(isLoading ? 0 : 1)
|
||||
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(2)
|
||||
.hoverEffect()
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func mediumPresentationDetentIfAvailable() -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
self.presentationDetents([.medium, .large])
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS, obsoleted: 17.1)
|
||||
@ViewBuilder
|
||||
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
|
||||
if #available(iOS 17.1, *) {
|
||||
self.searchPresentationToolbarBehavior(.avoidHidingContent)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AddReactionView()
|
||||
//}
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// AnnouncementContentTextView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/16/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import WebURL
|
||||
|
||||
class AnnouncementContentTextView: ContentTextView {
|
||||
|
||||
var heightChanged: ((CGFloat) -> Void)?
|
||||
|
||||
private var announcement: Announcement?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
heightChanged?(contentSize.height)
|
||||
}
|
||||
|
||||
func setTextFrom(announcement: Announcement, content: NSAttributedString) {
|
||||
self.announcement = announcement
|
||||
self.attributedText = content
|
||||
setEmojis(announcement.emojis, identifier: announcement.id)
|
||||
}
|
||||
|
||||
override func getMention(for url: URL, text: String) -> Mention? {
|
||||
announcement?.mentions.first {
|
||||
URL($0.url) == url
|
||||
}.map {
|
||||
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
|
||||
}
|
||||
}
|
||||
|
||||
override func getHashtag(for url: URL, text: String) -> Hashtag? {
|
||||
announcement?.tags.first {
|
||||
URL($0.url) == url
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,246 @@
|
|||
//
|
||||
// AnnouncementListRow.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
import WebURLFoundationExtras
|
||||
|
||||
struct AnnouncementListRow: View {
|
||||
@Binding var announcement: Announcement
|
||||
let mastodonController: MastodonController
|
||||
let navigationDelegate: TuskerNavigationDelegate?
|
||||
let removeAnnouncement: @MainActor () -> Void
|
||||
@State private var contentTextViewHeight: CGFloat?
|
||||
@State private var isShowingAddReactionSheet = false
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
mostOfTheBody
|
||||
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
|
||||
dimension[.leading]
|
||||
})
|
||||
} else {
|
||||
mostOfTheBody
|
||||
}
|
||||
}
|
||||
|
||||
private var mostOfTheBody: some View {
|
||||
VStack {
|
||||
HStack(alignment: .top) {
|
||||
AnnouncementContentTextViewRepresentable(announcement: announcement, navigationDelegate: navigationDelegate) { newHeight in
|
||||
DispatchQueue.main.async {
|
||||
contentTextViewHeight = newHeight
|
||||
}
|
||||
}
|
||||
.frame(height: contentTextViewHeight)
|
||||
|
||||
Text(announcement.publishedAt, format: .abbreviatedTimeAgo)
|
||||
.fontWeight(.light)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
ScrollView(.horizontal) {
|
||||
LazyHStack {
|
||||
Button {
|
||||
isShowingAddReactionSheet = true
|
||||
} label: {
|
||||
Label {
|
||||
Text("Add Reaction")
|
||||
} icon: {
|
||||
if #available(iOS 16.0, *) {
|
||||
Image("face.smiling.badge.plus")
|
||||
} else {
|
||||
Image(systemName: "face.smiling")
|
||||
}
|
||||
}
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.padding(4)
|
||||
.hoverEffect()
|
||||
|
||||
ForEach($announcement.reactions, id: \.name) { $reaction in
|
||||
ReactionButton(announcement: announcement, reaction: $reaction, mastodonController: mastodonController)
|
||||
}
|
||||
}
|
||||
.frame(height: 32)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await dismissAnnouncement()
|
||||
}
|
||||
} label: {
|
||||
Text("Dismiss")
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
await dismissAnnouncement()
|
||||
await removeAnnouncement()
|
||||
}
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark")
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isShowingAddReactionSheet) {
|
||||
AddReactionView(mastodonController: mastodonController, addReaction: self.addReaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissAnnouncement() async {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.dismiss(id: announcement.id))
|
||||
} catch {
|
||||
Logging.general.error("Error dismissing attachment: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func addReaction(_ reaction: AddReactionView.Reaction) async throws {
|
||||
let name = switch reaction {
|
||||
case .emoji(let s): s
|
||||
case .custom(let emoji): emoji.shortcode
|
||||
}
|
||||
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: name))
|
||||
for (idx, reaction) in announcement.reactions.enumerated() {
|
||||
if reaction.name == name {
|
||||
announcement.reactions[idx].me = true
|
||||
announcement.reactions[idx].count += 1
|
||||
return
|
||||
}
|
||||
}
|
||||
let url: URL?
|
||||
let staticURL: URL?
|
||||
if case .custom(let emoji) = reaction {
|
||||
url = URL(emoji.url)
|
||||
staticURL = URL(emoji.staticURL)
|
||||
} else {
|
||||
url = nil
|
||||
staticURL = nil
|
||||
}
|
||||
announcement.reactions.append(.init(name: name, count: 1, me: true, url: url, staticURL: staticURL))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnnouncementContentTextViewRepresentable: UIViewRepresentable {
|
||||
let announcement: Announcement
|
||||
let navigationDelegate: TuskerNavigationDelegate?
|
||||
let heightChanged: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> AnnouncementContentTextView {
|
||||
let view = AnnouncementContentTextView()
|
||||
view.isScrollEnabled = true
|
||||
view.backgroundColor = .clear
|
||||
view.isEditable = false
|
||||
view.isSelectable = false
|
||||
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: AnnouncementContentTextView, context: Context) {
|
||||
uiView.navigationDelegate = navigationDelegate
|
||||
uiView.setTextFrom(announcement: announcement, content: TimelineStatusCollectionViewCell.htmlConverter.convert(announcement.content))
|
||||
uiView.heightChanged = heightChanged
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReactionButton: View {
|
||||
let announcement: Announcement
|
||||
@Binding var reaction: Announcement.Reaction
|
||||
let mastodonController: MastodonController
|
||||
@State private var customEmojiImage: (Image, CGFloat)?
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.toggleReaction) {
|
||||
let countStr = reaction.count.formatted(.number)
|
||||
let title = if reaction.name.count == 1 {
|
||||
"\(reaction.name) \(countStr)"
|
||||
} else {
|
||||
countStr
|
||||
}
|
||||
if reaction.url != nil {
|
||||
Label {
|
||||
Text(title)
|
||||
} icon: {
|
||||
if let (image, aspectRatio) = customEmojiImage {
|
||||
image.aspectRatio(aspectRatio, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
.buttonStyle(TintedButtonStyle(highlighted: reaction.me == true))
|
||||
.font(.body.monospacedDigit())
|
||||
.hoverEffect()
|
||||
.task {
|
||||
if let url = reaction.url,
|
||||
let image = await ImageCache.emojis.get(url).1 {
|
||||
let aspectRatio = image.size.width / image.size.height
|
||||
customEmojiImage = (
|
||||
Image(uiImage: image).resizable(),
|
||||
aspectRatio
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleReaction() {
|
||||
if reaction.me == true {
|
||||
let oldCount = reaction.count
|
||||
reaction.me = false
|
||||
reaction.count -= 1
|
||||
Task {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.unreact(id: announcement.id, name: reaction.name))
|
||||
} catch {
|
||||
reaction.me = true
|
||||
reaction.count = oldCount
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let oldCount = reaction.count
|
||||
reaction.me = true
|
||||
reaction.count += 1
|
||||
Task {
|
||||
do {
|
||||
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: reaction.name))
|
||||
} catch {
|
||||
reaction.me = false
|
||||
reaction.count = oldCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TintedButtonStyle: ButtonStyle {
|
||||
let highlighted: Bool
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(highlighted ? AnyShapeStyle(.white) : AnyShapeStyle(.tint))
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 32)
|
||||
.background(.tint.opacity(highlighted ? 1 : 0.2), in: RoundedRectangle(cornerRadius: 4))
|
||||
.opacity(configuration.isPressed ? 0.8 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AnnouncementListRow()
|
||||
//}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// AnnouncementsCollection.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
class AnnouncementsCollection: ObservableObject {
|
||||
@Published var announcements: [Announcement]
|
||||
|
||||
init(announcements: [Announcement]) {
|
||||
self.announcements = announcements
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// AnnouncementsHostingController.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
class AnnouncementsHostingController: UIHostingController<AnnouncementsView> {
|
||||
private let mastodonController: MastodonController
|
||||
|
||||
init(announcements: AnnouncementsCollection, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
|
||||
@Box var boxedSelf: TuskerNavigationDelegate?
|
||||
super.init(rootView: AnnouncementsView(announcements: announcements, mastodonController: mastodonController, navigationDelegate: _boxedSelf))
|
||||
boxedSelf = self
|
||||
|
||||
navigationItem.title = "Announcements"
|
||||
}
|
||||
|
||||
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
extension AnnouncementsHostingController: TuskerNavigationDelegate {
|
||||
nonisolated var apiController: MastodonController! { mastodonController }
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// AnnouncementsView.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/17/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Pachyderm
|
||||
|
||||
struct AnnouncementsView: View {
|
||||
@ObservedObject var state: AnnouncementsCollection
|
||||
let mastodonController: MastodonController
|
||||
@Box var navigationDelegate: TuskerNavigationDelegate?
|
||||
|
||||
init(announcements: AnnouncementsCollection, mastodonController: MastodonController, navigationDelegate: Box<TuskerNavigationDelegate?>) {
|
||||
self.state = announcements
|
||||
self.mastodonController = mastodonController
|
||||
self._navigationDelegate = navigationDelegate
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach($state.announcements) { $announcement in
|
||||
AnnouncementListRow(announcement: $announcement, mastodonController: mastodonController, navigationDelegate: navigationDelegate) {
|
||||
withAnimation {
|
||||
state.announcements.removeAll(where: { $0.id == announcement.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped)
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// AnnouncementsView()
|
||||
//}
|
|
@ -222,7 +222,7 @@ class ConversationViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
if isLikelyMastodonRemoteStatus(url: url),
|
||||
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
|
||||
let (_, response) = try? await URLSession.appDefault.data(from: url, delegate: RedirectBlocker()),
|
||||
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
|
||||
effectiveURL = location
|
||||
} else {
|
||||
|
@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
|
|||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
do {
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
guard let status = results.statuses.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
|
|
|
@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController!
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
definesPresentationContext = true
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
|
|
|
@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
searchController.obscuresBackgroundDuringPresentation = true
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
definesPresentationContext = true
|
||||
|
|
|
@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
|
|||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
|
||||
let service = RemoveProfileSuggestionService(accountID: id, mastodonController: self.mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(id, .global)])
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
Task {
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
|||
private func loadTrendingStatuses() async {
|
||||
let statuses: [Status]
|
||||
do {
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
|
||||
} catch {
|
||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
await MainActor.run {
|
||||
|
|
|
@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
let linksReq = Client.getTrendingLinks(limit: 10)
|
||||
async let links = try? mastodonController.run(linksReq).0
|
||||
let statusesReq = Client.getTrendingStatuses(limit: 10)
|
||||
async let statuses = try? mastodonController.run(statusesReq).0
|
||||
async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
|
||||
|
||||
if let links = await links {
|
||||
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
||||
|
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
do {
|
||||
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
||||
|
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
@MainActor
|
||||
private func removeProfileSuggestion(accountID: String) async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
var snapshot = dataSource.snapshot()
|
||||
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(accountID, .global)])
|
||||
await apply(snapshot: snapshot)
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
_ = await self.removeProfileSuggestion(accountID: accountID)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ class ImageGalleryDataSource: GalleryDataSource {
|
|||
gifController: gifController
|
||||
)
|
||||
} else {
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
let (data, image) = await self.cache.get(self.url, loadOriginal: true)
|
||||
if let image {
|
||||
let gifController: GIFController? =
|
||||
|
|
|
@ -10,6 +10,7 @@ import UIKit
|
|||
import GalleryVC
|
||||
|
||||
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
|
||||
private let fallbackCaption: String?
|
||||
private let provider: () async -> (any GalleryContentViewController)?
|
||||
private var wrapped: (any GalleryContentViewController)!
|
||||
|
||||
|
@ -24,14 +25,15 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
|
|||
}
|
||||
|
||||
var caption: String? {
|
||||
wrapped?.caption
|
||||
wrapped?.caption ?? fallbackCaption
|
||||
}
|
||||
|
||||
var canAnimateFromSourceView: Bool {
|
||||
wrapped?.canAnimateFromSourceView ?? true
|
||||
}
|
||||
|
||||
init(provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
|
||||
self.fallbackCaption = caption
|
||||
self.provider = provider
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
|
|
@ -57,7 +57,8 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
|||
gifController: gifController
|
||||
)
|
||||
} else {
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: attachment.description) {
|
||||
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
|
||||
let (data, image) = await ImageCache.attachments.get(attachment.url, loadOriginal: true)
|
||||
if let image {
|
||||
let gifController: GIFController? =
|
||||
|
@ -95,9 +96,9 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
|
|||
// TODO: use separate content VC with audio visualization?
|
||||
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
|
||||
case .unknown:
|
||||
return LoadingGalleryContentViewController {
|
||||
return LoadingGalleryContentViewController(caption: nil) {
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: attachment.url)
|
||||
let (data, _) = try await URLSession.appDefault.data(from: attachment.url)
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
|
||||
try data.write(to: url)
|
||||
return FallbackGalleryNavigationController(url: url)
|
||||
|
|
|
@ -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,17 +101,34 @@ 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")
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let timestampObserverToken {
|
||||
player.removeTimeObserver(timestampObserverToken)
|
||||
}
|
||||
if let scrubberObserverToken {
|
||||
player.removeTimeObserver(scrubberObserverToken)
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
@ -170,7 +198,11 @@ class VideoControlsViewController: UIViewController {
|
|||
@objc private func scrubbingEnded() {
|
||||
scrubbingChanged()
|
||||
if wasPlayingWhenScrubbingStarted {
|
||||
#if os(visionOS)
|
||||
player.play()
|
||||
#else
|
||||
player.rate = playbackSpeed
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -233,10 +265,8 @@ private class VideoScrubbingControl: UIControl {
|
|||
|
||||
private func updateFillLayerMask() {
|
||||
// I don't know where this animation is coming from
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
|
||||
CATransaction.commit()
|
||||
fillMaskLayer.removeAllAnimations()
|
||||
}
|
||||
|
||||
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
let mastodonController: MastodonController
|
||||
private let predicate: (StatusMO) -> Bool
|
||||
private let predicateTitle: String
|
||||
private let request: (RequestRange) -> Request<[Status]>
|
||||
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
|
||||
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
|
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
self.predicate = predicate
|
||||
self.predicateTitle = predicateTitle
|
||||
|
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(.count(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
newer = pagination?.newer
|
||||
older = pagination?.older
|
||||
|
||||
|
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(older.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.older = pagination?.older
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
Task {
|
||||
do {
|
||||
let req = request(newer.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.newer = pagination?.newer
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
|
|
@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
private(set) var currentAccountID: String
|
||||
private(set) var root: AccountSwitchableViewController
|
||||
|
||||
private var userActivities: [String: NSUserActivity] = [:]
|
||||
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
|
||||
|
||||
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
||||
self.currentAccountID = account.id
|
||||
|
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
embedChild(root)
|
||||
}
|
||||
|
||||
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
viewControllers = viewControllers.mapValues { (_, activity) in
|
||||
(nil, activity)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAccount(_ account: UserAccountInfo) {
|
||||
viewControllers.removeValue(forKey: account.id)
|
||||
}
|
||||
|
||||
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
let oldRoot = self.root
|
||||
if direction == .none {
|
||||
oldRoot.removeViewAndController()
|
||||
}
|
||||
if let activity = oldRoot.stateRestorationActivity() {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
|
||||
userActivities[currentAccountID] = activity
|
||||
viewControllers[currentAccountID] = (oldRoot, activity)
|
||||
}
|
||||
|
||||
let newRoot: AccountSwitchableViewController
|
||||
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
|
||||
if let existingRoot {
|
||||
newRoot = existingRoot
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
Task(priority: .userInitiated) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
}
|
||||
|
||||
self.currentAccountID = account.id
|
||||
self.root = newRoot
|
||||
embedChild(newRoot)
|
||||
|
||||
if let activity = userActivities.removeValue(forKey: account.id) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
|
||||
if direction != .none {
|
||||
if UIAccessibility.prefersCrossFadeTransitions {
|
||||
newRoot.view.alpha = 0
|
||||
|
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
#endif
|
||||
|
||||
// only one edge is affected in each direction, i have no idea why
|
||||
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
|
||||
if direction == .upwards {
|
||||
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
|
||||
} else {
|
||||
|
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
|
||||
newRoot.view.transform = .identity
|
||||
} completion: { (_) in
|
||||
oldRoot.view.transform = .identity
|
||||
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
|
||||
oldRoot.removeViewAndController()
|
||||
newRoot.view.layer.masksToBounds = false
|
||||
}
|
||||
|
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
|||
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
loadViewIfNeeded()
|
||||
root.select(route: route, animated: animated)
|
||||
root.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
|
@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
|
|||
(child as! TuskerRootViewController).getNavigationController()
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue