Compare commits

..

No commits in common. "765b5e1a7cfe2bbd67d9795157cdd3287c30bbb3" and "bcc70e9f8c7810dde6b2312980d4e83f5d1d60ae" have entirely different histories.

38 changed files with 406 additions and 881 deletions

View File

@ -8,7 +8,7 @@
import Foundation import Foundation
public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { public struct List: Decodable, Equatable, Hashable, Sendable {
public let id: String public let id: String
public let title: String public let title: String
@ -16,11 +16,6 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
return .list(id: id) return .list(id: id)
} }
public init(id: String, title: String) {
self.id = id
self.title = title
}
public static func ==(lhs: List, rhs: List) -> Bool { public static func ==(lhs: List, rhs: List) -> Bool {
return lhs.id == rhs.id && lhs.title == rhs.title return lhs.id == rhs.id && lhs.title == rhs.title
} }
@ -30,28 +25,28 @@ public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable {
hasher.combine(title) hasher.combine(title)
} }
public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> { public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> {
var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts") var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts")
request.range = range request.range = range
return request return request
} }
public static func update(_ listID: String, title: String) -> Request<List> { public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title])) return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
} }
public static func delete(_ listID: String) -> Request<Empty> { public static func delete(_ list: List) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)") return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)")
} }
public static func add(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> { public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody( return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }
public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request<Empty> { public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody( return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }

View File

@ -1,13 +0,0 @@
//
// ListProtocol.swift
// Pachyderm
//
// Created by Shadowfacts on 2/25/23.
//
import Foundation
public protocol ListProtocol {
var id: String { get }
var title: String { get }
}

View File

@ -223,8 +223,6 @@
D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; }; D691771129A2B76A0054D7EF /* MainActor+Unsafe.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */; };
D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; }; D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */; };
D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; }; D691771729A710520054D7EF /* ProfileNoContentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */; };
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */; };
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */; };
D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; }; D693A72825CF282E003A14E2 /* TrendingHashtagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */; };
D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; }; D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; };
D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; }; D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; };
@ -643,8 +641,6 @@
D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; }; D691771029A2B76A0054D7EF /* MainActor+Unsafe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MainActor+Unsafe.swift"; sourceTree = "<group>"; };
D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; }; D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = "<group>"; };
D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; }; D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = "<group>"; };
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = "<group>"; };
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = "<group>"; };
D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = "<group>"; };
D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; }; D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = "<group>"; };
D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; }; D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = "<group>"; };
@ -988,7 +984,6 @@
children = ( children = (
D62D2425217ABF63005076CC /* UserActivityType.swift */, D62D2425217ABF63005076CC /* UserActivityType.swift */,
D62D2421217AA7E1005076CC /* UserActivityManager.swift */, D62D2421217AA7E1005076CC /* UserActivityManager.swift */,
D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */,
D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */, D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */,
D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */, D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */,
); );
@ -1213,7 +1208,6 @@
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */, D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */,
D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */, D651C5B32915B00400236EF6 /* ProfileFieldsView.swift */,
D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */, D6A3A37F295515550036B6EF /* ProfileHeaderButton.swift */,
D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */,
); );
path = "Profile Header"; path = "Profile Header";
sourceTree = "<group>"; sourceTree = "<group>";
@ -2081,7 +2075,6 @@
D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */,
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */,
D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */,
D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */,
04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */,
D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */,
D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */,
@ -2236,7 +2229,6 @@
D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */, D621733328F1D5ED004C7DB1 /* ReblogService.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */, D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */, D61F75AF293AF50C00C0B37F /* EditedFilter.swift in Sources */,
D691771929A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift in Sources */,
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */, D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */, D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */, D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,

View File

@ -48,7 +48,7 @@ class DeleteListService {
private func deleteList() async { private func deleteList() async {
do { do {
let request = List.delete(list.id) let request = List.delete(list)
_ = try await mastodonController.run(request) _ = try await mastodonController.run(request)
mastodonController.deletedList(list) mastodonController.deletedList(list)
} catch { } catch {

View File

@ -166,8 +166,6 @@ class MastodonController: ObservableObject {
loadAccountPreferences() loadAccountPreferences()
lists = loadCachedLists()
NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator) NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [unowned self] _ in .sink { [unowned self] _ in
@ -365,23 +363,6 @@ class MastodonController: ObservableObject {
} }
} }
private func loadCachedLists() -> [List] {
let req = ListMO.fetchRequest()
guard let lists = try? persistentContainer.viewContext.fetch(req) else {
return []
}
return lists.map {
List(id: $0.id, title: $0.title)
}.sorted(using: SemiCaseSensitiveComparator.keyPath(\.title))
}
func getCachedList(id: String) -> List? {
let req = ListMO.fetchRequest(id: id)
return (try? persistentContainer.viewContext.fetch(req).first).flatMap {
List(id: $0.id, title: $0.title)
}
}
@MainActor @MainActor
func addedList(_ list: List) { func addedList(_ list: List) {
var new = self.lists var new = self.lists

View File

@ -11,13 +11,13 @@ import Pachyderm
@MainActor @MainActor
class RenameListService { class RenameListService {
private let list: ListProtocol private let list: List
private let mastodonController: MastodonController private let mastodonController: MastodonController
private let present: (UIViewController) -> Void private let present: (UIViewController) -> Void
private var renameAction: UIAlertAction? private var renameAction: UIAlertAction?
init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) {
self.list = list self.list = list
self.mastodonController = mastodonController self.mastodonController = mastodonController
self.present = present self.present = present
@ -47,7 +47,7 @@ class RenameListService {
private func updateList(with title: String) async { private func updateList(with title: String) async {
do { do {
let req = List.update(list.id, title: title) let req = List.update(list, title: title)
let (list, _) = try await mastodonController.run(req) let (list, _) = try await mastodonController.run(req)
mastodonController.renamedList(list) mastodonController.renamedList(list)
} catch { } catch {

View File

@ -11,7 +11,7 @@ import CoreData
import Pachyderm import Pachyderm
@objc(ListMO) @objc(ListMO)
public final class ListMO: NSManagedObject, ListProtocol { public final class ListMO: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> { @nonobjc public class func fetchRequest() -> NSFetchRequest<ListMO> {
return NSFetchRequest(entityName: "List") return NSFetchRequest(entityName: "List")

View File

@ -11,7 +11,7 @@ import UIKit
struct MenuController { struct MenuController {
static let composeCommand: UIKeyCommand = { static let composeCommand: UIKeyCommand = {
return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command) return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command)
}() }()
static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand { static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand {

View File

@ -74,7 +74,7 @@ class AuxiliarySceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDel
private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? { private func viewController(for activity: NSUserActivity, mastodonController: MastodonController) -> UIViewController? {
switch UserActivityType(rawValue: activity.activityType) { switch UserActivityType(rawValue: activity.activityType) {
case .showTimeline: case .showTimeline:
guard let (timeline, _) = UserActivityManager.getTimeline(from: activity) else { return nil } guard let timeline = UserActivityManager.getTimeline(from: activity) else { return nil }
return timelineViewController(for: timeline, mastodonController: mastodonController) return timelineViewController(for: timeline, mastodonController: mastodonController)
case .showConversation: case .showConversation:

View File

@ -64,15 +64,7 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)") stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)")
let context: any UserActivityHandlingContext _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene))
if let account = UserActivityManager.getAccount(from: userActivity),
account.id != scene.session.mastodonController!.accountInfo!.id {
stateRestorationLogger.info("MainSceneDelegate cannot resume user activity for different account")
return
} else {
context = ActiveAccountUserActivityHandlingContext(isHandoff: true, root: rootViewController!)
}
_ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context))
} }
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
@ -177,16 +169,10 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate
activateAccount(account, animated: false) activateAccount(account, animated: false)
if let activity = launchActivity { if let activity = launchActivity {
func doRestoreActivity(context: UserActivityHandlingContext) {
_ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context))
context.finalize(activity: activity)
}
if activity.isStateRestorationActivity { if activity.isStateRestorationActivity {
doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!)) rootViewController?.restoreActivity(activity)
} else if activity.activityType != UserActivityType.mainScene.rawValue { } else if activity.activityType != UserActivityType.mainScene.rawValue {
doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(isHandoff: false, root: rootViewController!)) _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!))
} else {
stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)")
} }
} }
} else { } else {

View File

@ -227,10 +227,6 @@ class ConversationViewController: UIViewController {
} }
private func mainStatusLoaded(_ mainStatus: StatusMO) { private func mainStatusLoaded(_ mainStatus: StatusMO) {
if let accountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showConversationActivity(mainStatusID: mainStatus.id, accountID: accountID)
}
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self) let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, conversationViewController: self)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically vc.showStatusesAutomatically = showStatusesAutomatically
@ -434,6 +430,10 @@ extension ConversationViewController: StateRestorableViewController {
return nil return nil
} }
} }
func restoreActivity(_ activity: NSUserActivity) {
fatalError("ConversationViewController must be reconstructed, not restored")
}
} }
extension ConversationViewController: ToastableViewController { extension ConversationViewController: ToastableViewController {

View File

@ -539,6 +539,24 @@ extension ExploreViewController: StateRestorableViewController {
return nil return nil
} }
} }
func restoreActivity(_ activity: NSUserActivity) {
guard let type = UserActivityType(rawValue: activity.activityType) else {
return
}
if type == .bookmarks {
show(BookmarksViewController(mastodonController: mastodonController), sender: nil)
} else if type == .search {
loadViewIfNeeded()
searchController.isActive = true
if let query = UserActivityManager.getSearchQuery(from: activity),
!query.isEmpty {
searchController.searchBar.text = query
} else {
searchController.searchBar.becomeFirstResponder()
}
}
}
} }
extension ExploreViewController: InstanceTimelineViewControllerDelegate { extension ExploreViewController: InstanceTimelineViewControllerDelegate {

View File

@ -105,7 +105,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
func loadAccounts() async { func loadAccounts() async {
do { do {
let request = List.getAccounts(list.id) let request = List.getAccounts(list)
let (accounts, pagination) = try await mastodonController.run(request) let (accounts, pagination) = try await mastodonController.run(request)
self.nextRange = pagination?.older self.nextRange = pagination?.older
@ -135,7 +135,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
private func addAccount(id: String) async { private func addAccount(id: String) async {
changedAccounts = true changedAccounts = true
do { do {
let req = List.add(list.id, accounts: [id]) let req = List.add(list, accounts: [id])
_ = try await mastodonController.run(req) _ = try await mastodonController.run(req)
self.searchController.isActive = false self.searchController.isActive = false
await self.loadAccounts() await self.loadAccounts()
@ -151,7 +151,7 @@ class EditListAccountsViewController: EnhancedTableViewController {
private func removeAccount(id: String) async { private func removeAccount(id: String) async {
changedAccounts = true changedAccounts = true
do { do {
let request = List.remove(list.id, accounts: [id]) let request = List.remove(list, accounts: [id])
_ = try await mastodonController.run(request) _ = try await mastodonController.run(request)
await self.loadAccounts() await self.loadAccounts()
} catch { } catch {

View File

@ -32,4 +32,7 @@ extension BookmarksViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
return UserActivityManager.bookmarksActivity(accountID: mastodonController.accountInfo!.id) return UserActivityManager.bookmarksActivity(accountID: mastodonController.accountInfo!.id)
} }
func restoreActivity(_ activity: NSUserActivity) {
}
} }

View File

@ -92,14 +92,19 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
return root.stateRestorationActivity() return root.stateRestorationActivity()
} }
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { func restoreActivity(_ activity: NSUserActivity) {
loadViewIfNeeded() loadViewIfNeeded()
root.compose(editing: draft, animated: animated, isDucked: isDucked) root.restoreActivity(activity)
} }
func select(route: TuskerRoute, animated: Bool) { func presentCompose() {
loadViewIfNeeded() loadViewIfNeeded()
root.select(route: route, animated: animated) root.presentCompose()
}
func select(tab: MainTabBarViewController.Tab) {
loadViewIfNeeded()
root.select(tab: tab)
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -107,16 +112,6 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController {
return root.getTabController(tab: tab) return root.getTabController(tab: tab)
} }
func getNavigationDelegate() -> TuskerNavigationDelegate? {
loadViewIfNeeded()
return root.getNavigationDelegate()
}
func getNavigationController() -> NavigationControllerProtocol {
loadViewIfNeeded()
return root.getNavigationController()
}
func performSearch(query: String) { func performSearch(query: String) {
loadViewIfNeeded() loadViewIfNeeded()
root.performSearch(query: query) root.performSearch(query: query)

View File

@ -20,20 +20,22 @@ extension DuckableContainerViewController: TuskerRootViewController {
return activity return activity
} }
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { func restoreActivity(_ activity: NSUserActivity) {
(child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked) if let draft = UserActivityManager.getDraft(from: activity),
let account = UserActivityManager.getAccount(from: activity) {
let mastodonController = MastodonController.getForAccount(account)
let compose = ComposeHostingController(draft: draft, mastodonController: mastodonController)
_ = presentDuckable(compose, animated: false, isDucked: true)
}
(child as? TuskerRootViewController)?.restoreActivity(activity)
} }
func getNavigationDelegate() -> TuskerNavigationDelegate? { func presentCompose() {
(child as? TuskerRootViewController)?.getNavigationDelegate() (child as? TuskerRootViewController)?.presentCompose()
} }
func getNavigationController() -> NavigationControllerProtocol { func select(tab: MainTabBarViewController.Tab) {
(child as! TuskerRootViewController).getNavigationController() (child as? TuskerRootViewController)?.select(tab: tab)
}
func select(route: TuskerRoute, animated: Bool) {
(child as? TuskerRootViewController)?.select(route: route, animated: animated)
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {

View File

@ -106,7 +106,7 @@ class MainSidebarViewController: UIViewController {
NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil)
mastodonController.$lists mastodonController.$lists
.sink { [unowned self] in self.reloadLists($0, animated: true) } .sink { [unowned self] in self.reloadLists($0) }
.store(in: &cancellables) .store(in: &cancellables)
mastodonController.$followedHashtags mastodonController.$followedHashtags
.merge(with: .merge(with:
@ -179,7 +179,7 @@ class MainSidebarViewController: UIViewController {
], toSection: .compose) ], toSection: .compose)
dataSource.apply(snapshot, animatingDifferences: false) dataSource.apply(snapshot, animatingDifferences: false)
reloadLists(mastodonController.lists, animated: false) reloadLists(mastodonController.lists)
updateHashtagsSection(followed: mastodonController.followedHashtags) updateHashtagsSection(followed: mastodonController.followedHashtags)
reloadSavedInstances() reloadSavedInstances()
} }
@ -192,7 +192,7 @@ class MainSidebarViewController: UIViewController {
} }
} }
private func reloadLists(_ lists: [List], animated: Bool) { private func reloadLists(_ lists: [List]) {
if let selectedItem, if let selectedItem,
case .list(let list) = selectedItem, case .list(let list) = selectedItem,
!lists.contains(where: { $0.id == list.id }) { !lists.contains(where: { $0.id == list.id }) {
@ -204,7 +204,7 @@ class MainSidebarViewController: UIViewController {
exploreSnapshot.expand([.listsHeader]) exploreSnapshot.expand([.listsHeader])
exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader)
exploreSnapshot.append([.addList], to: .listsHeader) exploreSnapshot.append([.addList], to: .listsHeader)
self.dataSource.apply(exploreSnapshot, to: .lists, animatingDifferences: animated) self.dataSource.apply(exploreSnapshot, to: .lists)
} }
@MainActor @MainActor

View File

@ -128,10 +128,6 @@ class MainSplitViewController: UISplitViewController {
fastAccountSwitcher?.hide() fastAccountSwitcher?.hide()
} }
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
} }
extension MainSplitViewController: UISplitViewControllerDelegate { extension MainSplitViewController: UISplitViewControllerDelegate {
@ -357,7 +353,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate {
extension MainSplitViewController: MainSidebarViewControllerDelegate { extension MainSplitViewController: MainSidebarViewControllerDelegate {
func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) { func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) {
compose(editing: nil) presentCompose()
} }
func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) { func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) {
@ -415,41 +411,85 @@ extension MainSplitViewController: StateRestorableViewController {
return nil return nil
} }
} }
}
extension MainSplitViewController: TuskerRootViewController { func restoreActivity(_ activity: NSUserActivity) {
func select(route: TuskerRoute, animated: Bool) {
guard traitCollection.horizontalSizeClass != .compact else { guard traitCollection.horizontalSizeClass != .compact else {
tabBarViewController?.select(route: route, animated: animated) tabBarViewController.restoreActivity(activity)
return return
} }
guard presentedViewController == nil else { guard let type = UserActivityType(rawValue: activity.activityType) else {
dismiss(animated: animated) {
self.select(route: route, animated: animated)
}
return return
} }
let item: MainSidebarViewController.Item let item: MainSidebarViewController.Item
switch route { var needsRestore = true
case .timelines: switch type {
case .showTimeline:
item = .tab(.timelines) item = .tab(.timelines)
case .notifications: case .checkNotifications:
item = .tab(.notifications) item = .tab(.notifications)
case .myProfile: case .search:
item = .tab(.myProfile)
case .explore:
item = .explore item = .explore
case .bookmarks: case .bookmarks:
item = .bookmarks item = .bookmarks
case .list(id: let id): case .myProfile:
if let list = mastodonController.getCachedList(id: id) { item = .tab(.myProfile)
item = .list(list) needsRestore = false
} else { case .newPost:
return return
} case .showConversation, .showProfile:
item = .tab(.timelines)
default:
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
return
} }
sidebar.select(item: item, animated: false) sidebar.select(item: item, animated: false)
select(item: item) select(item: item)
if type == .showConversation {
if let statusID = UserActivityManager.getConversationStatus(from: activity) {
let conv = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController)
secondaryNavController.show(conv, sender: nil)
}
} else if type == .showProfile {
if let accountID = UserActivityManager.getProfile(from: activity) {
let profile = ProfileViewController(accountID: accountID, mastodonController: mastodonController)
secondaryNavController.show(profile, sender: nil)
}
} else if needsRestore {
if let vc = secondaryNavController.viewControllers.first as? StateRestorableViewController {
vc.restoreActivity(activity)
} else {
stateRestorationLogger.fault("MainSplitViewController: Unable to restore activity, couldn't find StateRestorableViewController")
}
}
}
}
extension MainSplitViewController: TuskerRootViewController {
@objc func presentCompose() {
self.compose()
}
func select(tab: MainTabBarViewController.Tab) {
if traitCollection.horizontalSizeClass == .compact {
tabBarViewController?.select(tab: tab)
} else {
if tab == .compose {
presentCompose()
} else {
if presentedViewController != nil {
dismiss(animated: true) {
self.select(item: .tab(tab))
self.sidebar.select(item: .tab(tab), animated: false)
}
} else {
select(item: .tab(tab))
sidebar.select(item: .tab(tab), animated: false)
}
}
}
} }
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? {
@ -466,22 +506,6 @@ extension MainSplitViewController: TuskerRootViewController {
} }
} }
func getNavigationDelegate() -> TuskerNavigationDelegate? {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.getNavigationDelegate()
} else {
return self
}
}
func getNavigationController() -> NavigationControllerProtocol {
if traitCollection.horizontalSizeClass == .compact {
return tabBarViewController.getNavigationController()
} else {
return secondaryNavController
}
}
func performSearch(query: String) { func performSearch(query: String) {
guard traitCollection.horizontalSizeClass != .compact else { guard traitCollection.horizontalSizeClass != .compact else {
// ensure the tab bar VC is loaded // ensure the tab bar VC is loaded

View File

@ -110,31 +110,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
repositionFastSwitcherIndicator() repositionFastSwitcherIndicator()
} }
func select(tab: Tab) {
if tab == .compose {
compose(editing: nil)
} else {
// when switching tabs, dismiss the currently presented VC
// otherwise the selected tab changes behind the presented VC
if presentedViewController != nil {
dismiss(animated: true) {
self.selectedIndex = tab.rawValue
}
} else {
stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
selectedIndex = tab.rawValue
}
}
}
override func show(_ vc: UIViewController, sender: Any?) {
if let nav = selectedViewController as? UINavigationController {
nav.pushViewController(vc, animated: true)
} else {
present(vc, animated: true)
}
}
private func repositionFastSwitcherIndicator() { private func repositionFastSwitcherIndicator() {
guard let myProfileButton = findMyProfileTabBarButton() else { guard let myProfileButton = findMyProfileTabBarButton() else {
return return
@ -170,10 +145,6 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
fastAccountSwitcher.hide() fastAccountSwitcher.hide()
} }
@objc func handleComposeKeyCommand() {
compose(editing: nil)
}
func embedInNavigationController(_ vc: UIViewController) -> UINavigationController { func embedInNavigationController(_ vc: UIViewController) -> UINavigationController {
if let vc = vc as? UINavigationController { if let vc = vc as? UINavigationController {
return vc return vc
@ -186,7 +157,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool {
if viewController == composePlaceholder { if viewController == composePlaceholder {
compose(editing: nil) presentCompose()
return false return false
} }
if viewController == viewControllers![selectedIndex], if viewController == viewControllers![selectedIndex],
@ -271,52 +242,98 @@ extension MainTabBarViewController: TuskerNavigationDelegate {
extension MainTabBarViewController: StateRestorableViewController { extension MainTabBarViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
let nav = viewController(for: selectedTab) as! UINavigationController
var activity: NSUserActivity? var activity: NSUserActivity?
if let presentedNav = presentedViewController as? UINavigationController, if let vc = nav.topViewController as? StateRestorableViewController {
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID)
} else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController {
activity = vc.stateRestorationActivity() activity = vc.stateRestorationActivity()
} } else {
if activity == nil {
stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController") stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController")
} }
if let presentedNav = presentedViewController as? UINavigationController,
let compose = presentedNav.viewControllers.first as? ComposeHostingController {
activity = UserActivityManager.addEditedDraft(to: activity, draft: compose.draft)
}
return activity return activity
} }
func restoreActivity(_ activity: NSUserActivity) {
guard let type = UserActivityType(rawValue: activity.activityType) else {
return
}
func restoreEditedDraft() {
// on iOS 16+, this is handled by the duckable container
if #unavailable(iOS 16.0),
let draft = UserActivityManager.getDraft(from: activity) {
draftToPresentOnAppear = draft
}
}
let tab: Tab
switch type {
case .showTimeline:
tab = .timelines
case .checkNotifications:
tab = .notifications
case .search, .bookmarks:
tab = .explore
case .myProfile:
tab = .myProfile
case .newPost:
restoreEditedDraft()
return
case .showConversation, .showProfile:
tab = .timelines
default:
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity of unexpected type \(activity.activityType, privacy: .public)")
return
}
select(tab: tab)
let nav = viewController(for: tab) as! UINavigationController
if type == .showConversation {
if let statusID = UserActivityManager.getConversationStatus(from: activity) {
let conv = ConversationViewController(for: statusID, state: .unknown, mastodonController: mastodonController)
nav.pushViewController(conv, animated: false)
}
} else if type == .showProfile {
if let accountID = UserActivityManager.getProfile(from: activity) {
let profile = ProfileViewController(accountID: accountID, mastodonController: mastodonController)
nav.pushViewController(profile, animated: false)
}
} else if type == .bookmarks {
nav.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: false)
} else if let vc = nav.viewControllers.first as? StateRestorableViewController {
vc.restoreActivity(activity)
} else {
stateRestorationLogger.fault("MainTabBarViewController: Unable to restore activity, couldn't find StateRestorableViewController")
}
}
} }
extension MainTabBarViewController: TuskerRootViewController { extension MainTabBarViewController: TuskerRootViewController {
func select(route: TuskerRoute, animated: Bool) { @objc func presentCompose() {
switch route { compose()
case .timelines: }
select(tab: .timelines)
case .notifications: func select(tab: Tab) {
select(tab: .notifications) if tab == .compose {
case .myProfile: presentCompose()
select(tab: .myProfile) } else {
case .explore: // when switching tabs, dismiss the currently presented VC
select(tab: .explore) // otherwise the selected tab changes behind the presented VC
case .bookmarks: if presentedViewController != nil {
select(tab: .explore) dismiss(animated: true) {
getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated) self.selectedIndex = tab.rawValue
case .list(id: let id): }
select(tab: .explore) } else {
if let list = mastodonController.getCachedList(id: id) { stateRestorationLogger.info("MainTabBarViewController: selecting \(String(describing: tab), privacy: .public)")
let nav = getNavigationController() selectedIndex = tab.rawValue
_ = nav.popToRootViewController(animated: animated)
nav.pushViewController(ListTimelineViewController(for: list, mastodonController: mastodonController), animated: animated)
} }
} }
} }
func getNavigationDelegate() -> TuskerNavigationDelegate? {
return self
}
func getNavigationController() -> NavigationControllerProtocol {
return (selectedViewController as! UINavigationController)
}
func performSearch(query: String) { func performSearch(query: String) {
guard let exploreNavController = getTabController(tab: .explore) as? UINavigationController, guard let exploreNavController = getTabController(tab: .explore) as? UINavigationController,
let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else { let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else {

View File

@ -8,91 +8,10 @@
import UIKit import UIKit
@MainActor
protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController {
func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) func presentCompose()
func select(route: TuskerRoute, animated: Bool) func select(tab: MainTabBarViewController.Tab)
func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController?
func getNavigationDelegate() -> TuskerNavigationDelegate?
func getNavigationController() -> NavigationControllerProtocol
func performSearch(query: String) func performSearch(query: String)
func presentPreferences(completion: (() -> Void)?) func presentPreferences(completion: (() -> Void)?)
} }
//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
case myProfile
case explore
case bookmarks
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
//}
//
protocol NavigationControllerProtocol {
var topViewController: UIViewController? { get }
func popToRootViewController(animated: Bool) -> [UIViewController]?
func pushViewController(_ vc: UIViewController, animated: Bool)
}
extension UINavigationController: NavigationControllerProtocol {
}
extension SplitNavigationController: NavigationControllerProtocol {
var topViewController: UIViewController? {
viewControllers.last
}
}

View File

@ -79,7 +79,6 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
} }
} }
@MainActor
func userActivity(accountID: String) -> NSUserActivity { func userActivity(accountID: String) -> NSUserActivity {
switch self { switch self {
case .all: case .all:
@ -96,4 +95,10 @@ extension NotificationsPageViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
return currentPage.userActivity(accountID: mastodonController.accountInfo!.id) return currentPage.userActivity(accountID: mastodonController.accountInfo!.id)
} }
func restoreActivity(_ activity: NSUserActivity) {
if let mode = UserActivityManager.getNotificationsMode(from: activity) {
selectMode(mode)
}
}
} }

View File

@ -38,9 +38,7 @@ class MyProfileViewController: ProfileViewController {
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Preferences", style: .plain, target: self, action: #selector(preferencesPressed))
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
override func updateUserActivity() {
userActivity = UserActivityManager.myProfileActivity(accountID: mastodonController.accountInfo!.id) userActivity = UserActivityManager.myProfileActivity(accountID: mastodonController.accountInfo!.id)
} }

View File

@ -109,13 +109,6 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
} }
} }
func updateUserActivity() {
if let accountID,
let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
}
}
private func loadAccount() async { private func loadAccount() async {
guard let accountID else { guard let accountID else {
return return
@ -143,7 +136,10 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
} }
private func updateAccountUI(account: AccountMO) { private func updateAccountUI(account: AccountMO) {
updateUserActivity() if let currentAccountID = mastodonController.accountInfo?.id {
userActivity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID)
}
navigationItem.title = account.displayNameWithoutCustomEmoji navigationItem.title = account.displayNameWithoutCustomEmoji
} }
@ -299,6 +295,10 @@ class ProfileViewController: UIViewController, StateRestorableViewController {
return nil return nil
} }
} }
func restoreActivity(_ activity: NSUserActivity) {
fatalError("ProfileViewController must be reconstructed, not restored")
}
} }
extension ProfileViewController { extension ProfileViewController {

View File

@ -23,7 +23,7 @@ struct ReportView: View {
self.account = mastodonController.persistentContainer.account(for: report.accountID)! self.account = mastodonController.persistentContainer.account(for: report.accountID)!
self.mastodonController = mastodonController self.mastodonController = mastodonController
self._report = StateObject(wrappedValue: report) self._report = StateObject(wrappedValue: report)
if mastodonController.instance?.rules == nil { if mastodonController.instance.rules == nil {
report.reason = .spam report.reason = .spam
} }
} }
@ -77,7 +77,7 @@ struct ReportView: View {
} }
} }
if mastodonController.instance?.rules != nil { if mastodonController.instance.rules != nil {
NavigationLink { NavigationLink {
ReportSelectRulesView(mastodonController: mastodonController, report: report) ReportSelectRulesView(mastodonController: mastodonController, report: report)
} label: { } label: {

View File

@ -25,7 +25,7 @@ extension TimelineViewControllerDelegate {
func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {} func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {}
} }
class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController, StateRestorableViewController { class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController {
weak var delegate: TimelineViewControllerDelegate? weak var delegate: TimelineViewControllerDelegate?
let timeline: Timeline let timeline: Timeline
@ -43,7 +43,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var userActivityNeedsUpdate = PassthroughSubject<Void, Never>() private var contentOffsetObservation: NSKeyValueObservation?
private var activityToRestore: NSUserActivity?
// the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing // the last time this VC disappeared or the scene was backgrounded while it was active, used to decide if we want to check for present when reappearing
private var disappearedAt: Date? private var disappearedAt: Date?
@ -68,10 +69,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
self.navigationItem.title = timeline.title self.navigationItem.title = timeline.title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
if let accountID = mastodonController.accountInfo?.id {
self.userActivity = UserActivityManager.showTimelineActivity(timeline: timeline, accountID: accountID)
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -139,6 +136,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif #endif
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
if let indexPath = self?.dataSource.indexPath(for: .gap),
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
filterer.filtersChanged = { [unowned self] actionsChanged in filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged) self.reapplyFilters(actionsChanged: actionsChanged)
} }
@ -164,18 +168,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: nil)
if userActivity != nil {
userActivityNeedsUpdate
.debounce(for: .seconds(1), scheduler: DispatchQueue.main)
.sink { [unowned self] _ in
if let info = self.timelinePositionInfo(),
let userActivity = self.userActivity {
UserActivityManager.addTimelinePositionInfo(to: userActivity, statusIDs: info.statusIDs, centerStatusID: info.centerStatusID)
}
}
.store(in: &cancellables)
}
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
@ -299,13 +291,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return centerVisible return centerVisible
} }
private func timelinePositionInfo() -> (statusIDs: [String], centerStatusID: String)? { private func saveState() {
guard isViewLoaded else { guard isViewLoaded,
return nil persistsState,
let accountInfo = mastodonController.accountInfo else {
return
} }
let snapshot = dataSource.snapshot() let snapshot = dataSource.snapshot()
guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else { guard let centerVisible = currentCenterVisibleIndexPath(snapshot: snapshot) else {
return nil return
} }
let allItems = snapshot.itemIdentifiers(inSection: .statuses) let allItems = snapshot.itemIdentifiers(inSection: .statuses)
@ -345,15 +339,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} else { } else {
fatalError() fatalError()
} }
return (ids, centerVisibleID)
}
private func saveState() {
guard persistsState,
let accountInfo = mastodonController.accountInfo,
let (ids, centerVisibleID) = timelinePositionInfo() else {
return
}
switch Preferences.shared.timelineSyncMode { switch Preferences.shared.timelineSyncMode {
case .icloud: case .icloud:
stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)") stateRestorationLogger.debug("TimelineViewController: saving state to persistent store with with centerID \(centerVisibleID)")
@ -421,22 +406,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return loaded return loaded
} }
func restoreStateFromHandoff(statusIDs: [String], centerStatusID: String) async {
let crumb = Breadcrumb(level: .debug, category: "TimelineViewController")
crumb.message = "Restoring state from handoff activity"
SentrySDK.addBreadcrumb(crumb)
await controller.restoreInitial { @MainActor in
let position = TimelinePosition(context: mastodonController.persistentContainer.viewContext)
position.statusIDs = statusIDs
position.centerStatusID = centerStatusID
let hasStatusesToRestore = await loadStatusesToRestore(position: position)
if hasStatusesToRestore {
applyItemsToRestore(position: position)
}
mastodonController.persistentContainer.viewContext.delete(position)
}
}
@MainActor @MainActor
private func loadStatusesToRestore(position: TimelinePosition) async -> Bool { private func loadStatusesToRestore(position: TimelinePosition) async -> Bool {
let originalPositionStatusIDs = position.statusIDs let originalPositionStatusIDs = position.statusIDs
@ -1321,23 +1290,6 @@ extension TimelineViewController: UICollectionViewDelegate {
removeTimelineDescriptionCell() removeTimelineDescriptionCell()
} }
} }
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate {
userActivityNeedsUpdate.send()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
userActivityNeedsUpdate.send()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if let indexPath = dataSource.indexPath(for: .gap),
let cell = collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
} }
extension TimelineViewController: UICollectionViewDragDelegate { extension TimelineViewController: UICollectionViewDragDelegate {

View File

@ -211,4 +211,16 @@ extension TimelinesPageViewController: StateRestorableViewController {
func stateRestorationActivity() -> NSUserActivity? { func stateRestorationActivity() -> NSUserActivity? {
return (currentViewController as? TimelineViewController)?.stateRestorationActivity() return (currentViewController as? TimelineViewController)?.stateRestorationActivity()
} }
func restoreActivity(_ activity: NSUserActivity) {
guard let timeline = UserActivityManager.getTimeline(from: activity),
let pinned = PinnedTimeline(timeline: timeline) else {
return
}
let page = Page(mastodonController: mastodonController, timeline: pinned)
// the pinned timelines may have changed after an iCloud sync, in which case don't restore anything
if pages.contains(page) {
selectPage(page, animated: false)
}
}
} }

View File

@ -73,7 +73,7 @@ extension MenuActionProvider {
actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in
var listActions = mastodonController.lists.map { list in var listActions = mastodonController.lists.map { list in
UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in
let req = List.add(list.id, accounts: [accountID]) let req = List.add(list, accounts: [accountID])
mastodonController.run(req) { response in mastodonController.run(req) { response in
if case .failure(let error) = response { if case .failure(let error) = response {
self.handleError(error, title: "Error Adding to List") self.handleError(error, title: "Error Adding to List")
@ -86,7 +86,7 @@ extension MenuActionProvider {
let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in
self.navigationDelegate!.present($0, animated: true) self.navigationDelegate!.present($0, animated: true)
}) { list in }) { list in
let req = List.add(list.id, accounts: [accountID]) let req = List.add(list, accounts: [accountID])
let response = await mastodonController.runResponse(req) let response = await mastodonController.runResponse(req)
if case .failure(let error) = response { if case .failure(let error) = response {
self.handleError(error, title: "Error Adding to List") self.handleError(error, title: "Error Adding to List")

View File

@ -143,17 +143,6 @@ class SplitNavigationController: UIViewController {
} }
} }
func pushViewController(_ vc: UIViewController, animated: Bool) {
if !canShowSecondaryNav {
rootNav.pushViewController(vc, animated: animated)
} else if rootNav.viewControllers.isEmpty {
rootNav.pushViewController(vc, animated: false)
} else {
secondaryNav.pushViewController(vc, animated: animated)
}
updateSecondaryNavVisibility()
}
private func updateSecondaryNavVisibility() { private func updateSecondaryNavVisibility() {
guard isViewLoaded else { guard isViewLoaded else {
return return
@ -230,9 +219,7 @@ class SplitNavigationController: UIViewController {
private var isLayingOutForAnimation = false private var isLayingOutForAnimation = false
@discardableResult func popToRootViewController(animated: Bool) {
func popToRootViewController(animated: Bool) -> [UIViewController]? {
let vcs = secondaryNav.viewControllers
if animated { if animated {
// we don't update secondaryNav.viewControllers until after the animation is completed // we don't update secondaryNav.viewControllers until after the animation is completed
// otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen // otherwise the secondary nav's contents disappear immediately, rather than sliding off-screen
@ -251,7 +238,6 @@ class SplitNavigationController: UIViewController {
self.secondaryNav.viewControllers = [] self.secondaryNav.viewControllers = []
self.updateSecondaryNavVisibility() self.updateSecondaryNavVisibility()
} }
return vcs
} }
} }

View File

@ -10,4 +10,6 @@ import UIKit
protocol StateRestorableViewController: UIViewController { protocol StateRestorableViewController: UIViewController {
func stateRestorationActivity() -> NSUserActivity? func stateRestorationActivity() -> NSUserActivity?
func restoreActivity(_ activity: NSUserActivity)
} }

View File

@ -35,20 +35,20 @@ enum AppShortcutItem: String, CaseIterable {
} }
} }
@MainActor
func handle() { func handle() {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first! let tab: MainTabBarViewController.Tab
let window = scene.windows.first { $0.isKeyWindow }!
guard let root = window.rootViewController as? TuskerRootViewController else {
return
}
switch self { switch self {
case .showHomeTimeline: case .showHomeTimeline:
root.select(route: .timelines, animated: false) tab = .timelines
case .showNotifications: case .showNotifications:
root.select(route: .notifications, animated: false) tab = .notifications
case .composePost: case .composePost:
root.compose(editing: nil, animated: false, isDucked: false) tab = .compose
}
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = scene.windows.first { $0.isKeyWindow }!
if let controller = window.rootViewController as? TuskerRootViewController {
controller.select(tab: tab)
} }
} }
} }
@ -60,7 +60,6 @@ extension AppShortcutItem {
} }
} }
@MainActor
static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool { static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool {
guard let type = AppShortcutItem(rawValue: shortcutItem.type) else { return false } guard let type = AppShortcutItem(rawValue: shortcutItem.type) else { return false }
type.handle() type.handle()

View File

@ -41,7 +41,6 @@ extension NSUserActivity {
] ]
} }
@MainActor
func handleResume(manager: UserActivityManager) -> Bool { func handleResume(manager: UserActivityManager) -> Bool {
guard let type = UserActivityType(rawValue: activityType) else { return false } guard let type = UserActivityType(rawValue: activityType) else { return false }
type.handle(manager)(self) type.handle(manager)(self)

View File

@ -1,119 +0,0 @@
//
// UserActivityHandlingContext.swift
// Tusker
//
// Created by Shadowfacts on 2/25/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Duckable
@MainActor
protocol UserActivityHandlingContext {
var isHandoff: Bool { get }
func select(route: TuskerRoute)
func present(_ vc: UIViewController)
var topViewController: UIViewController? { get }
func popToRoot()
func push(_ vc: UIViewController)
func compose(editing draft: Draft)
func finalize(activity: NSUserActivity)
}
struct ActiveAccountUserActivityHandlingContext: UserActivityHandlingContext {
let isHandoff: Bool
let root: TuskerRootViewController
var navigationDelegate: TuskerNavigationDelegate {
root.getNavigationDelegate()!
}
func select(route: TuskerRoute) {
root.select(route: route, animated: true)
}
func present(_ vc: UIViewController) {
navigationDelegate.present(vc, animated: true)
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }
func popToRoot() {
_ = root.getNavigationController().popToRootViewController(animated: true)
}
func push(_ vc: UIViewController) {
navigationDelegate.show(vc, sender: nil)
}
func compose(editing draft: Draft) {
navigationDelegate.compose(editing: draft, animated: true, isDucked: true)
}
func finalize(activity: NSUserActivity) {
}
}
class StateRestorationUserActivityHandlingContext: UserActivityHandlingContext {
private var state = State.initial
let root: TuskerRootViewController
init(root: TuskerRootViewController) {
self.root = root
}
var isHandoff: Bool { false }
func select(route: TuskerRoute) {
root.select(route: route, animated: false)
state = .selectedRoute
}
var topViewController: UIViewController? { root.getNavigationController().topViewController }
func popToRoot() {
// unnecessary during state restoration
}
func push(_ vc: UIViewController) {
precondition(state >= .selectedRoute)
root.getNavigationController().pushViewController(vc, animated: false)
state = .pushed
}
func present(_ vc: UIViewController) {
root.present(vc, animated: false)
state = .presented
}
func compose(editing draft: Draft) {
if #available(iOS 16.0, *),
UIDevice.current.userInterfaceIdiom == .phone {
self.root.compose(editing: draft, animated: false, isDucked: true)
} else {
DispatchQueue.main.async {
self.root.compose(editing: draft, animated: true, isDucked: false)
}
}
state = .presented
}
func finalize(activity: NSUserActivity) {
precondition(state > .initial)
if #available(iOS 16.0, *),
let duckedDraft = UserActivityManager.getDuckedDraft(from: activity) {
self.root.compose(editing: duckedDraft, animated: false, isDucked: true)
}
}
enum State: Comparable {
case initial
case selectedRoute
case pushed
case presented
}
}

View File

@ -13,15 +13,12 @@ import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager")
@MainActor
class UserActivityManager { class UserActivityManager {
private let scene: UIWindowScene private let scene: UIWindowScene
private let context: any UserActivityHandlingContext
init(scene: UIWindowScene, context: any UserActivityHandlingContext) { init(scene: UIWindowScene) {
self.scene = scene self.scene = scene
self.context = context
} }
// MARK: - Utils // MARK: - Utils
@ -76,13 +73,12 @@ class UserActivityManager {
} }
func handleNewPost(activity: NSUserActivity) { func handleNewPost(activity: NSUserActivity) {
if let draft = Self.getDraft(from: activity) { // TODO: check not currently showing compose screen
context.compose(editing: draft) let mentioning = activity.userInfo?["mentioning"] as? String
} else { let draft = mastodonController.createDraft(mentioningAcct: mentioning)
let mentioning = activity.userInfo?["mentioning"] as? String // todo: this shouldn't use self.mastodonController, it should get the right one based on the userInfo accountID
let draft = mastodonController.createDraft(mentioningAcct: mentioning) let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController)
context.compose(editing: draft) present(UINavigationController(rootViewController: composeVC))
}
} }
static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity { static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity {
@ -104,16 +100,25 @@ class UserActivityManager {
} }
} }
static func getDraft(from activity: NSUserActivity) -> Draft? { static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity {
guard let idStr = activity.userInfo?["draftID"] as? String, if let activity {
let uuid = UUID(uuidString: idStr) else { activity.addUserInfoEntries(from: [
return nil "editedDraftID": draft.id.uuidString
])
return activity
} else {
return editDraftActivity(id: draft.id, accountID: draft.accountID)
} }
return DraftsManager.shared.getBy(id: uuid)
} }
static func getDuckedDraft(from activity: NSUserActivity) -> Draft? { static func getDraft(from activity: NSUserActivity) -> Draft? {
guard let idStr = activity.userInfo?["duckedDraftID"] as? String, let idStr: String?
if activity.activityType == UserActivityType.newPost.rawValue {
idStr = activity.userInfo?["draftID"] as? String
} else {
idStr = activity.userInfo?["duckedDraftID"] as? String ?? activity.userInfo?["editedDraftID"] as? String
}
guard let idStr,
let uuid = UUID(uuidString: idStr) else { let uuid = UUID(uuidString: idStr) else {
return nil return nil
} }
@ -124,7 +129,6 @@ class UserActivityManager {
static func checkNotificationsActivity(mode: NotificationsMode = .allNotifications, accountID: String) -> NSUserActivity { static func checkNotificationsActivity(mode: NotificationsMode = .allNotifications, accountID: String) -> NSUserActivity {
let activity = NSUserActivity(type: .checkNotifications, accountID: accountID) let activity = NSUserActivity(type: .checkNotifications, accountID: accountID)
activity.isEligibleForPrediction = true activity.isEligibleForPrediction = true
activity.isEligibleForHandoff = true
activity.addUserInfoEntries(from: [ activity.addUserInfoEntries(from: [
"notificationsMode": mode.rawValue "notificationsMode": mode.rawValue
]) ])
@ -140,9 +144,11 @@ class UserActivityManager {
} }
func handleCheckNotifications(activity: NSUserActivity) { func handleCheckNotifications(activity: NSUserActivity) {
context.select(route: .notifications) let mainViewController = getMainViewController()
context.popToRoot() mainViewController.select(tab: .notifications)
if let notificationsPageController = context.topViewController as? NotificationsPageViewController { if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController,
let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController {
navigationController.popToRootViewController(animated: false)
notificationsPageController.loadViewIfNeeded() notificationsPageController.loadViewIfNeeded()
notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode) notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode)
} }
@ -161,7 +167,6 @@ class UserActivityManager {
let activity = NSUserActivity(type: .showTimeline, accountID: accountID) let activity = NSUserActivity(type: .showTimeline, accountID: accountID)
activity.isEligibleForPrediction = true activity.isEligibleForPrediction = true
activity.isEligibleForHandoff = true
activity.addUserInfoEntries(from: [ activity.addUserInfoEntries(from: [
"timelineData": timelineData, "timelineData": timelineData,
]) ])
@ -189,53 +194,31 @@ class UserActivityManager {
return activity return activity
} }
static func addTimelinePositionInfo(to activity: NSUserActivity, statusIDs: [String], centerStatusID: String) { static func getTimeline(from activity: NSUserActivity) -> Timeline? {
activity.addUserInfoEntries(from: [
"statusIDs": statusIDs,
"centerStatusID": centerStatusID
])
}
static func getTimeline(from activity: NSUserActivity) -> (Timeline, (statusIDs: [String], centerStatusID: String)?)? {
guard activity.activityType == UserActivityType.showTimeline.rawValue, guard activity.activityType == UserActivityType.showTimeline.rawValue,
let data = activity.userInfo?["timelineData"] as? Data, let data = activity.userInfo?["timelineData"] as? Data else {
let timeline = try? UserActivityManager.decoder.decode(Timeline.self, from: data) else {
return nil return nil
} }
var positionInfo: ([String], String)? return try? UserActivityManager.decoder.decode(Timeline.self, from: data)
if let ids = activity.userInfo?["statusIDs"] as? [String],
let center = activity.userInfo?["centerStatusID"] as? String {
positionInfo = (ids, center)
}
return (timeline, positionInfo)
} }
func handleShowTimeline(activity: NSUserActivity) { func handleShowTimeline(activity: NSUserActivity) {
guard let (timeline, positionInfo) = Self.getTimeline(from: activity) else { return } guard let timeline = Self.getTimeline(from: activity) else { return }
let timelineVC: TimelineViewController let mainViewController = getMainViewController()
if let pinned = PinnedTimeline(timeline: timeline), mainViewController.select(tab: .timelines)
mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { guard let navigationController = mainViewController.getTabController(tab: .timelines) as? UINavigationController else {
context.select(route: .timelines) return
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))
timelineVC = context.topViewController! as! TimelineViewController
} else {
context.select(route: .explore)
context.popToRoot()
timelineVC = TimelineViewController(for: timeline, mastodonController: mastodonController)
context.push(timelineVC)
} }
if let positionInfo, if let pinned = PinnedTimeline(timeline: timeline),
context.isHandoff { mastodonController.accountPreferences.pinnedTimelines.contains(pinned) {
Task { navigationController.popToRootViewController(animated: false)
await timelineVC.restoreStateFromHandoff(statusIDs: positionInfo.statusIDs, centerStatusID: positionInfo.centerStatusID) let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController
} rootController.selectTimeline(pinned, animated: false)
} else {
let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController)
navigationController.pushViewController(timeline, animated: false)
} }
} }
@ -246,7 +229,6 @@ class UserActivityManager {
"mainStatusID": mainStatusID, "mainStatusID": mainStatusID,
]) ])
activity.isEligibleForPrediction = isEligibleForPrediction activity.isEligibleForPrediction = isEligibleForPrediction
activity.isEligibleForHandoff = true
return activity return activity
} }
@ -254,14 +236,6 @@ class UserActivityManager {
return activity.userInfo?["mainStatusID"] as? String return activity.userInfo?["mainStatusID"] as? String
} }
func handleShowConversation(activity: NSUserActivity) {
guard let mainStatusID = Self.getConversationStatus(from: activity) else {
return
}
context.select(route: .timelines)
context.push(ConversationViewController(for: mainStatusID, state: .unknown, mastodonController: mastodonController))
}
// MARK: - Explore // MARK: - Explore
static func searchActivity(query: String?, accountID: String) -> NSUserActivity { static func searchActivity(query: String?, accountID: String) -> NSUserActivity {
@ -280,31 +254,19 @@ class UserActivityManager {
} }
func handleSearch(activity: NSUserActivity) { func handleSearch(activity: NSUserActivity) {
context.select(route: .explore) let mainViewController = getMainViewController()
context.popToRoot() mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController,
let searchController: UISearchController let exploreController = navigationController.viewControllers.first as? ExploreViewController {
let resultsController: SearchResultsViewController navigationController.popToRootViewController(animated: false)
if let explore = context.topViewController as? ExploreViewController { exploreController.loadViewIfNeeded()
explore.loadViewIfNeeded() exploreController.searchController.isActive = true
explore.searchControllerStatusOnAppearance = true if let query = Self.getSearchQuery(from: activity),
searchController = explore.searchController !query.isEmpty {
resultsController = explore.resultsController exploreController.searchController.searchBar.text = query
} else if let inlineTrends = context.topViewController as? InlineTrendsViewController { } else {
inlineTrends.loadViewIfNeeded() exploreController.searchController.searchBar.becomeFirstResponder()
inlineTrends.searchControllerStatusOnAppearance = true }
searchController = inlineTrends.searchController
resultsController = inlineTrends.resultsController
} else {
return
}
if let query = Self.getSearchQuery(from: activity),
!query.isEmpty {
searchController.searchBar.text = query
resultsController.performSearch(query: query)
} else {
searchController.searchBar.becomeFirstResponder()
} }
} }
@ -317,21 +279,26 @@ class UserActivityManager {
} }
func handleBookmarks(activity: NSUserActivity) { func handleBookmarks(activity: NSUserActivity) {
context.select(route: .bookmarks) let mainViewController = getMainViewController()
mainViewController.select(tab: .explore)
if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController {
navigationController.popToRootViewController(animated: false)
navigationController.pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: false)
}
} }
// MARK: - My Profile // MARK: - My Profile
static func myProfileActivity(accountID: String) -> NSUserActivity { static func myProfileActivity(accountID: String) -> NSUserActivity {
let activity = NSUserActivity(type: .myProfile, accountID: accountID) let activity = NSUserActivity(type: .myProfile, accountID: accountID)
activity.isEligibleForPrediction = true activity.isEligibleForPrediction = true
activity.isEligibleForHandoff = true
activity.title = NSLocalizedString("My Profile", comment: "my profile shortcut title") activity.title = NSLocalizedString("My Profile", comment: "my profile shortcut title")
activity.suggestedInvocationPhrase = NSLocalizedString("Show my Mastodon profile", comment: "my profile shortuct invocation phrase") activity.suggestedInvocationPhrase = NSLocalizedString("Show my Mastodon profile", comment: "my profile shortuct invocation phrase")
return activity return activity
} }
func handleMyProfile(activity: NSUserActivity) { func handleMyProfile(activity: NSUserActivity) {
context.select(route: .myProfile) let mainViewController = getMainViewController()
mainViewController.select(tab: .myProfile)
} }
// MARK: - Show Profile // MARK: - Show Profile
@ -341,7 +308,6 @@ class UserActivityManager {
"profileID": profileID, "profileID": profileID,
]) ])
activity.isEligibleForPrediction = true activity.isEligibleForPrediction = true
activity.isEligibleForHandoff = true
return activity return activity
} }
@ -349,12 +315,4 @@ class UserActivityManager {
return activity.userInfo?["profileID"] as? String return activity.userInfo?["profileID"] as? String
} }
func handleShowProfile(activity: NSUserActivity) {
guard let accountID = Self.getProfile(from: activity) else {
return
}
context.select(route: .timelines)
context.push(ProfileViewController(accountID: accountID, mastodonController: mastodonController))
}
} }

View File

@ -21,7 +21,7 @@ enum UserActivityType: String {
} }
extension UserActivityType { extension UserActivityType {
var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void { var handle: (UserActivityManager) -> (NSUserActivity) -> Void {
switch self { switch self {
case .mainScene: case .mainScene:
fatalError("cannot handle main scene activity") fatalError("cannot handle main scene activity")
@ -36,11 +36,11 @@ extension UserActivityType {
case .bookmarks: case .bookmarks:
return UserActivityManager.handleBookmarks return UserActivityManager.handleBookmarks
case .showConversation: case .showConversation:
return UserActivityManager.handleShowConversation fatalError("cannot handle show conversation activity")
case .myProfile: case .myProfile:
return UserActivityManager.handleMyProfile return UserActivityManager.handleMyProfile
case .showProfile: case .showProfile:
return UserActivityManager.handleShowProfile fatalError("cannot handle show profile activity")
} }
} }
} }

View File

@ -89,8 +89,7 @@ extension TuskerNavigationDelegate {
show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self) show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self)
} }
func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) { func compose(editing draft: Draft, animated: Bool = true) {
let draft = draft ?? apiController.createDraft()
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id)
let options = UIWindowScene.ActivationRequestOptions() let options = UIWindowScene.ActivationRequestOptions()
@ -99,7 +98,7 @@ extension TuskerNavigationDelegate {
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)
if #available(iOS 16.0, *), if #available(iOS 16.0, *),
presentDuckable(compose, animated: animated, isDucked: isDucked) { presentDuckable(compose, animated: animated) {
return return
} else { } else {
let compose = ComposeHostingController(draft: draft, mastodonController: apiController) let compose = ComposeHostingController(draft: draft, mastodonController: apiController)

View File

@ -1,158 +0,0 @@
//
// ProfileHeaderMovedOverlayView.swift
// Tusker
//
// Created by Shadowfacts on 2/23/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
class ProfileHeaderMovedOverlayView: UIView {
private var movedToID: String!
weak var delegate: TuskerNavigationDelegate?
var collapse: (() -> Void)?
private var avatarImageView: CachedImageView!
private var displayNameLabel: EmojiLabel!
private var usernameLabel: UILabel!
private(set) var collapseButton: UIButton!
init() {
super.init(frame: .zero)
let blur = UIBlurEffect(style: .systemUltraThinMaterial)
let blurView = UIVisualEffectView(effect: blur)
blurView.translatesAutoresizingMaskIntoConstraints = false
addSubview(blurView)
let vibrancy = UIVibrancyEffect(blurEffect: blur, style: .label)
let vibrancyView = UIVisualEffectView(effect: vibrancy)
vibrancyView.translatesAutoresizingMaskIntoConstraints = false
blurView.contentView.addSubview(vibrancyView)
let label = UILabel()
label.text = "This account has moved to"
label.font = .preferredFont(forTextStyle: .title3).withTraits(.traitBold)
label.adjustsFontForContentSizeCategory = true
label.textColor = .label
avatarImageView = CachedImageView(cache: .avatars)
avatarImageView.layer.masksToBounds = true
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
avatarImageView.addInteraction(UIPointerInteraction(delegate: self))
avatarImageView.isUserInteractionEnabled = true
displayNameLabel = EmojiLabel()
displayNameLabel.adjustsFontForContentSizeCategory = true
displayNameLabel.font = UIFont(descriptor: .preferredFontDescriptor(withTextStyle: .body).addingAttributes([
.traits: [
UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold.rawValue,
]
]), size: 0)
usernameLabel = UILabel()
usernameLabel.adjustsFontForContentSizeCategory = true
usernameLabel.font = .preferredFont(forTextStyle: .body)
usernameLabel.textColor = .secondaryLabel
let nameVStack = UIStackView(arrangedSubviews: [
displayNameLabel,
usernameLabel,
])
nameVStack.axis = .vertical
nameVStack.alignment = .leading
nameVStack.spacing = 4
let accountHStack = UIStackView(arrangedSubviews: [
avatarImageView,
nameVStack,
])
accountHStack.axis = .horizontal
accountHStack.alignment = .top
accountHStack.spacing = 4
accountHStack.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(accountTapped)))
let stack = UIStackView(arrangedSubviews: [
label,
accountHStack,
])
stack.axis = .vertical
stack.alignment = .center
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
vibrancyView.contentView.addSubview(stack)
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "chevron.up")
collapseButton = UIButton(configuration: config, primaryAction: UIAction(handler: { [unowned self] _ in
self.collapse?()
}))
collapseButton.accessibilityLabel = "Shrink banner"
collapseButton.isPointerInteractionEnabled = true
collapseButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(collapseButton)
NSLayoutConstraint.activate([
blurView.leadingAnchor.constraint(equalTo: leadingAnchor),
blurView.trailingAnchor.constraint(equalTo: trailingAnchor),
blurView.topAnchor.constraint(equalTo: topAnchor),
blurView.bottomAnchor.constraint(equalTo: bottomAnchor),
vibrancyView.leadingAnchor.constraint(equalTo: blurView.contentView.leadingAnchor),
vibrancyView.trailingAnchor.constraint(equalTo: blurView.contentView.trailingAnchor),
vibrancyView.topAnchor.constraint(equalTo: blurView.contentView.topAnchor),
vibrancyView.bottomAnchor.constraint(equalTo: blurView.contentView.bottomAnchor),
stack.centerXAnchor.constraint(equalTo: vibrancyView.contentView.readableContentGuide.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: vibrancyView.contentView.centerYAnchor),
stack.leadingAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.readableContentGuide.leadingAnchor),
stack.trailingAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.readableContentGuide.trailingAnchor),
stack.topAnchor.constraint(greaterThanOrEqualTo: vibrancyView.contentView.topAnchor),
stack.bottomAnchor.constraint(lessThanOrEqualTo: vibrancyView.contentView.bottomAnchor),
avatarImageView.widthAnchor.constraint(equalToConstant: 50),
avatarImageView.heightAnchor.constraint(equalToConstant: 50),
bottomAnchor.constraint(equalToSystemSpacingBelow: collapseButton.bottomAnchor, multiplier: 1),
collapseButton.centerXAnchor.constraint(equalTo: centerXAnchor),
])
NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func preferencesChanged() {
avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 50
}
func updateUI(movedTo: AccountMO) {
movedToID = movedTo.id
avatarImageView.update(for: movedTo.avatar)
displayNameLabel.text = movedTo.displayOrUserName
displayNameLabel.setEmojis(movedTo.emojis, identifier: movedTo.id)
usernameLabel.text = "@\(movedTo.acct)"
}
@objc private func accountTapped() {
delegate?.selected(account: movedToID)
}
}
extension ProfileHeaderMovedOverlayView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, regionFor request: UIPointerRegionRequest, defaultRegion: UIPointerRegion) -> UIPointerRegion? {
return defaultRegion
}
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: interaction.view!)
return UIPointerStyle(effect: .lift(preview))
}
}

View File

@ -37,7 +37,6 @@ class ProfileHeaderView: UIView {
@IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var followCountButton: UIButton! @IBOutlet weak var followCountButton: UIButton!
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>! private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
private var movedOverlayView: ProfileHeaderMovedOverlayView?
var accountID: String! var accountID: String!
@ -171,64 +170,15 @@ class ProfileHeaderView: UIView {
followCountButton.setAttributedTitle(followCountTitle, for: .normal) followCountButton.setAttributedTitle(followCountTitle, for: .normal)
followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers" followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers"
if let movedTo = account.movedTo { accessibilityElements = [
if let movedOverlayView { displayNameLabel!,
movedOverlayView.updateUI(movedTo: movedTo) usernameLabel!,
} else { relationshipLabel!,
let overlay = createMovedOverlayView(movedTo: movedTo) noteTextView!,
movedOverlayView = overlay fieldsView!,
moreButton!,
accessibilityElements = [ pagesSegmentedControl!,
overlay, ]
]
}
} else {
movedOverlayView?.removeFromSuperview()
movedOverlayView = nil
accessibilityElements = [
displayNameLabel!,
usernameLabel!,
relationshipLabel!,
noteTextView!,
fieldsView!,
moreButton!,
pagesSegmentedControl!,
]
}
}
private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView {
let overlay = ProfileHeaderMovedOverlayView()
overlay.delegate = delegate
overlay.updateUI(movedTo: movedTo)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
let bottomConstraint = overlay.bottomAnchor.constraint(equalTo: bottomAnchor)
NSLayoutConstraint.activate([
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
overlay.topAnchor.constraint(equalTo: topAnchor),
bottomConstraint,
])
overlay.collapse = { [weak self, weak overlay] in
guard let self, let overlay else { return }
bottomConstraint.isActive = false
overlay.bottomAnchor.constraint(equalTo: self.avatarContainerView.topAnchor, constant: -2).isActive = true
let animator = UIViewPropertyAnimator(duration: 0.35, dampingRatio: 0.8)
animator.addAnimations {
self.layoutIfNeeded()
overlay.collapseButton.layer.opacity = 0
}
animator.addCompletion { _ in
overlay.collapseButton.layer.opacity = 1
overlay.collapseButton?.removeFromSuperview()
}
animator.startAnimation()
}
return overlay
} }
private func updateRelationship() { private func updateRelationship() {

View File

@ -23,16 +23,15 @@ class StatusCardView: UIView {
private let activeBackgroundColor = UIColor.secondarySystemFill private let activeBackgroundColor = UIColor.secondarySystemFill
private let inactiveBackgroundColor = UIColor.secondarySystemBackground private let inactiveBackgroundColor = UIColor.secondarySystemBackground
private var imageRequest: ImageCache.Request?
private var isGrayscale = false private var isGrayscale = false
private var hStack: UIStackView! private var hStack: UIStackView!
private var titleLabel: UILabel! private var titleLabel: UILabel!
private var descriptionLabel: UILabel! private var descriptionLabel: UILabel!
private var domainLabel: UILabel! private var domainLabel: UILabel!
private var imageView: CachedImageView! private var imageView: UIImageView!
private var placeholderImageView: UIImageView! private var placeholderImageView: UIImageView!
private var leadingSpacer: UIView!
private var trailingSpacer: UIView!
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@ -77,22 +76,20 @@ class StatusCardView: UIView {
]) ])
vStack.axis = .vertical vStack.axis = .vertical
vStack.alignment = .leading vStack.alignment = .leading
vStack.distribution = .fill
vStack.spacing = 0 vStack.spacing = 0
imageView = CachedImageView(cache: .attachments) imageView = UIImageView()
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
leadingSpacer = UIView() let spacer = UIView()
leadingSpacer.backgroundColor = .clear spacer.backgroundColor = .clear
trailingSpacer = UIView()
trailingSpacer.backgroundColor = .clear
hStack = UIStackView(arrangedSubviews: [ hStack = UIStackView(arrangedSubviews: [
leadingSpacer,
imageView, imageView,
vStack, vStack,
trailingSpacer, spacer,
]) ])
hStack.translatesAutoresizingMaskIntoConstraints = false hStack.translatesAutoresizingMaskIntoConstraints = false
hStack.axis = .horizontal hStack.axis = .horizontal
@ -110,7 +107,6 @@ class StatusCardView: UIView {
placeholderImageView.translatesAutoresizingMaskIntoConstraints = false placeholderImageView.translatesAutoresizingMaskIntoConstraints = false
placeholderImageView.contentMode = .scaleAspectFit placeholderImageView.contentMode = .scaleAspectFit
placeholderImageView.tintColor = .gray placeholderImageView.tintColor = .gray
placeholderImageView.isHidden = true
addSubview(placeholderImageView) addSubview(placeholderImageView)
@ -118,10 +114,9 @@ class StatusCardView: UIView {
imageView.heightAnchor.constraint(equalTo: heightAnchor), imageView.heightAnchor.constraint(equalTo: heightAnchor),
imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor), imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor),
vStack.heightAnchor.constraint(lessThanOrEqualTo: heightAnchor, constant: -8), vStack.heightAnchor.constraint(equalTo: heightAnchor, constant: -8),
leadingSpacer.widthAnchor.constraint(equalToConstant: 4), spacer.widthAnchor.constraint(equalToConstant: 4),
trailingSpacer.widthAnchor.constraint(equalToConstant: 4),
hStack.leadingAnchor.constraint(equalTo: leadingAnchor), hStack.leadingAnchor.constraint(equalTo: leadingAnchor),
hStack.trailingAnchor.constraint(equalTo: trailingAnchor), hStack.trailingAnchor.constraint(equalTo: trailingAnchor),
@ -133,6 +128,8 @@ class StatusCardView: UIView {
placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor),
placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor),
]) ])
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@ -164,15 +161,10 @@ class StatusCardView: UIView {
return return
} }
if let image = card.image { self.imageView.image = nil
imageView.update(for: URL(image), blurhash: card.blurhash)
imageView.isHidden = false updateGrayscaleableUI(card: card)
leadingSpacer.isHidden = true updateUIForPreferences()
} else {
imageView.update(for: nil)
imageView.isHidden = true
leadingSpacer.isHidden = false
}
let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines)
titleLabel.text = title titleLabel.text = title
@ -190,6 +182,37 @@ class StatusCardView: UIView {
} }
} }
@objc private func updateUIForPreferences() {
if isGrayscale != Preferences.shared.grayscaleImages,
let card = card {
updateGrayscaleableUI(card: card)
}
}
private func updateGrayscaleableUI(card: Card) {
isGrayscale = Preferences.shared.grayscaleImages
if let imageURL = card.image {
placeholderImageView.isHidden = true
imageRequest = ImageCache.attachments.get(URL(imageURL)!, completion: { (_, image) in
guard let image = image,
self.card?.image == imageURL,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(imageURL)!, image: image) else {
return
}
DispatchQueue.main.async {
self.imageView.image = transformedImage
}
})
if imageRequest != nil {
loadBlurHash()
}
} else {
placeholderImageView.isHidden = false
}
}
private func loadBlurHash() { private func loadBlurHash() {
guard let card = card, let hash = card.blurhash else { return } guard let card = card, let hash = card.blurhash else { return }