Compare commits
34 Commits
9df3c33c6c
...
8557e110a8
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 8557e110a8 | |
Shadowfacts | c2232a5e14 | |
Shadowfacts | e6d9a33dbf | |
Shadowfacts | d8fccc8f1b | |
Shadowfacts | 6528070f1c | |
Shadowfacts | 09c6a87e19 | |
Shadowfacts | cd0d8fffcb | |
Shadowfacts | 1b6f0c07fd | |
Shadowfacts | 2f31b50a5b | |
Shadowfacts | cee4e15b06 | |
Shadowfacts | 888f44366c | |
Shadowfacts | c88076eec0 | |
Shadowfacts | afe47437e4 | |
Shadowfacts | 4dc484c3c2 | |
Shadowfacts | 0f2a85b108 | |
Shadowfacts | 5e55ce75c2 | |
Shadowfacts | eec2adbfd9 | |
Shadowfacts | a848f6e425 | |
Shadowfacts | 44896d305e | |
Shadowfacts | 6c70ed4b4e | |
Shadowfacts | e3c480131a | |
Shadowfacts | 575166f5b4 | |
Shadowfacts | c60aa3e3f3 | |
Shadowfacts | 75f0d12c82 | |
Shadowfacts | 5cf2bc4fbf | |
Shadowfacts | 908b499f8f | |
Shadowfacts | 67c7905acf | |
Shadowfacts | eacafe87b3 | |
Shadowfacts | 2a53b24487 | |
Shadowfacts | 06ba758309 | |
Shadowfacts | 2c56902389 | |
Shadowfacts | 5620b6ab78 | |
Shadowfacts | 3d0de5af04 | |
Shadowfacts | 966a906436 |
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -1,5 +1,29 @@
|
|||
# Changelog
|
||||
|
||||
## 2024.3 (127)
|
||||
Bugfixes:
|
||||
- Fix Remove Suggestion context menu action missing from Suggested Accounts screen
|
||||
- Fix profile header images being blurry
|
||||
- Fix dismissing gallery when presented from sheet
|
||||
- Fix potential crash in multi-column interface
|
||||
- Fix crash when opening push notification while sheet presented
|
||||
- Fix being able to block your own domain
|
||||
- Fix links in profile fields with other text not being interactable
|
||||
- Fix excessive CPU use immediately after app launch
|
||||
- Fix timeline failing to load when one status is malformed
|
||||
- iPadOS: Fix pointer interactions on conversation main status action buttons
|
||||
- iPadOS: Fix multiple close buttons being added in multi-column interface
|
||||
- iPadOS: Fix Cmd+1/etc. resetting navigation state when returning to previous column
|
||||
- iPadOS: Fix previous sidebar selection losing navigation state in some circumstances
|
||||
- iPadOS: Fix profile followers/following buttons not having pointer effect
|
||||
- iPadOS: Fix search token suggestions not having pointer effect
|
||||
- iPadOS: Fix conversation thread links appearing above avatar during pointer effect
|
||||
- iPadOS: Fix multi-column interface not animating scroll when replacing subsequent columns
|
||||
- iPadOS: Fix not being able to select text on conversation main status by double-clicking with cursor
|
||||
- iPadOS: Fix selecting search result always pushing new column rather than replacing
|
||||
- Pixelfed/Firefish: Fix error loading accounts in some circumstances
|
||||
- Pixelfed: Fix loading relationships and follow/block/etc. actions not working
|
||||
|
||||
## 2024.3 (126)
|
||||
Bugfixes:
|
||||
- Fix an issue displaying post HTML in certain edge cases
|
||||
|
|
|
@ -63,6 +63,7 @@ class NotificationService: UNNotificationServiceExtension {
|
|||
mutableContent.body = notification.body
|
||||
mutableContent.userInfo["notificationID"] = notification.notificationID
|
||||
mutableContent.userInfo["accountID"] = accountID
|
||||
mutableContent.targetContentIdentifier = accountID
|
||||
|
||||
let task = Task {
|
||||
await updateNotificationContent(mutableContent, account: account, push: notification)
|
||||
|
|
|
@ -52,15 +52,22 @@ class GalleryDismissAnimationController: NSObject, UIViewControllerAnimatedTrans
|
|||
appliedSourceToDestTransform = false
|
||||
}
|
||||
|
||||
to.view.frame = container.bounds
|
||||
// Moving `to.view` to the container is necessary when the presenting VC (i.e., `to`)
|
||||
// is in the window's root presentation.
|
||||
// But it breaks when the gallery is presented from a sheet-presented VC--in which case
|
||||
// `to.view` is already in the view hierarchy at this point; and adding it to the
|
||||
// container causees it to be removed when the transition completes.
|
||||
if to.view.superview == nil {
|
||||
to.view.frame = container.bounds
|
||||
container.addSubview(to.view)
|
||||
}
|
||||
|
||||
from.view.frame = container.bounds
|
||||
container.addSubview(from.view)
|
||||
|
||||
let content = itemViewController.takeContent()
|
||||
content.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
content.view.layer.masksToBounds = true
|
||||
|
||||
container.addSubview(to.view)
|
||||
container.addSubview(from.view)
|
||||
container.addSubview(content.view)
|
||||
|
||||
content.view.frame = destFrameInContainer
|
||||
|
|
|
@ -217,6 +217,18 @@ public final class InstanceFeatures: ObservableObject {
|
|||
instanceType.isPleroma
|
||||
}
|
||||
|
||||
public var muteNotifications: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var blockDomains: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public var hideReblogs: Bool {
|
||||
!instanceType.isPixelfed
|
||||
}
|
||||
|
||||
public init() {
|
||||
}
|
||||
|
||||
|
@ -338,6 +350,14 @@ extension InstanceFeatures {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isPixelfed: Bool {
|
||||
if case .pixelfed = self {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(InstanceType) public enum MastodonType {
|
||||
|
|
|
@ -42,8 +42,7 @@ public struct Client: Sendable {
|
|||
} else if let date = iso8601.date(from: str) {
|
||||
return date
|
||||
} else {
|
||||
// throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
return Date(timeIntervalSinceReferenceDate: 0)
|
||||
throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format: \(str)"))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -205,8 +204,8 @@ public struct Client: Sendable {
|
|||
return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
|
||||
}
|
||||
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
|
||||
public static func getFavourites(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/favourites")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -457,14 +456,13 @@ public struct Client: Sendable {
|
|||
}
|
||||
|
||||
// MARK: - Timelines
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
|
||||
public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
return timeline.request(range: range)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Bookmarks
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
|
||||
public static func getBookmarks(range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/bookmarks")
|
||||
request.range = range
|
||||
return request
|
||||
}
|
||||
|
@ -492,7 +490,7 @@ public struct Client: Sendable {
|
|||
return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
|
||||
}
|
||||
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
|
||||
public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var parameters: [Parameter] = []
|
||||
if let limit {
|
||||
parameters.append("limit" => limit)
|
||||
|
|
|
@ -40,8 +40,9 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
self.displayName = try container.decode(String.self, forKey: .displayName)
|
||||
self.locked = try container.decode(Bool.self, forKey: .locked)
|
||||
self.createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
self.followersCount = try container.decode(Int.self, forKey: .followersCount)
|
||||
self.followingCount = try container.decode(Int.self, forKey: .followingCount)
|
||||
// some instance types (pixelfed, firefish) seem to sometimes send null for these fields, so just fallback to 0
|
||||
self.followersCount = try container.decodeIfPresent(Int.self, forKey: .followersCount) ?? 0
|
||||
self.followingCount = try container.decodeIfPresent(Int.self, forKey: .followingCount) ?? 0
|
||||
self.statusesCount = try container.decode(Int.self, forKey: .statusesCount)
|
||||
self.note = try container.decode(String.self, forKey: .note)
|
||||
self.url = try container.decode(URL.self, forKey: .url)
|
||||
|
@ -94,8 +95,8 @@ public final class Account: AccountProtocol, Decodable, Sendable {
|
|||
return request
|
||||
}
|
||||
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[Status]> {
|
||||
var request = Request<[Status]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
public static func getStatuses(_ accountID: String, range: RequestRange = .default, onlyMedia: Bool? = nil, pinned: Bool? = nil, excludeReplies: Bool? = nil, excludeReblogs: Bool? = nil) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: "/api/v1/accounts/\(accountID)/statuses", queryParameters: [
|
||||
"only_media" => onlyMedia,
|
||||
"pinned" => pinned,
|
||||
"exclude_replies" => excludeReplies,
|
||||
|
|
|
@ -27,10 +27,13 @@ public struct Relationship: RelationshipProtocol, Decodable, Sendable {
|
|||
self.followedBy = try container.decode(Bool.self, forKey: .followedBy)
|
||||
self.blocking = try container.decode(Bool.self, forKey: .blocking)
|
||||
self.muting = try container.decode(Bool.self, forKey: .muting)
|
||||
self.mutingNotifications = try container.decode(Bool.self, forKey: .mutingNotifications)
|
||||
// not supported on pixelfed
|
||||
self.mutingNotifications = try container.decodeIfPresent(Bool.self, forKey: .mutingNotifications) ?? false
|
||||
self.followRequested = try container.decode(Bool.self, forKey: .followRequested)
|
||||
self.domainBlocking = try container.decode(Bool.self, forKey: .domainBlocking)
|
||||
self.showingReblogs = try container.decode(Bool.self, forKey: .showingReblogs)
|
||||
// not supported on pixelfed
|
||||
self.domainBlocking = try container.decodeIfPresent(Bool.self, forKey: .domainBlocking) ?? false
|
||||
// not supported on pixelfed
|
||||
self.showingReblogs = try container.decodeIfPresent(Bool.self, forKey: .showingReblogs) ?? true
|
||||
self.endorsed = try container.decodeIfPresent(Bool.self, forKey: .endorsed) ?? false
|
||||
}
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import Foundation
|
|||
|
||||
public struct SearchResults: Decodable, Sendable {
|
||||
public let accounts: [Account]
|
||||
public let statuses: [Status]
|
||||
public let statuses: [TryDecode<Status>]
|
||||
public let hashtags: [Hashtag]
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
|
@ -32,8 +32,8 @@ extension Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
func request(range: RequestRange) -> Request<[Status]> {
|
||||
var request: Request<[Status]> = Request<[Status]>(method: .get, path: endpoint)
|
||||
func request(range: RequestRange) -> Request<[TryDecode<Status>]> {
|
||||
var request = Request<[TryDecode<Status>]>(method: .get, path: endpoint)
|
||||
if case .public(true) = self {
|
||||
request.queryParameters.append("local" => true)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
//
|
||||
// TryDecode.swift
|
||||
// Pachyderm
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TryDecode<T: Decodable>: Decodable {
|
||||
case error(String)
|
||||
case value(T)
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
do {
|
||||
self = .value(try T(from: decoder))
|
||||
} catch {
|
||||
self = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
public var value: T? {
|
||||
if case .value(let value) = self {
|
||||
value
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TryDecode: Sendable where T: Sendable {
|
||||
}
|
|
@ -75,6 +75,7 @@
|
|||
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
|
||||
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
|
||||
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */; };
|
||||
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D621733228F1D5ED004C7DB1 /* ReblogService.swift */; };
|
||||
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */; };
|
||||
D623A53D2635F5590095BD04 /* StatusPollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D623A53C2635F5590095BD04 /* StatusPollView.swift */; };
|
||||
|
@ -258,6 +259,7 @@
|
|||
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C10E25B62D2400298D0F /* DiskCache.swift */; };
|
||||
D6A6C11525B62E9700298D0F /* CacheExpiry.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11425B62E9700298D0F /* CacheExpiry.swift */; };
|
||||
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */; };
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */; };
|
||||
D6AC956723C4347E008C9946 /* MainSceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */; };
|
||||
D6ADB6E828E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */; };
|
||||
D6ADB6EA28E91C30009924AB /* TimelineStatusCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ADB6E928E91C30009924AB /* TimelineStatusCollectionViewCell.swift */; };
|
||||
|
@ -506,6 +508,7 @@
|
|||
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
|
||||
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
|
||||
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveProfileSuggestionService.swift; sourceTree = "<group>"; };
|
||||
D621733228F1D5ED004C7DB1 /* ReblogService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReblogService.swift; sourceTree = "<group>"; };
|
||||
D62275A724F1CA2800B82A16 /* ComposeReplyContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeReplyContentView.swift; sourceTree = "<group>"; };
|
||||
D623A53C2635F5590095BD04 /* StatusPollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPollView.swift; sourceTree = "<group>"; };
|
||||
|
@ -685,6 +688,7 @@
|
|||
D6A6C10E25B62D2400298D0F /* DiskCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskCache.swift; sourceTree = "<group>"; };
|
||||
D6A6C11425B62E9700298D0F /* CacheExpiry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheExpiry.swift; sourceTree = "<group>"; };
|
||||
D6A6C11A25B63CEE00298D0F /* MemoryCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryCache.swift; sourceTree = "<group>"; };
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentHistoryTokenStore.swift; sourceTree = "<group>"; };
|
||||
D6A9E04F29F8917500BEDC7E /* MatchedGeometryPresentation */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = MatchedGeometryPresentation; sourceTree = "<group>"; };
|
||||
D6AC956623C4347E008C9946 /* MainSceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSceneDelegate.swift; sourceTree = "<group>"; };
|
||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicTimelineDescriptionCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
|
@ -1049,6 +1053,7 @@
|
|||
D608470E2A245D1F00C17380 /* ActiveInstance.swift */,
|
||||
D60E2F2D244248BF005F8713 /* MastodonCachePersistentStore.swift */,
|
||||
D6B936702829F72900237D0E /* NSManagedObjectContext+Helpers.swift */,
|
||||
D6A8D7A42C14DB280007B285 /* PersistentHistoryTokenStore.swift */,
|
||||
);
|
||||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1757,6 +1762,7 @@
|
|||
D6DD8FFC298495A8002AD3FD /* LogoutService.swift */,
|
||||
D6D79F252A0C8D2700AB2315 /* FetchStatusSourceService.swift */,
|
||||
D630C3CB2BC5FD4600208903 /* GetAuthorizationTokenService.swift */,
|
||||
D6210D752C0B924F009BB569 /* RemoveProfileSuggestionService.swift */,
|
||||
);
|
||||
path = API;
|
||||
sourceTree = "<group>";
|
||||
|
@ -2362,6 +2368,7 @@
|
|||
D698F46D2BD0B8310054DB14 /* AnnouncementsCollection.swift in Sources */,
|
||||
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
|
||||
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
|
||||
D6210D762C0B924F009BB569 /* RemoveProfileSuggestionService.swift in Sources */,
|
||||
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
|
||||
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
|
||||
04586B4122B2FFB10021BD04 /* PreferencesView.swift in Sources */,
|
||||
|
@ -2408,6 +2415,7 @@
|
|||
D68A76E829527884001DA1B3 /* PinnedTimelinesView.swift in Sources */,
|
||||
D690797324A4EF9700023A34 /* UIBezierPath+Helpers.swift in Sources */,
|
||||
D6D12B5A292D684600D528E1 /* AccountListViewController.swift in Sources */,
|
||||
D6A8D7A52C14DB280007B285 /* PersistentHistoryTokenStore.swift in Sources */,
|
||||
D693DE5723FE1A6A0061E07D /* EnhancedNavigationViewController.swift in Sources */,
|
||||
D646DCDC2A081CF10059ECEB /* StatusUpdatedNotificationCollectionViewCell.swift in Sources */,
|
||||
);
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
//
|
||||
// RemoveProfileSuggestionService.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/1/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Pachyderm
|
||||
|
||||
@MainActor
|
||||
class RemoveProfileSuggestionService {
|
||||
private let accountID: String
|
||||
private let mastodonController: MastodonController
|
||||
private let presenter: any TuskerNavigationDelegate
|
||||
private let completionHandler: @MainActor () -> Void
|
||||
|
||||
init(accountID: String, mastodonController: MastodonController, presenter: any TuskerNavigationDelegate, completionHandler: @MainActor @escaping () -> Void) {
|
||||
self.accountID = accountID
|
||||
self.mastodonController = mastodonController
|
||||
self.presenter = presenter
|
||||
self.completionHandler = completionHandler
|
||||
}
|
||||
|
||||
func run() async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
completionHandler()
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: presenter) { toast in
|
||||
toast.dismissToast(animated: true)
|
||||
await self.run()
|
||||
}
|
||||
self.presenter.showToast(configuration: config, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -290,12 +290,18 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
|||
// if the scene is already active, then we animate the account switching if necessary
|
||||
delegate.activateAccount(account, animated: scene.activationState == .foregroundActive)
|
||||
|
||||
rootViewController.select(route: .notifications, animated: false)
|
||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||
rootViewController.select(route: .notifications, animated: false) {
|
||||
let vc = NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController)
|
||||
rootViewController.getNavigationController().pushViewController(vc, animated: false)
|
||||
}
|
||||
} else {
|
||||
let activity = UserActivityManager.showNotificationActivity(id: notificationID, accountID: accountID)
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
if #available(iOS 17.0, *) {
|
||||
let request = UISceneSessionActivationRequest(userActivity: activity)
|
||||
UIApplication.shared.activateSceneSession(for: request)
|
||||
} else {
|
||||
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
|
||||
}
|
||||
}
|
||||
completionHandler()
|
||||
}
|
||||
|
|
|
@ -48,8 +48,6 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
return context
|
||||
}()
|
||||
|
||||
private var lastRemoteChangeToken: NSPersistentHistoryToken?
|
||||
|
||||
// TODO: consider sending managed objects through this to avoid re-fetching things unnecessarily
|
||||
// would need to audit existing uses to make sure everything happens on the main thread
|
||||
// and when updating things on the background context would need to switch to main, refetch, and then publish
|
||||
|
@ -190,8 +188,10 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
||||
viewContext.name = "View"
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||
if accountInfo != nil {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(remoteChanges), name: .NSPersistentStoreRemoteChange, object: persistentStoreCoordinator)
|
||||
}
|
||||
}
|
||||
|
||||
func save(context: NSManagedObjectContext) {
|
||||
|
@ -521,58 +521,82 @@ class MastodonCachePersistentStore: NSPersistentCloudKitContainer {
|
|||
}
|
||||
|
||||
@objc private func remoteChanges(_ notification: Foundation.Notification) {
|
||||
guard let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
guard let accountInfo,
|
||||
let token = notification.userInfo?[NSPersistentHistoryTokenKey] as? NSPersistentHistoryToken else {
|
||||
return
|
||||
}
|
||||
remoteChangesBackgroundContext.perform {
|
||||
defer {
|
||||
self.lastRemoteChangeToken = token
|
||||
PersistentHistoryTokenStore.token(for: accountInfo) { lastToken in
|
||||
self.remoteChangesBackgroundContext.perform {
|
||||
defer {
|
||||
PersistentHistoryTokenStore.setToken(token, for: accountInfo)
|
||||
}
|
||||
let transactions: [NSPersistentHistoryTransaction]
|
||||
do {
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: lastToken)
|
||||
if let result = try self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult {
|
||||
transactions = result.result as? [NSPersistentHistoryTransaction] ?? []
|
||||
} else {
|
||||
logger.error("Unexpectedly non-NSPersistentHistoryResult")
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
logger.error("Unable to fetch persistent history results: \(String(describing: error), privacy: .public)")
|
||||
return
|
||||
}
|
||||
if !transactions.isEmpty {
|
||||
self.processPersistentHistoryTransactions(transactions)
|
||||
}
|
||||
|
||||
// NB: We deliberately do not purge old persistent history.
|
||||
// Doing so causes the CoreData+CloudKit integration to replay all of
|
||||
// the server's changes on initialization, which takes a long time
|
||||
// and produces a bunch of intermediate UI updates we don't want.
|
||||
}
|
||||
let req = NSPersistentHistoryChangeRequest.fetchHistory(after: self.lastRemoteChangeToken)
|
||||
if let result = try? self.remoteChangesBackgroundContext.execute(req) as? NSPersistentHistoryResult,
|
||||
let transactions = result.result as? [NSPersistentHistoryTransaction],
|
||||
!transactions.isEmpty {
|
||||
var changedHashtags = false
|
||||
var changedInstances = false
|
||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||
var changedAccountPrefs = false
|
||||
outer: for transaction in transactions {
|
||||
for change in transaction.changes ?? [] {
|
||||
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||
changedHashtags = true
|
||||
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||
changedInstances = true
|
||||
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
||||
changedTimelinePositions.insert(change.changedObjectID)
|
||||
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
||||
changedAccountPrefs = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processPersistentHistoryTransactions(_ transactions: [NSPersistentHistoryTransaction]) {
|
||||
logger.info("Processing \(transactions.count) persistent history transactions")
|
||||
var changedHashtags = false
|
||||
var changedInstances = false
|
||||
var changedTimelinePositions = Set<NSManagedObjectID>()
|
||||
var changedAccountPrefs = false
|
||||
outer: for transaction in transactions {
|
||||
logger.info("Processing \(transaction.changes?.count ?? 0) changes in transaction")
|
||||
for change in transaction.changes ?? [] {
|
||||
if change.changedObjectID.entity.name == "SavedHashtag" {
|
||||
changedHashtags = true
|
||||
} else if change.changedObjectID.entity.name == "SavedInstance" {
|
||||
changedInstances = true
|
||||
} else if change.changedObjectID.entity.name == "TimelinePosition" {
|
||||
changedTimelinePositions.insert(change.changedObjectID)
|
||||
} else if change.changedObjectID.entity.name == "AccountPreferences" {
|
||||
changedAccountPrefs = true
|
||||
}
|
||||
// Can't capture vars in concurrently-executing closure
|
||||
let hashtags = changedHashtags
|
||||
let instances = changedInstances
|
||||
let timelinePositions = changedTimelinePositions
|
||||
let accountPrefs = changedAccountPrefs
|
||||
DispatchQueue.main.async {
|
||||
if hashtags {
|
||||
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
||||
}
|
||||
if instances {
|
||||
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
||||
}
|
||||
for id in timelinePositions {
|
||||
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
||||
continue
|
||||
}
|
||||
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
||||
timelinePosition.changedRemotely()
|
||||
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
||||
}
|
||||
if accountPrefs {
|
||||
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Can't capture vars in concurrently-executing closure
|
||||
let hashtags = changedHashtags
|
||||
let instances = changedInstances
|
||||
let timelinePositions = changedTimelinePositions
|
||||
let accountPrefs = changedAccountPrefs
|
||||
DispatchQueue.main.async {
|
||||
if hashtags {
|
||||
NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil)
|
||||
}
|
||||
if instances {
|
||||
NotificationCenter.default.post(name: .savedInstancesChanged, object: nil)
|
||||
}
|
||||
for id in timelinePositions {
|
||||
guard let timelinePosition = try? self.viewContext.existingObject(with: id) as? TimelinePosition else {
|
||||
continue
|
||||
}
|
||||
// the kvo observer that clears the LazilyDecoding cache doesn't always fire on remote changes, so do it manually
|
||||
timelinePosition.changedRemotely()
|
||||
NotificationCenter.default.post(name: .timelinePositionChanged, object: timelinePosition)
|
||||
}
|
||||
if accountPrefs {
|
||||
NotificationCenter.default.post(name: .accountPreferencesChangedRemotely, object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
//
|
||||
// PersistentHistoryTokenStore.swift
|
||||
// Tusker
|
||||
//
|
||||
// Created by Shadowfacts on 6/8/24.
|
||||
// Copyright © 2024 Shadowfacts. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
import UserAccounts
|
||||
|
||||
struct PersistentHistoryTokenStore {
|
||||
private static let queue = DispatchQueue(label: "PersistentHistoryTokenStore")
|
||||
|
||||
private static var tokens: [String: NSPersistentHistoryToken] = (try? load()) ?? [:]
|
||||
|
||||
private static let applicationSupportDirectory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
private static let storeURL = applicationSupportDirectory.appendingPathComponent("PersistentHistoryTokenStore.plist")
|
||||
|
||||
private static func load() throws -> [String: NSPersistentHistoryToken]? {
|
||||
let data = try Data(contentsOf: storeURL)
|
||||
let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSDictionary.self, NSString.self, NSPersistentHistoryToken.self], from: data)
|
||||
return unarchived as? [String: NSPersistentHistoryToken]
|
||||
}
|
||||
|
||||
private static func save() throws {
|
||||
let data = try NSKeyedArchiver.archivedData(withRootObject: tokens as [NSString: NSPersistentHistoryToken], requiringSecureCoding: true)
|
||||
try data.write(to: PersistentHistoryTokenStore.storeURL)
|
||||
}
|
||||
|
||||
static func token(for account: UserAccountInfo, completion: @escaping (NSPersistentHistoryToken?) -> Void) {
|
||||
queue.async {
|
||||
completion(tokens[account.id])
|
||||
}
|
||||
}
|
||||
|
||||
static func setToken(_ token: NSPersistentHistoryToken, for account: UserAccountInfo) {
|
||||
queue.async {
|
||||
tokens[account.id] = token
|
||||
try? save()
|
||||
}
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
}
|
|
@ -32,8 +32,9 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
|
|||
}
|
||||
launchActivity = activity
|
||||
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
|
||||
if let activityAccount = UserActivityManager.getAccount(from: activity) {
|
||||
account = activityAccount
|
||||
} else if let mostRecent = UserAccountsManager.shared.getMostRecentAccount() {
|
||||
|
|
|
@ -29,6 +29,8 @@ class ComposeSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDeleg
|
|||
return
|
||||
}
|
||||
|
||||
scene.activationConditions.canActivateForTargetContentIdentifierPredicate = NSPredicate(value: false)
|
||||
|
||||
let account: UserAccountInfo
|
||||
let controller: MastodonController
|
||||
let draft: Draft?
|
||||
|
|
|
@ -83,7 +83,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
|
||||
}
|
||||
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
|
||||
}
|
||||
}
|
||||
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||
|
@ -191,8 +193,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
|
||||
if let activity = launchActivity {
|
||||
func doRestoreActivity(context: UserActivityHandlingContext) {
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
Task(priority: .userInitiated) {
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
if activity.isStateRestorationActivity {
|
||||
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!))
|
||||
|
@ -225,7 +229,8 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
window!.windowScene!.title = account.instanceURL.host!
|
||||
}
|
||||
|
||||
let newRoot = createAppUI()
|
||||
window!.windowScene!.activationConditions.prefersToActivateForTargetContentIdentifierPredicate = NSPredicate(format: "self == %@", account.id)
|
||||
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
let direction: AccountSwitchingContainerViewController.AnimationDirection
|
||||
if animated,
|
||||
|
@ -235,9 +240,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
} else {
|
||||
direction = .none
|
||||
}
|
||||
container.setRoot(newRoot, for: account, animating: direction)
|
||||
container.setRoot(createAppUI, for: account, animating: direction)
|
||||
} else {
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: newRoot, for: account)
|
||||
window!.rootViewController = AccountSwitchingContainerViewController(root: createAppUI(), for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,6 +253,9 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
|
|||
LogoutService(accountInfo: account).run()
|
||||
if UserAccountsManager.shared.onboardingComplete {
|
||||
activateAccount(UserAccountsManager.shared.accounts.first!, animated: false)
|
||||
if let container = window?.rootViewController as? AccountSwitchingContainerViewController {
|
||||
container.removeAccount(account)
|
||||
}
|
||||
} else {
|
||||
window!.rootViewController = createOnboardingUI()
|
||||
}
|
||||
|
|
|
@ -232,7 +232,7 @@ class ConversationViewController: UIViewController {
|
|||
let request = Client.search(query: effectiveURL, types: [.statuses], resolve: true)
|
||||
do {
|
||||
let (results, _) = try await mastodonController.run(request)
|
||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
guard let status = results.statuses.compactMap(\.value).first(where: { $0.url?.serialized() == effectiveURL }) else {
|
||||
throw UnableToResolveError()
|
||||
}
|
||||
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
||||
|
|
|
@ -59,7 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController!
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
definesPresentationContext = true
|
||||
|
||||
navigationItem.searchController = searchController
|
||||
|
|
|
@ -34,7 +34,7 @@ class InlineTrendsViewController: UIViewController {
|
|||
|
||||
resultsController = SearchResultsViewController(mastodonController: mastodonController)
|
||||
resultsController.exploreNavigationController = self.navigationController
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController)
|
||||
searchController = MastodonSearchController(searchResultsController: resultsController, owner: self)
|
||||
searchController.obscuresBackgroundDuringPresentation = true
|
||||
searchController.hidesNavigationBarDuringPresentation = false
|
||||
definesPresentationContext = true
|
||||
|
|
|
@ -184,7 +184,19 @@ extension SuggestedProfilesViewController: UICollectionViewDelegate {
|
|||
return UIContextMenuConfiguration {
|
||||
ProfileViewController(accountID: id, mastodonController: self.mastodonController)
|
||||
} actionProvider: { _ in
|
||||
UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
let dismiss = UIAction(title: "Remove Suggestion", image: UIImage(systemName: "trash"), attributes: .destructive) { [unowned self] _ in
|
||||
let service = RemoveProfileSuggestionService(accountID: id, mastodonController: self.mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(id, .global)])
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
Task {
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
return UIMenu(children: [UIMenu(options: .displayInline, children: [dismiss])] + self.actionsForProfile(accountID: id, source: .view(cell)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -123,7 +123,7 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
|||
private func loadTrendingStatuses() async {
|
||||
let statuses: [Status]
|
||||
do {
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0.compactMap(\.value)
|
||||
} catch {
|
||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
await MainActor.run {
|
||||
|
|
|
@ -277,7 +277,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
let linksReq = Client.getTrendingLinks(limit: 10)
|
||||
async let links = try? mastodonController.run(linksReq).0
|
||||
let statusesReq = Client.getTrendingStatuses(limit: 10)
|
||||
async let statuses = try? mastodonController.run(statusesReq).0
|
||||
async let statuses = try? mastodonController.run(statusesReq).0.compactMap(\.value)
|
||||
|
||||
if let links = await links {
|
||||
if snapshot.sectionIdentifiers.contains(.profileSuggestions) {
|
||||
|
@ -332,7 +332,7 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
do {
|
||||
let request = Client.getTrendingStatuses(offset: origSnapshot.itemIdentifiers(inSection: .trendingStatuses).count)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
||||
|
@ -368,20 +368,14 @@ class TrendsViewController: UIViewController, CollectionViewController {
|
|||
|
||||
@MainActor
|
||||
private func removeProfileSuggestion(accountID: String) async {
|
||||
let req = Suggestion.remove(accountID: accountID)
|
||||
do {
|
||||
_ = try await mastodonController.run(req)
|
||||
var snapshot = dataSource.snapshot()
|
||||
let service = RemoveProfileSuggestionService(accountID: accountID, mastodonController: mastodonController, presenter: self) { [weak self] in
|
||||
guard let self else { return }
|
||||
var snapshot = self.dataSource.snapshot()
|
||||
// the source here doesn't matter, since it's ignored by the equatable and hashable impls
|
||||
snapshot.deleteItems([.account(accountID, .global)])
|
||||
await apply(snapshot: snapshot)
|
||||
} catch {
|
||||
let config = ToastConfiguration(from: error, with: "Error Removing Suggestion", in: self) { [unowned self] toast in
|
||||
toast.dismissToast(animated: true)
|
||||
_ = await self.removeProfileSuggestion(accountID: accountID)
|
||||
}
|
||||
self.showToast(configuration: config, animated: true)
|
||||
self.dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
await service.run()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
let mastodonController: MastodonController
|
||||
private let predicate: (StatusMO) -> Bool
|
||||
private let predicateTitle: String
|
||||
private let request: (RequestRange) -> Request<[Status]>
|
||||
private let request: (RequestRange) -> Request<[TryDecode<Status>]>
|
||||
|
||||
var collectionView: UICollectionView! {
|
||||
view as? UICollectionView
|
||||
|
@ -28,7 +28,7 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
private var newer: RequestRange?
|
||||
private var older: RequestRange?
|
||||
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[Status]>, mastodonController: MastodonController) {
|
||||
init(predicate: @escaping (StatusMO) -> Bool, predicateTitle: String, request: @escaping (RequestRange) -> Request<[TryDecode<Status>]>, mastodonController: MastodonController) {
|
||||
self.mastodonController = mastodonController
|
||||
self.predicate = predicate
|
||||
self.predicateTitle = predicateTitle
|
||||
|
@ -140,7 +140,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(.count(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
newer = pagination?.newer
|
||||
older = pagination?.older
|
||||
|
||||
|
@ -180,7 +181,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
|
||||
do {
|
||||
let req = request(older.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.older = pagination?.older
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
@ -278,7 +280,8 @@ class LocalPredicateStatusesViewController: UIViewController, CollectionViewCont
|
|||
Task {
|
||||
do {
|
||||
let req = request(newer.withCount(Self.pageSize))
|
||||
let (statuses, pagination) = try await mastodonController.run(req)
|
||||
let (tryStatuses, pagination) = try await mastodonController.run(req)
|
||||
let statuses = tryStatuses.compactMap(\.value)
|
||||
self.newer = pagination?.newer
|
||||
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
|
|
|
@ -23,7 +23,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
private(set) var currentAccountID: String
|
||||
private(set) var root: AccountSwitchableViewController
|
||||
|
||||
private var userActivities: [String: NSUserActivity] = [:]
|
||||
private var viewControllers: [String: (AccountSwitchableViewController?, NSUserActivity)] = [:]
|
||||
|
||||
init(root: AccountSwitchableViewController, for account: UserAccountInfo) {
|
||||
self.currentAccountID = account.id
|
||||
|
@ -42,27 +42,49 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
embedChild(root)
|
||||
}
|
||||
|
||||
func setRoot(_ newRoot: AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
viewControllers = viewControllers.mapValues { (_, activity) in
|
||||
(nil, activity)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAccount(_ account: UserAccountInfo) {
|
||||
viewControllers.removeValue(forKey: account.id)
|
||||
}
|
||||
|
||||
func setRoot(_ newRootProvider: () -> AccountSwitchableViewController, for account: UserAccountInfo, animating direction: AnimationDirection) {
|
||||
let oldRoot = self.root
|
||||
if direction == .none {
|
||||
oldRoot.removeViewAndController()
|
||||
}
|
||||
if let activity = oldRoot.stateRestorationActivity() {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: saving \(activity.activityType, privacy: .public) for \(self.currentAccountID, privacy: .public)")
|
||||
userActivities[currentAccountID] = activity
|
||||
viewControllers[currentAccountID] = (oldRoot, activity)
|
||||
}
|
||||
|
||||
let newRoot: AccountSwitchableViewController
|
||||
if let (existingRoot, activity) = viewControllers.removeValue(forKey: account.id) {
|
||||
if let existingRoot {
|
||||
newRoot = existingRoot
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: reusing existing VC for \(account.id, privacy: .public)")
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
Task(priority: .userInitiated) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = await activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newRoot = newRootProvider()
|
||||
}
|
||||
|
||||
self.currentAccountID = account.id
|
||||
self.root = newRoot
|
||||
embedChild(newRoot)
|
||||
|
||||
if let activity = userActivities.removeValue(forKey: account.id) {
|
||||
stateRestorationLogger.debug("AccountSwitchingContainer: restoring \(activity.activityType, privacy: .public) for \(account.id, privacy: .public)")
|
||||
let context = StateRestorationUserActivityHandlingContext(root: newRoot)
|
||||
_ = activity.handleResume(manager: UserActivityManager(scene: view.window!.windowScene!, context: context))
|
||||
context.finalize(activity: activity)
|
||||
}
|
||||
|
||||
if direction != .none {
|
||||
if UIAccessibility.prefersCrossFadeTransitions {
|
||||
newRoot.view.alpha = 0
|
||||
|
@ -92,6 +114,7 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
#endif
|
||||
|
||||
// only one edge is affected in each direction, i have no idea why
|
||||
let origAdditionalSafeAreaInsets = oldRoot.additionalSafeAreaInsets
|
||||
if direction == .upwards {
|
||||
oldRoot.additionalSafeAreaInsets.bottom = view.safeAreaInsets.bottom
|
||||
} else {
|
||||
|
@ -102,6 +125,8 @@ class AccountSwitchingContainerViewController: UIViewController {
|
|||
oldRoot.view.transform = CGAffineTransform(translationX: 0, y: -newInitialOffset).scaledBy(x: scale, y: scale)
|
||||
newRoot.view.transform = .identity
|
||||
} completion: { (_) in
|
||||
oldRoot.view.transform = .identity
|
||||
oldRoot.additionalSafeAreaInsets = origAdditionalSafeAreaInsets
|
||||
oldRoot.removeViewAndController()
|
||||
newRoot.view.layer.masksToBounds = false
|
||||
}
|
||||
|
@ -127,9 +152,9 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
|
|||
root.compose(editing: draft, animated: animated, isDucked: isDucked)
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
loadViewIfNeeded()
|
||||
root.select(route: route, animated: animated)
|
||||
root.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
|
@ -35,8 +35,8 @@ extension DuckableContainerViewController: AccountSwitchableViewController {
|
|||
(child as! TuskerRootViewController).getNavigationController()
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
(child as? TuskerRootViewController)?.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
|
|
@ -13,8 +13,8 @@ import Combine
|
|||
@MainActor
|
||||
protocol MainSidebarViewControllerDelegate: AnyObject {
|
||||
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, scrollToTopFor item: MainSidebarViewController.Item)
|
||||
}
|
||||
|
||||
|
@ -57,7 +57,7 @@ class MainSidebarViewController: UIViewController {
|
|||
return items
|
||||
}
|
||||
|
||||
private(set) var previouslySelectedItem: Item?
|
||||
private var previouslySelectedItem: Item?
|
||||
var selectedItem: Item? {
|
||||
guard let indexPath = collectionView?.indexPathsForSelectedItems?.first else {
|
||||
return nil
|
||||
|
@ -261,19 +261,21 @@ class MainSidebarViewController: UIViewController {
|
|||
}
|
||||
|
||||
private func returnToPreviousItem() {
|
||||
let item = previouslySelectedItem ?? .tab(.timelines)
|
||||
let oldItem = selectedItem
|
||||
let newItem = previouslySelectedItem ?? .tab(.timelines)
|
||||
previouslySelectedItem = nil
|
||||
select(item: item, animated: true)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
||||
select(item: newItem, animated: true)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: newItem, previousItem: oldItem)
|
||||
}
|
||||
|
||||
private func showAddList() {
|
||||
let service = CreateListService(mastodonController: mastodonController, present: { self.present($0, animated: true
|
||||
) }) { list in
|
||||
let oldItem = self.selectedItem
|
||||
self.select(item: .list(list), animated: false)
|
||||
let list = ListTimelineViewController(for: list, mastodonController: self.mastodonController)
|
||||
list.presentEditOnAppear = true
|
||||
self.sidebarDelegate?.sidebar(self, showViewController: list)
|
||||
self.sidebarDelegate?.sidebar(self, showViewController: list, previousItem: oldItem)
|
||||
}
|
||||
service.run()
|
||||
}
|
||||
|
@ -471,7 +473,7 @@ extension MainSidebarViewController: UICollectionViewDelegate {
|
|||
fatalError("unreachable")
|
||||
}
|
||||
} else {
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item)
|
||||
sidebarDelegate?.sidebar(self, didSelectItem: item, previousItem: previouslySelectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -540,8 +542,9 @@ extension MainSidebarViewController: UICollectionViewDragDelegate {
|
|||
extension MainSidebarViewController: InstanceTimelineViewControllerDelegate {
|
||||
func didSaveInstance(url: URL) {
|
||||
dismiss(animated: true) {
|
||||
let oldItem = self.selectedItem
|
||||
self.select(item: .savedInstance(url), animated: true)
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url))
|
||||
self.sidebarDelegate?.sidebar(self, didSelectItem: .savedInstance(url), previousItem: oldItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -86,7 +86,7 @@ class MainSplitViewController: UISplitViewController {
|
|||
// don't unnecesarily construct a content VC unless the we're in actually split mode
|
||||
// when we change from compact -> split for the first time, the VC will be transferred anyways
|
||||
if traitCollection.horizontalSizeClass != .compact {
|
||||
select(item: .tab(.timelines))
|
||||
doSelect(item: .tab(.timelines))
|
||||
}
|
||||
|
||||
if UIDevice.current.userInterfaceIdiom != .mac {
|
||||
|
@ -149,7 +149,15 @@ class MainSplitViewController: UISplitViewController {
|
|||
self.setViewController(newNav, for: .secondary)
|
||||
}
|
||||
|
||||
func select(item: MainSidebarViewController.Item) {
|
||||
private func select(newItem: MainSidebarViewController.Item, oldItem: MainSidebarViewController.Item?) {
|
||||
if let oldItem,
|
||||
newItem != oldItem {
|
||||
navigationStacks[oldItem] = secondaryNavController.viewControllers
|
||||
}
|
||||
doSelect(item: newItem)
|
||||
}
|
||||
|
||||
private func doSelect(item: MainSidebarViewController.Item) {
|
||||
secondaryNavController.viewControllers = getOrCreateNavigationStack(item: item)
|
||||
}
|
||||
|
||||
|
@ -180,28 +188,28 @@ class MainSplitViewController: UISplitViewController {
|
|||
}
|
||||
|
||||
@objc func handleSidebarCommandTimelines() {
|
||||
select(newItem: .tab(.timelines), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.timelines), animated: false)
|
||||
select(item: .tab(.timelines))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandNotifications() {
|
||||
select(newItem: .tab(.notifications), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.notifications), animated: false)
|
||||
select(item: .tab(.notifications))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandExplore() {
|
||||
select(newItem: .tab(.explore), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.explore), animated: false)
|
||||
select(item: .tab(.explore))
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandBookmarks() {
|
||||
select(newItem: .bookmarks, oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .bookmarks, animated: false)
|
||||
select(item: .bookmarks)
|
||||
}
|
||||
|
||||
@objc func handleSidebarCommandMyProfile() {
|
||||
select(newItem: .tab(.myProfile), oldItem: sidebar.selectedItem)
|
||||
sidebar.select(item: .tab(.myProfile), animated: false)
|
||||
select(item: .tab(.myProfile))
|
||||
}
|
||||
|
||||
@objc private func sidebarTapped() {
|
||||
|
@ -444,12 +452,12 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
|
|||
// These tabs map 1 <-> 1 with sidebar items
|
||||
let item = MainSidebarViewController.Item.tab(tabBarViewController.selectedTab)
|
||||
sidebar.select(item: item, animated: false)
|
||||
select(item: item)
|
||||
doSelect(item: item)
|
||||
|
||||
case .explore:
|
||||
// If the explore tab is active, the sidebar item is determined above when transferring the explore VC's nav stack
|
||||
sidebar.select(item: exploreItem!, animated: false)
|
||||
select(item: exploreItem!)
|
||||
doSelect(item: exploreItem!)
|
||||
|
||||
default:
|
||||
return
|
||||
|
@ -474,16 +482,13 @@ extension MainSplitViewController: MainSidebarViewControllerDelegate {
|
|||
compose(editing: nil)
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
|
||||
if let previous = sidebar.previouslySelectedItem {
|
||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
||||
}
|
||||
select(item: item)
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item, previousItem: MainSidebarViewController.Item?) {
|
||||
select(newItem: item, oldItem: previousItem)
|
||||
}
|
||||
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController) {
|
||||
if let previous = sidebar.previouslySelectedItem {
|
||||
navigationStacks[previous] = secondaryNavController.viewControllers
|
||||
func sidebar(_ sidebarViewController: MainSidebarViewController, showViewController viewController: UIViewController, previousItem: MainSidebarViewController.Item?) {
|
||||
if let previousItem {
|
||||
navigationStacks[previousItem] = secondaryNavController.viewControllers
|
||||
}
|
||||
secondaryNavController.viewControllers = [viewController]
|
||||
}
|
||||
|
@ -537,14 +542,14 @@ extension MainSplitViewController: StateRestorableViewController {
|
|||
}
|
||||
|
||||
extension MainSplitViewController: TuskerRootViewController {
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
guard traitCollection.horizontalSizeClass != .compact else {
|
||||
tabBarViewController?.select(route: route, animated: animated)
|
||||
tabBarViewController?.select(route: route, animated: animated, completion: completion)
|
||||
return
|
||||
}
|
||||
guard presentedViewController == nil else {
|
||||
dismiss(animated: animated) {
|
||||
self.select(route: route, animated: animated)
|
||||
self.select(route: route, animated: animated, completion: completion)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -567,8 +572,10 @@ extension MainSplitViewController: TuskerRootViewController {
|
|||
return
|
||||
}
|
||||
}
|
||||
let oldItem = sidebar.selectedItem
|
||||
sidebar.select(item: item, animated: false)
|
||||
select(item: item)
|
||||
select(newItem: item, oldItem: oldItem)
|
||||
completion?()
|
||||
}
|
||||
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
|
||||
|
@ -610,7 +617,7 @@ extension MainSplitViewController: TuskerRootViewController {
|
|||
}
|
||||
|
||||
if sidebar.selectedItem != .explore {
|
||||
select(item: .explore)
|
||||
select(newItem: .explore, oldItem: sidebar.selectedItem)
|
||||
}
|
||||
|
||||
guard let searchViewController = secondaryNavController.viewControllers.first as? InlineTrendsViewController else {
|
||||
|
|
|
@ -289,7 +289,7 @@ extension MainTabBarViewController: StateRestorableViewController {
|
|||
}
|
||||
|
||||
extension MainTabBarViewController: TuskerRootViewController {
|
||||
func select(route: TuskerRoute, animated: Bool) {
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?) {
|
||||
switch route {
|
||||
case .timelines:
|
||||
select(tab: .timelines, dismissPresented: true)
|
||||
|
@ -310,6 +310,7 @@ extension MainTabBarViewController: TuskerRootViewController {
|
|||
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
|
||||
}
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
|
||||
func getNavigationDelegate() -> TuskerNavigationDelegate? {
|
||||
|
|
|
@ -12,7 +12,7 @@ import ComposeUI
|
|||
@MainActor
|
||||
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
|
||||
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool)
|
||||
func select(route: TuskerRoute, animated: Bool)
|
||||
func select(route: TuskerRoute, animated: Bool, completion: (() -> Void)?)
|
||||
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
|
||||
func getNavigationDelegate() -> TuskerNavigationDelegate?
|
||||
func getNavigationController() -> NavigationControllerProtocol
|
||||
|
@ -21,33 +21,6 @@ protocol TuskerRootViewController: UIViewController, StateRestorableViewControll
|
|||
func presentPreferences(completion: (() -> Void)?) -> PreferencesNavigationController?
|
||||
}
|
||||
|
||||
//extension TuskerRootViewController {
|
||||
// func select(route: NewRoute, animated: Bool) {
|
||||
// doApply(components: route.components, animated: animated)
|
||||
// }
|
||||
//
|
||||
// private func doApply(components: ArraySlice<RouteComponent>, animated: Bool) {
|
||||
// guard let first = components.first else {
|
||||
// return
|
||||
// }
|
||||
// doApply(component: first, animated: animated) {
|
||||
// self.doApply(components: components.dropFirst(), animated: animated)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// private func doApply(component: RouteComponent, animated: Bool, completion: @escaping () -> Void) {
|
||||
// switch component {
|
||||
// case .topLevelItem(let rootRoute):
|
||||
// select(route: rootRoute)
|
||||
// completion()
|
||||
// case .popToRoot:
|
||||
// _ = getNavigationController().popToRootViewController(animated: animated)
|
||||
// completion()
|
||||
// case .push(<#T##(MastodonController) -> UIViewController#>)
|
||||
// }
|
||||
// }
|
||||
//}
|
||||
|
||||
enum TuskerRoute {
|
||||
case timelines
|
||||
case notifications
|
||||
|
@ -57,33 +30,6 @@ enum TuskerRoute {
|
|||
case list(id: String)
|
||||
}
|
||||
|
||||
//struct NewRoute: ExpressibleByArrayLiteral {
|
||||
// let components: [RouteComponent]
|
||||
//
|
||||
// init(arrayLiteral elements: RouteComponent...) {
|
||||
// self.components = elements
|
||||
// }
|
||||
//
|
||||
// static var timelines: Self { [.topLevelItem(.timelines)] }
|
||||
// static var explore: Self { [.topLevelItem(.explore)] }
|
||||
// static var myProfile: Self { [.topLevelItem(.myProfile)] }
|
||||
// static var bookmarks: Self { [.topLevelItem(.explore), .push({ BookmarksViewController(mastodonController: $0) })] }
|
||||
// static func profile(accountID: String) -> Self { [.topLevelItem(.timelines), .push({ ProfileViewController(accountID: accountID, mastodonController: $0) })] }
|
||||
//}
|
||||
//
|
||||
//enum RouteComponent {
|
||||
// case topLevelItem(RootRoute)
|
||||
// case popToRoot
|
||||
// case push((MastodonController) -> UIViewController)
|
||||
// case present(UIViewController)
|
||||
//}
|
||||
//
|
||||
//enum RootRoute {
|
||||
// case timelines
|
||||
// case explore
|
||||
// case myProfile
|
||||
//}
|
||||
//
|
||||
@MainActor
|
||||
protocol NavigationControllerProtocol: UIViewController {
|
||||
var viewControllers: [UIViewController] { get set }
|
||||
|
|
|
@ -78,18 +78,20 @@ struct MuteAccountView: View {
|
|||
}
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Section {
|
||||
Toggle(isOn: $muteNotifications) {
|
||||
Text("Hide notifications from this person")
|
||||
}
|
||||
} footer: {
|
||||
if muteNotifications {
|
||||
Text("This user's posts and notifications will be hidden.")
|
||||
} else {
|
||||
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
|
||||
if mastodonController.instanceFeatures.muteNotifications {
|
||||
Section {
|
||||
Toggle(isOn: $muteNotifications) {
|
||||
Text("Hide notifications from this person")
|
||||
}
|
||||
} footer: {
|
||||
if muteNotifications {
|
||||
Text("This user's posts and notifications will be hidden.")
|
||||
} else {
|
||||
Text("This user's posts will be hidden from your timeline. You can still receive notifications from them.")
|
||||
}
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
}
|
||||
.appGroupedListRowBackground()
|
||||
|
||||
Section {
|
||||
Picker(selection: $duration) {
|
||||
|
|
|
@ -9,6 +9,12 @@
|
|||
import UIKit
|
||||
import Pachyderm
|
||||
import Combine
|
||||
import OSLog
|
||||
#if canImport(Sentry)
|
||||
import Sentry
|
||||
#endif
|
||||
|
||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ProfileStatusesViewController")
|
||||
|
||||
class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
|
||||
|
||||
|
@ -250,9 +256,16 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
state = .setupInitialSnapshot
|
||||
|
||||
Task {
|
||||
if let (all, _) = try? await mastodonController.run(Client.getRelationships(accounts: [accountID])),
|
||||
let relationship = all.first {
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
do {
|
||||
let (all, _) = try await mastodonController.run(Client.getRelationships(accounts: [accountID]))
|
||||
if let relationship = all.first {
|
||||
self.mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error fetching relationship: \(String(describing: error))")
|
||||
#if canImport(Sentry)
|
||||
SentrySDK.capture(error: error)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -297,7 +310,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
|||
}
|
||||
|
||||
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
|
@ -513,7 +526,7 @@ extension ProfileStatusesViewController {
|
|||
extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
||||
typealias TimelineItem = String // status ID
|
||||
|
||||
private func request(for range: RequestRange = .default) -> Request<[Status]> {
|
||||
private func request(for range: RequestRange = .default) -> Request<[TryDecode<Status>]> {
|
||||
switch kind {
|
||||
case .statuses:
|
||||
return Account.getStatuses(accountID, range: range, onlyMedia: false, pinned: false, excludeReplies: true)
|
||||
|
@ -526,7 +539,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
|
||||
func loadInitial() async throws -> [String] {
|
||||
let request = request()
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
if !statuses.isEmpty {
|
||||
newer = .after(id: statuses.first!.id, count: nil)
|
||||
|
@ -546,7 +559,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = request(for: newer)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
throw Error.allCaughtUp
|
||||
|
@ -567,7 +580,7 @@ extension ProfileStatusesViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = request(for: older)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
|
|
@ -53,7 +53,7 @@ struct ReportAddStatusView: View {
|
|||
.task { @MainActor in
|
||||
do {
|
||||
let req = Account.getStatuses(report.accountID, range: .count(40), excludeReplies: false, excludeReblogs: true)
|
||||
let (statuses, _) = try await mastodonController.run(req)
|
||||
let statuses = try await mastodonController.run(req).0.compactMap(\.value)
|
||||
await mastodonController.persistentContainer.addAll(statuses: statuses)
|
||||
self.statuses = statuses.compactMap { mastodonController.persistentContainer.status(for: $0.id) }
|
||||
} catch {
|
||||
|
|
|
@ -24,7 +24,11 @@ class MastodonSearchController: UISearchController {
|
|||
super.searchResultsController as! SearchResultsViewController
|
||||
}
|
||||
|
||||
init(searchResultsController: SearchResultsViewController) {
|
||||
private weak var owner: UIViewController?
|
||||
|
||||
init(searchResultsController: SearchResultsViewController, owner: UIViewController) {
|
||||
self.owner = owner
|
||||
|
||||
super.init(searchResultsController: searchResultsController)
|
||||
|
||||
searchResultsController.tokenHandler = { [unowned self] token, op in
|
||||
|
@ -152,6 +156,12 @@ extension MastodonSearchController: UISearchBarDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
extension MastodonSearchController: MultiColumnNavigationCustomTargetProviding {
|
||||
var multiColumnNavigationTargetViewController: UIViewController? {
|
||||
owner
|
||||
}
|
||||
}
|
||||
|
||||
extension UISearchBar {
|
||||
var searchQueryWithOperators: String {
|
||||
var parts = searchTextField.tokens.compactMap { $0.representedObject as? String }
|
||||
|
|
|
@ -266,7 +266,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
|||
guard self.currentQuery == query else { return }
|
||||
self.mastodonController.persistentContainer.performBatchUpdates { (context, addAccounts, addStatuses) in
|
||||
addAccounts(results.accounts)
|
||||
addStatuses(results.statuses)
|
||||
addStatuses(results.statuses.compactMap(\.value))
|
||||
} completion: {
|
||||
DispatchQueue.main.async {
|
||||
self.showSearchResults(results)
|
||||
|
@ -299,7 +299,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController {
|
|||
}
|
||||
if !results.statuses.isEmpty && resultTypes.contains(.statuses) {
|
||||
snapshot.appendSections([.statuses])
|
||||
snapshot.appendItems(results.statuses.map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||
snapshot.appendItems(results.statuses.compactMap(\.value).map { .status($0.id, .unknown) }, toSection: .statuses)
|
||||
}
|
||||
|
||||
dataSource.apply(snapshot)
|
||||
|
|
|
@ -30,6 +30,8 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
|||
label.topAnchor.constraint(equalTo: contentView.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||
])
|
||||
|
||||
addInteraction(UIPointerInteraction(delegate: self))
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
@ -40,3 +42,10 @@ class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell {
|
|||
label.text = text
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchTokenSuggestionCollectionViewCell: UIPointerInteractionDelegate {
|
||||
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
|
||||
let preview = UITargetedPreview(view: self)
|
||||
return UIPointerStyle(effect: .lift(preview))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -565,7 +565,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
|||
do {
|
||||
let (marker, _) = try await mastodonController.run(TimelineMarkers.request(timeline: .home))
|
||||
async let status = try await mastodonController.run(Client.getStatus(id: marker.lastReadID)).0
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0
|
||||
// TODO: consider replacing undecodable statuses here with items to indicate that to the user
|
||||
async let olderStatuses = try await mastodonController.run(Client.getStatuses(timeline: .home, range: .before(id: marker.lastReadID, count: Self.pageSize))).0.compactMap(\.value)
|
||||
|
||||
let allStatuses = try await [status] + olderStatuses
|
||||
await mastodonController.persistentContainer.addAll(statuses: allStatuses)
|
||||
|
@ -1100,7 +1101,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
|
||||
func loadInitial() async throws -> [TimelineItem] {
|
||||
let request = Client.getStatuses(timeline: timeline, range: .count(TimelineViewController.pageSize))
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
await withCheckedContinuation { continuation in
|
||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||
|
@ -1119,7 +1120,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
let newer = RequestRange.after(id: id, count: TimelineViewController.pageSize)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
throw TimelineViewController.Error.allCaughtUp
|
||||
|
@ -1143,7 +1144,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
let older = RequestRange.before(id: id, count: TimelineViewController.pageSize)
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
@ -1181,7 +1182,7 @@ extension TimelineViewController: TimelineLikeControllerDataSource {
|
|||
}
|
||||
|
||||
let request = Client.getStatuses(timeline: timeline, range: range)
|
||||
let (statuses, _) = try await mastodonController.run(request)
|
||||
let statuses = try await mastodonController.run(request).0.compactMap(\.value)
|
||||
|
||||
guard !statuses.isEmpty else {
|
||||
return []
|
||||
|
|
|
@ -8,17 +8,28 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
/// View controllers, such as `UISearchController`, that live outside the normal VC hierarchy
|
||||
/// can adopt this protocol to indicate to `MultiColumnNavigationController` the context for
|
||||
/// navigation operations.
|
||||
protocol MultiColumnNavigationCustomTargetProviding {
|
||||
var multiColumnNavigationTargetViewController: UIViewController? { get }
|
||||
}
|
||||
|
||||
class MultiColumnNavigationController: UIViewController {
|
||||
|
||||
private var isManuallyUpdating = false
|
||||
var viewControllers: [UIViewController] = [] {
|
||||
didSet {
|
||||
guard isViewLoaded,
|
||||
!isManuallyUpdating else {
|
||||
return
|
||||
private var _viewControllers: [UIViewController] = []
|
||||
var viewControllers: [UIViewController] {
|
||||
get {
|
||||
_viewControllers
|
||||
}
|
||||
set {
|
||||
_viewControllers = newValue
|
||||
if isViewLoaded,
|
||||
!isManuallyUpdating {
|
||||
updateViews()
|
||||
scrollToEnd(animated: false)
|
||||
}
|
||||
updateViews()
|
||||
scrollToEnd(animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,13 +79,13 @@ class MultiColumnNavigationController: UIViewController {
|
|||
|
||||
private func updateViews() {
|
||||
var i = 0
|
||||
while i < viewControllers.count {
|
||||
while i < _viewControllers.count {
|
||||
let needsCloseButton = i > 0
|
||||
if i <= stackView.arrangedSubviews.count - 1 {
|
||||
let existing = stackView.arrangedSubviews[i] as! ColumnView
|
||||
existing.setContent(viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
existing.setContent(_viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
} else {
|
||||
let new = ColumnView(owner: self, contentViewController: viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
let new = ColumnView(owner: self, contentViewController: _viewControllers[i], needsCloseButton: needsCloseButton)
|
||||
stackView.addArrangedSubview(new)
|
||||
}
|
||||
i += 1
|
||||
|
@ -92,9 +103,11 @@ class MultiColumnNavigationController: UIViewController {
|
|||
var index: Int? = nil
|
||||
var current: UIViewController? = sender
|
||||
while let c = current {
|
||||
index = viewControllers.firstIndex(of: c)
|
||||
index = _viewControllers.firstIndex(of: c)
|
||||
if index != nil {
|
||||
break
|
||||
} else if let targetProviding = c as? MultiColumnNavigationCustomTargetProviding {
|
||||
current = targetProviding.multiColumnNavigationTargetViewController
|
||||
} else {
|
||||
current = c.parent
|
||||
}
|
||||
|
@ -112,19 +125,20 @@ class MultiColumnNavigationController: UIViewController {
|
|||
}
|
||||
|
||||
func replaceViewControllers(_ vcs: [UIViewController], after afterIndex: Int, animated: Bool) {
|
||||
if afterIndex == viewControllers.count - 1 && vcs.count == 1 {
|
||||
if afterIndex == _viewControllers.count - 1 && vcs.count == 1 {
|
||||
pushViewController(vcs[0], animated: animated)
|
||||
} else {
|
||||
viewControllers = Array(viewControllers[...afterIndex]) + vcs
|
||||
_viewControllers = Array(_viewControllers[...afterIndex]) + vcs
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToEnd(animated: Bool) {
|
||||
if viewControllers.isEmpty {
|
||||
if _viewControllers.isEmpty {
|
||||
scrollView.setContentOffset(.init(x: -scrollView.adjustedLeadingContentInset, y: -scrollView.adjustedContentInset.top), animated: false)
|
||||
} else {
|
||||
scrollColumnToEnd(columnIndex: viewControllers.count - 1, animated: animated)
|
||||
scrollColumnToEnd(columnIndex: _viewControllers.count - 1, animated: animated)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,14 +156,12 @@ class MultiColumnNavigationController: UIViewController {
|
|||
}
|
||||
|
||||
fileprivate func closeColumn(_ vc: UIViewController) {
|
||||
let index = viewControllers.firstIndex(of: vc)!
|
||||
guard index > 0 else {
|
||||
guard let index = _viewControllers.firstIndex(of: vc),
|
||||
index > 0 else {
|
||||
// Can't close the last column
|
||||
return
|
||||
}
|
||||
isManuallyUpdating = true
|
||||
defer { isManuallyUpdating = false }
|
||||
viewControllers.removeSubrange(index...)
|
||||
_viewControllers.removeSubrange(index...)
|
||||
animateChanges {
|
||||
for column in self.stackView.arrangedSubviews[index...] {
|
||||
column.layer.opacity = 0
|
||||
|
@ -158,7 +170,6 @@ class MultiColumnNavigationController: UIViewController {
|
|||
} completion: {
|
||||
self.updateViews()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func animateChanges(_ animations: @escaping () -> Void, completion: (() -> Void)? = nil) {
|
||||
|
@ -173,19 +184,22 @@ class MultiColumnNavigationController: UIViewController {
|
|||
|
||||
extension MultiColumnNavigationController: NavigationControllerProtocol {
|
||||
var topViewController: UIViewController? {
|
||||
viewControllers.last
|
||||
_viewControllers.last
|
||||
}
|
||||
|
||||
func popToRootViewController(animated: Bool) -> [UIViewController]? {
|
||||
let removed = Array(viewControllers.dropFirst())
|
||||
viewControllers = [viewControllers.first!]
|
||||
guard !_viewControllers.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let removed = Array(_viewControllers.dropFirst())
|
||||
_viewControllers = [_viewControllers.first!]
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
return removed
|
||||
}
|
||||
|
||||
func pushViewController(_ vc: UIViewController, animated: Bool) {
|
||||
isManuallyUpdating = true
|
||||
defer { isManuallyUpdating = false }
|
||||
viewControllers.append(vc)
|
||||
_viewControllers.append(vc)
|
||||
updateViews()
|
||||
scrollToEnd(animated: animated)
|
||||
if animated {
|
||||
|
@ -273,12 +287,17 @@ private class ColumnView: UIView {
|
|||
}
|
||||
|
||||
private func installCloseBarButton(navigationItem: UINavigationItem) {
|
||||
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
|
||||
item.accessibilityLabel = "Close Column"
|
||||
if navigationItem.leftBarButtonItems != nil {
|
||||
navigationItem.leftBarButtonItems!.insert(item, at: 0)
|
||||
func makeItem() -> UIBarButtonItem {
|
||||
let item = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(closeNavigationColumn))
|
||||
item.accessibilityLabel = "Close Column"
|
||||
return item
|
||||
}
|
||||
if let leftItems = navigationItem.leftBarButtonItems {
|
||||
if !leftItems.contains(where: { $0.action == #selector(closeNavigationColumn) }) {
|
||||
navigationItem.leftBarButtonItems!.insert(makeItem(), at: 0)
|
||||
}
|
||||
} else {
|
||||
navigationItem.leftBarButtonItems = [item]
|
||||
navigationItem.leftBarButtonItems = [makeItem()]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -557,11 +557,15 @@ extension MenuActionProvider {
|
|||
return createAction(identifier: "block", title: "Unblock \(displayName)", systemImageName: "circle.slash", handler: handler(false))
|
||||
} else {
|
||||
let image = UIImage(systemName: "circle.slash")
|
||||
return UIMenu(title: "Block", image: image, children: [
|
||||
var children = [
|
||||
UIAction(title: "Cancel", handler: { _ in }),
|
||||
UIAction(title: "Block \(displayName)", image: image, attributes: .destructive, handler: handler(true)),
|
||||
UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true))
|
||||
])
|
||||
]
|
||||
if mastodonController.instanceFeatures.blockDomains,
|
||||
host != mastodonController.account?.url.host {
|
||||
children.append(UIAction(title: "Block \(host)", image: image, attributes: .destructive, handler: domainHandler(true)))
|
||||
}
|
||||
return UIMenu(title: "Block", image: image, children: children)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -592,7 +596,8 @@ extension MenuActionProvider {
|
|||
@MainActor
|
||||
private func hideReblogsAction(for relationship: RelationshipMO, mastodonController: MastodonController) -> UIMenuElement? {
|
||||
// don't show action for people that the user isn't following and isn't already hiding reblogs for
|
||||
guard relationship.following || relationship.showingReblogs else {
|
||||
guard relationship.following || relationship.showingReblogs,
|
||||
mastodonController.instanceFeatures.hideReblogs else {
|
||||
return nil
|
||||
}
|
||||
let title = relationship.showingReblogs ? "Hide Reblogs" : "Show Reblogs"
|
||||
|
|
|
@ -50,8 +50,11 @@ extension UIViewController {
|
|||
}
|
||||
|
||||
func removeViewAndController() {
|
||||
beginAppearanceTransition(false, animated: false)
|
||||
view.removeFromSuperview()
|
||||
willMove(toParent: nil)
|
||||
removeFromParent()
|
||||
endAppearanceTransition()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,7 +71,7 @@ extension UIView {
|
|||
|
||||
if layout {
|
||||
subview.frame = bounds
|
||||
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
|
|
|
@ -44,9 +44,9 @@ enum AppShortcutItem: String, CaseIterable {
|
|||
}
|
||||
switch self {
|
||||
case .showHomeTimeline:
|
||||
root.select(route: .timelines, animated: false)
|
||||
root.select(route: .timelines, animated: false, completion: nil)
|
||||
case .showNotifications:
|
||||
root.select(route: .notifications, animated: false)
|
||||
root.select(route: .notifications, animated: false, completion: nil)
|
||||
case .composePost:
|
||||
root.compose(editing: nil, animated: false, isDucked: false)
|
||||
}
|
||||
|
|
|
@ -39,12 +39,13 @@ extension NSUserActivity {
|
|||
self.userInfo = [
|
||||
"accountID": accountID
|
||||
]
|
||||
self.targetContentIdentifier = accountID
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleResume(manager: UserActivityManager) -> Bool {
|
||||
func handleResume(manager: UserActivityManager) async -> Bool {
|
||||
guard let type = UserActivityType(rawValue: activityType) else { return false }
|
||||
type.handle(manager)(self)
|
||||
await type.handle(manager)(self)
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,8 @@ import ComposeUI
|
|||
protocol UserActivityHandlingContext {
|
||||
var isHandoff: Bool { get }
|
||||
|
||||
func select(route: TuskerRoute)
|
||||
func select(route: TuskerRoute) async
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?)
|
||||
func present(_ vc: UIViewController)
|
||||
|
||||
var topViewController: UIViewController? { get }
|
||||
|
@ -28,6 +29,16 @@ protocol UserActivityHandlingContext {
|
|||
func finalize(activity: NSUserActivity)
|
||||
}
|
||||
|
||||
extension UserActivityHandlingContext {
|
||||
func select(route: TuskerRoute) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
select(route: route) {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
||||
let isHandoff: Bool
|
||||
let root: TuskerRootViewController
|
||||
|
@ -35,8 +46,8 @@ struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
|
|||
root.getNavigationDelegate()!
|
||||
}
|
||||
|
||||
func select(route: TuskerRoute) {
|
||||
root.select(route: route, animated: true)
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||
root.select(route: route, animated: true, completion: completion)
|
||||
}
|
||||
|
||||
func present(_ vc: UIViewController) {
|
||||
|
@ -71,9 +82,11 @@ class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
|
|||
|
||||
var isHandoff: Bool { false }
|
||||
|
||||
func select(route: TuskerRoute) {
|
||||
root.select(route: route, animated: false)
|
||||
state = .selectedRoute
|
||||
func select(route: TuskerRoute, completion: (() -> Void)?) {
|
||||
root.select(route: route, animated: false) {
|
||||
self.state = .selectedRoute
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
var topViewController: UIViewController? { root.getNavigationController().topViewController }
|
||||
|
|
|
@ -133,8 +133,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleCheckNotifications(activity: NSUserActivity) {
|
||||
context.select(route: .notifications)
|
||||
func handleCheckNotifications(activity: NSUserActivity) async {
|
||||
await context.select(route: .notifications)
|
||||
context.popToRoot()
|
||||
if let notificationsPageController = context.topViewController as? NotificationsPageViewController {
|
||||
notificationsPageController.loadViewIfNeeded()
|
||||
|
@ -204,22 +204,22 @@ class UserActivityManager {
|
|||
return (timeline, positionInfo)
|
||||
}
|
||||
|
||||
func handleShowTimeline(activity: NSUserActivity) {
|
||||
func handleShowTimeline(activity: NSUserActivity) async {
|
||||
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return }
|
||||
|
||||
var timelineVC: TimelineViewController?
|
||||
if let pinned = PinnedTimeline(timeline: timeline),
|
||||
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.popToRoot()
|
||||
let pageController = context.topViewController as! TimelinesPageViewController
|
||||
pageController.selectTimeline(pinned, animated: false)
|
||||
timelineVC = pageController.currentViewController as? TimelineViewController
|
||||
} else if case .list(let id) = timeline {
|
||||
context.select(route: .list(id: id))
|
||||
await context.select(route: .list(id: id))
|
||||
timelineVC = context.topViewController as? TimelineViewController
|
||||
} else {
|
||||
context.select(route: .explore)
|
||||
await context.select(route: .explore)
|
||||
context.popToRoot()
|
||||
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
|
||||
context.push(timelineVC!)
|
||||
|
@ -249,11 +249,11 @@ class UserActivityManager {
|
|||
return activity.userInfo?["mainStatusID"] as? String
|
||||
}
|
||||
|
||||
func handleShowConversation(activity: NSUserActivity) {
|
||||
func handleShowConversation(activity: NSUserActivity) async {
|
||||
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
|
||||
return
|
||||
}
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
@ -274,8 +274,8 @@ class UserActivityManager {
|
|||
return activity.userInfo?["query"] as? String
|
||||
}
|
||||
|
||||
func handleSearch(activity: NSUserActivity) {
|
||||
context.select(route: .explore)
|
||||
func handleSearch(activity: NSUserActivity) async {
|
||||
await context.select(route: .explore)
|
||||
context.popToRoot()
|
||||
|
||||
let searchController: UISearchController
|
||||
|
@ -311,8 +311,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleBookmarks(activity: NSUserActivity) {
|
||||
context.select(route: .bookmarks)
|
||||
func handleBookmarks(activity: NSUserActivity) async {
|
||||
await context.select(route: .bookmarks)
|
||||
}
|
||||
|
||||
// MARK: - My Profile
|
||||
|
@ -325,8 +325,8 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleMyProfile(activity: NSUserActivity) {
|
||||
context.select(route: .myProfile)
|
||||
func handleMyProfile(activity: NSUserActivity) async {
|
||||
await context.select(route: .myProfile)
|
||||
}
|
||||
|
||||
// MARK: - Show Profile
|
||||
|
@ -344,11 +344,11 @@ class UserActivityManager {
|
|||
return activity.userInfo?["profileID"] as? String
|
||||
}
|
||||
|
||||
func handleShowProfile(activity: NSUserActivity) {
|
||||
func handleShowProfile(activity: NSUserActivity) async {
|
||||
guard let accountID = Self.getProfile(from: activity) else {
|
||||
return
|
||||
}
|
||||
context.select(route: .timelines)
|
||||
await context.select(route: .timelines)
|
||||
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
@ -361,11 +361,11 @@ class UserActivityManager {
|
|||
return activity
|
||||
}
|
||||
|
||||
func handleShowNotification(activity: NSUserActivity) {
|
||||
func handleShowNotification(activity: NSUserActivity) async {
|
||||
guard let notificationID = activity.userInfo?["notificationID"] as? String else {
|
||||
return
|
||||
}
|
||||
context.select(route: .notifications)
|
||||
await context.select(route: .notifications)
|
||||
context.push(NotificationLoadingViewController(notificationID: notificationID, mastodonController: mastodonController))
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ enum UserActivityType: String {
|
|||
|
||||
extension UserActivityType {
|
||||
@MainActor
|
||||
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void {
|
||||
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) async -> Void {
|
||||
switch self {
|
||||
case .mainScene:
|
||||
fatalError("cannot handle main scene activity")
|
||||
|
|
|
@ -33,6 +33,11 @@ class CachedImageView: UIImageView {
|
|||
commonInit()
|
||||
}
|
||||
|
||||
deinit {
|
||||
fetchTask?.cancel()
|
||||
blurHashTask?.cancel()
|
||||
}
|
||||
|
||||
private func commonInit() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
|
||||
}
|
||||
|
|
|
@ -36,6 +36,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
var emojiFont: UIFont = .preferredFont(forTextStyle: .body)
|
||||
var emojiTextColor: UIColor = .label
|
||||
|
||||
private let tapRecognizer = UITapGestureRecognizer()
|
||||
|
||||
// The link range currently being previewed
|
||||
private var currentPreviewedLinkRange: NSRange?
|
||||
// The preview created in the previewForHighlighting method, so that we can use the same one in previewForDismissing.
|
||||
|
@ -78,8 +80,9 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
updateLinkUnderlineStyle()
|
||||
|
||||
// the text view's builtin link interaction code is tied to isSelectable, so we need to use our own tap recognizer
|
||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(textTapped(_:)))
|
||||
addGestureRecognizer(recognizer)
|
||||
tapRecognizer.addTarget(self, action: #selector(textTapped(_:)))
|
||||
tapRecognizer.delegate = self
|
||||
addGestureRecognizer(tapRecognizer)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(_updateLinkUnderlineStyle), name: UIAccessibility.buttonShapesEnabledStatusDidChangeNotification, object: nil)
|
||||
underlineTextLinksCancellable =
|
||||
|
@ -132,12 +135,6 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
|||
}
|
||||
|
||||
@objc func textTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
// if there currently is a selection, deselct it on single-tap
|
||||
if selectedRange.length > 0 {
|
||||
// location doesn't matter since we are non-editable and the cursor isn't visible
|
||||
selectedRange = NSRange(location: 0, length: 0)
|
||||
}
|
||||
|
||||
let location = recognizer.location(in: self)
|
||||
if let (link, range) = getLinkAtPoint(location),
|
||||
link.scheme != dataDetectorsScheme {
|
||||
|
@ -384,3 +381,25 @@ extension ContentTextView: UIContextMenuInteractionDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentTextView: UIGestureRecognizerDelegate {
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// NB: This method is both a gesture recognizer delegate method and a UIView method.
|
||||
// We only want to prevent our own tap gesture recognizer from beginning, but don't
|
||||
// want to interfere with any other gestures that may begin over this view.
|
||||
if gestureRecognizer === tapRecognizer {
|
||||
let location = gestureRecognizer.location(in: self)
|
||||
if let (link, _) = getLinkAtPoint(location) {
|
||||
if link.scheme == dataDetectorsScheme {
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,11 @@ import SwiftUI
|
|||
import SafariServices
|
||||
|
||||
class ProfileFieldValueView: UIView {
|
||||
weak var navigationDelegate: TuskerNavigationDelegate?
|
||||
weak var navigationDelegate: TuskerNavigationDelegate? {
|
||||
didSet {
|
||||
textView.navigationDelegate = navigationDelegate
|
||||
}
|
||||
}
|
||||
|
||||
private static let converter = HTMLConverter(
|
||||
font: .preferredFont(forTextStyle: .body),
|
||||
|
@ -23,9 +27,8 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
private let account: AccountMO
|
||||
private let field: Account.Field
|
||||
private var link: (String, URL)?
|
||||
|
||||
private let label = EmojiLabel()
|
||||
private let textView = ContentTextView()
|
||||
private var iconView: UIView?
|
||||
|
||||
private var currentTargetedPreview: UITargetedPreview?
|
||||
|
@ -38,34 +41,28 @@ class ProfileFieldValueView: UIView {
|
|||
|
||||
let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value))
|
||||
|
||||
var range = NSRange(location: 0, length: 0)
|
||||
if converted.length != 0,
|
||||
let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL {
|
||||
link = (converted.attributedSubstring(from: range).string, url)
|
||||
label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped)))
|
||||
label.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||
label.isUserInteractionEnabled = true
|
||||
|
||||
converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in
|
||||
guard value != nil else { return }
|
||||
#if os(visionOS)
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.link, range: range)
|
||||
#else
|
||||
converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range)
|
||||
#endif
|
||||
// the .link attribute in a UILabel always makes the color blue >.>
|
||||
converted.removeAttribute(.link, range: range)
|
||||
}
|
||||
}
|
||||
|
||||
label.numberOfLines = 0
|
||||
label.font = .preferredFont(forTextStyle: .body)
|
||||
label.adjustsFontForContentSizeCategory = true
|
||||
label.attributedText = converted
|
||||
label.setEmojis(account.emojis, identifier: account.id)
|
||||
label.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(label)
|
||||
#if os(visionOS)
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.link
|
||||
]
|
||||
#else
|
||||
textView.linkTextAttributes = [
|
||||
.foregroundColor: UIColor.tintColor
|
||||
]
|
||||
#endif
|
||||
textView.backgroundColor = nil
|
||||
textView.isScrollEnabled = false
|
||||
textView.isSelectable = false
|
||||
textView.isEditable = false
|
||||
textView.textContainerInset = .zero
|
||||
textView.font = .preferredFont(forTextStyle: .body)
|
||||
textView.adjustsFontForContentSizeCategory = true
|
||||
textView.attributedText = converted
|
||||
textView.setEmojis(account.emojis, identifier: account.id)
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(textView)
|
||||
|
||||
let labelTrailingConstraint: NSLayoutConstraint
|
||||
|
||||
|
@ -82,20 +79,20 @@ class ProfileFieldValueView: UIView {
|
|||
icon.isPointerInteractionEnabled = true
|
||||
icon.accessibilityLabel = "Verified link"
|
||||
addSubview(icon)
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor)
|
||||
NSLayoutConstraint.activate([
|
||||
icon.centerYAnchor.constraint(equalTo: label.centerYAnchor),
|
||||
icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor),
|
||||
icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
|
||||
])
|
||||
} else {
|
||||
labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
labelTrailingConstraint,
|
||||
label.topAnchor.constraint(equalTo: topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
textView.topAnchor.constraint(equalTo: topAnchor),
|
||||
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
|
@ -104,7 +101,7 @@ class ProfileFieldValueView: UIView {
|
|||
}
|
||||
|
||||
override func sizeThatFits(_ size: CGSize) -> CGSize {
|
||||
var size = label.sizeThatFits(size)
|
||||
var size = textView.sizeThatFits(size)
|
||||
if let iconView {
|
||||
size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width
|
||||
}
|
||||
|
@ -112,29 +109,7 @@ class ProfileFieldValueView: UIView {
|
|||
}
|
||||
|
||||
func setTextAlignment(_ alignment: NSTextAlignment) {
|
||||
label.textAlignment = alignment
|
||||
}
|
||||
|
||||
func getHashtagOrURL() -> (Hashtag?, URL)? {
|
||||
guard let (text, url) = link else {
|
||||
return nil
|
||||
}
|
||||
if text.starts(with: "#") {
|
||||
return (Hashtag(name: String(text.dropFirst()), url: url), url)
|
||||
} else {
|
||||
return (nil, url)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func linkTapped() {
|
||||
guard let (hashtag, url) = getHashtagOrURL() else {
|
||||
return
|
||||
}
|
||||
if let hashtag {
|
||||
navigationDelegate?.selected(tag: hashtag)
|
||||
} else {
|
||||
navigationDelegate?.selected(url: url)
|
||||
}
|
||||
textView.textAlignment = alignment
|
||||
}
|
||||
|
||||
@objc private func verifiedIconTapped() {
|
||||
|
@ -144,7 +119,7 @@ class ProfileFieldValueView: UIView {
|
|||
let view = ProfileFieldVerificationView(
|
||||
acct: account.acct,
|
||||
verifiedAt: field.verifiedAt!,
|
||||
linkText: label.text ?? "",
|
||||
linkText: textView.text ?? "",
|
||||
navigationDelegate: navigationDelegate
|
||||
)
|
||||
let host = UIHostingController(rootView: view)
|
||||
|
@ -168,49 +143,3 @@ class ProfileFieldValueView: UIView {
|
|||
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!)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ class ProfileHeaderView: UIView {
|
|||
weak var delegate: ProfileHeaderViewDelegate?
|
||||
var mastodonController: MastodonController! { delegate?.apiController }
|
||||
|
||||
@IBOutlet weak var headerImageView: UIImageView!
|
||||
@IBOutlet weak var headerImageView: CachedImageView!
|
||||
@IBOutlet weak var avatarContainerView: UIView!
|
||||
@IBOutlet weak var avatarImageView: UIImageView!
|
||||
@IBOutlet weak var avatarImageView: CachedImageView!
|
||||
@IBOutlet weak var moreButton: ProfileHeaderButton!
|
||||
@IBOutlet weak var followButton: ProfileHeaderButton!
|
||||
@IBOutlet weak var displayNameLabel: AccountDisplayNameLabel!
|
||||
|
@ -44,8 +44,6 @@ class ProfileHeaderView: UIView {
|
|||
|
||||
var accountID: String!
|
||||
|
||||
private var imagesTask: Task<Void, Never>?
|
||||
|
||||
private var isGrayscale = false
|
||||
private var followButtonMode = FollowButtonMode.follow {
|
||||
didSet {
|
||||
|
@ -56,10 +54,6 @@ class ProfileHeaderView: UIView {
|
|||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
imagesTask?.cancel()
|
||||
}
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
|
@ -69,11 +63,13 @@ class ProfileHeaderView: UIView {
|
|||
avatarContainerView.layer.cornerCurve = .continuous
|
||||
// Set zPositions so the gallery presentation/dismissal animation looks correct.
|
||||
avatarContainerView.layer.zPosition = 2
|
||||
avatarImageView.cache = .avatars
|
||||
avatarImageView.layer.masksToBounds = true
|
||||
avatarImageView.layer.cornerCurve = .continuous
|
||||
avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed)))
|
||||
avatarImageView.isUserInteractionEnabled = true
|
||||
|
||||
headerImageView.cache = .headers
|
||||
headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed)))
|
||||
headerImageView.isUserInteractionEnabled = true
|
||||
headerImageView.layer.zPosition = 1
|
||||
|
@ -138,11 +134,11 @@ class ProfileHeaderView: UIView {
|
|||
usernameLabel.text = "@\(account.acct)"
|
||||
lockImageView.isHidden = !account.locked
|
||||
|
||||
imagesTask?.cancel()
|
||||
let avatar = account.avatar
|
||||
let header = account.header
|
||||
imagesTask = Task {
|
||||
await updateImages(avatar: avatar, header: header)
|
||||
if let avatar = account.avatar {
|
||||
avatarImageView.update(for: avatar)
|
||||
}
|
||||
if let header = account.header {
|
||||
headerImageView.update(for: header)
|
||||
}
|
||||
|
||||
moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? [])
|
||||
|
@ -294,44 +290,6 @@ class ProfileHeaderView: UIView {
|
|||
avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView)
|
||||
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView)
|
||||
displayNameLabel.updateForAccountDisplayName(account: account)
|
||||
|
||||
if isGrayscale != Preferences.shared.grayscaleImages {
|
||||
isGrayscale = Preferences.shared.grayscaleImages
|
||||
imagesTask?.cancel()
|
||||
let avatar = account.avatar
|
||||
let header = account.header
|
||||
imagesTask = Task {
|
||||
await updateImages(avatar: avatar, header: header)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func updateImages(avatar: URL?, header: URL?) async {
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
guard let avatar,
|
||||
let image = await ImageCache.avatars.get(avatar, loadOriginal: true).1,
|
||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: avatar, image: image),
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.avatarImageView.image = transformedImage
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
guard let header,
|
||||
let image = await ImageCache.avatars.get(header, loadOriginal: true).1,
|
||||
let transformedImage = await ImageGrayscalifier.convertIfNecessary(url: header, image: image),
|
||||
!Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
self.headerImageView.image = transformedImage
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func formatBigNumber(_ value: Int) -> (String, String) {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22685"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="System colors in document resources" minToolsVersion="11.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
|
@ -14,7 +14,7 @@
|
|||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iN0-l3-epB" customClass="ProfileHeaderView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv">
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="dgG-dR-lSv" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="48" width="414" height="150"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="150" id="aCE-CA-XWm"/>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wT9-2J-uSY">
|
||||
<rect key="frame" x="16" y="138" width="120" height="120"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4">
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="TkY-oK-if4" customClass="CachedImageView" customModule="Tusker" customModuleProvider="target">
|
||||
<rect key="frame" x="2" y="2" width="116" height="116"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="116" id="eDg-Vc-o8R"/>
|
||||
|
@ -93,7 +93,7 @@
|
|||
<stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" spacing="8" translatesAutoresizingMaskIntoConstraints="NO" id="udp-EN-wtc">
|
||||
<rect key="frame" x="0.0" y="575.5" width="218.5" height="20.5"/>
|
||||
<subviews>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" translatesAutoresizingMaskIntoConstraints="NO" id="5w9-LA-8kc">
|
||||
<rect key="frame" x="0.0" y="0.0" width="104" height="20.5"/>
|
||||
<state key="normal" title="Button"/>
|
||||
<buttonConfiguration key="configuration" style="plain" title="123 Following">
|
||||
|
@ -104,7 +104,7 @@
|
|||
<action selector="followingCountButtonPressed:" destination="iN0-l3-epB" eventType="touchUpInside" id="QOO-zK-pfu"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="XCX-Y3-cG5">
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" pointerInteraction="YES" id="XCX-Y3-cG5">
|
||||
<rect key="frame" x="112" y="0.0" width="106.5" height="20.5"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<state key="normal" title="Button"/>
|
||||
|
|
|
@ -640,7 +640,7 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
return defaultRegion
|
||||
} else if let button = interaction.view as? UIButton,
|
||||
actionButtons.contains(button) {
|
||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
||||
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||
rect = rect.insetBy(dx: -24, dy: -24)
|
||||
return UIPointerRegion(rect: rect)
|
||||
}
|
||||
|
@ -654,8 +654,8 @@ extension ConversationMainStatusCollectionViewCell: UIPointerInteractionDelegate
|
|||
} else if let button = interaction.view as? UIButton,
|
||||
actionButtons.contains(button) {
|
||||
let preview = UITargetedPreview(view: button.imageView!)
|
||||
var rect = button.convert(button.imageView!.bounds, to: button.imageView!)
|
||||
rect = rect.insetBy(dx: -24, dy: -24)
|
||||
var rect = button.convert(button.imageView!.bounds, from: button.imageView!)
|
||||
rect = rect.insetBy(dx: -8, dy: -8)
|
||||
return UIPointerStyle(effect: .highlight(preview), shape: .roundedRect(rect))
|
||||
}
|
||||
return nil
|
||||
|
|
|
@ -256,6 +256,7 @@ extension StatusCollectionViewCell {
|
|||
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
|
||||
view.layer.cornerRadius = 2.5
|
||||
view.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner]
|
||||
view.layer.zPosition = -1
|
||||
prevThreadLinkView = view
|
||||
contentView.addSubview(view)
|
||||
NSLayoutConstraint.activate([
|
||||
|
@ -278,6 +279,7 @@ extension StatusCollectionViewCell {
|
|||
view.backgroundColor = .tintColor.withAlphaComponent(0.5)
|
||||
view.layer.cornerRadius = 2.5
|
||||
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
|
||||
view.layer.zPosition = -1
|
||||
nextThreadLinkView = view
|
||||
contentView.addSubview(view)
|
||||
let bottomConstraint = view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
// https://help.apple.com/xcode/#/dev745c5c974
|
||||
|
||||
MARKETING_VERSION = 2024.3
|
||||
CURRENT_PROJECT_VERSION = 126
|
||||
CURRENT_PROJECT_VERSION = 127
|
||||
CURRENT_PROJECT_VERSION = $(inherited)$(CURRENT_PROJECT_VERSION_BUILD_SUFFIX_$(CONFIGURATION))
|
||||
|
||||
CURRENT_PROJECT_VERSION_BUILD_SUFFIX_Debug=-dev
|
||||
|
|
Loading…
Reference in New Issue