Compare commits

...

138 Commits

Author SHA1 Message Date
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
Shadowfacts 7825ccbb3d Bump version and update changelog 2024-04-13 11:09:26 -04:00
Shadowfacts f87da10a29 Deep link to iOS Settings from Notifications prefs 2024-04-12 22:54:17 -04:00
Shadowfacts 1eec70449d Show notification when push notification banner tapped 2024-04-12 22:47:11 -04:00
Shadowfacts 19ca930ee8 Remove the need to register with the push proxy 2024-04-12 16:15:52 -04:00
Shadowfacts 2e31d34e9d Maybe fix continuation being reused 2024-04-11 22:30:43 -04:00
Shadowfacts 8a339ec171 Reregister client when adding push scope 2024-04-11 22:19:29 -04:00
Shadowfacts c7d79422bd Fix clean build failures 2024-04-11 21:48:41 -04:00
Shadowfacts baf96a8b06 Support settings -> app notification preferences link 2024-04-11 18:26:58 -04:00
Shadowfacts bc516a6326 Remove push proxy scheme build setting 2024-04-11 13:00:39 -04:00
Shadowfacts 1cd6af1236 Remove existing push subscriptions when unregistering from proxy 2024-04-11 12:58:43 -04:00
Shadowfacts 9f6910ba73 Implement communication notifications 2024-04-11 12:44:41 -04:00
Shadowfacts 9cf4975bfd Remove transaction ID from push notifications registration 2024-04-11 11:55:56 -04:00
Shadowfacts ee992bc0bf Improve per-instance push settings 2024-04-10 19:13:47 -04:00
Shadowfacts ff8a83ca2d Decrypt push notifications 2024-04-09 22:39:58 -04:00
Shadowfacts 4c957b86ae Fix push subscription policy/alerts not persisting 2024-04-09 21:07:14 -04:00
Shadowfacts ff11835333 Update oauth scopes when enabling push notifications
Closes #467
2024-04-09 19:05:31 -04:00
Shadowfacts 9353bbb56c Merge branch 'develop' into push-notifications 2024-04-09 18:43:53 -04:00
Shadowfacts edc887dd4c Rename PushManager properties 2024-04-09 12:38:24 -04:00
Shadowfacts 68dad77f81 Update Mastodon push subscriptions when endpoint changes 2024-04-09 12:38:24 -04:00
Shadowfacts 840b83012a Don't use Sentry in PushNotifications package 2024-04-09 11:56:22 -04:00
Shadowfacts e150856e91 Improve AsyncToggle behavior on failure 2024-04-09 11:49:55 -04:00
Shadowfacts 42a3f6c880 Use the right public key representation for push subscriptions 2024-04-09 11:48:53 -04:00
Shadowfacts 7a47b09b39 Remove push subscription when logging out of account 2024-04-08 22:50:39 -04:00
Shadowfacts 241e6f7e3a Notification type toggles 2024-04-08 22:32:46 -04:00
Shadowfacts f02afaac26 Move AsyncToggle to TuskerComponents 2024-04-08 22:32:46 -04:00
Shadowfacts bdd4a4d755 Scaffolding for push subscription alert types 2024-04-08 18:44:56 -04:00
Shadowfacts 94c1eb2c81 Create/remove instance push subscriptions 2024-04-08 12:25:39 -04:00
Shadowfacts b03991ae1d Move push notifications stuff to separate package 2024-04-08 10:48:28 -04:00
Shadowfacts f98589b419 Start account-specific push subscriptions 2024-04-07 23:14:12 -04:00
Shadowfacts 9fad2a882a More reliable registering/unregistering 2024-04-07 22:47:58 -04:00
Shadowfacts ec76754270 Bump build number and update changelog 2024-04-07 22:36:56 -04:00
Shadowfacts d0bb197e8c Correct button titles 2024-04-07 22:29:48 -04:00
Shadowfacts efd90bca3e Add Account Settings button to preferences 2024-04-07 22:28:30 -04:00
Shadowfacts 3efa017942 Push proxy registration 2024-04-07 14:04:42 -04:00
Shadowfacts c5226f6374 Add push scope 2024-04-06 11:04:03 -04:00
Shadowfacts 281585cdf0 Update release changelog 2024-04-04 23:13:02 -04:00
Shadowfacts 6d4ab4d54b Bump build number and update changelog 2024-04-04 18:32:13 -04:00
Shadowfacts 9e429463b2 Make the audio session work better
Closes #353
Closes #443
2024-04-04 17:31:16 -04:00
Shadowfacts 51db0066ac Bump build number and update changelog 2024-04-02 22:13:28 -04:00
Shadowfacts 9763edef47 Add See Results button to polls
Closes #445
2024-04-02 22:04:16 -04:00
Shadowfacts 442f57bfc4 Enable gallery interactive dismissal for statuses with >4 attachments
Closes #466
2024-04-02 21:21:39 -04:00
Shadowfacts ae7101bb30 Fix race between loading/animation when presenting gallery from attachment more view 2024-04-02 21:21:19 -04:00
Shadowfacts 490d48c635 Fix loading indicator never disappearing when presenting gallery from status with >4 attachments 2024-04-02 21:16:09 -04:00
Shadowfacts 69ee3bb4f0 Fix gallery content being incorrectly positioned on macOS when reduce motion is on
See #446
2024-04-02 21:01:54 -04:00
Shadowfacts 46b455c3d1 Fix crash when there are multiple follow notifications for the same account
Only reproducible on Pixelfed
2024-04-01 21:52:47 -04:00
Shadowfacts e522e30ce5 Bump build number and update changelog 2024-04-01 21:39:43 -04:00
Shadowfacts c73784aa81 Mark notifications on Mastodon web frontend as read once displayed
Fixes #357
2024-04-01 19:51:57 -04:00
Shadowfacts 7affa09e5e Remove timeline marker Sentry reporting
I'm 99% sure these timeouts are all due to bad network conditions
2024-04-01 19:43:14 -04:00
Shadowfacts 7435d02f6e Fiddle with how the timeline markers API is organized 2024-04-01 19:22:55 -04:00
Shadowfacts 2467297f04 Add preference for inverted alt text badge
Closes #423
2024-04-01 18:47:19 -04:00
Shadowfacts cf317e15e9 Designed for iPad: Possible fix for gallery content being positioned/sized incorrectly
See #446
2024-04-01 12:40:30 -04:00
Shadowfacts bcae60316b Fix changing list reply policy not reloading list timeline 2024-04-01 11:04:40 -04:00
Shadowfacts 1a2fa10708 Improve edit list account removal animation 2024-04-01 11:02:33 -04:00
Shadowfacts f79c2feea6 Fix edit list screen not updating after adding account 2024-04-01 11:02:15 -04:00
Shadowfacts 7ec87d7853 Add no content message to list timelines
Closes #215

Also fix interactive dismissal of edit screen not reloading list
2024-04-01 10:58:42 -04:00
Shadowfacts f5704e561b Support tapping selected sidebar item to scroll to top 2024-04-01 10:35:54 -04:00
Shadowfacts d6faf3a37b Fix fast account switching view not respecting safe area 2024-04-01 10:28:40 -04:00
Shadowfacts b0a6952643 Fix trending hashtags screen not clearing selection 2024-04-01 09:47:05 -04:00
Shadowfacts 06b58cfb9c Fix notifications screen not responding to tab bar/status bar scroll to top 2024-04-01 09:45:22 -04:00
Shadowfacts afcec24f86 Fix reference cycles in gallery 2024-03-31 23:29:28 -04:00
Shadowfacts 3f90a0df04 Fix gifvs preventing sleep 2024-03-31 23:20:55 -04:00
Shadowfacts 395ce6523d Fix follows account list using wrong separator insets 2024-03-31 23:14:54 -04:00
Shadowfacts cced930549 Bump build number and update changelog 2024-03-31 21:00:52 -04:00
Shadowfacts 7b2bd1a7af Apply grayscale attachments preference to videos in gallery 2024-03-31 20:56:20 -04:00
Shadowfacts f447150bbc Maybe improve grayscale gifv playback performance 2024-03-31 20:51:51 -04:00
Shadowfacts 08bd78d51b Fix changing greyscale images preference breaking gifvs looping 2024-03-31 20:51:33 -04:00
Shadowfacts f0ec372f50 Fix attachment blur view corners not being curved 2024-03-31 20:44:13 -04:00
Shadowfacts d2c28ada7f Improve gallery video autoplay behavior 2024-03-31 20:41:57 -04:00
Shadowfacts 375ad25919 Tweak gallery animation timing 2024-03-31 16:07:09 -04:00
Shadowfacts abf0568398 Improve gallery presentation/dismissal animation layering 2024-03-31 15:56:51 -04:00
Shadowfacts 2386f545e2 Fix gallery button delay on Catalyst/Designed for iPad 2024-03-31 15:44:15 -04:00
Shadowfacts 908c4ee085 Catalyst: Fix gallery close/share buttons 2024-03-31 14:12:07 -04:00
Shadowfacts 23e5e87915 Enable trackpad scrolling to dismiss gallery 2024-03-31 14:08:18 -04:00
Shadowfacts b4693252be Fix how we're getting the Sentry installation ID 2024-03-31 12:52:56 -04:00
Shadowfacts f3cf2dd8ec Catalyst: Fix gallery presentation animation not working 2024-03-31 12:35:10 -04:00
Shadowfacts d96ec2a732 Make gallery control buttons square 2024-03-30 15:21:23 -04:00
Shadowfacts b8fe0454b5 Inset gallery controls on devices without safe area insets 2024-03-30 15:18:17 -04:00
Shadowfacts 1166c6e639 Fix gallery transition dimming view not being removed 2024-03-30 15:18:02 -04:00
Shadowfacts eda552c7c9 Add pointer interactions to gallery controls 2024-03-30 15:17:26 -04:00
Shadowfacts 841c08be2c Fix crash when sharing attachment from context menu on iPad 2024-03-30 14:43:03 -04:00
Shadowfacts eafb506d64 Fix blurhash image being used as gallery content 2024-03-29 22:18:24 -04:00
Shadowfacts fe00015248 Add background to gallery close/share buttons 2024-03-29 22:10:14 -04:00
Shadowfacts 509ed305cd Ignore safe area for gallery content 2024-03-29 22:06:28 -04:00
Shadowfacts c05107bccd Scale evenly in both dimensions in gallery animations 2024-03-29 18:59:27 -04:00
Shadowfacts 4fcc32ca4b Fix gallery controls popping in over content after presentation 2024-03-29 18:22:33 -04:00
Shadowfacts 6857529d06 Video gallery controls
See #450
2024-03-28 23:19:32 -04:00
Shadowfacts 42e29862ac Fix crash when compose screen dismissed while adding attachments 2024-03-25 10:06:38 -04:00
Shadowfacts 3ecee61013 Fix Save to Photos UIActivity icon being stretched 2024-03-20 12:29:10 -04:00
Shadowfacts f9aee46bbe Asynchronously share video instead of fetching it on the main thread 2024-03-20 12:23:18 -04:00
Shadowfacts 1cf3ce48ce Support sharing/saving videos and gifvs from gallery
See #450
2024-03-20 12:00:57 -04:00
Shadowfacts 072bb0daf0 Fix grayscale images preference not applying to gifvs 2024-03-20 11:54:47 -04:00
Shadowfacts d36e0ad27d Grayscale images in new gallery
See #450
2024-03-20 11:54:35 -04:00
Shadowfacts a80cbe79c2 Re-add image analysis interaction
See #450
2024-03-20 11:49:00 -04:00
Shadowfacts cf71fc3f98 Remove old gallery implementation
See #450
2024-03-19 15:20:18 -04:00
Shadowfacts be977dbea9 Gallery rewrite
See #450
2024-03-19 15:04:14 -04:00
Shadowfacts f327cfd197 Move SwiftPM packages into separate group 2024-03-17 18:57:51 -04:00
Shadowfacts 4bb01becd2 Update Sentry to fix required reason API issues 2024-03-17 13:34:34 -04:00
Shadowfacts 64fcc87516 Add privacy manifest to share extension 2024-03-17 13:34:15 -04:00
201 changed files with 9618 additions and 3088 deletions

View File

@ -1,3 +1,37 @@
## 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.
Features/Improvements:
- Improve attachment gallery
- Improve animations
- Display video captions
- Support sharing/saving videos
- Resume music playback after playing videos
- Improve rich text display in posts
- Add See Results button to polls
- Add Share and Save to Photos menu items to post attachments
- Show verified links in account lists
- Display message on empty list timelines
- Add preference to indicate attachments lacking alt text
- Mark notifications as read on Mastodon web frontend once displayed
- iPadOS: Support tapping the selected sidebar item to scroll to top
Bugfixes:
- Fix issue changing scope after searching
- Fix crash when searching "from:me"
- Fix tapping Followers button on profile opening Following screen
- Fix crash when removing poll option on Compose screen
- Fix hang when sharing video/GIFV attachments
- Fix stretched Save to Photos icon when sharing attachments
- Fix GIFV playback preventing device sleep
- Fix Notifications tab not scrolling to top when tab bar item tapped
- Fix selection not clearing on Trending Hashtags
- Fix fast account switcher overlapping iPhone sensor housing in landscape
- Fix Edit List screen not updating when adding/removing accounts
- Fix changing list reply policy not refreshing timeline
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
## 2023.8
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.

View File

@ -1,5 +1,83 @@
# Changelog
## 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.
## 2024.1 (119)
Features/Improvements:
- Add Account Settings button to Preferences
## 2024.1 (118)
Bugfixes:
- Fix music not pausing/resuming when video playback starts
## 2024.1 (117)
Features/Improvements:
- Add See Results button to polls
Bugfixes:
- Fix race condition when presenting gallery for 4th of more than 4 attachments
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
## 2024.1 (116)
Features/Improvements:
- Display message on empty list timelines
- Add preference to display badge for attachments that lack alt text
- Mark notifications as read on the Mastodon web frontend once displayed
- iPadOS: Support tapping the selected sidebar item to scroll to top
Bugfixes:
- Fix playing back GIFVs preventing the device sleeping
- Fix incorrect cell separator insets followers/following lists
- Fix memory leak in attachments gallery
- Fix notifications tab not scrolling to top when tab bar item tapped
- Fix Trending Hashtags screen not clearing selection
- Fix fast account switcher overlapping sensor housing on landscape iPhones
- Fix Edit List screen not updating when accounts are added/removed
- Fix changing List reply policy not refreshing list timeline
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
## 2024.1 (115)
Features/Improvements:
- Rewrite attachment gallery
- Fixes a number of long-standing issues
- Adds a custom video player that shows controls and caption
- Supports sharing/saving videos
Bugfixes:
- Fix hang when sharing video/gifv attachments
- Fix stretched icon for Save to Photos action when sharing attachment
- Fix crash when Compose screen is dismissed while adding attachments
- Fix crash when sharing attachment from context menu on iPad
## 2024.1 (113)
Features/Improvements:
- Add Share and Save to Photos context menu actions to attachments

View File

@ -0,0 +1,22 @@
<?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>TuskerInfo</key>
<dict>
<key>PushProxyHost</key>
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
<key>PushProxyScheme</key>
<string>$(TUSKER_PUSH_PROXY_SCHEME)</string>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
</dict>
</plist>

View File

@ -0,0 +1,14 @@
<?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>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View File

@ -0,0 +1,361 @@
//
// NotificationService.swift
// NotificationExtension
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UserNotifications
import UserAccounts
import PushNotifications
import CryptoKit
import OSLog
import Pachyderm
import Intents
import HTMLStreamer
import WebURL
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationService")
class NotificationService: UNNotificationServiceExtension {
private static let textConverter = TextConverter(configuration: .init(insertNewlines: false), callbacks: HTMLCallbacks.self)
private var pendingRequest: (UNMutableNotificationContent, (UNNotificationContent) -> Void, Task<Void, Never>)?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
guard let mutableContent = request.content.mutableCopy() as? UNMutableNotificationContent else {
logger.error("Couldn't get mutable content")
contentHandler(request.content)
return
}
guard request.content.userInfo["v"] as? Int == 1,
let accountID = (request.content.userInfo["ctx"] as? String).flatMap(\.removingPercentEncoding),
let account = UserAccountsManager.shared.getAccount(id: accountID),
let subscription = getSubscription(account: account),
let encryptedBody = (request.content.userInfo["data"] as? String).flatMap({ Data(base64Encoded: $0) }),
let salt = (request.content.userInfo["salt"] as? String).flatMap(decodeBase64URL(_:)),
let serverPublicKeyData = (request.content.userInfo["pk"] as? String).flatMap(decodeBase64URL(_:)) else {
logger.error("Missing info from push notification")
contentHandler(request.content)
return
}
guard let body = decryptNotification(subscription: subscription, serverPublicKeyData: serverPublicKeyData, salt: salt, encryptedBody: encryptedBody) else {
contentHandler(request.content)
return
}
let withoutPadding = body.dropFirst(2)
let notification: PushNotification
do {
notification = try JSONDecoder().decode(PushNotification.self, from: withoutPadding)
} catch {
logger.error("Unable to decode push payload: \(String(describing: error))")
contentHandler(request.content)
return
}
mutableContent.title = notification.title
mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID
let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification)
if !Task.isCancelled {
contentHandler(pendingRequest?.0 ?? mutableContent)
pendingRequest = nil
}
}
pendingRequest = (mutableContent, contentHandler, task)
}
override func serviceExtensionTimeWillExpire() {
if let pendingRequest {
logger.debug("Expiring with pending request")
pendingRequest.2.cancel()
pendingRequest.1(pendingRequest.0)
self.pendingRequest = nil
} else {
logger.debug("Expiring without pending request")
}
}
private func updateNotificationContent(_ content: UNMutableNotificationContent, account: UserAccountInfo, push: PushNotification) async {
let client = Client(baseURL: account.instanceURL, accessToken: account.accessToken)
let notification: Pachyderm.Notification
do {
notification = try await client.run(Client.getNotification(id: push.notificationID)).0
} catch {
logger.error("Error fetching notification: \(String(describing: error))")
return
}
let kindStr: String?
switch notification.kind {
case .reblog:
kindStr = "🔁 Reblogged"
case .favourite:
kindStr = "⭐️ Favorited"
case .follow:
kindStr = "👤 Followed by @\(notification.account.acct)"
case .followRequest:
kindStr = "👤 Asked to follow by @\(notification.account.acct)"
case .poll:
kindStr = "📊 Poll finished"
case .update:
kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default:
kindStr = nil
}
let notificationContent: String?
if let status = notification.status {
notificationContent = NotificationService.textConverter.convert(html: status.content)
} else if notification.kind == .follow || notification.kind == .followRequest {
notificationContent = nil
} else {
notificationContent = push.body
}
content.body = [kindStr, notificationContent].compactMap { $0 }.joined(separator: "\n")
let attachmentDataTask: Task<URL?, Never>?
// We deliberately don't include attachments for other types of notifications that have statuses (favs, etc.)
// because we risk just fetching the same thing a bunch of times for many senders.
if notification.kind == .mention || notification.kind == .status || notification.kind == .update,
let attachment = notification.status?.attachments.first {
let url = attachment.previewURL ?? attachment.url
attachmentDataTask = Task {
do {
let data = try await URLSession.shared.data(from: url).0
let localAttachmentURL = FileManager.default.temporaryDirectory.appendingPathComponent("attachment_\(attachment.id)").appendingPathExtension(url.pathExtension)
try data.write(to: localAttachmentURL)
return localAttachmentURL
} catch {
logger.error("Error setting notification attachments: \(String(describing: error))")
return nil
}
}
} else {
attachmentDataTask = nil
}
let conversationIdentifier: String?
if let status = notification.status {
if let context = status.pleromaExtras?.context {
conversationIdentifier = "context:\(context)"
} else if [Notification.Kind.reblog, .favourite, .poll, .update].contains(notification.kind) {
conversationIdentifier = "status:\(status.id)"
} else {
conversationIdentifier = nil
}
} else {
conversationIdentifier = nil
}
let account: Account?
switch notification.kind {
case .mention, .status:
account = notification.status?.account
default:
account = notification.account
}
let sender: INPerson?
if let account {
let handle = INPersonHandle(value: "@\(account.acct)", type: .unknown)
let image: INImage?
if let avatar = account.avatar,
let (data, resp) = try? await URLSession.shared.data(from: avatar),
let code = (resp as? HTTPURLResponse)?.statusCode,
(200...299).contains(code) {
image = INImage(imageData: data)
} else {
image = nil
}
sender = INPerson(
personHandle: handle,
nameComponents: nil,
displayName: account.displayName,
image: image,
contactIdentifier: nil,
customIdentifier: account.id
)
} else {
sender = nil
}
let intent = INSendMessageIntent(
recipients: nil,
outgoingMessageType: .outgoingMessageText,
content: notificationContent,
speakableGroupName: nil,
conversationIdentifier: conversationIdentifier,
serviceName: nil,
sender: sender,
attachments: nil
)
let interaction = INInteraction(intent: intent, response: nil)
interaction.direction = .incoming
do {
try await interaction.donate()
} catch {
logger.error("Error donating interaction: \(String(describing: error))")
return
}
let updatedContent: UNMutableNotificationContent
do {
let newContent = try content.updating(from: intent)
if let newMutableContent = newContent.mutableCopy() as? UNMutableNotificationContent {
pendingRequest?.0 = newMutableContent
updatedContent = newMutableContent
} else {
updatedContent = content
}
} catch {
logger.error("Error updating notification from intent: \(String(describing: error))")
updatedContent = content
}
if let localAttachmentURL = await attachmentDataTask?.value,
let attachment = try? UNNotificationAttachment(identifier: localAttachmentURL.lastPathComponent, url: localAttachmentURL) {
updatedContent.attachments = [
attachment
]
}
}
private func getSubscription(account: UserAccountInfo) -> PushNotifications.PushSubscription? {
DispatchQueue.main.sync {
// this is necessary because of a swift bug: https://github.com/apple/swift/pull/72507
MainActor.runUnsafely {
PushManager.shared.pushSubscription(account: account)
}
}
}
private func decryptNotification(subscription: PushNotifications.PushSubscription, serverPublicKeyData: Data, salt: Data, encryptedBody: Data) -> Data? {
// See https://github.com/ClearlyClaire/webpush/blob/f14a4d52e201128b1b00245d11b6de80d6cfdcd9/lib/webpush/encryption.rb
var context = Data()
context.append(0)
let clientPublicKey = subscription.secretKey.publicKey.x963Representation
let clientPublicKeyLength = UInt16(clientPublicKey.count)
context.append(UInt8((clientPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(clientPublicKeyLength & 0xFF))
context.append(clientPublicKey)
let serverPublicKeyLength = UInt16(serverPublicKeyData.count)
context.append(UInt8((serverPublicKeyLength >> 8) & 0xFF))
context.append(UInt8(serverPublicKeyLength & 0xFF))
context.append(serverPublicKeyData)
func info(encoding: String) -> Data {
var info = Data("Content-Encoding: \(encoding)\0P-256".utf8)
info.append(context)
return info
}
let sharedSecret: SharedSecret
do {
let serverPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: serverPublicKeyData)
sharedSecret = try subscription.secretKey.sharedSecretFromKeyAgreement(with: serverPublicKey)
} catch {
logger.error("Error getting shared secret: \(String(describing: error))")
return nil
}
let sharedInfo = Data("Content-Encoding: auth\0".utf8)
let pseudoRandomKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: subscription.authSecret, sharedInfo: sharedInfo, outputByteCount: 32)
let contentEncryptionKeyInfo = info(encoding: "aesgcm")
let contentEncryptionKey = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: contentEncryptionKeyInfo, outputByteCount: 16)
let nonceInfo = info(encoding: "nonce")
let nonce = HKDF<SHA256>.deriveKey(inputKeyMaterial: pseudoRandomKey, salt: salt, info: nonceInfo, outputByteCount: 12)
let nonceAndEncryptedBody = nonce.withUnsafeBytes { noncePtr in
var data = Data(buffer: noncePtr.bindMemory(to: UInt8.self))
data.append(encryptedBody)
return data
}
do {
let sealedBox = try AES.GCM.SealedBox(combined: nonceAndEncryptedBody)
let decrypted = try AES.GCM.open(sealedBox, using: contentEncryptionKey)
return decrypted
} catch {
logger.error("Error decrypting push: \(String(describing: error))")
return nil
}
}
}
extension MainActor {
@_unavailableFromAsync
@available(macOS, obsoleted: 14.0)
@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)
}
dispatchPrecondition(condition: .onQueue(.main))
return try withoutActuallyEscaping(body) { fn in
try unsafeBitCast(fn, to: (() throws -> T).self)()
}
}
}
private func decodeBase64URL(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
if str.count % 4 != 0 {
str.append(String(repeating: "=", count: 4 - str.count % 4))
}
return Data(base64Encoded: str)
}
// copied from HTMLConverter.Callbacks, blergh
private struct HTMLCallbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by
// serializing the WebURL as a string and then having Foundation parse it again),
// so, if available, use the system parser which doesn't require another round trip.
if #available(iOS 16.0, macOS 13.0, *),
let url = try? URL.ParseStrategy().parse(string) {
url
} else if let web = WebURL(string),
let url = URL(web) {
url
} else {
URL(string: string)
}
}
static func elementAction(name: String, attributes: [Attribute]) -> ElementAction {
guard name == "span" else {
return .default
}
let clazz = attributes.attributeValue(for: "class")
if clazz == "invisible" {
return .skip
} else if clazz == "ellipsis" {
return .append("")
} else {
return .default
}
}
}

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

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -85,8 +85,11 @@ class AttachmentsListController: ViewController {
for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
provider.loadObject(ofClass: DraftAttachment.self) { object, error in
guard let attachment = object as? DraftAttachment else { return }
DispatchQueue.main.async {
guard self.canAddAttachment else { return }
DispatchQueue.main.async { [weak self] in
guard let self,
self.canAddAttachment else {
return
}
DraftsPersistentContainer.shared.viewContext.insert(attachment)
attachment.draft = self.draft
self.draft.attachments.add(attachment)

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)
.font(.system(size: imageSize))
} else if let (str, attrs) = format.title {
let container = try! AttributeContainer(attrs, including: \.uiKit)
Text(AttributedString(str, attributes: container))
}
Image(systemName: format.imageName)
.font(.system(size: imageSize))
}
.accessibilityLabel(format.accessibilityLabel)
.padding(5)

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

8
Packages/GalleryVC/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,26 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "GalleryVC",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "GalleryVC",
targets: ["GalleryVC"]),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "GalleryVC"),
.testTarget(
name: "GalleryVCTests",
dependencies: ["GalleryVC"]),
]
)

View File

@ -0,0 +1,46 @@
//
// GalleryContentViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/17/24.
//
import UIKit
@MainActor
public protocol GalleryContentViewController: UIViewController {
var container: GalleryContentViewControllerContainer? { get set }
var contentSize: CGSize { get }
var activityItemsForSharing: [Any] { get }
var caption: String? { get }
var contentOverlayAccessoryViewController: UIViewController? { get }
var bottomControlsAccessoryViewController: UIViewController? { get }
var canAnimateFromSourceView: Bool { get }
func setControlsVisible(_ visible: Bool, animated: Bool)
func galleryContentDidAppear()
func galleryContentWillDisappear()
}
public extension GalleryContentViewController {
var contentOverlayAccessoryViewController: UIViewController? {
nil
}
var bottomControlsAccessoryViewController: UIViewController? {
nil
}
var canAnimateFromSourceView: Bool {
true
}
func setControlsVisible(_ visible: Bool, animated: Bool) {
}
func galleryContentDidAppear() {
}
func galleryContentWillDisappear() {
}
}

View File

@ -0,0 +1,18 @@
//
// GalleryContentViewControllerContainer.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import Foundation
@MainActor
public protocol GalleryContentViewControllerContainer: AnyObject {
var galleryControlsVisible: Bool { get }
func setGalleryContentLoading(_ loading: Bool)
func galleryContentChanged()
func disableGalleryScrollAndZoom()
func setGalleryControlsVisible(_ visible: Bool, animated: Bool)
}

View File

@ -0,0 +1,22 @@
//
// GalleryDataSource.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
@MainActor
public protocol GalleryDataSource {
func galleryItemsCount() -> Int
func galleryContentViewController(forItemAt index: Int) -> GalleryContentViewController
func galleryContentTransitionSourceView(forItemAt index: Int) -> UIView?
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]?
}
public extension GalleryDataSource {
func galleryApplicationActivities(forItemAt index: Int) -> [UIActivity]? {
nil
}
}

View File

@ -0,0 +1,133 @@
//
// GalleryDismissAnimationController.swift
// GalleryVC
//
// Created by Shadowfacts on 3/1/24.
//
import UIKit
class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let sourceView: UIView
private let interactiveTranslation: CGPoint?
private let interactiveVelocity: CGPoint?
init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) {
self.sourceView = sourceView
self.interactiveTranslation = interactiveTranslation
self.interactiveVelocity = interactiveVelocity
}
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
return 0.3
}
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to),
let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else {
fatalError()
}
let itemViewController = from.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
let origSourceTransform = sourceView.transform
let appliedSourceToDestTransform: Bool
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
appliedSourceToDestTransform = true
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
let sourceToDestTransform = origSourceTransform
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
sourceView.transform = sourceToDestTransform
} else {
appliedSourceToDestTransform = false
}
to.view.frame = container.bounds
from.view.frame = container.bounds
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
content.view.layer.opacity = 1
container.layoutIfNeeded()
let duration = self.transitionDuration(using: transitionContext)
var initialVelocity: CGVector
if let interactiveVelocity,
let interactiveTranslation,
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY
initialVelocity = CGVector(
dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance,
dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance
)
} else {
initialVelocity = .zero
}
initialVelocity.dx = max(-10, min(10, initialVelocity.dx))
initialVelocity.dy = max(-10, min(10, initialVelocity.dy))
// no bounce for the dismiss animation
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity)
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
from.view.layer.opacity = 0
if appliedSourceToDestTransform {
self.sourceView.transform = origSourceTransform
}
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
itemViewController.setControlsVisible(false, animated: false)
}
animator.addCompletion { _ in
transitionContext.completeTransition(true)
}
animator.startAnimation()
}
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let fromVC = transitionContext.viewController(forKey: .from),
let toVC = transitionContext.viewController(forKey: .to) else {
return
}
toVC.view.frame = transitionContext.containerView.bounds
fromVC.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(toVC.view)
transitionContext.containerView.addSubview(fromVC.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
animator.addAnimations {
fromVC.view.alpha = 0
}
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
animator.startAnimation()
}
}

View File

@ -0,0 +1,83 @@
//
// GalleryDismissInteraction.swift
// GalleryVC
//
// Created by Shadowfacts on 3/1/24.
//
import UIKit
@MainActor
class GalleryDismissInteraction: NSObject {
private unowned let viewController: GalleryViewController
private var content: GalleryContentViewController?
private var origContentFrameInGallery: CGRect?
private var origControlsVisible: Bool?
private(set) var isActive = false
private(set) var dismissVelocity: CGPoint?
private(set) var dismissTranslation: CGPoint?
init(viewController: GalleryViewController) {
self.viewController = viewController
super.init()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panRecognized))
panRecognizer.delegate = self
panRecognizer.allowedScrollTypesMask = .continuous
viewController.view.addGestureRecognizer(panRecognizer)
}
@objc private func panRecognized(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .began:
isActive = true
origContentFrameInGallery = viewController.view.convert(viewController.currentItemViewController.content.view.bounds, from: viewController.currentItemViewController.content.view)
content = viewController.currentItemViewController.takeContent()
content!.view.translatesAutoresizingMaskIntoConstraints = true
content!.view.frame = origContentFrameInGallery!
viewController.view.addSubview(content!.view)
origControlsVisible = viewController.currentItemViewController.controlsVisible
if origControlsVisible! {
viewController.currentItemViewController.setControlsVisible(false, animated: true)
}
case .changed:
let translation = recognizer.translation(in: viewController.view)
content!.view.frame = origContentFrameInGallery!.offsetBy(dx: translation.x, dy: translation.y)
case .ended:
let translation = recognizer.translation(in: viewController.view)
let velocity = recognizer.velocity(in: viewController.view)
dismissVelocity = velocity
dismissTranslation = translation
viewController.dismiss(animated: true)
// don't unset this until after dismiss is called, so that the dismiss animation controller can read it
isActive = false
default:
break
}
}
}
extension GalleryDismissInteraction: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
let itemVC = viewController.currentItemViewController
if viewController.galleryDataSource.galleryContentTransitionSourceView(forItemAt: itemVC.itemIndex) == nil {
return false
} else if itemVC.scrollView.zoomScale > itemVC.scrollView.minimumZoomScale {
return false
} else if !itemVC.scrollAndZoomEnabled {
return false
} else {
return true
}
}
}

View File

@ -0,0 +1,566 @@
//
// GalleryItemViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
import AVFoundation
@MainActor
protocol GalleryItemViewControllerDelegate: AnyObject {
func isGalleryBeingPresented() -> Bool
func addPresentationAnimationCompletion(_ block: @escaping () -> Void)
func galleryItemClose(_ item: GalleryItemViewController)
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]?
}
class GalleryItemViewController: UIViewController {
private weak var delegate: GalleryItemViewControllerDelegate?
let itemIndex: Int
let content: GalleryContentViewController
private var overlayVC: UIViewController?
private var activityIndicator: UIActivityIndicatorView?
private(set) var scrollView: UIScrollView!
private var topControlsView: UIView!
private var shareButton: UIButton!
private var shareButtonLeadingConstraint: NSLayoutConstraint!
private var shareButtonTopConstraint: NSLayoutConstraint!
private var closeButtonTrailingConstraint: NSLayoutConstraint!
private var closeButtonTopConstraint: NSLayoutConstraint!
private var bottomControlsView: UIStackView!
private(set) var captionTextView: UITextView!
private var singleTap: UITapGestureRecognizer!
private var doubleTap: UITapGestureRecognizer!
private var contentViewLeadingConstraint: NSLayoutConstraint?
private var contentViewTopConstraint: NSLayoutConstraint?
private(set) var controlsVisible: Bool = true
private(set) var scrollAndZoomEnabled = true
private var scrollViewSizeForLastZoomScaleUpdate: CGSize?
override var prefersHomeIndicatorAutoHidden: Bool {
return !controlsVisible
}
init(delegate: GalleryItemViewControllerDelegate, itemIndex: Int, content: GalleryContentViewController) {
self.delegate = delegate
self.itemIndex = itemIndex
self.content = content
super.init(nibName: nil, bundle: nil)
content.container = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.delegate = self
view.addSubview(scrollView)
addContent()
centerContent()
overlayVC = content.contentOverlayAccessoryViewController
if let overlayVC {
overlayVC.view.isHidden = activityIndicator != nil
overlayVC.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(overlayVC.view)
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: content.view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: content.view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: content.view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: content.view.bottomAnchor),
])
}
topControlsView = UIView()
topControlsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topControlsView)
var shareConfig = UIButton.Configuration.gray()
shareConfig.cornerStyle = .dynamic
shareConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
shareConfig.baseForegroundColor = .white
shareConfig.image = UIImage(systemName: "square.and.arrow.up")
shareConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
shareButton = UIButton(configuration: shareConfig)
shareButton.addTarget(self, action: #selector(shareButtonPressed), for: .touchUpInside)
shareButton.isPointerInteractionEnabled = true
shareButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
shareButton.preferredBehavioralStyle = .pad
shareButton.translatesAutoresizingMaskIntoConstraints = false
updateShareButton()
topControlsView.addSubview(shareButton)
var closeConfig = UIButton.Configuration.gray()
closeConfig.cornerStyle = .dynamic
closeConfig.background.backgroundColor = .black.withAlphaComponent(0.25)
closeConfig.baseForegroundColor = .white
closeConfig.image = UIImage(systemName: "xmark")
closeConfig.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 4, bottom: 4, trailing: 4)
let closeButton = UIButton(configuration: closeConfig)
closeButton.addTarget(self, action: #selector(closeButtonPressed), for: .touchUpInside)
closeButton.isPointerInteractionEnabled = true
closeButton.pointerStyleProvider = { button, effect, shape in
return UIPointerStyle(effect: .highlight(effect.preview), shape: .roundedRect(button.frame))
}
closeButton.preferredBehavioralStyle = .pad
closeButton.translatesAutoresizingMaskIntoConstraints = false
topControlsView.addSubview(closeButton)
bottomControlsView = UIStackView()
bottomControlsView.translatesAutoresizingMaskIntoConstraints = false
bottomControlsView.axis = .vertical
bottomControlsView.alignment = .fill
bottomControlsView.backgroundColor = .black.withAlphaComponent(0.5)
view.addSubview(bottomControlsView)
if let controlsAccessory = content.bottomControlsAccessoryViewController {
addChild(controlsAccessory)
bottomControlsView.addArrangedSubview(controlsAccessory.view)
controlsAccessory.didMove(toParent: self)
// Make sure the controls accessory is within the safe area.
let spacer = UIView()
bottomControlsView.addArrangedSubview(spacer)
let spacerTopConstraint = spacer.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
spacerTopConstraint.priority = .init(999)
spacerTopConstraint.isActive = true
}
captionTextView = UITextView()
captionTextView.backgroundColor = .clear
captionTextView.textColor = .white
captionTextView.isEditable = false
captionTextView.isSelectable = true
captionTextView.font = .preferredFont(forTextStyle: .body)
captionTextView.adjustsFontForContentSizeCategory = true
captionTextView.alwaysBounceVertical = true
updateCaptionTextView()
bottomControlsView.addArrangedSubview(captionTextView)
#if targetEnvironment(macCatalyst)
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.safeAreaLayoutGuide.topAnchor)
#else
closeButtonTopConstraint = closeButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
shareButtonTopConstraint = shareButton.topAnchor.constraint(equalTo: topControlsView.topAnchor)
#endif
closeButtonTrailingConstraint = topControlsView.trailingAnchor.constraint(equalTo: closeButton.trailingAnchor)
shareButtonLeadingConstraint = shareButton.leadingAnchor.constraint(equalTo: topControlsView.leadingAnchor)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
topControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
topControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
topControlsView.topAnchor.constraint(equalTo: view.topAnchor),
shareButtonLeadingConstraint,
shareButtonTopConstraint,
shareButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
shareButton.widthAnchor.constraint(equalTo: shareButton.heightAnchor),
closeButtonTrailingConstraint,
closeButtonTopConstraint,
closeButton.bottomAnchor.constraint(equalTo: topControlsView.bottomAnchor),
closeButton.widthAnchor.constraint(equalTo: closeButton.heightAnchor),
bottomControlsView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
bottomControlsView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
bottomControlsView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
captionTextView.heightAnchor.constraint(equalToConstant: 150),
])
updateTopControlsInsets()
singleTap = UITapGestureRecognizer(target: self, action: #selector(viewPressed))
singleTap.delegate = self
doubleTap = UITapGestureRecognizer(target: self, action: #selector(viewDoublePressed))
doubleTap.delegate = self
doubleTap.numberOfTapsRequired = 2
// This is needed to prevent a delay between tapping a button on and the action firing on Catalyst and Designed for iPad
doubleTap.delaysTouchesEnded = false
// this requirement is needed to make sure the double tap is ever recognized
singleTap.require(toFail: doubleTap)
view.addGestureRecognizer(singleTap)
view.addGestureRecognizer(doubleTap)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateZoomScale(resetZoom: false)
// Ensure the transform is correct if the controls are hidden
setControlsVisible(controlsVisible, animated: false)
updateTopControlsInsets()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// When the scrollView size changes, make sure the zoom scale is up-to-date since it depends on the scrollView's bounds.
// This might also fix an issue on macOS (Designed for iPad) where the content isn't placed correctly. See #446
if scrollViewSizeForLastZoomScaleUpdate != scrollView.bounds.size {
scrollViewSizeForLastZoomScaleUpdate = scrollView.bounds.size
updateZoomScale(resetZoom: true)
}
centerContent()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if controlsVisible && !captionTextView.isHidden {
captionTextView.flashScrollIndicators()
}
}
func takeContent() -> GalleryContentViewController {
content.willMove(toParent: nil)
content.removeFromParent()
content.view.removeFromSuperview()
return content
}
func addContent() {
content.loadViewIfNeeded()
content.setControlsVisible(controlsVisible, animated: false)
content.view.translatesAutoresizingMaskIntoConstraints = false
if content.parent != self {
addChild(content)
content.didMove(toParent: self)
}
if scrollAndZoomEnabled {
scrollView.addSubview(content.view)
contentViewLeadingConstraint = content.view.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
contentViewLeadingConstraint!.isActive = true
contentViewTopConstraint = content.view.topAnchor.constraint(equalTo: scrollView.topAnchor)
contentViewTopConstraint!.isActive = true
updateZoomScale(resetZoom: true)
} else {
// If the content was previously added, deactivate the old constraints.
contentViewLeadingConstraint?.isActive = false
contentViewTopConstraint?.isActive = false
view.addSubview(content.view)
NSLayoutConstraint.activate([
content.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
content.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
content.view.topAnchor.constraint(equalTo: view.topAnchor),
content.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
if let overlayVC {
NSLayoutConstraint.activate([
overlayVC.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
overlayVC.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
overlayVC.view.topAnchor.constraint(equalTo: view.topAnchor),
overlayVC.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
content.view.layoutIfNeeded()
}
func setControlsVisible(_ visible: Bool, animated: Bool) {
controlsVisible = visible
guard let topControlsView,
let bottomControlsView else {
return
}
func updateControlsViews() {
topControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : -topControlsView.bounds.height)
bottomControlsView.transform = CGAffineTransform(translationX: 0, y: visible ? 0 : bottomControlsView.bounds.height)
content.setControlsVisible(visible, animated: animated)
}
if animated {
let animator = UIViewPropertyAnimator(duration: 0.2, timingParameters: UISpringTimingParameters())
animator.addAnimations(updateControlsViews)
animator.startAnimation()
} else {
updateControlsViews()
}
setNeedsUpdateOfHomeIndicatorAutoHidden()
}
func updateZoomScale(resetZoom: Bool) {
scrollView.contentSize = content.contentSize
guard scrollAndZoomEnabled else {
scrollView.maximumZoomScale = 1
scrollView.minimumZoomScale = 1
scrollView.zoomScale = 1
return
}
guard content.contentSize.width > 0 && content.contentSize.height > 0 else {
return
}
let heightScale = view.bounds.height / content.contentSize.height
let widthScale = view.bounds.width / content.contentSize.width
let minScale = min(widthScale, heightScale)
let maxScale = minScale >= 1 ? minScale + 2 : 2
scrollView.minimumZoomScale = minScale
scrollView.maximumZoomScale = maxScale
if resetZoom {
scrollView.zoomScale = minScale
} else {
scrollView.zoomScale = max(minScale, min(maxScale, scrollView.zoomScale))
}
centerContent()
}
private func centerContent() {
guard scrollAndZoomEnabled else {
return
}
// Note: use frame for the content.view, because that's in the coordinate space of the scroll view
// which means it's already been scaled by the zoom factor.
let yOffset = max(0, (view.bounds.height - content.view.frame.height) / 2)
contentViewTopConstraint!.constant = yOffset
let xOffset = max(0, (view.bounds.width - content.view.frame.width) / 2)
contentViewLeadingConstraint!.constant = xOffset
}
private func updateShareButton() {
shareButton.isEnabled = !content.activityItemsForSharing.isEmpty
}
private func updateCaptionTextView() {
guard let caption = content.caption,
!caption.isEmpty else {
captionTextView.isHidden = true
return
}
captionTextView.text = caption
}
private func updateTopControlsInsets() {
let notchedDeviceTopInsets: [CGFloat] = [
44, // iPhone X, Xs, Xs Max, 11 Pro, 11 Pro Max
48, // iPhone XR, 11
47, // iPhone 12, 12 Pro, 12 Pro Max, 13, 13 Pro, 13 Pro Max, 14, 14 Plus
50, // iPhone 12 mini, 13 mini
]
let islandDeviceTopInsets: [CGFloat] = [
59, // iPhone 14 Pro, 14 Pro Max, 15 Pro, 15 Pro Max
]
if notchedDeviceTopInsets.contains(view.safeAreaInsets.top) {
// the notch width is not the same for the iPhones 13,
// but what we actually want is the same offset from the edges
// since the corner radius didn't change
let notchWidth: CGFloat = 210
let earWidth = (view.bounds.width - notchWidth) / 2
let offset = (earWidth - (shareButton.imageView?.bounds.width ?? 0)) / 2
shareButtonLeadingConstraint.constant = offset
closeButtonTrailingConstraint.constant = offset
} else if islandDeviceTopInsets.contains(view.safeAreaInsets.top) {
shareButtonLeadingConstraint.constant = 24
shareButtonTopConstraint.constant = 24
closeButtonTrailingConstraint.constant = 24
closeButtonTopConstraint.constant = 24
} else {
shareButtonLeadingConstraint.constant = 8
shareButtonTopConstraint.constant = 8
closeButtonTrailingConstraint.constant = 8
closeButtonTopConstraint.constant = 8
}
}
private func zoomRectFor(scale: CGFloat, center: CGPoint) -> CGRect {
var zoomRect = CGRect.zero
zoomRect.size.width = content.view.frame.width / scale
zoomRect.size.height = content.view.frame.height / scale
let newCenter = scrollView.convert(center, to: content.view)
zoomRect.origin.x = newCenter.x - (zoomRect.width / 2)
zoomRect.origin.y = newCenter.y - (zoomRect.height / 2)
return zoomRect
}
private func animateZoomOut() {
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoomScale = self.scrollView.minimumZoomScale
self.scrollView.layoutIfNeeded()
}
animator.startAnimation()
}
// MARK: Interaction
@objc private func viewPressed() {
if scrollAndZoomEnabled,
scrollView.zoomScale > scrollView.minimumZoomScale {
animateZoomOut()
} else {
setControlsVisible(!controlsVisible, animated: true)
}
}
@objc private func viewDoublePressed(_ recognizer: UITapGestureRecognizer) {
guard scrollAndZoomEnabled else {
return
}
if scrollView.zoomScale <= scrollView.minimumZoomScale {
let point = recognizer.location(in: recognizer.view)
let scale = min(
max(
scrollView.bounds.width / content.contentSize.width,
scrollView.bounds.height / content.contentSize.height,
scrollView.zoomScale + 0.75
),
scrollView.maximumZoomScale
)
let rect = zoomRectFor(scale: scale, center: point)
let animator = UIViewPropertyAnimator(duration: 0.3, timingParameters: UISpringTimingParameters())
animator.addAnimations {
self.scrollView.zoom(to: rect, animated: false)
self.view.layoutIfNeeded()
}
animator.startAnimation()
} else {
animateZoomOut()
}
}
@objc private func closeButtonPressed() {
delegate?.galleryItemClose(self)
}
@objc private func shareButtonPressed() {
let items = content.activityItemsForSharing
guard !items.isEmpty else {
return
}
let activityVC = UIActivityViewController(activityItems: items, applicationActivities: delegate?.galleryItemApplicationActivities(self))
activityVC.popoverPresentationController?.sourceView = shareButton
present(activityVC, animated: true)
}
}
extension GalleryItemViewController: GalleryContentViewControllerContainer {
var galleryControlsVisible: Bool {
controlsVisible
}
func setGalleryContentLoading(_ loading: Bool) {
if loading {
overlayVC?.view.isHidden = true
if activityIndicator == nil {
let activityIndicator = UIActivityIndicatorView(style: .large)
self.activityIndicator = activityIndicator
activityIndicator.startAnimating()
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
} else {
if let activityIndicator {
// If we're in the middle of the presentation animation,
// wait until it finishes to hide the loading indicator.
// Since the updated content frame won't affect the animation,
// make sure the loading indicator remains visible.
if let delegate,
delegate.isGalleryBeingPresented() {
delegate.addPresentationAnimationCompletion { [unowned self] in
self.setGalleryContentLoading(false)
}
} else {
activityIndicator.removeFromSuperview()
self.activityIndicator = nil
self.overlayVC?.view.isHidden = false
}
}
}
}
func galleryContentChanged() {
updateZoomScale(resetZoom: true)
updateShareButton()
updateCaptionTextView()
}
func disableGalleryScrollAndZoom() {
scrollAndZoomEnabled = false
updateZoomScale(resetZoom: true)
scrollView.isScrollEnabled = false
// Make sure the content is re-added with the correct constraints
if content.parent == self {
addContent()
}
}
func setGalleryControlsVisible(_ visible: Bool, animated: Bool) {
setControlsVisible(visible, animated: animated)
}
}
extension GalleryItemViewController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
if scrollAndZoomEnabled {
return content.view
} else {
return nil
}
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
if scrollView.zoomScale <= scrollView.minimumZoomScale {
setControlsVisible(true, animated: true)
} else {
setControlsVisible(false, animated: true)
}
centerContent()
scrollView.layoutIfNeeded()
}
}
extension GalleryItemViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer == singleTap {
let loc = gestureRecognizer.location(in: view)
return !topControlsView.frame.contains(loc) && !bottomControlsView.frame.contains(loc)
} else if gestureRecognizer == doubleTap {
let loc = gestureRecognizer.location(in: content.view)
return content.view.bounds.contains(loc)
} else {
return true
}
}
}

View File

@ -0,0 +1,136 @@
//
// GalleryPresentationAnimationController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
class GalleryPresentationAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
private let sourceView: UIView
init(sourceView: UIView) {
self.sourceView = sourceView
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.4
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
fatalError()
}
let itemViewController = to.currentItemViewController
if !itemViewController.content.canAnimateFromSourceView || UIAccessibility.prefersCrossFadeTransitions {
animateCrossFadeTransition(using: transitionContext)
return
}
let container = transitionContext.containerView
to.view.frame = container.bounds
container.addSubview(to.view)
container.layoutIfNeeded()
// Make sure the zoom scale is updated before getting the content view frame, since it needs to take into account the correct transform.
itemViewController.updateZoomScale(resetZoom: true)
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
// Use a transformation to make the actual source view appear to move into the destination frame.
// Doing this while having the content view fade-in papers over the z-index change when
// there was something overlapping the source view.
let origSourceTransform = sourceView.transform
let sourceToDestTransform: CGAffineTransform?
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
// Scale evenly in both dimensions, to prevent the source view appearing to stretch/distort during the animation.
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
sourceToDestTransform = origSourceTransform
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
.scaledBy(x: scale, y: scale)
} else {
sourceToDestTransform = nil
}
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
container.insertSubview(content.view, belowSubview: to.view)
// Use a separate dimming view from to.view, so that the gallery controls can be in front of the moving content.
let dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.frame = container.bounds
dimmingView.layer.opacity = 0
container.insertSubview(dimmingView, belowSubview: content.view)
to.view.backgroundColor = nil
to.view.layer.opacity = 0
content.view.frame = sourceFrameInContainer
content.view.layer.opacity = 0
container.layoutIfNeeded()
// This needs to take place after the layout, so that the transform is correct.
itemViewController.setControlsVisible(false, animated: false)
let duration = self.transitionDuration(using: transitionContext)
// rougly equivalent to duration: 0.35, bounce: 0.3
let spring = UISpringTimingParameters(mass: 1, stiffness: 322, damping: 25, initialVelocity: .zero)
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
animator.addAnimations {
dimmingView.layer.opacity = 1
to.view.layer.opacity = 1
content.view.frame = destFrameInContainer
content.view.layer.opacity = 1
itemViewController.setControlsVisible(true, animated: false)
if let sourceToDestTransform {
self.sourceView.transform = sourceToDestTransform
}
}
animator.addCompletion { _ in
dimmingView.removeFromSuperview()
to.view.backgroundColor = .black
if sourceToDestTransform != nil {
self.sourceView.transform = origSourceTransform
}
itemViewController.addContent()
transitionContext.completeTransition(true)
}
animator.startAnimation()
}
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
guard let to = transitionContext.viewController(forKey: .to) as? GalleryViewController else {
return
}
to.view.alpha = 0
to.view.frame = transitionContext.containerView.bounds
transitionContext.containerView.addSubview(to.view)
let duration = transitionDuration(using: transitionContext)
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
animator.addAnimations {
to.view.alpha = 1
}
animator.addCompletion { _ in
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
animator.startAnimation()
}
}

View File

@ -0,0 +1,179 @@
//
// GalleryViewController.swift
// GalleryVC
//
// Created by Shadowfacts on 12/28/23.
//
import UIKit
public class GalleryViewController: UIPageViewController {
let galleryDataSource: GalleryDataSource
let initialItemIndex: Int
private let _itemsCount: Int
private var itemsCount: Int {
get {
precondition(_itemsCount == galleryDataSource.galleryItemsCount(), "GalleryDataSource item count cannot change")
return _itemsCount
}
}
var currentItemViewController: GalleryItemViewController {
viewControllers![0] as! GalleryItemViewController
}
private var dismissInteraction: GalleryDismissInteraction!
private var presentationAnimationCompletionHandlers: [() -> Void] = []
override public var prefersStatusBarHidden: Bool {
true
}
override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
.none
}
override public var childForHomeIndicatorAutoHidden: UIViewController? {
currentItemViewController
}
public init(dataSource: GalleryDataSource, initialItemIndex: Int) {
self.galleryDataSource = dataSource
self.initialItemIndex = initialItemIndex
self._itemsCount = dataSource.galleryItemsCount()
precondition(initialItemIndex >= 0 && initialItemIndex < _itemsCount, "initialItemIndex is out of bounds")
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [
.interPageSpacing: 50
])
modalPresentationStyle = .fullScreen
transitioningDelegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func viewDidLoad() {
super.viewDidLoad()
dismissInteraction = GalleryDismissInteraction(viewController: self)
view.backgroundColor = .black
overrideUserInterfaceStyle = .dark
dataSource = self
delegate = self
setViewControllers([makeItemVC(index: initialItemIndex)], direction: .forward, animated: false)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if animated {
// Wait until the transition is no longer in-progress, otherwise things will just get deferred again.
DispatchQueue.main.async {
self.presentationAnimationCompleted()
}
}
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isBeingDismissed {
currentItemViewController.content.galleryContentWillDisappear()
}
}
private func makeItemVC(index: Int) -> GalleryItemViewController {
let content = galleryDataSource.galleryContentViewController(forItemAt: index)
return GalleryItemViewController(delegate: self, itemIndex: index, content: content)
}
func presentationAnimationCompleted() {
for block in presentationAnimationCompletionHandlers {
block()
}
currentItemViewController.content.galleryContentDidAppear()
}
}
extension GalleryViewController: UIPageViewControllerDataSource {
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let viewController = viewController as? GalleryItemViewController else {
preconditionFailure("VC must be GalleryItemViewController")
}
guard viewController.itemIndex > 0 else {
return nil
}
return makeItemVC(index: viewController.itemIndex - 1)
}
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let viewController = viewController as? GalleryItemViewController else {
preconditionFailure("VC must be GalleryItemViewController")
}
guard viewController.itemIndex < itemsCount - 1 else {
return nil
}
return makeItemVC(index: viewController.itemIndex + 1)
}
}
extension GalleryViewController: UIPageViewControllerDelegate {
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
currentItemViewController.content.galleryContentWillDisappear()
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
currentItemViewController.content.galleryContentDidAppear()
}
}
extension GalleryViewController: GalleryItemViewControllerDelegate {
func isGalleryBeingPresented() -> Bool {
isBeingPresented
}
func addPresentationAnimationCompletion(_ block: @escaping () -> Void) {
presentationAnimationCompletionHandlers.append(block)
}
func galleryItemClose(_ item: GalleryItemViewController) {
dismiss(animated: true)
}
func galleryItemApplicationActivities(_ item: GalleryItemViewController) -> [UIActivity]? {
galleryDataSource.galleryApplicationActivities(forItemAt: item.itemIndex)
}
}
extension GalleryViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: initialItemIndex) {
return GalleryPresentationAnimationController(sourceView: sourceView)
} else {
return nil
}
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let sourceView = galleryDataSource.galleryContentTransitionSourceView(forItemAt: currentItemViewController.itemIndex) {
let translation: CGPoint?
let velocity: CGPoint?
if let dismissInteraction,
dismissInteraction.isActive {
translation = dismissInteraction.dismissTranslation
velocity = dismissInteraction.dismissVelocity
} else {
translation = nil
velocity = nil
}
return GalleryDismissAnimationController(sourceView: sourceView, interactiveTranslation: translation, interactiveVelocity: velocity)
} else {
return nil
}
}
}

View File

@ -0,0 +1,12 @@
import XCTest
@testable import GalleryVC
final class GalleryVCTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
}

View File

@ -184,6 +184,39 @@ 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 init() {
}

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)"))
}
})
@ -341,6 +341,10 @@ public struct Client: Sendable {
}
// MARK: - Notifications
public static func getNotification(id: String) -> Request<Notification> {
return Request(method: .get, path: "/api/v1/notifications/\(id)")
}
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
"types" => allowedTypes.map { $0.rawValue }

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

@ -0,0 +1,46 @@
//
// PushNotification.swift
// Pachyderm
//
// Created by Shadowfacts on 4/9/24.
//
import Foundation
import WebURL
public struct PushNotification: Decodable {
public let accessToken: String
public let preferredLocale: String
public let notificationID: String
public let notificationType: Notification.Kind
public let icon: WebURL
public let title: String
public let body: String
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.accessToken = try container.decode(String.self, forKey: .accessToken)
self.preferredLocale = try container.decode(String.self, forKey: .preferredLocale)
// this should be a string, but mastodon encodes it as a json number
if let s = try? container.decode(String.self, forKey: .notificationID) {
self.notificationID = s
} else {
let i = try container.decode(Int.self, forKey: .notificationID)
self.notificationID = i.description
}
self.notificationType = try container.decode(Notification.Kind.self, forKey: .notificationType)
self.icon = try container.decode(WebURL.self, forKey: .icon)
self.title = try container.decode(String.self, forKey: .title)
self.body = try container.decode(String.self, forKey: .body)
}
private enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case preferredLocale = "preferred_locale"
case notificationID = "notification_id"
case notificationType = "notification_type"
case icon
case title
case body
}
}

View File

@ -9,16 +9,144 @@
import Foundation
public struct PushSubscription: Decodable, Sendable {
public let id: String
public let endpoint: URL
public let serverKey: String
// TODO: WTF is this?
// public let alerts
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)
// id is documented as being a string, but mastodon returns a json number
if let s = try? container.decode(String.self, forKey: .id) {
self.id = s
} else {
let i = try container.decode(Int.self, forKey: .id)
self.id = i.description
}
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)
// 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> {
return Request(method: .post, path: "/api/v1/push/subscription", body: ParametersBody([
"subscription[endpoint]" => endpoint.absoluteString,
"subscription[keys][p256dh]" => publicKey.base64EncodedString(),
"subscription[keys][auth]" => authSecret.base64EncodedString(),
"data[alerts][mention]" => alerts.mention,
"data[alerts][status]" => alerts.status,
"data[alerts][reblog]" => alerts.reblog,
"data[alerts][follow]" => alerts.follow,
"data[alerts][follow_request]" => alerts.followRequest,
"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,
]))
}
public static func update(alerts: Alerts, policy: Policy) -> Request<PushSubscription> {
return Request(method: .put, path: "/api/v1/push/subscription", body: ParametersBody([
"data[alerts][mention]" => alerts.mention,
"data[alerts][status]" => alerts.status,
"data[alerts][reblog]" => alerts.reblog,
"data[alerts][follow]" => alerts.follow,
"data[alerts][follow_request]" => alerts.followRequest,
"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,
]))
}
public static func delete() -> Request<Empty> {
return Request(method: .delete, path: "/api/v1/push/subscription")
}
private enum CodingKeys: String, CodingKey {
case id
case endpoint
case serverKey = "server_key"
// case alerts
case alerts
case policy
}
}
extension PushSubscription {
public struct Alerts: Decodable, Sendable {
public let mention: Bool
public let status: Bool
public let reblog: Bool
public let follow: Bool
public let followRequest: Bool
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,
emojiReaction: Bool
) {
self.mention = mention
self.status = status
self.reblog = reblog
self.follow = follow
self.followRequest = followRequest
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 {
case mention
case status
case reblog
case follow
case followRequest = "follow_request"
case favourite
case poll
case update
case emojiReaction = "pleroma:emoji_reaction"
}
}
}
extension PushSubscription {
public enum Policy: String, Decodable, Sendable {
case all
case followed
case followers
case none
}
}

View File

@ -12,6 +12,7 @@ public enum Scope: String, Sendable {
case read
case write
case follow
case push
}
extension Array where Element == Scope {

View File

@ -46,6 +46,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
public let localOnly: Bool?
public let editedAt: Date?
public let pleromaExtras: PleromaExtras?
public var applicationName: String? { application?.name }
public init(from decoder: Decoder) throws {
@ -98,6 +100,8 @@ public final class Status: StatusProtocol, Decodable, Sendable {
self.card = try container.decodeIfPresent(Card.self, forKey: .card)
self.poll = try container.decodeIfPresent(Poll.self, forKey: .poll)
self.editedAt = try container.decodeIfPresent(Date.self, forKey: .editedAt)
self.pleromaExtras = try container.decodeIfPresent(PleromaExtras.self, forKey: .pleromaExtras)
}
public static func getContext(_ statusID: String) -> Request<ConversationContext> {
@ -120,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)")
}
@ -212,7 +222,15 @@ public final class Status: StatusProtocol, Decodable, Sendable {
case poll
case localOnly = "local_only"
case editedAt = "edited_at"
case pleromaExtras = "pleroma"
}
}
extension Status: Identifiable {}
extension Status {
public struct PleromaExtras: Decodable, Sendable {
public let context: String?
}
}

View File

@ -7,34 +7,83 @@
import Foundation
public struct TimelineMarkers: Decodable, Sendable {
public let home: Marker?
public let notifications: Marker?
public struct TimelineMarkers {
private init() {}
public static func request(timelines: [Timeline]) -> Request<TimelineMarkers> {
return Request(method: .get, path: "/api/v1/markers", queryParameters: "timeline[]" => timelines.map(\.rawValue))
public static func request<T: TimelineMarkerType>(timeline: T) -> Request<TimelineMarker<T.Payload>> {
Request(method: .get, path: "/api/v1/markers", queryParameters: ["timeline[]" => T.name])
}
public static func update(timeline: Timeline, lastReadID: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(timeline.rawValue)[last_read_id]" => lastReadID,
public static func update<T: TimelineMarkerType>(timeline: T, lastReadID: String) -> Request<Empty> {
Request(method: .post, path: "/api/v1/markers", body: ParametersBody([
"\(T.name)[last_read_id]" => lastReadID
]))
}
}
public struct TimelineMarker<Payload: TimelineMarkerTypePayload>: Decodable, Sendable {
let payload: Payload
public enum Timeline: String {
case home
case notifications
public var lastReadID: String {
payload.payload.lastReadID
}
public struct Marker: Decodable, Sendable {
public let lastReadID: String
public let version: Int
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case lastReadID = "last_read_id"
case version
case updatedAt = "updated_at"
}
public var version: Int {
payload.payload.version
}
public var updatedAt: Date {
payload.payload.updatedAt
}
public init(from decoder: any Decoder) throws {
self.payload = try Payload(from: decoder)
}
}
public protocol TimelineMarkerTypePayload: Decodable, Sendable {
var payload: MarkerPayload { get }
}
public struct HomeMarkerPayload: TimelineMarkerTypePayload {
public var home: MarkerPayload
public var payload: MarkerPayload { home }
}
public struct NotificationsMarkerPayload: TimelineMarkerTypePayload {
public var notifications: MarkerPayload
public var payload: MarkerPayload { notifications }
}
public struct MarkerPayload: Decodable, Sendable {
public let lastReadID: String
public let version: Int
public let updatedAt: Date
enum CodingKeys: String, CodingKey {
case lastReadID = "last_read_id"
case version
case updatedAt = "updated_at"
}
}
public protocol TimelineMarkerType {
static var name: String { get }
associatedtype Payload: TimelineMarkerTypePayload
}
extension TimelineMarkerType where Self == HomeMarker {
public static var home: Self { .init() }
}
extension TimelineMarkerType where Self == NotificationsMarker {
public static var notifications: Self { .init() }
}
public struct HomeMarker: TimelineMarkerType {
public typealias Payload = HomeMarkerPayload
public static var name: String { "home" }
}
public struct NotificationsMarker: TimelineMarkerType {
public typealias Payload = NotificationsMarkerPayload
public static var name: String { "notifications" }
}

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 {
@ -43,31 +44,62 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
private mutating func append(group: NotificationGroup) {
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
}
}
}
}

8
Packages/PushNotifications/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PushNotifications"
BuildableName = "PushNotifications"
BlueprintName = "PushNotifications"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "PushNotifications"
BuildableName = "PushNotifications"
BlueprintName = "PushNotifications"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,32 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "PushNotifications",
platforms: [
.iOS(.v15),
],
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.library(
name: "PushNotifications",
targets: ["PushNotifications"]),
],
dependencies: [
.package(path: "../UserAccounts"),
.package(path: "../Pachyderm"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "PushNotifications",
dependencies: ["UserAccounts", "Pachyderm"]
),
.testTarget(
name: "PushNotificationsTests",
dependencies: ["PushNotifications"]),
]
)

View File

@ -0,0 +1,47 @@
//
// DisabledPushManager.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import UserAccounts
class DisabledPushManager: _PushManager {
var enabled: Bool {
false
}
var subscriptions: [PushSubscription] {
[]
}
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
throw Disabled()
}
func removeSubscription(account: UserAccountInfo) {
}
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
}
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
nil
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
}
func didFailToRegisterForRemoteNotifications(error: any Error) {
}
struct Disabled: LocalizedError {
var errorDescription: String? {
"Push notifications disabled"
}
}
}

View File

@ -0,0 +1,55 @@
//
// PushManager.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import OSLog
import Pachyderm
import UserAccounts
public struct PushManager {
@MainActor
public static let shared = createPushManager()
public static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PushManager")
@MainActor
public static var captureError: ((any Error) -> Void)?
private init() {}
@MainActor
private static func createPushManager() -> any _PushManager {
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
let host = info["PushProxyHost"] as? String,
!host.isEmpty else {
logger.debug("Missing proxy info, push disabled")
return DisabledPushManager()
}
var endpoint = URLComponents()
endpoint.scheme = "https"
endpoint.host = host
let url = endpoint.url!
logger.debug("Push notifications enabled with proxy \(url.absoluteString, privacy: .public)")
return PushManagerImpl(endpoint: url)
}
}
@MainActor
public protocol _PushManager {
var enabled: Bool { get }
var subscriptions: [PushSubscription] { get }
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription
func removeSubscription(account: UserAccountInfo)
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy)
func pushSubscription(account: UserAccountInfo) -> PushSubscription?
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async
func didRegisterForRemoteNotifications(deviceToken: Data)
func didFailToRegisterForRemoteNotifications(error: any Error)
}

View File

@ -0,0 +1,202 @@
//
// PushManagerImpl.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import UIKit
import UserAccounts
import CryptoKit
class PushManagerImpl: _PushManager {
private let endpoint: URL
var enabled: Bool {
true
}
private var apnsEnvironment: String {
#if DEBUG
"development"
#else
"production"
#endif
}
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
public private(set) var subscriptions: [PushSubscription] {
get {
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
return array.compactMap(PushSubscription.init(defaultsDict:))
} else {
return []
}
}
set {
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
}
}
init(endpoint: URL) {
self.endpoint = endpoint
}
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
if let existing = pushSubscription(account: account) {
return existing
}
let key = P256.KeyAgreement.PrivateKey()
var authSecret = Data(count: 16)
let res = authSecret.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
}
guard res == errSecSuccess else {
throw CreateSubscriptionError.generatingAuthSecret(res)
}
let token = try await getDeviceToken()
let subscription = PushSubscription(
accountID: account.id,
endpoint: endpointURL(deviceToken: token, accountID: account.id),
secretKey: key,
authSecret: authSecret,
alerts: [],
policy: .all
)
subscriptions.append(subscription)
return subscription
}
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
return endpoint.url!
}
func removeSubscription(account: UserAccountInfo) {
subscriptions.removeAll { $0.accountID == account.id }
}
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
return
}
var copy = subscriptions[index]
copy.alerts = alerts
copy.policy = policy
subscriptions[index] = copy
}
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
subscriptions.first { $0.accountID == account.id }
}
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
let subscriptions = self.subscriptions
guard !subscriptions.isEmpty else {
return
}
do {
let token = try await getDeviceToken()
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
guard newEndpoint != $0.endpoint else {
return $0
}
var copy = $0
copy.endpoint = newEndpoint
if await updateSubscription(copy) {
return copy
} else {
return $0
}
}.reduce(into: [], { partialResult, el in
partialResult.append(el)
})
} catch {
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
PushManager.captureError?(error)
}
}
private func getDeviceToken() async throws -> Data {
return try await withCheckedThrowingContinuation { continuation in
remoteNotificationsRegistrationContinuation = continuation
UIApplication.shared.registerForRemoteNotifications()
}
}
func didRegisterForRemoteNotifications(deviceToken: Data) {
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
remoteNotificationsRegistrationContinuation = nil
}
func didFailToRegisterForRemoteNotifications(error: any Error) {
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
remoteNotificationsRegistrationContinuation = nil
}
}
enum PushRegistrationError: LocalizedError {
case alreadyRegistering
case registeringForRemoteNotifications(any Error)
var errorDescription: String? {
switch self {
case .alreadyRegistering:
"Already registering"
case .registeringForRemoteNotifications(let error):
"Remote notifications: \(error.localizedDescription)"
}
}
}
enum CreateSubscriptionError: LocalizedError {
case generatingAuthSecret(OSStatus)
var errorDescription: String? {
switch self {
case .generatingAuthSecret(let code):
"Generating auth secret: \(code)"
}
}
}
private extension Data {
func hexEncodedString() -> String {
String(unsafeUninitializedCapacity: count * 2) { buffer in
let chars = Array("0123456789ABCDEF".utf8)
for (i, x) in enumerated() {
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
buffer[i * 2] = chars[Int(upper)]
buffer[i * 2 + 1] = chars[Int(lower)]
}
return count * 2
}
}
}
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
typealias Element = S.Element
let base: S
init(wrapping base: S) {
self.base = base
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(base: base.makeIterator())
}
struct AsyncIterator: AsyncIteratorProtocol {
var base: S.Iterator
mutating func next() async -> Element? {
base.next()
}
}
}

View File

@ -0,0 +1,81 @@
//
// PushSubscription.swift
// PushNotifications
//
// Created by Shadowfacts on 4/7/24.
//
import Foundation
import CryptoKit
public struct PushSubscription {
public let accountID: String
public internal(set) var endpoint: URL
public let secretKey: P256.KeyAgreement.PrivateKey
public let authSecret: Data
public var alerts: Alerts
public var policy: Policy
var defaultsDict: [String: Any] {
[
"accountID": accountID,
"endpoint": endpoint.absoluteString,
"secretKey": secretKey.rawRepresentation,
"authSecret": authSecret,
"alerts": alerts.rawValue,
"policy": policy.rawValue
]
}
init?(defaultsDict: [String: Any]) {
guard let accountID = defaultsDict["accountID"] as? String,
let endpoint = (defaultsDict["endpoint"] as? String).flatMap(URL.init(string:)),
let secretKey = (defaultsDict["secretKey"] as? Data).flatMap({ try? P256.KeyAgreement.PrivateKey(rawRepresentation: $0) }),
let authSecret = defaultsDict["authSecret"] as? Data,
let alerts = defaultsDict["alerts"] as? Int,
let policy = (defaultsDict["policy"] as? String).flatMap(Policy.init(rawValue:)) else {
return nil
}
self.accountID = accountID
self.endpoint = endpoint
self.secretKey = secretKey
self.authSecret = authSecret
self.alerts = Alerts(rawValue: alerts)
self.policy = policy
}
init(accountID: String, endpoint: URL, secretKey: P256.KeyAgreement.PrivateKey, authSecret: Data, alerts: Alerts, policy: Policy) {
self.accountID = accountID
self.endpoint = endpoint
self.secretKey = secretKey
self.authSecret = authSecret
self.alerts = alerts
self.policy = policy
}
public enum Policy: String, CaseIterable, Identifiable, Sendable {
case all, followed, followers
public var id: some Hashable {
self
}
}
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)
public static let follow = Alerts(rawValue: 1 << 3)
public static let followRequest = Alerts(rawValue: 1 << 4)
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
public init(rawValue: Int) {
self.rawValue = rawValue
}
}
}

View File

@ -0,0 +1,12 @@
import XCTest
@testable import PushNotifications
final class PushNotificationsTests: XCTestCase {
func testExample() throws {
// XCTest Documentation
// https://developer.apple.com/documentation/xctest
// Defining Test Cases and Test Methods
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
}
}

View File

@ -0,0 +1,93 @@
//
// AsyncPicker.swift
// TuskerComponents
//
// Created by Shadowfacts on 4/9/24.
//
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
let content: Content
@State private var isLoading = false
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
self.content = content()
}
public var body: some View {
#if os(visionOS)
LabeledContent(titleKey) {
picker
}
#else
if #available(iOS 16.0, *) {
LabeledContent(titleKey) {
picker
}
} else if labelHidden {
picker
} else {
HStack {
Text(titleKey)
Spacer()
picker
}
}
#endif
}
private var picker: some View {
ZStack(alignment: alignment) {
Picker(titleKey, selection: Binding(get: {
value
}, set: { newValue in
let oldValue = value
value = newValue
isLoading = true
Task {
let operationCompleted = await onChange(newValue)
if !operationCompleted {
value = oldValue
}
isLoading = false
}
})) {
content
}
.labelsHidden()
.opacity(isLoading ? 0 : 1)
if isLoading {
ProgressView()
}
}
}
}
#Preview {
@State var value = 0
return AsyncPicker("", value: $value) { _ in
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
return true
} content: {
ForEach(0..<10) {
Text("\($0)").tag($0)
}
}
}

View File

@ -0,0 +1,89 @@
//
// AsyncToggle.swift
// TuskerComponents
//
// Created by Shadowfacts on 4/7/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
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
}
} else if labelHidden {
toggleOrSpinner
} else {
HStack {
Text(titleKey)
Spacer()
toggleOrSpinner
}
}
#endif
}
@ViewBuilder
private var toggleOrSpinner: some View {
ZStack {
Toggle(titleKey, isOn: Binding {
mode == .on
} set: { newValue in
mode = .loading
Task {
let operationCompleted = await onChange(newValue)
if operationCompleted {
mode = newValue ? .on : .off
} else {
mode = newValue ? .off : .on
}
}
})
.labelsHidden()
.opacity(mode == .loading ? 0 : 1)
if mode == .loading {
ProgressView()
}
}
}
public enum Mode {
case off
case loading
case on
}
}
#Preview {
@State var mode = AsyncToggle.Mode.on
return AsyncToggle("", mode: $mode) { _ in
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
return true
}
}

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,426 +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.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(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
// 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 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

@ -11,10 +11,11 @@ import CryptoKit
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
public let id: String
public let instanceURL: URL
public let clientID: String
public let clientSecret: String
public private(set) var username: String!
public let accessToken: String
public internal(set) var clientID: String
public internal(set) var clientSecret: String
public let username: String!
public internal(set) var accessToken: String
public internal(set) var scopes: [String]?
// Sort of hack to be able to access these from the share extension.
public internal(set) var serverDefaultLanguage: String?
@ -40,16 +41,19 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.username = nil
self.accessToken = accessToken
self.scopes = nil
}
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [String]) {
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.username = username
self.accessToken = accessToken
self.scopes = scopes
}
init?(userDefaultsDict dict: [String: Any]) {
@ -67,6 +71,7 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
self.clientSecret = secret
self.username = dict["username"] as? String
self.accessToken = accessToken
self.scopes = dict["scopes"] as? [String]
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
@ -83,6 +88,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
if let username {
dict["username"] = username
}
if let scopes {
dict["scopes"] = scopes
}
if let serverDefaultLanguage {
dict["serverDefaultLanguage"] = serverDefaultLanguage
}
@ -100,12 +108,4 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
// slashes are not allowed in the persistent store coordinator name
id.replacingOccurrences(of: "/", with: "_")
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
return lhs.id == rhs.id
}
}

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()
@ -25,7 +26,9 @@ public class UserAccountsManager: ObservableObject {
clientID: "client_id",
clientSecret: "client_secret",
username: "admin",
accessToken: "access_token")
accessToken: "access_token",
scopes: []
)
]
}
} else {
@ -38,7 +41,7 @@ public class UserAccountsManager: ObservableObject {
private let accountsKey = "accounts"
public private(set) var accounts: [UserAccountInfo] {
get {
if let array = defaults.array(forKey: accountsKey) as? [[String: String]] {
if let array = defaults.array(forKey: accountsKey) as? [[String: Any]] {
return array.compactMap(UserAccountInfo.init(userDefaultsDict:))
} else {
return []
@ -101,12 +104,12 @@ public class UserAccountsManager: ObservableObject {
return !accounts.isEmpty
}
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String) -> UserAccountInfo {
public func addAccount(instanceURL url: URL, clientID: String, clientSecret secret: String, username: String?, accessToken: String, scopes: [String]) -> UserAccountInfo {
var accounts = self.accounts
if let index = accounts.firstIndex(where: { $0.instanceURL == url && $0.username == username }) {
accounts.remove(at: index)
}
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken)
let info = UserAccountInfo(instanceURL: url, clientID: clientID, clientSecret: secret, username: username, accessToken: accessToken, scopes: scopes)
accounts.append(info)
self.accounts = accounts
return info
@ -145,6 +148,18 @@ public class UserAccountsManager: ObservableObject {
account.serverDefaultFederation = defaultFederation
accounts[index] = account
}
public func updateCredentials(_ account: UserAccountInfo, clientID: String, clientSecret: String, accessToken: String, scopes: [String]) {
guard let index = accounts.firstIndex(where: { $0.id == account.id }) else {
return
}
var account = account
account.clientID = clientID
account.clientSecret = clientSecret
account.accessToken = accessToken
account.scopes = scopes
accounts[index] = account
}
}

View File

@ -0,0 +1,25 @@
<?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>
<dict>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>54BD.1</string>
</array>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
</dict>
</array>
</dict>
</plist>

View File

@ -6,7 +6,7 @@
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.space.vaccor.Tusker</string>
<string>group.$(BUNDLE_ID_PREFIX).Tusker</string>
</array>
<key>com.apple.security.network.client</key>
<true/>

View File

@ -3,3 +3,4 @@
DEVELOPMENT_TEAM = YOUR_TEAM_ID
BUNDLE_ID_PREFIX = com.example
TUSKER_PUSH_PROXY_HOST =

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,63 @@
//
// GetAuthorizationTokenService.swift
// Tusker
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import AuthenticationServices
class GetAuthorizationTokenService {
let instanceURL: URL
let clientID: String
let presentationContextProvider: ASWebAuthenticationPresentationContextProviding
init(instanceURL: URL, clientID: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding) {
self.instanceURL = instanceURL
self.clientID = clientID
self.presentationContextProvider = presentationContextProvider
}
@MainActor
func run() async throws -> String {
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
components.path = "/oauth/authorize"
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
]
let authorizeURL = components.url!
return try await withCheckedThrowingContinuation({ continuation in
let authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
if let error = error {
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
continuation.resume(throwing: Error.cancelled)
} else {
continuation.resume(throwing: error)
}
} else if let url = url,
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let item = components.queryItems?.first(where: { $0.name == "code" }),
let code = item.value {
continuation.resume(returning: code)
} else {
continuation.resume(throwing: Error.noAuthorizationCode)
}
})
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
authenticationSession.prefersEphemeralWebBrowserSession = true
authenticationSession.presentationContextProvider = presentationContextProvider
authenticationSession.start()
})
}
enum Error: Swift.Error {
case cancelled
case noAuthorizationCode
}
}

View File

@ -8,6 +8,8 @@
import Foundation
import UserAccounts
import PushNotifications
import Pachyderm
@MainActor
class LogoutService {
@ -20,7 +22,12 @@ class LogoutService {
}
func run() {
Task.detached {
let accountInfo = self.accountInfo
Task.detached { @MainActor in
if PushManager.shared.pushSubscription(account: accountInfo) != nil {
_ = try? await self.mastodonController.run(Pachyderm.PushSubscription.delete())
PushManager.shared.removeSubscription(account: accountInfo)
}
try? await self.mastodonController.client.revokeAccessToken()
}
MastodonController.removeForAccount(accountInfo)

View File

@ -0,0 +1,78 @@
//
// MastodonController+Push.swift
// Tusker
//
// Created by Shadowfacts on 4/9/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import PushNotifications
import CryptoKit
extension MastodonController {
func createPushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
let req = Pachyderm.PushSubscription.create(
endpoint: subscription.endpoint,
// mastodon docs just say "Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve."
// other apps use SecKeyCopyExternalRepresentation which is documented to use X9.63 for elliptic curve keys
// and that seems to work
publicKey: subscription.secretKey.publicKey.x963Representation,
authSecret: subscription.authSecret,
alerts: .init(subscription.alerts),
policy: .init(subscription.policy)
)
return try await run(req).0
}
func updatePushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
// when updating anything other than the alerts/policy, we need to go through the create route
return try await createPushSubscription(subscription: subscription)
}
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))
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 {
let req = Pachyderm.PushSubscription.delete()
_ = try await run(req)
}
}
private extension Pachyderm.PushSubscription.Alerts {
init(_ alerts: PushNotifications.PushSubscription.Alerts) {
self.init(
mention: alerts.contains(.mention),
status: alerts.contains(.status),
reblog: alerts.contains(.reblog),
follow: alerts.contains(.follow),
followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll),
update: alerts.contains(.update),
emojiReaction: alerts.contains(.emojiReaction)
)
}
}
private extension Pachyderm.PushSubscription.Policy {
init(_ policy: PushNotifications.PushSubscription.Policy) {
switch policy {
case .all:
self = .all
case .followers:
self = .followers
case .followed:
self = .followed
}
}
}

View File

@ -17,28 +17,29 @@ import Sentry
import ComposeUI
import OSLog
private let oauthScopes = [Scope.read, .write, .follow]
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
final class MastodonController: ObservableObject, Sendable {
static let oauthScopes = [Scope.read, .write, .follow, .push]
@MainActor
static private(set) var all = [UserAccountInfo: MastodonController]()
static private(set) var all = [String: MastodonController]()
@MainActor
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
if let controller = all[account] {
if let controller = all[account.id] {
return controller
} else {
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
all[account] = controller
all[account.id] = controller
return controller
}
}
@MainActor
static func removeForAccount(_ account: UserAccountInfo) {
all.removeValue(forKey: account)
all.removeValue(forKey: account.id)
}
@MainActor
@ -172,13 +173,14 @@ final class MastodonController: ObservableObject, Sendable {
}
/// - Returns: A tuple of client ID and client secret.
func registerApp() async throws -> (String, String) {
if let clientID = client.clientID,
func registerApp(reregister: Bool = false) async throws -> (String, String) {
if !reregister,
let clientID = client.clientID,
let clientSecret = client.clientSecret {
return (clientID, clientSecret)
} else {
let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)
@ -196,7 +198,7 @@ final class MastodonController: ObservableObject, Sendable {
/// - Returns: The access token
func authorize(authorizationCode: String) async throws -> String {
return try await withCheckedThrowingContinuation({ continuation in
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
switch response {
case .failure(let error):
continuation.resume(throwing: error)

View File

@ -0,0 +1,44 @@
//
// ImageActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import UniformTypeIdentifiers
class ImageActivityItemSource: NSObject, UIActivityItemSource {
let data: Data
let url: URL
let image: UIImage?
init(data: Data, url: URL, image: UIImage?) {
self.data = data
self.url = url
self.image = image
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return url
}
func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? {
return image
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
do {
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
try data.write(to: tempURL)
return tempURL
} catch {
return nil
}
}
func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
return (UTType(filenameExtension: url.pathExtension) ?? .image).identifier
}
}

View File

@ -23,7 +23,18 @@ class SaveToPhotosActivity: UIActivity {
return "Save to Photos"
}
override var activityImage: UIImage? {
UIImage(systemName: "square.and.arrow.down")
// 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)
}
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {

View File

@ -0,0 +1,44 @@
//
// VideoActivityItemSource.swift
// Tusker
//
// Created by Shadowfacts on 3/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import UniformTypeIdentifiers
import AVFoundation
class VideoActivityItemSource: UIActivityItemProvider {
private let asset: AVAsset
private let url: URL
private var tempURL: URL?
init(asset: AVAsset, url: URL) {
self.asset = asset
self.url = url
super.init(placeholderItem: url)
}
override var item: Any {
if let tempURL {
return tempURL
}
do {
let data = try Data(contentsOf: url)
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
try data.write(to: tempURL)
self.tempURL = tempURL
return tempURL
} catch {
return url
}
}
override func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
return (UTType(filenameExtension: url.pathExtension) ?? .video).identifier
}
}

View File

@ -15,6 +15,7 @@ import Sentry
import UserAccounts
import ComposeUI
import TuskerPreferences
import PushNotifications
typealias Preferences = TuskerPreferences.Preferences
@ -35,11 +36,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
AppShortcutItem.createItems(for: application)
DispatchQueue.global(qos: .userInitiated).async {
AudioSessionHelper.disable()
AudioSessionHelper.setDefault()
}
if let oldSavedData = SavedDataManager.load() {
do {
for account in oldSavedData.accountIDs {
@ -58,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) {
@ -88,12 +75,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
BackgroundManager.shared.registerHandlers()
initializePushNotifications()
return true
}
#if canImport(Sentry)
private func configureSentry() {
guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String,
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
let dsn = info["SentryDSN"] as? String,
!dsn.isEmpty else {
return
}
@ -123,12 +113,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
event.context?.removeValue(forKey: "culture")
return Preferences.shared.reportErrorsAutomatically ? event : nil
}
}
if let clazz = NSClassFromString("SentryInstallation"),
let objClazz = clazz as AnyObject as? NSObject,
let id = objClazz.value(forKey: "id") as? String {
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
if let clazz = NSClassFromString("SentryInstallation"),
let objClazz = clazz as AnyObject as? NSObject,
let id = objClazz.perform(Selector(("idWithCacheDirectoryPath:")), with: options.cacheDirectoryPath).takeUnretainedValue() as? String {
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
}
}
}
#endif
@ -160,7 +150,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
.search,
.bookmarks,
.myProfile,
.showProfile:
.showProfile,
.showNotification:
if activity.displaysAuxiliaryScene {
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
return "auxiliary"
@ -173,6 +164,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
PushManager.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error)
}
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
}
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)")
return true
} catch {
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
return false
}
})
}
}
#if !os(visionOS)
private func swizzleStatusBar() {
let selector = Selector(("handleTapAction:"))
@ -228,3 +250,52 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
#endif
}
extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
let mainSceneDelegate: MainSceneDelegate
if let delegate = UIApplication.shared.activeScene?.delegate as? MainSceneDelegate {
mainSceneDelegate = delegate
} else if let scene = UIApplication.shared.connectedScenes.first(where: { $0.delegate is MainSceneDelegate }) {
mainSceneDelegate = scene.delegate as! MainSceneDelegate
} else if let accountID = UserAccountsManager.shared.mostRecentAccountID {
let activity = UserActivityManager.mainSceneActivity(accountID: accountID)
activity.addUserInfoEntries(from: ["showNotificationsPreferences": true])
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
return
} else {
// without an account, we can't do anything
return
}
mainSceneDelegate.showNotificationsPreferences()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
completionHandler(.banner)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let userInfo = response.notification.request.content.userInfo
guard let notificationID = userInfo["notificationID"] as? String,
let accountID = userInfo["accountID"] as? String,
let account = UserAccountsManager.shared.getAccount(id: accountID) else {
return
}
if let scene = response.targetScene,
let delegate = scene.delegate as? MainSceneDelegate,
let rootViewController = delegate.rootViewController {
let mastodonController = MastodonController.getForAccount(account)
// 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)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
} else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
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

@ -1,28 +0,0 @@
//
// AudioSessionHelper.swift
// Tusker
//
// Created by Shadowfacts on 6/21/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import Foundation
import AVFoundation
struct AudioSessionHelper {
static func enable() {
try? AVAudioSession.sharedInstance().setActive(true, options: [])
}
static func disable() {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
static func setDefault() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
}
static func setVideoPlayback() {
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
}
}

18
Tusker/Box.swift Normal file
View File

@ -0,0 +1,18 @@
//
// Box.swift
// Tusker
//
// Created by Shadowfacts on 3/26/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
@propertyWrapper
class Box<Value> {
var wrappedValue: Value
init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}

View File

@ -124,12 +124,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
// migrate saved data from local store to cloud store
// this can be removed pre-app store release
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) {
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)
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
@ -377,9 +378,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
backgroundContext.perform {
let statuses = notifications.compactMap { $0.status }
// filter out mentions, otherwise we would double increment the reference count of those accounts
// since the status has the same account as the notification
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
let accounts = notifications.map { $0.account }
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
self.save(context: self.backgroundContext)

View File

@ -47,6 +47,11 @@ public extension MainActor {
///
/// It will crash if run on any non-main thread.
@_unavailableFromAsync
@available(macOS, obsoleted: 14.0)
@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

@ -1,38 +0,0 @@
//
// UIViewController+Delegate.swift
// Tusker
//
// Created by Shadowfacts on 8/27/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
#if !os(visionOS)
import UIKit
extension UIViewController: UIViewControllerTransitioningDelegate {
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let presented = presented as? LargeImageAnimatableViewController,
presented.animationImage != nil {
return LargeImageExpandAnimationController()
}
return nil
}
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if let dismissed = dismissed as? LargeImageAnimatableViewController,
dismissed.animationImage != nil {
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
}
return nil
}
public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
if let animator = animator as? LargeImageShrinkAnimationController,
let interactionController = animator.interactionController,
interactionController.inProgress {
return interactionController
}
return nil
}
}
#endif

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

@ -36,6 +36,7 @@ class HTMLConverter {
}
extension HTMLConverter {
// note: this is duplicated in NotificationExtension
struct Callbacks: HTMLConversionCallbacks {
static func makeURL(string: String) -> URL? {
// Converting WebURL to URL is a small but non-trivial expense (since it works by

View File

@ -49,13 +49,7 @@
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>localhost</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
<dict/>
</dict>
<key>NSCameraUsageDescription</key>
<string>Post photos and videos from the camera.</string>
@ -67,6 +61,7 @@
<string>Post photos from the photo library.</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
@ -76,6 +71,7 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-notification</string>
</array>
<key>OSLogPreferences</key>
<dict>
@ -106,8 +102,13 @@
</dict>
</dict>
</dict>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
<key>TuskerInfo</key>
<dict>
<key>PushProxyHost</key>
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
<key>SentryDSN</key>
<string>$(SENTRY_DSN)</string>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

Some files were not shown because too many files have changed in this diff Show More