Compare commits
232 Commits
2023.8-110
...
develop
Author | SHA1 | Date |
---|---|---|
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 9df3c33c6c | |
Shadowfacts | d4e82d6e7a | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | cb3fd43dbd | |
Shadowfacts | 3d15759fb9 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 09999175f7 | |
Shadowfacts | f2a9f890ff | |
Shadowfacts | 093994b474 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 | |
Shadowfacts | 844d4056e3 | |
Shadowfacts | 00ef131bb6 | |
Shadowfacts | d6be6f14dc | |
Shadowfacts | 2ccf028bc2 | |
Shadowfacts | 3eeffada1f | |
Shadowfacts | 0499255be7 | |
Shadowfacts | f909c1da10 | |
Shadowfacts | 81543965ae | |
Shadowfacts | 96d42756d5 | |
Shadowfacts | f6e57d664f | |
Shadowfacts | c33be1cbf3 | |
Shadowfacts | 6d99156bd9 | |
Shadowfacts | ca764811ed | |
Shadowfacts | a589bb2863 | |
Shadowfacts | 6f35fd2676 | |
Shadowfacts | e83cef1c8c | |
Shadowfacts | b89df3f27b | |
Shadowfacts | 4ecc16a93b | |
Shadowfacts | 8960873ff3 | |
Shadowfacts | 043a708515 | |
Shadowfacts | c6b230414e | |
Shadowfacts | f5e9f66f76 | |
Shadowfacts | ee5f9a62ff | |
Shadowfacts | a92cf8c812 | |
Shadowfacts | 756874949a | |
Shadowfacts | 798e0c0cf1 | |
Shadowfacts | 3f370945e6 | |
Shadowfacts | a759731eba | |
Shadowfacts | 405d5def7c | |
Shadowfacts | 1f9806d02f | |
Shadowfacts | c43c951b92 | |
Shadowfacts | 00c44c612f | |
Shadowfacts | e5c4fceacd | |
Shadowfacts | 70227a7fa1 | |
Shadowfacts | cb5488dcaa | |
Shadowfacts | 910e18fb5e | |
Shadowfacts | 66af946766 | |
Shadowfacts | 6784ed7fdf | |
Shadowfacts | 66f0ba6891 | |
Shadowfacts | ee7bf5138c | |
Shadowfacts | c32181818a | |
Shadowfacts | 4665df228d | |
Shadowfacts | c7a56a9f61 | |
Shadowfacts | 39251b9aa2 | |
Shadowfacts | db534e5993 | |
Shadowfacts | e94bee4fc8 | |
Shadowfacts | 216e58e5ec | |
Shadowfacts | a4d13ad03b | |
Shadowfacts | 05cfecb797 | |
Shadowfacts | 132fcfa099 | |
Shadowfacts | 475b9911b1 | |
Shadowfacts | 7825ccbb3d | |
Shadowfacts | f87da10a29 | |
Shadowfacts | 1eec70449d | |
Shadowfacts | 19ca930ee8 | |
Shadowfacts | 2e31d34e9d | |
Shadowfacts | 8a339ec171 | |
Shadowfacts | c7d79422bd | |
Shadowfacts | baf96a8b06 | |
Shadowfacts | bc516a6326 | |
Shadowfacts | 1cd6af1236 | |
Shadowfacts | 9f6910ba73 | |
Shadowfacts | 9cf4975bfd | |
Shadowfacts | ee992bc0bf | |
Shadowfacts | ff8a83ca2d | |
Shadowfacts | 4c957b86ae | |
Shadowfacts | ff11835333 | |
Shadowfacts | 9353bbb56c | |
Shadowfacts | edc887dd4c | |
Shadowfacts | 68dad77f81 | |
Shadowfacts | 840b83012a | |
Shadowfacts | e150856e91 | |
Shadowfacts | 42a3f6c880 | |
Shadowfacts | 7a47b09b39 | |
Shadowfacts | 241e6f7e3a | |
Shadowfacts | f02afaac26 | |
Shadowfacts | bdd4a4d755 | |
Shadowfacts | 94c1eb2c81 | |
Shadowfacts | b03991ae1d | |
Shadowfacts | f98589b419 | |
Shadowfacts | 9fad2a882a | |
Shadowfacts | ec76754270 | |
Shadowfacts | d0bb197e8c | |
Shadowfacts | efd90bca3e | |
Shadowfacts | 3efa017942 | |
Shadowfacts | c5226f6374 | |
Shadowfacts | 281585cdf0 | |
Shadowfacts | 6d4ab4d54b | |
Shadowfacts | 9e429463b2 | |
Shadowfacts | 51db0066ac | |
Shadowfacts | 9763edef47 | |
Shadowfacts | 442f57bfc4 | |
Shadowfacts | ae7101bb30 | |
Shadowfacts | 490d48c635 | |
Shadowfacts | 69ee3bb4f0 | |
Shadowfacts | 46b455c3d1 | |
Shadowfacts | e522e30ce5 | |
Shadowfacts | c73784aa81 | |
Shadowfacts | 7affa09e5e | |
Shadowfacts | 7435d02f6e | |
Shadowfacts | 2467297f04 | |
Shadowfacts | cf317e15e9 | |
Shadowfacts | bcae60316b | |
Shadowfacts | 1a2fa10708 | |
Shadowfacts | f79c2feea6 | |
Shadowfacts | 7ec87d7853 | |
Shadowfacts | f5704e561b | |
Shadowfacts | d6faf3a37b | |
Shadowfacts | b0a6952643 | |
Shadowfacts | 06b58cfb9c | |
Shadowfacts | afcec24f86 | |
Shadowfacts | 3f90a0df04 | |
Shadowfacts | 395ce6523d | |
Shadowfacts | cced930549 | |
Shadowfacts | 7b2bd1a7af | |
Shadowfacts | f447150bbc | |
Shadowfacts | 08bd78d51b | |
Shadowfacts | f0ec372f50 | |
Shadowfacts | d2c28ada7f | |
Shadowfacts | 375ad25919 | |
Shadowfacts | abf0568398 | |
Shadowfacts | 2386f545e2 | |
Shadowfacts | 908c4ee085 | |
Shadowfacts | 23e5e87915 | |
Shadowfacts | b4693252be | |
Shadowfacts | f3cf2dd8ec | |
Shadowfacts | d96ec2a732 | |
Shadowfacts | b8fe0454b5 | |
Shadowfacts | 1166c6e639 | |
Shadowfacts | eda552c7c9 | |
Shadowfacts | 841c08be2c | |
Shadowfacts | eafb506d64 | |
Shadowfacts | fe00015248 | |
Shadowfacts | 509ed305cd | |
Shadowfacts | c05107bccd | |
Shadowfacts | 4fcc32ca4b | |
Shadowfacts | 6857529d06 | |
Shadowfacts | 42e29862ac | |
Shadowfacts | 3ecee61013 | |
Shadowfacts | f9aee46bbe | |
Shadowfacts | 1cf3ce48ce | |
Shadowfacts | 072bb0daf0 | |
Shadowfacts | d36e0ad27d | |
Shadowfacts | a80cbe79c2 | |
Shadowfacts | cf71fc3f98 | |
Shadowfacts | be977dbea9 | |
Shadowfacts | f327cfd197 | |
Shadowfacts | 4bb01becd2 | |
Shadowfacts | 64fcc87516 | |
Shadowfacts | 62e528fc22 | |
Shadowfacts | 030fd4467d | |
Shadowfacts | 489840019e | |
Shadowfacts | 9af8c06b1c | |
Shadowfacts | 55e0573a5c | |
Shadowfacts | ac142ae11c | |
Shadowfacts | 99a58e2c33 | |
Shadowfacts | c740fb1c1f | |
Shadowfacts | 175001d561 | |
Shadowfacts | d481ef6c9f | |
Shadowfacts | 3caa419659 | |
Shadowfacts | 074b028015 | |
Shadowfacts | bab0dd3294 | |
Shadowfacts | 8a3acc6889 | |
Shadowfacts | d37c5dde2f | |
Shadowfacts | 53260555f6 | |
Shadowfacts | 70524dd642 | |
Shadowfacts | b6232a9f1e | |
Shadowfacts | 41481f465a | |
Shadowfacts | 527e7129af | |
Shadowfacts | 229b51686c | |
Shadowfacts | e156a97861 | |
Shadowfacts | bdec14c463 | |
Shadowfacts | ec0509c645 | |
Shadowfacts | 4500e9be27 | |
Shadowfacts | a2cc3a0436 | |
Shadowfacts | dc654812b1 | |
Shadowfacts | f122383d0b | |
Shadowfacts | 0f6492a051 | |
Shadowfacts | b235f0e826 | |
Shadowfacts | 27d44340e8 | |
Shadowfacts | fc26c9fb54 | |
Shadowfacts | ba60f92223 | |
Shadowfacts | c489d018bd | |
Shadowfacts | a9a518c6c1 | |
Shadowfacts | b4bdf8b0dc | |
Shadowfacts | 94f71541f8 | |
Shadowfacts | c2402303cc | |
Shadowfacts | 5cef76e494 | |
Shadowfacts | bf27b8fd47 | |
Shadowfacts | 32b8d27949 | |
Shadowfacts | fb5581ae67 | |
Shadowfacts | cd01d2f8c3 | |
Shadowfacts | 65c3c8026d | |
Shadowfacts | 534f83e716 | |
Shadowfacts | 93c859a3c4 | |
Shadowfacts | 4d183fe0b2 | |
Shadowfacts | fd72390a22 | |
Shadowfacts | 01bbfc31f2 | |
Shadowfacts | a846954dcd | |
Shadowfacts | 53302e3b26 | |
Shadowfacts | c0301ce7e7 | |
Shadowfacts | 14f32f24fa | |
Shadowfacts | 19db78e352 | |
Shadowfacts | 9d01bbabd7 | |
Shadowfacts | a93a4fccc1 | |
Shadowfacts | 1da25300ca | |
Shadowfacts | 978486bc15 | |
Shadowfacts | 27dd8a1927 | |
Shadowfacts | 78196e14c3 | |
Shadowfacts | a0eb5dc596 | |
Shadowfacts | e4c22a0205 |
|
@ -1,3 +1,54 @@
|
|||
## 2024.2
|
||||
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
|
||||
|
||||
Features/Improvements:
|
||||
- Push notifications
|
||||
- Add post preview to Appearance preferences
|
||||
- Show instance announcements in Notifications tab
|
||||
- Add subscription option to Tip Jar
|
||||
- iPadOS: Multi-column navigation
|
||||
- Pleroma/Akkoma: Emoji reaction notifications
|
||||
|
||||
Bugfixes:
|
||||
- Fix fetching server info on some instances
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- macOS: Remove in-app Safari preferences
|
||||
- Pleroma: Handle posts with missing creation date
|
||||
|
||||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
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.
|
||||
|
||||
|
|
118
CHANGELOG.md
118
CHANGELOG.md
|
@ -1,5 +1,123 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.3 (126)
|
||||
Bugfixes:
|
||||
- Fix an issue displaying post HTML in certain edge cases
|
||||
- Fix crash when video attachment playback ends
|
||||
- Fix excessive CPU usage when scrubbing video attachment
|
||||
- Fix video attachment thubmnails being flipped on Compose screen
|
||||
- Pleroma: Fix editing attachment descriptions not working
|
||||
|
||||
## 2024.2 (124)
|
||||
Features/Improvements:
|
||||
- Add subscription option to Tip Jar
|
||||
|
||||
Bugfixes:
|
||||
- Fix attachment captions not displaying while loading in gallery
|
||||
- Fix tapping follow request push notification not working
|
||||
- Pleroma: Handle posts with missing creation dates
|
||||
|
||||
## 2024.2 (122)
|
||||
Features/Improvements:
|
||||
- Show instance announcements in Notifications
|
||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue fetching server info on some instances
|
||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||
|
||||
## 2024.2 (121)
|
||||
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
||||
|
||||
Features/Improvements:
|
||||
- iPadOS: Enable multi-column navigation
|
||||
- Add post preview to Appearance preferences
|
||||
- Consolidate Media preferences section with Appearance
|
||||
- Add icons to Preferences sections
|
||||
|
||||
Bugfixes:
|
||||
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
|
||||
- Fix push notifications not working with certain accounts
|
||||
- Fix links on About screen not being aligned
|
||||
- macOS: Remove non-functional in-app Safari preferences
|
||||
|
||||
## 2024.2 (120)
|
||||
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||
|
||||
## 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
|
||||
- Show verified link in account lists
|
||||
- Change cell separator appearance on posts
|
||||
|
||||
Bugfixes:
|
||||
- Fix tapping Followers button on profiles opening Following screen
|
||||
- Fix crash when removing poll option on Compose screen
|
||||
- Fix leading indentation in post text being ignored
|
||||
- Fix crash when viewing posts containing HTML numeric character references
|
||||
- Fix paragraphs starting with links being combined with previous paragraph
|
||||
|
||||
## 2024.1 (112)
|
||||
Bugfixes:
|
||||
- Fix profile field links not displaying
|
||||
- Fix various issues displaying rich text in posts
|
||||
- Fix issue changing scope after searching
|
||||
- Fix crash when searching for "from:me"
|
||||
|
||||
## 2024.1 (111)
|
||||
This build contains a complete rewrite of the HTML parsing pipeline for displaying posts. If you notice any issues with how post text appears—especially when it differs from on the web—please report it!
|
||||
|
||||
## 2023.8 (110)
|
||||
Bugfixes:
|
||||
- Fix potential crash after deleting List on Explore screen
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ActionViewController: UIViewController {
|
||||
|
||||
|
@ -17,25 +18,29 @@ class ActionViewController: UIViewController {
|
|||
super.viewDidLoad()
|
||||
|
||||
findURLFromWebPage { (components) in
|
||||
if let components = components {
|
||||
self.searchForURLInApp(components)
|
||||
} else {
|
||||
self.findURLItem { (components) in
|
||||
if let components = components {
|
||||
self.searchForURLInApp(components)
|
||||
DispatchQueue.main.async {
|
||||
if let components {
|
||||
self.searchForURLInApp(components)
|
||||
} else {
|
||||
self.findURLItem { (components) in
|
||||
if let components {
|
||||
DispatchQueue.main.async {
|
||||
self.searchForURLInApp(components)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findURLFromWebPage(completion: @escaping (URLComponents?) -> Void) {
|
||||
private func findURLFromWebPage(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for provider in item.attachments! {
|
||||
guard provider.hasItemConformingToTypeIdentifier(kUTTypePropertyList as String) else {
|
||||
guard provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) else {
|
||||
continue
|
||||
}
|
||||
provider.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil) { (result, error) in
|
||||
provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { (result, error) in
|
||||
guard let result = result as? [String: Any],
|
||||
let jsResult = result[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
|
||||
let urlString = jsResult["activityPubURL"] as? String ?? jsResult["url"] as? String,
|
||||
|
@ -53,13 +58,13 @@ class ActionViewController: UIViewController {
|
|||
completion(nil)
|
||||
}
|
||||
|
||||
private func findURLItem(completion: @escaping (URLComponents?) -> Void) {
|
||||
private func findURLItem(completion: @escaping @Sendable (URLComponents?) -> Void) {
|
||||
for item in extensionContext!.inputItems as! [NSExtensionItem] {
|
||||
for provider in item.attachments! {
|
||||
guard provider.hasItemConformingToTypeIdentifier(kUTTypeURL as String) else {
|
||||
guard provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) else {
|
||||
continue
|
||||
}
|
||||
provider.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { (result, error) in
|
||||
provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { (result, error) in
|
||||
guard let result = result as? URL,
|
||||
let components = URLComponents(url: result, resolvingAgainstBaseURL: false) else {
|
||||
completion(nil)
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
|
@ -15,7 +15,8 @@ public protocol ComposeMastodonContext {
|
|||
var instanceFeatures: InstanceFeatures { get }
|
||||
|
||||
func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?)
|
||||
func getCustomEmojis(completion: @escaping ([Emoji]) -> Void)
|
||||
|
||||
func getCustomEmojis() async -> [Emoji]
|
||||
|
||||
@MainActor
|
||||
func searchCachedAccounts(query: String) -> [AccountProtocol]
|
||||
|
|
|
@ -136,7 +136,7 @@ class AttachmentRowController: ViewController {
|
|||
.overlay {
|
||||
thumbnailFocusedOverlay
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.frame(width: thumbnailSize, height: thumbnailSize)
|
||||
.onTapGesture {
|
||||
textEditorFocused = false
|
||||
// if we just focus the attachment immediately, the text editor doesn't actually unfocus
|
||||
|
@ -162,7 +162,7 @@ class AttachmentRowController: ViewController {
|
|||
|
||||
switch controller.descriptionMode {
|
||||
case .allowEntry:
|
||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: 80)
|
||||
InlineAttachmentDescriptionView(attachment: attachment, minHeight: thumbnailSize)
|
||||
.matchedGeometrySource(id: AttachmentDescriptionTextViewID(attachment), presentationID: attachment.id)
|
||||
.focused($textEditorFocused)
|
||||
|
||||
|
@ -177,11 +177,27 @@ class AttachmentRowController: ViewController {
|
|||
Text(error.localizedDescription)
|
||||
}
|
||||
.onAppear(perform: controller.updateAttachmentDescriptionState)
|
||||
#if os(visionOS)
|
||||
.onChange(of: textEditorFocused) {
|
||||
if !textEditorFocused && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#else
|
||||
.onChange(of: textEditorFocused) { newValue in
|
||||
if !newValue && controller.focusAttachmentOnTextEditorUnfocus {
|
||||
controller.focusAttachment()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var thumbnailSize: CGFloat {
|
||||
#if os(visionOS)
|
||||
120
|
||||
#else
|
||||
80
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
@ -208,6 +224,7 @@ extension AttachmentRowController {
|
|||
|
||||
private extension View {
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@available(visionOS 1.0, *)
|
||||
@ViewBuilder
|
||||
func contextMenu<M: View, P: View>(@ViewBuilder menuItems: () -> M, @ViewBuilder previewIfAvailable preview: () -> P) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
|
|
|
@ -40,9 +40,14 @@ class AttachmentThumbnailController: ViewController {
|
|||
case .video, .gifv:
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
|
||||
case .audio, .unknown:
|
||||
break
|
||||
|
@ -87,9 +92,14 @@ class AttachmentThumbnailController: ViewController {
|
|||
if type.conforms(to: .movie) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
||||
imageGenerator.appliesPreferredTrackTransform = true
|
||||
#if os(visionOS)
|
||||
#warning("Use async AVAssetImageGenerator.image(at:)")
|
||||
#else
|
||||
if let cgImage = try? imageGenerator.copyCGImage(at: .zero, actualTime: nil) {
|
||||
self.image = UIImage(cgImage: cgImage)
|
||||
}
|
||||
#endif
|
||||
} else if let data = try? Data(contentsOf: url) {
|
||||
if type == .gif {
|
||||
self.gifController = GIFController(gifData: data)
|
||||
|
|
|
@ -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)
|
||||
|
@ -131,9 +134,9 @@ class AttachmentsListController: ViewController {
|
|||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
attachmentsList
|
||||
|
||||
Group {
|
||||
attachmentsList
|
||||
|
||||
if controller.parent.config.presentAssetPicker != nil {
|
||||
addImageButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
|
@ -147,6 +150,10 @@ class AttachmentsListController: ViewController {
|
|||
togglePollButton
|
||||
.listRowInsets(EdgeInsets(top: cellPadding / 2, leading: cellPadding / 2, bottom: cellPadding / 2, trailing: cellPadding / 2))
|
||||
}
|
||||
#if os(visionOS)
|
||||
.buttonStyle(.bordered)
|
||||
.labelStyle(AttachmentButtonLabelStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
private var attachmentsList: some View {
|
||||
|
@ -246,3 +253,11 @@ fileprivate struct SheetOrPopover<V: View>: ViewModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(visionOS 1.0, *)
|
||||
fileprivate struct AttachmentButtonLabelStyle: LabelStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DefaultLabelStyle().makeBody(configuration: configuration)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
@ -44,11 +45,7 @@ class AutocompleteEmojisController: ViewController {
|
|||
|
||||
@MainActor
|
||||
private func queryChanged(_ query: String) async {
|
||||
var emojis = await withCheckedContinuation { continuation in
|
||||
composeController.mastodonController.getCustomEmojis {
|
||||
continuation.resume(returning: $0)
|
||||
}
|
||||
}
|
||||
var emojis = await composeController.mastodonController.getCustomEmojis()
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -275,7 +275,9 @@ public final class ComposeController: ViewController {
|
|||
@OptionalObservedObject var poster: PostService?
|
||||
@EnvironmentObject var controller: ComposeController
|
||||
@EnvironmentObject var draft: Draft
|
||||
#if !os(visionOS)
|
||||
@StateObject private var keyboardReader = KeyboardReader()
|
||||
#endif
|
||||
@State private var globalFrameOutsideList = CGRect.zero
|
||||
|
||||
init(poster: PostService?) {
|
||||
|
@ -318,16 +320,25 @@ public final class ComposeController: ViewController {
|
|||
.transition(.move(edge: .bottom))
|
||||
.animation(.default, value: controller.currentInput?.autocompleteState)
|
||||
|
||||
#if !os(visionOS)
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
#endif
|
||||
}
|
||||
#if !os(visionOS)
|
||||
// on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
|
||||
.padding(.bottom, keyboardInset)
|
||||
#endif
|
||||
.transition(.move(edge: .bottom))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { cancelButton }
|
||||
ToolbarItem(placement: .confirmationAction) { postButton }
|
||||
#if os(visionOS)
|
||||
ToolbarItem(placement: .bottomOrnament) {
|
||||
ControllerView(controller: { controller.toolbarController })
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
|
@ -419,7 +430,9 @@ public final class ComposeController: ViewController {
|
|||
.listRowBackground(config.backgroundColor)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboardInteractivelyIfAvailable()
|
||||
#endif
|
||||
.disabled(controller.isPosting)
|
||||
}
|
||||
|
||||
|
@ -462,6 +475,7 @@ public final class ComposeController: ViewController {
|
|||
}
|
||||
}
|
||||
|
||||
#if !os(visionOS)
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
private var keyboardInset: CGFloat {
|
||||
if #unavailable(iOS 16.0),
|
||||
|
@ -472,6 +486,7 @@ public final class ComposeController: ViewController {
|
|||
return 0
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -34,11 +34,16 @@ class PollController: ViewController {
|
|||
}
|
||||
|
||||
private func moveOptions(indices: IndexSet, newIndex: Int) {
|
||||
poll.options.moveObjects(at: indices, to: newIndex)
|
||||
// see AttachmentsListController.moveAttachments
|
||||
var array = poll.pollOptions
|
||||
array.move(fromOffsets: indices, toOffset: newIndex)
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private func removeOption(_ option: PollOption) {
|
||||
poll.options.remove(option)
|
||||
var array = poll.pollOptions
|
||||
array.remove(at: poll.options.index(of: option))
|
||||
poll.options = NSMutableOrderedSet(array: array)
|
||||
}
|
||||
|
||||
private var canAddOption: Bool {
|
||||
|
@ -123,9 +128,15 @@ class PollController: ViewController {
|
|||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.foregroundColor(backgroundColor)
|
||||
)
|
||||
#if os(visionOS)
|
||||
.onChange(of: controller.duration) {
|
||||
poll.duration = controller.duration.timeInterval
|
||||
}
|
||||
#else
|
||||
.onChange(of: controller.duration) { newValue in
|
||||
poll.duration = newValue.timeInterval
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
|
|
|
@ -45,61 +45,26 @@ class ToolbarController: ViewController {
|
|||
@EnvironmentObject private var composeController: ComposeController
|
||||
@ScaledMetric(relativeTo: .body) private var imageSize: CGFloat = 22
|
||||
|
||||
#if !os(visionOS)
|
||||
@State private var minWidth: CGFloat?
|
||||
@State private var realWidth: CGFloat?
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
#if os(visionOS)
|
||||
buttons
|
||||
#else
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
cwButton
|
||||
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||
localOnlyPicker
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.padding(.leading, 4)
|
||||
#else
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.emojiPicker) {
|
||||
customEmojiButton
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.formattingButtons),
|
||||
composeController.config.contentType != .plain {
|
||||
|
||||
Spacer()
|
||||
formatButtons
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if #available(iOS 16.0, *),
|
||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: minWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
realWidth = width
|
||||
}
|
||||
})
|
||||
buttons
|
||||
.padding(.horizontal, 16)
|
||||
.frame(minWidth: minWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: ToolbarWidthPrefKey.self, value: proxy.size.width)
|
||||
.onPreferenceChange(ToolbarWidthPrefKey.self) { width in
|
||||
realWidth = width
|
||||
}
|
||||
})
|
||||
}
|
||||
.scrollDisabledIfAvailable(realWidth ?? 0 <= minWidth ?? 0)
|
||||
.frame(height: ToolbarController.height)
|
||||
|
@ -116,6 +81,52 @@ class ToolbarController: ViewController {
|
|||
minWidth = width
|
||||
}
|
||||
})
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var buttons: some View {
|
||||
HStack(spacing: 0) {
|
||||
cwButton
|
||||
|
||||
MenuPicker(selection: visibilityBinding, options: visibilityOptions, buttonStyle: .iconOnly)
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
// the button has a bunch of extra space by default, but combined with what we add it's too much
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
.disabled(composeController.mastodonController.instanceFeatures.localOnlyPostsVisibility && draft.localOnly)
|
||||
|
||||
if composeController.mastodonController.instanceFeatures.localOnlyPosts {
|
||||
localOnlyPicker
|
||||
#if targetEnvironment(macCatalyst)
|
||||
.padding(.leading, 4)
|
||||
#elseif !os(visionOS)
|
||||
.padding(.horizontal, -8)
|
||||
#endif
|
||||
.disabled(draft.editedStatusID != nil)
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.emojiPicker) {
|
||||
customEmojiButton
|
||||
}
|
||||
|
||||
if let currentInput = composeController.currentInput,
|
||||
currentInput.toolbarElements.contains(.formattingButtons),
|
||||
composeController.config.contentType != .plain {
|
||||
|
||||
Spacer()
|
||||
formatButtons
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if #available(iOS 16.0, *),
|
||||
composeController.mastodonController.instanceFeatures.createStatusWithLanguage {
|
||||
LanguagePicker(draftLanguage: $draft.language, hasChangedSelection: $composeController.hasChangedLanguageSelection)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cwButton: some View {
|
||||
|
@ -170,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)
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
// Created by Shadowfacts on 3/7/23.
|
||||
//
|
||||
|
||||
#if !os(visionOS)
|
||||
|
||||
import UIKit
|
||||
import Combine
|
||||
|
||||
|
@ -37,3 +39,5 @@ class KeyboardReader: ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import PencilKit
|
|||
|
||||
extension PKDrawing {
|
||||
|
||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = UIScreen.main.scale) -> UIImage {
|
||||
func imageInLightMode(from rect: CGRect, scale: CGFloat = 1) -> UIImage {
|
||||
let lightTraitCollection = UITraitCollection(userInterfaceStyle: .light)
|
||||
var drawingImage: UIImage!
|
||||
lightTraitCollection.performAsCurrent {
|
||||
|
|
|
@ -8,6 +8,11 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
#if os(visionOS)
|
||||
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||
self.scrollDisabled(disabled)
|
||||
}
|
||||
#else
|
||||
@available(iOS, obsoleted: 16.0)
|
||||
@ViewBuilder
|
||||
func scrollDisabledIfAvailable(_ disabled: Bool) -> some View {
|
||||
|
@ -17,4 +22,5 @@ extension View {
|
|||
self
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
|
@ -22,13 +22,21 @@ struct InlineAttachmentDescriptionView: View {
|
|||
self.minHeight = minHeight
|
||||
}
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
if attachment.attachmentDescription.isEmpty {
|
||||
placeholder
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
.offset(placeholderOffset)
|
||||
}
|
||||
|
||||
WrappedTextView(
|
||||
|
@ -84,6 +92,10 @@ private struct WrappedTextView: UIViewRepresentable {
|
|||
view.font = .preferredFont(forTextStyle: .body)
|
||||
view.adjustsFontForContentSizeCategory = true
|
||||
view.textContainer.lineBreakMode = .byWordWrapping
|
||||
#if os(visionOS)
|
||||
view.borderStyle = .roundedRect
|
||||
view.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
||||
#endif
|
||||
return view
|
||||
}
|
||||
|
||||
|
|
|
@ -52,12 +52,19 @@ struct EmojiTextField: UIViewRepresentable {
|
|||
if text != uiView.text {
|
||||
uiView.text = text
|
||||
}
|
||||
if placeholder != uiView.attributedPlaceholder?.string {
|
||||
uiView.attributedPlaceholder = NSAttributedString(string: placeholder, attributes: [
|
||||
.foregroundColor: UIColor.secondaryLabel,
|
||||
])
|
||||
}
|
||||
|
||||
context.coordinator.text = $text
|
||||
context.coordinator.maxLength = maxLength
|
||||
context.coordinator.focusNextView = focusNextView
|
||||
|
||||
#if !os(visionOS)
|
||||
uiView.backgroundColor = colorScheme == .dark ? UIColor(controller.config.fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
|
||||
if becomeFirstResponder?.wrappedValue == true {
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
@ -129,7 +129,9 @@ private struct LanguagePickerList: View {
|
|||
.scrollContentBackground(.hidden)
|
||||
.background(groupedBackgroundColor.edgesIgnoringSafeArea(.all))
|
||||
.searchable(text: $query)
|
||||
#if !os(visionOS)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
.navigationTitle("Post Language")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
@ -152,13 +154,23 @@ private struct LanguagePickerList: View {
|
|||
.map { Lang(code: $0) }
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
#if os(visionOS)
|
||||
.onChange(of: query, initial: true) {
|
||||
filteredLangsChanged(query: query)
|
||||
}
|
||||
#else
|
||||
.onChange(of: query) { newValue in
|
||||
if newValue.isEmpty {
|
||||
filteredLangs = nil
|
||||
} else {
|
||||
filteredLangs = langs.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(newValue) || $0.code.identifier.localizedCaseInsensitiveContains(newValue)
|
||||
}
|
||||
filteredLangsChanged(query: newValue)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func filteredLangsChanged(query: String) {
|
||||
if query.isEmpty {
|
||||
filteredLangs = nil
|
||||
} else {
|
||||
filteredLangs = langs.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(query) || $0.code.identifier.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,19 +23,41 @@ struct MainTextView: View {
|
|||
controller.config
|
||||
}
|
||||
|
||||
private var placeholderOffset: CGSize {
|
||||
#if os(visionOS)
|
||||
CGSize(width: 8, height: 8)
|
||||
#else
|
||||
CGSize(width: 4, height: 8)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var textViewBackgroundColor: UIColor? {
|
||||
#if os(visionOS)
|
||||
nil
|
||||
#else
|
||||
colorScheme == .dark ? UIColor(config.fillColor) : .secondarySystemBackground
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
colorScheme == .dark ? config.fillColor : Color(uiColor: .secondarySystemBackground)
|
||||
MainWrappedTextViewRepresentable(
|
||||
text: $draft.text,
|
||||
backgroundColor: textViewBackgroundColor,
|
||||
becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder,
|
||||
updateSelection: $updateSelection,
|
||||
textDidChange: textDidChange
|
||||
)
|
||||
|
||||
if draft.text.isEmpty {
|
||||
ControllerView(controller: { PlaceholderController() })
|
||||
.font(.system(size: fontSize))
|
||||
.foregroundColor(.secondary)
|
||||
.offset(x: 4, y: 8)
|
||||
.offset(placeholderOffset)
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
MainWrappedTextViewRepresentable(text: $draft.text, becomeFirstResponder: $controller.mainComposeTextViewBecomeFirstResponder, updateSelection: $updateSelection, textDidChange: textDidChange)
|
||||
}
|
||||
.frame(height: effectiveHeight)
|
||||
.onAppear(perform: becomeFirstResponderOnFirstAppearance)
|
||||
|
@ -62,6 +84,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
let backgroundColor: UIColor?
|
||||
@Binding var becomeFirstResponder: Bool
|
||||
@Binding var updateSelection: ((UITextView) -> Void)?
|
||||
let textDidChange: (UITextView) -> Void
|
||||
|
@ -74,10 +97,16 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
context.coordinator.textView = textView
|
||||
textView.delegate = context.coordinator
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .clear
|
||||
textView.font = UIFontMetrics.default.scaledFont(for: .systemFont(ofSize: 20))
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.textContainer.lineBreakMode = .byWordWrapping
|
||||
|
||||
#if os(visionOS)
|
||||
textView.borderStyle = .roundedRect
|
||||
// yes, the X inset is 4 less than the placeholder offset
|
||||
textView.textContainerInset = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4)
|
||||
#endif
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
|
@ -90,6 +119,8 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
uiView.isEditable = isEnabled
|
||||
uiView.keyboardType = controller.config.useTwitterKeyboard ? .twitter : .default
|
||||
|
||||
uiView.backgroundColor = backgroundColor
|
||||
|
||||
context.coordinator.text = $text
|
||||
|
||||
if let updateSelection {
|
||||
|
@ -228,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)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
public protocol DuckableViewController: UIViewController {
|
||||
func duckableViewControllerShouldDuck() -> DuckAttemptAction
|
||||
|
||||
|
|
|
@ -62,7 +62,9 @@ public class DuckableContainerViewController: UIViewController {
|
|||
guard case .idle = state else {
|
||||
if animated,
|
||||
case .ducked(_, placeholder: let placeholder) = state {
|
||||
#if !os(visionOS)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
#endif
|
||||
let origConstant = placeholder.topConstraint.constant
|
||||
UIView.animateKeyframes(withDuration: 0.4, delay: 0) {
|
||||
UIView.addKeyframe(withRelativeStartTime: 0, relativeDuration: 0.5) {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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() {
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -0,0 +1,140 @@
|
|||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
container.addSubview(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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
import Combine
|
||||
import Pachyderm
|
||||
|
||||
public class InstanceFeatures: ObservableObject {
|
||||
public final class InstanceFeatures: ObservableObject {
|
||||
private static let pleromaVersionRegex = try! NSRegularExpression(pattern: "\\(compatible; (pleroma|akkoma) (.*)\\)", options: .caseInsensitive)
|
||||
|
||||
private let _featuresUpdated = PassthroughSubject<Void, Never>()
|
||||
|
@ -157,7 +157,7 @@ public class InstanceFeatures: ObservableObject {
|
|||
}
|
||||
|
||||
public var needsEditAttachmentsInSeparateRequest: Bool {
|
||||
instanceType.isPleroma(.akkoma(nil))
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var composeDirectStatuses: Bool {
|
||||
|
@ -184,6 +184,39 @@ public 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() {
|
||||
}
|
||||
|
||||
|
|
|
@ -225,6 +225,7 @@ class MatchedGeometryDismissAnimationController<Content: View>: NSObject, UIView
|
|||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(true)
|
||||
matchedGeomVC.state.animating = false
|
||||
matchedGeomVC.state.mode = .idle
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ import WebURL
|
|||
/**
|
||||
The base Mastodon API client.
|
||||
*/
|
||||
public class Client {
|
||||
public struct Client: Sendable {
|
||||
|
||||
public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
|
||||
|
||||
|
@ -20,8 +20,6 @@ public class Client {
|
|||
let session: URLSession
|
||||
|
||||
public var accessToken: String?
|
||||
|
||||
public var appID: String?
|
||||
public var clientID: String?
|
||||
public var clientSecret: String?
|
||||
|
||||
|
@ -44,7 +42,8 @@ public class Client {
|
|||
} 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)"))
|
||||
return Date(timeIntervalSinceReferenceDate: 0)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -61,9 +60,11 @@ public class Client {
|
|||
return encoder
|
||||
}()
|
||||
|
||||
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
|
||||
public init(baseURL: URL, accessToken: String? = nil, clientID: String? = nil, clientSecret: String? = nil, session: URLSession = .shared) {
|
||||
self.baseURL = baseURL
|
||||
self.accessToken = accessToken
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.session = session
|
||||
}
|
||||
|
||||
|
@ -150,14 +151,7 @@ public class Client {
|
|||
"scopes" => scopes.scopeString,
|
||||
"website" => website?.absoluteString
|
||||
]))
|
||||
run(request) { result in
|
||||
defer { completion(result) }
|
||||
guard case let .success(application, _) = result else { return }
|
||||
|
||||
self.appID = application.id
|
||||
self.clientID = application.clientID
|
||||
self.clientSecret = application.clientSecret
|
||||
}
|
||||
run(request, completion: completion)
|
||||
}
|
||||
|
||||
public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
|
||||
|
@ -169,12 +163,7 @@ public class Client {
|
|||
"redirect_uri" => redirectURI,
|
||||
"scope" => scopes.scopeString,
|
||||
]))
|
||||
run(request) { result in
|
||||
defer { completion(result) }
|
||||
guard case let .success(loginSettings, _) = result else { return }
|
||||
|
||||
self.accessToken = loginSettings.accessToken
|
||||
}
|
||||
run(request, completion: completion)
|
||||
}
|
||||
|
||||
public func revokeAccessToken() async throws {
|
||||
|
@ -198,21 +187,16 @@ public class Client {
|
|||
})
|
||||
}
|
||||
|
||||
public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
|
||||
public func nodeInfo() async throws -> NodeInfo {
|
||||
let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
|
||||
run(wellKnown) { result in
|
||||
switch result {
|
||||
case let .failure(error):
|
||||
completion(.failure(error))
|
||||
|
||||
case let .success(wellKnown, _):
|
||||
if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||
let href = WebURL(url.href),
|
||||
href.host == WebURL(self.baseURL)?.host {
|
||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||
self.run(nodeInfo, completion: completion)
|
||||
}
|
||||
}
|
||||
let wellKnownResults = try await run(wellKnown).0
|
||||
if let url = wellKnownResults.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
|
||||
let href = WebURL(url.href),
|
||||
href.host == WebURL(self.baseURL)?.host {
|
||||
let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
|
||||
return try await run(nodeInfo).0
|
||||
} else {
|
||||
throw NodeInfoError.noWellKnownLink
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -358,6 +342,10 @@ public class Client {
|
|||
}
|
||||
|
||||
// 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 }
|
||||
|
@ -600,4 +588,15 @@ extension Client {
|
|||
case invalidModel(Swift.Error)
|
||||
case mastodonError(Int, String)
|
||||
}
|
||||
|
||||
enum NodeInfoError: LocalizedError {
|
||||
case noWellKnownLink
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noWellKnownLink:
|
||||
return "No well-known link"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,99 @@
|
|||
//
|
||||
// Announcement.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 4/16/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
|
||||
public let id: String
|
||||
public let content: String
|
||||
public let startsAt: Date?
|
||||
public let endsAt: Date?
|
||||
public let allDay: Bool
|
||||
public let publishedAt: Date
|
||||
public let updatedAt: Date
|
||||
public let read: Bool?
|
||||
public let mentions: [Account]
|
||||
public let statuses: [Status]
|
||||
public let tags: [Hashtag]
|
||||
public let emojis: [Emoji]
|
||||
public var reactions: [Reaction]
|
||||
|
||||
public static func all() -> Request<[Announcement]> {
|
||||
return Request(method: .get, path: "/api/v1/announcements")
|
||||
}
|
||||
|
||||
public static func dismiss(id: String) -> Request<Empty> {
|
||||
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
|
||||
}
|
||||
|
||||
public static func react(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
public static func unreact(id: String, name: String) -> Request<Empty> {
|
||||
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case content
|
||||
case startsAt = "starts_at"
|
||||
case endsAt = "ends_at"
|
||||
case allDay = "all_day"
|
||||
case publishedAt = "published_at"
|
||||
case updatedAt = "updated_at"
|
||||
case read
|
||||
case mentions
|
||||
case statuses
|
||||
case tags
|
||||
case emojis
|
||||
case reactions
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Account: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let username: String
|
||||
public let url: WebURL
|
||||
public let acct: String
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Status: Decodable, Sendable, Hashable {
|
||||
public let id: String
|
||||
public let url: WebURL
|
||||
}
|
||||
}
|
||||
|
||||
extension Announcement {
|
||||
public struct Reaction: Decodable, Sendable, Hashable {
|
||||
public let name: String
|
||||
public var count: Int
|
||||
public var me: Bool?
|
||||
public let url: URL?
|
||||
public let staticURL: URL?
|
||||
|
||||
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
|
||||
self.name = name
|
||||
self.count = count
|
||||
self.me = me
|
||||
self.url = url
|
||||
self.staticURL = staticURL
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case count
|
||||
case me
|
||||
case url
|
||||
case staticURL = "static_url"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,6 +25,17 @@ public struct Attachment: Codable, Sendable {
|
|||
], nil))
|
||||
}
|
||||
|
||||
public init(id: String, kind: Attachment.Kind, url: URL, remoteURL: URL? = nil, previewURL: URL? = nil, meta: Attachment.Metadata? = nil, description: String? = nil, blurHash: String? = nil) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.url = url
|
||||
self.remoteURL = remoteURL
|
||||
self.previewURL = previewURL
|
||||
self.meta = meta
|
||||
self.description = description
|
||||
self.blurHash = blurHash
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
|
|
|
@ -26,6 +26,38 @@ public struct Card: Codable, Sendable {
|
|||
/// Only present when returned from the trending links endpoint
|
||||
public let history: [History]?
|
||||
|
||||
public init(
|
||||
url: WebURL,
|
||||
title: String,
|
||||
description: String,
|
||||
image: WebURL? = nil,
|
||||
kind: Card.Kind,
|
||||
authorName: String? = nil,
|
||||
authorURL: WebURL? = nil,
|
||||
providerName: String? = nil,
|
||||
providerURL: WebURL? = nil,
|
||||
html: String? = nil,
|
||||
width: Int? = nil,
|
||||
height: Int? = nil,
|
||||
blurhash: String? = nil,
|
||||
history: [History]? = nil
|
||||
) {
|
||||
self.url = url
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.image = image
|
||||
self.kind = kind
|
||||
self.authorName = authorName
|
||||
self.authorURL = authorURL
|
||||
self.providerName = providerName
|
||||
self.providerURL = providerURL
|
||||
self.html = html
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.blurhash = blurhash
|
||||
self.history = history
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
|
|
|
@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
|
|||
}
|
||||
}
|
||||
|
||||
extension Emoji: Equatable {
|
||||
extension Emoji: Equatable, Hashable {
|
||||
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
|
||||
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(shortcode)
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ extension InstanceV2 {
|
|||
public struct Thumbnail: Decodable, Sendable {
|
||||
public let url: String
|
||||
public let blurhash: String?
|
||||
public let versions: ThumbnailVersions
|
||||
public let versions: ThumbnailVersions?
|
||||
}
|
||||
|
||||
public struct ThumbnailVersions: Decodable, Sendable {
|
||||
|
@ -120,6 +120,6 @@ extension InstanceV2 {
|
|||
extension InstanceV2 {
|
||||
public struct Contact: Decodable, Sendable {
|
||||
public let email: String
|
||||
public let account: Account
|
||||
public let account: Account?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import WebURL
|
||||
|
||||
public struct Notification: Decodable, Sendable {
|
||||
public let id: String
|
||||
|
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
|
|||
public let createdAt: Date
|
||||
public let account: Account
|
||||
public let status: Status?
|
||||
// Only present for pleroma emoji reactions
|
||||
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
|
||||
public let emoji: String?
|
||||
public let emojiURL: WebURL?
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
|
|||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.account = try container.decode(Account.self, forKey: .account)
|
||||
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
|
||||
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
|
||||
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
|
||||
}
|
||||
|
||||
public static func dismiss(id notificationID: String) -> Request<Empty> {
|
||||
|
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
|
|||
case createdAt = "created_at"
|
||||
case account
|
||||
case status
|
||||
case emoji
|
||||
case emojiURL = "emoji_url"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -52,6 +61,7 @@ extension Notification {
|
|||
case poll
|
||||
case update
|
||||
case status
|
||||
case emojiReaction = "pleroma:emoji_reaction"
|
||||
case unknown
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ public enum Scope: String, Sendable {
|
|||
case read
|
||||
case write
|
||||
case follow
|
||||
case push
|
||||
}
|
||||
|
||||
extension Array where Element == Scope {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum SearchOperatorType: String, CaseIterable, Equatable {
|
||||
public enum SearchOperatorType: String, CaseIterable, Equatable, Sendable {
|
||||
case has
|
||||
case `is`
|
||||
case language
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct StatusEdit: Decodable {
|
||||
public struct StatusEdit: Decodable, Sendable {
|
||||
public let content: String
|
||||
public let spoilerText: String
|
||||
public let sensitive: Bool
|
||||
|
@ -28,10 +28,10 @@ public struct StatusEdit: Decodable {
|
|||
case emojis
|
||||
}
|
||||
|
||||
public struct Poll: Decodable {
|
||||
public struct Poll: Decodable, Sendable {
|
||||
public let options: [Option]
|
||||
|
||||
public struct Option: Decodable {
|
||||
public struct Option: Decodable, Sendable {
|
||||
public let title: String
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public struct StatusSource: Decodable {
|
||||
public struct StatusSource: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let text: String
|
||||
public let spoilerText: String
|
||||
|
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ public class InstanceSelector {
|
|||
}
|
||||
|
||||
public extension InstanceSelector {
|
||||
struct Instance: Codable {
|
||||
struct Instance: Codable, Sendable {
|
||||
public let domain: String
|
||||
public let description: String
|
||||
public let proxiedThumbnailURL: URL
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.DS_Store
|
||||
/.build
|
||||
/Packages
|
||||
xcuserdata/
|
||||
DerivedData/
|
||||
.swiftpm/configuration/registries.json
|
||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||
.netrc
|
|
@ -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>
|
|
@ -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"]),
|
||||
]
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -0,0 +1,203 @@
|
|||
//
|
||||
// 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 {
|
||||
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
//
|
||||
// FuzzyMatcher.swift
|
||||
// ComposeUI
|
||||
// TuskerComponents
|
||||
//
|
||||
// Created by Shadowfacts on 10/10/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
|
@ -8,7 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
struct FuzzyMatcher {
|
||||
public struct FuzzyMatcher {
|
||||
|
||||
private init() {}
|
||||
|
||||
|
@ -21,7 +21,7 @@ struct FuzzyMatcher {
|
|||
/// +2 points for every char in `pattern` that occurs in `str` sequentially
|
||||
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
|
||||
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
|
||||
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
|
||||
let pattern = pattern.lowercased()
|
||||
let str = str.lowercased()
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "025bcb1165deab2e20d4eaba79967ce73013f496",
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-url",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/karwa/swift-url.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "01ad5a103d14839a68c55ee556513e5939008e9e"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
|
@ -24,5 +24,9 @@ let package = Package(
|
|||
name: "TuskerPreferences",
|
||||
dependencies: ["Pachyderm"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "TuskerPreferencesTests",
|
||||
dependencies: ["TuskerPreferences"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
|
@ -0,0 +1,282 @@
|
|||
//
|
||||
// Coding.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private protocol PreferenceProtocol {
|
||||
associatedtype Key: PreferenceKey
|
||||
var storedValue: Key.Value? { get }
|
||||
init()
|
||||
}
|
||||
|
||||
extension Preference: PreferenceProtocol {
|
||||
}
|
||||
|
||||
struct PreferenceCoding<Wrapped: Codable>: Codable {
|
||||
let wrapped: Wrapped
|
||||
|
||||
init(wrapped: Wrapped) {
|
||||
self.wrapped = wrapped
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
self.wrapped = try Wrapped(from: PreferenceDecoder(wrapped: decoder))
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
try wrapped.encode(to: PreferenceEncoder(wrapped: encoder))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceDecoder: Decoder {
|
||||
let wrapped: any Decoder
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var userInfo: [CodingUserInfoKey : Any] {
|
||||
wrapped.userInfo
|
||||
}
|
||||
|
||||
func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey {
|
||||
KeyedDecodingContainer(PreferenceDecodingContainer(wrapped: try wrapped.container(keyedBy: type)))
|
||||
}
|
||||
|
||||
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
|
||||
throw Error.onlyKeyedContainerSupported
|
||||
}
|
||||
|
||||
func singleValueContainer() throws -> any SingleValueDecodingContainer {
|
||||
throw Error.onlyKeyedContainerSupported
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case onlyKeyedContainerSupported
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceDecodingContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
|
||||
let wrapped: KeyedDecodingContainer<Key>
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var allKeys: [Key] {
|
||||
wrapped.allKeys
|
||||
}
|
||||
|
||||
func contains(_ key: Key) -> Bool {
|
||||
wrapped.contains(key)
|
||||
}
|
||||
|
||||
func decodeNil(forKey key: Key) throws -> Bool {
|
||||
try wrapped.decodeNil(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: String.Type, forKey key: Key) throws -> String {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
|
||||
try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable {
|
||||
if let type = type as? any PreferenceProtocol.Type,
|
||||
!contains(key) {
|
||||
func makePreference<P: PreferenceProtocol>(_: P.Type) -> T {
|
||||
P() as! T
|
||||
}
|
||||
return _openExistential(type, do: makePreference)
|
||||
}
|
||||
return try wrapped.decode(type, forKey: key)
|
||||
}
|
||||
|
||||
func nestedContainer<NestedKey>(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||
try wrapped.nestedContainer(keyedBy: type, forKey: key)
|
||||
}
|
||||
|
||||
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
|
||||
try wrapped.nestedUnkeyedContainer(forKey: key)
|
||||
}
|
||||
|
||||
func superDecoder() throws -> any Decoder {
|
||||
try wrapped.superDecoder()
|
||||
}
|
||||
|
||||
func superDecoder(forKey key: Key) throws -> any Decoder {
|
||||
try wrapped.superDecoder(forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceEncoder: Encoder {
|
||||
let wrapped: any Encoder
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
var userInfo: [CodingUserInfoKey : Any] {
|
||||
wrapped.userInfo
|
||||
}
|
||||
|
||||
func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
|
||||
KeyedEncodingContainer(PreferenceEncodingContainer(wrapped: wrapped.container(keyedBy: type)))
|
||||
}
|
||||
|
||||
func unkeyedContainer() -> any UnkeyedEncodingContainer {
|
||||
fatalError("Only keyed containers supported")
|
||||
}
|
||||
|
||||
func singleValueContainer() -> any SingleValueEncodingContainer {
|
||||
fatalError("Only keyed containers supported")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PreferenceEncodingContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
|
||||
var wrapped: KeyedEncodingContainer<Key>
|
||||
|
||||
var codingPath: [any CodingKey] {
|
||||
wrapped.codingPath
|
||||
}
|
||||
|
||||
mutating func encodeNil(forKey key: Key) throws {
|
||||
try wrapped.encodeNil(forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Bool, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: String, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Double, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Float, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int8, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int16, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int32, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: Int64, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt8, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt16, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt32, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode(_ value: UInt64, forKey key: Key) throws {
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func encode<T>(_ value: T, forKey key: Key) throws where T : Encodable {
|
||||
if let value = value as? any PreferenceProtocol,
|
||||
value.storedValue == nil {
|
||||
return
|
||||
}
|
||||
try wrapped.encode(value, forKey: key)
|
||||
}
|
||||
|
||||
mutating func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
|
||||
wrapped.nestedContainer(keyedBy: keyType, forKey: key)
|
||||
}
|
||||
|
||||
mutating func nestedUnkeyedContainer(forKey key: Key) -> any UnkeyedEncodingContainer {
|
||||
wrapped.nestedUnkeyedContainer(forKey: key)
|
||||
}
|
||||
|
||||
mutating func superEncoder() -> any Encoder {
|
||||
wrapped.superEncoder()
|
||||
}
|
||||
|
||||
mutating func superEncoder(forKey key: Key) -> any Encoder {
|
||||
wrapped.superEncoder(forKey: key)
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
//
|
||||
// AdvancedKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
struct StatusContentTypeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: StatusContentType { .plain }
|
||||
}
|
||||
|
||||
struct FeatureFlagsKey: MigratablePreferenceKey, CustomCodablePreferenceKey {
|
||||
static var defaultValue: Set<FeatureFlag> { [] }
|
||||
|
||||
static func encode(value: Set<FeatureFlag>, to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(value.map(\.rawValue))
|
||||
}
|
||||
|
||||
static func decode(from decoder: any Decoder) throws -> Set<FeatureFlag>? {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let names = try container.decode([String].self)
|
||||
return Set(names.compactMap(FeatureFlag.init(rawValue:)))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
//
|
||||
// AppearanceKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public struct ThemeKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: Theme { .unspecified }
|
||||
}
|
||||
|
||||
public struct AccentColorKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: AccentColor { .default }
|
||||
}
|
||||
|
||||
struct AvatarStyleKey: MigratablePreferenceKey {
|
||||
static var defaultValue: AvatarStyle { .roundRect }
|
||||
}
|
||||
|
||||
struct LeadingSwipeActionsKey: MigratablePreferenceKey {
|
||||
static var defaultValue: [StatusSwipeAction] { [.favorite, .reblog] }
|
||||
}
|
||||
|
||||
struct TrailingSwipeActionsKey: MigratablePreferenceKey {
|
||||
static var defaultValue: [StatusSwipeAction] { [.reply, .share] }
|
||||
}
|
||||
|
||||
public struct WidescreenNavigationModeKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: WidescreenNavigationMode { .multiColumn }
|
||||
|
||||
public static func shouldMigrate(oldValue: WidescreenNavigationMode) -> Bool {
|
||||
oldValue != .splitScreen
|
||||
}
|
||||
}
|
||||
|
||||
struct AttachmentBlurModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: AttachmentBlurMode { .useStatusSetting }
|
||||
|
||||
static func didSet(in store: PreferenceStore, newValue: AttachmentBlurMode) {
|
||||
if newValue == .always {
|
||||
store.blurMediaBehindContentWarning = true
|
||||
} else if newValue == .never {
|
||||
store.blurMediaBehindContentWarning = false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
//
|
||||
// BehaviorKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct OppositeCollapseKeywordsKey: MigratablePreferenceKey {
|
||||
static var defaultValue: [String] { [] }
|
||||
}
|
||||
|
||||
struct ConfirmReblogKey: MigratablePreferenceKey {
|
||||
static var defaultValue: Bool {
|
||||
#if os(visionOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineSyncModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: TimelineSyncMode { .icloud }
|
||||
}
|
||||
|
||||
struct InAppSafariKey: MigratablePreferenceKey {
|
||||
static var defaultValue: Bool {
|
||||
#if targetEnvironment(macCatalyst) || os(visionOS)
|
||||
false
|
||||
#else
|
||||
if ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
//
|
||||
// CommonKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TrueKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: Bool { true }
|
||||
}
|
||||
|
||||
public struct FalseKey: MigratablePreferenceKey {
|
||||
public static var defaultValue: Bool { false }
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
//
|
||||
// ComposingKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PostVisibilityKey: MigratablePreferenceKey {
|
||||
static var defaultValue: PostVisibility { .serverDefault }
|
||||
}
|
||||
|
||||
struct ReplyVisibilityKey: MigratablePreferenceKey {
|
||||
static var defaultValue: ReplyVisibility { .sameAsPost }
|
||||
}
|
||||
|
||||
struct ContentWarningCopyModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: ContentWarningCopyMode { .asIs }
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// DigitalWellnessKeys.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct NotificationsModeKey: MigratablePreferenceKey {
|
||||
static var defaultValue: NotificationsMode { .allNotifications }
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
//
|
||||
// LegacyPreferences.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
public final class LegacyPreferences: Decodable {
|
||||
|
||||
init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
||||
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
||||
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
|
||||
|
||||
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
||||
self.defaultPostVisibility = .visibility(existing)
|
||||
} else {
|
||||
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
||||
}
|
||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||
} else {
|
||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||
}
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
self.attachmentAltBadgeInverted = try container.decodeIfPresent(Bool.self, forKey: .attachmentAltBadgeInverted) ?? false
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
||||
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
||||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
||||
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||
|
||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
||||
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
||||
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
||||
|
||||
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
||||
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
@Published public var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published public var pureBlackDarkMode = true
|
||||
@Published public var accentColor = AccentColor.default
|
||||
@Published public var avatarStyle = AvatarStyle.roundRect
|
||||
@Published public var hideCustomEmojiInUsernames = false
|
||||
@Published public var showIsStatusReplyIcon = false
|
||||
@Published public var alwaysShowStatusVisibilityIcon = false
|
||||
@Published public var hideActionsInTimeline = false
|
||||
@Published public var showLinkPreviews = true
|
||||
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||
@Published public var widescreenNavigationMode = LegacyPreferences.defaultWidescreenNavigationMode
|
||||
@Published public var underlineTextLinks = false
|
||||
@Published public var showAttachmentsInTimeline = true
|
||||
|
||||
// MARK: Composing
|
||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||
@Published public var requireAttachmentDescriptions = false
|
||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published public var mentionReblogger = false
|
||||
@Published public var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting
|
||||
@Published public var blurMediaBehindContentWarning = true
|
||||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
@Published public var attachmentAltBadgeInverted = false
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
@Published public var useInAppSafari = true
|
||||
@Published public var inAppSafariAutomaticReaderMode = false
|
||||
@Published public var expandAllContentWarnings = false
|
||||
@Published public var collapseLongPosts = true
|
||||
@Published public var oppositeCollapseKeywords: [String] = []
|
||||
@Published public var confirmBeforeReblog = false
|
||||
@Published public var timelineStateRestoration = true
|
||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published public var hideReblogsInTimelines = false
|
||||
@Published public var hideRepliesInTimelines = false
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published public var showFavoriteAndReblogCounts = true
|
||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published public var grayscaleImages = false
|
||||
@Published public var disableInfiniteScrolling = false
|
||||
@Published public var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published public var statusContentType: StatusContentType = .plain
|
||||
@Published public var reportErrorsAutomatically = true
|
||||
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
||||
|
||||
// MARK:
|
||||
@Published public var hasShownLocalTimelineDescription = false
|
||||
@Published public var hasShownFederatedTimelineDescription = false
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case pureBlackDarkMode
|
||||
case accentColor
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case showLinkPreviews
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
case widescreenNavigationMode
|
||||
case underlineTextLinks
|
||||
case showAttachmentsInTimeline
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia // only used for migration
|
||||
case attachmentBlurMode
|
||||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
case attachmentAltBadgeInverted
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
case grayscaleImages
|
||||
case disableInfiniteScrolling
|
||||
case hideTrends = "hideDiscover"
|
||||
|
||||
case statusContentType
|
||||
case reportErrorsAutomatically
|
||||
case enabledFeatureFlags
|
||||
|
||||
case hasShownLocalTimelineDescription
|
||||
case hasShownFederatedTimelineDescription
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
|
@ -0,0 +1,106 @@
|
|||
//
|
||||
// PreferenceStore+Migrate.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
extension PreferenceStore {
|
||||
func migrate(from legacy: LegacyPreferences) {
|
||||
var migrations: [any MigrationProtocol] = [
|
||||
Migration(from: \.theme.theme, to: \.$theme),
|
||||
Migration(from: \.pureBlackDarkMode, to: \.$pureBlackDarkMode),
|
||||
Migration(from: \.accentColor, to: \.$accentColor),
|
||||
Migration(from: \.avatarStyle, to: \.$avatarStyle),
|
||||
Migration(from: \.hideCustomEmojiInUsernames, to: \.$hideCustomEmojiInUsernames),
|
||||
Migration(from: \.showIsStatusReplyIcon, to: \.$showIsStatusReplyIcon),
|
||||
Migration(from: \.alwaysShowStatusVisibilityIcon, to: \.$alwaysShowStatusVisibilityIcon),
|
||||
Migration(from: \.hideActionsInTimeline, to: \.$hideActionsInTimeline),
|
||||
Migration(from: \.showLinkPreviews, to: \.$showLinkPreviews),
|
||||
Migration(from: \.leadingStatusSwipeActions, to: \.$leadingStatusSwipeActions),
|
||||
Migration(from: \.trailingStatusSwipeActions, to: \.$trailingStatusSwipeActions),
|
||||
Migration(from: \.widescreenNavigationMode, to: \.$widescreenNavigationMode),
|
||||
Migration(from: \.underlineTextLinks, to: \.$underlineTextLinks),
|
||||
Migration(from: \.showAttachmentsInTimeline, to: \.$showAttachmentsInTimeline),
|
||||
|
||||
Migration(from: \.defaultPostVisibility, to: \.$defaultPostVisibility),
|
||||
Migration(from: \.defaultReplyVisibility, to: \.$defaultReplyVisibility),
|
||||
Migration(from: \.requireAttachmentDescriptions, to: \.$requireAttachmentDescriptions),
|
||||
Migration(from: \.contentWarningCopyMode, to: \.$contentWarningCopyMode),
|
||||
Migration(from: \.mentionReblogger, to: \.$mentionReblogger),
|
||||
Migration(from: \.useTwitterKeyboard, to: \.$useTwitterKeyboard),
|
||||
|
||||
Migration(from: \.attachmentBlurMode, to: \.$attachmentBlurMode),
|
||||
Migration(from: \.blurMediaBehindContentWarning, to: \.$blurMediaBehindContentWarning),
|
||||
Migration(from: \.automaticallyPlayGifs, to: \.$automaticallyPlayGifs),
|
||||
Migration(from: \.showUncroppedMediaInline, to: \.$showUncroppedMediaInline),
|
||||
Migration(from: \.showAttachmentBadges, to: \.$showAttachmentBadges),
|
||||
Migration(from: \.attachmentAltBadgeInverted, to: \.$attachmentAltBadgeInverted),
|
||||
|
||||
Migration(from: \.openLinksInApps, to: \.$openLinksInApps),
|
||||
Migration(from: \.expandAllContentWarnings, to: \.$expandAllContentWarnings),
|
||||
Migration(from: \.collapseLongPosts, to: \.$collapseLongPosts),
|
||||
Migration(from: \.oppositeCollapseKeywords, to: \.$oppositeCollapseKeywords),
|
||||
Migration(from: \.confirmBeforeReblog, to: \.$confirmBeforeReblog),
|
||||
Migration(from: \.timelineStateRestoration, to: \.$timelineStateRestoration),
|
||||
Migration(from: \.timelineSyncMode, to: \.$timelineSyncMode),
|
||||
Migration(from: \.hideReblogsInTimelines, to: \.$hideReblogsInTimelines),
|
||||
Migration(from: \.hideRepliesInTimelines, to: \.$hideRepliesInTimelines),
|
||||
|
||||
Migration(from: \.showFavoriteAndReblogCounts, to: \.$showFavoriteAndReblogCounts),
|
||||
Migration(from: \.defaultNotificationsMode, to: \.$defaultNotificationsMode),
|
||||
Migration(from: \.grayscaleImages, to: \.$grayscaleImages),
|
||||
Migration(from: \.disableInfiniteScrolling, to: \.$disableInfiniteScrolling),
|
||||
Migration(from: \.hideTrends, to: \.$hideTrends),
|
||||
|
||||
Migration(from: \.statusContentType, to: \.$statusContentType),
|
||||
Migration(from: \.reportErrorsAutomatically, to: \.$reportErrorsAutomatically),
|
||||
Migration(from: \.enabledFeatureFlags, to: \.$enabledFeatureFlags),
|
||||
|
||||
Migration(from: \.hasShownLocalTimelineDescription, to: \.$hasShownLocalTimelineDescription),
|
||||
Migration(from: \.hasShownFederatedTimelineDescription, to: \.$hasShownFederatedTimelineDescription),
|
||||
]
|
||||
#if !targetEnvironment(macCatalyst) && !os(visionOS)
|
||||
migrations.append(contentsOf: [
|
||||
Migration(from: \.useInAppSafari, to: \.$useInAppSafari),
|
||||
Migration(from: \.inAppSafariAutomaticReaderMode, to: \.$inAppSafariAutomaticReaderMode),
|
||||
] as [any MigrationProtocol])
|
||||
#endif
|
||||
|
||||
for migration in migrations {
|
||||
migration.migrate(from: legacy, to: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private protocol MigrationProtocol {
|
||||
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore)
|
||||
}
|
||||
|
||||
private struct Migration<Key: MigratablePreferenceKey>: MigrationProtocol where Key.Value: Equatable {
|
||||
let from: KeyPath<LegacyPreferences, Key.Value>
|
||||
let to: KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
func migrate(from legacy: LegacyPreferences, to store: PreferenceStore) {
|
||||
let value = legacy[keyPath: from]
|
||||
if Key.shouldMigrate(oldValue: value) {
|
||||
Preference.set(enclosingInstance: store, storage: to.appending(path: \.preference), newValue: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIUserInterfaceStyle {
|
||||
var theme: Theme {
|
||||
switch self {
|
||||
case .light:
|
||||
.light
|
||||
case .dark:
|
||||
.dark
|
||||
default:
|
||||
.unspecified
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
//
|
||||
// Preference.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
// TODO: once we target iOS 17, use Observable for this
|
||||
@propertyWrapper
|
||||
final class Preference<Key: PreferenceKey>: Codable {
|
||||
@Published private(set) var storedValue: Key.Value?
|
||||
|
||||
var wrappedValue: Key.Value {
|
||||
get {
|
||||
storedValue ?? Key.defaultValue
|
||||
}
|
||||
set {
|
||||
fatalError("unreachable")
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.storedValue = nil
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
|
||||
self.storedValue = try keyType.decode(from: decoder) as! Key.Value?
|
||||
} else if let container = try? decoder.singleValueContainer() {
|
||||
self.storedValue = try? container.decode(Key.Value.self)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
if let storedValue {
|
||||
if let keyType = Key.self as? any CustomCodablePreferenceKey.Type {
|
||||
func encode<K: CustomCodablePreferenceKey>(_: K.Type) throws {
|
||||
try K.encode(value: storedValue as! K.Value, to: encoder)
|
||||
}
|
||||
return try _openExistential(keyType, do: encode)
|
||||
} else {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(storedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static subscript(
|
||||
_enclosingInstance instance: PreferenceStore,
|
||||
wrapped wrappedKeyPath: ReferenceWritableKeyPath<PreferenceStore, Key.Value>,
|
||||
storage storageKeyPath: ReferenceWritableKeyPath<PreferenceStore, Preference>
|
||||
) -> Key.Value {
|
||||
get {
|
||||
get(enclosingInstance: instance, storage: storageKeyPath)
|
||||
}
|
||||
set {
|
||||
set(enclosingInstance: instance, storage: storageKeyPath, newValue: newValue)
|
||||
Key.didSet(in: instance, newValue: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// for testing only
|
||||
@inline(__always)
|
||||
static func get<Enclosing>(
|
||||
enclosingInstance: Enclosing,
|
||||
storage: KeyPath<Enclosing, Preference>
|
||||
) -> Key.Value where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
|
||||
let pref = enclosingInstance[keyPath: storage]
|
||||
return pref.storedValue ?? Key.defaultValue
|
||||
}
|
||||
|
||||
// for testing only
|
||||
@inline(__always)
|
||||
static func set<Enclosing>(
|
||||
enclosingInstance: Enclosing,
|
||||
storage: KeyPath<Enclosing, Preference>,
|
||||
newValue: Key.Value
|
||||
) where Enclosing: ObservableObject, Enclosing.ObjectWillChangePublisher == ObservableObjectPublisher {
|
||||
enclosingInstance.objectWillChange.send()
|
||||
let pref = enclosingInstance[keyPath: storage]
|
||||
pref.storedValue = newValue
|
||||
}
|
||||
|
||||
var projectedValue: PreferencePublisher<Key> {
|
||||
.init(preference: self)
|
||||
}
|
||||
}
|
||||
|
||||
public struct PreferencePublisher<Key: PreferenceKey>: Publisher {
|
||||
public typealias Output = Key.Value
|
||||
public typealias Failure = Never
|
||||
|
||||
let preference: Preference<Key>
|
||||
|
||||
public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Key.Value == S.Input {
|
||||
preference.$storedValue.map { $0 ?? Key.defaultValue }.receive(subscriber: subscriber)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
//
|
||||
// PreferenceKey.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol PreferenceKey {
|
||||
associatedtype Value: Codable
|
||||
|
||||
static var defaultValue: Value { get }
|
||||
|
||||
static func didSet(in store: PreferenceStore, newValue: Value)
|
||||
}
|
||||
|
||||
extension PreferenceKey {
|
||||
public static func didSet(in store: PreferenceStore, newValue: Value) {}
|
||||
}
|
||||
|
||||
protocol MigratablePreferenceKey: PreferenceKey where Value: Equatable {
|
||||
static func shouldMigrate(oldValue: Value) -> Bool
|
||||
}
|
||||
|
||||
extension MigratablePreferenceKey {
|
||||
static func shouldMigrate(oldValue: Value) -> Bool {
|
||||
oldValue != defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
protocol CustomCodablePreferenceKey: PreferenceKey {
|
||||
static func encode(value: Value, to encoder: any Encoder) throws
|
||||
static func decode(from decoder: any Decoder) throws -> Value?
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
//
|
||||
// PreferenceStore.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
public final class PreferenceStore: ObservableObject, Codable {
|
||||
// MARK: Appearance
|
||||
@Preference<ThemeKey> public var theme
|
||||
@Preference<TrueKey> public var pureBlackDarkMode
|
||||
@Preference<AccentColorKey> public var accentColor
|
||||
@Preference<AvatarStyleKey> public var avatarStyle
|
||||
@Preference<FalseKey> public var hideCustomEmojiInUsernames
|
||||
@Preference<FalseKey> public var showIsStatusReplyIcon
|
||||
@Preference<FalseKey> public var alwaysShowStatusVisibilityIcon
|
||||
@Preference<FalseKey> public var hideActionsInTimeline
|
||||
@Preference<TrueKey> public var showLinkPreviews
|
||||
@Preference<LeadingSwipeActionsKey> public var leadingStatusSwipeActions
|
||||
@Preference<TrailingSwipeActionsKey> public var trailingStatusSwipeActions
|
||||
@Preference<WidescreenNavigationModeKey> public var widescreenNavigationMode
|
||||
@Preference<FalseKey> public var underlineTextLinks
|
||||
@Preference<TrueKey> public var showAttachmentsInTimeline
|
||||
@Preference<AttachmentBlurModeKey> public var attachmentBlurMode
|
||||
@Preference<TrueKey> public var blurMediaBehindContentWarning
|
||||
@Preference<TrueKey> public var automaticallyPlayGifs
|
||||
@Preference<TrueKey> public var showUncroppedMediaInline
|
||||
@Preference<TrueKey> public var showAttachmentBadges
|
||||
@Preference<FalseKey> public var attachmentAltBadgeInverted
|
||||
|
||||
// MARK: Composing
|
||||
@Preference<PostVisibilityKey> public var defaultPostVisibility
|
||||
@Preference<ReplyVisibilityKey> public var defaultReplyVisibility
|
||||
@Preference<FalseKey> public var requireAttachmentDescriptions
|
||||
@Preference<ContentWarningCopyModeKey> public var contentWarningCopyMode
|
||||
@Preference<FalseKey> public var mentionReblogger
|
||||
@Preference<FalseKey> public var useTwitterKeyboard
|
||||
|
||||
// MARK: Behavior
|
||||
@Preference<TrueKey> public var openLinksInApps
|
||||
@Preference<InAppSafariKey> public var useInAppSafari
|
||||
@Preference<FalseKey> public var inAppSafariAutomaticReaderMode
|
||||
@Preference<FalseKey> public var expandAllContentWarnings
|
||||
@Preference<TrueKey> public var collapseLongPosts
|
||||
@Preference<OppositeCollapseKeywordsKey> public var oppositeCollapseKeywords
|
||||
@Preference<ConfirmReblogKey> public var confirmBeforeReblog
|
||||
@Preference<TrueKey> public var timelineStateRestoration
|
||||
@Preference<TimelineSyncModeKey> public var timelineSyncMode
|
||||
@Preference<FalseKey> public var hideReblogsInTimelines
|
||||
@Preference<FalseKey> public var hideRepliesInTimelines
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Preference<TrueKey> public var showFavoriteAndReblogCounts
|
||||
@Preference<NotificationsModeKey> public var defaultNotificationsMode
|
||||
@Preference<FalseKey> public var grayscaleImages
|
||||
@Preference<FalseKey> public var disableInfiniteScrolling
|
||||
@Preference<FalseKey> public var hideTrends
|
||||
|
||||
// MARK: Advanced
|
||||
@Preference<StatusContentTypeKey> public var statusContentType
|
||||
@Preference<TrueKey> public var reportErrorsAutomatically
|
||||
@Preference<FeatureFlagsKey> public var enabledFeatureFlags
|
||||
|
||||
// MARK: Internal
|
||||
@Preference<FalseKey> public var hasShownLocalTimelineDescription
|
||||
@Preference<FalseKey> public var hasShownFederatedTimelineDescription
|
||||
}
|
||||
|
||||
extension PreferenceStore {
|
||||
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
||||
enabledFeatureFlags.contains(flag)
|
||||
}
|
||||
|
||||
|
||||
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
|
||||
self[keyPath: preferenceKeyPath].preference.wrappedValue
|
||||
}
|
||||
}
|
|
@ -2,423 +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
|
||||
import Foundation
|
||||
|
||||
public final class Preferences: Codable, ObservableObject {
|
||||
|
||||
public static var shared: Preferences = load()
|
||||
public struct Preferences {
|
||||
@MainActor
|
||||
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")
|
||||
|
||||
public static func save() {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
}
|
||||
|
||||
public static func load() -> Preferences {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let preferences = try? decoder.decode(Preferences.self, from: data) {
|
||||
return preferences
|
||||
}
|
||||
return Preferences()
|
||||
}
|
||||
|
||||
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 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() {}
|
||||
|
||||
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)
|
||||
@MainActor
|
||||
public static func save() {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
|
||||
try? data?.write(to: preferencesURL, options: .noFileProtection)
|
||||
}
|
||||
|
||||
private static func load() -> PreferenceStore {
|
||||
let decoder = PropertyListDecoder()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// AccentColor.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// AttachmentBlurMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// FeatureFlag.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
|
@ -8,11 +8,11 @@
|
|||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
public enum PostVisibility: Codable, Hashable, CaseIterable {
|
||||
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,8 +57,9 @@ 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 {
|
||||
switch self {
|
||||
case .sameAsPost:
|
|
@ -9,7 +9,7 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
|
||||
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable {
|
||||
public enum StatusSwipeAction: String, Codable, Hashable, CaseIterable, Sendable {
|
||||
case reply
|
||||
case favorite
|
||||
case reblog
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Theme.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public enum Theme: String, Codable {
|
||||
case unspecified, light, dark
|
||||
|
||||
public var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .unspecified:
|
||||
.unspecified
|
||||
case .light:
|
||||
.light
|
||||
case .dark:
|
||||
.dark
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// TimelineSyncMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// WidescreenNavigationMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
//
|
||||
// PreferenceStoreTests.swift
|
||||
// TuskerPreferencesTests
|
||||
//
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import TuskerPreferences
|
||||
import Combine
|
||||
|
||||
final class PreferenceStoreTests: XCTestCase {
|
||||
|
||||
struct TestKey: PreferenceKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
final class TestStore<Key: PreferenceKey>: Codable, ObservableObject {
|
||||
private var _test = Preference<Key>()
|
||||
|
||||
// the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually
|
||||
var test: Key.Value {
|
||||
get {
|
||||
Preference.get(enclosingInstance: self, storage: \._test)
|
||||
}
|
||||
set {
|
||||
Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var testPublisher: some Publisher<Key.Value, Never> {
|
||||
_test.projectedValue
|
||||
}
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self._test = try container.decode(Preference<Key>.self, forKey: .test)
|
||||
}
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case test
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self._test, forKey: .test)
|
||||
}
|
||||
}
|
||||
|
||||
func testDecoding() throws {
|
||||
let decoder = JSONDecoder()
|
||||
let present = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
|
||||
{"test": true}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(present.test, true)
|
||||
let absent = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
|
||||
{}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(absent.test, false)
|
||||
}
|
||||
|
||||
func testEncoding() throws {
|
||||
let store = TestStore<TestKey>()
|
||||
let encoder = JSONEncoder()
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{}
|
||||
""")
|
||||
store.test = true
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":true}
|
||||
""")
|
||||
store.test = false
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":false}
|
||||
""")
|
||||
}
|
||||
|
||||
func testPublisher() {
|
||||
let topLevel = expectation(description: "top level publisher")
|
||||
let specificPref = expectation(description: "preference publisher")
|
||||
// initial and on change
|
||||
specificPref.expectedFulfillmentCount = 2
|
||||
let store = TestStore<TestKey>()
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
store.objectWillChange.sink {
|
||||
topLevel.fulfill()
|
||||
// fires on will change
|
||||
XCTAssertEqual(store.test, false)
|
||||
}.store(in: &cancellables)
|
||||
store.testPublisher.sink { _ in
|
||||
specificPref.fulfill()
|
||||
}.store(in: &cancellables)
|
||||
store.test = true
|
||||
wait(for: [topLevel, specificPref])
|
||||
}
|
||||
|
||||
func testCustomCodable() throws {
|
||||
struct Key: CustomCodablePreferenceKey {
|
||||
static let defaultValue = 1
|
||||
static func encode(value: Int, to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(2)
|
||||
}
|
||||
static func decode(from decoder: any Decoder) throws -> Int? {
|
||||
3
|
||||
}
|
||||
}
|
||||
let store = TestStore<Key>()
|
||||
store.test = 123
|
||||
let encoder = JSONEncoder()
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":2}
|
||||
""")
|
||||
let decoder = JSONDecoder()
|
||||
let present = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
|
||||
{"test":2}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(present.test, 3)
|
||||
let absent = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
|
||||
{}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(absent.test, 1)
|
||||
}
|
||||
|
||||
}
|
|
@ -8,13 +8,14 @@
|
|||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
public struct UserAccountInfo: Equatable, Hashable, Identifiable {
|
||||
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 {
|
|||
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 {
|
|||
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 {
|
|||
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 {
|
|||
// 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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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/>
|
||||
|
|
|
@ -19,7 +19,11 @@ class ShareHostingController: UIHostingController<ShareHostingController.View> {
|
|||
let image = UIImage(data: data) else {
|
||||
return nil
|
||||
}
|
||||
#if os(visionOS)
|
||||
let size: CGFloat = 50 * 2
|
||||
#else
|
||||
let size = 50 * UIScreen.main.scale
|
||||
#endif
|
||||
return await image.byPreparingThumbnail(ofSize: CGSize(width: size, height: size)) ?? image
|
||||
}
|
||||
|
||||
|
@ -128,7 +132,7 @@ extension UIColor {
|
|||
return .systemBackground
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static let appGroupedBackground = UIColor { traitCollection in
|
||||
if case .dark = traitCollection.userInterfaceStyle,
|
||||
!Preferences.shared.pureBlackDarkMode {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue