Compare commits

...

92 Commits

Author SHA1 Message Date
Shadowfacts 8557e110a8 Bump build number and update changelog 2024-06-08 13:55:32 -07:00
Shadowfacts c2232a5e14 Don't fail decoding when one status fails to decode
Also remove old workaround for bad dates from #477

Closes #478
2024-06-08 13:29:56 -07:00
Shadowfacts e6d9a33dbf Actually don't purge old persistent history 2024-06-08 13:18:23 -07:00
Shadowfacts d8fccc8f1b Purge old persistent history after processing
Closes #480
2024-06-08 12:23:12 -07:00
Shadowfacts 6528070f1c Save persistent history tokens across launches
See #480
2024-06-08 12:18:22 -07:00
Shadowfacts 09c6a87e19 Fix switching sidebar sections with keyboard shortcuts not saving old section's navigation stack 2024-06-08 11:30:16 -07:00
Shadowfacts cd0d8fffcb Fix conversation thread links appearing above avatar when lifted by pointer 2024-06-08 11:28:19 -07:00
Shadowfacts 1b6f0c07fd Add pointer effect to search token suggestions 2024-06-08 11:26:10 -07:00
Shadowfacts 2f31b50a5b Fix search results always pushing new column in multi-column nav
Closes #498
2024-06-08 11:21:05 -07:00
Shadowfacts cee4e15b06 Fix not being able to select text by double clicking with cursor on iPad
Also fix not being able to single-tap data detector value to see menu

Closes #499
2024-06-08 11:01:07 -07:00
Shadowfacts 888f44366c Fix multi-column nav not animating scroll position when replacing subsequent columns
Closes #500
2024-06-08 10:32:32 -07:00
Shadowfacts c88076eec0 Use text view for profile field value view
Fixes #501
2024-06-08 10:23:24 -07:00
Shadowfacts afe47437e4 Disallow blocking your own domain 2024-06-02 11:41:50 -07:00
Shadowfacts 4dc484c3c2 Fix follow button never activating on Pixelfed
Caused by not being able to decode Relationship due to missing fields.
Also disable actions that are unsupported on Pixelfed.

Closes #481
2024-06-02 11:40:42 -07:00
Shadowfacts 0f2a85b108 Fix crash when opening push notification while VC modally presented
The dismissal of the modally presented VC turns the route change into an
asynchronous operation, even when not animated.

Closes #484
2024-06-02 11:25:49 -07:00
Shadowfacts 5e55ce75c2 Fix previous sidebar selection losing navigation stack in some circumstances 2024-06-02 10:33:25 -07:00
Shadowfacts eec2adbfd9 Set target content identifiers on scenes/activities 2024-06-02 10:10:16 -07:00
Shadowfacts a848f6e425 Fix error on Pixelfed/Firefish due to missing followers/following counts
Closes #483
2024-06-02 09:44:20 -07:00
Shadowfacts 44896d305e Add pointer interaction to profile followers/following buttons
Closes #497
2024-06-02 09:42:54 -07:00
Shadowfacts 6c70ed4b4e Fix crash in MultiColumnNavController due to closing already-removed VC
Not sure how this is possible, but there was a report of it

Closes #485
2024-06-02 09:41:22 -07:00
Shadowfacts e3c480131a Fix gallery dismiss transition from sheet-presented VC
Closes #490
2024-06-01 11:22:19 -07:00
Shadowfacts 575166f5b4 Fix Cmd+1/etc. resetting navigation stacks
Closes #491
2024-06-01 10:56:55 -07:00
Shadowfacts c60aa3e3f3 Fix close buttons unnecessarily being added to navigation column 2024-06-01 10:56:31 -07:00
Shadowfacts 75f0d12c82 Fix incorrect pointer actions on conversation main status
Closes #493
2024-06-01 10:47:56 -07:00
Shadowfacts 5cf2bc4fbf Fix profile header images being blurry
Due to the old method using ImageCache.avatars for the headers 🤦

Closes #494
2024-06-01 10:44:49 -07:00
Shadowfacts 908b499f8f Fix Remove Suggestion action missing from Suggested Accounts screen
Closes #495
2024-06-01 10:40:30 -07:00
Shadowfacts 67c7905acf Fix missing VC callbacks in removeViewAndController 2024-06-01 10:29:33 -07:00
Shadowfacts eacafe87b3 Fix logout from current resulting in black screen after switching to reused VC
Closes #489
2024-06-01 10:28:46 -07:00
Shadowfacts 2a53b24487 Merge branch 'public-beta' into develop 2024-05-29 22:42:43 -07:00
Shadowfacts 9df3c33c6c Bump build number and update changelog 2024-05-29 22:37:53 -07:00
Shadowfacts d4e82d6e7a Fix AVPlayer periodic time observers not being removed 2024-05-29 22:35:45 -07:00
Shadowfacts 06ba758309 Merge branch 'public-beta' into develop 2024-05-29 22:30:48 -07:00
Shadowfacts 2c56902389 Remove old account UI state when logging out 2024-05-29 22:23:09 -07:00
Shadowfacts cb3fd43dbd Fix video thubmnail being flipped in Compose
Closes #487
2024-05-29 22:03:53 -07:00
Shadowfacts 3d15759fb9 Don't constantly commit CA transactions when scrubbing video
Closes #488
2024-05-29 21:56:18 -07:00
Shadowfacts 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -07:00
Shadowfacts 09999175f7 Fix editing attachment descriptions not working on Pleroma 2024-05-27 22:29:11 -07:00
Shadowfacts f2a9f890ff Use development URLSession in more places 2024-05-27 22:14:28 -07:00
Shadowfacts 093994b474 More push subscription logging 2024-05-27 13:33:00 -07:00
Shadowfacts 3d0de5af04 Persist more state when switching accounts
Closes #486
2024-05-24 14:03:51 -04:00
Shadowfacts 966a906436 Fix AVPlayer periodic time observers not being removed 2024-05-23 14:29:56 -04:00
Shadowfacts 844d4056e3 Bump version and update changelog 2024-05-23 14:25:39 -04:00
Shadowfacts 00ef131bb6 Update HTMLStreamer 2024-05-23 14:12:35 -04:00
Shadowfacts d6be6f14dc Hide subscription section from tip jar when there are no products 2024-05-23 14:11:54 -04:00
Shadowfacts 2ccf028bc2 Bump build number and update changelog 2024-05-20 14:28:25 -04:00
Shadowfacts 3eeffada1f Add tip jar link to push notifications settings 2024-05-20 12:49:26 -04:00
Shadowfacts 0499255be7 Add tip jar subscription 2024-05-20 12:49:20 -04:00
Shadowfacts f909c1da10 Fix selecting follow request push notification
Closes #474
2024-05-19 15:14:03 -04:00
Shadowfacts 81543965ae Fix notification extension not building on visionOS 2024-05-19 15:00:47 -04:00
Shadowfacts 96d42756d5 Fix caption not displaying in gallery while image loading
Closes #476
2024-05-19 15:00:25 -04:00
Shadowfacts f6e57d664f Handle invalid date in Status created_at
Closes #477
2024-05-19 14:48:57 -04:00
Shadowfacts c33be1cbf3 Bump build number 2024-05-17 11:26:57 -04:00
Shadowfacts 6d99156bd9 Include badly formatted date in error message 2024-05-10 16:33:03 -04:00
Shadowfacts ca764811ed Bump build number and update changelog 2024-04-23 13:19:52 -04:00
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
Shadowfacts b89df3f27b Add instance announcements
Closes #356
2024-04-18 00:00:00 -04:00
Shadowfacts 4ecc16a93b Move FuzzyMatcher to TuskerComponents 2024-04-17 22:34:31 -04:00
Shadowfacts 8960873ff3 Remove redundant toastableViewController property 2024-04-17 22:34:31 -04:00
Shadowfacts 043a708515 Add more logging to onboarding VC 2024-04-17 17:04:32 -04:00
Shadowfacts c6b230414e Fix error decoding InstanceV2 response on certain instances 2024-04-17 10:18:01 -04:00
Shadowfacts f5e9f66f76 Fix app background colors not updating when preference changed
This only fully fixes it on iOS 17, but it seems to be the best we can do
2024-04-16 12:03:52 -04:00
Shadowfacts ee5f9a62ff Fix push subscription settings GroupBox background in dark mode
Closes #470
2024-04-16 11:37:36 -04:00
Shadowfacts a92cf8c812 Fix potential crash when hit testing StatusCollapseButton 2024-04-15 22:50:31 -04:00
Shadowfacts 756874949a Actually add notification extension privacy manifest to target 2024-04-15 22:42:18 -04:00
Shadowfacts 798e0c0cf1 Bump build number and update changelog 2024-04-15 22:40:54 -04:00
Shadowfacts 3f370945e6 Fix linker errors when building in release mode 2024-04-15 22:30:20 -04:00
Shadowfacts a759731eba Fix push notifications not working when account ID contains slashes 2024-04-15 22:19:24 -04:00
Shadowfacts 405d5def7c Disable non-stack navigation on Max iPhones 2024-04-15 11:33:52 -04:00
Shadowfacts 1f9806d02f Fix preferences post preview background on macOS 2024-04-15 11:04:33 -04:00
Shadowfacts c43c951b92 Enable iPad multi-column navigation by default 2024-04-15 11:00:36 -04:00
Shadowfacts 00c44c612f Fix feature flag preference decoding with old flags 2024-04-15 10:55:43 -04:00
Shadowfacts e5c4fceacd Add CustomCodablePreferenceKey 2024-04-15 10:50:08 -04:00
Shadowfacts 70227a7fa1 Add MigratablePreferenceKey protocol 2024-04-15 10:37:02 -04:00
Shadowfacts cb5488dcaa Reorganize preference keys to match Preferences 2024-04-15 09:50:49 -04:00
Shadowfacts 910e18fb5e Fix compiling for visionOS 2024-04-15 09:49:42 -04:00
Shadowfacts 66af946766 Use uniform deployment targets from project settings 2024-04-15 09:41:53 -04:00
Shadowfacts 6784ed7fdf Remove in-app Safari settings on macOS
Closes #469
2024-04-15 09:34:44 -04:00
Shadowfacts 66f0ba6891 Add icons for Preferences sections 2024-04-15 00:13:04 -04:00
Shadowfacts ee7bf5138c Tweak iCloud status appearance in advanced prefs 2024-04-15 00:13:04 -04:00
Shadowfacts c32181818a Use image for code formatting option 2024-04-15 00:13:04 -04:00
Shadowfacts 4665df228d More preferences reorganizing 2024-04-15 00:13:04 -04:00
Shadowfacts c7a56a9f61 Reorganize appearance prefs, add mock status preview 2024-04-14 14:11:43 -04:00
Shadowfacts 39251b9aa2 Fix TuskerTests not compiling 2024-04-14 13:37:10 -04:00
Shadowfacts db534e5993 Fix About screen link labels not being aligned 2024-04-13 23:19:28 -04:00
Shadowfacts e94bee4fc8 Fix a handful of strict concurrency warnings 2024-04-13 23:06:30 -04:00
Shadowfacts 216e58e5ec Merge branch 'prefs-refactor' into develop 2024-04-13 22:39:49 -04:00
Shadowfacts a4d13ad03b Only migrate changed preferences 2024-04-13 22:36:42 -04:00
Shadowfacts 05cfecb797 Fix push notifications on Pleroma/Akkoma and older Mastodon versions 2024-04-13 18:59:42 -04:00
Shadowfacts 132fcfa099 Refactor preferences 2024-04-13 18:44:43 -04:00
Shadowfacts 475b9911b1 Add privacy manifest to notification extension 2024-04-13 11:11:26 -04:00
162 changed files with 4715 additions and 1400 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@
import SwiftUI
import Pachyderm
import Combine
import TuskerComponents
class AutocompleteEmojisController: ViewController {
unowned let composeController: ComposeController

View File

@ -8,6 +8,7 @@
import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
class AutocompleteHashtagsController: ViewController {
unowned let composeController: ComposeController

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,106 @@
//
// PreferenceStore+Migrate.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import UIKit
extension PreferenceStore {
func migrate(from legacy: LegacyPreferences) {
var migrations: [any MigrationProtocol] = [
Migration(from: \.theme.theme, to: \.$theme),
Migration(from: \.pureBlackDarkMode, to: \.$pureBlackDarkMode),
Migration(from: \.accentColor, to: \.$accentColor),
Migration(from: \.avatarStyle, to: \.$avatarStyle),
Migration(from: \.hideCustomEmojiInUsernames, to: \.$hideCustomEmojiInUsernames),
Migration(from: \.showIsStatusReplyIcon, to: \.$showIsStatusReplyIcon),
Migration(from: \.alwaysShowStatusVisibilityIcon, to: \.$alwaysShowStatusVisibilityIcon),
Migration(from: \.hideActionsInTimeline, to: \.$hideActionsInTimeline),
Migration(from: \.showLinkPreviews, to: \.$showLinkPreviews),
Migration(from: \.leadingStatusSwipeActions, to: \.$leadingStatusSwipeActions),
Migration(from: \.trailingStatusSwipeActions, to: \.$trailingStatusSwipeActions),
Migration(from: \.widescreenNavigationMode, to: \.$widescreenNavigationMode),
Migration(from: \.underlineTextLinks, to: \.$underlineTextLinks),
Migration(from: \.showAttachmentsInTimeline, to: \.$showAttachmentsInTimeline),
Migration(from: \.defaultPostVisibility, to: \.$defaultPostVisibility),
Migration(from: \.defaultReplyVisibility, to: \.$defaultReplyVisibility),
Migration(from: \.requireAttachmentDescriptions, to: \.$requireAttachmentDescriptions),
Migration(from: \.contentWarningCopyMode, to: \.$contentWarningCopyMode),
Migration(from: \.mentionReblogger, to: \.$mentionReblogger),
Migration(from: \.useTwitterKeyboard, to: \.$useTwitterKeyboard),
Migration(from: \.attachmentBlurMode, to: \.$attachmentBlurMode),
Migration(from: \.blurMediaBehindContentWarning, to: \.$blurMediaBehindContentWarning),
Migration(from: \.automaticallyPlayGifs, to: \.$automaticallyPlayGifs),
Migration(from: \.showUncroppedMediaInline, to: \.$showUncroppedMediaInline),
Migration(from: \.showAttachmentBadges, to: \.$showAttachmentBadges),
Migration(from: \.attachmentAltBadgeInverted, to: \.$attachmentAltBadgeInverted),
Migration(from: \.openLinksInApps, to: \.$openLinksInApps),
Migration(from: \.expandAllContentWarnings, to: \.$expandAllContentWarnings),
Migration(from: \.collapseLongPosts, to: \.$collapseLongPosts),
Migration(from: \.oppositeCollapseKeywords, to: \.$oppositeCollapseKeywords),
Migration(from: \.confirmBeforeReblog, to: \.$confirmBeforeReblog),
Migration(from: \.timelineStateRestoration, to: \.$timelineStateRestoration),
Migration(from: \.timelineSyncMode, to: \.$timelineSyncMode),
Migration(from: \.hideReblogsInTimelines, to: \.$hideReblogsInTimelines),
Migration(from: \.hideRepliesInTimelines, to: \.$hideRepliesInTimelines),
Migration(from: \.showFavoriteAndReblogCounts, to: \.$showFavoriteAndReblogCounts),
Migration(from: \.defaultNotificationsMode, to: \.$defaultNotificationsMode),
Migration(from: \.grayscaleImages, to: \.$grayscaleImages),
Migration(from: \.disableInfiniteScrolling, to: \.$disableInfiniteScrolling),
Migration(from: \.hideTrends, to: \.$hideTrends),
Migration(from: \.statusContentType, to: \.$statusContentType),
Migration(from: \.reportErrorsAutomatically, to: \.$reportErrorsAutomatically),
Migration(from: \.enabledFeatureFlags, to: \.$enabledFeatureFlags),
Migration(from: \.hasShownLocalTimelineDescription, to: \.$hasShownLocalTimelineDescription),
Migration(from: \.hasShownFederatedTimelineDescription, to: \.$hasShownFederatedTimelineDescription),
]
#if !targetEnvironment(macCatalyst) && !os(visionOS)
migrations.append(contentsOf: [
Migration(from: \.useInAppSafari, to: \.$useInAppSafari),
Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode),
] as [any MigrationProtocol])
#endif
for migration in migrations {
migration.migrate(from: legacy, to: self)
}
}
}
private protocol MigrationProtocol {
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore)
}
private struct Migration<Key: MigratablePreferenceKey>: MigrationProtocol where Key.Value: Equatable {
let from: KeyPath<LegacyPreferences, Key.Value>
let to: KeyPath<PreferenceStore, PreferencePublisher<Key>>
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) {
let value = legacy[keyPath: from]
if Key.shouldMigrate(oldValue: value) {
Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value)
}
}
}
private extension UIUserInterfaceStyle {
var theme: Theme {
switch self {
case .light:
.light
case .dark:
.dark
default:
.unspecified
}
}
}

View File

@ -0,0 +1,101 @@
//
// Preference.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/13/24.
//
import Foundation
import Combine
// TODO: once we target iOS 17, use Observable for this
@propertyWrapper
final class Preference<Key: PreferenceKey>: Codable {
@Published private(set) var storedValue: Key.Value?
var wrappedValue: Key.Value {
get {
storedValue ?? Key.defaultValue
}
set {
fatalError("unreachable")
}
}
init() {
self.storedValue = nil
}
init(from decoder: any Decoder) throws {
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
self.storedValue = try keyType.decode(from: decoder) as! Key.Value?
} else if let container = try? decoder.singleValueContainer() {
self.storedValue = try? container.decode(Key.Value.self)
}
}
func encode(to encoder: any Encoder) throws {
if let storedValue {
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
func encode<K: CustomCodablePreferenceKey>(_: K.Type) throws {
try K.encode(value: storedValue as! K.Value, to: encoder)
}
return try _openExistential(keyType, do: encode)
} else {
var container = encoder.singleValueContainer()
try container.encode(storedValue)
}
}
}
static subscript(
_enclosingInstance instance: PreferenceStore,
wrapped wrappedKeyPath: ReferenceWritableKeyPath<PreferenceStore, Key.Value>,
storage storageKeyPath: ReferenceWritableKeyPath<PreferenceStore, Preference>
) -> Key.Value {
get {
get(enclosingInstance: instance, storage: storageKeyPath)
}
set {
set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue)
Key.didSet(in: instance, newValue: newValue)
}
}
// for testing only
@inline(__always)
static func get<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>
) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
let pref = enclosingInstance[keyPath: storage]
return pref.storedValue ?? Key.defaultValue
}
// for testing only
@inline(__always)
static func set<Enclosing>(
enclosingInstance: Enclosing,
storage: KeyPath<Enclosing, Preference>,
newValue: Key.Value
) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
enclosingInstance.objectWillChange.send()
let pref = enclosingInstance[keyPath: storage]
pref.storedValue = newValue
}
var projectedValue: PreferencePublisher<Key> {
.init(preference: self)
}
}
public struct PreferencePublisher<Key: PreferenceKey>: Publisher {
public typealias Output = Key.Value
public typealias Failure = Never
let preference: Preference<Key>
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
}
}

View File

@ -0,0 +1,35 @@
//
// PreferenceKey.swift
// TuskerPreferences
//
// Created by Shadowfacts on 4/12/24.
//
import Foundation
public protocol PreferenceKey {
associatedtype Value: Codable
static var defaultValue: Value { get }
static func didSet(in store: PreferenceStore, newValue: Value)
}
extension PreferenceKey {
public static func didSet(in store: PreferenceStore, newValue: Value) {}
}
protocol MigratablePreferenceKey: PreferenceKey where Value: Equatable {
static func shouldMigrate(oldValue: Value) -> Bool
}
extension MigratablePreferenceKey {
static func shouldMigrate(oldValue: Value) -> Bool {
oldValue != defaultValue
}
}
protocol CustomCodablePreferenceKey: PreferenceKey {
static func encode(value: Value, to encoder: any Encoder) throws
static func decode(from decoder: any Decoder) throws -> Value?
}

View File

@ -0,0 +1,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
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
case serverDefault
case 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 {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,128 @@
//
// PreferenceStoreTests.swift
// TuskerPreferencesTests
//
// Created by Shadowfacts on 4/12/24.
//
import XCTest
@testable import TuskerPreferences
import Combine
final class PreferenceStoreTests: XCTestCase {
struct TestKey: PreferenceKey {
static let defaultValue = false
}
final class TestStore<Key: PreferenceKey>: Codable, ObservableObject {
private var _test = Preference<Key>()
// the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually
var test: Key.Value {
get {
Preference.get(enclosingInstance: self, storage: \._test)
}
set {
Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue)
}
}
var testPublisher: some Publisher<Key.Value, Never> {
_test.projectedValue
}
init() {
}
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self._test = try container.decode(Preference<Key>.self, forKey: .test)
}
enum CodingKeys: CodingKey {
case test
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self._test, forKey: .test)
}
}
func testDecoding() throws {
let decoder = JSONDecoder()
let present = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
{"test": true}
""".utf8)).wrapped
XCTAssertEqual(present.test, true)
let absent = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
{}
""".utf8)).wrapped
XCTAssertEqual(absent.test, false)
}
func testEncoding() throws {
let store = TestStore<TestKey>()
let encoder = JSONEncoder()
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
{}
""")
store.test = true
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
{"test":true}
""")
store.test = false
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
{"test":false}
""")
}
func testPublisher() {
let topLevel = expectation(description: "top level publisher")
let specificPref = expectation(description: "preference publisher")
// initial and on change
specificPref.expectedFulfillmentCount = 2
let store = TestStore<TestKey>()
var cancellables = Set<AnyCancellable>()
store.objectWillChange.sink {
topLevel.fulfill()
// fires on will change
XCTAssertEqual(store.test, false)
}.store(in: &cancellables)
store.testPublisher.sink { _ in
specificPref.fulfill()
}.store(in: &cancellables)
store.test = true
wait(for: [topLevel, specificPref])
}
func testCustomCodable() throws {
struct Key: CustomCodablePreferenceKey {
static let defaultValue = 1
static func encode(value: Int, to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(2)
}
static func decode(from decoder: any Decoder) throws -> Int? {
3
}
}
let store = TestStore<Key>()
store.test = 123
let encoder = JSONEncoder()
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
{"test":2}
""")
let decoder = JSONDecoder()
let present = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
{"test":2}
""".utf8)).wrapped
XCTAssertEqual(present.test, 3)
let absent = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
{}
""".utf8)).wrapped
XCTAssertEqual(absent.test, 1)
}
}

View File

@ -8,7 +8,8 @@
import Foundation
import 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()

View File

@ -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" */ = {

View File

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

View File

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

View File

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

View File

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

View File

@ -54,21 +54,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
#if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
}
}
// make sure the persistent container is initialized on the main thread
// 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()
}
}

View File

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "face.smiling.badge.plus.svg",
"idiom" : "universal"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
//}

View File

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

View File

@ -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()
//}

View File

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

View File

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

View File

@ -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()
//}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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? =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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? {

View File

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