Compare commits
138 Commits
2024.1-113
...
develop
Author | SHA1 | Date |
---|---|---|
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 |
|
@ -1,3 +1,37 @@
|
|||
## 2024.1
|
||||
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
Features/Improvements:
|
||||
- Improve attachment gallery
|
||||
- Improve animations
|
||||
- Display video captions
|
||||
- Support sharing/saving videos
|
||||
- Resume music playback after playing videos
|
||||
- Improve rich text display in posts
|
||||
- Add See Results button to polls
|
||||
- Add Share and Save to Photos menu items to post attachments
|
||||
- Show verified links in account lists
|
||||
- Display message on empty list timelines
|
||||
- Add preference to indicate attachments lacking alt text
|
||||
- Mark notifications as read on Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue changing scope after searching
|
||||
- Fix crash when searching "from:me"
|
||||
- Fix tapping Followers button on profile opening Following screen
|
||||
- Fix crash when removing poll option on Compose screen
|
||||
- Fix hang when sharing video/GIFV attachments
|
||||
- Fix stretched Save to Photos icon when sharing attachments
|
||||
- Fix GIFV playback preventing device sleep
|
||||
- Fix Notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix selection not clearing on Trending Hashtags
|
||||
- Fix fast account switcher overlapping iPhone sensor housing in landscape
|
||||
- Fix Edit List screen not updating when adding/removing accounts
|
||||
- Fix changing list reply policy not refreshing timeline
|
||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||
- macOS: Fix attachment gallery displaying improperly when Reduce Motion is on
|
||||
|
||||
## 2023.8
|
||||
This update adds support for search operators and post translation, and improves support for displaying rich-text posts. See below for a full list of improvements and fixes.
|
||||
|
||||
|
|
78
CHANGELOG.md
78
CHANGELOG.md
|
@ -1,5 +1,83 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.2 (122)
|
||||
Features/Improvements:
|
||||
- Show instance announcements in Notifications
|
||||
- Pleroma/Akkoma: Display emoji reactions in Notifications
|
||||
- Pleroma/Akkoma: Add push notifications for emoji reactions
|
||||
|
||||
Bugfixes:
|
||||
- Fix issue fetching server info on some instances
|
||||
- Fix Preferences background color not updating after changing Pure Black Dark Mode
|
||||
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
|
||||
|
||||
## 2024.2 (121)
|
||||
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.
|
||||
|
||||
Features/Improvements:
|
||||
- iPadOS: Enable multi-column navigation
|
||||
- Add post preview to Appearance preferences
|
||||
- Consolidate Media preferences section with Appearance
|
||||
- Add icons to Preferences sections
|
||||
|
||||
Bugfixes:
|
||||
- Fix push notifications not working on Pleroma/Akkoma and older Mastodon versions
|
||||
- Fix push notifications not working with certain accounts
|
||||
- Fix links on About screen not being aligned
|
||||
- macOS: Remove non-functional in-app Safari preferences
|
||||
|
||||
## 2024.2 (120)
|
||||
This build adds push notifications, which can be enabled in Preferences -> Notifications.
|
||||
|
||||
## 2024.1 (119)
|
||||
Features/Improvements:
|
||||
- Add Account Settings button to Preferences
|
||||
|
||||
## 2024.1 (118)
|
||||
Bugfixes:
|
||||
- Fix music not pausing/resuming when video playback starts
|
||||
|
||||
## 2024.1 (117)
|
||||
Features/Improvements:
|
||||
- Add See Results button to polls
|
||||
|
||||
Bugfixes:
|
||||
- Fix race condition when presenting gallery for 4th of more than 4 attachments
|
||||
- Fix gallery interactive dismissal not working for 4th or later attachments on posts with more than 4 attachments
|
||||
- Pixelfed: Fix crash when there are multiple follow notifications from the same account
|
||||
- macOS: Fix gallery being positioned incorrectly when Reduce Motion is on
|
||||
|
||||
## 2024.1 (116)
|
||||
Features/Improvements:
|
||||
- Display message on empty list timelines
|
||||
- Add preference to display badge for attachments that lack alt text
|
||||
- Mark notifications as read on the Mastodon web frontend once displayed
|
||||
- iPadOS: Support tapping the selected sidebar item to scroll to top
|
||||
|
||||
Bugfixes:
|
||||
- Fix playing back GIFVs preventing the device sleeping
|
||||
- Fix incorrect cell separator insets followers/following lists
|
||||
- Fix memory leak in attachments gallery
|
||||
- Fix notifications tab not scrolling to top when tab bar item tapped
|
||||
- Fix Trending Hashtags screen not clearing selection
|
||||
- Fix fast account switcher overlapping sensor housing on landscape iPhones
|
||||
- Fix Edit List screen not updating when accounts are added/removed
|
||||
- Fix changing List reply policy not refreshing list timeline
|
||||
- macOS: Fix certain gallery attachments being incorrectly sized/positioned
|
||||
|
||||
## 2024.1 (115)
|
||||
Features/Improvements:
|
||||
- Rewrite attachment gallery
|
||||
- Fixes a number of long-standing issues
|
||||
- Adds a custom video player that shows controls and caption
|
||||
- Supports sharing/saving videos
|
||||
|
||||
Bugfixes:
|
||||
- Fix hang when sharing video/gifv attachments
|
||||
- Fix stretched icon for Save to Photos action when sharing attachment
|
||||
- Fix crash when Compose screen is dismissed while adding attachments
|
||||
- Fix crash when sharing attachment from context menu on iPad
|
||||
|
||||
## 2024.1 (113)
|
||||
Features/Improvements:
|
||||
- Add Share and Save to Photos context menu actions to attachments
|
||||
|
|
|
@ -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>
|
|
@ -1,7 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
|
@ -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)
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteEmojisController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Pachyderm
|
||||
import TuskerComponents
|
||||
|
||||
class AutocompleteHashtagsController: ViewController {
|
||||
unowned let composeController: ComposeController
|
||||
|
|
|
@ -181,13 +181,8 @@ class ToolbarController: ViewController {
|
|||
private var formatButtons: some View {
|
||||
ForEach(StatusFormat.allCases, id: \.rawValue) { format in
|
||||
Button(action: controller.formatAction(format)) {
|
||||
if let imageName = format.imageName {
|
||||
Image(systemName: imageName)
|
||||
.font(.system(size: imageSize))
|
||||
} else if let (str, attrs) = format.title {
|
||||
let container = try! AttributeContainer(attrs, including: \.uiKit)
|
||||
Text(AttributedString(str, attributes: container))
|
||||
}
|
||||
Image(systemName: format.imageName)
|
||||
.font(.system(size: imageSize))
|
||||
}
|
||||
.accessibilityLabel(format.accessibilityLabel)
|
||||
.padding(5)
|
||||
|
|
|
@ -23,7 +23,7 @@ enum StatusFormat: Int, CaseIterable {
|
|||
}
|
||||
}
|
||||
|
||||
var imageName: String? {
|
||||
var imageName: String {
|
||||
switch self {
|
||||
case .italics:
|
||||
return "italic"
|
||||
|
@ -31,16 +31,8 @@ enum StatusFormat: Int, CaseIterable {
|
|||
return "bold"
|
||||
case .strikethrough:
|
||||
return "strikethrough"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var title: (String, [NSAttributedString.Key: Any])? {
|
||||
if self == .code {
|
||||
return ("</>", [.font: UIFont(name: "Menlo", size: 17)!])
|
||||
} else {
|
||||
return nil
|
||||
case .code:
|
||||
return "chevron.left.forwardslash.chevron.right"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -259,11 +259,7 @@ fileprivate struct MainWrappedTextViewRepresentable: UIViewRepresentable {
|
|||
if range.length > 0 {
|
||||
let formatMenu = suggestedActions[index] as! UIMenu
|
||||
let newFormatMenu = formatMenu.replacingChildren(StatusFormat.allCases.map { fmt in
|
||||
var image: UIImage?
|
||||
if let imageName = fmt.imageName {
|
||||
image = UIImage(systemName: imageName)
|
||||
}
|
||||
return UIAction(title: fmt.accessibilityLabel, image: image) { [weak self] _ in
|
||||
return UIAction(title: fmt.accessibilityLabel, image: UIImage(systemName: fmt.imageName)) { [weak self] _ in
|
||||
self?.applyFormat(fmt)
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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,133 @@
|
|||
//
|
||||
// GalleryDismissAnimationController.swift
|
||||
// GalleryVC
|
||||
//
|
||||
// Created by Shadowfacts on 3/1/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
private let sourceView: UIView
|
||||
private let interactiveTranslation: CGPoint?
|
||||
private let interactiveVelocity: CGPoint?
|
||||
|
||||
init(sourceView: UIView, interactiveTranslation: CGPoint?, interactiveVelocity: CGPoint?) {
|
||||
self.sourceView = sourceView
|
||||
self.interactiveTranslation = interactiveTranslation
|
||||
self.interactiveVelocity = interactiveVelocity
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: (any UIViewControllerContextTransitioning)?) -> TimeInterval {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
func animateTransition(using transitionContext: any UIViewControllerContextTransitioning) {
|
||||
guard let to = transitionContext.viewController(forKey: .to),
|
||||
let from = transitionContext.viewController(forKey: .from) as? GalleryViewController else {
|
||||
fatalError()
|
||||
}
|
||||
|
||||
let itemViewController = from.currentItemViewController
|
||||
|
||||
if !itemViewController.content.canAnimateFromSourceView || (UIAccessibility.prefersCrossFadeTransitions && interactiveVelocity == nil) {
|
||||
animateCrossFadeTransition(using: transitionContext)
|
||||
return
|
||||
}
|
||||
|
||||
let container = transitionContext.containerView
|
||||
let sourceFrameInContainer = container.convert(sourceView.bounds, from: sourceView)
|
||||
let destFrameInContainer = container.convert(itemViewController.content.view.bounds, from: itemViewController.content.view)
|
||||
|
||||
let origSourceTransform = sourceView.transform
|
||||
let appliedSourceToDestTransform: Bool
|
||||
if destFrameInContainer.width > 0 && destFrameInContainer.height > 0 {
|
||||
appliedSourceToDestTransform = true
|
||||
let scale = min(destFrameInContainer.width / sourceFrameInContainer.width, destFrameInContainer.height / sourceFrameInContainer.height)
|
||||
let sourceToDestTransform = origSourceTransform
|
||||
.translatedBy(x: destFrameInContainer.midX - sourceFrameInContainer.midX, y: destFrameInContainer.midY - sourceFrameInContainer.midY)
|
||||
.scaledBy(x: scale, y: scale)
|
||||
sourceView.transform = sourceToDestTransform
|
||||
} else {
|
||||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
to.view.frame = container.bounds
|
||||
from.view.frame = container.bounds
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
|
||||
container.addSubview(to.view)
|
||||
container.addSubview(from.view)
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
content.view.layer.opacity = 1
|
||||
|
||||
container.layoutIfNeeded()
|
||||
|
||||
let duration = self.transitionDuration(using: transitionContext)
|
||||
var initialVelocity: CGVector
|
||||
if let interactiveVelocity,
|
||||
let interactiveTranslation,
|
||||
// very short/fast flicks don't transfer their velocity, since it makes size change animation look wacky due to the springs initial undershoot
|
||||
sqrt(pow(interactiveTranslation.x, 2) + pow(interactiveTranslation.y, 2)) > 100,
|
||||
sqrt(pow(interactiveVelocity.x, 2) + pow(interactiveVelocity.y, 2)) < 2000 {
|
||||
let xDistance = sourceFrameInContainer.midX - destFrameInContainer.midX
|
||||
let yDistance = sourceFrameInContainer.midY - destFrameInContainer.midY
|
||||
initialVelocity = CGVector(
|
||||
dx: xDistance == 0 ? 0 : interactiveVelocity.x / xDistance,
|
||||
dy: yDistance == 0 ? 0 : interactiveVelocity.y / yDistance
|
||||
)
|
||||
} else {
|
||||
initialVelocity = .zero
|
||||
}
|
||||
initialVelocity.dx = max(-10, min(10, initialVelocity.dx))
|
||||
initialVelocity.dy = max(-10, min(10, initialVelocity.dy))
|
||||
// no bounce for the dismiss animation
|
||||
let spring = UISpringTimingParameters(mass: 1, stiffness: 439, damping: 42, initialVelocity: initialVelocity)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, timingParameters: spring)
|
||||
|
||||
animator.addAnimations {
|
||||
from.view.layer.opacity = 0
|
||||
|
||||
if appliedSourceToDestTransform {
|
||||
self.sourceView.transform = origSourceTransform
|
||||
}
|
||||
content.view.frame = sourceFrameInContainer
|
||||
content.view.layer.opacity = 0
|
||||
|
||||
itemViewController.setControlsVisible(false, animated: false)
|
||||
}
|
||||
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(true)
|
||||
}
|
||||
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
private func animateCrossFadeTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
guard let fromVC = transitionContext.viewController(forKey: .from),
|
||||
let toVC = transitionContext.viewController(forKey: .to) else {
|
||||
return
|
||||
}
|
||||
|
||||
toVC.view.frame = transitionContext.containerView.bounds
|
||||
fromVC.view.frame = transitionContext.containerView.bounds
|
||||
transitionContext.containerView.addSubview(toVC.view)
|
||||
transitionContext.containerView.addSubview(fromVC.view)
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
let animator = UIViewPropertyAnimator(duration: duration, curve: .easeInOut)
|
||||
animator.addAnimations {
|
||||
fromVC.view.alpha = 0
|
||||
}
|
||||
animator.addCompletion { _ in
|
||||
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -184,6 +184,39 @@ public final class InstanceFeatures: ObservableObject {
|
|||
hasMastodonVersion(4, 2, 0) || instanceType.isMastodon(.hometown(nil))
|
||||
}
|
||||
|
||||
public var pushNotificationTypeStatus: Bool {
|
||||
hasMastodonVersion(3, 3, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationTypeFollowRequest: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationTypeUpdate: Bool {
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationPolicy: Bool {
|
||||
hasMastodonVersion(3, 5, 0)
|
||||
}
|
||||
|
||||
public var pushNotificationPolicyMissingFromResponse: Bool {
|
||||
switch instanceType {
|
||||
case .mastodon(_, let version):
|
||||
return version >= Version(3, 5, 0) && version < Version(4, 1, 0)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public var instanceAnnouncements: Bool {
|
||||
hasMastodonVersion(3, 1, 0)
|
||||
}
|
||||
|
||||
public var emojiReactionNotifications: Bool {
|
||||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
|
|
|
@ -42,7 +42,7 @@ public struct Client: Sendable {
|
|||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -341,6 +341,10 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Notifications
|
||||
public static func getNotification(id: String) -> Request<Notification> {
|
||||
return Request(method: .get, path: "/api/v1/notifications/\(id)")
|
||||
}
|
||||
|
||||
public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
|
||||
var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
|
||||
"types" => allowedTypes.map { $0.rawValue }
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,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" }
|
||||
}
|
||||
|
|
|
@ -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,202 @@
|
|||
//
|
||||
// PushManagerImpl.swift
|
||||
// PushNotifications
|
||||
//
|
||||
// Created by Shadowfacts on 4/7/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UserAccounts
|
||||
import CryptoKit
|
||||
|
||||
class PushManagerImpl: _PushManager {
|
||||
private let endpoint: URL
|
||||
|
||||
var enabled: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
private var apnsEnvironment: String {
|
||||
#if DEBUG
|
||||
"development"
|
||||
#else
|
||||
"production"
|
||||
#endif
|
||||
}
|
||||
|
||||
private var remoteNotificationsRegistrationContinuation: CheckedContinuation<Data, any Error>?
|
||||
|
||||
private let defaults = UserDefaults(suiteName: "group.space.vaccor.Tusker")!
|
||||
public private(set) var subscriptions: [PushSubscription] {
|
||||
get {
|
||||
if let array = defaults.array(forKey: "PushSubscriptions") as? [[String: Any]] {
|
||||
return array.compactMap(PushSubscription.init(defaultsDict:))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
set {
|
||||
defaults.setValue(newValue.map(\.defaultsDict), forKey: "PushSubscriptions")
|
||||
}
|
||||
}
|
||||
|
||||
init(endpoint: URL) {
|
||||
self.endpoint = endpoint
|
||||
}
|
||||
|
||||
func createSubscription(account: UserAccountInfo) async throws -> PushSubscription {
|
||||
if let existing = pushSubscription(account: account) {
|
||||
return existing
|
||||
}
|
||||
let key = P256.KeyAgreement.PrivateKey()
|
||||
var authSecret = Data(count: 16)
|
||||
let res = authSecret.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!)
|
||||
}
|
||||
guard res == errSecSuccess else {
|
||||
throw CreateSubscriptionError.generatingAuthSecret(res)
|
||||
}
|
||||
let token = try await getDeviceToken()
|
||||
let subscription = PushSubscription(
|
||||
accountID: account.id,
|
||||
endpoint: endpointURL(deviceToken: token, accountID: account.id),
|
||||
secretKey: key,
|
||||
authSecret: authSecret,
|
||||
alerts: [],
|
||||
policy: .all
|
||||
)
|
||||
subscriptions.append(subscription)
|
||||
return subscription
|
||||
}
|
||||
|
||||
private func endpointURL(deviceToken: Data, accountID: String) -> URL {
|
||||
var endpoint = URLComponents(url: endpoint, resolvingAgainstBaseURL: false)!
|
||||
let accountID = accountID.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
||||
endpoint.path = "/push/v1/\(apnsEnvironment)/\(deviceToken.hexEncodedString())/\(accountID)"
|
||||
return endpoint.url!
|
||||
}
|
||||
|
||||
func removeSubscription(account: UserAccountInfo) {
|
||||
subscriptions.removeAll { $0.accountID == account.id }
|
||||
}
|
||||
|
||||
func updateSubscription(account: UserAccountInfo, alerts: PushSubscription.Alerts, policy: PushSubscription.Policy) {
|
||||
guard let index = subscriptions.firstIndex(where: { $0.accountID == account.id }) else {
|
||||
return
|
||||
}
|
||||
var copy = subscriptions[index]
|
||||
copy.alerts = alerts
|
||||
copy.policy = policy
|
||||
subscriptions[index] = copy
|
||||
}
|
||||
|
||||
func pushSubscription(account: UserAccountInfo) -> PushSubscription? {
|
||||
subscriptions.first { $0.accountID == account.id }
|
||||
}
|
||||
|
||||
func updateIfNecessary(updateSubscription: @escaping (PushSubscription) async -> Bool) async {
|
||||
let subscriptions = self.subscriptions
|
||||
guard !subscriptions.isEmpty else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
let token = try await getDeviceToken()
|
||||
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
|
||||
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
|
||||
guard newEndpoint != $0.endpoint else {
|
||||
return $0
|
||||
}
|
||||
var copy = $0
|
||||
copy.endpoint = newEndpoint
|
||||
if await updateSubscription(copy) {
|
||||
return copy
|
||||
} else {
|
||||
return $0
|
||||
}
|
||||
}.reduce(into: [], { partialResult, el in
|
||||
partialResult.append(el)
|
||||
})
|
||||
} catch {
|
||||
PushManager.logger.error("Failed to update push registration: \(String(describing: error), privacy: .public)")
|
||||
PushManager.captureError?(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func getDeviceToken() async throws -> Data {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
remoteNotificationsRegistrationContinuation = continuation
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
}
|
||||
|
||||
func didRegisterForRemoteNotifications(deviceToken: Data) {
|
||||
remoteNotificationsRegistrationContinuation?.resume(returning: deviceToken)
|
||||
remoteNotificationsRegistrationContinuation = nil
|
||||
}
|
||||
|
||||
func didFailToRegisterForRemoteNotifications(error: any Error) {
|
||||
remoteNotificationsRegistrationContinuation?.resume(throwing: PushRegistrationError.registeringForRemoteNotifications(error))
|
||||
remoteNotificationsRegistrationContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum PushRegistrationError: LocalizedError {
|
||||
case alreadyRegistering
|
||||
case registeringForRemoteNotifications(any Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .alreadyRegistering:
|
||||
"Already registering"
|
||||
case .registeringForRemoteNotifications(let error):
|
||||
"Remote notifications: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CreateSubscriptionError: LocalizedError {
|
||||
case generatingAuthSecret(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .generatingAuthSecret(let code):
|
||||
"Generating auth secret: \(code)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
func hexEncodedString() -> String {
|
||||
String(unsafeUninitializedCapacity: count * 2) { buffer in
|
||||
let chars = Array("0123456789ABCDEF".utf8)
|
||||
for (i, x) in enumerated() {
|
||||
let (upper, lower) = x.quotientAndRemainder(dividingBy: 16)
|
||||
buffer[i * 2] = chars[Int(upper)]
|
||||
buffer[i * 2 + 1] = chars[Int(lower)]
|
||||
}
|
||||
return count * 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AsyncSequenceAdaptor<S: Sequence>: AsyncSequence {
|
||||
typealias Element = S.Element
|
||||
|
||||
let base: S
|
||||
|
||||
init(wrapping base: S) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
func makeAsyncIterator() -> AsyncIterator {
|
||||
AsyncIterator(base: base.makeIterator())
|
||||
}
|
||||
|
||||
struct AsyncIterator: AsyncIteratorProtocol {
|
||||
var base: S.Iterator
|
||||
|
||||
mutating func next() async -> Element? {
|
||||
base.next()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,426 +2,42 @@
|
|||
// Preferences.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 8/28/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
|
||||
public final class Preferences: Codable, ObservableObject {
|
||||
import Foundation
|
||||
|
||||
public struct Preferences {
|
||||
@MainActor
|
||||
public static var shared: Preferences = load()
|
||||
public static let shared: PreferenceStore = load()
|
||||
|
||||
private static var documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
private static var appGroupDirectory = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!
|
||||
private static var archiveURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
private static var legacyURL = appGroupDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
private static var preferencesURL = appGroupDirectory.appendingPathComponent("preferences.v2").appendingPathExtension("plist")
|
||||
private static var nonAppGroupURL = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
|
||||
private init() {}
|
||||
|
||||
@MainActor
|
||||
public static func save() {
|
||||
let encoder = PropertyListEncoder()
|
||||
let data = try? encoder.encode(shared)
|
||||
try? data?.write(to: archiveURL, options: .noFileProtection)
|
||||
let data = try? encoder.encode(PreferenceCoding(wrapped: shared))
|
||||
try? data?.write(to: preferencesURL, options: .noFileProtection)
|
||||
}
|
||||
|
||||
public static func load() -> Preferences {
|
||||
private static func load() -> PreferenceStore {
|
||||
let decoder = PropertyListDecoder()
|
||||
if let data = try? Data(contentsOf: archiveURL),
|
||||
let preferences = try? decoder.decode(Preferences.self, from: data) {
|
||||
return preferences
|
||||
}
|
||||
return Preferences()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public static func migrate(from url: URL) -> Result<Void, any Error> {
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: archiveURL)
|
||||
try FileManager.default.moveItem(at: url, to: archiveURL)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
shared = load()
|
||||
return .success(())
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
public required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
self.pureBlackDarkMode = try container.decodeIfPresent(Bool.self, forKey: .pureBlackDarkMode) ?? true
|
||||
self.accentColor = try container.decodeIfPresent(AccentColor.self, forKey: .accentColor) ?? .default
|
||||
self.avatarStyle = try container.decode(AvatarStyle.self, forKey: .avatarStyle)
|
||||
self.hideCustomEmojiInUsernames = try container.decode(Bool.self, forKey: .hideCustomEmojiInUsernames)
|
||||
self.showIsStatusReplyIcon = try container.decode(Bool.self, forKey: .showIsStatusReplyIcon)
|
||||
self.alwaysShowStatusVisibilityIcon = try container.decode(Bool.self, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
self.hideActionsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .hideActionsInTimeline) ?? false
|
||||
self.showLinkPreviews = try container.decodeIfPresent(Bool.self, forKey: .showLinkPreviews) ?? true
|
||||
self.leadingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .leadingStatusSwipeActions) ?? leadingStatusSwipeActions
|
||||
self.trailingStatusSwipeActions = try container.decodeIfPresent([StatusSwipeAction].self, forKey: .trailingStatusSwipeActions) ?? trailingStatusSwipeActions
|
||||
self.widescreenNavigationMode = try container.decodeIfPresent(WidescreenNavigationMode.self, forKey: .widescreenNavigationMode) ?? Self.defaultWidescreenNavigationMode
|
||||
self.underlineTextLinks = try container.decodeIfPresent(Bool.self, forKey: .underlineTextLinks) ?? false
|
||||
self.showAttachmentsInTimeline = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentsInTimeline) ?? true
|
||||
|
||||
if let existing = try? container.decode(Visibility.self, forKey: .defaultPostVisibility) {
|
||||
self.defaultPostVisibility = .visibility(existing)
|
||||
if let data = try? Data(contentsOf: preferencesURL),
|
||||
let store = try? decoder.decode(PreferenceCoding<PreferenceStore>.self, from: data) {
|
||||
return store.wrapped
|
||||
} else if let legacyData = (try? Data(contentsOf: legacyURL)) ?? (try? Data(contentsOf: nonAppGroupURL)),
|
||||
let legacy = try? decoder.decode(LegacyPreferences.self, from: legacyData) {
|
||||
let store = PreferenceStore()
|
||||
store.migrate(from: legacy)
|
||||
return store
|
||||
} else {
|
||||
self.defaultPostVisibility = try container.decode(PostVisibility.self, forKey: .defaultPostVisibility)
|
||||
}
|
||||
self.defaultReplyVisibility = try container.decodeIfPresent(ReplyVisibility.self, forKey: .defaultReplyVisibility) ?? .sameAsPost
|
||||
self.requireAttachmentDescriptions = try container.decode(Bool.self, forKey: .requireAttachmentDescriptions)
|
||||
self.contentWarningCopyMode = try container.decode(ContentWarningCopyMode.self, forKey: .contentWarningCopyMode)
|
||||
self.mentionReblogger = try container.decode(Bool.self, forKey: .mentionReblogger)
|
||||
self.useTwitterKeyboard = try container.decodeIfPresent(Bool.self, forKey: .useTwitterKeyboard) ?? false
|
||||
|
||||
if let blurAllMedia = try? container.decodeIfPresent(Bool.self, forKey: .blurAllMedia) {
|
||||
self.attachmentBlurMode = blurAllMedia ? .always : .useStatusSetting
|
||||
} else {
|
||||
self.attachmentBlurMode = try container.decode(AttachmentBlurMode.self, forKey: .attachmentBlurMode)
|
||||
}
|
||||
self.blurMediaBehindContentWarning = try container.decodeIfPresent(Bool.self, forKey: .blurMediaBehindContentWarning) ?? true
|
||||
self.automaticallyPlayGifs = try container.decode(Bool.self, forKey: .automaticallyPlayGifs)
|
||||
self.showUncroppedMediaInline = try container.decodeIfPresent(Bool.self, forKey: .showUncroppedMediaInline) ?? true
|
||||
self.showAttachmentBadges = try container.decodeIfPresent(Bool.self, forKey: .showAttachmentBadges) ?? true
|
||||
|
||||
self.openLinksInApps = try container.decode(Bool.self, forKey: .openLinksInApps)
|
||||
self.useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
self.inAppSafariAutomaticReaderMode = try container.decode(Bool.self, forKey: .inAppSafariAutomaticReaderMode)
|
||||
self.expandAllContentWarnings = try container.decodeIfPresent(Bool.self, forKey: .expandAllContentWarnings) ?? false
|
||||
self.collapseLongPosts = try container.decodeIfPresent(Bool.self, forKey: .collapseLongPosts) ?? true
|
||||
self.oppositeCollapseKeywords = try container.decodeIfPresent([String].self, forKey: .oppositeCollapseKeywords) ?? []
|
||||
self.confirmBeforeReblog = try container.decodeIfPresent(Bool.self, forKey: .confirmBeforeReblog) ?? false
|
||||
self.timelineStateRestoration = try container.decodeIfPresent(Bool.self, forKey: .timelineStateRestoration) ?? true
|
||||
self.timelineSyncMode = try container.decodeIfPresent(TimelineSyncMode.self, forKey: .timelineSyncMode) ?? .icloud
|
||||
self.hideReblogsInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideReblogsInTimelines) ?? false
|
||||
self.hideRepliesInTimelines = try container.decodeIfPresent(Bool.self, forKey: .hideRepliesInTimelines) ?? false
|
||||
|
||||
self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts)
|
||||
self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType)
|
||||
self.grayscaleImages = try container.decodeIfPresent(Bool.self, forKey: .grayscaleImages) ?? false
|
||||
self.disableInfiniteScrolling = try container.decodeIfPresent(Bool.self, forKey: .disableInfiniteScrolling) ?? false
|
||||
self.hideTrends = try container.decodeIfPresent(Bool.self, forKey: .hideTrends) ?? false
|
||||
|
||||
self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType)
|
||||
self.reportErrorsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .reportErrorsAutomatically) ?? true
|
||||
let featureFlagNames = (try? container.decodeIfPresent([String].self, forKey: .enabledFeatureFlags)) ?? []
|
||||
self.enabledFeatureFlags = Set(featureFlagNames.compactMap(FeatureFlag.init))
|
||||
|
||||
self.hasShownLocalTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownLocalTimelineDescription) ?? false
|
||||
self.hasShownFederatedTimelineDescription = try container.decodeIfPresent(Bool.self, forKey: .hasShownFederatedTimelineDescription) ?? false
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
try container.encode(pureBlackDarkMode, forKey: .pureBlackDarkMode)
|
||||
try container.encode(accentColor, forKey: .accentColor)
|
||||
try container.encode(avatarStyle, forKey: .avatarStyle)
|
||||
try container.encode(hideCustomEmojiInUsernames, forKey: .hideCustomEmojiInUsernames)
|
||||
try container.encode(showIsStatusReplyIcon, forKey: .showIsStatusReplyIcon)
|
||||
try container.encode(alwaysShowStatusVisibilityIcon, forKey: .alwaysShowStatusVisibilityIcon)
|
||||
try container.encode(hideActionsInTimeline, forKey: .hideActionsInTimeline)
|
||||
try container.encode(showLinkPreviews, forKey: .showLinkPreviews)
|
||||
try container.encode(leadingStatusSwipeActions, forKey: .leadingStatusSwipeActions)
|
||||
try container.encode(trailingStatusSwipeActions, forKey: .trailingStatusSwipeActions)
|
||||
try container.encode(widescreenNavigationMode, forKey: .widescreenNavigationMode)
|
||||
try container.encode(underlineTextLinks, forKey: .underlineTextLinks)
|
||||
try container.encode(showAttachmentsInTimeline, forKey: .showAttachmentsInTimeline)
|
||||
|
||||
try container.encode(defaultPostVisibility, forKey: .defaultPostVisibility)
|
||||
try container.encode(defaultReplyVisibility, forKey: .defaultReplyVisibility)
|
||||
try container.encode(requireAttachmentDescriptions, forKey: .requireAttachmentDescriptions)
|
||||
try container.encode(contentWarningCopyMode, forKey: .contentWarningCopyMode)
|
||||
try container.encode(mentionReblogger, forKey: .mentionReblogger)
|
||||
try container.encode(useTwitterKeyboard, forKey: .useTwitterKeyboard)
|
||||
|
||||
try container.encode(attachmentBlurMode, forKey: .attachmentBlurMode)
|
||||
try container.encode(blurMediaBehindContentWarning, forKey: .blurMediaBehindContentWarning)
|
||||
try container.encode(automaticallyPlayGifs, forKey: .automaticallyPlayGifs)
|
||||
try container.encode(showUncroppedMediaInline, forKey: .showUncroppedMediaInline)
|
||||
try container.encode(showAttachmentBadges, forKey: .showAttachmentBadges)
|
||||
|
||||
try container.encode(openLinksInApps, forKey: .openLinksInApps)
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
try container.encode(inAppSafariAutomaticReaderMode, forKey: .inAppSafariAutomaticReaderMode)
|
||||
try container.encode(expandAllContentWarnings, forKey: .expandAllContentWarnings)
|
||||
try container.encode(collapseLongPosts, forKey: .collapseLongPosts)
|
||||
try container.encode(oppositeCollapseKeywords, forKey: .oppositeCollapseKeywords)
|
||||
try container.encode(confirmBeforeReblog, forKey: .confirmBeforeReblog)
|
||||
try container.encode(timelineStateRestoration, forKey: .timelineStateRestoration)
|
||||
try container.encode(timelineSyncMode, forKey: .timelineSyncMode)
|
||||
try container.encode(hideReblogsInTimelines, forKey: .hideReblogsInTimelines)
|
||||
try container.encode(hideRepliesInTimelines, forKey: .hideRepliesInTimelines)
|
||||
|
||||
try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts)
|
||||
try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType)
|
||||
try container.encode(grayscaleImages, forKey: .grayscaleImages)
|
||||
try container.encode(disableInfiniteScrolling, forKey: .disableInfiniteScrolling)
|
||||
try container.encode(hideTrends, forKey: .hideTrends)
|
||||
|
||||
try container.encode(statusContentType, forKey: .statusContentType)
|
||||
try container.encode(reportErrorsAutomatically, forKey: .reportErrorsAutomatically)
|
||||
try container.encode(enabledFeatureFlags, forKey: .enabledFeatureFlags)
|
||||
|
||||
try container.encode(hasShownLocalTimelineDescription, forKey: .hasShownLocalTimelineDescription)
|
||||
try container.encode(hasShownFederatedTimelineDescription, forKey: .hasShownFederatedTimelineDescription)
|
||||
}
|
||||
|
||||
// MARK: Appearance
|
||||
@Published public var theme = UIUserInterfaceStyle.unspecified
|
||||
@Published public var pureBlackDarkMode = true
|
||||
@Published public var accentColor = AccentColor.default
|
||||
@Published public var avatarStyle = AvatarStyle.roundRect
|
||||
@Published public var hideCustomEmojiInUsernames = false
|
||||
@Published public var showIsStatusReplyIcon = false
|
||||
@Published public var alwaysShowStatusVisibilityIcon = false
|
||||
@Published public var hideActionsInTimeline = false
|
||||
@Published public var showLinkPreviews = true
|
||||
@Published public var leadingStatusSwipeActions: [StatusSwipeAction] = [.favorite, .reblog]
|
||||
@Published public var trailingStatusSwipeActions: [StatusSwipeAction] = [.reply, .share]
|
||||
private static var defaultWidescreenNavigationMode = WidescreenNavigationMode.splitScreen
|
||||
@Published public var widescreenNavigationMode = Preferences.defaultWidescreenNavigationMode
|
||||
@Published public var underlineTextLinks = false
|
||||
@Published public var showAttachmentsInTimeline = true
|
||||
|
||||
// MARK: Composing
|
||||
@Published public var defaultPostVisibility = PostVisibility.serverDefault
|
||||
@Published public var defaultReplyVisibility = ReplyVisibility.sameAsPost
|
||||
@Published public var requireAttachmentDescriptions = false
|
||||
@Published public var contentWarningCopyMode = ContentWarningCopyMode.asIs
|
||||
@Published public var mentionReblogger = false
|
||||
@Published public var useTwitterKeyboard = false
|
||||
|
||||
// MARK: Media
|
||||
@Published public var attachmentBlurMode = AttachmentBlurMode.useStatusSetting {
|
||||
didSet {
|
||||
if attachmentBlurMode == .always {
|
||||
blurMediaBehindContentWarning = true
|
||||
} else if attachmentBlurMode == .never {
|
||||
blurMediaBehindContentWarning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@Published public var blurMediaBehindContentWarning = true
|
||||
@Published public var automaticallyPlayGifs = true
|
||||
@Published public var showUncroppedMediaInline = true
|
||||
@Published public var showAttachmentBadges = true
|
||||
|
||||
// MARK: Behavior
|
||||
@Published public var openLinksInApps = true
|
||||
@Published public var useInAppSafari = true
|
||||
@Published public var inAppSafariAutomaticReaderMode = false
|
||||
@Published public var expandAllContentWarnings = false
|
||||
@Published public var collapseLongPosts = true
|
||||
@Published public var oppositeCollapseKeywords: [String] = []
|
||||
@Published public var confirmBeforeReblog = false
|
||||
@Published public var timelineStateRestoration = true
|
||||
@Published public var timelineSyncMode = TimelineSyncMode.icloud
|
||||
@Published public var hideReblogsInTimelines = false
|
||||
@Published public var hideRepliesInTimelines = false
|
||||
|
||||
// MARK: Digital Wellness
|
||||
@Published public var showFavoriteAndReblogCounts = true
|
||||
@Published public var defaultNotificationsMode = NotificationsMode.allNotifications
|
||||
@Published public var grayscaleImages = false
|
||||
@Published public var disableInfiniteScrolling = false
|
||||
@Published public var hideTrends = false
|
||||
|
||||
// MARK: Advanced
|
||||
@Published public var statusContentType: StatusContentType = .plain
|
||||
@Published public var reportErrorsAutomatically = true
|
||||
@Published public var enabledFeatureFlags: Set<FeatureFlag> = []
|
||||
|
||||
// MARK:
|
||||
@Published public var hasShownLocalTimelineDescription = false
|
||||
@Published public var hasShownFederatedTimelineDescription = false
|
||||
|
||||
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
|
||||
enabledFeatureFlags.contains(flag)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
case pureBlackDarkMode
|
||||
case accentColor
|
||||
case avatarStyle
|
||||
case hideCustomEmojiInUsernames
|
||||
case showIsStatusReplyIcon
|
||||
case alwaysShowStatusVisibilityIcon
|
||||
case hideActionsInTimeline
|
||||
case showLinkPreviews
|
||||
case leadingStatusSwipeActions
|
||||
case trailingStatusSwipeActions
|
||||
case widescreenNavigationMode
|
||||
case underlineTextLinks
|
||||
case showAttachmentsInTimeline
|
||||
|
||||
case defaultPostVisibility
|
||||
case defaultReplyVisibility
|
||||
case requireAttachmentDescriptions
|
||||
case contentWarningCopyMode
|
||||
case mentionReblogger
|
||||
case useTwitterKeyboard
|
||||
|
||||
case blurAllMedia // only used for migration
|
||||
case attachmentBlurMode
|
||||
case blurMediaBehindContentWarning
|
||||
case automaticallyPlayGifs
|
||||
case showUncroppedMediaInline
|
||||
case showAttachmentBadges
|
||||
|
||||
case openLinksInApps
|
||||
case useInAppSafari
|
||||
case inAppSafariAutomaticReaderMode
|
||||
case expandAllContentWarnings
|
||||
case collapseLongPosts
|
||||
case oppositeCollapseKeywords
|
||||
case confirmBeforeReblog
|
||||
case timelineStateRestoration
|
||||
case timelineSyncMode
|
||||
case hideReblogsInTimelines
|
||||
case hideRepliesInTimelines
|
||||
|
||||
case showFavoriteAndReblogCounts
|
||||
case defaultNotificationsType
|
||||
case grayscaleImages
|
||||
case disableInfiniteScrolling
|
||||
case hideTrends = "hideDiscover"
|
||||
|
||||
case statusContentType
|
||||
case reportErrorsAutomatically
|
||||
case enabledFeatureFlags
|
||||
|
||||
case hasShownLocalTimelineDescription
|
||||
case hasShownFederatedTimelineDescription
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
return PreferenceStore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
||||
|
||||
extension Preferences {
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadMultiColumn = "ipad-multi-column"
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
||||
}
|
||||
|
||||
extension Preferences {
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
//
|
||||
// AccentColor.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public enum AccentColor: String, Codable, CaseIterable {
|
||||
case `default`
|
||||
case purple
|
||||
case indigo
|
||||
case blue
|
||||
case cyan
|
||||
case teal
|
||||
case mint
|
||||
case green
|
||||
// case yellow
|
||||
case orange
|
||||
case red
|
||||
case pink
|
||||
// case brown
|
||||
|
||||
public var color: UIColor? {
|
||||
switch self {
|
||||
case .default:
|
||||
return nil
|
||||
case .blue:
|
||||
return .systemBlue
|
||||
// case .brown:
|
||||
// return .systemBrown
|
||||
case .cyan:
|
||||
return .systemCyan
|
||||
case .green:
|
||||
return .systemGreen
|
||||
case .indigo:
|
||||
return .systemIndigo
|
||||
case .mint:
|
||||
return .systemMint
|
||||
case .orange:
|
||||
return .systemOrange
|
||||
case .pink:
|
||||
return .systemPink
|
||||
case .purple:
|
||||
return .systemPurple
|
||||
case .red:
|
||||
return .systemRed
|
||||
case .teal:
|
||||
return .systemTeal
|
||||
// case .yellow:
|
||||
// return .systemYellow
|
||||
}
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
switch self {
|
||||
case .default:
|
||||
return "Default"
|
||||
case .blue:
|
||||
return "Blue"
|
||||
// case .brown:
|
||||
// return "Brown"
|
||||
case .cyan:
|
||||
return "Cyan"
|
||||
case .green:
|
||||
return "Green"
|
||||
case .indigo:
|
||||
return "Indigo"
|
||||
case .mint:
|
||||
return "Mint"
|
||||
case .orange:
|
||||
return "Orange"
|
||||
case .pink:
|
||||
return "Pink"
|
||||
case .purple:
|
||||
return "Purple"
|
||||
case .red:
|
||||
return "Red"
|
||||
case .teal:
|
||||
return "Teal"
|
||||
// case .yellow:
|
||||
// return "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
//
|
||||
// AttachmentBlurMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AttachmentBlurMode: Codable, Hashable, CaseIterable {
|
||||
case useStatusSetting
|
||||
case always
|
||||
case never
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .useStatusSetting:
|
||||
return "Default"
|
||||
case .always:
|
||||
return "Always"
|
||||
case .never:
|
||||
return "Never"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
//
|
||||
// FeatureFlag.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FeatureFlag: String, Codable {
|
||||
case iPadBrowserNavigation = "ipad-browser-navigation"
|
||||
}
|
|
@ -12,7 +12,7 @@ public enum PostVisibility: Codable, Hashable, CaseIterable, Sendable {
|
|||
case serverDefault
|
||||
case visibility(Visibility)
|
||||
|
||||
public static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
||||
public private(set) static var allCases: [PostVisibility] = [.serverDefault] + Visibility.allCases.map { .visibility($0) }
|
||||
|
||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
||||
switch self {
|
||||
|
@ -57,7 +57,7 @@ public enum ReplyVisibility: Codable, Hashable, CaseIterable {
|
|||
case sameAsPost
|
||||
case visibility(Visibility)
|
||||
|
||||
public static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
||||
public private(set) static var allCases: [ReplyVisibility] = [.sameAsPost] + Visibility.allCases.map { .visibility($0) }
|
||||
|
||||
@MainActor
|
||||
public func resolved(withServerDefault serverDefault: Visibility?) -> Visibility {
|
|
@ -0,0 +1,24 @@
|
|||
//
|
||||
// Theme.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
public enum Theme: String, Codable {
|
||||
case unspecified, light, dark
|
||||
|
||||
public var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
switch self {
|
||||
case .unspecified:
|
||||
.unspecified
|
||||
case .light:
|
||||
.light
|
||||
case .dark:
|
||||
.dark
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// TimelineSyncMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TimelineSyncMode: String, Codable {
|
||||
case mastodon
|
||||
case icloud
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
//
|
||||
// WidescreenNavigationMode.swift
|
||||
// TuskerPreferences
|
||||
//
|
||||
// Created by Shadowfacts on 4/13/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum WidescreenNavigationMode: String, Codable {
|
||||
case stack
|
||||
case splitScreen
|
||||
case multiColumn
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
//
|
||||
// PreferenceStoreTests.swift
|
||||
// TuskerPreferencesTests
|
||||
//
|
||||
// Created by Shadowfacts on 4/12/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import TuskerPreferences
|
||||
import Combine
|
||||
|
||||
final class PreferenceStoreTests: XCTestCase {
|
||||
|
||||
struct TestKey: PreferenceKey {
|
||||
static let defaultValue = false
|
||||
}
|
||||
|
||||
final class TestStore<Key: PreferenceKey>: Codable, ObservableObject {
|
||||
private var _test = Preference<Key>()
|
||||
|
||||
// the acutal subscript expects the enclosingInstance to be a PreferenceStore, so do it manually
|
||||
var test: Key.Value {
|
||||
get {
|
||||
Preference.get(enclosingInstance: self, storage: \._test)
|
||||
}
|
||||
set {
|
||||
Preference.set(enclosingInstance: self, storage: \._test, newValue: newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var testPublisher: some Publisher<Key.Value, Never> {
|
||||
_test.projectedValue
|
||||
}
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self._test = try container.decode(Preference<Key>.self, forKey: .test)
|
||||
}
|
||||
|
||||
enum CodingKeys: CodingKey {
|
||||
case test
|
||||
}
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(self._test, forKey: .test)
|
||||
}
|
||||
}
|
||||
|
||||
func testDecoding() throws {
|
||||
let decoder = JSONDecoder()
|
||||
let present = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
|
||||
{"test": true}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(present.test, true)
|
||||
let absent = try decoder.decode(PreferenceCoding<TestStore<TestKey>>.self, from: Data("""
|
||||
{}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(absent.test, false)
|
||||
}
|
||||
|
||||
func testEncoding() throws {
|
||||
let store = TestStore<TestKey>()
|
||||
let encoder = JSONEncoder()
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{}
|
||||
""")
|
||||
store.test = true
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":true}
|
||||
""")
|
||||
store.test = false
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":false}
|
||||
""")
|
||||
}
|
||||
|
||||
func testPublisher() {
|
||||
let topLevel = expectation(description: "top level publisher")
|
||||
let specificPref = expectation(description: "preference publisher")
|
||||
// initial and on change
|
||||
specificPref.expectedFulfillmentCount = 2
|
||||
let store = TestStore<TestKey>()
|
||||
var cancellables = Set<AnyCancellable>()
|
||||
store.objectWillChange.sink {
|
||||
topLevel.fulfill()
|
||||
// fires on will change
|
||||
XCTAssertEqual(store.test, false)
|
||||
}.store(in: &cancellables)
|
||||
store.testPublisher.sink { _ in
|
||||
specificPref.fulfill()
|
||||
}.store(in: &cancellables)
|
||||
store.test = true
|
||||
wait(for: [topLevel, specificPref])
|
||||
}
|
||||
|
||||
func testCustomCodable() throws {
|
||||
struct Key: CustomCodablePreferenceKey {
|
||||
static let defaultValue = 1
|
||||
static func encode(value: Int, to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(2)
|
||||
}
|
||||
static func decode(from decoder: any Decoder) throws -> Int? {
|
||||
3
|
||||
}
|
||||
}
|
||||
let store = TestStore<Key>()
|
||||
store.test = 123
|
||||
let encoder = JSONEncoder()
|
||||
XCTAssertEqual(String(data: try encoder.encode(PreferenceCoding(wrapped: store)), encoding: .utf8)!, """
|
||||
{"test":2}
|
||||
""")
|
||||
let decoder = JSONDecoder()
|
||||
let present = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
|
||||
{"test":2}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(present.test, 3)
|
||||
let absent = try decoder.decode(PreferenceCoding<TestStore<Key>>.self, from: Data("""
|
||||
{}
|
||||
""".utf8)).wrapped
|
||||
XCTAssertEqual(absent.test, 1)
|
||||
}
|
||||
|
||||
}
|
|
@ -11,10 +11,11 @@ import CryptoKit
|
|||
public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let instanceURL: URL
|
||||
public let clientID: String
|
||||
public let clientSecret: String
|
||||
public private(set) var username: String!
|
||||
public let accessToken: String
|
||||
public internal(set) var clientID: String
|
||||
public internal(set) var clientSecret: String
|
||||
public let username: String!
|
||||
public internal(set) var accessToken: String
|
||||
public internal(set) var scopes: [String]?
|
||||
|
||||
// Sort of hack to be able to access these from the share extension.
|
||||
public internal(set) var serverDefaultLanguage: String?
|
||||
|
@ -40,16 +41,19 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.username = nil
|
||||
self.accessToken = accessToken
|
||||
self.scopes = nil
|
||||
}
|
||||
|
||||
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String) {
|
||||
init(instanceURL: URL, clientID: String, clientSecret: String, username: String? = nil, accessToken: String, scopes: [String]) {
|
||||
self.id = UserAccountInfo.id(instanceURL: instanceURL, username: username)
|
||||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.clientSecret = clientSecret
|
||||
self.username = username
|
||||
self.accessToken = accessToken
|
||||
self.scopes = scopes
|
||||
}
|
||||
|
||||
init?(userDefaultsDict dict: [String: Any]) {
|
||||
|
@ -67,6 +71,7 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
self.clientSecret = secret
|
||||
self.username = dict["username"] as? String
|
||||
self.accessToken = accessToken
|
||||
self.scopes = dict["scopes"] as? [String]
|
||||
self.serverDefaultLanguage = dict["serverDefaultLanguage"] as? String
|
||||
self.serverDefaultVisibility = dict["serverDefaultVisibility"] as? String
|
||||
self.serverDefaultFederation = dict["serverDefaultFederation"] as? Bool
|
||||
|
@ -83,6 +88,9 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
if let username {
|
||||
dict["username"] = username
|
||||
}
|
||||
if let scopes {
|
||||
dict["scopes"] = scopes
|
||||
}
|
||||
if let serverDefaultLanguage {
|
||||
dict["serverDefaultLanguage"] = serverDefaultLanguage
|
||||
}
|
||||
|
@ -100,12 +108,4 @@ public struct UserAccountInfo: Equatable, Hashable, Identifiable, Sendable {
|
|||
// slashes are not allowed in the persistent store coordinator name
|
||||
id.replacingOccurrences(of: "/", with: "_")
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public static func ==(lhs: UserAccountInfo, rhs: UserAccountInfo) -> Bool {
|
||||
return lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/>
|
||||
|
|
|
@ -3,3 +3,4 @@
|
|||
DEVELOPMENT_TEAM = YOUR_TEAM_ID
|
||||
BUNDLE_ID_PREFIX = com.example
|
||||
|
||||
TUSKER_PUSH_PROXY_HOST =
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,63 @@
|
|||
//
|
||||
// GetAuthorizationTokenService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/9/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
class GetAuthorizationTokenService {
|
||||
let instanceURL: URL
|
||||
let clientID: String
|
||||
let presentationContextProvider: ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
init(instanceURL: URL, clientID: String, presentationContextProvider: ASWebAuthenticationPresentationContextProviding) {
|
||||
self.instanceURL = instanceURL
|
||||
self.clientID = clientID
|
||||
self.presentationContextProvider = presentationContextProvider
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func run() async throws -> String {
|
||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||
components.path = "/oauth/authorize"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "scope", value: MastodonController.oauthScopes.map(\.rawValue).joined(separator: " ")),
|
||||
URLQueryItem(name: "redirect_uri", value: "tusker://oauth")
|
||||
]
|
||||
let authorizeURL = components.url!
|
||||
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
let authenticationSession = ASWebAuthenticationSession(url: authorizeURL, callbackURLScheme: "tusker", completionHandler: { url, error in
|
||||
if let error = error {
|
||||
if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin {
|
||||
continuation.resume(throwing: Error.cancelled)
|
||||
} else {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
} else if let url = url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let item = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = item.value {
|
||||
continuation.resume(returning: code)
|
||||
} else {
|
||||
continuation.resume(throwing: Error.noAuthorizationCode)
|
||||
}
|
||||
})
|
||||
// Prefer ephemeral sessions to make it easier to sign into multiple accounts on the same instance.
|
||||
authenticationSession.prefersEphemeralWebBrowserSession = true
|
||||
authenticationSession.presentationContextProvider = presentationContextProvider
|
||||
authenticationSession.start()
|
||||
})
|
||||
}
|
||||
|
||||
enum Error: Swift.Error {
|
||||
case cancelled
|
||||
case noAuthorizationCode
|
||||
}
|
||||
}
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
import Foundation
|
||||
import UserAccounts
|
||||
import PushNotifications
|
||||
import Pachyderm
|
||||
|
||||
@MainActor
|
||||
class LogoutService {
|
||||
|
@ -20,7 +22,12 @@ class LogoutService {
|
|||
}
|
||||
|
||||
func run() {
|
||||
Task.detached {
|
||||
let accountInfo = self.accountInfo
|
||||
Task.detached { @MainActor in
|
||||
if PushManager.shared.pushSubscription(account: accountInfo) != nil {
|
||||
_ = try? await self.mastodonController.run(Pachyderm.PushSubscription.delete())
|
||||
PushManager.shared.removeSubscription(account: accountInfo)
|
||||
}
|
||||
try? await self.mastodonController.client.revokeAccessToken()
|
||||
}
|
||||
MastodonController.removeForAccount(accountInfo)
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
//
|
||||
// MastodonController+Push.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 4/9/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
import PushNotifications
|
||||
import CryptoKit
|
||||
|
||||
extension MastodonController {
|
||||
func createPushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
|
||||
let req = Pachyderm.PushSubscription.create(
|
||||
endpoint: subscription.endpoint,
|
||||
// mastodon docs just say "Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve."
|
||||
// other apps use SecKeyCopyExternalRepresentation which is documented to use X9.63 for elliptic curve keys
|
||||
// and that seems to work
|
||||
publicKey: subscription.secretKey.publicKey.x963Representation,
|
||||
authSecret: subscription.authSecret,
|
||||
alerts: .init(subscription.alerts),
|
||||
policy: .init(subscription.policy)
|
||||
)
|
||||
return try await run(req).0
|
||||
}
|
||||
|
||||
func updatePushSubscription(subscription: PushNotifications.PushSubscription) async throws -> Pachyderm.PushSubscription {
|
||||
// when updating anything other than the alerts/policy, we need to go through the create route
|
||||
return try await createPushSubscription(subscription: subscription)
|
||||
}
|
||||
|
||||
func updatePushSubscription(alerts: PushNotifications.PushSubscription.Alerts, policy: PushNotifications.PushSubscription.Policy) async throws -> Pachyderm.PushSubscription {
|
||||
let req = Pachyderm.PushSubscription.update(alerts: .init(alerts), policy: .init(policy))
|
||||
var result = try await run(req).0
|
||||
if instanceFeatures.pushNotificationPolicyMissingFromResponse {
|
||||
// see https://github.com/mastodon/mastodon/issues/23145
|
||||
// so just assume if the request was successful that it worked
|
||||
result.policy = .init(policy)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func deletePushSubscription() async throws {
|
||||
let req = Pachyderm.PushSubscription.delete()
|
||||
_ = try await run(req)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Pachyderm.PushSubscription.Alerts {
|
||||
init(_ alerts: PushNotifications.PushSubscription.Alerts) {
|
||||
self.init(
|
||||
mention: alerts.contains(.mention),
|
||||
status: alerts.contains(.status),
|
||||
reblog: alerts.contains(.reblog),
|
||||
follow: alerts.contains(.follow),
|
||||
followRequest: alerts.contains(.followRequest),
|
||||
favourite: alerts.contains(.favorite),
|
||||
poll: alerts.contains(.poll),
|
||||
update: alerts.contains(.update),
|
||||
emojiReaction: alerts.contains(.emojiReaction)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension Pachyderm.PushSubscription.Policy {
|
||||
init(_ policy: PushNotifications.PushSubscription.Policy) {
|
||||
switch policy {
|
||||
case .all:
|
||||
self = .all
|
||||
case .followers:
|
||||
self = .followers
|
||||
case .followed:
|
||||
self = .followed
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,28 +17,29 @@ import Sentry
|
|||
import ComposeUI
|
||||
import OSLog
|
||||
|
||||
private let oauthScopes = [Scope.read, .write, .follow]
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "MastodonController")
|
||||
|
||||
final class MastodonController: ObservableObject, Sendable {
|
||||
|
||||
static let oauthScopes = [Scope.read, .write, .follow, .push]
|
||||
|
||||
@MainActor
|
||||
static private(set) var all = [UserAccountInfo: MastodonController]()
|
||||
static private(set) var all = [String: MastodonController]()
|
||||
|
||||
@MainActor
|
||||
static func getForAccount(_ account: UserAccountInfo) -> MastodonController {
|
||||
if let controller = all[account] {
|
||||
if let controller = all[account.id] {
|
||||
return controller
|
||||
} else {
|
||||
let controller = MastodonController(instanceURL: account.instanceURL, accountInfo: account)
|
||||
all[account] = controller
|
||||
all[account.id] = controller
|
||||
return controller
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func removeForAccount(_ account: UserAccountInfo) {
|
||||
all.removeValue(forKey: account)
|
||||
all.removeValue(forKey: account.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
@ -172,13 +173,14 @@ final class MastodonController: ObservableObject, Sendable {
|
|||
}
|
||||
|
||||
/// - Returns: A tuple of client ID and client secret.
|
||||
func registerApp() async throws -> (String, String) {
|
||||
if let clientID = client.clientID,
|
||||
func registerApp(reregister: Bool = false) async throws -> (String, String) {
|
||||
if !reregister,
|
||||
let clientID = client.clientID,
|
||||
let clientSecret = client.clientSecret {
|
||||
return (clientID, clientSecret)
|
||||
} else {
|
||||
let app: RegisteredApplication = try await withCheckedThrowingContinuation({ continuation in
|
||||
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
|
||||
client.registerApp(name: "Tusker", redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
|
@ -196,7 +198,7 @@ final class MastodonController: ObservableObject, Sendable {
|
|||
/// - Returns: The access token
|
||||
func authorize(authorizationCode: String) async throws -> String {
|
||||
return try await withCheckedThrowingContinuation({ continuation in
|
||||
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: oauthScopes) { response in
|
||||
client.getAccessToken(authorizationCode: authorizationCode, redirectURI: "tusker://oauth", scopes: MastodonController.oauthScopes) { response in
|
||||
switch response {
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// ImageActivityItemSource.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/19/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
class ImageActivityItemSource: NSObject, UIActivityItemSource {
|
||||
let data: Data
|
||||
let url: URL
|
||||
let image: UIImage?
|
||||
|
||||
init(data: Data, url: URL, image: UIImage?) {
|
||||
self.data = data
|
||||
self.url = url
|
||||
self.image = image
|
||||
}
|
||||
|
||||
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
|
||||
return url
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, thumbnailImageForActivityType activityType: UIActivity.ActivityType?, suggestedSize size: CGSize) -> UIImage? {
|
||||
return image
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
||||
do {
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
|
||||
try data.write(to: tempURL)
|
||||
return tempURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
return (UTType(filenameExtension: url.pathExtension) ?? .image).identifier
|
||||
}
|
||||
}
|
|
@ -23,7 +23,18 @@ class SaveToPhotosActivity: UIActivity {
|
|||
return "Save to Photos"
|
||||
}
|
||||
override var activityImage: UIImage? {
|
||||
UIImage(systemName: "square.and.arrow.down")
|
||||
// Just using the symbol image directly causes it to be stretched.
|
||||
let symbol = UIImage(systemName: "square.and.arrow.down", withConfiguration: UIImage.SymbolConfiguration(scale: .large))!
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
#if os(visionOS)
|
||||
format.scale = 2
|
||||
#else
|
||||
format.scale = UIScreen.main.scale
|
||||
#endif
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: 76, height: 76), format: format).image { ctx in
|
||||
let rect = AVMakeRect(aspectRatio: symbol.size, insideRect: CGRect(x: 0, y: 0, width: 76, height: 76))
|
||||
symbol.draw(in: rect)
|
||||
}
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
|
||||
|
|
|
@ -0,0 +1,44 @@
|
|||
//
|
||||
// VideoActivityItemSource.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/19/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
import AVFoundation
|
||||
|
||||
class VideoActivityItemSource: UIActivityItemProvider {
|
||||
private let asset: AVAsset
|
||||
private let url: URL
|
||||
|
||||
private var tempURL: URL?
|
||||
|
||||
init(asset: AVAsset, url: URL) {
|
||||
self.asset = asset
|
||||
self.url = url
|
||||
|
||||
super.init(placeholderItem: url)
|
||||
}
|
||||
|
||||
override var item: Any {
|
||||
if let tempURL {
|
||||
return tempURL
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent)
|
||||
try data.write(to: tempURL)
|
||||
self.tempURL = tempURL
|
||||
return tempURL
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
override func activityViewController(_ activityViewController: UIActivityViewController, dataTypeIdentifierForActivityType activityType: UIActivity.ActivityType?) -> String {
|
||||
return (UTType(filenameExtension: url.pathExtension) ?? .video).identifier
|
||||
}
|
||||
}
|
|
@ -15,6 +15,7 @@ import Sentry
|
|||
import UserAccounts
|
||||
import ComposeUI
|
||||
import TuskerPreferences
|
||||
import PushNotifications
|
||||
|
||||
typealias Preferences = TuskerPreferences.Preferences
|
||||
|
||||
|
@ -35,11 +36,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
AppShortcutItem.createItems(for: application)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
AudioSessionHelper.disable()
|
||||
AudioSessionHelper.setDefault()
|
||||
}
|
||||
|
||||
if let oldSavedData = SavedDataManager.load() {
|
||||
do {
|
||||
for account in oldSavedData.accountIDs {
|
||||
|
@ -58,21 +54,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let oldPreferencesFile = documentsDirectory.appendingPathComponent("preferences").appendingPathExtension("plist")
|
||||
if FileManager.default.fileExists(atPath: oldPreferencesFile.path) {
|
||||
if case .failure(let error) = Preferences.migrate(from: oldPreferencesFile) {
|
||||
#if canImport(Sentry)
|
||||
SentrySDK.capture(error: error)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the persistent container is initialized on the main thread
|
||||
// otherwise initializing it on the background thread can deadlock with accessing it on the main thread elsewhere
|
||||
_ = DraftsPersistentContainer.shared
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
let oldDraftsFile = documentsDirectory.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
let appGroupDraftsFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.space.vaccor.Tusker")!.appendingPathComponent("drafts").appendingPathExtension("plist")
|
||||
for url in [oldDraftsFile, appGroupDraftsFile] where FileManager.default.fileExists(atPath: url.path) {
|
||||
|
@ -88,12 +75,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
|
||||
BackgroundManager.shared.registerHandlers()
|
||||
|
||||
initializePushNotifications()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
#if canImport(Sentry)
|
||||
private func configureSentry() {
|
||||
guard let dsn = Bundle.main.object(forInfoDictionaryKey: "SentryDSN") as? String,
|
||||
guard let info = Bundle.main.object(forInfoDictionaryKey: "TuskerInfo") as? [String: Any],
|
||||
let dsn = info["SentryDSN"] as? String,
|
||||
!dsn.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
@ -123,12 +113,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
event.context?.removeValue(forKey: "culture")
|
||||
return Preferences.shared.reportErrorsAutomatically ? event : nil
|
||||
}
|
||||
}
|
||||
|
||||
if let clazz = NSClassFromString("SentryInstallation"),
|
||||
let objClazz = clazz as AnyObject as? NSObject,
|
||||
let id = objClazz.value(forKey: "id") as? String {
|
||||
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
|
||||
|
||||
if let clazz = NSClassFromString("SentryInstallation"),
|
||||
let objClazz = clazz as AnyObject as? NSObject,
|
||||
let id = objClazz.perform(Selector(("idWithCacheDirectoryPath:")), with: options.cacheDirectoryPath).takeUnretainedValue() as? String {
|
||||
logger.info("Initialized Sentry with installation/user ID: \(id, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
@ -160,7 +150,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
.search,
|
||||
.bookmarks,
|
||||
.myProfile,
|
||||
.showProfile:
|
||||
.showProfile,
|
||||
.showNotification:
|
||||
if activity.displaysAuxiliaryScene {
|
||||
stateRestorationLogger.info("Using auxiliary scene for \(type.rawValue, privacy: .public)")
|
||||
return "auxiliary"
|
||||
|
@ -173,6 +164,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
PushManager.shared.didRegisterForRemoteNotifications(deviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: any Error) {
|
||||
PushManager.shared.didFailToRegisterForRemoteNotifications(error: error)
|
||||
}
|
||||
|
||||
private func initializePushNotifications() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
Task {
|
||||
#if canImport(Sentry)
|
||||
PushManager.captureError = { SentrySDK.capture(error: $0) }
|
||||
#endif
|
||||
await PushManager.shared.updateIfNecessary(updateSubscription: {
|
||||
guard let account = UserAccountsManager.shared.getAccount(id: $0.accountID) else {
|
||||
return false
|
||||
}
|
||||
let mastodonController = MastodonController.getForAccount(account)
|
||||
do {
|
||||
let result = try await mastodonController.updatePushSubscription(subscription: $0)
|
||||
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
|
||||
return true
|
||||
} catch {
|
||||
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(visionOS)
|
||||
private func swizzleStatusBar() {
|
||||
let selector = Selector(("handleTapAction:"))
|
||||
|
@ -228,3 +250,52 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||
#endif
|
||||
|
||||
}
|
||||
|
||||
extension AppDelegate: UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, openSettingsFor notification: UNNotification?) {
|
||||
let mainSceneDelegate: MainSceneDelegate
|
||||
if let delegate = UIApplication.shared.activeScene?.delegate as? MainSceneDelegate {
|
||||
mainSceneDelegate = delegate
|
||||
} else if let scene = UIApplication.shared.connectedScenes.first(where: { $0.delegate is MainSceneDelegate }) {
|
||||
mainSceneDelegate = scene.delegate as! MainSceneDelegate
|
||||
} else if let accountID = UserAccountsManager.shared.mostRecentAccountID {
|
||||
let activity = UserActivityManager.mainSceneActivity(accountID: accountID)
|
||||
activity.addUserInfoEntries(from: ["showNotificationsPreferences": true])
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
return
|
||||
} else {
|
||||
// without an account, we can't do anything
|
||||
return
|
||||
}
|
||||
mainSceneDelegate.showNotificationsPreferences()
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
completionHandler(.banner)
|
||||
}
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
guard let notificationID = userInfo["notificationID"] as? String,
|
||||
let accountID = userInfo["accountID"] as? String,
|
||||
let account = UserAccountsManager.shared.getAccount(id: accountID) else {
|
||||
return
|
||||
}
|
||||
if let scene = response.targetScene,
|
||||
let delegate = scene.delegate as? MainSceneDelegate,
|
||||
let rootViewController = delegate.rootViewController {
|
||||
let mastodonController = MastodonController.getForAccount(account)
|
||||
|
||||
// if the scene is already active, then we animate the account switching if necessary
|
||||
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||
|
||||
rootViewController.select(route: .notifications, animated: false)
|
||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||
} else {
|
||||
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "face.smiling.badge.plus.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 232.5-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
|
||||
<!--glyph: "", point size: 100.0, font version: "19.2d2e1", template writer version: "128"-->
|
||||
<style>.monochrome-0 {-sfsymbols-motion-group:1}
|
||||
.monochrome-1 {-sfsymbols-motion-group:1}
|
||||
.monochrome-2 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.monochrome-3 {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.monochrome-4 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.multicolor-0:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-1:tintColor {-sfsymbols-motion-group:1}
|
||||
.multicolor-2:tintColor {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.multicolor-3:systemGreenColor {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
.multicolor-4:white {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
|
||||
|
||||
.hierarchical-0:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-1:secondary {-sfsymbols-motion-group:1}
|
||||
.hierarchical-2:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
.hierarchical-3:primary {-sfsymbols-motion-group:0}
|
||||
.hierarchical-4:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
|
||||
|
||||
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
|
||||
</style>
|
||||
<g id="Notes">
|
||||
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 263 1933)">
|
||||
<path d="m46.2402 4.15039c21.5332 0 39.4531-17.8711 39.4531-39.4043s-17.9688-39.4043-39.502-39.4043c-21.4844 0-39.3555 17.8711-39.3555 39.4043s17.9199 39.4043 39.4043 39.4043Zm0-7.42188c-17.7246 0-31.8848-14.209-31.8848-31.9824s14.1113-31.9824 31.8359-31.9824c17.7734 0 32.0312 14.209 32.0312 31.9824s-14.209 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
|
||||
<path d="m58.5449 14.5508c27.2461 0 49.8047-22.6074 49.8047-49.8047 0-27.2461-22.6074-49.8047-49.8535-49.8047-27.1973 0-49.7559 22.5586-49.7559 49.8047 0 27.1973 22.6074 49.8047 49.8047 49.8047Zm0-8.30078c-23.0469 0-41.4551-18.457-41.4551-41.5039s18.3594-41.5039 41.4062-41.5039 41.5527 18.457 41.5527 41.5039-18.457 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
|
||||
<path d="m74.8535 28.3203c34.8145 0 63.623-28.8086 63.623-63.5742 0-34.8145-28.8574-63.623-63.6719-63.623-34.7656 0-63.5254 28.8086-63.5254 63.623 0 34.7656 28.8086 63.5742 63.5742 63.5742Zm0-9.08203c-30.1758 0-54.4434-24.3164-54.4434-54.4922 0-30.2246 24.2188-54.4922 54.3945-54.4922 30.2246 0 54.541 24.2676 54.541 54.4922 0 30.1758-24.2676 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 776 1933)">
|
||||
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
|
||||
</g>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
|
||||
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
|
||||
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
|
||||
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.5.0</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 15 or greater</text>
|
||||
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from </text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
|
||||
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.649" x2="515.649" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="603.773" x2="603.773" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1403.58" x2="1403.58" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Regular-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="1496.11" x2="1496.11" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2884.57" x2="2884.57" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Black-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="2982.23" x2="2982.23" y1="600.785" y2="720.121"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Black-S" transform="matrix(1 0 0 1 2884.57 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M48.8281 6.78711C71.9727 6.78711 90.8203-12.0605 90.8203-35.2051C90.8203-58.3496 71.9727-77.1973 48.8281-77.1973C25.6836-77.1973 6.83594-58.3496 6.83594-35.2051C6.83594-12.0605 25.6836 6.78711 48.8281 6.78711ZM48.8281-7.37305C33.4473-7.37305 20.9961-19.8242 20.9961-35.2051C20.9961-50.5859 33.4473-63.0371 48.8281-63.0371C64.209-63.0371 76.6602-50.5859 76.6602-35.2051C76.6602-19.8242 64.209-7.37305 48.8281-7.37305Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M48.8281-18.1641C56.9824-18.1641 62.4512-23.5352 62.4512-26.1719C62.4512-27.1973 61.4258-27.6855 60.4004-27.2461C57.4707-25.9766 54.3945-24.2676 48.8281-24.2676C43.2617-24.2676 40.0879-25.8789 37.2559-27.2461C36.2305-27.7344 35.2051-27.1973 35.2051-26.1719C35.2051-23.5352 40.625-18.1641 48.8281-18.1641ZM37.793-38.916C40.0879-38.916 42.0898-40.9668 42.0898-43.7988C42.0898-46.6797 40.0879-48.7305 37.793-48.7305C35.498-48.7305 33.5938-46.6797 33.5938-43.7988C33.5938-40.9668 35.498-38.916 37.793-38.916ZM59.8145-38.916C62.0605-38.916 64.1113-40.9668 64.1113-43.7988C64.1113-46.6797 62.0605-48.7305 59.8145-48.7305C57.4707-48.7305 55.5664-46.6797 55.5664-43.7988C55.5664-40.9668 57.4707-38.916 59.8145-38.916Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M87.8941 20.2949C102.836 20.2949 115.189 7.89255 115.189-7.04885C115.189-21.9903 102.836-34.2949 87.8941-34.2949C72.9527-34.2949 60.5992-21.9903 60.5992-7.04885C60.5992 7.89255 72.9527 20.2949 87.8941 20.2949Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M87.8941 13.9473C99.3688 13.9473 108.842 4.37695 108.842-7.04885C108.842-18.4746 99.3688-27.9472 87.8941-27.9472C76.4195-27.9472 66.9468-18.4746 66.9468-7.04885C66.9468 4.37695 76.4195 13.9473 87.8941 13.9473Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M87.8941 7.01365C85.5503 7.01365 83.9878 5.45115 83.9878 3.15625L83.9878-3.09375L77.8355-3.09375C75.5406-3.09375 73.9292-4.65625 73.9292-7.00005C73.9292-9.34375 75.4429-10.9062 77.8355-10.9062L83.9878-10.9062L83.9878-17.0097C83.9878-19.3047 85.5503-20.9161 87.8941-20.9161C90.2378-20.9161 91.8003-19.4023 91.8003-17.0097L91.8003-10.9062L98.0018-10.9062C100.297-10.9062 101.859-9.34375 101.859-7.00005C101.859-4.65625 100.297-3.09375 98.0018-3.09375L91.8003-3.09375L91.8003 3.15625C91.8003 5.45115 90.2378 7.01365 87.8941 7.01365Z"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1 0 0 1 1403.58 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M46.2402 4.15039C67.7734 4.15039 85.6934-13.7207 85.6934-35.2539C85.6934-56.7871 67.7246-74.6582 46.1914-74.6582C24.707-74.6582 6.83594-56.7871 6.83594-35.2539C6.83594-13.7207 24.7559 4.15039 46.2402 4.15039ZM46.2402-3.27148C28.5156-3.27148 14.3555-17.4805 14.3555-35.2539C14.3555-53.0273 28.4668-67.2363 46.1914-67.2363C63.9648-67.2363 78.2227-53.0273 78.2227-35.2539C78.2227-17.4805 64.0137-3.27148 46.2402-3.27148Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M46.1914-15.8691C54.3457-15.8691 59.8145-21.2402 59.8145-23.877C59.8145-24.8535 58.8379-25.3418 57.8613-24.9512C54.9805-23.584 51.8066-21.875 46.1914-21.875C40.625-21.875 37.4512-23.584 34.5703-24.9512C33.5938-25.3418 32.6172-24.8535 32.6172-23.877C32.6172-21.2402 38.0859-15.8691 46.1914-15.8691ZM34.9121-38.5742C37.4512-38.5742 39.6973-40.7715 39.6973-43.9453C39.6973-47.2168 37.4512-49.4141 34.9121-49.4141C32.4219-49.4141 30.2246-47.2168 30.2246-43.9453C30.2246-40.7715 32.4219-38.5742 34.9121-38.5742ZM57.5195-38.5742C60.0586-38.5742 62.2559-40.7715 62.2559-43.9453C62.2559-47.2168 60.0586-49.4141 57.5195-49.4141C54.9805-49.4141 52.832-47.2168 52.832-43.9453C52.832-40.7715 54.9805-38.5742 57.5195-38.5742Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M83.277 18.3906C97.0956 18.3906 108.668 6.8184 108.668-7C108.668-20.916 97.1926-32.3906 83.277-32.3906C69.3121-32.3906 57.8864-20.916 57.8864-7C57.8864 6.9649 69.3121 18.3906 83.277 18.3906Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M83.277 12.6777C93.9216 12.6777 102.955 3.7422 102.955-7C102.955-17.791 94.0676-26.6777 83.277-26.6777C72.486-26.6777 63.5504-17.791 63.5504-7C63.5504 3.8399 72.486 12.6777 83.277 12.6777Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M83.277 5.2559C81.7145 5.2559 80.7379 4.2305 80.7379 2.7168L80.7379-4.4609L73.5602-4.4609C72.0465-4.4609 71.0211-5.4863 71.0211-7C71.0211-8.5137 72.0465-9.5391 73.5602-9.5391L80.7379-9.5391L80.7379-16.7168C80.7379-18.2305 81.7145-19.2559 83.277-19.2559C84.7906-19.2559 85.816-18.2305 85.816-16.7168L85.816-9.5391L92.9936-9.5391C94.5076-9.5391 95.4836-8.5137 95.4836-7C95.4836-5.4863 94.5076-4.4609 92.9936-4.4609L85.816-4.4609L85.816 2.7168C85.816 4.2305 84.7906 5.2559 83.277 5.2559Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.649 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M44.0606 1.97072C64.5039 1.97072 81.2886-14.8105 81.2886-35.2539C81.2886-55.6973 64.5005-72.4785 44.0571-72.4785C23.5718-72.4785 6.83594-55.6973 6.83594-35.2539C6.83594-14.8105 23.5752 1.97072 44.0606 1.97072ZM44.0606-0.274438C24.7466-0.274438 9.04252-15.9365 9.04252-35.2539C9.04252-54.5713 24.7432-70.2334 44.0571-70.2334C63.3745-70.2334 79.04-54.5713 79.04-35.2539C79.04-15.9365 63.3779-0.274438 44.0606-0.274438Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M44.0571-17.0044C51.3032-17.0044 56.3633-22.103 56.3633-24.2856C56.3633-24.7627 55.9317-24.8423 55.6362-24.5879C53.4365-22.4487 49.4453-20.6489 44.0571-20.6489C38.6724-20.6489 34.7266-22.4941 32.4815-24.5879C32.186-24.8423 31.7544-24.7627 31.7544-24.2856C31.7544-22.103 36.8145-17.0044 44.0571-17.0044ZM32.5054-39.0283C34.4541-39.0283 36.2007-41.0439 36.2007-43.5366C36.2007-45.9907 34.4995-48.0064 32.5054-48.0064C30.5147-48.0064 28.8169-45.9907 28.8169-43.5366C28.8169-41.0439 30.5601-39.0283 32.5054-39.0283ZM55.6123-39.0283C57.5611-39.0283 59.3042-41.0439 59.3042-43.5366C59.3042-45.9907 57.6065-48.0064 55.6123-48.0064C53.6182-48.0064 51.9238-45.9907 51.9238-43.5366C51.9238-41.0439 53.6636-39.0283 55.6123-39.0283Z"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M79.3341 14.3492C90.9727 14.3492 100.684 4.72955 100.684-6.99995C100.684-18.7362 91.0247-28.3492 79.3341-28.3492C67.6397-28.3492 57.9395-18.6908 57.9395-6.99995C57.9395 4.73985 67.6397 14.3492 79.3341 14.3492Z"/>
|
||||
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M79.3341 11.2701C89.2977 11.2701 97.6037 3.06105 97.6037-6.99995C97.6037-17.0644 89.3527-25.2699 79.3341-25.2699C69.315-25.2699 61.0152-17.019 61.0152-6.99995C61.0152 3.06795 69.315 11.2701 79.3341 11.2701Z"/>
|
||||
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M79.3341 4.89265C78.3619 4.89265 77.794 4.23055 77.794 3.35255L77.794-5.50535L68.9361-5.50535C68.149-5.50535 67.4415-6.03115 67.4415-6.99995C67.4415-7.96875 68.149-8.54005 68.9361-8.54005L77.794-8.54005L77.794-17.3071C77.794-18.1396 78.3619-18.8471 79.3341-18.8471C80.2574-18.8471 80.8287-18.1396 80.8287-17.3071L80.8287-8.54005L89.6407-8.54005C90.4737-8.54005 91.1327-7.96875 91.1327-6.99995C91.1327-6.03115 90.4737-5.50535 89.6407-5.50535L80.8287-5.50535L80.8287 3.35255C80.8287 4.23055 80.2574 4.89265 79.3341 4.89265Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 22 KiB |
|
@ -1,28 +0,0 @@
|
|||
//
|
||||
// AudioSessionHelper.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/21/20.
|
||||
// Copyright © 2020 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
struct AudioSessionHelper {
|
||||
static func enable() {
|
||||
try? AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
}
|
||||
|
||||
static func disable() {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
static func setDefault() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers)
|
||||
}
|
||||
|
||||
static func setVideoPlayback() {
|
||||
try? AVAudioSession.sharedInstance().setCategory(.playback, options: [])
|
||||
}
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
//
|
||||
// Box.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 3/26/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@propertyWrapper
|
||||
class Box<Value> {
|
||||
var wrappedValue: Value
|
||||
|
||||
init(wrappedValue: Value) {
|
||||
self.wrappedValue = wrappedValue
|
||||
}
|
||||
}
|
|
@ -124,12 +124,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
|
||||
// migrate saved data from local store to cloud store
|
||||
// this can be removed pre-app store release
|
||||
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||
if FileManager.default.fileExists(atPath: defaultPath.path) {
|
||||
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
|
||||
group.enter()
|
||||
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
|
||||
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
|
||||
defaultDesc.configuration = "Default"
|
||||
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
|
||||
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
|
||||
defaultPSC.persistentStoreDescriptions = [defaultDesc]
|
||||
defaultPSC.loadPersistentStores { _, error in
|
||||
|
@ -377,9 +378,7 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) {
|
||||
backgroundContext.perform {
|
||||
let statuses = notifications.compactMap { $0.status }
|
||||
// filter out mentions, otherwise we would double increment the reference count of those accounts
|
||||
// since the status has the same account as the notification
|
||||
let accounts = notifications.filter { $0.kind != .mention }.map { $0.account }
|
||||
let accounts = notifications.map { $0.account }
|
||||
statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) }
|
||||
accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) }
|
||||
self.save(context: self.backgroundContext)
|
||||
|
|
|
@ -47,6 +47,11 @@ public extension MainActor {
|
|||
///
|
||||
/// It will crash if run on any non-main thread.
|
||||
@_unavailableFromAsync
|
||||
@available(macOS, obsoleted: 14.0)
|
||||
@available(iOS, obsoleted: 17.0)
|
||||
@available(watchOS, obsoleted: 10.0)
|
||||
@available(tvOS, obsoleted: 17.0)
|
||||
@available(visionOS 1.0, *)
|
||||
static func runUnsafely<T>(_ body: @MainActor () throws -> T) rethrows -> T {
|
||||
if #available(macOS 14.0, iOS 17.0, watchOS 10.0, tvOS 17.0, *) {
|
||||
return try MainActor.assumeIsolated(body)
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
//
|
||||
// UIViewController+Delegate.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 8/27/18.
|
||||
// Copyright © 2018 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
#if !os(visionOS)
|
||||
import UIKit
|
||||
|
||||
extension UIViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
if let presented = presented as? LargeImageAnimatableViewController,
|
||||
presented.animationImage != nil {
|
||||
return LargeImageExpandAnimationController()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
if let dismissed = dismissed as? LargeImageAnimatableViewController,
|
||||
dismissed.animationImage != nil {
|
||||
return LargeImageShrinkAnimationController(interactionController: dismissed.dismissInteractionController)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
|
||||
if let animator = animator as? LargeImageShrinkAnimationController,
|
||||
let interactionController = animator.interactionController,
|
||||
interactionController.inProgress {
|
||||
return interactionController
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
#endif
|
|
@ -8,26 +8,11 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TuskerPreferences
|
||||
|
||||
extension View {
|
||||
@MainActor
|
||||
@ViewBuilder
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
if applyBackground {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
self
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
func appGroupedListBackground(container: UIAppearanceContainer.Type) -> some View {
|
||||
self.modifier(AppGroupedListBackground(container: container))
|
||||
}
|
||||
|
||||
func appGroupedListRowBackground() -> some View {
|
||||
|
@ -35,11 +20,45 @@ extension View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
private struct AppGroupedListBackground: ViewModifier {
|
||||
let container: any UIAppearanceContainer.Type
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.pureBlackDarkMode) private var environmentPureBlackDarkMode
|
||||
|
||||
private var pureBlackDarkMode: Bool {
|
||||
// using @PreferenceObserving just does not work for this, so try the environment key when available
|
||||
// if it's not available, the color won't update automatically, but it will be correct when the view is created
|
||||
if #available(iOS 17.0, *) {
|
||||
environmentPureBlackDarkMode
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
|
||||
if #available(iOS 16.0, *) {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.onAppear {
|
||||
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppGroupedListRowBackground: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@PreferenceObserving(\.$pureBlackDarkMode) private var pureBlackDarkMode
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if colorScheme == .dark, !pureBlackDarkMode {
|
||||
content
|
||||
.listRowBackground(Color.appGroupedCellBackground)
|
||||
} else {
|
||||
|
@ -47,3 +66,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@propertyWrapper
|
||||
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
|
||||
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
|
||||
|
||||
let keyPath: PrefKeyPath
|
||||
@StateObject private var observer: Observer
|
||||
|
||||
init(_ keyPath: PrefKeyPath) {
|
||||
self.keyPath = keyPath
|
||||
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
|
||||
}
|
||||
|
||||
var wrappedValue: Key.Value {
|
||||
Preferences.shared.getValue(preferenceKeyPath: keyPath)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private class Observer: ObservableObject {
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(keyPath: PrefKeyPath) {
|
||||
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
|
||||
self.objectWillChange.send()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@ class HTMLConverter {
|
|||
}
|
||||
|
||||
extension HTMLConverter {
|
||||
// note: this is duplicated in NotificationExtension
|
||||
struct Callbacks: HTMLConversionCallbacks {
|
||||
static func makeURL(string: String) -> URL? {
|
||||
// Converting WebURL to URL is a small but non-trivial expense (since it works by
|
||||
|
|
|
@ -49,13 +49,7 @@
|
|||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>localhost</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Post photos and videos from the camera.</string>
|
||||
|
@ -67,6 +61,7 @@
|
|||
<string>Post photos from the photo library.</string>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INSendMessageIntent</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-conversation</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-timeline</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.check-notifications</string>
|
||||
|
@ -76,6 +71,7 @@
|
|||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.my-profile</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-profile</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.main-scene</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.show-notification</string>
|
||||
</array>
|
||||
<key>OSLogPreferences</key>
|
||||
<dict>
|
||||
|
@ -106,8 +102,13 @@
|
|||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SentryDSN</key>
|
||||
<string>$(SENTRY_DSN)</string>
|
||||
<key>TuskerInfo</key>
|
||||
<dict>
|
||||
<key>PushProxyHost</key>
|
||||
<string>$(TUSKER_PUSH_PROXY_HOST)</string>
|
||||
<key>SentryDSN</key>
|
||||
<string>$(SENTRY_DSN)</string>
|
||||
</dict>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue