Compare commits

..

No commits in common. "8557e110a8d180d8eb94dadc45390c3ffcad4b57" and "9df3c33c6ca6564c9c01ff8d455ee4158c7d484c" have entirely different histories.

54 changed files with 478 additions and 679 deletions

View File

@ -1,29 +1,5 @@
# 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,7 +63,6 @@ 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,22 +52,15 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
appliedSourceToDestTransform = false appliedSourceToDestTransform = false
} }
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
// is in the window's root presentation.
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
// `to.view` is already in the view hierarchy at this point; and adding it to the
// container causees it to be removed when the transition completes.
if to.view.superview == nil {
to.view.frame = container.bounds 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,18 +217,6 @@ 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() {
} }
@ -350,14 +338,6 @@ 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,7 +42,8 @@ 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)
} }
}) })
@ -204,8 +205,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<[TryDecode<Status>]> { public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites") var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
request.range = range request.range = range
return request return request
} }
@ -456,13 +457,14 @@ public struct Client: Sendable {
} }
// MARK: - Timelines // MARK: - Timelines
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> { public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
return timeline.request(range: range) return timeline.request(range: range)
} }
// MARK: - Bookmarks // MARK: - Bookmarks
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> { public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks") var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
request.range = range request.range = range
return request return request
} }
@ -490,7 +492,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<[TryDecode<Status>]> { public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
var parameters: [Parameter] = [] var parameters: [Parameter] = []
if let limit { if let limit {
parameters.append("limit" => limit) parameters.append("limit" => limit)

View File

@ -40,9 +40,8 @@ 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)
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0 self.followersCount = try container.decode(Int.self, forKey: .followersCount)
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0 self.followingCount = try container.decode(Int.self, forKey: .followingCount)
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)
@ -95,8 +94,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<[TryDecode<Status>]> { public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [ var request = Request<[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,13 +27,10 @@ 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)
// not supported on pixelfed self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
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)
// not supported on pixelfed self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
// 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: [TryDecode<Status>] public let statuses: [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<[TryDecode<Status>]> { func request(range: RequestRange) -> Request<[Status]> {
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint) var request: Request<[Status]> = Request<[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

@ -1,32 +0,0 @@
//
// 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,7 +75,6 @@
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 */; };
@ -259,7 +258,6 @@
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 */; };
@ -508,7 +506,6 @@
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>"; };
@ -688,7 +685,6 @@
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>"; };
@ -1053,7 +1049,6 @@
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>";
@ -1762,7 +1757,6 @@
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>";
@ -2368,7 +2362,6 @@
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 */,
@ -2415,7 +2408,6 @@
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

@ -1,39 +0,0 @@
//
// 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,19 +290,13 @@ 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)
if #available(iOS 17.0, *) {
let request = UISceneSessionActivationRequest(userActivity: activity)
UIApplication.shared.activateSceneSession(for: request)
} else {
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil) UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
} }
}
completionHandler() completionHandler()
} }
} }

View File

@ -48,6 +48,8 @@ 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
@ -188,11 +190,9 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
viewContext.name = "View" viewContext.name = "View"
if accountInfo != nil {
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator) NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
} }
}
func save(context: NSManagedObjectContext) { func save(context: NSManagedObjectContext) {
guard context.hasChanges else { guard context.hasChanges else {
@ -521,48 +521,22 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
@objc private func remoteChanges(_ notification: Foundation.Notification) { @objc private func remoteChanges(_ notification: Foundation.Notification) {
guard let accountInfo, guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
return return
} }
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in remoteChangesBackgroundContext.perform {
self.remoteChangesBackgroundContext.perform {
defer { defer {
PersistentHistoryTokenStore.setToken(token, for: accountInfo) self.lastRemoteChangeToken = token
} }
let transactions: [NSPersistentHistoryTransaction] let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
do { if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken) let transactions = result.result as? [NSPersistentHistoryTransaction],
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult { !transactions.isEmpty {
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.
}
}
}
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
logger.info("Processing \(transactions.count) persistent history transactions")
var changedHashtags = false var changedHashtags = false
var changedInstances = false var changedInstances = false
var changedTimelinePositions = Set<NSManagedObjectID>() var changedTimelinePositions = Set<NSManagedObjectID>()
var changedAccountPrefs = false var changedAccountPrefs = false
outer: for transaction in transactions { outer: for transaction in transactions {
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
for change in transaction.changes ?? [] { for change in transaction.changes ?? [] {
if change.changedObjectID.entity.name == "SavedHashtag" { if change.changedObjectID.entity.name == "SavedHashtag" {
changedHashtags = true changedHashtags = true
@ -600,6 +574,8 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
} }
} }
} }
}
}
} }

View File

@ -1,47 +0,0 @@
//
// 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,9 +32,8 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
} }
launchActivity = activity launchActivity = activity
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
let account: UserAccountInfo 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,8 +29,6 @@ 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,9 +83,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else { } else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!) context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
} }
Task(priority: .userInitiated) { _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
_ = 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) {
@ -193,11 +191,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
if let activity = launchActivity { if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) { func doRestoreActivity(context: UserActivityHandlingContext) {
Task(priority: .userInitiated) { _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity) context.finalize(activity: activity)
} }
}
if activity.isStateRestorationActivity { if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!)) doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
} else if activity.activityType != UserActivityType.mainScene.rawValue { } else if activity.activityType != UserActivityType.mainScene.rawValue {
@ -229,8 +225,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
window!.windowScene!.title = account.instanceURL.host! window!.windowScene!.title = account.instanceURL.host!
} }
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id) let newRoot = createAppUI()
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,
@ -240,9 +235,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
} else { } else {
direction = .none direction = .none
} }
container.setRoot(createAppUI, for: account, animating: direction) container.setRoot(newRoot, for: account, animating: direction)
} else { } else {
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account) window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
} }
} }
@ -253,9 +248,6 @@ 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.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else { guard let status = results.statuses.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, owner: self) searchController = MastodonSearchController(searchResultsController: resultsController)
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, owner: self) searchController = MastodonSearchController(searchResultsController: resultsController)
searchController.obscuresBackgroundDuringPresentation = true searchController.obscuresBackgroundDuringPresentation = true
searchController.hidesNavigationBarDuringPresentation = false searchController.hidesNavigationBarDuringPresentation = false
definesPresentationContext = true definesPresentationContext = true

View File

@ -184,19 +184,7 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
return UIContextMenuConfiguration { return UIContextMenuConfiguration {
ProfileViewController(accountID: id, mastodonController: self.mastodonController) ProfileViewController(accountID: id, mastodonController: self.mastodonController)
} actionProvider: { _ in } actionProvider: { _ in
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
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.compactMap(\.value) statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
} 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.compactMap(\.value) async let statuses = try? mastodonController.run(statusesReq).0
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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
await mastodonController.persistentContainer.addAll(statuses: statuses) await mastodonController.persistentContainer.addAll(statuses: statuses)
@ -368,14 +368,20 @@ class TrendsViewController: UIViewController, CollectionViewController {
@MainActor @MainActor
private func removeProfileSuggestion(accountID: String) async { private func removeProfileSuggestion(accountID: String) async {
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in let req = Suggestion.remove(accountID: accountID)
guard let self else { return } do {
var snapshot = self.dataSource.snapshot() _ = try await mastodonController.run(req)
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)])
self.dataSource.apply(snapshot, animatingDifferences: true) await apply(snapshot: snapshot)
} catch {
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
toast.dismissToast(animated: true)
_ = await self.removeProfileSuggestion(accountID: accountID)
}
self.showToast(configuration: config, animated: true)
} }
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<[TryDecode<Status>]> private let request: (RequestRange) -> Request<[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<[TryDecode<Status>]>, mastodonController: MastodonController) { init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.predicate = predicate self.predicate = predicate
self.predicateTitle = predicateTitle self.predicateTitle = predicateTitle
@ -140,8 +140,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do { do {
let req = request(.count(Self.pageSize)) let req = request(.count(Self.pageSize))
let (tryStatuses, pagination) = try await mastodonController.run(req) let (statuses, pagination) = try await mastodonController.run(req)
let statuses = tryStatuses.compactMap(\.value)
newer = pagination?.newer newer = pagination?.newer
older = pagination?.older older = pagination?.older
@ -181,8 +180,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
do { do {
let req = request(older.withCount(Self.pageSize)) let req = request(older.withCount(Self.pageSize))
let (tryStatuses, pagination) = try await mastodonController.run(req) let (statuses, 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)
@ -280,8 +278,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
Task { Task {
do { do {
let req = request(newer.withCount(Self.pageSize)) let req = request(newer.withCount(Self.pageSize))
let (tryStatuses, pagination) = try await mastodonController.run(req) let (statuses, 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 viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:] private var userActivities: [String: NSUserActivity] = [:]
init(root: AccountSwitchableViewController, for account: UserAccountInfo) { init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
self.currentAccountID = account.id self.currentAccountID = account.id
@ -42,49 +42,27 @@ class AccountSwitchingContainerViewController: UIViewController {
embedChild(root) embedChild(root)
} }
override func didReceiveMemoryWarning() { func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
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)")
viewControllers[currentAccountID] = (oldRoot, activity) userActivities[currentAccountID] = 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
@ -114,7 +92,6 @@ 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 {
@ -125,8 +102,6 @@ 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
} }
@ -152,9 +127,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, completion: (() -> Void)?) { func select(route: TuskerRoute, animated: Bool) {
loadViewIfNeeded() loadViewIfNeeded()
root.select(route: route, animated: animated, completion: completion) root.select(route: route, animated: animated)
} }
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, completion: (() -> Void)?) { func select(route: TuskerRoute, animated: Bool) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion) (child as? TuskerRootViewController)?.select(route: route, animated: animated)
} }
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, previousItem: MainSidebarViewController.Item?) func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
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 var previouslySelectedItem: Item? private(set) 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,21 +261,19 @@ class MainSidebarViewController: UIViewController {
} }
private func returnToPreviousItem() { private func returnToPreviousItem() {
let oldItem = selectedItem let item = previouslySelectedItem ?? .tab(.timelines)
let newItem = previouslySelectedItem ?? .tab(.timelines)
previouslySelectedItem = nil previouslySelectedItem = nil
select(item: newItem, animated: true) select(item: item, animated: true)
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem) sidebarDelegate?.sidebar(self, didSelectItem: item)
} }
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, previousItem: oldItem) self.sidebarDelegate?.sidebar(self, showViewController: list)
} }
service.run() service.run()
} }
@ -473,7 +471,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
fatalError("unreachable") fatalError("unreachable")
} }
} else { } else {
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem) sidebarDelegate?.sidebar(self, didSelectItem: item)
} }
} }
@ -542,9 +540,8 @@ 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), previousItem: oldItem) self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
} }
} }

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 {
doSelect(item: .tab(.timelines)) select(item: .tab(.timelines))
} }
if UIDevice.current.userInterfaceIdiom != .mac { if UIDevice.current.userInterfaceIdiom != .mac {
@ -149,15 +149,7 @@ class MainSplitViewController: UISplitViewController {
self.setViewController(newNav, for: .secondary) self.setViewController(newNav, for: .secondary)
} }
private func select(newItem: MainSidebarViewController.Item, oldItem: MainSidebarViewController.Item?) { func select(item: 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)
} }
@ -188,28 +180,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() {
@ -452,12 +444,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)
doSelect(item: item) select(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)
doSelect(item: exploreItem!) select(item: exploreItem!)
default: default:
return return
@ -482,13 +474,16 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
compose(editing: nil) compose(editing: nil)
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) { func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
select(newItem: item, oldItem: previousItem) if let previous = sidebar.previouslySelectedItem {
navigationStacks[previous] = secondaryNavController.viewControllers
}
select(item: item)
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) { func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
if let previousItem { if let previous = sidebar.previouslySelectedItem {
navigationStacks[previousItem] = secondaryNavController.viewControllers navigationStacks[previous] = secondaryNavController.viewControllers
} }
secondaryNavController.viewControllers = [viewController] secondaryNavController.viewControllers = [viewController]
} }
@ -542,14 +537,14 @@ extension MainSplitViewController: StateRestorableViewController {
} }
extension MainSplitViewController: TuskerRootViewController { extension MainSplitViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) { func select(route: TuskerRoute, animated: Bool) {
guard traitCollection.horizontalSizeClass != .compact else { guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated, completion: completion) tabBarViewController?.select(route: route, animated: animated)
return return
} }
guard presentedViewController == nil else { guard presentedViewController == nil else {
dismiss(animated: animated) { dismiss(animated: animated) {
self.select(route: route, animated: animated, completion: completion) self.select(route: route, animated: animated)
} }
return return
} }
@ -572,10 +567,8 @@ extension MainSplitViewController: TuskerRootViewController {
return return
} }
} }
let oldItem = sidebar.selectedItem
sidebar.select(item: item, animated: false) sidebar.select(item: item, animated: false)
select(newItem: item, oldItem: oldItem) select(item: item)
completion?()
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -617,7 +610,7 @@ extension MainSplitViewController: TuskerRootViewController {
} }
if sidebar.selectedItem != .explore { if sidebar.selectedItem != .explore {
select(newItem: .explore, oldItem: sidebar.selectedItem) select(item: .explore)
} }
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, completion: (() -> Void)?) { func select(route: TuskerRoute, animated: Bool) {
switch route { switch route {
case .timelines: case .timelines:
select(tab: .timelines, dismissPresented: true) select(tab: .timelines, dismissPresented: true)
@ -310,7 +310,6 @@ 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, completion: (() -> Void)?) func select(route: TuskerRoute, animated: Bool)
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,6 +21,33 @@ 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
@ -30,6 +57,33 @@ 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,7 +78,6 @@ struct MuteAccountView: View {
} }
.accessibilityHidden(true) .accessibilityHidden(true)
if mastodonController.instanceFeatures.muteNotifications {
Section { Section {
Toggle(isOn: $muteNotifications) { Toggle(isOn: $muteNotifications) {
Text("Hide notifications from this person") Text("Hide notifications from this person")
@ -91,7 +90,6 @@ struct MuteAccountView: View {
} }
} }
.appGroupedListRowBackground() .appGroupedListRowBackground()
}
Section { Section {
Picker(selection: $duration) { Picker(selection: $duration) {

View File

@ -9,12 +9,6 @@
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 {
@ -256,17 +250,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
state = .setupInitialSnapshot state = .setupInitialSnapshot
Task { Task {
do { if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID])) let relationship = all.first {
if let relationship = all.first {
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship) self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
} }
} catch {
logger.error("Error fetching relationship: \(String(describing: error))")
#if canImport(Sentry)
SentrySDK.capture(error: error)
#endif
}
} }
await controller.loadInitial() await controller.loadInitial()
@ -310,7 +297,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -526,7 +513,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<[TryDecode<Status>]> { private func request(for range: RequestRange = .default) -> Request<[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)
@ -539,7 +526,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty { if !statuses.isEmpty {
newer = .after(id: statuses.first!.id, count: nil) newer = .after(id: statuses.first!.id, count: nil)
@ -559,7 +546,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: newer) let request = request(for: newer)
let statuses = try await mastodonController.run(request).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
throw Error.allCaughtUp throw Error.allCaughtUp
@ -580,7 +567,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
} }
let request = request(for: older) let request = request(for: older)
let statuses = try await mastodonController.run(request).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(req)
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,11 +24,7 @@ class MastodonSearchController: UISearchController {
super.searchResultsController as! SearchResultsViewController super.searchResultsController as! SearchResultsViewController
} }
private weak var owner: UIViewController? init(searchResultsController: SearchResultsViewController) {
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
@ -156,12 +152,6 @@ 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.compactMap(\.value)) addStatuses(results.statuses)
} 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.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses) snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
} }
dataSource.apply(snapshot) dataSource.apply(snapshot)

View File

@ -30,8 +30,6 @@ 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) {
@ -42,10 +40,3 @@ 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,8 +565,7 @@ 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
// 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
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)
@ -1101,7 +1100,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
@ -1120,7 +1119,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
throw TimelineViewController.Error.allCaughtUp throw TimelineViewController.Error.allCaughtUp
@ -1144,7 +1143,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
return [] return []
@ -1182,7 +1181,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).0.compactMap(\.value) let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
return [] return []

View File

@ -8,30 +8,19 @@
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
private var _viewControllers: [UIViewController] = [] var viewControllers: [UIViewController] = [] {
var viewControllers: [UIViewController] { didSet {
get { guard isViewLoaded,
_viewControllers !isManuallyUpdating else {
return
} }
set {
_viewControllers = newValue
if isViewLoaded,
!isManuallyUpdating {
updateViews() updateViews()
scrollToEnd(animated: false) scrollToEnd(animated: false)
} }
} }
}
private var scrollView = UIScrollView() private var scrollView = UIScrollView()
private var stackView = UIStackView() private var stackView = UIStackView()
@ -79,13 +68,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
@ -103,11 +92,9 @@ 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
} }
@ -125,20 +112,19 @@ 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)
} }
} }
@ -156,12 +142,14 @@ class MultiColumnNavigationController: UIViewController {
} }
fileprivate func closeColumn(_ vc: UIViewController) { fileprivate func closeColumn(_ vc: UIViewController) {
guard let index = _viewControllers.firstIndex(of: vc), let index = viewControllers.firstIndex(of: vc)!
index > 0 else { guard index > 0 else {
// Can't close the last column // Can't close the last column
return return
} }
_viewControllers.removeSubrange(index...) isManuallyUpdating = true
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
@ -170,6 +158,7 @@ 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) {
@ -184,22 +173,19 @@ 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]? {
guard !_viewControllers.isEmpty else { let removed = Array(viewControllers.dropFirst())
return nil viewControllers = [viewControllers.first!]
}
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) {
_viewControllers.append(vc) isManuallyUpdating = true
defer { isManuallyUpdating = false }
viewControllers.append(vc)
updateViews() updateViews()
scrollToEnd(animated: animated) scrollToEnd(animated: animated)
if animated { if animated {
@ -287,17 +273,12 @@ private class ColumnView: UIView {
} }
private func installCloseBarButton(navigationItem: UINavigationItem) { private func installCloseBarButton(navigationItem: UINavigationItem) {
func makeItem() -> UIBarButtonItem {
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn)) let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
item.accessibilityLabel = "Close Column" item.accessibilityLabel = "Close Column"
return item if navigationItem.leftBarButtonItems != nil {
} navigationItem.leftBarButtonItems!.insert(item, at: 0)
if let leftItems = navigationItem.leftBarButtonItems {
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
}
} else { } else {
navigationItem.leftBarButtonItems = [makeItem()] navigationItem.leftBarButtonItems = [item]
} }
} }

View File

@ -557,15 +557,11 @@ 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")
var children = [ return UIMenu(title: "Block", image: image, 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)
} }
} }
@ -596,8 +592,7 @@ 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, guard relationship.following || relationship.showingReblogs else {
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,11 +50,8 @@ extension UIViewController {
} }
func removeViewAndController() { func removeViewAndController() {
beginAppearanceTransition(false, animated: false)
view.removeFromSuperview() view.removeFromSuperview()
willMove(toParent: nil)
removeFromParent() removeFromParent()
endAppearanceTransition()
} }
} }
@ -71,7 +68,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, completion: nil) root.select(route: .timelines, animated: false)
case .showNotifications: case .showNotifications:
root.select(route: .notifications, animated: false, completion: nil) root.select(route: .notifications, animated: false)
case .composePost: case .composePost:
root.compose(editing: nil, animated: false, isDucked: false) root.compose(editing: nil, animated: false, isDucked: false)
} }

View File

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

View File

@ -16,8 +16,7 @@ import ComposeUI
protocol UserActivityHandlingContext { protocol UserActivityHandlingContext {
var isHandoff: Bool { get } var isHandoff: Bool { get }
func select(route: TuskerRoute) async func select(route: TuskerRoute)
func select(route: TuskerRoute, completion: (() -> Void)?)
func present(_ vc: UIViewController) func present(_ vc: UIViewController)
var topViewController: UIViewController? { get } var topViewController: UIViewController? { get }
@ -29,16 +28,6 @@ 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
@ -46,8 +35,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
root.getNavigationDelegate()! root.getNavigationDelegate()!
} }
func select(route: TuskerRoute, completion: (() -> Void)?) { func select(route: TuskerRoute) {
root.select(route: route, animated: true, completion: completion) root.select(route: route, animated: true)
} }
func present(_ vc: UIViewController) { func present(_ vc: UIViewController) {
@ -82,11 +71,9 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
var isHandoff: Bool { false } var isHandoff: Bool { false }
func select(route: TuskerRoute, completion: (() -> Void)?) { func select(route: TuskerRoute) {
root.select(route: route, animated: false) { root.select(route: route, animated: false)
self.state = .selectedRoute 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) async { func handleCheckNotifications(activity: NSUserActivity) {
await context.select(route: .notifications) 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) async { func handleShowTimeline(activity: NSUserActivity) {
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) {
await context.select(route: .timelines) 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 {
await context.select(route: .list(id: id)) context.select(route: .list(id: id))
timelineVC = context.topViewController as? TimelineViewController timelineVC = context.topViewController as? TimelineViewController
} else { } else {
await context.select(route: .explore) 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) async { func handleShowConversation(activity: NSUserActivity) {
guard let mainStatusID = Self.getConversationStatus(from: activity) else { guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return return
} }
await context.select(route: .timelines) 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) async { func handleSearch(activity: NSUserActivity) {
await context.select(route: .explore) 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) async { func handleBookmarks(activity: NSUserActivity) {
await context.select(route: .bookmarks) context.select(route: .bookmarks)
} }
// MARK: - My Profile // MARK: - My Profile
@ -325,8 +325,8 @@ class UserActivityManager {
return activity return activity
} }
func handleMyProfile(activity: NSUserActivity) async { func handleMyProfile(activity: NSUserActivity) {
await context.select(route: .myProfile) 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) async { func handleShowProfile(activity: NSUserActivity) {
guard let accountID = Self.getProfile(from: activity) else { guard let accountID = Self.getProfile(from: activity) else {
return return
} }
await context.select(route: .timelines) 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) async { func handleShowNotification(activity: NSUserActivity) {
guard let notificationID = activity.userInfo?["notificationID"] as? String else { guard let notificationID = activity.userInfo?["notificationID"] as? String else {
return return
} }
await context.select(route: .notifications) 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) async -> Void { var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
switch self { switch self {
case .mainScene: case .mainScene:
fatalError("cannot handle main scene activity") fatalError("cannot handle main scene activity")

View File

@ -33,11 +33,6 @@ 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,8 +36,6 @@ 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.
@ -80,9 +78,8 @@ 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
tapRecognizer.addTarget(self, action: #selector(textTapped(_:))) let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
tapRecognizer.delegate = self addGestureRecognizer(recognizer)
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 =
@ -135,6 +132,12 @@ 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 {
@ -381,25 +384,3 @@ 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,11 +12,7 @@ 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),
@ -27,8 +23,9 @@ 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 textView = ContentTextView() private let label = EmojiLabel()
private var iconView: UIView? private var iconView: UIView?
private var currentTargetedPreview: UITargetedPreview? private var currentTargetedPreview: UITargetedPreview?
@ -41,28 +38,34 @@ 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 converted.length != 0,
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
link = (converted.attributedSubstring(from: range).string, url)
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
label.addInteraction(UIContextMenuInteraction(delegate: self))
label.isUserInteractionEnabled = true
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
guard value != nil else { return }
#if os(visionOS) #if os(visionOS)
textView.linkTextAttributes = [ converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
.foregroundColor: UIColor.link
]
#else #else
textView.linkTextAttributes = [ converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
.foregroundColor: UIColor.tintColor
]
#endif #endif
textView.backgroundColor = nil // the .link attribute in a UILabel always makes the color blue >.>
textView.isScrollEnabled = false converted.removeAttribute(.link, range: range)
textView.isSelectable = false }
textView.isEditable = false }
textView.textContainerInset = .zero
textView.font = .preferredFont(forTextStyle: .body) label.numberOfLines = 0
textView.adjustsFontForContentSizeCategory = true label.font = .preferredFont(forTextStyle: .body)
textView.attributedText = converted label.adjustsFontForContentSizeCategory = true
textView.setEmojis(account.emojis, identifier: account.id) label.attributedText = converted
textView.isUserInteractionEnabled = true label.setEmojis(account.emojis, identifier: account.id)
textView.setContentCompressionResistancePriority(.required, for: .vertical) label.setContentCompressionResistancePriority(.required, for: .vertical)
textView.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView) addSubview(label)
let labelTrailingConstraint: NSLayoutConstraint let labelTrailingConstraint: NSLayoutConstraint
@ -79,20 +82,20 @@ class ProfileFieldValueView: UIView {
icon.isPointerInteractionEnabled = true icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link" icon.accessibilityLabel = "Verified link"
addSubview(icon) addSubview(icon)
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor) labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor), icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
]) ])
} else { } else {
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor) labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
} }
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor), label.leadingAnchor.constraint(equalTo: leadingAnchor),
labelTrailingConstraint, labelTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor), label.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor),
]) ])
} }
@ -101,7 +104,7 @@ class ProfileFieldValueView: UIView {
} }
override func sizeThatFits(_ size: CGSize) -> CGSize { override func sizeThatFits(_ size: CGSize) -> CGSize {
var size = textView.sizeThatFits(size) var size = label.sizeThatFits(size)
if let iconView { if let iconView {
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
} }
@ -109,7 +112,29 @@ class ProfileFieldValueView: UIView {
} }
func setTextAlignment(_ alignment: NSTextAlignment) { func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment label.textAlignment = alignment
}
func getHashtagOrURL() -> (Hashtag?, URL)? {
guard let (text, url) = link else {
return nil
}
if text.starts(with: "#") {
return (Hashtag(name: String(text.dropFirst()), url: url), url)
} else {
return (nil, url)
}
}
@objc private func linkTapped() {
guard let (hashtag, url) = getHashtagOrURL() else {
return
}
if let hashtag {
navigationDelegate?.selected(tag: hashtag)
} else {
navigationDelegate?.selected(url: url)
}
} }
@objc private func verifiedIconTapped() { @objc private func verifiedIconTapped() {
@ -119,7 +144,7 @@ class ProfileFieldValueView: UIView {
let view = ProfileFieldVerificationView( let view = ProfileFieldVerificationView(
acct: account.acct, acct: account.acct,
verifiedAt: field.verifiedAt!, verifiedAt: field.verifiedAt!,
linkText: textView.text ?? "", linkText: label.text ?? "",
navigationDelegate: navigationDelegate navigationDelegate: navigationDelegate
) )
let host = UIHostingController(rootView: view) let host = UIHostingController(rootView: view)
@ -143,3 +168,49 @@ 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: CachedImageView! @IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: CachedImageView! @IBOutlet weak var avatarImageView: UIImageView!
@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,6 +44,8 @@ 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 {
@ -54,6 +56,10 @@ class ProfileHeaderView: UIView {
} }
} }
deinit {
imagesTask?.cancel()
}
override func awakeFromNib() { override func awakeFromNib() {
super.awakeFromNib() super.awakeFromNib()
@ -63,13 +69,11 @@ 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
@ -134,11 +138,11 @@ class ProfileHeaderView: UIView {
usernameLabel.text = "@\(account.acct)" usernameLabel.text = "@\(account.acct)"
lockImageView.isHidden = !account.locked lockImageView.isHidden = !account.locked
if let avatar = account.avatar { imagesTask?.cancel()
avatarImageView.update(for: avatar) let avatar = account.avatar
} let header = account.header
if let header = account.header { imagesTask = Task {
headerImageView.update(for: header) await updateImages(avatar: avatar, header: 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) ?? [])
@ -290,6 +294,44 @@ 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="22685"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
<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" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv">
<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" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target"> <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
<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" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc"> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" 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" pointerInteraction="YES" id="XCX-Y3-cG5"> <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" 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, from: button.imageView!) var rect = button.convert(button.imageView!.bounds, to: 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, from: button.imageView!) var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
rect = rect.insetBy(dx: -8, dy: -8) rect = rect.insetBy(dx: -24, dy: -24)
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect)) return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
} }
return nil return nil

View File

@ -256,7 +256,6 @@ 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([
@ -279,7 +278,6 @@ 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 = 127 CURRENT_PROJECT_VERSION = 126
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