Compare commits

...

66 Commits

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

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

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

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

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

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

Closes #494
2024-06-01 10:44:49 -07:00
Shadowfacts 908b499f8f Fix Remove Suggestion action missing from Suggested Accounts screen
Closes #495
2024-06-01 10:40:30 -07:00
Shadowfacts 67c7905acf Fix missing VC callbacks in removeViewAndController 2024-06-01 10:29:33 -07:00
Shadowfacts eacafe87b3 Fix logout from current resulting in black screen after switching to reused VC
Closes #489
2024-06-01 10:28:46 -07:00
Shadowfacts 2a53b24487 Merge branch 'public-beta' into develop 2024-05-29 22:42:43 -07:00
Shadowfacts 9df3c33c6c Bump build number and update changelog 2024-05-29 22:37:53 -07:00
Shadowfacts d4e82d6e7a Fix AVPlayer periodic time observers not being removed 2024-05-29 22:35:45 -07:00
Shadowfacts 06ba758309 Merge branch 'public-beta' into develop 2024-05-29 22:30:48 -07:00
Shadowfacts 2c56902389 Remove old account UI state when logging out 2024-05-29 22:23:09 -07:00
Shadowfacts cb3fd43dbd Fix video thubmnail being flipped in Compose
Closes #487
2024-05-29 22:03:53 -07:00
Shadowfacts 3d15759fb9 Don't constantly commit CA transactions when scrubbing video
Closes #488
2024-05-29 21:56:18 -07:00
Shadowfacts 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -07:00
Shadowfacts 09999175f7 Fix editing attachment descriptions not working on Pleroma 2024-05-27 22:29:11 -07:00
Shadowfacts f2a9f890ff Use development URLSession in more places 2024-05-27 22:14:28 -07:00
Shadowfacts 093994b474 More push subscription logging 2024-05-27 13:33:00 -07:00
Shadowfacts 3d0de5af04 Persist more state when switching accounts
Closes #486
2024-05-24 14:03:51 -04:00
Shadowfacts 966a906436 Fix AVPlayer periodic time observers not being removed 2024-05-23 14:29:56 -04:00
Shadowfacts 844d4056e3 Bump version and update changelog 2024-05-23 14:25:39 -04:00
Shadowfacts 00ef131bb6 Update HTMLStreamer 2024-05-23 14:12:35 -04:00
Shadowfacts d6be6f14dc Hide subscription section from tip jar when there are no products 2024-05-23 14:11:54 -04:00
Shadowfacts 2ccf028bc2 Bump build number and update changelog 2024-05-20 14:28:25 -04:00
Shadowfacts 3eeffada1f Add tip jar link to push notifications settings 2024-05-20 12:49:26 -04:00
Shadowfacts 0499255be7 Add tip jar subscription 2024-05-20 12:49:20 -04:00
Shadowfacts f909c1da10 Fix selecting follow request push notification
Closes #474
2024-05-19 15:14:03 -04:00
Shadowfacts 81543965ae Fix notification extension not building on visionOS 2024-05-19 15:00:47 -04:00
Shadowfacts 96d42756d5 Fix caption not displaying in gallery while image loading
Closes #476
2024-05-19 15:00:25 -04:00
Shadowfacts f6e57d664f Handle invalid date in Status created_at
Closes #477
2024-05-19 14:48:57 -04:00
Shadowfacts c33be1cbf3 Bump build number 2024-05-17 11:26:57 -04:00
Shadowfacts 6d99156bd9 Include badly formatted date in error message 2024-05-10 16:33:03 -04:00
Shadowfacts ca764811ed Bump build number and update changelog 2024-04-23 13:19:52 -04:00
Shadowfacts a589bb2863 Support emoji reaction push notifications on pleroma/akkoma 2024-04-18 13:17:55 -04:00
Shadowfacts 6f35fd2676 Show pleroma/akkoma emoji notifications
Closes #159
2024-04-18 12:59:44 -04:00
Shadowfacts e83cef1c8c Fix overzealously attempting to migrate local data to cloud store
Fix error when actually migrating due to not opening the store with
NSPersistentHistoryTrackingKey set to true.
2024-04-18 11:45:32 -04:00
Shadowfacts b89df3f27b Add instance announcements
Closes #356
2024-04-18 00:00:00 -04:00
Shadowfacts 4ecc16a93b Move FuzzyMatcher to TuskerComponents 2024-04-17 22:34:31 -04:00
Shadowfacts 8960873ff3 Remove redundant toastableViewController property 2024-04-17 22:34:31 -04:00
Shadowfacts 043a708515 Add more logging to onboarding VC 2024-04-17 17:04:32 -04:00
Shadowfacts c6b230414e Fix error decoding InstanceV2 response on certain instances 2024-04-17 10:18:01 -04:00
Shadowfacts f5e9f66f76 Fix app background colors not updating when preference changed
This only fully fixes it on iOS 17, but it seems to be the best we can do
2024-04-16 12:03:52 -04:00
Shadowfacts ee5f9a62ff Fix push subscription settings GroupBox background in dark mode
Closes #470
2024-04-16 11:37:36 -04:00
Shadowfacts a92cf8c812 Fix potential crash when hit testing StatusCollapseButton 2024-04-15 22:50:31 -04:00
Shadowfacts 756874949a Actually add notification extension privacy manifest to target 2024-04-15 22:42:18 -04:00
104 changed files with 2532 additions and 637 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,57 @@
# Changelog
## 2024.3 (127)
Bugfixes:
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
- Fix profile header images being blurry
- Fix dismissing gallery when presented from sheet
- Fix potential crash in multi-column interface
- Fix crash when opening push notification while sheet presented
- Fix being able to block your own domain
- Fix links in profile fields with other text not being interactable
- Fix excessive CPU use immediately after app launch
- Fix timeline failing to load when one status is malformed
- iPadOS: Fix pointer interactions on conversation main status action buttons
- iPadOS: Fix multiple close buttons being added in multi-column interface
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
- iPadOS: Fix profile followers/following buttons not having pointer effect
- iPadOS: Fix search token suggestions not having pointer effect
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
- iPadOS: Fix selecting search result always pushing new column rather than replacing
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
## 2024.3 (126)
Bugfixes:
- Fix an issue displaying post HTML in certain edge cases
- Fix crash when video attachment playback ends
- Fix excessive CPU usage when scrubbing video attachment
- Fix video attachment thubmnails being flipped on Compose screen
- Pleroma: Fix editing attachment descriptions not working
## 2024.2 (124)
Features/Improvements:
- Add subscription option to Tip Jar
Bugfixes:
- Fix attachment captions not displaying while loading in gallery
- Fix tapping follow request push notification not working
- Pleroma: Handle posts with missing creation dates
## 2024.2 (122)
Features/Improvements:
- Show instance announcements in Notifications
- Pleroma/Akkoma: Display emoji reactions in Notifications
- Pleroma/Akkoma: Add push notifications for emoji reactions
Bugfixes:
- Fix issue fetching server info on some instances
- Fix Preferences background color not updating after changing Pure Black Dark Mode
- Fix push subscription settings background using incorrect color with Pure Black Dark Mode off
## 2024.2 (121)
This build introduces a new multi-column navigation mode on iPad. You can revert to the old mode under Preferences -> Appearance.

View File

@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID
mutableContent.targetContentIdentifier = accountID
let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification)
@ -109,6 +110,12 @@ class NotificationService: UNNotificationServiceExtension {
kindStr = "📊 Poll finished"
case .update:
kindStr = "✏️ Edited"
case .emojiReaction:
if let emoji = notification.emoji {
kindStr = "\(emoji) Reacted"
} else {
kindStr = nil
}
default:
kindStr = nil
}

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
}
to.view.frame = container.bounds
// 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,26 @@ public final class InstanceFeatures: ObservableObject {
}
}
public var instanceAnnouncements: Bool {
hasMastodonVersion(3, 1, 0)
}
public var emojiReactionNotifications: Bool {
instanceType.isPleroma
}
public var muteNotifications: Bool {
!instanceType.isPixelfed
}
public var blockDomains: Bool {
!instanceType.isPixelfed
}
public var hideReblogs: Bool {
!instanceType.isPixelfed
}
public init() {
}
@ -330,6 +350,14 @@ extension InstanceFeatures {
return false
}
}
var isPixelfed: Bool {
if case .pixelfed = self {
return true
} else {
return false
}
}
}
@_spi(InstanceType) public enum MastodonType {

View File

@ -42,7 +42,7 @@ public struct Client: Sendable {
} else if let date = iso8601.date(from: str) {
return date
} else {
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
}
})
@ -204,8 +204,8 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
}
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range
return request
}
@ -456,14 +456,13 @@ public struct Client: Sendable {
}
// MARK: - Timelines
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
return timeline.request(range: range)
}
// MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
request.range = range
return request
}
@ -491,7 +490,7 @@ public struct Client: Sendable {
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
}
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
var parameters: [Parameter] = []
if let limit {
parameters.append("limit" => limit)

View File

@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
self.displayName = try container.decode(String.self, forKey: .displayName)
self.locked = try container.decode(Bool.self, forKey: .locked)
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
self.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url)
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return request
}
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia,
"pinned" => pinned,
"exclude_replies" => excludeReplies,

View File

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

View File

@ -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 init(mention: Bool, status: Bool, reblog: Bool, follow: Bool, followRequest: Bool, favourite: Bool, poll: Bool, update: Bool) {
public let emojiReaction: Bool
public init(
mention: Bool,
status: Bool,
reblog: Bool,
follow: Bool,
followRequest: Bool,
favourite: Bool,
poll: Bool,
update: Bool,
emojiReaction: Bool
) {
self.mention = mention
self.status = status
self.reblog = reblog
@ -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

@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
self.blocking = try container.decode(Bool.self, forKey: .blocking)
self.muting = try container.decode(Bool.self, forKey: .muting)
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
// not supported on pixelfed
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
// not supported on pixelfed
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
// not supported on pixelfed
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
}

View File

@ -10,7 +10,7 @@ import Foundation
public struct SearchResults: Decodable, Sendable {
public let accounts: [Account]
public let statuses: [Status]
public let statuses: [TryDecode<Status>]
public let hashtags: [Hashtag]
private enum CodingKeys: String, CodingKey {

View File

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

View File

@ -32,8 +32,8 @@ extension Timeline {
}
}
func request(range: RequestRange) -> Request<[Status]> {
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
if case .public(true) = self {
request.queryParameters.append("local" => true)
}

View File

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

View File

@ -0,0 +1,32 @@
//
// TryDecode.swift
// Pachyderm
//
// Created by Shadowfacts on 6/8/24.
//
import Foundation
public enum TryDecode<T: Decodable>: Decodable {
case error(String)
case value(T)
public init(from decoder: any Decoder) throws {
do {
self = .value(try T(from: decoder))
} catch {
self = .error(error.localizedDescription)
}
}
public var value: T? {
if case .value(let value) = self {
value
} else {
nil
}
}
}
extension TryDecode: Sendable where T: Sendable {
}

View File

@ -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 */; };
@ -251,6 +259,7 @@
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
@ -291,6 +300,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 +470,7 @@
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCachePersistentStore.swift; sourceTree = "<group>"; };
D6114E0C27F7FEB30080E273 /* TrendingStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingStatusesViewController.swift; sourceTree = "<group>"; };
D6114E1027F899B30080E273 /* TrendingLinksViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingLinksViewController.swift; sourceTree = "<group>"; };
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowRequestNotificationViewController.swift; sourceTree = "<group>"; };
D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = "<group>"; };
D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = "<group>"; };
@ -497,6 +508,7 @@
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
@ -649,6 +661,12 @@
D69693F925859A8000F4E116 /* ComposeSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeSceneDelegate.swift; sourceTree = "<group>"; };
D6969E9D240C81B9002843CE /* NSTextAttachment+Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTextAttachment+Emoji.swift"; sourceTree = "<group>"; };
D6969E9F240C8384002843CE /* EmojiLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiLabel.swift; sourceTree = "<group>"; };
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsHostingController.swift; sourceTree = "<group>"; };
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsView.swift; sourceTree = "<group>"; };
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementListRow.swift; sourceTree = "<group>"; };
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnnouncementsCollection.swift; sourceTree = "<group>"; };
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddReactionView.swift; sourceTree = "<group>"; };
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnnouncementContentTextView.swift; sourceTree = "<group>"; };
D6A00B1C26379FC900316AD4 /* PollOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionsView.swift; sourceTree = "<group>"; };
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderButton.swift; sourceTree = "<group>"; };
D6A3A3812956123A0036B6EF /* TimelinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelinePosition.swift; sourceTree = "<group>"; };
@ -670,6 +688,7 @@
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1034,6 +1053,7 @@
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
);
path = CoreData;
sourceTree = "<group>";
@ -1054,6 +1074,7 @@
children = (
D65B4B89297879DE00DABDFB /* Account Follows */,
D6A3BC822321F69400FD64D5 /* Account List */,
D698F4472BCEE2320054DB14 /* Announcements */,
D641C787213DD862004B4513 /* Compose */,
D641C785213DD83B004B4513 /* Conversation */,
D6F2E960249E772F005846BB /* Crash Reporter */,
@ -1150,6 +1171,7 @@
D646DCD92A081A2C0059ECEB /* PollFinishedNotificationCollectionViewCell.swift */,
D646DCDB2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift */,
D68245112BCA1F4000AFB38B /* NotificationLoadingViewController.swift */,
D6187BEC2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -1349,6 +1371,19 @@
path = About;
sourceTree = "<group>";
};
D698F4472BCEE2320054DB14 /* Announcements */ = {
isa = PBXGroup;
children = (
D698F4662BD079800054DB14 /* AnnouncementsHostingController.swift */,
D698F46C2BD0B8310054DB14 /* AnnouncementsCollection.swift */,
D698F4682BD0799F0054DB14 /* AnnouncementsView.swift */,
D698F46A2BD079F00054DB14 /* AnnouncementListRow.swift */,
D698F4702BD0CBAA0054DB14 /* AnnouncementContentTextView.swift */,
D698F46E2BD0B8DF0054DB14 /* AddReactionView.swift */,
);
path = Announcements;
sourceTree = "<group>";
};
D6A3BC822321F69400FD64D5 /* Account List */ = {
isa = PBXGroup;
children = (
@ -1727,6 +1762,7 @@
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
);
path = API;
sourceTree = "<group>";
@ -1954,6 +1990,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D6C453372BCE1CEF00E26A0E /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -2129,6 +2166,7 @@
D6934F3C2BAA0F80002B1C8D /* VideoGalleryContentViewController.swift in Sources */,
D6F6A552291F098700F496A8 /* RenameListService.swift in Sources */,
D62D2426217ABF63005076CC /* UserActivityType.swift in Sources */,
D698F46B2BD079F00054DB14 /* AnnouncementListRow.swift in Sources */,
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */,
D6311C5025B3765B00B27539 /* ImageDataCache.swift in Sources */,
D6934F362BA8E020002B1C8D /* GifvGalleryContentViewController.swift in Sources */,
@ -2159,8 +2197,10 @@
D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */,
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D698F4692BD0799F0054DB14 /* AnnouncementsView.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D698F46F2BD0B8DF0054DB14 /* AddReactionView.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,
D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */,
@ -2188,6 +2228,7 @@
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
D6114E0D27F7FEB30080E273 /* TrendingStatusesViewController.swift in Sources */,
D6BC9DDA232D8BE5002CA326 /* SearchResultsViewController.swift in Sources */,
D698F4672BD079800054DB14 /* AnnouncementsHostingController.swift in Sources */,
D61ABEF628EE74D400B29151 /* StatusCollectionViewCell.swift in Sources */,
D65B4B6629773AE600DABDFB /* DeleteStatusService.swift in Sources */,
D61DC84628F498F200B82C6E /* Logging.swift in Sources */,
@ -2202,6 +2243,7 @@
D65A261D2BC39399005EB5D8 /* PushInstanceSettingsView.swift in Sources */,
D65C6BF525478A9C00A6E89C /* BackgroundableViewController.swift in Sources */,
D61DC84D28F500D200B82C6E /* ProfileViewController.swift in Sources */,
D6187BED2BFA840B00B3A281 /* FollowRequestNotificationViewController.swift in Sources */,
D600891F29848DE2005B4D00 /* AddInstancePinnedTimelineView.swift in Sources */,
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */,
D627943223A5466600D38C68 /* SelectableTableViewCell.swift in Sources */,
@ -2323,8 +2365,10 @@
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */,
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
@ -2367,9 +2411,11 @@
D6934F2E2BA7AEF9002B1C8D /* StatusAttachmentsGalleryDataSource.swift in Sources */,
D623A543263634100095BD04 /* PollOptionCheckboxView.swift in Sources */,
D6C4532F2BCB873400E26A0E /* MockStatusView.swift in Sources */,
D698F4712BD0CBAA0054DB14 /* AnnouncementContentTextView.swift in Sources */,
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
);
@ -3203,7 +3249,7 @@
repositoryURL = "https://git.shadowfacts.net/shadowfacts/HTMLStreamer.git";
requirement = {
kind = exactVersion;
version = 0.2.3;
version = 0.2.4;
};
};
D63CC700290EC0B8000E19DE /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = {

View File

@ -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))")
@ -289,12 +290,18 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
// if the scene is already active, then we animate the account switching if necessary
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
rootViewController.select(route: .notifications, animated: false)
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
rootViewController.select(route: .notifications, animated: false) {
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false)
}
} else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
if #available(iOS 17.0, *) {
let request = UISceneSessionActivationRequest(userActivity: activity)
UIApplication.shared.activateSceneSession(for: request)
} else {
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
}
}
completionHandler()
}

View File

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

View File

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

After

Width:  |  Height:  |  Size: 22 KiB

View File

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

View File

@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return context
}()
private var lastRemoteChangeToken: NSPersistentHistoryToken?
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
// would need to audit existing uses to make sure everything happens on the main thread
// and when updating things on the background context would need to switch to main, refetch, and then publish
@ -124,12 +122,13 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
// migrate saved data from local store to cloud store
// this can be removed pre-app store release
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
if FileManager.default.fileExists(atPath: defaultPath.path) {
if !FileManager.default.fileExists(atPath: cloudStoreLocation.path) {
group.enter()
var defaultPath = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
defaultPath.appendPathComponent("\(accountInfo!.persistenceKey)_cache.sqlite", isDirectory: false)
let defaultDesc = NSPersistentStoreDescription(url: defaultPath)
defaultDesc.configuration = "Default"
defaultDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
let defaultPSC = NSPersistentContainer(name: "\(accountInfo!.persistenceKey)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel)
defaultPSC.persistentStoreDescriptions = [defaultDesc]
defaultPSC.loadPersistentStores { _, error in
@ -189,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
viewContext.name = "View"
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
if accountInfo != nil {
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
}
}
func save(context: NSManagedObjectContext) {
@ -520,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
}
@objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
guard let accountInfo,
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return
}
remoteChangesBackgroundContext.perform {
defer {
self.lastRemoteChangeToken = token
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
self.remoteChangesBackgroundContext.perform {
defer {
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
}
let transactions: [NSPersistentHistoryTransaction]
do {
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
} else {
logger.error("Unexpectedly non-NSPersistentHistoryResult")
return
}
} catch {
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
return
}
if !transactions.isEmpty {
self.processPersistentHistoryTransactions(transactions)
}
// NB: We deliberately do not purge old persistent history.
// Doing so causes the CoreData+CloudKit integration to replay all of
// the server's changes on initialization, which takes a long time
// and produces a bunch of intermediate UI updates we don't want.
}
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
let transactions = result.result as? [NSPersistentHistoryTransaction],
!transactions.isEmpty {
var changedHashtags = false
var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false
outer: for transaction in transactions {
for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" {
changedTimelinePositions.insert(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true
}
}
}
}
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
logger.info("Processing \(transactions.count) persistent history transactions")
var changedHashtags = false
var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false
outer: for transaction in transactions {
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true
} else if change.changedObjectID.entity.name == "SavedInstance" {
changedInstances = true
} else if change.changedObjectID.entity.name == "TimelinePosition" {
changedTimelinePositions.insert(change.changedObjectID)
} else if change.changedObjectID.entity.name == "AccountPreferences" {
changedAccountPrefs = true
}
// Can't capture vars in concurrently-executing closure
let hashtags = changedHashtags
let instances = changedInstances
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async {
if hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
for id in timelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
}
}
// Can't capture vars in concurrently-executing closure
let hashtags = changedHashtags
let instances = changedInstances
let timelinePositions = changedTimelinePositions
let accountPrefs = changedAccountPrefs
DispatchQueue.main.async {
if hashtags {
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
}
if instances {
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
}
for id in timelinePositions {
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
continue
}
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
}
}

View File

@ -0,0 +1,47 @@
//
// PersistentHistoryTokenStore.swift
// Tusker
//
// Created by Shadowfacts on 6/8/24.
// Copyright © 2024 Shadowfacts. All rights reserved.
//
import Foundation
import CoreData
import UserAccounts
struct PersistentHistoryTokenStore {
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
private static func load() throws -> [String: NSPersistentHistoryToken]? {
let data = try Data(contentsOf: storeURL)
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
return unarchived as? [String: NSPersistentHistoryToken]
}
private static func save() throws {
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
try data.write(to: PersistentHistoryTokenStore.storeURL)
}
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
queue.async {
completion(tokens[account.id])
}
}
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
queue.async {
tokens[account.id] = token
try? save()
}
}
private init() {}
}

View File

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

View File

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

View File

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

View File

@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
}
launchActivity = activity
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
let account: UserAccountInfo
if let activityAccount = UserActivityManager.getAccount(from: activity) {
account = activityAccount
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {

View File

@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
return
}
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
let account: UserAccountInfo
let controller: MastodonController
let draft: Draft?

View File

@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
}
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
Task(priority: .userInitiated) {
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
}
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -191,8 +193,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
Task(priority: .userInitiated) {
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
}
}
if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
window!.windowScene!.title = account.instanceURL.host!
}
let newRoot = createAppUI()
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id)
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
let direction: AccountSwitchingContainerViewController.AnimationDirection
if animated,
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else {
direction = .none
}
container.setRoot(newRoot, for: account, animating: direction)
container.setRoot(createAppUI, for: account, animating: direction)
} else {
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
}
}
@ -248,6 +253,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
LogoutService(accountInfo: account).run()
if UserAccountsManager.shared.onboardingComplete {
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
container.removeAccount(account)
}
} else {
window!.rootViewController = createOnboardingUI()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -222,7 +222,7 @@ class ConversationViewController: UIViewController {
}
}
if isLikelyMastodonRemoteStatus(url: url),
let (_, response) = try? await URLSession.shared.data(from: url, delegate: RedirectBlocker()),
let (_, response) = try? await URLSession.appDefault.data(from: url, delegate: RedirectBlocker()),
let location = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "location") {
effectiveURL = location
} else {
@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do {
let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
guard let status = results.statuses.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else {
throw UnableToResolveError()
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)

View File

@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController!
searchController = MastodonSearchController(searchResultsController: resultsController)
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
definesPresentationContext = true
navigationItem.searchController = searchController

View File

@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
resultsController = SearchResultsViewController(mastodonController: mastodonController)
resultsController.exploreNavigationController = self.navigationController
searchController = MastodonSearchController(searchResultsController: resultsController)
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
searchController.obscuresBackgroundDuringPresentation = true
searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true

View File

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

View File

@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
private func loadTrendingStatuses() async {
let statuses: [Status]
do {
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
} catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await MainActor.run {

View File

@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
let linksReq = Client.getTrendingLinks(limit: 10)
async let links = try? mastodonController.run(linksReq).0
let statusesReq = Client.getTrendingStatuses(limit: 10)
async let statuses = try? mastodonController.run(statusesReq).0
async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
if let links = await links {
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
do {
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await mastodonController.persistentContainer.addAll(statuses: statuses)
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
@MainActor
private func removeProfileSuggestion(accountID: String) async {
let req = Suggestion.remove(accountID: accountID)
do {
_ = try await mastodonController.run(req)
var snapshot = dataSource.snapshot()
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
guard let self else { return }
var snapshot = self.dataSource.snapshot()
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
snapshot.deleteItems([.account(accountID, .global)])
await apply(snapshot: snapshot)
} catch {
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
_ = await self.removeProfileSuggestion(accountID: accountID)
}
self.showToast(configuration: config, animated: true)
self.dataSource.apply(snapshot, animatingDifferences: true)
}
await service.run()
}
}

View File

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

View File

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

View File

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

View File

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

@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
let mastodonController: MastodonController
private let predicate: (StatusMO) -> Bool
private let predicateTitle: String
private let request: (RequestRange) -> Request<[Status]>
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
var collectionView: UICollectionView! {
view as? UICollectionView
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
private var newer: RequestRange?
private var older: RequestRange?
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
self.mastodonController = mastodonController
self.predicate = predicate
self.predicateTitle = predicateTitle
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do {
let req = request(.count(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
newer = pagination?.newer
older = pagination?.older
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do {
let req = request(older.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
self.older = pagination?.older
await mastodonController.persistentContainer.addAll(statuses: statuses)
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
Task {
do {
let req = request(newer.withCount(Self.pageSize))
let (statuses, pagination) = try await mastodonController.run(req)
let (tryStatuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
self.newer = pagination?.newer
await mastodonController.persistentContainer.addAll(statuses: statuses)

View File

@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
private(set) var currentAccountID: String
private(set) var root: AccountSwitchableViewController
private var userActivities: [String: NSUserActivity] = [:]
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(root)
}
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
viewControllers = viewControllers.mapValues { (_, activity) in
(nil, activity)
}
}
func removeAccount(_ account: UserAccountInfo) {
viewControllers.removeValue(forKey: account.id)
}
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
let oldRoot = self.root
if direction == .none {
oldRoot.removeViewAndController()
}
if let activity = oldRoot.stateRestorationActivity() {
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
userActivities[currentAccountID] = activity
viewControllers[currentAccountID] = (oldRoot, activity)
}
let newRoot: AccountSwitchableViewController
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
if let existingRoot {
newRoot = existingRoot
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
} else {
newRoot = newRootProvider()
Task(priority: .userInitiated) {
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
context.finalize(activity: activity)
}
}
} else {
newRoot = newRootProvider()
}
self.currentAccountID = account.id
self.root = newRoot
embedChild(newRoot)
if let activity = userActivities.removeValue(forKey: account.id) {
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
context.finalize(activity: activity)
}
if direction != .none {
if UIAccessibility.prefersCrossFadeTransitions {
newRoot.view.alpha = 0
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
#endif
// only one edge is affected in each direction, i have no idea why
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
if direction == .upwards {
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
} else {
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
newRoot.view.transform = .identity
} completion: { (_) in
oldRoot.view.transform = .identity
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
oldRoot.removeViewAndController()
newRoot.view.layer.masksToBounds = false
}
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.compose(editing: draft, animated: animated, isDucked: isDucked)
}
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
loadViewIfNeeded()
root.select(route: route, animated: animated)
root.select(route: route, animated: animated, completion: completion)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
(child as! TuskerRootViewController).getNavigationController()
}
func select(route: TuskerRoute, animated: Bool) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

@ -13,8 +13,8 @@ import Combine
@MainActor
protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
}
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
return items
}
private(set) var previouslySelectedItem: Item?
private var previouslySelectedItem: Item?
var selectedItem: Item? {
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
return nil
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
}
private func returnToPreviousItem() {
let item = previouslySelectedItem ?? .tab(.timelines)
let oldItem = selectedItem
let newItem = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil
select(item: item, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: item)
select(item: newItem, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
}
private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in
let oldItem = self.selectedItem
self.select(item: .list(list), animated: false)
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
list.presentEditOnAppear = true
self.sidebarDelegate?.sidebar(self, showViewController: list)
self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
}
service.run()
}
@ -471,7 +473,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
fatalError("unreachable")
}
} else {
sidebarDelegate?.sidebar(self, didSelectItem: item)
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
}
}
@ -540,8 +542,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) {
dismiss(animated: true) {
let oldItem = self.selectedItem
self.select(item: .savedInstance(url), animated: true)
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url), previousItem: oldItem)
}
}

View File

@ -86,7 +86,7 @@ class MainSplitViewController: UISplitViewController {
// don't unnecesarily construct a content VC unless the we're in actually split mode
// when we change from compact -> split for the first time, the VC will be transferred anyways
if traitCollection.horizontalSizeClass != .compact {
select(item: .tab(.timelines))
doSelect(item: .tab(.timelines))
}
if UIDevice.current.userInterfaceIdiom != .mac {
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
self.setViewController(newNav, for: .secondary)
}
func select(item: MainSidebarViewController.Item) {
private func select(newItem: MainSidebarViewController.Item, oldItem: MainSidebarViewController.Item?) {
if let oldItem,
newItem != oldItem {
navigationStacks[oldItem] = secondaryNavController.viewControllers
}
doSelect(item: newItem)
}
private func doSelect(item: MainSidebarViewController.Item) {
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
}
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
}
@objc func handleSidebarCommandTimelines() {
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.timelines), animated: false)
select(item: .tab(.timelines))
}
@objc func handleSidebarCommandNotifications() {
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.notifications), animated: false)
select(item: .tab(.notifications))
}
@objc func handleSidebarCommandExplore() {
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.explore), animated: false)
select(item: .tab(.explore))
}
@objc func handleSidebarCommandBookmarks() {
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
sidebar.select(item: .bookmarks, animated: false)
select(item: .bookmarks)
}
@objc func handleSidebarCommandMyProfile() {
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.myProfile), animated: false)
select(item: .tab(.myProfile))
}
@objc private func sidebarTapped() {
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// These tabs map 1 <-> 1 with sidebar items
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
sidebar.select(item: item, animated: false)
select(item: item)
doSelect(item: item)
case .explore:
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
sidebar.select(item: exploreItem!, animated: false)
select(item: exploreItem!)
doSelect(item: exploreItem!)
default:
return
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
compose(editing: nil)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
select(newItem: item, oldItem: previousItem)
}
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
if let previousItem {
navigationStacks[previousItem] = secondaryNavController.viewControllers
}
secondaryNavController.viewControllers = [viewController]
}
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
}
extension MainSplitViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated)
tabBarViewController?.select(route: route, animated: animated, completion: completion)
return
}
guard presentedViewController == nil else {
dismiss(animated: animated) {
self.select(route: route, animated: animated)
self.select(route: route, animated: animated, completion: completion)
}
return
}
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
return
}
}
let oldItem = sidebar.selectedItem
sidebar.select(item: item, animated: false)
select(item: item)
select(newItem: item, oldItem: oldItem)
completion?()
}
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -610,7 +617,7 @@ extension MainSplitViewController: TuskerRootViewController {
}
if sidebar.selectedItem != .explore {
select(item: .explore)
select(newItem: .explore, oldItem: sidebar.selectedItem)
}
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {

View File

@ -289,7 +289,7 @@ extension MainTabBarViewController: StateRestorableViewController {
}
extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
switch route {
case .timelines:
select(tab: .timelines, dismissPresented: true)
@ -310,6 +310,7 @@ extension MainTabBarViewController: TuskerRootViewController {
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
}
}
completion?()
}
func getNavigationDelegate() -> TuskerNavigationDelegate? {

View File

@ -12,7 +12,7 @@ import ComposeUI
@MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
func select(route: TuskerRoute, animated: Bool)
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func getNavigationDelegate() -> TuskerNavigationDelegate?
func getNavigationController() -> NavigationControllerProtocol
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
}
//extension TuskerRootViewController {
// func select(route: NewRoute, animated: Bool) {
// doApply(components: route.components, animated: animated)
// }
//
// private func doApply(components: ArraySlice<RouteComponent>, animated: Bool) {
// guard let first = components.first else {
// return
// }
// doApply(component: first, animated: animated) {
// self.doApply(components: components.dropFirst(), animated: animated)
// }
// }
//
// private func doApply(component: RouteComponent, animated: Bool, completion: @escaping () -> Void) {
// switch component {
// case .topLevelItem(let rootRoute):
// select(route: rootRoute)
// completion()
// case .popToRoot:
// _ = getNavigationController().popToRootViewController(animated: animated)
// completion()
// case .push(<#T##(MastodonController) -> UIViewController#>)
// }
// }
//}
enum TuskerRoute {
case timelines
case notifications
@ -57,33 +30,6 @@ enum TuskerRoute {
case list(id: String)
}
//struct NewRoute: ExpressibleByArrayLiteral {
// let components: [RouteComponent]
//
// init(arrayLiteral elements: RouteComponent...) {
// self.components = elements
// }
//
// static var timelines: Self { [.topLevelItem(.timelines)] }
// static var explore: Self { [.topLevelItem(.explore)] }
// static var myProfile: Self { [.topLevelItem(.myProfile)] }
// static var bookmarks: Self { [.topLevelItem(.explore), .push({ BookmarksViewController(mastodonController: $0) })] }
// static func profile(accountID: String) -> Self { [.topLevelItem(.timelines), .push({ ProfileViewController(accountID: accountID, mastodonController: $0) })] }
//}
//
//enum RouteComponent {
// case topLevelItem(RootRoute)
// case popToRoot
// case push((MastodonController) -> UIViewController)
// case present(UIViewController)
//}
//
//enum RootRoute {
// case timelines
// case explore
// case myProfile
//}
//
@MainActor
protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set }

View File

@ -78,18 +78,20 @@ struct MuteAccountView: View {
}
.accessibilityHidden(true)
Section {
Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person")
}
} footer: {
if muteNotifications {
Text("This user's posts and notifications will be hidden.")
} else {
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
if mastodonController.instanceFeatures.muteNotifications {
Section {
Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person")
}
} footer: {
if muteNotifications {
Text("This user's posts and notifications will be hidden.")
} else {
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
}
}
.appGroupedListRowBackground()
}
.appGroupedListRowBackground()
Section {
Picker(selection: $duration) {

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),
vStack.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8),
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: 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!")
.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 !supporterProducts.isEmpty {
supporterSubscriptions
}
if let total = getTotalTips(), total > 0 {
Text("You've tipped a total of \(Text(total, format: products[0].0.priceFormatStyle)) 😍")
tipPurchases
if let tipStatus {
tipStatus
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.top, 16)
Text("Thank you!")
}
}
@ -95,19 +104,92 @@ 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
}
switch verificationResult {
case .verified(let transaction):
await transaction.finish()
self.products[index].1 = false
self.showConfetti = true
case .unverified(_, let error):
self.error = .verifyingTransaction(error)
if let index = tipProducts.firstIndex(where: { $0.0.id == verificationResult.unsafePayloadValue.productID }) {
switch verificationResult {
case .verified(let transaction):
await transaction.finish()
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,25 +231,11 @@ private struct TipRow: View {
Text(product.displayName)
.alignmentGuide(.myAlignment, computeValue: { context in context[.trailing] })
Button {
Task {
await self.purchase()
}
} label: {
if isPurchasing {
ProgressView()
.progressViewStyle(.circular)
.frame(width: buttonWidth, alignment: .center)
} else {
Text(product.displayPrice)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ButtonWidthKey.self, value: proxy.size.width)
})
.frame(width: buttonWidth)
}
if let subscription = product.subscription {
SubscriptionButton(product: product, subscriptionInfo: subscription, isPurchasing: $isPurchasing, buttonWidth: buttonWidth, purchase: purchase)
} else {
tipButton
}
.buttonStyle(.borderedProminent)
}
.alertWithData("Error", data: $error) { _ in
Button("OK") {}
@ -176,6 +244,28 @@ private struct TipRow: View {
}
}
private var tipButton: some View {
Button {
Task {
await self.purchase()
}
} label: {
if isPurchasing {
ProgressView()
.progressViewStyle(.circular)
.frame(width: buttonWidth, alignment: .center)
} else {
Text(product.displayPrice)
.background(GeometryReader { proxy in
Color.clear
.preference(key: ButtonWidthKey.self, value: proxy.size.width)
})
.frame(width: buttonWidth)
}
}
.buttonStyle(.borderedProminent)
}
@MainActor
private func purchase() async {
isPurchasing = true
@ -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

@ -9,6 +9,12 @@
import UIKit
import Pachyderm
import Combine
import OSLog
#if canImport(Sentry)
import Sentry
#endif
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ProfileStatusesViewController")
class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
@ -250,9 +256,16 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
state = .setupInitialSnapshot
Task {
if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
let relationship = all.first {
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
do {
let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID]))
if let relationship = all.first {
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
} catch {
logger.error("Error fetching relationship: \(String(describing: error))")
#if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
}
}
@ -297,7 +310,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
}
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -513,7 +526,7 @@ extension ProfileStatusesViewController {
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
typealias TimelineItem = String // status ID
private func request(for range: RequestRange = .default) -> Request<[Status]> {
private func request(for range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
switch kind {
case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
@ -526,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [String] {
let request = request()
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil)
@ -546,7 +559,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
}
let request = request(for: newer)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else {
throw Error.allCaughtUp
@ -567,7 +580,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
}
let request = request(for: older)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else {
return []

View File

@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
.task { @MainActor in
do {
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
let (statuses, _) = try await mastodonController.run(req)
let statuses = try await mastodonController.run(req).0.compactMap(\.value)
await mastodonController.persistentContainer.addAll(statuses: statuses)
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
} catch {

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

@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
super.searchResultsController as! SearchResultsViewController
}
init(searchResultsController: SearchResultsViewController) {
private weak var owner: UIViewController?
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
self.owner = owner
super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
}
}
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? {
owner
}
}
extension UISearchBar {
var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }

View File

@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
guard self.currentQuery == query else { return }
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
addAccounts(results.accounts)
addStatuses(results.statuses)
addStatuses(results.statuses.compactMap(\.value))
} completion: {
DispatchQueue.main.async {
self.showSearchResults(results)
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
}
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.statuses])
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
snapshot.appendItems(results.statuses.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses)
}
dataSource.apply(snapshot)

View File

@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
])
addInteraction(UIPointerInteraction(delegate: self))
}
required init?(coder: NSCoder) {
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.text = text
}
}
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: self)
return UIPointerStyle(effect: .lift(preview))
}
}

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

@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
do {
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
// TODO: consider replacing undecodable statuses here with items to indicate that to the user
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0.compactMap(\.value)
let allStatuses = try await [status] + olderStatuses
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [TimelineItem] {
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: newer)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else {
throw TimelineViewController.Error.allCaughtUp
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else {
return []
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
}
let request = Client.getStatuses(timeline: timeline, range: range)
let (statuses, _) = try await mastodonController.run(request)
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
guard !statuses.isEmpty else {
return []

View File

@ -8,17 +8,28 @@
import UIKit
/// View controllers, such as `UISearchController`, that live outside the normal VC hierarchy
/// can adopt this protocol to indicate to `MultiColumnNavigationController` the context for
/// navigation operations.
protocol MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? { get }
}
class MultiColumnNavigationController: UIViewController {
private var isManuallyUpdating = false
var viewControllers: [UIViewController] = [] {
didSet {
guard isViewLoaded,
!isManuallyUpdating else {
return
private var _viewControllers: [UIViewController] = []
var viewControllers: [UIViewController] {
get {
_viewControllers
}
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
updateViews()
scrollToEnd(animated: false)
}
updateViews()
scrollToEnd(animated: false)
}
}
@ -68,13 +79,13 @@ class MultiColumnNavigationController: UIViewController {
private func updateViews() {
var i = 0
while i < viewControllers.count {
while i < _viewControllers.count {
let needsCloseButton = i > 0
if i <= stackView.arrangedSubviews.count - 1 {
let existing = stackView.arrangedSubviews[i] as! ColumnView
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
} else {
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
stackView.addArrangedSubview(new)
}
i += 1
@ -92,9 +103,11 @@ class MultiColumnNavigationController: UIViewController {
var index: Int? = nil
var current: UIViewController? = sender
while let c = current {
index = viewControllers.firstIndex(of: c)
index = _viewControllers.firstIndex(of: c)
if index != nil {
break
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
current = targetProviding.multiColumnNavigationTargetViewController
} else {
current = c.parent
}
@ -112,19 +125,20 @@ class MultiColumnNavigationController: UIViewController {
}
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
if afterIndex == viewControllers.count - 1 && vcs.count == 1 {
if afterIndex == _viewControllers.count - 1 && vcs.count == 1 {
pushViewController(vcs[0], animated: animated)
} else {
viewControllers = Array(viewControllers[...afterIndex]) + vcs
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
updateViews()
scrollToEnd(animated: animated)
}
}
private func scrollToEnd(animated: Bool) {
if viewControllers.isEmpty {
if _viewControllers.isEmpty {
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
} else {
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
}
}
@ -142,14 +156,12 @@ class MultiColumnNavigationController: UIViewController {
}
fileprivate func closeColumn(_ vc: UIViewController) {
let index = viewControllers.firstIndex(of: vc)!
guard index > 0 else {
guard let index = _viewControllers.firstIndex(of: vc),
index > 0 else {
// Can't close the last column
return
}
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.removeSubrange(index...)
_viewControllers.removeSubrange(index...)
animateChanges {
for column in self.stackView.arrangedSubviews[index...] {
column.layer.opacity = 0
@ -158,7 +170,6 @@ class MultiColumnNavigationController: UIViewController {
} completion: {
self.updateViews()
}
}
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
@ -173,19 +184,22 @@ class MultiColumnNavigationController: UIViewController {
extension MultiColumnNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? {
viewControllers.last
_viewControllers.last
}
func popToRootViewController(animated: Bool) -> [UIViewController]? {
let removed = Array(viewControllers.dropFirst())
viewControllers = [viewControllers.first!]
guard !_viewControllers.isEmpty else {
return nil
}
let removed = Array(_viewControllers.dropFirst())
_viewControllers = [_viewControllers.first!]
updateViews()
scrollToEnd(animated: animated)
return removed
}
func pushViewController(_ vc: UIViewController, animated: Bool) {
isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.append(vc)
_viewControllers.append(vc)
updateViews()
scrollToEnd(animated: animated)
if animated {
@ -273,12 +287,17 @@ private class ColumnView: UIView {
}
private func installCloseBarButton(navigationItem: UINavigationItem) {
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)
func makeItem() -> UIBarButtonItem {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
item.accessibilityLabel = "Close Column"
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
@ -562,11 +557,15 @@ extension MenuActionProvider {
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
} else {
let image = UIImage(systemName: "circle.slash")
return UIMenu(title: "Block", image: image, children: [
var children = [
UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
])
]
if mastodonController.instanceFeatures.blockDomains,
host != mastodonController.account?.url.host {
children.append(UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true)))
}
return UIMenu(title: "Block", image: image, children: children)
}
}
@ -597,7 +596,8 @@ extension MenuActionProvider {
@MainActor
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
// don't show action for people that the user isn't following and isn't already hiding reblogs for
guard relationship.following || relationship.showingReblogs else {
guard relationship.following || relationship.showingReblogs,
mastodonController.instanceFeatures.hideReblogs else {
return nil
}
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"

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

@ -44,9 +44,9 @@ enum AppShortcutItem: String, CaseIterable {
}
switch self {
case .showHomeTimeline:
root.select(route: .timelines, animated: false)
root.select(route: .timelines, animated: false, completion: nil)
case .showNotifications:
root.select(route: .notifications, animated: false)
root.select(route: .notifications, animated: false, completion: nil)
case .composePost:
root.compose(editing: nil, animated: false, isDucked: false)
}

View File

@ -39,12 +39,13 @@ extension NSUserActivity {
self.userInfo = [
"accountID": accountID
]
self.targetContentIdentifier = accountID
}
@MainActor
func handleResume(manager: UserActivityManager) -> Bool {
func handleResume(manager: UserActivityManager) async -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false }
type.handle(manager)(self)
await type.handle(manager)(self)
return true
}

View File

@ -16,7 +16,8 @@ import ComposeUI
protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
func select(route: TuskerRoute)
func select(route: TuskerRoute) async
func select(route: TuskerRoute, completion: (() -> Void)?)
func present(_ vc: UIViewController)
var topViewController: UIViewController? { get }
@ -28,6 +29,16 @@ protocol UserActivityHandlingContext {
func finalize(activity: NSUserActivity)
}
extension UserActivityHandlingContext {
func select(route: TuskerRoute) async {
await withCheckedContinuation { continuation in
select(route: route) {
continuation.resume()
}
}
}
}
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
let root: TuskerRootViewController
@ -35,8 +46,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
root.getNavigationDelegate()!
}
func select(route: TuskerRoute) {
root.select(route: route, animated: true)
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: true, completion: completion)
}
func present(_ vc: UIViewController) {
@ -71,9 +82,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
var isHandoff: Bool { false }
func select(route: TuskerRoute) {
root.select(route: route, animated: false)
state = .selectedRoute
func select(route: TuskerRoute, completion: (() -> Void)?) {
root.select(route: route, animated: false) {
self.state = .selectedRoute
completion?()
}
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }

View File

@ -133,8 +133,8 @@ class UserActivityManager {
return activity
}
func handleCheckNotifications(activity: NSUserActivity) {
context.select(route: .notifications)
func handleCheckNotifications(activity: NSUserActivity) async {
await context.select(route: .notifications)
context.popToRoot()
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
notificationsPageController.loadViewIfNeeded()
@ -204,22 +204,22 @@ class UserActivityManager {
return (timeline, positionInfo)
}
func handleShowTimeline(activity: NSUserActivity) {
func handleShowTimeline(activity: NSUserActivity) async {
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
var timelineVC: TimelineViewController?
if let pinned = PinnedTimeline(timeline: timeline),
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
context.select(route: .timelines)
await context.select(route: .timelines)
context.popToRoot()
let pageController = context.topViewController as! TimelinesPageViewController
pageController.selectTimeline(pinned, animated: false)
timelineVC = pageController.currentViewController as? TimelineViewController
} else if case .list(let id) = timeline {
context.select(route: .list(id: id))
await context.select(route: .list(id: id))
timelineVC = context.topViewController as? TimelineViewController
} else {
context.select(route: .explore)
await context.select(route: .explore)
context.popToRoot()
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timelineVC!)
@ -249,11 +249,11 @@ class UserActivityManager {
return activity.userInfo?["mainStatusID"] as? String
}
func handleShowConversation(activity: NSUserActivity) {
func handleShowConversation(activity: NSUserActivity) async {
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return
}
context.select(route: .timelines)
await context.select(route: .timelines)
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
}
@ -274,8 +274,8 @@ class UserActivityManager {
return activity.userInfo?["query"] as? String
}
func handleSearch(activity: NSUserActivity) {
context.select(route: .explore)
func handleSearch(activity: NSUserActivity) async {
await context.select(route: .explore)
context.popToRoot()
let searchController: UISearchController
@ -311,8 +311,8 @@ class UserActivityManager {
return activity
}
func handleBookmarks(activity: NSUserActivity) {
context.select(route: .bookmarks)
func handleBookmarks(activity: NSUserActivity) async {
await context.select(route: .bookmarks)
}
// MARK: - My Profile
@ -325,8 +325,8 @@ class UserActivityManager {
return activity
}
func handleMyProfile(activity: NSUserActivity) {
context.select(route: .myProfile)
func handleMyProfile(activity: NSUserActivity) async {
await context.select(route: .myProfile)
}
// MARK: - Show Profile
@ -344,11 +344,11 @@ class UserActivityManager {
return activity.userInfo?["profileID"] as? String
}
func handleShowProfile(activity: NSUserActivity) {
func handleShowProfile(activity: NSUserActivity) async {
guard let accountID = Self.getProfile(from: activity) else {
return
}
context.select(route: .timelines)
await context.select(route: .timelines)
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
}
@ -361,11 +361,11 @@ class UserActivityManager {
return activity
}
func handleShowNotification(activity: NSUserActivity) {
func handleShowNotification(activity: NSUserActivity) async {
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
return
}
context.select(route: .notifications)
await context.select(route: .notifications)
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
}

View File

@ -23,7 +23,7 @@ enum UserActivityType: String {
extension UserActivityType {
@MainActor
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
switch self {
case .mainScene:
fatalError("cannot handle main scene activity")

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

@ -36,6 +36,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label
private let tapRecognizer = UITapGestureRecognizer()
// The link range currently being previewed
private var currentPreviewedLinkRange: NSRange?
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
@ -78,8 +80,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
updateLinkUnderlineStyle()
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer)
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
tapRecognizer.delegate = self
addGestureRecognizer(tapRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
underlineTextLinksCancellable =
@ -132,12 +135,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
// if there currently is a selection, deselct it on single-tap
if selectedRange.length > 0 {
// location doesn't matter since we are non-editable and the cursor isn't visible
selectedRange = NSRange(location: 0, length: 0)
}
let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme {
@ -267,10 +264,6 @@ extension ContentTextView: UITextViewDelegate {
}
extension ContentTextView: MenuActionProvider {
var toastableViewController: ToastableViewController? {
// todo: pass this down through the text view
nil
}
}
extension ContentTextView: UIContextMenuInteractionDelegate {
@ -388,3 +381,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
}
}
}
extension ContentTextView: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// NB: This method is both a gesture recognizer delegate method and a UIView method.
// We only want to prevent our own tap gesture recognizer from beginning, but don't
// want to interfere with any other gestures that may begin over this view.
if gestureRecognizer === tapRecognizer {
let location = gestureRecognizer.location(in: self)
if let (link, _) = getLinkAtPoint(location) {
if link.scheme == dataDetectorsScheme {
return false
} else {
return true
}
} else {
return false
}
} else {
return true
}
}
}

View File

@ -12,7 +12,11 @@ import SwiftUI
import SafariServices
class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate?
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body),
@ -23,9 +27,8 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO
private let field: Account.Field
private var link: (String, URL)?
private let label = EmojiLabel()
private let textView = ContentTextView()
private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview?
@ -38,34 +41,28 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
var range = NSRange(location: 0, length: 0)
if converted.length != 0,
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
link = (converted.attributedSubstring(from: range).string, url)
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
#if os(visionOS)
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
#else
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
#endif
// the .link attribute in a UILabel always makes the color blue >.>
converted.removeAttribute(.link, range: range)
}
}
label.numberOfLines = 0
label.font = .preferredFont(forTextStyle: .body)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
#if os(visionOS)
textView.linkTextAttributes = [
.foregroundColor: UIColor.link
]
#else
textView.linkTextAttributes = [
.foregroundColor: UIColor.tintColor
]
#endif
textView.backgroundColor = nil
textView.isScrollEnabled = false
textView.isSelectable = false
textView.isEditable = false
textView.textContainerInset = .zero
textView.font = .preferredFont(forTextStyle: .body)
textView.adjustsFontForContentSizeCategory = true
textView.attributedText = converted
textView.setEmojis(account.emojis, identifier: account.id)
textView.isUserInteractionEnabled = true
textView.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
let labelTrailingConstraint: NSLayoutConstraint
@ -82,20 +79,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
])
} else {
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor),
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint,
label.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor),
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
@ -104,7 +101,7 @@ class ProfileFieldValueView: UIView {
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = label.sizeThatFits(size)
var size = textView.sizeThatFits(size)
if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
}
@ -112,29 +109,7 @@ class ProfileFieldValueView: UIView {
}
func setTextAlignment(_ alignment: NSTextAlignment) {
label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
textView.textAlignment = alignment
}
@objc private func verifiedIconTapped() {
@ -144,7 +119,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: label.text ?? "",
linkText: textView.text ?? "",
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
@ -168,53 +143,3 @@ class ProfileFieldValueView: UIView {
navigationDelegate.present(toPresent, animated: true)
}
}
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
var toastableViewController: ToastableViewController? {
navigationDelegate
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let (hashtag, url) = getHashtagOrURL(),
let navigationDelegate else {
return nil
}
if let hashtag {
return UIContextMenuConfiguration {
HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController)
} actionProvider: { _ in
UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self)))
}
} else {
return UIContextMenuConfiguration {
let vc = SFSafariViewController(url: url)
#if !os(visionOS)
vc.preferredControlTintColor = Preferences.shared.accentColor.color
#endif
return vc
} actionProvider: { _ in
UIMenu(children: self.actionsForURL(url, source: .view(self)))
}
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0)
// the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account
rect.origin.x = 0
rect.origin.y = (bounds.height - rect.height) / 2
let parameters = UIPreviewParameters(textLineRects: [rect as NSValue])
let preview = UITargetedPreview(view: label, parameters: parameters)
currentTargetedPreview = preview
return preview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? {
return currentTargetedPreview
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!)
}
}

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"/>
@ -93,7 +93,7 @@
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="udp-EN-wtc">
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
<rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
<state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" title="123 Following">
@ -104,7 +104,7 @@
<action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
</connections>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="XCX-Y3-cG5">
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" id="XCX-Y3-cG5">
<rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="Button"/>

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

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