Compare commits

...

34 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 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 5620b6ab78 Merge branch 'public-beta' into develop 2024-05-27 22:29:23 -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
54 changed files with 679 additions and 478 deletions

View File

@ -1,5 +1,29 @@
# Changelog # 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) ## 2024.3 (126)
Bugfixes: Bugfixes:
- Fix an issue displaying post HTML in certain edge cases - Fix an issue displaying post HTML in certain edge cases

View File

@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
mutableContent.body = notification.body mutableContent.body = notification.body
mutableContent.userInfo["notificationID"] = notification.notificationID mutableContent.userInfo["notificationID"] = notification.notificationID
mutableContent.userInfo["accountID"] = accountID mutableContent.userInfo["accountID"] = accountID
mutableContent.targetContentIdentifier = accountID
let task = Task { let task = Task {
await updateNotificationContent(mutableContent, account: account, push: notification) await updateNotificationContent(mutableContent, account: account, push: notification)

View File

@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
appliedSourceToDestTransform = false 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 from.view.frame = container.bounds
container.addSubview(from.view)
let content = itemViewController.takeContent() let content = itemViewController.takeContent()
content.view.translatesAutoresizingMaskIntoConstraints = true content.view.translatesAutoresizingMaskIntoConstraints = true
content.view.layer.masksToBounds = true content.view.layer.masksToBounds = true
container.addSubview(to.view)
container.addSubview(from.view)
container.addSubview(content.view) container.addSubview(content.view)
content.view.frame = destFrameInContainer content.view.frame = destFrameInContainer

View File

@ -217,6 +217,18 @@ public final class InstanceFeatures: ObservableObject {
instanceType.isPleroma instanceType.isPleroma
} }
public var muteNotifications: Bool {
!instanceType.isPixelfed
}
public var blockDomains: Bool {
!instanceType.isPixelfed
}
public var hideReblogs: Bool {
!instanceType.isPixelfed
}
public init() { public init() {
} }
@ -338,6 +350,14 @@ extension InstanceFeatures {
return false return false
} }
} }
var isPixelfed: Bool {
if case .pixelfed = self {
return true
} else {
return false
}
}
} }
@_spi(InstanceType) public enum MastodonType { @_spi(InstanceType) public enum MastodonType {

View File

@ -42,8 +42,7 @@ public struct Client: Sendable {
} else if let date = iso8601.date(from: str) { } else if let date = iso8601.date(from: str) {
return date return date
} else { } else {
// throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)")) throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
return Date(timeIntervalSinceReferenceDate: 0)
} }
}) })
@ -205,8 +204,8 @@ public struct Client: Sendable {
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials") return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
} }
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> { public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
request.range = range request.range = range
return request return request
} }
@ -457,14 +456,13 @@ public struct Client: Sendable {
} }
// MARK: - Timelines // 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) return timeline.request(range: range)
} }
// MARK: - Bookmarks // MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> { public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks") var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
request.range = range request.range = range
return request return request
} }
@ -492,7 +490,7 @@ public struct Client: Sendable {
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters) 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] = [] var parameters: [Parameter] = []
if let limit { if let limit {
parameters.append("limit" => 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.displayName = try container.decode(String.self, forKey: .displayName)
self.locked = try container.decode(Bool.self, forKey: .locked) self.locked = try container.decode(Bool.self, forKey: .locked)
self.createdAt = try container.decode(Date.self, forKey: .createdAt) self.createdAt = try container.decode(Date.self, forKey: .createdAt)
self.followersCount = try container.decode(Int.self, forKey: .followersCount) // some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
self.followingCount = try container.decode(Int.self, forKey: .followingCount) 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.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
self.note = try container.decode(String.self, forKey: .note) self.note = try container.decode(String.self, forKey: .note)
self.url = try container.decode(URL.self, forKey: .url) self.url = try container.decode(URL.self, forKey: .url)
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
return request 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]> { 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<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [ var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
"only_media" => onlyMedia, "only_media" => onlyMedia,
"pinned" => pinned, "pinned" => pinned,
"exclude_replies" => excludeReplies, "exclude_replies" => excludeReplies,

View File

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

View File

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

View File

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

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

@ -75,6 +75,7 @@
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; }; D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; }; D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.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 */; }; D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; }; D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; }; D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
@ -258,6 +259,7 @@
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; }; D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; }; D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.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 */; }; D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; }; D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; }; D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
@ -506,6 +508,7 @@
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
@ -685,6 +688,7 @@
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; 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>"; }; D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
@ -1049,6 +1053,7 @@
D608470E2A245D1F00C17380 /* ActiveInstance.swift */, D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */, D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */, D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
); );
path = CoreData; path = CoreData;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1757,6 +1762,7 @@
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */, D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */, D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */, D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
); );
path = API; path = API;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2362,6 +2368,7 @@
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */, D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */, D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */, 04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
@ -2408,6 +2415,7 @@
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */, D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */, D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */, D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */, D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */, D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
); );

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

@ -290,12 +290,18 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
// if the scene is already active, then we animate the account switching if necessary // if the scene is already active, then we animate the account switching if necessary
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive) delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
rootViewController.select(route: .notifications, animated: false) rootViewController.select(route: .notifications, animated: false) {
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController) let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
rootViewController.getNavigationController().pushViewController(vc, animated: false) rootViewController.getNavigationController().pushViewController(vc, animated: false)
}
} else { } else {
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID) 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() completionHandler()
} }

View File

@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
return context return context
}() }()
private var lastRemoteChangeToken: NSPersistentHistoryToken?
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily // 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 // 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 // and when updating things on the background context would need to switch to main, refetch, and then publish
@ -190,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
viewContext.name = "View" viewContext.name = "View"
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) if accountInfo != nil {
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator) 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) { func save(context: NSManagedObjectContext) {
@ -521,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
@objc private func remoteChanges(_ notification: Foundation.Notification) { @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 return
} }
remoteChangesBackgroundContext.perform { PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
defer { self.remoteChangesBackgroundContext.perform {
self.lastRemoteChangeToken = token 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 { private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
var changedHashtags = false logger.info("Processing \(transactions.count) persistent history transactions")
var changedInstances = false var changedHashtags = false
var changedTimelinePositions = Set<NSManagedObjectID>() var changedInstances = false
var changedAccountPrefs = false var changedTimelinePositions = Set<NSManagedObjectID>()
outer: for transaction in transactions { var changedAccountPrefs = false
for change in transaction.changes ?? [] { outer: for transaction in transactions {
if change.changedObjectID.entity.name == "SavedHashtag" { logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
changedHashtags = true for change in transaction.changes ?? [] {
} else if change.changedObjectID.entity.name == "SavedInstance" { if change.changedObjectID.entity.name == "SavedHashtag" {
changedInstances = true changedHashtags = true
} else if change.changedObjectID.entity.name == "TimelinePosition" { } else if change.changedObjectID.entity.name == "SavedInstance" {
changedTimelinePositions.insert(change.changedObjectID) changedInstances = true
} else if change.changedObjectID.entity.name == "AccountPreferences" { } else if change.changedObjectID.entity.name == "TimelinePosition" {
changedAccountPrefs = true 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 // Can't capture vars in concurrently-executing closure
let timelinePositions = changedTimelinePositions let hashtags = changedHashtags
let accountPrefs = changedAccountPrefs let instances = changedInstances
DispatchQueue.main.async { let timelinePositions = changedTimelinePositions
if hashtags { let accountPrefs = changedAccountPrefs
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) DispatchQueue.main.async {
} if hashtags {
if instances { NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) }
} if instances {
for id in timelinePositions { NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else { }
continue for id in timelinePositions {
} guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually continue
timelinePosition.changedRemotely()
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
}
if accountPrefs {
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
}
} }
// 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

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

View File

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

View File

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

View File

@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true) let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
do { do {
let (results, _) = try await mastodonController.run(request) 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() throw UnableToResolveError()
} }
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status) _ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)

View File

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

View File

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

View File

@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } 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 { private func loadTrendingStatuses() async {
let statuses: [Status] let statuses: [Status]
do { do {
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
} catch { } catch {
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>() let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await MainActor.run { await MainActor.run {

View File

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

View File

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

View File

@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
private(set) var currentAccountID: String private(set) var currentAccountID: String
private(set) var root: AccountSwitchableViewController private(set) var root: AccountSwitchableViewController
private var userActivities: [String: NSUserActivity] = [:] private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
init(root: AccountSwitchableViewController, for account: UserAccountInfo) { init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id self.currentAccountID = account.id
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(root) 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 let oldRoot = self.root
if direction == .none { if direction == .none {
oldRoot.removeViewAndController() oldRoot.removeViewAndController()
} }
if let activity = oldRoot.stateRestorationActivity() { if let activity = oldRoot.stateRestorationActivity() {
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)") 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.currentAccountID = account.id
self.root = newRoot self.root = newRoot
embedChild(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 direction != .none {
if UIAccessibility.prefersCrossFadeTransitions { if UIAccessibility.prefersCrossFadeTransitions {
newRoot.view.alpha = 0 newRoot.view.alpha = 0
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
#endif #endif
// only one edge is affected in each direction, i have no idea why // only one edge is affected in each direction, i have no idea why
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
if direction == .upwards { if direction == .upwards {
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
} else { } else {
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale) oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
newRoot.view.transform = .identity newRoot.view.transform = .identity
} completion: { (_) in } completion: { (_) in
oldRoot.view.transform = .identity
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
oldRoot.removeViewAndController() oldRoot.removeViewAndController()
newRoot.view.layer.masksToBounds = false newRoot.view.layer.masksToBounds = false
} }
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
root.compose(editing: draft, animated: animated, isDucked: isDucked) root.compose(editing: draft, animated: animated, isDucked: isDucked)
} }
func select(route: TuskerRoute, animated: Bool) { func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
loadViewIfNeeded() loadViewIfNeeded()
root.select(route: route, animated: animated) root.select(route: route, animated: animated, completion: completion)
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

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

View File

@ -13,8 +13,8 @@ import Combine
@MainActor @MainActor
protocol MainSidebarViewControllerDelegate: AnyObject { protocol MainSidebarViewControllerDelegate: AnyObject {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item) func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
} }
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
return items return items
} }
private(set) var previouslySelectedItem: Item? private var previouslySelectedItem: Item?
var selectedItem: Item? { var selectedItem: Item? {
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else { guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
return nil return nil
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
} }
private func returnToPreviousItem() { private func returnToPreviousItem() {
let item = previouslySelectedItem ?? .tab(.timelines) let oldItem = selectedItem
let newItem = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil previouslySelectedItem = nil
select(item: item, animated: true) select(item: newItem, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: item) sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
} }
private func showAddList() { private func showAddList() {
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
) }) { list in ) }) { list in
let oldItem = self.selectedItem
self.select(item: .list(list), animated: false) self.select(item: .list(list), animated: false)
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController) let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
list.presentEditOnAppear = true list.presentEditOnAppear = true
self.sidebarDelegate?.sidebar(self, showViewController: list) self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
} }
service.run() service.run()
} }
@ -471,7 +473,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
fatalError("unreachable") fatalError("unreachable")
} }
} else { } else {
sidebarDelegate?.sidebar(self, didSelectItem: item) sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
} }
} }
@ -540,8 +542,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate { extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
func didSaveInstance(url: URL) { func didSaveInstance(url: URL) {
dismiss(animated: true) { dismiss(animated: true) {
let oldItem = self.selectedItem
self.select(item: .savedInstance(url), animated: true) 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 // 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 // when we change from compact -> split for the first time, the VC will be transferred anyways
if traitCollection.horizontalSizeClass != .compact { if traitCollection.horizontalSizeClass != .compact {
select(item: .tab(.timelines)) doSelect(item: .tab(.timelines))
} }
if UIDevice.current.userInterfaceIdiom != .mac { if UIDevice.current.userInterfaceIdiom != .mac {
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
self.setViewController(newNav, for: .secondary) 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) secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
} }
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
} }
@objc func handleSidebarCommandTimelines() { @objc func handleSidebarCommandTimelines() {
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.timelines), animated: false) sidebar.select(item: .tab(.timelines), animated: false)
select(item: .tab(.timelines))
} }
@objc func handleSidebarCommandNotifications() { @objc func handleSidebarCommandNotifications() {
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.notifications), animated: false) sidebar.select(item: .tab(.notifications), animated: false)
select(item: .tab(.notifications))
} }
@objc func handleSidebarCommandExplore() { @objc func handleSidebarCommandExplore() {
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.explore), animated: false) sidebar.select(item: .tab(.explore), animated: false)
select(item: .tab(.explore))
} }
@objc func handleSidebarCommandBookmarks() { @objc func handleSidebarCommandBookmarks() {
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
sidebar.select(item: .bookmarks, animated: false) sidebar.select(item: .bookmarks, animated: false)
select(item: .bookmarks)
} }
@objc func handleSidebarCommandMyProfile() { @objc func handleSidebarCommandMyProfile() {
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
sidebar.select(item: .tab(.myProfile), animated: false) sidebar.select(item: .tab(.myProfile), animated: false)
select(item: .tab(.myProfile))
} }
@objc private func sidebarTapped() { @objc private func sidebarTapped() {
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
// These tabs map 1 <-> 1 with sidebar items // These tabs map 1 <-> 1 with sidebar items
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab) let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
sidebar.select(item: item, animated: false) sidebar.select(item: item, animated: false)
select(item: item) doSelect(item: item)
case .explore: case .explore:
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack // 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) sidebar.select(item: exploreItem!, animated: false)
select(item: exploreItem!) doSelect(item: exploreItem!)
default: default:
return return
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
compose(editing: nil) compose(editing: nil)
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) { func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
if let previous = sidebar.previouslySelectedItem { select(newItem: item, oldItem: previousItem)
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) { func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
if let previous = sidebar.previouslySelectedItem { if let previousItem {
navigationStacks[previous] = secondaryNavController.viewControllers navigationStacks[previousItem] = secondaryNavController.viewControllers
} }
secondaryNavController.viewControllers = [viewController] secondaryNavController.viewControllers = [viewController]
} }
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
} }
extension MainSplitViewController: TuskerRootViewController { extension MainSplitViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) { func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
guard traitCollection.horizontalSizeClass != .compact else { guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated) tabBarViewController?.select(route: route, animated: animated, completion: completion)
return return
} }
guard presentedViewController == nil else { guard presentedViewController == nil else {
dismiss(animated: animated) { dismiss(animated: animated) {
self.select(route: route, animated: animated) self.select(route: route, animated: animated, completion: completion)
} }
return return
} }
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
return return
} }
} }
let oldItem = sidebar.selectedItem
sidebar.select(item: item, animated: false) sidebar.select(item: item, animated: false)
select(item: item) select(newItem: item, oldItem: oldItem)
completion?()
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -610,7 +617,7 @@ extension MainSplitViewController: TuskerRootViewController {
} }
if sidebar.selectedItem != .explore { if sidebar.selectedItem != .explore {
select(item: .explore) select(newItem: .explore, oldItem: sidebar.selectedItem)
} }
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else { guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {

View File

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

View File

@ -12,7 +12,7 @@ import ComposeUI
@MainActor @MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) 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 getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func getNavigationDelegate() -> TuskerNavigationDelegate? func getNavigationDelegate() -> TuskerNavigationDelegate?
func getNavigationController() -> NavigationControllerProtocol func getNavigationController() -> NavigationControllerProtocol
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController? 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 { enum TuskerRoute {
case timelines case timelines
case notifications case notifications
@ -57,33 +30,6 @@ enum TuskerRoute {
case list(id: String) 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 @MainActor
protocol NavigationControllerProtocol: UIViewController { protocol NavigationControllerProtocol: UIViewController {
var viewControllers: [UIViewController] { get set } var viewControllers: [UIViewController] { get set }

View File

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

View File

@ -9,6 +9,12 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
import Combine 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 { class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
@ -250,9 +256,16 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
state = .setupInitialSnapshot state = .setupInitialSnapshot
Task { Task {
if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])), do {
let relationship = all.first { let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID]))
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) 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 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 await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -513,7 +526,7 @@ extension ProfileStatusesViewController {
extension ProfileStatusesViewController: TimelineLikeControllerDataSource { extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
typealias TimelineItem = String // status ID 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 { switch kind {
case .statuses: case .statuses:
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true) return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
@ -526,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [String] { func loadInitial() async throws -> [String] {
let request = request() let request = request()
let (statuses, _) = try await mastodonController.run(request) let statuses = try await mastodonController.run(request).0.compactMap(\.value)
if !statuses.isEmpty { if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil) newer = .after(id: statuses.first!.id, count: nil)
@ -546,7 +559,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: newer) 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 { guard !statuses.isEmpty else {
throw Error.allCaughtUp throw Error.allCaughtUp
@ -567,7 +580,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: older) 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 { guard !statuses.isEmpty else {
return [] return []

View File

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

View File

@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
super.searchResultsController as! SearchResultsViewController super.searchResultsController as! SearchResultsViewController
} }
init(searchResultsController: SearchResultsViewController) { private weak var owner: UIViewController?
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
self.owner = owner
super.init(searchResultsController: searchResultsController) super.init(searchResultsController: searchResultsController)
searchResultsController.tokenHandler = { [unowned self] token, op in searchResultsController.tokenHandler = { [unowned self] token, op in
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
} }
} }
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
var multiColumnNavigationTargetViewController: UIViewController? {
owner
}
}
extension UISearchBar { extension UISearchBar {
var searchQueryWithOperators: String { var searchQueryWithOperators: String {
var parts = searchTextField.tokens.compactMap { $0.representedObject as? 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 } guard self.currentQuery == query else { return }
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
addAccounts(results.accounts) addAccounts(results.accounts)
addStatuses(results.statuses) addStatuses(results.statuses.compactMap(\.value))
} completion: { } completion: {
DispatchQueue.main.async { DispatchQueue.main.async {
self.showSearchResults(results) self.showSearchResults(results)
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
} }
if !results.statuses.isEmpty && resultTypes.contains(.statuses) { if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
snapshot.appendSections([.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) dataSource.apply(snapshot)

View File

@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.topAnchor.constraint(equalTo: contentView.topAnchor), label.topAnchor.constraint(equalTo: contentView.topAnchor),
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
]) ])
addInteraction(UIPointerInteraction(delegate: self))
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
label.text = text 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

@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
do { do {
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home)) 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 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 let allStatuses = try await [status] + olderStatuses
await mastodonController.persistentContainer.addAll(statuses: allStatuses) await mastodonController.persistentContainer.addAll(statuses: allStatuses)
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize)) 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 await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize) let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: newer) 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 { guard !statuses.isEmpty else {
throw TimelineViewController.Error.allCaughtUp throw TimelineViewController.Error.allCaughtUp
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize) let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
let request = Client.getStatuses(timeline: timeline, range: older) 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 { guard !statuses.isEmpty else {
return [] return []
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
} }
let request = Client.getStatuses(timeline: timeline, range: range) 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 { guard !statuses.isEmpty else {
return [] return []

View File

@ -8,17 +8,28 @@
import UIKit 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 { class MultiColumnNavigationController: UIViewController {
private var isManuallyUpdating = false private var isManuallyUpdating = false
var viewControllers: [UIViewController] = [] { private var _viewControllers: [UIViewController] = []
didSet { var viewControllers: [UIViewController] {
guard isViewLoaded, get {
!isManuallyUpdating else { _viewControllers
return }
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
updateViews()
scrollToEnd(animated: false)
} }
updateViews()
scrollToEnd(animated: false)
} }
} }
@ -68,13 +79,13 @@ class MultiColumnNavigationController: UIViewController {
private func updateViews() { private func updateViews() {
var i = 0 var i = 0
while i < viewControllers.count { while i < _viewControllers.count {
let needsCloseButton = i > 0 let needsCloseButton = i > 0
if i <= stackView.arrangedSubviews.count - 1 { if i <= stackView.arrangedSubviews.count - 1 {
let existing = stackView.arrangedSubviews[i] as! ColumnView let existing = stackView.arrangedSubviews[i] as! ColumnView
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton) existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
} else { } else {
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton) let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
stackView.addArrangedSubview(new) stackView.addArrangedSubview(new)
} }
i += 1 i += 1
@ -92,9 +103,11 @@ class MultiColumnNavigationController: UIViewController {
var index: Int? = nil var index: Int? = nil
var current: UIViewController? = sender var current: UIViewController? = sender
while let c = current { while let c = current {
index = viewControllers.firstIndex(of: c) index = _viewControllers.firstIndex(of: c)
if index != nil { if index != nil {
break break
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
current = targetProviding.multiColumnNavigationTargetViewController
} else { } else {
current = c.parent current = c.parent
} }
@ -112,19 +125,20 @@ class MultiColumnNavigationController: UIViewController {
} }
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) { 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) pushViewController(vcs[0], animated: animated)
} else { } else {
viewControllers = Array(viewControllers[...afterIndex]) + vcs _viewControllers = Array(_viewControllers[...afterIndex]) + vcs
updateViews()
scrollToEnd(animated: animated) scrollToEnd(animated: animated)
} }
} }
private func scrollToEnd(animated: Bool) { private func scrollToEnd(animated: Bool) {
if viewControllers.isEmpty { if _viewControllers.isEmpty {
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false) scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
} else { } 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) { fileprivate func closeColumn(_ vc: UIViewController) {
let index = viewControllers.firstIndex(of: vc)! guard let index = _viewControllers.firstIndex(of: vc),
guard index > 0 else { index > 0 else {
// Can't close the last column // Can't close the last column
return return
} }
isManuallyUpdating = true _viewControllers.removeSubrange(index...)
defer { isManuallyUpdating = false }
viewControllers.removeSubrange(index...)
animateChanges { animateChanges {
for column in self.stackView.arrangedSubviews[index...] { for column in self.stackView.arrangedSubviews[index...] {
column.layer.opacity = 0 column.layer.opacity = 0
@ -158,7 +170,6 @@ class MultiColumnNavigationController: UIViewController {
} completion: { } completion: {
self.updateViews() self.updateViews()
} }
} }
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) { private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
@ -173,19 +184,22 @@ class MultiColumnNavigationController: UIViewController {
extension MultiColumnNavigationController: NavigationControllerProtocol { extension MultiColumnNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? { var topViewController: UIViewController? {
viewControllers.last _viewControllers.last
} }
func popToRootViewController(animated: Bool) -> [UIViewController]? { func popToRootViewController(animated: Bool) -> [UIViewController]? {
let removed = Array(viewControllers.dropFirst()) guard !_viewControllers.isEmpty else {
viewControllers = [viewControllers.first!] return nil
}
let removed = Array(_viewControllers.dropFirst())
_viewControllers = [_viewControllers.first!]
updateViews()
scrollToEnd(animated: animated)
return removed return removed
} }
func pushViewController(_ vc: UIViewController, animated: Bool) { func pushViewController(_ vc: UIViewController, animated: Bool) {
isManuallyUpdating = true _viewControllers.append(vc)
defer { isManuallyUpdating = false }
viewControllers.append(vc)
updateViews() updateViews()
scrollToEnd(animated: animated) scrollToEnd(animated: animated)
if animated { if animated {
@ -273,12 +287,17 @@ private class ColumnView: UIView {
} }
private func installCloseBarButton(navigationItem: UINavigationItem) { private func installCloseBarButton(navigationItem: UINavigationItem) {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn)) func makeItem() -> UIBarButtonItem {
item.accessibilityLabel = "Close Column" let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
if navigationItem.leftBarButtonItems != nil { item.accessibilityLabel = "Close Column"
navigationItem.leftBarButtonItems!.insert(item, at: 0) return item
}
if let leftItems = navigationItem.leftBarButtonItems {
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
}
} else { } else {
navigationItem.leftBarButtonItems = [item] navigationItem.leftBarButtonItems = [makeItem()]
} }
} }

View File

@ -557,11 +557,15 @@ extension MenuActionProvider {
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false)) return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
} else { } else {
let image = UIImage(systemName: "circle.slash") let image = UIImage(systemName: "circle.slash")
return UIMenu(title: "Block", image: image, children: [ var children = [
UIAction(title: "Cancel", handler: { _ in }), UIAction(title: "Cancel", handler: { _ in }),
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)), 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)
} }
} }
@ -592,7 +596,8 @@ extension MenuActionProvider {
@MainActor @MainActor
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? { 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 // 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 return nil
} }
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs" let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
commonInit() commonInit()
} }
deinit {
fetchTask?.cancel()
blurHashTask?.cancel()
}
private func commonInit() { private func commonInit() {
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) 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 emojiFont: UIFont = .preferredFont(forTextStyle: .body)
var emojiTextColor: UIColor = .label var emojiTextColor: UIColor = .label
private let tapRecognizer = UITapGestureRecognizer()
// The link range currently being previewed // The link range currently being previewed
private var currentPreviewedLinkRange: NSRange? private var currentPreviewedLinkRange: NSRange?
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing. // 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() updateLinkUnderlineStyle()
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer // 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(_:))) tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
addGestureRecognizer(recognizer) tapRecognizer.delegate = self
addGestureRecognizer(tapRecognizer)
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
underlineTextLinksCancellable = underlineTextLinksCancellable =
@ -132,12 +135,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
} }
@objc func textTapped(_ recognizer: UITapGestureRecognizer) { @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) let location = recognizer.location(in: self)
if let (link, range) = getLinkAtPoint(location), if let (link, range) = getLinkAtPoint(location),
link.scheme != dataDetectorsScheme { link.scheme != dataDetectorsScheme {
@ -384,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 import SafariServices
class ProfileFieldValueView: UIView { class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private static let converter = HTMLConverter( private static let converter = HTMLConverter(
font: .preferredFont(forTextStyle: .body), font: .preferredFont(forTextStyle: .body),
@ -23,9 +27,8 @@ class ProfileFieldValueView: UIView {
private let account: AccountMO private let account: AccountMO
private let field: Account.Field private let field: Account.Field
private var link: (String, URL)?
private let label = EmojiLabel() private let textView = ContentTextView()
private var iconView: UIView? private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview? private var currentTargetedPreview: UITargetedPreview?
@ -38,34 +41,28 @@ class ProfileFieldValueView: UIView {
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
var range = NSRange(location: 0, length: 0) #if os(visionOS)
if converted.length != 0, textView.linkTextAttributes = [
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL { .foregroundColor: UIColor.link
link = (converted.attributedSubstring(from: range).string, url) ]
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped))) #else
label.addInteraction(UIContextMenuInteraction(delegate: self)) textView.linkTextAttributes = [
label.isUserInteractionEnabled = true .foregroundColor: UIColor.tintColor
]
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in #endif
guard value != nil else { return } textView.backgroundColor = nil
#if os(visionOS) textView.isScrollEnabled = false
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) textView.isSelectable = false
#else textView.isEditable = false
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) textView.textContainerInset = .zero
#endif textView.font = .preferredFont(forTextStyle: .body)
// the .link attribute in a UILabel always makes the color blue >.> textView.adjustsFontForContentSizeCategory = true
converted.removeAttribute(.link, range: range) textView.attributedText = converted
} textView.setEmojis(account.emojis, identifier: account.id)
} textView.isUserInteractionEnabled = true
textView.setContentCompressionResistancePriority(.required, for: .vertical)
label.numberOfLines = 0 textView.translatesAutoresizingMaskIntoConstraints = false
label.font = .preferredFont(forTextStyle: .body) addSubview(textView)
label.adjustsFontForContentSizeCategory = true
label.attributedText = converted
label.setEmojis(account.emojis, identifier: account.id)
label.setContentCompressionResistancePriority(.required, for: .vertical)
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint let labelTrailingConstraint: NSLayoutConstraint
@ -82,20 +79,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link" icon.accessibilityLabel = "Verified link"
addSubview(icon) addSubview(icon)
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
]) ])
} else { } else {
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: leadingAnchor), textView.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint, labelTrailingConstraint,
label.topAnchor.constraint(equalTo: topAnchor), textView.topAnchor.constraint(equalTo: topAnchor),
label.bottomAnchor.constraint(equalTo: bottomAnchor), textView.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
@ -104,7 +101,7 @@ class ProfileFieldValueView: UIView {
} }
override func sizeThatFits(_ size: CGSize) -> CGSize { override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = label.sizeThatFits(size) var size = textView.sizeThatFits(size)
if let iconView { if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
} }
@ -112,29 +109,7 @@ class ProfileFieldValueView: UIView {
} }
func setTextAlignment(_ alignment: NSTextAlignment) { func setTextAlignment(_ alignment: NSTextAlignment) {
label.textAlignment = alignment textView.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)
}
} }
@objc private func verifiedIconTapped() { @objc private func verifiedIconTapped() {
@ -144,7 +119,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView( let view = ProfileFieldVerificationView(
acct: account.acct, acct: account.acct,
verifiedAt: field.verifiedAt!, verifiedAt: field.verifiedAt!,
linkText: label.text ?? "", linkText: textView.text ?? "",
navigationDelegate: navigationDelegate navigationDelegate: navigationDelegate
) )
let host = UIHostingController(rootView: view) let host = UIHostingController(rootView: view)
@ -168,49 +143,3 @@ class ProfileFieldValueView: UIView {
navigationDelegate.present(toPresent, animated: true) navigationDelegate.present(toPresent, animated: true)
} }
} }
extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider {
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? weak var delegate: ProfileHeaderViewDelegate?
var mastodonController: MastodonController! { delegate?.apiController } var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView! @IBOutlet weak var headerImageView: CachedImageView!
@IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: CachedImageView!
@IBOutlet weak var moreButton: ProfileHeaderButton! @IBOutlet weak var moreButton: ProfileHeaderButton!
@IBOutlet weak var followButton: ProfileHeaderButton! @IBOutlet weak var followButton: ProfileHeaderButton!
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel! @IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
@ -44,8 +44,6 @@ class ProfileHeaderView: UIView {
var accountID: String! var accountID: String!
private var imagesTask: Task<Void, Never>?
private var isGrayscale = false private var isGrayscale = false
private var followButtonMode = FollowButtonMode.follow { private var followButtonMode = FollowButtonMode.follow {
didSet { didSet {
@ -56,10 +54,6 @@ class ProfileHeaderView: UIView {
} }
} }
deinit {
imagesTask?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -69,11 +63,13 @@ class ProfileHeaderView: UIView {
avatarContainerView.layer.cornerCurve = .continuous avatarContainerView.layer.cornerCurve = .continuous
// Set zPositions so the gallery presentation/dismissal animation looks correct. // Set zPositions so the gallery presentation/dismissal animation looks correct.
avatarContainerView.layer.zPosition = 2 avatarContainerView.layer.zPosition = 2
avatarImageView.cache = .avatars
avatarImageView.layer.masksToBounds = true avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerCurve = .continuous avatarImageView.layer.cornerCurve = .continuous
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed))) avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
avatarImageView.isUserInteractionEnabled = true avatarImageView.isUserInteractionEnabled = true
headerImageView.cache = .headers
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed))) headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
headerImageView.isUserInteractionEnabled = true headerImageView.isUserInteractionEnabled = true
headerImageView.layer.zPosition = 1 headerImageView.layer.zPosition = 1
@ -138,11 +134,11 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked lockImageView.isHidden = !account.locked
imagesTask?.cancel() if let avatar = account.avatar {
let avatar = account.avatar avatarImageView.update(for: avatar)
let header = account.header }
imagesTask = Task { if let header = account.header {
await updateImages(avatar: avatar, header: header) headerImageView.update(for: header)
} }
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? []) 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) avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
displayNameLabel.updateForAccountDisplayName(account: account) 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) { private func formatBigNumber(_ value: Int) -> (String, String) {

View File

@ -3,7 +3,7 @@
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<subviews> <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"/> <rect key="frame" x="0.0" y="48" width="414" height="150"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/> <constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
@ -23,7 +23,7 @@
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY"> <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
<rect key="frame" x="16" y="138" width="120" height="120"/> <rect key="frame" x="16" y="138" width="120" height="120"/>
<subviews> <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"/> <rect key="frame" x="2" y="2" width="116" height="116"/>
<constraints> <constraints>
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/> <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"> <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"/> <rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
<subviews> <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"/> <rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
<state key="normal" title="Button"/> <state key="normal" title="Button"/>
<buttonConfiguration key="configuration" style="plain" title="123 Following"> <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"/> <action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
</connections> </connections>
</button> </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"/> <rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="Button"/> <state key="normal" title="Button"/>

View File

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

View File

@ -256,6 +256,7 @@ extension StatusCollectionViewCell {
view.backgroundColor = .tintColor.withAlphaComponent(0.5) view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5 view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
view.layer.zPosition = -1
prevThreadLinkView = view prevThreadLinkView = view
contentView.addSubview(view) contentView.addSubview(view)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -278,6 +279,7 @@ extension StatusCollectionViewCell {
view.backgroundColor = .tintColor.withAlphaComponent(0.5) view.backgroundColor = .tintColor.withAlphaComponent(0.5)
view.layer.cornerRadius = 2.5 view.layer.cornerRadius = 2.5
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
view.layer.zPosition = -1
nextThreadLinkView = view nextThreadLinkView = view
contentView.addSubview(view) contentView.addSubview(view)
let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)

View File

@ -10,7 +10,7 @@
// https://help.apple.com/xcode/#/dev745c5c974 // https://help.apple.com/xcode/#/dev745c5c974
MARKETING_VERSION = 2024.3 MARKETING_VERSION = 2024.3
CURRENT_PROJECT_VERSION = 126 CURRENT_PROJECT_VERSION = 127
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION)) CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev