From 6ca5bb0c744855c33529d0bebae3cbc6d7cdd5bf Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 25 Feb 2023 13:55:46 -0500 Subject: [PATCH] Unify state restoration with user activity handling code --- .../Sources/Pachyderm/Model/List.swift | 27 ++-- .../Model/Protocols/ListProtocol.swift | 13 ++ Tusker.xcodeproj/project.pbxproj | 4 + Tusker/API/DeleteListService.swift | 2 +- Tusker/API/MastodonController.swift | 19 +++ Tusker/API/RenameListService.swift | 6 +- Tusker/CoreData/ListMO.swift | 2 +- Tusker/MenuController.swift | 2 +- Tusker/Scenes/MainSceneDelegate.swift | 20 ++- .../ConversationViewController.swift | 4 - .../Explore/ExploreViewController.swift | 18 --- .../EditListAccountsViewController.swift | 6 +- .../BookmarksViewController.swift | 3 - ...ountSwitchingContainerViewController.swift | 23 +-- Tusker/Screens/Main/Duckable+Root.swift | 22 ++- .../Main/MainSidebarViewController.swift | 8 +- .../Main/MainSplitViewController.swift | 124 ++++++--------- .../Main/MainTabBarViewController.swift | 143 ++++++++---------- .../Main/TuskerRootViewController.swift | 85 ++++++++++- .../NotificationsPageViewController.swift | 7 +- .../Profile/ProfileViewController.swift | 4 - .../Timeline/TimelineViewController.swift | 2 +- .../TimelinesPageViewController.swift | 12 -- Tusker/Screens/Utilities/Previewing.swift | 4 +- .../Utilities/SplitNavigationController.swift | 16 +- .../StateRestorableViewController.swift | 2 - Tusker/Shortcuts/AppShortcutItems.swift | 23 +-- .../Shortcuts/NSUserActivity+Extensions.swift | 1 + .../UserActivityHandlingContext.swift | 114 ++++++++++++++ Tusker/Shortcuts/UserActivityManager.swift | 131 +++++++++------- Tusker/Shortcuts/UserActivityType.swift | 6 +- Tusker/TuskerNavigationDelegate.swift | 5 +- 32 files changed, 527 insertions(+), 331 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift create mode 100644 Tusker/Shortcuts/UserActivityHandlingContext.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift index 6ff289e5..491035ab 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/List.swift @@ -8,7 +8,7 @@ import Foundation -public struct List: Decodable, Equatable, Hashable, Sendable { +public struct List: ListProtocol, Decodable, Equatable, Hashable, Sendable { public let id: String public let title: String @@ -16,6 +16,11 @@ public struct List: Decodable, Equatable, Hashable, Sendable { return .list(id: id) } + public init(id: String, title: String) { + self.id = id + self.title = title + } + public static func ==(lhs: List, rhs: List) -> Bool { return lhs.id == rhs.id && lhs.title == rhs.title } @@ -25,28 +30,28 @@ public struct List: Decodable, Equatable, Hashable, Sendable { hasher.combine(title) } - public static func getAccounts(_ list: List, range: RequestRange = .default) -> Request<[Account]> { - var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(list.id)/accounts") + public static func getAccounts(_ listID: String, range: RequestRange = .default) -> Request<[Account]> { + var request = Request<[Account]>(method: .get, path: "/api/v1/lists/\(listID)/accounts") request.range = range return request } - public static func update(_ list: List, title: String) -> Request { - return Request(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title])) + public static func update(_ listID: String, title: String) -> Request { + return Request(method: .put, path: "/api/v1/lists/\(listID)", body: ParametersBody(["title" => title])) } - public static func delete(_ list: List) -> Request { - return Request(method: .delete, path: "/api/v1/lists/\(list.id)") + public static func delete(_ listID: String) -> Request { + return Request(method: .delete, path: "/api/v1/lists/\(listID)") } - public static func add(_ list: List, accounts accountIDs: [String]) -> Request { - return Request(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody( + public static func add(_ listID: String, accounts accountIDs: [String]) -> Request { + return Request(method: .post, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody( "account_ids" => accountIDs )) } - public static func remove(_ list: List, accounts accountIDs: [String]) -> Request { - return Request(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody( + public static func remove(_ listID: String, accounts accountIDs: [String]) -> Request { + return Request(method: .delete, path: "/api/v1/lists/\(listID)/accounts", body: ParametersBody( "account_ids" => accountIDs )) } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift new file mode 100644 index 00000000..d9d5291d --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/Protocols/ListProtocol.swift @@ -0,0 +1,13 @@ +// +// ListProtocol.swift +// Pachyderm +// +// Created by Shadowfacts on 2/25/23. +// + +import Foundation + +public protocol ListProtocol { + var id: String { get } + var title: String { get } +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 3b56d475..d2abfc74 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -224,6 +224,7 @@ D691771529A6FCAB0054D7EF /* StateRestorableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691771429A6FCAB0054D7EF /* StateRestorableViewController.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 */; }; D693A72A25CF8C1E003A14E2 /* ProfileDirectoryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */; }; D693A72F25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */; }; @@ -643,6 +644,7 @@ D691771429A6FCAB0054D7EF /* StateRestorableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateRestorableViewController.swift; sourceTree = ""; }; D691771629A710520054D7EF /* ProfileNoContentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileNoContentCollectionViewCell.swift; sourceTree = ""; }; D691771829A7B8820054D7EF /* ProfileHeaderMovedOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderMovedOverlayView.swift; sourceTree = ""; }; + D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivityHandlingContext.swift; sourceTree = ""; }; D693A72725CF282E003A14E2 /* TrendingHashtagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendingHashtagsViewController.swift; sourceTree = ""; }; D693A72925CF8C1E003A14E2 /* ProfileDirectoryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDirectoryViewController.swift; sourceTree = ""; }; D693A72D25CF91C6003A14E2 /* FeaturedProfileCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedProfileCollectionViewCell.swift; sourceTree = ""; }; @@ -986,6 +988,7 @@ children = ( D62D2425217ABF63005076CC /* UserActivityType.swift */, D62D2421217AA7E1005076CC /* UserActivityManager.swift */, + D691772D29AA5D420054D7EF /* UserActivityHandlingContext.swift */, D62D2423217ABF3F005076CC /* NSUserActivity+Extensions.swift */, D6674AE923341F7600E8DF94 /* AppShortcutItems.swift */, ); @@ -2078,6 +2081,7 @@ D673ACCE2919E74200D6F8B0 /* MenuPicker.swift in Sources */, D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */, D601FA83297EEC3F00A8E8B5 /* SuggestedProfileCardCollectionViewCell.swift in Sources */, + D691772E29AA5D420054D7EF /* UserActivityHandlingContext.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D677284C24ECBE9100C732D3 /* ComposeAvatarImageView.swift in Sources */, D6A6C11B25B63CEE00298D0F /* MemoryCache.swift in Sources */, diff --git a/Tusker/API/DeleteListService.swift b/Tusker/API/DeleteListService.swift index 5b4ef56a..8923c1fd 100644 --- a/Tusker/API/DeleteListService.swift +++ b/Tusker/API/DeleteListService.swift @@ -48,7 +48,7 @@ class DeleteListService { private func deleteList() async { do { - let request = List.delete(list) + let request = List.delete(list.id) _ = try await mastodonController.run(request) mastodonController.deletedList(list) } catch { diff --git a/Tusker/API/MastodonController.swift b/Tusker/API/MastodonController.swift index 4780b1f8..a2b8e60a 100644 --- a/Tusker/API/MastodonController.swift +++ b/Tusker/API/MastodonController.swift @@ -166,6 +166,8 @@ class MastodonController: ObservableObject { loadAccountPreferences() + lists = loadCachedLists() + NotificationCenter.default.publisher(for: .NSPersistentStoreRemoteChange, object: persistentContainer.persistentStoreCoordinator) .receive(on: DispatchQueue.main) .sink { [unowned self] _ in @@ -363,6 +365,23 @@ 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 func addedList(_ list: List) { var new = self.lists diff --git a/Tusker/API/RenameListService.swift b/Tusker/API/RenameListService.swift index ea435b0f..9f3a9789 100644 --- a/Tusker/API/RenameListService.swift +++ b/Tusker/API/RenameListService.swift @@ -11,13 +11,13 @@ import Pachyderm @MainActor class RenameListService { - private let list: List + private let list: ListProtocol private let mastodonController: MastodonController private let present: (UIViewController) -> Void private var renameAction: UIAlertAction? - init(list: List, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { + init(list: ListProtocol, mastodonController: MastodonController, present: @escaping (UIViewController) -> Void) { self.list = list self.mastodonController = mastodonController self.present = present @@ -47,7 +47,7 @@ class RenameListService { private func updateList(with title: String) async { do { - let req = List.update(list, title: title) + let req = List.update(list.id, title: title) let (list, _) = try await mastodonController.run(req) mastodonController.renamedList(list) } catch { diff --git a/Tusker/CoreData/ListMO.swift b/Tusker/CoreData/ListMO.swift index 4b1b5436..b9cc59fb 100644 --- a/Tusker/CoreData/ListMO.swift +++ b/Tusker/CoreData/ListMO.swift @@ -11,7 +11,7 @@ import CoreData import Pachyderm @objc(ListMO) -public final class ListMO: NSManagedObject { +public final class ListMO: NSManagedObject, ListProtocol { @nonobjc public class func fetchRequest() -> NSFetchRequest { return NSFetchRequest(entityName: "List") diff --git a/Tusker/MenuController.swift b/Tusker/MenuController.swift index 7c4d1519..7c1ff0f1 100644 --- a/Tusker/MenuController.swift +++ b/Tusker/MenuController.swift @@ -11,7 +11,7 @@ import UIKit struct MenuController { static let composeCommand: UIKeyCommand = { - return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.presentCompose), input: "n", modifierFlags: .command) + return UIKeyCommand(title: "Compose", action: #selector(MainSplitViewController.handleComposeKeyCommand), input: "n", modifierFlags: .command) }() static func refreshCommand(discoverabilityTitle: String?) -> UIKeyCommand { diff --git a/Tusker/Scenes/MainSceneDelegate.swift b/Tusker/Scenes/MainSceneDelegate.swift index a55bc5fb..62441910 100644 --- a/Tusker/Scenes/MainSceneDelegate.swift +++ b/Tusker/Scenes/MainSceneDelegate.swift @@ -64,7 +64,15 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { stateRestorationLogger.info("MainSceneDelegate.scene(_:continue:) called with \(userActivity.activityType, privacy: .public)") - _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene)) + let context: any UserActivityHandlingContext + 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(root: rootViewController!) + } + _ = userActivity.handleResume(manager: UserActivityManager(scene: scene as! UIWindowScene, context: context)) } func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { @@ -169,10 +177,16 @@ class MainSceneDelegate: UIResponder, UIWindowSceneDelegate, TuskerSceneDelegate activateAccount(account, animated: false) if let activity = launchActivity { + func doRestoreActivity(context: UserActivityHandlingContext) { + _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!, context: context)) + context.finalize(activity: activity) + } if activity.isStateRestorationActivity { - rootViewController?.restoreActivity(activity) + doRestoreActivity(context: StateRestorationUserActivityHandlingContext(root: rootViewController!)) } else if activity.activityType != UserActivityType.mainScene.rawValue { - _ = activity.handleResume(manager: UserActivityManager(scene: window!.windowScene!)) + doRestoreActivity(context: ActiveAccountUserActivityHandlingContext(root: rootViewController!)) + } else { + stateRestorationLogger.fault("MainSceneDelegate launched with non-restorable activity \(activity.activityType, privacy: .public)") } } } else { diff --git a/Tusker/Screens/Conversation/ConversationViewController.swift b/Tusker/Screens/Conversation/ConversationViewController.swift index cb88c164..21babeaa 100644 --- a/Tusker/Screens/Conversation/ConversationViewController.swift +++ b/Tusker/Screens/Conversation/ConversationViewController.swift @@ -430,10 +430,6 @@ extension ConversationViewController: StateRestorableViewController { return nil } } - - func restoreActivity(_ activity: NSUserActivity) { - fatalError("ConversationViewController must be reconstructed, not restored") - } } extension ConversationViewController: ToastableViewController { diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index b89deab4..f7e6ed9a 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -539,24 +539,6 @@ extension ExploreViewController: StateRestorableViewController { 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 { diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index bc595e49..f726e636 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -105,7 +105,7 @@ class EditListAccountsViewController: EnhancedTableViewController { func loadAccounts() async { do { - let request = List.getAccounts(list) + let request = List.getAccounts(list.id) let (accounts, pagination) = try await mastodonController.run(request) self.nextRange = pagination?.older @@ -135,7 +135,7 @@ class EditListAccountsViewController: EnhancedTableViewController { private func addAccount(id: String) async { changedAccounts = true do { - let req = List.add(list, accounts: [id]) + let req = List.add(list.id, accounts: [id]) _ = try await mastodonController.run(req) self.searchController.isActive = false await self.loadAccounts() @@ -151,7 +151,7 @@ class EditListAccountsViewController: EnhancedTableViewController { private func removeAccount(id: String) async { changedAccounts = true do { - let request = List.remove(list, accounts: [id]) + let request = List.remove(list.id, accounts: [id]) _ = try await mastodonController.run(request) await self.loadAccounts() } catch { diff --git a/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift b/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift index f7af2b59..da759371 100644 --- a/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift +++ b/Tusker/Screens/Local Predicate Statuses List/BookmarksViewController.swift @@ -32,7 +32,4 @@ extension BookmarksViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { return UserActivityManager.bookmarksActivity(accountID: mastodonController.accountInfo!.id) } - - func restoreActivity(_ activity: NSUserActivity) { - } } diff --git a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift index 7c9110a4..d1fd7692 100644 --- a/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift +++ b/Tusker/Screens/Main/AccountSwitchingContainerViewController.swift @@ -92,19 +92,14 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController { return root.stateRestorationActivity() } - func restoreActivity(_ activity: NSUserActivity) { + func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { loadViewIfNeeded() - root.restoreActivity(activity) + root.compose(editing: draft, animated: animated, isDucked: isDucked) } - func presentCompose() { + func select(route: TuskerRoute, animated: Bool) { loadViewIfNeeded() - root.presentCompose() - } - - func select(tab: MainTabBarViewController.Tab) { - loadViewIfNeeded() - root.select(tab: tab) + root.select(route: route, animated: animated) } func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { @@ -112,6 +107,16 @@ extension AccountSwitchingContainerViewController: TuskerRootViewController { return root.getTabController(tab: tab) } + func getNavigationDelegate() -> TuskerNavigationDelegate? { + loadViewIfNeeded() + return root.getNavigationDelegate() + } + + func getNavigationController() -> NavigationControllerProtocol { + loadViewIfNeeded() + return root.getNavigationController() + } + func performSearch(query: String) { loadViewIfNeeded() root.performSearch(query: query) diff --git a/Tusker/Screens/Main/Duckable+Root.swift b/Tusker/Screens/Main/Duckable+Root.swift index 388eb8ec..d740c9d5 100644 --- a/Tusker/Screens/Main/Duckable+Root.swift +++ b/Tusker/Screens/Main/Duckable+Root.swift @@ -20,22 +20,20 @@ extension DuckableContainerViewController: TuskerRootViewController { return activity } - func restoreActivity(_ activity: NSUserActivity) { - 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 compose(editing draft: Draft?, animated: Bool, isDucked: Bool) { + (child as? TuskerRootViewController)?.compose(editing: draft, animated: animated, isDucked: isDucked) } - func presentCompose() { - (child as? TuskerRootViewController)?.presentCompose() + func getNavigationDelegate() -> TuskerNavigationDelegate? { + (child as? TuskerRootViewController)?.getNavigationDelegate() } - func select(tab: MainTabBarViewController.Tab) { - (child as? TuskerRootViewController)?.select(tab: tab) + func getNavigationController() -> NavigationControllerProtocol { + (child as! TuskerRootViewController).getNavigationController() + } + + func select(route: TuskerRoute, animated: Bool) { + (child as? TuskerRootViewController)?.select(route: route, animated: animated) } func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { diff --git a/Tusker/Screens/Main/MainSidebarViewController.swift b/Tusker/Screens/Main/MainSidebarViewController.swift index 6095d92d..07f9e6e3 100644 --- a/Tusker/Screens/Main/MainSidebarViewController.swift +++ b/Tusker/Screens/Main/MainSidebarViewController.swift @@ -106,7 +106,7 @@ class MainSidebarViewController: UIViewController { NotificationCenter.default.addObserver(self, selector: #selector(reloadSavedInstances), name: .savedInstancesChanged, object: nil) mastodonController.$lists - .sink { [unowned self] in self.reloadLists($0) } + .sink { [unowned self] in self.reloadLists($0, animated: true) } .store(in: &cancellables) mastodonController.$followedHashtags .merge(with: @@ -179,7 +179,7 @@ class MainSidebarViewController: UIViewController { ], toSection: .compose) dataSource.apply(snapshot, animatingDifferences: false) - reloadLists(mastodonController.lists) + reloadLists(mastodonController.lists, animated: false) updateHashtagsSection(followed: mastodonController.followedHashtags) reloadSavedInstances() } @@ -192,7 +192,7 @@ class MainSidebarViewController: UIViewController { } } - private func reloadLists(_ lists: [List]) { + private func reloadLists(_ lists: [List], animated: Bool) { if let selectedItem, case .list(let list) = selectedItem, !lists.contains(where: { $0.id == list.id }) { @@ -204,7 +204,7 @@ class MainSidebarViewController: UIViewController { exploreSnapshot.expand([.listsHeader]) exploreSnapshot.append(lists.map { .list($0) }, to: .listsHeader) exploreSnapshot.append([.addList], to: .listsHeader) - self.dataSource.apply(exploreSnapshot, to: .lists) + self.dataSource.apply(exploreSnapshot, to: .lists, animatingDifferences: animated) } @MainActor diff --git a/Tusker/Screens/Main/MainSplitViewController.swift b/Tusker/Screens/Main/MainSplitViewController.swift index 8eca4132..1aa5f580 100644 --- a/Tusker/Screens/Main/MainSplitViewController.swift +++ b/Tusker/Screens/Main/MainSplitViewController.swift @@ -127,6 +127,10 @@ class MainSplitViewController: UISplitViewController { @objc private func sidebarTapped() { fastAccountSwitcher?.hide() } + + @objc func handleComposeKeyCommand() { + compose(editing: nil) + } } @@ -353,7 +357,7 @@ extension MainSplitViewController: UISplitViewControllerDelegate { extension MainSplitViewController: MainSidebarViewControllerDelegate { func sidebarRequestPresentCompose(_ sidebarViewController: MainSidebarViewController) { - presentCompose() + compose(editing: nil) } func sidebar(_ sidebarViewController: MainSidebarViewController, didSelectItem item: MainSidebarViewController.Item) { @@ -411,85 +415,41 @@ extension MainSplitViewController: StateRestorableViewController { return nil } } - - func restoreActivity(_ activity: NSUserActivity) { - guard traitCollection.horizontalSizeClass != .compact else { - tabBarViewController.restoreActivity(activity) - return - } - guard let type = UserActivityType(rawValue: activity.activityType) else { - return - } - - let item: MainSidebarViewController.Item - var needsRestore = true - switch type { - case .showTimeline: - item = .tab(.timelines) - case .checkNotifications: - item = .tab(.notifications) - case .search: - item = .explore - case .bookmarks: - item = .bookmarks - case .myProfile: - item = .tab(.myProfile) - needsRestore = false - case .newPost: - 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) - 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() + func select(route: TuskerRoute, animated: Bool) { + guard traitCollection.horizontalSizeClass != .compact else { + tabBarViewController?.select(route: route, animated: animated) + return + } + guard presentedViewController == nil else { + dismiss(animated: animated) { + self.select(route: route, animated: animated) + } + return + } + let item: MainSidebarViewController.Item + switch route { + case .timelines: + item = .tab(.timelines) + case .notifications: + item = .tab(.notifications) + case .myProfile: + item = .tab(.myProfile) + case .explore: + item = .explore + case .bookmarks: + item = .bookmarks + case .list(id: let id): + if let list = mastodonController.getCachedList(id: id) { + item = .list(list) } 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) - } + return } } + sidebar.select(item: item, animated: false) + select(item: item) } func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? { @@ -506,6 +466,22 @@ 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) { guard traitCollection.horizontalSizeClass != .compact else { // ensure the tab bar VC is loaded diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index 3af3740e..a6a950c4 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -110,6 +110,31 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { 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() { guard let myProfileButton = findMyProfileTabBarButton() else { return @@ -145,6 +170,10 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { fastAccountSwitcher.hide() } + @objc func handleComposeKeyCommand() { + compose(editing: nil) + } + func embedInNavigationController(_ vc: UIViewController) -> UINavigationController { if let vc = vc as? UINavigationController { return vc @@ -157,7 +186,7 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { func tabBarController(_ tabBarController: UITabBarController, shouldSelect viewController: UIViewController) -> Bool { if viewController == composePlaceholder { - presentCompose() + compose(editing: nil) return false } if viewController == viewControllers![selectedIndex], @@ -242,98 +271,52 @@ extension MainTabBarViewController: TuskerNavigationDelegate { extension MainTabBarViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { - let nav = viewController(for: selectedTab) as! UINavigationController var activity: NSUserActivity? - if let vc = nav.topViewController as? StateRestorableViewController { - activity = vc.stateRestorationActivity() - } else { - 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) + activity = UserActivityManager.editDraftActivity(id: compose.draft.id, accountID: compose.draft.accountID) + } else if let vc = (selectedViewController as! UINavigationController).topViewController as? StateRestorableViewController { + activity = vc.stateRestorationActivity() + } + if activity == nil { + stateRestorationLogger.fault("MainTabBarViewController: Unable to create state restoration activity, couldn't find StateRestorableViewController") } 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 { - @objc func presentCompose() { - compose() - } - - func select(tab: Tab) { - if tab == .compose { - presentCompose() - } 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 + func select(route: TuskerRoute, animated: Bool) { + switch route { + case .timelines: + select(tab: .timelines) + case .notifications: + select(tab: .notifications) + case .myProfile: + select(tab: .myProfile) + case .explore: + select(tab: .explore) + case .bookmarks: + select(tab: .explore) + getNavigationController().pushViewController(BookmarksViewController(mastodonController: mastodonController), animated: animated) + case .list(id: let id): + select(tab: .explore) + if let list = mastodonController.getCachedList(id: id) { + let nav = getNavigationController() + _ = 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) { guard let exploreNavController = getTabController(tab: .explore) as? UINavigationController, let exploreController = exploreNavController.viewControllers.first as? ExploreViewController else { diff --git a/Tusker/Screens/Main/TuskerRootViewController.swift b/Tusker/Screens/Main/TuskerRootViewController.swift index 71541a9e..861e664d 100644 --- a/Tusker/Screens/Main/TuskerRootViewController.swift +++ b/Tusker/Screens/Main/TuskerRootViewController.swift @@ -8,10 +8,91 @@ import UIKit +@MainActor protocol TuskerRootViewController: UIViewController, StateRestorableViewController, StatusBarTappableViewController { - func presentCompose() - func select(tab: MainTabBarViewController.Tab) + func compose(editing draft: Draft?, animated: Bool, isDucked: Bool) + func select(route: TuskerRoute, animated: Bool) func getTabController(tab: MainTabBarViewController.Tab) -> UIViewController? + func getNavigationDelegate() -> TuskerNavigationDelegate? + func getNavigationController() -> NavigationControllerProtocol func performSearch(query: String) func presentPreferences(completion: (() -> Void)?) } + +//extension TuskerRootViewController { +// func select(route: NewRoute, animated: Bool) { +// doApply(components: route.components, animated: animated) +// } +// +// private func doApply(components: ArraySlice, 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 + } +} diff --git a/Tusker/Screens/Notifications/NotificationsPageViewController.swift b/Tusker/Screens/Notifications/NotificationsPageViewController.swift index 3bc57cb6..ac2aae5d 100644 --- a/Tusker/Screens/Notifications/NotificationsPageViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsPageViewController.swift @@ -79,6 +79,7 @@ class NotificationsPageViewController: SegmentedPageViewController NSUserActivity { switch self { case .all: @@ -95,10 +96,4 @@ extension NotificationsPageViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { return currentPage.userActivity(accountID: mastodonController.accountInfo!.id) } - - func restoreActivity(_ activity: NSUserActivity) { - if let mode = UserActivityManager.getNotificationsMode(from: activity) { - selectMode(mode) - } - } } diff --git a/Tusker/Screens/Profile/ProfileViewController.swift b/Tusker/Screens/Profile/ProfileViewController.swift index 4699f2a0..8a34d653 100644 --- a/Tusker/Screens/Profile/ProfileViewController.swift +++ b/Tusker/Screens/Profile/ProfileViewController.swift @@ -295,10 +295,6 @@ class ProfileViewController: UIViewController, StateRestorableViewController { return nil } } - - func restoreActivity(_ activity: NSUserActivity) { - fatalError("ProfileViewController must be reconstructed, not restored") - } } extension ProfileViewController { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 724c006a..3ce956e8 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -25,7 +25,7 @@ extension TimelineViewControllerDelegate { func timelineViewController(_ timelineViewController: TimelineViewController, willDismissSyncToastWith animator: UIViewPropertyAnimator?) {} } -class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController { +class TimelineViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController, RefreshableViewController, StateRestorableViewController { weak var delegate: TimelineViewControllerDelegate? let timeline: Timeline diff --git a/Tusker/Screens/Timeline/TimelinesPageViewController.swift b/Tusker/Screens/Timeline/TimelinesPageViewController.swift index 69efc3f7..7ebe5f9c 100644 --- a/Tusker/Screens/Timeline/TimelinesPageViewController.swift +++ b/Tusker/Screens/Timeline/TimelinesPageViewController.swift @@ -211,16 +211,4 @@ extension TimelinesPageViewController: StateRestorableViewController { func stateRestorationActivity() -> NSUserActivity? { 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) - } - } } diff --git a/Tusker/Screens/Utilities/Previewing.swift b/Tusker/Screens/Utilities/Previewing.swift index d4dd20f3..1ecca0e5 100644 --- a/Tusker/Screens/Utilities/Previewing.swift +++ b/Tusker/Screens/Utilities/Previewing.swift @@ -73,7 +73,7 @@ extension MenuActionProvider { actionsSection.append(UIDeferredMenuElement.uncached({ elementHandler in var listActions = mastodonController.lists.map { list in UIAction(title: list.title, image: UIImage(systemName: "list.bullet")) { [unowned self] _ in - let req = List.add(list, accounts: [accountID]) + let req = List.add(list.id, accounts: [accountID]) mastodonController.run(req) { response in if case .failure(let error) = response { self.handleError(error, title: "Error Adding to List") @@ -86,7 +86,7 @@ extension MenuActionProvider { let service = CreateListService(mastodonController: mastodonController, present: { [unowned self] in self.navigationDelegate!.present($0, animated: true) }) { list in - let req = List.add(list, accounts: [accountID]) + let req = List.add(list.id, accounts: [accountID]) let response = await mastodonController.runResponse(req) if case .failure(let error) = response { self.handleError(error, title: "Error Adding to List") diff --git a/Tusker/Screens/Utilities/SplitNavigationController.swift b/Tusker/Screens/Utilities/SplitNavigationController.swift index 05f68922..b5d24be9 100644 --- a/Tusker/Screens/Utilities/SplitNavigationController.swift +++ b/Tusker/Screens/Utilities/SplitNavigationController.swift @@ -143,6 +143,17 @@ 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() { guard isViewLoaded else { return @@ -219,7 +230,9 @@ class SplitNavigationController: UIViewController { private var isLayingOutForAnimation = false - func popToRootViewController(animated: Bool) { + @discardableResult + func popToRootViewController(animated: Bool) -> [UIViewController]? { + let vcs = secondaryNav.viewControllers if animated { // 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 @@ -238,6 +251,7 @@ class SplitNavigationController: UIViewController { self.secondaryNav.viewControllers = [] self.updateSecondaryNavVisibility() } + return vcs } } diff --git a/Tusker/Screens/Utilities/StateRestorableViewController.swift b/Tusker/Screens/Utilities/StateRestorableViewController.swift index afecf7c2..5044586b 100644 --- a/Tusker/Screens/Utilities/StateRestorableViewController.swift +++ b/Tusker/Screens/Utilities/StateRestorableViewController.swift @@ -10,6 +10,4 @@ import UIKit protocol StateRestorableViewController: UIViewController { func stateRestorationActivity() -> NSUserActivity? - - func restoreActivity(_ activity: NSUserActivity) } diff --git a/Tusker/Shortcuts/AppShortcutItems.swift b/Tusker/Shortcuts/AppShortcutItems.swift index 334c02eb..add8d617 100644 --- a/Tusker/Shortcuts/AppShortcutItems.swift +++ b/Tusker/Shortcuts/AppShortcutItems.swift @@ -35,20 +35,20 @@ enum AppShortcutItem: String, CaseIterable { } } + @MainActor func handle() { - let tab: MainTabBarViewController.Tab - switch self { - case .showHomeTimeline: - tab = .timelines - case .showNotifications: - tab = .notifications - case .composePost: - 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) + guard let root = window.rootViewController as? TuskerRootViewController else { + return + } + switch self { + case .showHomeTimeline: + root.select(route: .timelines, animated: false) + case .showNotifications: + root.select(route: .notifications, animated: false) + case .composePost: + root.compose(editing: nil, animated: false, isDucked: false) } } } @@ -60,6 +60,7 @@ extension AppShortcutItem { } } + @MainActor static func handle(_ shortcutItem: UIApplicationShortcutItem) -> Bool { guard let type = AppShortcutItem(rawValue: shortcutItem.type) else { return false } type.handle() diff --git a/Tusker/Shortcuts/NSUserActivity+Extensions.swift b/Tusker/Shortcuts/NSUserActivity+Extensions.swift index aab37c53..d1dcacc2 100644 --- a/Tusker/Shortcuts/NSUserActivity+Extensions.swift +++ b/Tusker/Shortcuts/NSUserActivity+Extensions.swift @@ -41,6 +41,7 @@ extension NSUserActivity { ] } + @MainActor func handleResume(manager: UserActivityManager) -> Bool { guard let type = UserActivityType(rawValue: activityType) else { return false } type.handle(manager)(self) diff --git a/Tusker/Shortcuts/UserActivityHandlingContext.swift b/Tusker/Shortcuts/UserActivityHandlingContext.swift new file mode 100644 index 00000000..70022a29 --- /dev/null +++ b/Tusker/Shortcuts/UserActivityHandlingContext.swift @@ -0,0 +1,114 @@ +// +// UserActivityHandlingContext.swift +// Tusker +// +// Created by Shadowfacts on 2/25/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Duckable + +@MainActor +protocol UserActivityHandlingContext { + 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 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 + } + + 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 + } +} diff --git a/Tusker/Shortcuts/UserActivityManager.swift b/Tusker/Shortcuts/UserActivityManager.swift index bc937cca..5c2f8d46 100644 --- a/Tusker/Shortcuts/UserActivityManager.swift +++ b/Tusker/Shortcuts/UserActivityManager.swift @@ -13,12 +13,15 @@ import OSLog private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserActivityManager") +@MainActor class UserActivityManager { private let scene: UIWindowScene + private let context: any UserActivityHandlingContext - init(scene: UIWindowScene) { + init(scene: UIWindowScene, context: any UserActivityHandlingContext) { self.scene = scene + self.context = context } // MARK: - Utils @@ -73,12 +76,13 @@ class UserActivityManager { } func handleNewPost(activity: NSUserActivity) { - // TODO: check not currently showing compose screen - let mentioning = activity.userInfo?["mentioning"] as? String - let draft = mastodonController.createDraft(mentioningAcct: mentioning) - // todo: this shouldn't use self.mastodonController, it should get the right one based on the userInfo accountID - let composeVC = ComposeHostingController(draft: draft, mastodonController: mastodonController) - present(UINavigationController(rootViewController: composeVC)) + if let draft = Self.getDraft(from: activity) { + context.compose(editing: draft) + } else { + let mentioning = activity.userInfo?["mentioning"] as? String + let draft = mastodonController.createDraft(mentioningAcct: mentioning) + context.compose(editing: draft) + } } static func editDraftActivity(id: UUID, accountID: String) -> NSUserActivity { @@ -100,25 +104,16 @@ class UserActivityManager { } } - static func addEditedDraft(to activity: NSUserActivity?, draft: Draft) -> NSUserActivity { - if let activity { - activity.addUserInfoEntries(from: [ - "editedDraftID": draft.id.uuidString - ]) - return activity - } else { - return editDraftActivity(id: draft.id, accountID: draft.accountID) + static func getDraft(from activity: NSUserActivity) -> Draft? { + guard let idStr = activity.userInfo?["draftID"] as? String, + let uuid = UUID(uuidString: idStr) else { + return nil } + return DraftsManager.shared.getBy(id: uuid) } - static func getDraft(from activity: NSUserActivity) -> Draft? { - 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, + static func getDuckedDraft(from activity: NSUserActivity) -> Draft? { + guard let idStr = activity.userInfo?["duckedDraftID"] as? String, let uuid = UUID(uuidString: idStr) else { return nil } @@ -144,11 +139,9 @@ class UserActivityManager { } func handleCheckNotifications(activity: NSUserActivity) { - let mainViewController = getMainViewController() - mainViewController.select(tab: .notifications) - if let navigationController = mainViewController.getTabController(tab: .notifications) as? UINavigationController, - let notificationsPageController = navigationController.viewControllers.first as? NotificationsPageViewController { - navigationController.popToRootViewController(animated: false) + context.select(route: .notifications) + context.popToRoot() + if let notificationsPageController = context.topViewController as? NotificationsPageViewController { notificationsPageController.loadViewIfNeeded() notificationsPageController.selectMode(Self.getNotificationsMode(from: activity) ?? Preferences.shared.defaultNotificationsMode) } @@ -205,20 +198,19 @@ class UserActivityManager { func handleShowTimeline(activity: NSUserActivity) { guard let timeline = Self.getTimeline(from: activity) else { return } - let mainViewController = getMainViewController() - mainViewController.select(tab: .timelines) - guard let navigationController = mainViewController.getTabController(tab: .timelines) as? UINavigationController else { - return - } - if let pinned = PinnedTimeline(timeline: timeline), mastodonController.accountPreferences.pinnedTimelines.contains(pinned) { - navigationController.popToRootViewController(animated: false) - let rootController = navigationController.viewControllers.first! as! TimelinesPageViewController + context.select(route: .timelines) + context.popToRoot() + let rootController = context.topViewController as! TimelinesPageViewController rootController.selectTimeline(pinned, animated: false) + } else if case .list(let id) = timeline { + context.select(route: .list(id: id)) } else { + context.select(route: .explore) + context.popToRoot() let timeline = TimelineViewController(for: timeline, mastodonController: mastodonController) - navigationController.pushViewController(timeline, animated: false) + context.push(timeline) } } @@ -236,6 +228,14 @@ class UserActivityManager { 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 static func searchActivity(query: String?, accountID: String) -> NSUserActivity { @@ -254,19 +254,31 @@ class UserActivityManager { } func handleSearch(activity: NSUserActivity) { - let mainViewController = getMainViewController() - mainViewController.select(tab: .explore) - if let navigationController = mainViewController.getTabController(tab: .explore) as? UINavigationController, - let exploreController = navigationController.viewControllers.first as? ExploreViewController { - navigationController.popToRootViewController(animated: false) - exploreController.loadViewIfNeeded() - exploreController.searchController.isActive = true - if let query = Self.getSearchQuery(from: activity), - !query.isEmpty { - exploreController.searchController.searchBar.text = query - } else { - exploreController.searchController.searchBar.becomeFirstResponder() - } + context.select(route: .explore) + context.popToRoot() + + let searchController: UISearchController + let resultsController: SearchResultsViewController + if let explore = context.topViewController as? ExploreViewController { + explore.loadViewIfNeeded() + explore.searchControllerStatusOnAppearance = true + searchController = explore.searchController + resultsController = explore.resultsController + } else if let inlineTrends = context.topViewController as? InlineTrendsViewController { + inlineTrends.loadViewIfNeeded() + 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() } } @@ -279,12 +291,7 @@ class UserActivityManager { } func handleBookmarks(activity: NSUserActivity) { - 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) - } + context.select(route: .bookmarks) } // MARK: - My Profile @@ -297,8 +304,7 @@ class UserActivityManager { } func handleMyProfile(activity: NSUserActivity) { - let mainViewController = getMainViewController() - mainViewController.select(tab: .myProfile) + context.select(route: .myProfile) } // MARK: - Show Profile @@ -308,6 +314,7 @@ class UserActivityManager { "profileID": profileID, ]) activity.isEligibleForPrediction = true + activity.isEligibleForHandoff = true return activity } @@ -315,4 +322,12 @@ class UserActivityManager { 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)) + } + } diff --git a/Tusker/Shortcuts/UserActivityType.swift b/Tusker/Shortcuts/UserActivityType.swift index aad6ff1d..ccb6d586 100644 --- a/Tusker/Shortcuts/UserActivityType.swift +++ b/Tusker/Shortcuts/UserActivityType.swift @@ -21,7 +21,7 @@ enum UserActivityType: String { } extension UserActivityType { - var handle: (UserActivityManager) -> (NSUserActivity) -> Void { + var handle: (UserActivityManager) -> @MainActor (NSUserActivity) -> Void { switch self { case .mainScene: fatalError("cannot handle main scene activity") @@ -36,11 +36,11 @@ extension UserActivityType { case .bookmarks: return UserActivityManager.handleBookmarks case .showConversation: - fatalError("cannot handle show conversation activity") + return UserActivityManager.handleShowConversation case .myProfile: return UserActivityManager.handleMyProfile case .showProfile: - fatalError("cannot handle show profile activity") + return UserActivityManager.handleShowProfile } } } diff --git a/Tusker/TuskerNavigationDelegate.swift b/Tusker/TuskerNavigationDelegate.swift index 2013eccd..4e0a2172 100644 --- a/Tusker/TuskerNavigationDelegate.swift +++ b/Tusker/TuskerNavigationDelegate.swift @@ -89,7 +89,8 @@ extension TuskerNavigationDelegate { show(ConversationViewController(for: statusID, state: state, mastodonController: apiController), sender: self) } - func compose(editing draft: Draft, animated: Bool = true) { + func compose(editing draft: Draft?, animated: Bool = true, isDucked: Bool = false) { + let draft = draft ?? apiController.createDraft() if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { let compose = UserActivityManager.editDraftActivity(id: draft.id, accountID: apiController.accountInfo!.id) let options = UIWindowScene.ActivationRequestOptions() @@ -98,7 +99,7 @@ extension TuskerNavigationDelegate { } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController) if #available(iOS 16.0, *), - presentDuckable(compose, animated: animated) { + presentDuckable(compose, animated: animated, isDucked: isDucked) { return } else { let compose = ComposeHostingController(draft: draft, mastodonController: apiController)