Compare commits

...

46 Commits

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

Closes #494
2024-06-01 10:44:49 -07:00
Shadowfacts 908b499f8f Fix Remove Suggestion action missing from Suggested Accounts screen
Closes #495
2024-06-01 10:40:30 -07:00
Shadowfacts 67c7905acf Fix missing VC callbacks in removeViewAndController 2024-06-01 10:29:33 -07:00
Shadowfacts eacafe87b3 Fix logout from current resulting in black screen after switching to reused VC
Closes #489
2024-06-01 10:28:46 -07:00
Shadowfacts 2a53b24487 Merge branch 'public-beta' into develop 2024-05-29 22:42:43 -07:00
Shadowfacts 9df3c33c6c Bump build number and update changelog 2024-05-29 22:37:53 -07:00
Shadowfacts d4e82d6e7a Fix AVPlayer periodic time observers not being removed 2024-05-29 22:35:45 -07:00
Shadowfacts 06ba758309 Merge branch 'public-beta' into develop 2024-05-29 22:30:48 -07:00
Shadowfacts 2c56902389 Remove old account UI state when logging out 2024-05-29 22:23:09 -07:00
Shadowfacts cb3fd43dbd Fix video thubmnail being flipped in Compose
Closes #487
2024-05-29 22:03:53 -07:00
Shadowfacts 3d15759fb9 Don't constantly commit CA transactions when scrubbing video
Closes #488
2024-05-29 21:56:18 -07:00
Shadowfacts 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -07:00
Shadowfacts 09999175f7 Fix editing attachment descriptions not working on Pleroma 2024-05-27 22:29:11 -07:00
Shadowfacts f2a9f890ff Use development URLSession in more places 2024-05-27 22:14:28 -07:00
Shadowfacts 093994b474 More push subscription logging 2024-05-27 13:33:00 -07:00
Shadowfacts 3d0de5af04 Persist more state when switching accounts
Closes #486
2024-05-24 14:03:51 -04:00
Shadowfacts 966a906436 Fix AVPlayer periodic time observers not being removed 2024-05-23 14:29:56 -04:00
Shadowfacts 844d4056e3 Bump version and update changelog 2024-05-23 14:25:39 -04:00
Shadowfacts 00ef131bb6 Update HTMLStreamer 2024-05-23 14:12:35 -04:00
Shadowfacts d6be6f14dc Hide subscription section from tip jar when there are no products 2024-05-23 14:11:54 -04:00
Shadowfacts 2ccf028bc2 Bump build number and update changelog 2024-05-20 14:28:25 -04:00
Shadowfacts 3eeffada1f Add tip jar link to push notifications settings 2024-05-20 12:49:26 -04:00
Shadowfacts 0499255be7 Add tip jar subscription 2024-05-20 12:49:20 -04:00
Shadowfacts f909c1da10 Fix selecting follow request push notification
Closes #474
2024-05-19 15:14:03 -04:00
Shadowfacts 81543965ae Fix notification extension not building on visionOS 2024-05-19 15:00:47 -04:00
Shadowfacts 96d42756d5 Fix caption not displaying in gallery while image loading
Closes #476
2024-05-19 15:00:25 -04:00
Shadowfacts f6e57d664f Handle invalid date in Status created_at
Closes #477
2024-05-19 14:48:57 -04:00
Shadowfacts c33be1cbf3 Bump build number 2024-05-17 11:26:57 -04:00
Shadowfacts 6d99156bd9 Include badly formatted date in error message 2024-05-10 16:33:03 -04:00
Shadowfacts ca764811ed Bump build number and update changelog 2024-04-23 13:19:52 -04:00
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
Shadowfacts b89df3f27b Add instance announcements
Closes #356
2024-04-18 00:00:00 -04:00
Shadowfacts 4ecc16a93b Move FuzzyMatcher to TuskerComponents 2024-04-17 22:34:31 -04:00
Shadowfacts 8960873ff3 Remove redundant toastableViewController property 2024-04-17 22:34:31 -04:00
Shadowfacts 043a708515 Add more logging to onboarding VC 2024-04-17 17:04:32 -04:00
Shadowfacts c6b230414e Fix error decoding InstanceV2 response on certain instances 2024-04-17 10:18:01 -04:00
Shadowfacts f5e9f66f76 Fix app background colors not updating when preference changed
This only fully fixes it on iOS 17, but it seems to be the best we can do
2024-04-16 12:03:52 -04:00
Shadowfacts ee5f9a62ff Fix push subscription settings GroupBox background in dark mode
Closes #470
2024-04-16 11:37:36 -04:00
Shadowfacts a92cf8c812 Fix potential crash when hit testing StatusCollapseButton 2024-04-15 22:50:31 -04:00
Shadowfacts 756874949a Actually add notification extension privacy manifest to target 2024-04-15 22:42:18 -04:00
75 changed files with 2015 additions and 254 deletions

View File

@ -1,3 +1,20 @@
## 2024.2
This release introduces push notifications as well as an enhanced multi-column interface on iPadOS!
Features/Improvements:
- Push notifications
- Add post preview to Appearance preferences
- Show instance announcements in Notifications tab
- Add subscription option to Tip Jar
- iPadOS: Multi-column navigation
- Pleroma/Akkoma: Emoji reaction notifications
Bugfixes:
- Fix fetching server info on some instances
- Fix attachment captions not displaying while loading in gallery
- macOS: Remove in-app Safari preferences
- Pleroma: Handle posts with missing creation date
## 2024.1
This update includes a significant improvements for the attachment gallery and displaying rich text posts. See below for a full list of improvements and fixes.

View File

@ -1,5 +1,33 @@
# Changelog
## 2024.3 (126)
Bugfixes:
- Fix an issue displaying post HTML in certain edge cases
- Fix crash when video attachment playback ends
- Fix excessive CPU usage when scrubbing video attachment
- Fix video attachment thubmnails being flipped on Compose screen
- Pleroma: Fix editing attachment descriptions not working
## 2024.2 (124)
Features/Improvements:
- Add subscription option to Tip Jar
Bugfixes:
- Fix attachment captions not displaying while loading in gallery
- Fix tapping follow request push notification not working
- Pleroma: Handle posts with missing creation dates
## 2024.2 (122)
Features/Improvements:
- Show instance announcements in Notifications
- Pleroma/Akkoma: Display emoji reactions in Notifications
- Pleroma/Akkoma: Add push notifications for emoji reactions
Bugfixes:
- Fix issue fetching server info on some instances
- Fix Preferences background color not updating after changing Pure Black Dark Mode
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
## 2024.2 (121)
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.

View File

@ -109,6 +109,12 @@ class NotificationService: UNNotificationServiceExtension {
kindStr = "📊 Poll finished"
case .update:
kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default:
kindStr = nil
}

View File

@ -40,6 +40,7 @@ class AttachmentThumbnailController: ViewController {
case .video, .gifv:
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else
@ -91,6 +92,7 @@ class AttachmentThumbnailController: ViewController {
if type.conforms(to: .movie) {
let asset = AVURLAsset(url: url)
let imageGenerator = AVAssetImageGenerator(asset: asset)
imageGenerator.appliesPreferredTrackTransform = true
#if os(visionOS)
#warning("Use async AVAssetImageGenerator.image(at:)")
#else

View File

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

View File

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

View File

@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
appliedSourceToDestTransform = false
}
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds
container.addSubview(to.view)
}
from.view.frame = container.bounds
container.addSubview(from.view)
let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true
container.addSubview(to.view)
container.addSubview(from.view)
container.addSubview(content.view)
content.view.frame = destFrameInContainer

View File

@ -157,7 +157,7 @@ public final class InstanceFeatures: ObservableObject {
}
public var needsEditAttachmentsInSeparateRequest: Bool {
instanceType.isPleroma(.akkoma(nil))
instanceType.isPleroma
}
public var composeDirectStatuses: Bool {
@ -209,6 +209,14 @@ public final class InstanceFeatures: ObservableObject {
}
}
public var instanceAnnouncements: Bool {
hasMastodonVersion(3, 1, 0)
}
public var emojiReactionNotifications: Bool {
instanceType.isPleroma
}
public init() {
}

View File

@ -42,7 +42,8 @@ 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)"))
return Date(timeIntervalSinceReferenceDate: 0)
}
})

View File

@ -0,0 +1,99 @@
//
// Announcement.swift
// Pachyderm
//
// Created by Shadowfacts on 4/16/24.
//
import Foundation
import WebURL
public struct Announcement: Decodable, Sendable, Hashable, Identifiable {
public let id: String
public let content: String
public let startsAt: Date?
public let endsAt: Date?
public let allDay: Bool
public let publishedAt: Date
public let updatedAt: Date
public let read: Bool?
public let mentions: [Account]
public let statuses: [Status]
public let tags: [Hashtag]
public let emojis: [Emoji]
public var reactions: [Reaction]
public static func all() -> Request<[Announcement]> {
return Request(method: .get, path: "/api/v1/announcements")
}
public static func dismiss(id: String) -> Request<Empty> {
return Request(method: .post, path: "/api/v1/announcements/\(id)/dismiss")
}
public static func react(id: String, name: String) -> Request<Empty> {
return Request(method: .put, path: "/api/v1/announcements/\(id)/reactions/\(name)")
}
public static func unreact(id: String, name: String) -> Request<Empty> {
return Request(method: .delete, path: "/api/v1/announcements/\(id)/reactions/\(name)")
}
enum CodingKeys: String, CodingKey {
case id
case content
case startsAt = "starts_at"
case endsAt = "ends_at"
case allDay = "all_day"
case publishedAt = "published_at"
case updatedAt = "updated_at"
case read
case mentions
case statuses
case tags
case emojis
case reactions
}
}
extension Announcement {
public struct Account: Decodable, Sendable, Hashable {
public let id: String
public let username: String
public let url: WebURL
public let acct: String
}
}
extension Announcement {
public struct Status: Decodable, Sendable, Hashable {
public let id: String
public let url: WebURL
}
}
extension Announcement {
public struct Reaction: Decodable, Sendable, Hashable {
public let name: String
public var count: Int
public var me: Bool?
public let url: URL?
public let staticURL: URL?
public init(name: String, count: Int, me: Bool?, url: URL?, staticURL: URL?) {
self.name = name
self.count = count
self.me = me
self.url = url
self.staticURL = staticURL
}
enum CodingKeys: String, CodingKey {
case name
case count
case me
case url
case staticURL = "static_url"
}
}
}

View File

@ -43,8 +43,13 @@ extension Emoji: CustomDebugStringConvertible {
}
}
extension Emoji: Equatable {
extension Emoji: Equatable, Hashable {
public static func ==(lhs: Emoji, rhs: Emoji) -> Bool {
return lhs.shortcode == rhs.shortcode && lhs.url == rhs.url
}
public func hash(into hasher: inout Hasher) {
hasher.combine(shortcode)
hasher.combine(url)
}
}

View File

@ -53,7 +53,7 @@ extension InstanceV2 {
public struct Thumbnail: Decodable, Sendable {
public let url: String
public let blurhash: String?
public let versions: ThumbnailVersions
public let versions: ThumbnailVersions?
}
public struct ThumbnailVersions: Decodable, Sendable {
@ -120,6 +120,6 @@ extension InstanceV2 {
extension InstanceV2 {
public struct Contact: Decodable, Sendable {
public let email: String
public let account: Account
public let account: Account?
}
}

View File

@ -7,6 +7,7 @@
//
import Foundation
import WebURL
public struct Notification: Decodable, Sendable {
public let id: String
@ -14,6 +15,10 @@ public struct Notification: Decodable, Sendable {
public let createdAt: Date
public let account: Account
public let status: Status?
// Only present for pleroma emoji reactions
// Either an emoji or :shortcode: (for akkoma custom emoji reactions)
public let emoji: String?
public let emojiURL: WebURL?
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
@ -27,6 +32,8 @@ public struct Notification: Decodable, Sendable {
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.account = try container.decode(Account.self, forKey: .account)
self.status = try container.decodeIfPresent(Status.self, forKey: .status)
self.emoji = try container.decodeIfPresent(String.self, forKey: .emoji)
self.emojiURL = try container.decodeIfPresent(WebURL.self, forKey: .emojiURL)
}
public static func dismiss(id notificationID: String) -> Request<Empty> {
@ -39,6 +46,8 @@ public struct Notification: Decodable, Sendable {
case createdAt = "created_at"
case account
case status
case emoji
case emojiURL = "emoji_url"
}
}
@ -52,6 +61,7 @@ extension Notification {
case poll
case update
case status
case emojiReaction = "pleroma:emoji_reaction"
case unknown
}
}

View File

@ -44,6 +44,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue,
]))
}
@ -58,6 +59,7 @@ public struct PushSubscription: Decodable, Sendable {
"data[alerts][favourite]" => alerts.favourite,
"data[alerts][poll]" => alerts.poll,
"data[alerts][update]" => alerts.update,
"data[alerts][pleroma:emoji_reaction]" => alerts.emojiReaction,
"data[policy]" => policy.rawValue,
]))
}
@ -85,8 +87,19 @@ extension PushSubscription {
public let favourite: Bool
public let poll: Bool
public let update: Bool
public let emojiReaction: Bool
public init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
public init(
mention: Bool,
status: Bool,
reblog: Bool,
follow: Bool,
followRequest: Bool,
favourite: Bool,
poll: Bool,
update: Bool,
emojiReaction: Bool
) {
self.mention = mention
self.status = status
self.reblog = reblog
@ -95,6 +108,7 @@ extension PushSubscription {
self.favourite = favourite
self.poll = poll
self.update = update
self.emojiReaction = emojiReaction
}
public init(from decoder: any Decoder) throws {
@ -110,6 +124,8 @@ extension PushSubscription {
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 {
@ -121,6 +137,7 @@ extension PushSubscription {
case favourite
case poll
case update
case emojiReaction = "pleroma:emoji_reaction"
}
}
}

View File

@ -124,6 +124,12 @@ public final class Status: StatusProtocol, Decodable, Sendable {
return request
}
public static func getReactions(_ statusID: String, emoji: String, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/statuses/\(statusID)/reactions/\(emoji)")
request.range = range
return request
}
public static func delete(_ statusID: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/statuses/\(statusID)")
}

View File

@ -7,17 +7,18 @@
//
import Foundation
import WebURL
public struct NotificationGroup: Identifiable, Hashable, Sendable {
public private(set) var notifications: [Notification]
public let id: String
public let kind: Notification.Kind
public let kind: Kind
public init?(notifications: [Notification]) {
public init?(notifications: [Notification], kind: Kind) {
guard !notifications.isEmpty else { return nil }
self.notifications = notifications
self.id = notifications.first!.id
self.kind = notifications.first!.kind
self.kind = kind
}
public static func ==(lhs: NotificationGroup, rhs: NotificationGroup) -> Bool {
@ -44,30 +45,61 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
notifications.append(contentsOf: group.notifications)
}
private static func groupKind(for notification: Notification) -> Kind {
switch notification.kind {
case .mention:
return .mention
case .reblog:
return .reblog
case .favourite:
return .favourite
case .follow:
return .follow
case .followRequest:
return .followRequest
case .poll:
return .poll
case .update:
return .update
case .status:
return .status
case .emojiReaction:
if let emoji = notification.emoji {
return .emojiReaction(emoji, notification.emojiURL)
} else {
return .unknown
}
case .unknown:
return .unknown
}
}
@MainActor
public static func createGroups(notifications: [Notification], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
var groups = [NotificationGroup]()
for notification in notifications {
let groupKind = groupKind(for: notification)
if allowedTypes.contains(notification.kind) {
if let lastGroup = groups.last, canMerge(notification: notification, into: lastGroup) {
if let lastGroup = groups.last, canMerge(notification: notification, kind: groupKind, into: lastGroup) {
groups[groups.count - 1].append(notification)
continue
} else if groups.count >= 2 {
let secondToLastGroup = groups[groups.count - 2]
if allowedTypes.contains(groups[groups.count - 1].kind), canMerge(notification: notification, into: secondToLastGroup) {
if allowedTypes.contains(notification.kind), canMerge(notification: notification, kind: groupKind, into: secondToLastGroup) {
groups[groups.count - 2].append(notification)
continue
}
}
}
groups.append(NotificationGroup(notifications: [notification])!)
groups.append(NotificationGroup(notifications: [notification], kind: groupKind)!)
}
return groups
}
private static func canMerge(notification: Notification, into group: NotificationGroup) -> Bool {
return notification.kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
private static func canMerge(notification: Notification, kind: Kind, into group: NotificationGroup) -> Bool {
return kind == group.kind && notification.status?.id == group.notifications.first!.status?.id
}
public static func mergeGroups(first: [NotificationGroup], second: [NotificationGroup], only allowedTypes: [Notification.Kind]) -> [NotificationGroup] {
@ -82,21 +114,21 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
var second = second
merged.reserveCapacity(second.count)
while let firstGroupFromSecond = second.first,
allowedTypes.contains(firstGroupFromSecond.kind) {
allowedTypes.contains(firstGroupFromSecond.kind.notificationKind) {
second.removeFirst()
guard let lastGroup = merged.last,
allowedTypes.contains(lastGroup.kind) else {
allowedTypes.contains(lastGroup.kind.notificationKind) else {
merged.append(firstGroupFromSecond)
break
}
if canMerge(notification: firstGroupFromSecond.notifications.first!, into: lastGroup) {
if canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: lastGroup) {
merged[merged.count - 1].append(group: firstGroupFromSecond)
} else if merged.count >= 2 {
let secondToLastGroup = merged[merged.count - 2]
if allowedTypes.contains(secondToLastGroup.kind), canMerge(notification: firstGroupFromSecond.notifications.first!, into: secondToLastGroup) {
if allowedTypes.contains(secondToLastGroup.kind.notificationKind), canMerge(notification: firstGroupFromSecond.notifications.first!, kind: firstGroupFromSecond.kind, into: secondToLastGroup) {
merged[merged.count - 2].append(group: firstGroupFromSecond)
} else {
merged.append(firstGroupFromSecond)
@ -109,4 +141,42 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
return merged
}
public enum Kind: Sendable, Equatable {
case mention
case reblog
case favourite
case follow
case followRequest
case poll
case update
case status
case emojiReaction(String, WebURL?)
case unknown
var notificationKind: Notification.Kind {
switch self {
case .mention:
.mention
case .reblog:
.reblog
case .favourite:
.favourite
case .follow:
.follow
case .followRequest:
.followRequest
case .poll:
.poll
case .update:
.update
case .status:
.status
case .emojiReaction(_, _):
.emojiReaction
case .unknown:
.unknown
}
}
}
}

View File

@ -104,6 +104,7 @@ class PushManagerImpl: _PushManager {
self.subscriptions = await AsyncSequenceAdaptor(wrapping: subscriptions).map {
let newEndpoint = await self.endpointURL(deviceToken: token, accountID: $0.accountID)
guard newEndpoint != $0.endpoint else {
PushManager.logger.debug("Skipping update of push subscription with endpoint \($0.endpoint, privacy: .public)")
return $0
}
var copy = $0

View File

@ -70,6 +70,7 @@ public struct PushSubscription {
public static let favorite = Alerts(rawValue: 1 << 5)
public static let poll = Alerts(rawValue: 1 << 6)
public static let update = Alerts(rawValue: 1 << 7)
public static let emojiReaction = Alerts(rawValue: 1 << 8)
public let rawValue: Int

View File

@ -1,6 +1,6 @@
//
// FuzzyMatcher.swift
// ComposeUI
// TuskerComponents
//
// Created by Shadowfacts on 10/10/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
@ -8,7 +8,7 @@
import Foundation
struct FuzzyMatcher {
public struct FuzzyMatcher {
private init() {}
@ -21,7 +21,7 @@ struct FuzzyMatcher {
/// +2 points for every char in `pattern` that occurs in `str` sequentially
/// -2 points for every char in `pattern` that does not occur in `str` sequentially
/// -1 point for every char in `str` skipped between matching chars from the `pattern`
static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
public static func match(pattern: String, str: String) -> (matched: Bool, score: Int) {
let pattern = pattern.lowercased()
let str = str.lowercased()

View File

@ -74,4 +74,9 @@ extension PreferenceStore {
public func hasFeatureFlag(_ flag: FeatureFlag) -> Bool {
enabledFeatureFlags.contains(flag)
}
public func getValue<Key: PreferenceKey>(preferenceKeyPath: KeyPath<PreferenceStore, PreferencePublisher<Key>>) -> Key.Value {
self[keyPath: preferenceKeyPath].preference.wrappedValue
}
}

View File

@ -36,6 +36,7 @@
D60E2F2E244248BF005F8713 /* MastodonCachePersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */; };
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */; };
D6114E1127F899B30080E273 /* TrendingLinksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */; };
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */; };
D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; };
D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; };
D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; };
@ -74,6 +75,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
@ -95,7 +97,7 @@
D630C3CA2BC59FF500208903 /* MastodonController+Push.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3C92BC59FF500208903 /* MastodonController+Push.swift */; };
D630C3CC2BC5FD4600208903 /* GetAuthorizationTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */; };
D630C3D42BC61B6100208903 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D630C3D32BC61B6100208903 /* NotificationService.swift */; };
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D630C3D82BC61B6100208903 /* NotificationExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D630C3D12BC61B6000208903 /* NotificationExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
D630C3DF2BC61C4900208903 /* PushNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3DE2BC61C4900208903 /* PushNotifications */; };
D630C3E12BC61C6700208903 /* UserAccounts in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E02BC61C6700208903 /* UserAccounts */; };
D630C3E52BC6313400208903 /* Pachyderm in Frameworks */ = {isa = PBXBuildFile; productRef = D630C3E42BC6313400208903 /* Pachyderm */; };
@ -227,6 +229,12 @@
D69693FA25859A8000F4E116 /* ComposeSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */; };
D6969E9E240C81B9002843CE /* NSTextAttachment+Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */; };
D6969EA0240C8384002843CE /* EmojiLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6969E9F240C8384002843CE /* EmojiLabel.swift */; };
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */; };
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */; };
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */; };
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */; };
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */; };
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */; };
D6A00B1D26379FC900316AD4 /* PollOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */; };
D6A3A380295515550036B6EF /* ProfileHeaderButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */; };
D6A3A3822956123A0036B6EF /* TimelinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A3A3812956123A0036B6EF /* TimelinePosition.swift */; };
@ -291,6 +299,7 @@
D6C3F5192991F5D60009FCFF /* TimelineJumpButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F5182991F5D60009FCFF /* TimelineJumpButton.swift */; };
D6C4532D2BCB86AC00E26A0E /* AppearancePrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532C2BCB86AC00E26A0E /* AppearancePrefsView.swift */; };
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C4532E2BCB873400E26A0E /* MockStatusView.swift */; };
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D6C4532A2BCAD7F900E26A0E /* PrivacyInfo.xcprivacy */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -460,6 +469,7 @@
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationViewController.swift; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
@ -497,6 +507,7 @@
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
@ -649,6 +660,12 @@
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; };
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsHostingController.swift; sourceTree = "<group>"; };
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsView.swift; sourceTree = "<group>"; };
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementListRow.swift; sourceTree = "<group>"; };
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
@ -1054,6 +1071,7 @@
children = (
D65B4B89297879DE00DABDFB /* Account Follows */,
D6A3BC822321F69400FD64D5 /* Account List */,
D698F4472BCEE2320054DB14 /* Announcements */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1150,6 +1168,7 @@
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */,
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -1349,6 +1368,19 @@
path = About;
sourceTree = "<group>";
};
D698F4472BCEE2320054DB14 /* Announcements */ = {
isa = PBXGroup;
children = (
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */,
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */,
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */,
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */,
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */,
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */,
);
path = Announcements;
sourceTree = "<group>";
};
D6A3BC822321F69400FD64D5 /* Account List */ = {
isa = PBXGroup;
children = (
@ -1727,6 +1759,7 @@
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1954,6 +1987,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2129,6 +2163,7 @@
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
@ -2159,8 +2194,10 @@
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
@ -2188,6 +2225,7 @@
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
@ -2202,6 +2240,7 @@
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */,
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
@ -2323,8 +2362,10 @@
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
@ -2367,6 +2408,7 @@
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */,
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
@ -3203,7 +3245,7 @@
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = {
kind = exactVersion;
version = 0.2.3;
version = 0.2.4;
};
};
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {

View File

@ -58,7 +58,8 @@ private extension Pachyderm.PushSubscription.Alerts {
followRequest: alerts.contains(.followRequest),
favourite: alerts.contains(.favorite),
poll: alerts.contains(.poll),
update: alerts.contains(.update)
update: alerts.contains(.update),
emojiReaction: alerts.contains(.emojiReaction)
)
}
}

View File

@ -0,0 +1,39 @@
//
// RemoveProfileSuggestionService.swift
// Tusker
//
// Created by Shadowfacts on 6/1/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
@MainActor
class RemoveProfileSuggestionService {
private let accountID: String
private let mastodonController: MastodonController
private let presenter: any TuskerNavigationDelegate
private let completionHandler: @MainActor () -> Void
init(accountID: String, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate, completionHandler: @MainActor @escaping () -> Void) {
self.accountID = accountID
self.mastodonController = mastodonController
self.presenter = presenter
self.completionHandler = completionHandler
}
func run() async {
let req = Suggestion.remove(accountID: accountID)
do {
_ = try await mastodonController.run(req)
completionHandler()
} catch {
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: presenter) { toast in
toast.dismissToast(animated: true)
await self.run()
}
self.presenter.showToast(configuration: config, animated: true)
}
}
}

View File

@ -185,7 +185,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
let mastodonController = MastodonController.getForAccount(account)
do {
let result = try await mastodonController.updatePushSubscription(subscription: $0)
PushManager.logger.debug("Updated push subscription \(result.id) on \(mastodonController.instanceURL)")
PushManager.logger.info("Updated push subscription \(result.id, privacy: .public) on \(mastodonController.instanceURL) with endpoint \($0.endpoint, privacy: .public)")
PushManager.logger.debug("New push subscription: \(String(describing: result))")
return true
} catch {
PushManager.logger.error("Error updating push subscription: \(String(describing: error))")

View File

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

View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 232.5-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="3300" height="2200">
<!--glyph: "", point size: 100.0, font version: "19.2d2e1", template writer version: "128"-->
<style>.monochrome-0 {-sfsymbols-motion-group:1}
.monochrome-1 {-sfsymbols-motion-group:1}
.monochrome-2 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
.monochrome-3 {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
.monochrome-4 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
.multicolor-0:tintColor {-sfsymbols-motion-group:1}
.multicolor-1:tintColor {-sfsymbols-motion-group:1}
.multicolor-2:tintColor {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
.multicolor-3:systemGreenColor {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
.multicolor-4:white {-sfsymbols-motion-group:0;-sfsymbols-always-pulses:true}
.hierarchical-0:secondary {-sfsymbols-motion-group:1}
.hierarchical-1:secondary {-sfsymbols-motion-group:1}
.hierarchical-2:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
.hierarchical-3:primary {-sfsymbols-motion-group:0}
.hierarchical-4:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0}
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
</style>
<g id="Notes">
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
<g transform="matrix(0.2 0 0 0.2 263 1933)">
<path d="m46.2402 4.15039c21.5332 0 39.4531-17.8711 39.4531-39.4043s-17.9688-39.4043-39.502-39.4043c-21.4844 0-39.3555 17.8711-39.3555 39.4043s17.9199 39.4043 39.4043 39.4043Zm0-7.42188c-17.7246 0-31.8848-14.209-31.8848-31.9824s14.1113-31.9824 31.8359-31.9824c17.7734 0 32.0312 14.209 32.0312 31.9824s-14.209 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
</g>
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
<path d="m58.5449 14.5508c27.2461 0 49.8047-22.6074 49.8047-49.8047 0-27.2461-22.6074-49.8047-49.8535-49.8047-27.1973 0-49.7559 22.5586-49.7559 49.8047 0 27.1973 22.6074 49.8047 49.8047 49.8047Zm0-8.30078c-23.0469 0-41.4551-18.457-41.4551-41.5039s18.3594-41.5039 41.4062-41.5039 41.5527 18.457 41.5527 41.5039-18.457 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
</g>
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
<path d="m74.8535 28.3203c34.8145 0 63.623-28.8086 63.623-63.5742 0-34.8145-28.8574-63.623-63.6719-63.623-34.7656 0-63.5254 28.8086-63.5254 63.623 0 34.7656 28.8086 63.5742 63.5742 63.5742Zm0-9.08203c-30.1758 0-54.4434-24.3164-54.4434-54.4922 0-30.2246 24.2188-54.4922 54.3945-54.4922 30.2246 0 54.541 24.2676 54.541 54.4922 0 30.1758-24.2676 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
</g>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
<g transform="matrix(0.2 0 0 0.2 776 1933)">
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
</g>
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
</g>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.5.0</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 15 or greater</text>
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from </text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
</g>
<g id="Guides">
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
</g>
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.649" x2="515.649" y1="600.785" y2="720.121"/>
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="603.773" x2="603.773" y1="600.785" y2="720.121"/>
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1403.58" x2="1403.58" y1="600.785" y2="720.121"/>
<line id="right-margin-Regular-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="1496.11" x2="1496.11" y1="600.785" y2="720.121"/>
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2884.57" x2="2884.57" y1="600.785" y2="720.121"/>
<line id="right-margin-Black-S" style="fill:none;stroke:#FF3B30;stroke-width:0.5;opacity:1.0;" x1="2982.23" x2="2982.23" y1="600.785" y2="720.121"/>
</g>
<g id="Symbols">
<g id="Black-S" transform="matrix(1 0 0 1 2884.57 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M48.8281 6.78711C71.9727 6.78711 90.8203-12.0605 90.8203-35.2051C90.8203-58.3496 71.9727-77.1973 48.8281-77.1973C25.6836-77.1973 6.83594-58.3496 6.83594-35.2051C6.83594-12.0605 25.6836 6.78711 48.8281 6.78711ZM48.8281-7.37305C33.4473-7.37305 20.9961-19.8242 20.9961-35.2051C20.9961-50.5859 33.4473-63.0371 48.8281-63.0371C64.209-63.0371 76.6602-50.5859 76.6602-35.2051C76.6602-19.8242 64.209-7.37305 48.8281-7.37305Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M48.8281-18.1641C56.9824-18.1641 62.4512-23.5352 62.4512-26.1719C62.4512-27.1973 61.4258-27.6855 60.4004-27.2461C57.4707-25.9766 54.3945-24.2676 48.8281-24.2676C43.2617-24.2676 40.0879-25.8789 37.2559-27.2461C36.2305-27.7344 35.2051-27.1973 35.2051-26.1719C35.2051-23.5352 40.625-18.1641 48.8281-18.1641ZM37.793-38.916C40.0879-38.916 42.0898-40.9668 42.0898-43.7988C42.0898-46.6797 40.0879-48.7305 37.793-48.7305C35.498-48.7305 33.5938-46.6797 33.5938-43.7988C33.5938-40.9668 35.498-38.916 37.793-38.916ZM59.8145-38.916C62.0605-38.916 64.1113-40.9668 64.1113-43.7988C64.1113-46.6797 62.0605-48.7305 59.8145-48.7305C57.4707-48.7305 55.5664-46.6797 55.5664-43.7988C55.5664-40.9668 57.4707-38.916 59.8145-38.916Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M87.8941 20.2949C102.836 20.2949 115.189 7.89255 115.189-7.04885C115.189-21.9903 102.836-34.2949 87.8941-34.2949C72.9527-34.2949 60.5992-21.9903 60.5992-7.04885C60.5992 7.89255 72.9527 20.2949 87.8941 20.2949Z"/>
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M87.8941 13.9473C99.3688 13.9473 108.842 4.37695 108.842-7.04885C108.842-18.4746 99.3688-27.9472 87.8941-27.9472C76.4195-27.9472 66.9468-18.4746 66.9468-7.04885C66.9468 4.37695 76.4195 13.9473 87.8941 13.9473Z"/>
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M87.8941 7.01365C85.5503 7.01365 83.9878 5.45115 83.9878 3.15625L83.9878-3.09375L77.8355-3.09375C75.5406-3.09375 73.9292-4.65625 73.9292-7.00005C73.9292-9.34375 75.4429-10.9062 77.8355-10.9062L83.9878-10.9062L83.9878-17.0097C83.9878-19.3047 85.5503-20.9161 87.8941-20.9161C90.2378-20.9161 91.8003-19.4023 91.8003-17.0097L91.8003-10.9062L98.0018-10.9062C100.297-10.9062 101.859-9.34375 101.859-7.00005C101.859-4.65625 100.297-3.09375 98.0018-3.09375L91.8003-3.09375L91.8003 3.15625C91.8003 5.45115 90.2378 7.01365 87.8941 7.01365Z"/>
</g>
<g id="Regular-S" transform="matrix(1 0 0 1 1403.58 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M46.2402 4.15039C67.7734 4.15039 85.6934-13.7207 85.6934-35.2539C85.6934-56.7871 67.7246-74.6582 46.1914-74.6582C24.707-74.6582 6.83594-56.7871 6.83594-35.2539C6.83594-13.7207 24.7559 4.15039 46.2402 4.15039ZM46.2402-3.27148C28.5156-3.27148 14.3555-17.4805 14.3555-35.2539C14.3555-53.0273 28.4668-67.2363 46.1914-67.2363C63.9648-67.2363 78.2227-53.0273 78.2227-35.2539C78.2227-17.4805 64.0137-3.27148 46.2402-3.27148Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M46.1914-15.8691C54.3457-15.8691 59.8145-21.2402 59.8145-23.877C59.8145-24.8535 58.8379-25.3418 57.8613-24.9512C54.9805-23.584 51.8066-21.875 46.1914-21.875C40.625-21.875 37.4512-23.584 34.5703-24.9512C33.5938-25.3418 32.6172-24.8535 32.6172-23.877C32.6172-21.2402 38.0859-15.8691 46.1914-15.8691ZM34.9121-38.5742C37.4512-38.5742 39.6973-40.7715 39.6973-43.9453C39.6973-47.2168 37.4512-49.4141 34.9121-49.4141C32.4219-49.4141 30.2246-47.2168 30.2246-43.9453C30.2246-40.7715 32.4219-38.5742 34.9121-38.5742ZM57.5195-38.5742C60.0586-38.5742 62.2559-40.7715 62.2559-43.9453C62.2559-47.2168 60.0586-49.4141 57.5195-49.4141C54.9805-49.4141 52.832-47.2168 52.832-43.9453C52.832-40.7715 54.9805-38.5742 57.5195-38.5742Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M83.277 18.3906C97.0956 18.3906 108.668 6.8184 108.668-7C108.668-20.916 97.1926-32.3906 83.277-32.3906C69.3121-32.3906 57.8864-20.916 57.8864-7C57.8864 6.9649 69.3121 18.3906 83.277 18.3906Z"/>
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M83.277 12.6777C93.9216 12.6777 102.955 3.7422 102.955-7C102.955-17.791 94.0676-26.6777 83.277-26.6777C72.486-26.6777 63.5504-17.791 63.5504-7C63.5504 3.8399 72.486 12.6777 83.277 12.6777Z"/>
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M83.277 5.2559C81.7145 5.2559 80.7379 4.2305 80.7379 2.7168L80.7379-4.4609L73.5602-4.4609C72.0465-4.4609 71.0211-5.4863 71.0211-7C71.0211-8.5137 72.0465-9.5391 73.5602-9.5391L80.7379-9.5391L80.7379-16.7168C80.7379-18.2305 81.7145-19.2559 83.277-19.2559C84.7906-19.2559 85.816-18.2305 85.816-16.7168L85.816-9.5391L92.9936-9.5391C94.5076-9.5391 95.4836-8.5137 95.4836-7C95.4836-5.4863 94.5076-4.4609 92.9936-4.4609L85.816-4.4609L85.816 2.7168C85.816 4.2305 84.7906 5.2559 83.277 5.2559Z"/>
</g>
<g id="Ultralight-S" transform="matrix(1 0 0 1 515.649 696)">
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M44.0606 1.97072C64.5039 1.97072 81.2886-14.8105 81.2886-35.2539C81.2886-55.6973 64.5005-72.4785 44.0571-72.4785C23.5718-72.4785 6.83594-55.6973 6.83594-35.2539C6.83594-14.8105 23.5752 1.97072 44.0606 1.97072ZM44.0606-0.274438C24.7466-0.274438 9.04252-15.9365 9.04252-35.2539C9.04252-54.5713 24.7432-70.2334 44.0571-70.2334C63.3745-70.2334 79.04-54.5713 79.04-35.2539C79.04-15.9365 63.3779-0.274438 44.0606-0.274438Z"/>
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:secondary SFSymbolsPreviewWireframe" d="M44.0571-17.0044C51.3032-17.0044 56.3633-22.103 56.3633-24.2856C56.3633-24.7627 55.9317-24.8423 55.6362-24.5879C53.4365-22.4487 49.4453-20.6489 44.0571-20.6489C38.6724-20.6489 34.7266-22.4941 32.4815-24.5879C32.186-24.8423 31.7544-24.7627 31.7544-24.2856C31.7544-22.103 36.8145-17.0044 44.0571-17.0044ZM32.5054-39.0283C34.4541-39.0283 36.2007-41.0439 36.2007-43.5366C36.2007-45.9907 34.4995-48.0064 32.5054-48.0064C30.5147-48.0064 28.8169-45.9907 28.8169-43.5366C28.8169-41.0439 30.5601-39.0283 32.5054-39.0283ZM55.6123-39.0283C57.5611-39.0283 59.3042-41.0439 59.3042-43.5366C59.3042-45.9907 57.6065-48.0064 55.6123-48.0064C53.6182-48.0064 51.9238-45.9907 51.9238-43.5366C51.9238-41.0439 53.6636-39.0283 55.6123-39.0283Z"/>
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M79.3341 14.3492C90.9727 14.3492 100.684 4.72955 100.684-6.99995C100.684-18.7362 91.0247-28.3492 79.3341-28.3492C67.6397-28.3492 57.9395-18.6908 57.9395-6.99995C57.9395 4.73985 67.6397 14.3492 79.3341 14.3492Z"/>
<path class="monochrome-3 multicolor-3:systemGreenColor hierarchical-3:primary SFSymbolsPreviewWireframe" d="M79.3341 11.2701C89.2977 11.2701 97.6037 3.06105 97.6037-6.99995C97.6037-17.0644 89.3527-25.2699 79.3341-25.2699C69.315-25.2699 61.0152-17.019 61.0152-6.99995C61.0152 3.06795 69.315 11.2701 79.3341 11.2701Z"/>
<path class="monochrome-4 multicolor-4:white hierarchical-4:primary SFSymbolsPreviewWireframe" d="M79.3341 4.89265C78.3619 4.89265 77.794 4.23055 77.794 3.35255L77.794-5.50535L68.9361-5.50535C68.149-5.50535 67.4415-6.03115 67.4415-6.99995C67.4415-7.96875 68.149-8.54005 68.9361-8.54005L77.794-8.54005L77.794-17.3071C77.794-18.1396 78.3619-18.8471 79.3341-18.8471C80.2574-18.8471 80.8287-18.1396 80.8287-17.3071L80.8287-8.54005L89.6407-8.54005C90.4737-8.54005 91.1327-7.96875 91.1327-6.99995C91.1327-6.03115 90.4737-5.50535 89.6407-5.50535L80.8287-5.50535L80.8287 3.35255C80.8287 4.23055 80.2574 4.89265 79.3341 4.89265Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -96,7 +96,7 @@ final class ImageCache: @unchecked Sendable {
}
private func fetch(url: URL) async -> FetchResult {
guard let (data, _) = try? await URLSession.shared.data(from: url) else {
guard let (data, _) = try? await URLSession.appDefault.data(from: url) else {
return .none
}
guard let image = UIImage(data: data) else {

View File

@ -124,12 +124,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
// migrate saved data from local store to cloud store
// this can be removed pre-app store release
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
group.enter()
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
if FileManager.default.fileExists(atPath: defaultPath.path) {
group.enter()
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
defaultDesc.configuration = "Default"
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
defaultPSC.persistentStoreDescriptions = [defaultDesc]
defaultPSC.loadPersistentStores { _, error in

View File

@ -8,26 +8,11 @@
import SwiftUI
import Combine
import TuskerPreferences
extension View {
@MainActor
@ViewBuilder
func appGroupedListBackground(container: UIAppearanceContainer.Type, applyBackground: Bool = true) -> some View {
if #available(iOS 16.0, *) {
if applyBackground {
self
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
self
.scrollContentBackground(.hidden)
}
} else {
self
.onAppear {
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
}
}
func appGroupedListBackground(container: UIAppearanceContainer.Type) -> some View {
self.modifier(AppGroupedListBackground(container: container))
}
func appGroupedListRowBackground() -> some View {
@ -35,11 +20,45 @@ extension View {
}
}
private struct AppGroupedListRowBackground: ViewModifier {
private struct AppGroupedListBackground: ViewModifier {
let container: any UIAppearanceContainer.Type
@Environment(\.colorScheme) private var colorScheme
@Environment(\.pureBlackDarkMode) private var environmentPureBlackDarkMode
private var pureBlackDarkMode: Bool {
// using @PreferenceObserving just does not work for this, so try the environment key when available
// if it's not available, the color won't update automatically, but it will be correct when the view is created
if #available(iOS 17.0, *) {
environmentPureBlackDarkMode
} else {
false
}
}
func body(content: Content) -> some View {
if colorScheme == .dark, !Preferences.shared.pureBlackDarkMode {
if #available(iOS 16.0, *) {
if colorScheme == .dark, !pureBlackDarkMode {
content
.scrollContentBackground(.hidden)
.background(Color.appGroupedBackground.edgesIgnoringSafeArea(.all))
} else {
content
}
} else {
content
.onAppear {
UITableView.appearance(whenContainedInInstancesOf: [container]).backgroundColor = .appGroupedBackground
}
}
}
}
private struct AppGroupedListRowBackground: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
@PreferenceObserving(\.$pureBlackDarkMode) private var pureBlackDarkMode
func body(content: Content) -> some View {
if colorScheme == .dark, !pureBlackDarkMode {
content
.listRowBackground(Color.appGroupedCellBackground)
} else {
@ -47,3 +66,31 @@ private struct AppGroupedListRowBackground: ViewModifier {
}
}
}
@propertyWrapper
private struct PreferenceObserving<Key: TuskerPreferences.PreferenceKey>: DynamicProperty {
typealias PrefKeyPath = KeyPath<PreferenceStore, PreferencePublisher<Key>>
let keyPath: PrefKeyPath
@StateObject private var observer: Observer
init(_ keyPath: PrefKeyPath) {
self.keyPath = keyPath
self._observer = StateObject(wrappedValue: Observer(keyPath: keyPath))
}
var wrappedValue: Key.Value {
Preferences.shared.getValue(preferenceKeyPath: keyPath)
}
@MainActor
private class Observer: ObservableObject {
private var cancellable: AnyCancellable?
init(keyPath: PrefKeyPath) {
cancellable = Preferences.shared[keyPath: keyPath].sink { [unowned self] _ in
self.objectWillChange.send()
}
}
}
}

View File

@ -143,3 +143,33 @@ extension UIMutableTraits {
set { self[PureBlackDarkModeTrait.self] = newValue }
}
}
@available(iOS 17.0, *)
private struct PureBlackDarkModeKey: UITraitBridgedEnvironmentKey {
static let defaultValue: Bool = false
static func read(from traitCollection: UITraitCollection) -> Bool {
traitCollection[PureBlackDarkModeTrait.self]
}
static func write(to mutableTraits: inout any UIMutableTraits, value: Bool) {
mutableTraits[PureBlackDarkModeTrait.self] = value
}
}
extension EnvironmentValues {
var pureBlackDarkMode: Bool {
get {
if #available(iOS 17.0, *) {
self[PureBlackDarkModeKey.self]
} else {
true
}
}
set {
if #available(iOS 17.0, *) {
self[PureBlackDarkModeKey.self] = newValue
}
}
}
}

View File

@ -34,7 +34,6 @@ extension StatusSwipeAction {
protocol StatusSwipeActionContainer: UIView {
var mastodonController: MastodonController! { get }
var navigationDelegate: any TuskerNavigationDelegate { get }
var toastableViewController: ToastableViewController? { get }
var canReblog: Bool { get }

View File

@ -225,7 +225,6 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
window!.windowScene!.title = account.instanceURL.host!
}
let newRoot = createAppUI()
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated,
@ -235,9 +234,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
direction = .none
}
container.setRoot(newRoot, for: account, animating: direction)
container.setRoot(createAppUI, for: account, animating: direction)
} else {
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
}
}
@ -248,6 +247,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
LogoutService(accountInfo: account).run()
if UserAccountsManager.shared.onboardingComplete {
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
container.removeAccount(account)
}
} else {
window!.rootViewController = createOnboardingUI()
}

View File

@ -0,0 +1,191 @@
//
// AddReactionView.swift
// Tusker
//
// Created by Shadowfacts on 4/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
import TuskerComponents
struct AddReactionView: View {
let mastodonController: MastodonController
let addReaction: (Reaction) async throws -> Void
@Environment(\.dismiss) private var dismiss
@ScaledMetric private var emojiSize = 30
@State private var allEmojis: [Emoji] = []
@State private var emojisBySection: [String: [Emoji]] = [:]
@State private var query = ""
@State private var error: (any Error)?
var body: some View {
NavigationView {
ScrollView(.vertical) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: emojiSize), spacing: 4)]) {
if query.count == 1 {
Section {
AddReactionButton {
await doAddReaction(.emoji(query))
} label: {
Text(query)
.font(.system(size: 25))
}
.buttonStyle(.plain)
}
}
ForEach(emojisBySection.keys.sorted(), id: \.self) { section in
Section {
ForEach(emojisBySection[section]!, id: \.shortcode) { emoji in
AddReactionButton {
await doAddReaction(.custom(emoji))
} label: {
CustomEmojiImageView(emoji: emoji)
.frame(height: emojiSize)
.accessibilityLabel(emoji.shortcode)
}
}
} header: {
if !section.isEmpty {
VStack(alignment: .leading, spacing: 2) {
Text(section)
.font(.caption)
Divider()
}
.padding(.top, 4)
}
}
}
}
.padding(.horizontal)
}
.searchable(text: $query, placement: .navigationBarDrawer(displayMode: .always))
.searchPresentationToolbarBehaviorIfAvailable()
.onChange(of: query) { _ in
updateFilteredEmojis()
}
.navigationTitle("Add Reaction")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(role: .cancel) {
dismiss()
} label: {
Text("Cancel")
}
}
}
}
.navigationViewStyle(.stack)
.mediumPresentationDetentIfAvailable()
.alertWithData("Error Adding Reaction", data: $error, actions: { _ in
Button("OK") {}
}, message: { error in
Text(error.localizedDescription)
})
.task {
allEmojis = await mastodonController.getCustomEmojis()
updateFilteredEmojis()
}
}
private func updateFilteredEmojis() {
let filteredEmojis = if !query.isEmpty {
allEmojis.map { emoji -> (Emoji, (matched: Bool, score: Int)) in
(emoji, FuzzyMatcher.match(pattern: query, str: emoji.shortcode))
}
.filter(\.1.matched)
.sorted { $0.1.score > $1.1.score }
.map(\.0)
} else {
allEmojis
}
var shortcodes = Set<String>()
var newEmojis = [Emoji]()
var newEmojisBySection = [String: [Emoji]]()
for emoji in filteredEmojis where !shortcodes.contains(emoji.shortcode) {
newEmojis.append(emoji)
shortcodes.insert(emoji.shortcode)
let category = emoji.category ?? ""
if newEmojisBySection.keys.contains(category) {
newEmojisBySection[category]!.append(emoji)
} else {
newEmojisBySection[category] = [emoji]
}
}
emojisBySection = newEmojisBySection
}
private func doAddReaction(_ reaction: Reaction) async {
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
do {
try await addReaction(reaction)
dismiss()
} catch {
self.error = error
}
}
enum Reaction {
case emoji(String)
case custom(Emoji)
}
}
private struct AddReactionButton<Label: View>: View {
let addReaction: () async -> Void
@ViewBuilder let label: Label
@State private var isLoading = false
var body: some View {
Button {
isLoading = true
Task {
await addReaction()
isLoading = false
}
} label: {
ZStack {
label
.opacity(isLoading ? 0 : 1)
if isLoading {
ProgressView()
}
}
}
.padding(2)
.hoverEffect()
}
}
private extension View {
@available(iOS, obsoleted: 16.0)
@ViewBuilder
func mediumPresentationDetentIfAvailable() -> some View {
if #available(iOS 16.0, *) {
self.presentationDetents([.medium, .large])
} else {
self
}
}
@available(iOS, obsoleted: 17.1)
@ViewBuilder
func searchPresentationToolbarBehaviorIfAvailable() -> some View {
if #available(iOS 17.1, *) {
self.searchPresentationToolbarBehavior(.avoidHidingContent)
} else {
self
}
}
}
//#Preview {
// AddReactionView()
//}

View File

@ -0,0 +1,45 @@
//
// AnnouncementContentTextView.swift
// Tusker
//
// Created by Shadowfacts on 4/16/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import WebURL
class AnnouncementContentTextView: ContentTextView {
var heightChanged: ((CGFloat) -> Void)?
private var announcement: Announcement?
override func layoutSubviews() {
super.layoutSubviews()
heightChanged?(contentSize.height)
}
func setTextFrom(announcement: Announcement, content: NSAttributedString) {
self.announcement = announcement
self.attributedText = content
setEmojis(announcement.emojis, identifier: announcement.id)
}
override func getMention(for url: URL, text: String) -> Mention? {
announcement?.mentions.first {
URL($0.url) == url
}.map {
Mention(url: $0.url, username: $0.username, acct: $0.acct, id: $0.id)
}
}
override func getHashtag(for url: URL, text: String) -> Hashtag? {
announcement?.tags.first {
URL($0.url) == url
}
}
}

View File

@ -0,0 +1,246 @@
//
// AnnouncementListRow.swift
// Tusker
//
// Created by Shadowfacts on 4/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
import TuskerComponents
import WebURLFoundationExtras
struct AnnouncementListRow: View {
@Binding var announcement: Announcement
let mastodonController: MastodonController
let navigationDelegate: TuskerNavigationDelegate?
let removeAnnouncement: @MainActor () -> Void
@State private var contentTextViewHeight: CGFloat?
@State private var isShowingAddReactionSheet = false
var body: some View {
if #available(iOS 16.0, *) {
mostOfTheBody
.alignmentGuide(.listRowSeparatorLeading, computeValue: { dimension in
dimension[.leading]
})
} else {
mostOfTheBody
}
}
private var mostOfTheBody: some View {
VStack {
HStack(alignment: .top) {
AnnouncementContentTextViewRepresentable(announcement: announcement, navigationDelegate: navigationDelegate) { newHeight in
DispatchQueue.main.async {
contentTextViewHeight = newHeight
}
}
.frame(height: contentTextViewHeight)
Text(announcement.publishedAt, format: .abbreviatedTimeAgo)
.fontWeight(.light)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 16)
ScrollView(.horizontal) {
LazyHStack {
Button {
isShowingAddReactionSheet = true
} label: {
Label {
Text("Add Reaction")
} icon: {
if #available(iOS 16.0, *) {
Image("face.smiling.badge.plus")
} else {
Image(systemName: "face.smiling")
}
}
}
.labelStyle(.iconOnly)
.padding(4)
.hoverEffect()
ForEach($announcement.reactions, id: \.name) { $reaction in
ReactionButton(announcement: announcement, reaction: $reaction, mastodonController: mastodonController)
}
}
.frame(height: 32)
.padding(.horizontal, 16)
}
}
.padding(.vertical, 8)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.swipeActions {
Button(role: .destructive) {
Task {
await dismissAnnouncement()
}
} label: {
Text("Dismiss")
}
}
.contextMenu {
Button(role: .destructive) {
Task {
await dismissAnnouncement()
await removeAnnouncement()
}
} label: {
Label("Dismiss", systemImage: "xmark")
}
}
.sheet(isPresented: $isShowingAddReactionSheet) {
AddReactionView(mastodonController: mastodonController, addReaction: self.addReaction)
}
}
private func dismissAnnouncement() async {
do {
_ = try await mastodonController.run(Announcement.dismiss(id: announcement.id))
} catch {
Logging.general.error("Error dismissing attachment: \(String(describing: error))")
}
}
@MainActor
private func addReaction(_ reaction: AddReactionView.Reaction) async throws {
let name = switch reaction {
case .emoji(let s): s
case .custom(let emoji): emoji.shortcode
}
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: name))
for (idx, reaction) in announcement.reactions.enumerated() {
if reaction.name == name {
announcement.reactions[idx].me = true
announcement.reactions[idx].count += 1
return
}
}
let url: URL?
let staticURL: URL?
if case .custom(let emoji) = reaction {
url = URL(emoji.url)
staticURL = URL(emoji.staticURL)
} else {
url = nil
staticURL = nil
}
announcement.reactions.append(.init(name: name, count: 1, me: true, url: url, staticURL: staticURL))
}
}
private struct AnnouncementContentTextViewRepresentable: UIViewRepresentable {
let announcement: Announcement
let navigationDelegate: TuskerNavigationDelegate?
let heightChanged: (CGFloat) -> Void
func makeUIView(context: Context) -> AnnouncementContentTextView {
let view = AnnouncementContentTextView()
view.isScrollEnabled = true
view.backgroundColor = .clear
view.isEditable = false
view.isSelectable = false
view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
view.adjustsFontForContentSizeCategory = true
return view
}
func updateUIView(_ uiView: AnnouncementContentTextView, context: Context) {
uiView.navigationDelegate = navigationDelegate
uiView.setTextFrom(announcement: announcement, content: TimelineStatusCollectionViewCell.htmlConverter.convert(announcement.content))
uiView.heightChanged = heightChanged
}
}
private struct ReactionButton: View {
let announcement: Announcement
@Binding var reaction: Announcement.Reaction
let mastodonController: MastodonController
@State private var customEmojiImage: (Image, CGFloat)?
var body: some View {
Button(action: self.toggleReaction) {
let countStr = reaction.count.formatted(.number)
let title = if reaction.name.count == 1 {
"\(reaction.name) \(countStr)"
} else {
countStr
}
if reaction.url != nil {
Label {
Text(title)
} icon: {
if let (image, aspectRatio) = customEmojiImage {
image.aspectRatio(aspectRatio, contentMode: .fit)
}
}
} else {
Text(title)
}
}
.buttonStyle(TintedButtonStyle(highlighted: reaction.me == true))
.font(.body.monospacedDigit())
.hoverEffect()
.task {
if let url = reaction.url,
let image = await ImageCache.emojis.get(url).1 {
let aspectRatio = image.size.width / image.size.height
customEmojiImage = (
Image(uiImage: image).resizable(),
aspectRatio
)
}
}
}
private func toggleReaction() {
if reaction.me == true {
let oldCount = reaction.count
reaction.me = false
reaction.count -= 1
Task {
do {
_ = try await mastodonController.run(Announcement.unreact(id: announcement.id, name: reaction.name))
} catch {
reaction.me = true
reaction.count = oldCount
}
}
} else {
let oldCount = reaction.count
reaction.me = true
reaction.count += 1
Task {
do {
_ = try await mastodonController.run(Announcement.react(id: announcement.id, name: reaction.name))
} catch {
reaction.me = false
reaction.count = oldCount
}
}
}
}
}
private struct TintedButtonStyle: ButtonStyle {
let highlighted: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(highlighted ? AnyShapeStyle(.white) : AnyShapeStyle(.tint))
.padding(.vertical, 4)
.padding(.horizontal, 8)
.frame(height: 32)
.background(.tint.opacity(highlighted ? 1 : 0.2), in: RoundedRectangle(cornerRadius: 4))
.opacity(configuration.isPressed ? 0.8 : 1)
}
}
//#Preview {
// AnnouncementListRow()
//}

View File

@ -0,0 +1,18 @@
//
// AnnouncementsCollection.swift
// Tusker
//
// Created by Shadowfacts on 4/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class AnnouncementsCollection: ObservableObject {
@Published var announcements: [Announcement]
init(announcements: [Announcement]) {
self.announcements = announcements
}
}

View File

@ -0,0 +1,32 @@
//
// AnnouncementsHostingController.swift
// Tusker
//
// Created by Shadowfacts on 4/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
class AnnouncementsHostingController: UIHostingController<AnnouncementsView> {
private let mastodonController: MastodonController
init(announcements: AnnouncementsCollection, mastodonController: MastodonController) {
self.mastodonController = mastodonController
@Box var boxedSelf: TuskerNavigationDelegate?
super.init(rootView: AnnouncementsView(announcements: announcements, mastodonController: mastodonController, navigationDelegate: _boxedSelf))
boxedSelf = self
navigationItem.title = "Announcements"
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
extension AnnouncementsHostingController: TuskerNavigationDelegate {
nonisolated var apiController: MastodonController! { mastodonController }
}

View File

@ -0,0 +1,39 @@
//
// AnnouncementsView.swift
// Tusker
//
// Created by Shadowfacts on 4/17/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import SwiftUI
import Pachyderm
struct AnnouncementsView: View {
@ObservedObject var state: AnnouncementsCollection
let mastodonController: MastodonController
@Box var navigationDelegate: TuskerNavigationDelegate?
init(announcements: AnnouncementsCollection, mastodonController: MastodonController, navigationDelegate: Box<TuskerNavigationDelegate?>) {
self.state = announcements
self.mastodonController = mastodonController
self._navigationDelegate = navigationDelegate
}
var body: some View {
List {
ForEach($state.announcements) { $announcement in
AnnouncementListRow(announcement: $announcement, mastodonController: mastodonController, navigationDelegate: navigationDelegate) {
withAnimation {
state.announcements.removeAll(where: { $0.id == announcement.id })
}
}
}
}
.listStyle(.grouped)
}
}
//#Preview {
// AnnouncementsView()
//}

View File

@ -222,7 +222,7 @@ class ConversationViewController: UIViewController {
}
}
if isLikelyMastodonRemoteStatus(url: url),
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
let (_, response) = try? await URLSession.appDefault.data(from: url, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {

View File

@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
let service = RemoveProfileSuggestionService(accountID: id, mastodonController: self.mastodonController, presenter: self) { [weak self] in
guard let self else { return }
var snapshot = self.dataSource.snapshot()
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
snapshot.deleteItems([.account(id, .global)])
self.dataSource.apply(snapshot, animatingDifferences: true)
}
Task {
await service.run()
}
}
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
}
}

View File

@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
@MainActor
private func removeProfileSuggestion(accountID: String) async {
let req = Suggestion.remove(accountID: accountID)
do {
_ = try await mastodonController.run(req)
var snapshot = dataSource.snapshot()
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
guard let self else { return }
var snapshot = self.dataSource.snapshot()
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
snapshot.deleteItems([.account(accountID, .global)])
await apply(snapshot: snapshot)
} catch {
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
_ = await self.removeProfileSuggestion(accountID: accountID)
}
self.showToast(configuration: config, animated: true)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
await service.run()
}
}

View File

@ -42,7 +42,7 @@ class ImageGalleryDataSource: GalleryDataSource {
gifController: gifController
)
} else {
return LoadingGalleryContentViewController {
return LoadingGalleryContentViewController(caption: nil) {
let (data, image) = await self.cache.get(self.url, loadOriginal: true)
if let image {
let gifController: GIFController? =

View File

@ -10,6 +10,7 @@ import UIKit
import GalleryVC
class LoadingGalleryContentViewController: UIViewController, GalleryContentViewController {
private let fallbackCaption: String?
private let provider: () async -> (any GalleryContentViewController)?
private var wrapped: (any GalleryContentViewController)!
@ -24,14 +25,15 @@ class LoadingGalleryContentViewController: UIViewController, GalleryContentViewC
}
var caption: String? {
wrapped?.caption
wrapped?.caption ?? fallbackCaption
}
var canAnimateFromSourceView: Bool {
wrapped?.canAnimateFromSourceView ?? true
}
init(provider: @escaping () async -> (any GalleryContentViewController)?) {
init(caption: String?, provider: @escaping () async -> (any GalleryContentViewController)?) {
self.fallbackCaption = caption
self.provider = provider
super.init(nibName: nil, bundle: nil)

View File

@ -57,7 +57,8 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
gifController: gifController
)
} else {
return LoadingGalleryContentViewController {
return LoadingGalleryContentViewController(caption: attachment.description) {
try! await Task.sleep(nanoseconds: NSEC_PER_SEC)
let (data, image) = await ImageCache.attachments.get(attachment.url, loadOriginal: true)
if let image {
let gifController: GIFController? =
@ -95,9 +96,9 @@ class StatusAttachmentsGalleryDataSource: GalleryDataSource {
// TODO: use separate content VC with audio visualization?
return VideoGalleryContentViewController(url: attachment.url, caption: attachment.description)
case .unknown:
return LoadingGalleryContentViewController {
return LoadingGalleryContentViewController(caption: nil) {
do {
let (data, _) = try await URLSession.shared.data(from: attachment.url)
let (data, _) = try await URLSession.appDefault.data(from: attachment.url)
let url = FileManager.default.temporaryDirectory.appendingPathComponent(attachment.url.lastPathComponent)
try data.write(to: url)
return FallbackGalleryNavigationController(url: url)

View File

@ -120,6 +120,15 @@ class VideoControlsViewController: UIViewController {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let timestampObserverToken {
player.removeTimeObserver(timestampObserverToken)
}
if let scrubberObserverToken {
player.removeTimeObserver(scrubberObserverToken)
}
}
override func viewDidLoad() {
super.viewDidLoad()
@ -256,10 +265,8 @@ private class VideoScrubbingControl: UIControl {
private func updateFillLayerMask() {
// I don't know where this animation is coming from
CATransaction.begin()
CATransaction.setDisableActions(true)
fillMaskLayer.frame = CGRect(x: 0, y: 0, width: fractionComplete * bounds.width, height: 8)
CATransaction.commit()
fillMaskLayer.removeAllAnimations()
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {

View File

@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
private(set) var currentAccountID: String
private(set) var root: AccountSwitchableViewController
private var userActivities: [String: NSUserActivity] = [:]
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id
@ -42,26 +42,46 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(root)
}
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
viewControllers = viewControllers.mapValues { (_, activity) in
(nil, activity)
}
}
func removeAccount(_ account: UserAccountInfo) {
viewControllers.removeValue(forKey: account.id)
}
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
let oldRoot = self.root
if direction == .none {
oldRoot.removeViewAndController()
}
if let activity = oldRoot.stateRestorationActivity() {
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
userActivities[currentAccountID] = activity
viewControllers[currentAccountID] = (oldRoot, activity)
}
self.currentAccountID = account.id
self.root = newRoot
embedChild(newRoot)
if let activity = userActivities.removeValue(forKey: account.id) {
let newRoot: AccountSwitchableViewController
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
if let existingRoot {
newRoot = existingRoot
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
} else {
newRoot = newRootProvider()
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
context.finalize(activity: activity)
}
} else {
newRoot = newRootProvider()
}
self.currentAccountID = account.id
self.root = newRoot
embedChild(newRoot)
if direction != .none {
if UIAccessibility.prefersCrossFadeTransitions {
@ -92,6 +112,7 @@ class AccountSwitchingContainerViewController: UIViewController {
#endif
// only one edge is affected in each direction, i have no idea why
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
if direction == .upwards {
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
} else {
@ -102,6 +123,8 @@ class AccountSwitchingContainerViewController: UIViewController {
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
newRoot.view.transform = .identity
} completion: { (_) in
oldRoot.view.transform = .identity
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
oldRoot.removeViewAndController()
newRoot.view.layer.masksToBounds = false
}

View File

@ -180,26 +180,41 @@ class MainSplitViewController: UISplitViewController {
}
@objc func handleSidebarCommandTimelines() {
if let previous = sidebar.selectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
sidebar.select(item: .tab(.timelines), animated: false)
select(item: .tab(.timelines))
}
@objc func handleSidebarCommandNotifications() {
if let previous = sidebar.selectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
sidebar.select(item: .tab(.notifications), animated: false)
select(item: .tab(.notifications))
}
@objc func handleSidebarCommandExplore() {
if let previous = sidebar.selectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
sidebar.select(item: .tab(.explore), animated: false)
select(item: .tab(.explore))
}
@objc func handleSidebarCommandBookmarks() {
if let previous = sidebar.selectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
sidebar.select(item: .bookmarks, animated: false)
select(item: .bookmarks)
}
@objc func handleSidebarCommandMyProfile() {
if let previous = sidebar.selectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
sidebar.select(item: .tab(.myProfile), animated: false)
select(item: .tab(.myProfile))
}

View File

@ -12,7 +12,16 @@ import HTMLStreamer
class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
private let iconView = UIImageView().configure {
private static func canDisplay(_ kind: NotificationGroup.Kind) -> Bool {
switch kind {
case .favourite, .reblog, .emojiReaction:
return true
default:
return false
}
}
private let iconImageView = UIImageView().configure {
$0.tintColor = UIColor(red: 1, green: 204/255, blue: 0, alpha: 1)
$0.contentMode = .scaleAspectFit
NSLayoutConstraint.activate([
@ -21,6 +30,10 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
])
}
private let iconLabel = UILabel().configure {
$0.font = .systemFont(ofSize: 30)
}
private let avatarStack = UIStackView().configure {
$0.axis = .horizontal
$0.alignment = .fill
@ -81,6 +94,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
private var group: NotificationGroup!
private var statusID: String!
private var fetchCustomEmojiImage: (URL, Task<Void, Never>)?
private var updateTimestampWorkItem: DispatchWorkItem?
deinit {
@ -90,15 +104,21 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
override init(frame: CGRect) {
super.init(frame: frame)
iconView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconView)
iconImageView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconImageView)
iconLabel.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(iconLabel)
vStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(vStack)
NSLayoutConstraint.activate([
iconView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
iconImageView.topAnchor.constraint(equalTo: vStack.topAnchor),
iconImageView.trailingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16 + 50),
iconLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
iconLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
iconLabel.leadingAnchor.constraint(equalTo: iconImageView.leadingAnchor),
iconLabel.trailingAnchor.constraint(equalTo: iconImageView.trailingAnchor),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
vStack.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8),
vStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
vStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8),
vStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8),
@ -116,7 +136,7 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
}
func updateUI(group: NotificationGroup) {
guard group.kind == .favourite || group.kind == .reblog,
guard ActionNotificationGroupCollectionViewCell.canDisplay(group.kind),
let firstNotification = group.notifications.first,
let status = firstNotification.status else {
fatalError()
@ -126,9 +146,29 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
switch group.kind {
case .favourite:
iconView.image = UIImage(systemName: "star.fill")
iconImageView.image = UIImage(systemName: "star.fill")
iconLabel.text = ""
fetchCustomEmojiImage?.1.cancel()
case .reblog:
iconView.image = UIImage(systemName: "repeat")
iconImageView.image = UIImage(systemName: "repeat")
iconLabel.text = ""
fetchCustomEmojiImage?.1.cancel()
case .emojiReaction(let emojiOrShortcode, let url):
iconImageView.image = nil
if let url = url.flatMap({ URL($0) }),
fetchCustomEmojiImage?.0 != url {
fetchCustomEmojiImage?.1.cancel()
let task = Task {
let (_, image) = await ImageCache.emojis.get(url)
if !Task.isCancelled {
self.iconImageView.image = image
}
}
fetchCustomEmojiImage = (url, task)
} else {
iconLabel.text = emojiOrShortcode
fetchCustomEmojiImage?.1.cancel()
}
default:
fatalError()
}
@ -207,6 +247,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
verb = "Favorited"
case .reblog:
verb = "Reblogged"
case .emojiReaction(_, _):
verb = "Reacted to"
default:
fatalError()
}
@ -252,6 +294,8 @@ class ActionNotificationGroupCollectionViewCell: UICollectionViewListCell {
str += "Favorited by "
case .reblog:
str += "Reblogged by "
case .emojiReaction(let emoji, _):
str += "Reacted \(emoji) by "
default:
return nil
}

View File

@ -0,0 +1,144 @@
//
// FollowRequestNotificationViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/19/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class FollowRequestNotificationViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController
private let notification: Pachyderm.Notification
var collectionView: UICollectionView! {
view as? UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
init(notification: Pachyderm.Notification, mastodonController: MastodonController) {
precondition(notification.kind == .followRequest)
self.mastodonController = mastodonController
self.notification = notification
super.init(nibName: nil, bundle: nil)
title = "Follow Request"
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
var config = UICollectionLayoutListConfiguration(appearance: .grouped)
config.backgroundColor = .appGroupedBackground
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment)
return section
}
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
collectionView.dragDelegate = self
collectionView.allowsFocus = true
dataSource = createDataSource()
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let followRequestCell = UICollectionView.CellRegistration<FollowRequestNotificationCollectionViewCell, Void> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: self.notification)
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in
return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: ())
}
}
override func viewDidLoad() {
super.viewDidLoad()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.notifications])
snapshot.appendItems([.notification])
dataSource.apply(snapshot, animatingDifferences: false)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
}
}
extension FollowRequestNotificationViewController {
enum Section {
case notifications
}
enum Item {
case notification
}
}
extension FollowRequestNotificationViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
selected(account: notification.account.id)
}
func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let cell = collectionView.cellForItem(at: indexPath) else {
return nil
}
let accountID = notification.account.id
return UIContextMenuConfiguration {
ProfileViewController(accountID: accountID, mastodonController: self.mastodonController)
} actionProvider: { _ in
let cell = cell as! FollowRequestNotificationCollectionViewCell
let acceptRejectChildren = [
UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }),
UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }),
]
let acceptRejectMenu: UIMenu
if #available(iOS 16.0, *) {
acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren)
} else {
acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren)
}
return UIMenu(children: [
acceptRejectMenu,
UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))),
])
}
}
func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: any UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
}
extension FollowRequestNotificationViewController: UICollectionViewDragDelegate {
func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: any UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
let provider = NSItemProvider(object: notification.account.url as NSURL)
let activity = UserActivityManager.showProfileActivity(id: notification.account.id, accountID: mastodonController.accountInfo!.id)
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
}
}
extension FollowRequestNotificationViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController }
}
extension FollowRequestNotificationViewController: MenuActionProvider {
}
extension FollowRequestNotificationViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}

View File

@ -75,11 +75,21 @@ class NotificationLoadingViewController: UIViewController {
}
let actionType = notification.kind == .reblog ? StatusActionAccountListViewController.ActionType.reblog : .favorite
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController)
case .emojiReaction:
guard let statusID = notification.status?.id else {
showLoadingError(Error.missingStatus)
return
}
guard let emoji = notification.emoji else {
showLoadingError(Error.unknownType)
return
}
let actionType = StatusActionAccountListViewController.ActionType.emojiReaction(emoji, notification.emojiURL)
vc = StatusActionAccountListViewController(actionType: actionType, statusID: statusID, statusState: .unknown, accountIDs: [notification.account.id], mastodonController: mastodonController)
case .follow:
vc = ProfileViewController(accountID: notification.account.id, mastodonController: mastodonController)
case .followRequest:
// todo
return
vc = FollowRequestNotificationViewController(notification: notification, mastodonController: mastodonController)
case .unknown:
showLoadingError(Error.unknownType)
return

View File

@ -178,7 +178,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
case .favourite, .reblog:
case .favourite, .reblog, .emojiReaction:
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
case .follow:
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
@ -317,7 +317,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] }
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed, kind: group.kind)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
snapshot.deleteItems([.group(group, collapseState, filterState)])
}
await apply(snapshot, animatingDifferences: true)
@ -624,8 +624,8 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
let state = collapseState?.copy() ?? .unknown
selected(status: statusID, state: state)
}
case .favourite, .reblog:
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
case .favourite, .reblog, .emojiReaction(_, _):
let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques()
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
@ -666,9 +666,9 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
} actionProvider: { _ in
UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update))
}
case .favourite, .reblog:
case .favourite, .reblog, .emojiReaction(_, _):
return UIContextMenuConfiguration(previewProvider: {
let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog
let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques()
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
@ -751,7 +751,7 @@ extension NotificationsCollectionViewController: UICollectionViewDragDelegate {
activity.displaysAuxiliaryScene = true
provider.registerObject(activity, visibility: .all)
return [UIDragItem(itemProvider: provider)]
case .favourite, .reblog:
case .favourite, .reblog, .emojiReaction(_, _):
return []
case .follow, .followRequest:
guard group.notifications.count == 1 else {

View File

@ -16,6 +16,22 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
var initialMode: NotificationsMode?
private lazy var announcementsButton: UIButton = {
#if os(visionOS)
var config = UIButton.Configuration.borderedProminent()
#else
var config = UIButton.Configuration.plain()
// We don't want a background for this button, even when accessibility button shapes are enabled, because it's in the navbar.
config.background.backgroundColor = .clear
#endif
config.image = UIImage(systemName: "megaphone.fill")
config.contentInsets = .zero
let button = UIButton(configuration: config)
button.addTarget(self, action: #selector(announcementsButtonPresesd), for: .touchUpInside)
return button
}()
private var unreadAnnouncements: AnnouncementsCollection?
init(initialMode: NotificationsMode? = nil, mastodonController: MastodonController) {
self.initialMode = initialMode
self.mastodonController = mastodonController
@ -30,6 +46,8 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
title = Page.all.title
tabBarItem.image = UIImage(systemName: "bell.fill")
navigationItem.rightBarButtonItem = UIBarButtonItem(customView: announcementsButton)
announcementsButton.isHidden = true
}
required init?(coder: NSCoder) {
@ -42,6 +60,14 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectMode(initialMode ?? Preferences.shared.defaultNotificationsMode)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task {
await checkForAnnouncements()
}
}
func selectMode(_ mode: NotificationsMode) {
let page: Page
switch mode {
@ -53,6 +79,61 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
selectPage(page, animated: false)
}
private func checkForAnnouncements() async {
guard mastodonController.instanceFeatures.instanceAnnouncements else {
navigationItem.rightBarButtonItem = nil
return
}
let announcements: [Announcement]
do {
(announcements, _) = try await mastodonController.run(Announcement.all())
} catch {
Logging.general.error("Error fetching announcements: \(String(describing: error))")
return
}
let unread = announcements.filter { $0.read == false }
if unread.isEmpty {
unreadAnnouncements = nil
if #available(iOS 17.0, *) {
announcementsButton.imageView!.addSymbolEffect(.disappear)
} else {
UIView.animate(withDuration: 0.2, delay: 0.1, options: .curveEaseInOut) {
self.announcementsButton.transform = CGAffineTransform(scaleX: 0.8, y: 0.8)
self.announcementsButton.layer.opacity = 0
} completion: { _ in
self.announcementsButton.transform = .identity
self.announcementsButton.layer.opacity = 1
}
}
} else {
announcementsButton.isHidden = false
announcementsButton.layer.opacity = 1
unreadAnnouncements = AnnouncementsCollection(announcements: unread)
if #available(iOS 17.0, *) {
// make sure to remove the .disappear effect, which stays around indefinitely
announcementsButton.imageView!.removeAllSymbolEffects()
announcementsButton.imageView!.addSymbolEffect(.bounce)
} else {
UIView.animate(withDuration: 0.2, delay: 0.1, options: .curveEaseInOut) {
self.announcementsButton.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
} completion: { _ in
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) {
self.announcementsButton.transform = .identity
}
}
}
}
}
@objc private func announcementsButtonPresesd() {
guard let unreadAnnouncements else {
return
}
show(AnnouncementsHostingController(announcements: unreadAnnouncements, mastodonController: mastodonController), sender: nil)
}
}
extension NotificationsPageViewController {
enum Page: SegmentedPageViewControllerPage {
case all
case mentions

View File

@ -116,10 +116,13 @@ class OnboardingViewController: UINavigationController {
}
private func tryLogin(to instanceURL: URL, updateStatus: (String) -> Void) async throws {
logger.debug("Attempting to log in to \(instanceURL, privacy: .public)")
let mastodonController = MastodonController(instanceURL: instanceURL, transient: true)
let clientID: String
let clientSecret: String
if let clientInfo, clientInfo.url == instanceURL {
logger.debug("Using client info from previous attempt")
clientID = clientInfo.id
clientSecret = clientInfo.secret
} else {
@ -127,21 +130,32 @@ class OnboardingViewController: UINavigationController {
do {
(clientID, clientSecret) = try await mastodonController.registerApp()
self.clientInfo = (instanceURL, clientID, clientSecret)
logger.debug("Obtained client info")
updateStatus("Reticulating Splines")
try await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
} catch {
logger.error("Failed to register app: \(String(describing: error), privacy: .public)")
throw Error.registeringApp(error)
}
}
updateStatus("Logging in")
let authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
let authCode: String
do {
authCode = try await getAuthorizationCode(instanceURL: instanceURL, clientID: clientID)
logger.debug("Obtained authorization code")
} catch {
logger.error("Failed to get auth code: \(String(describing: error), privacy: .public)")
throw error
}
updateStatus("Authorizing")
let accessToken: String
do {
accessToken = try await retrying("Getting access token") {
try await mastodonController.authorize(authorizationCode: authCode)
}
logger.debug("Obtained access token")
} catch {
logger.error("Failed to get access token: \(String(describing: error), privacy: .public)")
throw Error.gettingAccessToken(error)
}

View File

@ -17,6 +17,15 @@ struct NotificationsPrefsView: View {
var body: some View {
List {
NavigationLink {
TipJarView()
} label: {
Text("Push notifications are available for free to all users, but providing them has an ongoing cost. If you like the app, please consider \(Text("supporting Tusker").foregroundColor(.accentColor)).")
.font(.callout)
.foregroundStyle(.secondary)
}
.listRowBackground(Color.accentColor.opacity(0.1))
Section {
ForEach(userAccounts.accounts) { account in
PushInstanceSettingsView(account: account)

View File

@ -90,7 +90,7 @@ struct PushInstanceSettingsView: View {
let mastodonController = await MastodonController.getForAccount(account)
do {
let result = try await mastodonController.createPushSubscription(subscription: subscription)
PushManager.logger.debug("Push subscription \(result.id) created on \(account.instanceURL)")
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) created on \(account.instanceURL) with endpoint \(result.endpoint, privacy: .public)")
self.subscription = subscription
return true
} catch {
@ -112,7 +112,7 @@ struct PushInstanceSettingsView: View {
let mastodonController = await MastodonController.getForAccount(account)
do {
let result = try await mastodonController.updatePushSubscription(alerts: alerts, policy: policy)
PushManager.logger.debug("Push subscription \(result.id) updated on \(account.instanceURL)")
PushManager.logger.debug("Push subscription \(result.id, privacy: .public) updated on \(account.instanceURL)")
await PushManager.shared.updateSubscription(account: account, alerts: alerts, policy: policy)
subscription?.alerts = alerts
subscription?.policy = policy

View File

@ -74,20 +74,27 @@ private struct PushSubscriptionSettingsView: View {
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
toggle("Edits", alert: .update)
}
if mastodonController.instanceFeatures.emojiReactionNotifications {
toggle("Reactions", alert: .emojiReaction)
}
// status notifications not supported until we can enable/disable them in the app
}
}
.groupBoxStyle(AppBackgroundGroupBoxStyle())
}
private var allSupportedAlertTypes: PushSubscription.Alerts {
var alerts: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
var all: PushSubscription.Alerts = [.mention, .favorite, .reblog, .follow, .poll]
if mastodonController.instanceFeatures.pushNotificationTypeFollowRequest {
alerts.insert(.followRequest)
all.insert(.followRequest)
}
if mastodonController.instanceFeatures.pushNotificationTypeUpdate {
alerts.insert(.update)
all.insert(.update)
}
return alerts
if mastodonController.instanceFeatures.emojiReactionNotifications {
all.insert(.emojiReaction)
}
return all
}
private func toggle(_ titleKey: LocalizedStringKey, alert: PushSubscription.Alerts) -> some View {
@ -125,6 +132,19 @@ private extension PushSubscription.Policy {
}
}
private struct AppBackgroundGroupBoxStyle: GroupBoxStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(alignment: .leading, spacing: 16) {
configuration.label
.font(.headline)
configuration.content
}
.padding()
.background(Color.appGroupedBackground, in: RoundedRectangle(cornerRadius: 8))
}
}
//#Preview {
// PushSubscriptionView()
//}

View File

@ -108,7 +108,12 @@ struct PreferencesView: View {
PreferenceSectionLabel(title: "Composing", systemImageName: "pencil", backgroundColor: .blue)
}
NavigationLink(destination: WellnessPrefsView()) {
PreferenceSectionLabel(title: "Digital Wellness", systemImageName: "brain.fill", backgroundColor: .purple)
let brainImageName = if #available(iOS 17.0, *) {
"brain.fill"
} else {
"brain"
}
PreferenceSectionLabel(title: "Digital Wellness", systemImageName: brainImageName, backgroundColor: .purple)
}
NavigationLink(destination: AdvancedPrefsView()) {
PreferenceSectionLabel(title: "Advanced", systemImageName: "gearshape.2.fill", backgroundColor: .gray)

View File

@ -11,18 +11,24 @@ import StoreKit
import Combine
struct TipJarView: View {
private static let productIDs = [
private static let tipProductIDs = [
"tusker.tip.small",
"tusker.tip.medium",
"tusker.tip.large",
]
private static let supporterProductIDs = [
"tusker.supporter.regular",
]
@State private var isLoaded = false
@State private var products: [(Product, Bool)] = []
@State private var tipProducts: [(Product, Bool)] = []
@State private var supporterProducts: [(Product, Bool)] = []
@State private var error: Error?
@State private var showConfetti = false
@State private var updatesObserver: Task<Void, Never>?
@State private var buttonWidth: CGFloat?
@State private var tipButtonWidth: CGFloat?
@State private var supporterButtonWidth: CGFloat?
@State private var supporterStartDate: Date?
@StateObject private var observer = UbiquitousKeyValueStoreObserver()
var body: some View {
@ -47,9 +53,20 @@ struct TipJarView: View {
updatesObserver = Task.detached { @MainActor in
await observeTransactionUpdates()
}
for await verificationResult in Transaction.currentEntitlements {
if case .verified(let transaction) = verificationResult,
Self.supporterProductIDs.contains(transaction.productID),
await transaction.subscriptionStatus?.state == .subscribed {
supporterStartDate = transaction.originalPurchaseDate
break
}
}
do {
products = try await Product.products(for: Self.productIDs).map { ($0, false) }
products.sort(by: { $0.0.price < $1.0.price })
let allProducts = try await Product.products(for: Self.tipProductIDs + Self.supporterProductIDs).map { ($0, false) }
tipProducts = allProducts.filter { Self.tipProductIDs.contains($0.0.id) }
tipProducts.sort(by: { $0.0.price < $1.0.price })
supporterProducts = allProducts.filter { Self.supporterProductIDs.contains($0.0.id) }
supporterProducts.sort(by: { $0.0.price < $1.0.price })
isLoaded = true
} catch {
self.error = .fetchingProducts(error)
@ -67,25 +84,17 @@ struct TipJarView: View {
private var productsView: some View {
if isLoaded {
VStack {
Text("If you're enjoying using Tusker and want to show your gratitude or help support its development, it is greatly appreciated!")
if !supporterProducts.isEmpty {
supporterSubscriptions
}
tipPurchases
if let tipStatus {
tipStatus
.multilineTextAlignment(.center)
.padding(.horizontal)
VStack(alignment: .myAlignment) {
ForEach($products, id: \.0.id) { $productAndPurchasing in
TipRow(product: productAndPurchasing.0, buttonWidth: buttonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
}
}
.onPreferenceChange(ButtonWidthKey.self) { newValue in
if let buttonWidth {
self.buttonWidth = max(buttonWidth, newValue)
} else {
self.buttonWidth = newValue
}
}
if let total = getTotalTips(), total > 0 {
Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍")
.padding(.top, 16)
Text("Thank you!")
}
}
@ -95,20 +104,93 @@ struct TipJarView: View {
}
}
@ViewBuilder
private var supporterSubscriptions: some View {
Text("If you want to contribute Tusker's continued development, you can become a supporter. Supporting Tusker is an auto-renewable monthly subscription.")
.multilineTextAlignment(.center)
.padding(.horizontal)
VStack(alignment: .myAlignment) {
ForEach($supporterProducts, id: \.0.id) { $productAndPurchasing in
TipRow(product: productAndPurchasing.0, buttonWidth: supporterButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
}
}
.onPreferenceChange(ButtonWidthKey.self) { newValue in
if let supporterButtonWidth {
self.supporterButtonWidth = max(supporterButtonWidth, newValue)
} else {
self.supporterButtonWidth = newValue
}
}
}
@ViewBuilder
private var tipPurchases: some View {
Text("Or, you can choose to make a one-time tip to show your gratitutde or help support the app's development. It is greatly appreciated!")
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.top, 16)
VStack(alignment: .myAlignment) {
ForEach($tipProducts, id: \.0.id) { $productAndPurchasing in
TipRow(product: productAndPurchasing.0, buttonWidth: tipButtonWidth, isPurchasing: $productAndPurchasing.1, showConfetti: $showConfetti)
}
}
.onPreferenceChange(ButtonWidthKey.self) { newValue in
if let tipButtonWidth {
self.tipButtonWidth = max(tipButtonWidth, newValue)
} else {
self.tipButtonWidth = newValue
}
}
}
private var tipStatus: Text? {
var text: Text?
if let supporterStartDate {
var months = Calendar.current.dateComponents([.month], from: supporterStartDate, to: Date()).month!
// the user has already paid for n months before the nth month has finished, so reflect that
months += 1
text = Text("You've been a supporter for ^[\(months) months](inflect: true)")
}
if let total = getTotalTips(),
total > 0 {
if let t = text {
text = Text("\(t) and tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))")
} else {
text = Text("You've tipped \(total.formatted(tipProducts[0].0.priceFormatStyle))")
}
}
if let text {
return Text("\(text) 😍")
} else {
return nil
}
}
@MainActor
private func observeTransactionUpdates() async {
for await verificationResult in StoreKit.Transaction.updates {
guard let index = products.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) else {
continue
}
if let index = tipProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) {
switch verificationResult {
case .verified(let transaction):
await transaction.finish()
self.products[index].1 = false
self.tipProducts[index].1 = false
self.showConfetti = true
case .unverified(_, let error):
self.error = .verifyingTransaction(error)
}
} else if let index = supporterProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) {
switch verificationResult {
case .verified(let transaction):
await transaction.finish()
self.supporterProducts[index].1 = false
self.showConfetti = true
self.supporterStartDate = transaction.originalPurchaseDate
case .unverified(_, let error):
self.error = .verifyingTransaction(error)
}
}
}
}
@ -149,6 +231,20 @@ private struct TipRow: View {
Text(product.displayName)
.alignmentGuide(.myAlignment, computeValue: { context in context[.trailing] })
if let subscription = product.subscription {
SubscriptionButton(product: product, subscriptionInfo: subscription, isPurchasing: $isPurchasing, buttonWidth: buttonWidth, purchase: purchase)
} else {
tipButton
}
}
.alertWithData("Error", data: $error) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
private var tipButton: some View {
Button {
Task {
await self.purchase()
@ -169,12 +265,6 @@ private struct TipRow: View {
}
.buttonStyle(.borderedProminent)
}
.alertWithData("Error", data: $error) { _ in
Button("OK") {}
} message: { error in
Text(error.localizedDescription)
}
}
@MainActor
private func purchase() async {
@ -221,6 +311,71 @@ private struct TipRow: View {
}
}
private struct SubscriptionButton: View {
let product: Product
let subscriptionInfo: Product.SubscriptionInfo
@Binding var isPurchasing: Bool
let buttonWidth: CGFloat?
let purchase: () async -> Void
@State private var hasPurchased = false
@State private var showManageSheet = false
var body: some View {
Button {
if #available(iOS 17.0, *), hasPurchased {
showManageSheet = true
} else {
Task {
await purchase()
await updateHasPurchased()
}
}
} label: {
if #available(iOS 17.0, *), hasPurchased {
Text("Manage")
} else if isPurchasing {
ProgressView()
.progressViewStyle(.circular)
.frame(width: buttonWidth, alignment: .center)
} else {
let per: String = if subscriptionInfo.subscriptionPeriod.value == 1, subscriptionInfo.subscriptionPeriod.unit == .month {
"mo"
} else {
subscriptionInfo.subscriptionPeriod.formatted(product.subscriptionPeriodFormatStyle)
}
Text("\(product.displayPrice)/\(per)")
.background(GeometryReader { proxy in
Color.clear
.preference(key: ButtonWidthKey.self, value: proxy.size.width)
})
.frame(width: buttonWidth)
}
}
.buttonStyle(.borderedProminent)
.task {
await updateHasPurchased()
}
.onChange(of: showManageSheet) {
if !$0 {
Task {
await updateHasPurchased()
}
}
}
.manageSubscriptionsSheetIfAvailable(isPresented: $showManageSheet, subscriptionGroupID: subscriptionInfo.subscriptionGroupID)
}
private func updateHasPurchased() async {
switch await Transaction.currentEntitlement(for: product.id) {
case .verified(let transaction):
let state = await transaction.subscriptionStatus?.state
hasPurchased = state == .subscribed
default:
break
}
}
}
extension HorizontalAlignment {
private enum MyTrailing: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
@ -263,3 +418,15 @@ private class UbiquitousKeyValueStoreObserver: ObservableObject {
}
}
}
private extension View {
@available(iOS, obsoleted: 17.0)
@ViewBuilder
func manageSubscriptionsSheetIfAvailable(isPresented: Binding<Bool>, subscriptionGroupID: String) -> some View {
if #available(iOS 17.0, *) {
self.manageSubscriptionsSheet(isPresented: isPresented, subscriptionGroupID: subscriptionGroupID)
} else {
self
}
}
}

View File

@ -157,7 +157,7 @@ struct ReportView: View {
.appGroupedListRowBackground()
}
.listStyle(.insetGrouped)
.appGroupedListBackground(container: UIHostingController<ReportView>.self, applyBackground: true)
.appGroupedListBackground(container: UIHostingController<ReportView>.self)
.alertWithData("Error Reporting", data: $error, actions: { error in
Button("OK") {}
}, message: { error in

View File

@ -172,6 +172,8 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect
return Status.getFavourites(statusID, range: range.withCount(Self.pageSize))
case .reblog:
return Status.getReblogs(statusID, range: range.withCount(Self.pageSize))
case .emojiReaction(let name, _):
return Status.getReactions(statusID, emoji: name, range: range.withCount(Self.pageSize))
}
}

View File

@ -8,6 +8,7 @@
import UIKit
import Pachyderm
import WebURL
class StatusActionAccountListViewController: UIViewController {
@ -80,6 +81,8 @@ class StatusActionAccountListViewController: UIViewController {
title = NSLocalizedString("Favorited By", comment: "status favorited by accounts list title")
case .reblog:
title = NSLocalizedString("Reblogged By", comment: "status reblogged by accounts list title")
case .emojiReaction(_, _):
title = "Reacted To By"
}
view.backgroundColor = .appBackground
@ -178,7 +181,22 @@ extension StatusActionAccountListViewController {
extension StatusActionAccountListViewController {
enum ActionType {
case favorite, reblog
case favorite
case reblog
case emojiReaction(String, WebURL?)
init?(_ groupKind: NotificationGroup.Kind) {
switch groupKind {
case .reblog:
self = .reblog
case .favourite:
self = .favorite
case .emojiReaction(let emoji, let url):
self = .emojiReaction(emoji, url)
default:
return nil
}
}
}
}

View File

@ -273,12 +273,17 @@ private class ColumnView: UIView {
}
private func installCloseBarButton(navigationItem: UINavigationItem) {
func makeItem() -> UIBarButtonItem {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
item.accessibilityLabel = "Close Column"
if navigationItem.leftBarButtonItems != nil {
navigationItem.leftBarButtonItems!.insert(item, at: 0)
return item
}
if let leftItems = navigationItem.leftBarButtonItems {
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
}
} else {
navigationItem.leftBarButtonItems = [item]
navigationItem.leftBarButtonItems = [makeItem()]
}
}

View File

@ -15,7 +15,6 @@ import SwiftUI
@MainActor
protocol MenuActionProvider: AnyObject {
var navigationDelegate: TuskerNavigationDelegate? { get }
var toastableViewController: ToastableViewController? { get }
}
@MainActor
@ -34,10 +33,6 @@ extension MenuActionProvider where Self: TuskerNavigationDelegate {
var navigationDelegate: TuskerNavigationDelegate? { self }
}
extension MenuActionProvider where Self: ToastableViewController {
var toastableViewController: ToastableViewController? { self }
}
extension MenuActionProvider {
private var mastodonController: MastodonController? { navigationDelegate?.apiController }
@ -459,7 +454,7 @@ extension MenuActionProvider {
}
private func handleSuccess(title: String) {
if let toastable = self.toastableViewController {
if let toastable = self.navigationDelegate {
var config = ToastConfiguration(title: title)
config.systemImageName = "checkmark"
config.dismissAutomaticallyAfter = 2

View File

@ -50,8 +50,11 @@ extension UIViewController {
}
func removeViewAndController() {
beginAppearanceTransition(false, animated: false)
view.removeFromSuperview()
willMove(toParent: nil)
removeFromParent()
endAppearanceTransition()
}
}
@ -68,7 +71,7 @@ extension UIView {
if layout {
subview.frame = bounds
subview.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
subview.trailingAnchor.constraint(equalTo: trailingAnchor),

View File

@ -53,13 +53,94 @@
"settings" : {
"_applicationInternalID" : "1498334597",
"_developerTeamID" : "V4WK9KR9U2",
"_lastSynchronizedDate" : 696310076.23998904
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 737914663.21114194,
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
{
"id" : "21490109",
"localizations" : [
],
"name" : "Tip Jar",
"subscriptions" : [
{
"adHocOffers" : [
],
"codeOffers" : [
],
"displayPrice" : "1.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "6502909920",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Support the continued development of Tusker!",
"displayName" : "Tusker Supporter",
"locale" : "en_US"
}
],
"productID" : "tusker.supporter.regular",
"recurringSubscriptionPeriod" : "P1M",
"referenceName" : "tusker.supporter.regular",
"subscriptionGroupID" : "21490109",
"type" : "RecurringSubscription"
}
]
}
],
"version" : {
"major" : 2,
"major" : 3,
"minor" : 0
}
}

View File

@ -511,7 +511,7 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
} else if self.attachment.kind == .gifv || self.attachment.kind == .video {
itemSource = VideoActivityItemSource(asset: AVAsset(url: self.attachment.url), url: self.attachment.url)
itemData = Task {
try? await URLSession.shared.data(from: self.attachment.url).0
try? await URLSession.appDefault.data(from: self.attachment.url).0
}
} else {
return nil

View File

@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
commonInit()
}
deinit {
fetchTask?.cancel()
blurHashTask?.cancel()
}
private func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}

View File

@ -267,10 +267,6 @@ extension ContentTextView: UITextViewDelegate {
}
extension ContentTextView: MenuActionProvider {
var toastableViewController: ToastableViewController? {
// todo: pass this down through the text view
nil
}
}
extension ContentTextView: UIContextMenuInteractionDelegate {

View File

@ -170,10 +170,6 @@ class ProfileFieldValueView: UIView {
}
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
var toastableViewController: ToastableViewController? {
navigationDelegate
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {

View File

@ -25,9 +25,9 @@ class ProfileHeaderView: UIView {
weak var delegate: ProfileHeaderViewDelegate?
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var headerImageView: CachedImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var avatarImageView: CachedImageView!
@IBOutlet weak var moreButton: ProfileHeaderButton!
@IBOutlet weak var followButton: ProfileHeaderButton!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@ -44,8 +44,6 @@ class ProfileHeaderView: UIView {
var accountID: String!
private var imagesTask: Task<Void, Never>?
private var isGrayscale = false
private var followButtonMode = FollowButtonMode.follow {
didSet {
@ -56,10 +54,6 @@ class ProfileHeaderView: UIView {
}
}
deinit {
imagesTask?.cancel()
}
override func awakeFromNib() {
super.awakeFromNib()
@ -69,11 +63,13 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerCurve = .continuous
// Set zPositions so the gallery presentation/dismissal animation looks correct.
avatarContainerView.layer.zPosition = 2
avatarImageView.cache = .avatars
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerCurve = .continuous
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
avatarImageView.isUserInteractionEnabled = true
headerImageView.cache = .headers
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
headerImageView.isUserInteractionEnabled = true
headerImageView.layer.zPosition = 1
@ -138,11 +134,11 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
if let avatar = account.avatar {
avatarImageView.update(for: avatar)
}
if let header = account.header {
headerImageView.update(for: header)
}
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
@ -294,44 +290,6 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account)
if isGrayscale != Preferences.shared.grayscaleImages {
isGrayscale = Preferences.shared.grayscaleImages
imagesTask?.cancel()
let avatar = account.avatar
let header = account.header
imagesTask = Task {
await updateImages(avatar: avatar, header: header)
}
}
}
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
await withTaskGroup(of: Void.self) { group in
group.addTask {
guard let avatar,
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
!Task.isCancelled else {
return
}
await MainActor.run {
self.avatarImageView.image = transformedImage
}
}
group.addTask {
guard let header,
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
!Task.isCancelled else {
return
}
await MainActor.run {
self.headerImageView.image = transformedImage
}
}
await group.waitForAll()
}
}
private func formatBigNumber(_ value: Int) -> (String, String) {

View File

@ -3,7 +3,7 @@
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@ -14,7 +14,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB" customClass="ProfileHeaderView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="0.0" y="48" width="414" height="150"/>
<constraints>
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
@ -23,7 +23,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
<rect key="frame" x="16" y="138" width="120" height="120"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="2" y="2" width="116" height="116"/>
<constraints>
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>

View File

@ -640,7 +640,7 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
return defaultRegion
} else if let button = interaction.view as? UIButton,
actionButtons.contains(button) {
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
rect = rect.insetBy(dx: -24, dy: -24)
return UIPointerRegion(rect: rect)
}
@ -654,8 +654,8 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
} else if let button = interaction.view as? UIButton,
actionButtons.contains(button) {
let preview = UITargetedPreview(view: button.imageView!)
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
rect = rect.insetBy(dx: -24, dy: -24)
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
rect = rect.insetBy(dx: -8, dy: -8)
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
}
return nil

View File

@ -10,7 +10,7 @@ import UIKit
class StatusCollapseButton: UIButton {
private var interactionBounds: CGRect!
private var interactionBounds: CGRect?
override func layoutSubviews() {
super.layoutSubviews()
@ -19,7 +19,7 @@ class StatusCollapseButton: UIButton {
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return interactionBounds.contains(point)
return interactionBounds?.contains(point) ?? false
}
}

View File

@ -945,7 +945,6 @@ extension TimelineStatusCollectionViewCell: UIPointerInteractionDelegate {
extension TimelineStatusCollectionViewCell: StatusSwipeActionContainer {
var navigationDelegate: TuskerNavigationDelegate { delegate! }
var toastableViewController: ToastableViewController? { delegate }
var canReblog: Bool {
reblogButton.isEnabled

View File

@ -9,8 +9,8 @@
// Configuration settings file format documentation can be found at:
// https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.2
CURRENT_PROJECT_VERSION = 121
MARKETING_VERSION = 2024.3
CURRENT_PROJECT_VERSION = 126
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev