From 523fb91b21cdb778dd7d05ea90f5a811f9afb758 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 11 Nov 2022 17:28:19 -0500 Subject: [PATCH] Add scope to search following accounts when editing list Also fixes crash when loading or editing list Closes #216 Closes #221 --- .../Sources/Pachyderm/Model/Account.swift | 8 +- Tusker.xcodeproj/project.pbxproj | 8 + .../MastodonCachePersistentStore.swift | 9 +- .../EditListAccountsViewController.swift | 110 ++++++----- ...ditListSearchFollowingViewController.swift | 178 ++++++++++++++++++ ...SearchResultsContainerViewController.swift | 115 +++++++++++ .../Account Cell/AccountTableViewCell.swift | 1 + 7 files changed, 379 insertions(+), 50 deletions(-) create mode 100644 Tusker/Screens/Lists/EditListSearchFollowingViewController.swift create mode 100644 Tusker/Screens/Lists/EditListSearchResultsContainerViewController.swift diff --git a/Pachyderm/Sources/Pachyderm/Model/Account.swift b/Pachyderm/Sources/Pachyderm/Model/Account.swift index 07da3350c3..fa7ead8e66 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Account.swift +++ b/Pachyderm/Sources/Pachyderm/Model/Account.swift @@ -82,14 +82,14 @@ public final class Account: AccountProtocol, Decodable { return Request(method: .delete, path: "/api/v1/suggestions/\(account.id)") } - public static func getFollowers(_ account: Account, range: RequestRange = .default) -> Request<[Account]> { - var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/followers") + public static func getFollowers(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> { + var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/followers") request.range = range return request } - public static func getFollowing(_ account: Account, range: RequestRange = .default) -> Request<[Account]> { - var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(account.id)/following") + public static func getFollowing(_ accountID: String, range: RequestRange = .default) -> Request<[Account]> { + var request = Request<[Account]>(method: .get, path: "/api/v1/accounts/\(accountID)/following") request.range = range return request } diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index ce4c83fc83..d137609729 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -310,6 +310,8 @@ D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; + D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */; }; + D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */; }; D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; }; D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; }; /* End PBXBuildFile section */ @@ -675,6 +677,8 @@ D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = ""; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = ""; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = ""; }; + D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchResultsContainerViewController.swift; sourceTree = ""; }; + D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditListSearchFollowingViewController.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -833,6 +837,8 @@ children = ( D627944C23A9A03D00D38C68 /* ListTimelineViewController.swift */, D627944E23A9C99800D38C68 /* EditListAccountsViewController.swift */, + D6F6A54B291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift */, + D6F6A54D291EF7E100F496A8 /* EditListSearchFollowingViewController.swift */, ); path = Lists; sourceTree = ""; @@ -1751,6 +1757,7 @@ D6285B5321EA708700FE4B39 /* StatusFormat.swift in Sources */, D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */, 0427033A22B31269000D31B6 /* AdvancedPrefsView.swift in Sources */, + D6F6A54C291EF6FE00F496A8 /* EditListSearchResultsContainerViewController.swift in Sources */, D662AEEF263A3B880082A153 /* PollFinishedTableViewCell.swift in Sources */, D626493C23C1000300612E6E /* AlbumTableViewCell.swift in Sources */, D62275A624F1C81800B82A16 /* ComposeReplyView.swift in Sources */, @@ -1982,6 +1989,7 @@ D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */, D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, + D6F6A54E291EF7E100F496A8 /* EditListSearchFollowingViewController.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D626493F23C101C500612E6E /* AlbumAssetCollectionViewController.swift in Sources */, diff --git a/Tusker/CoreData/MastodonCachePersistentStore.swift b/Tusker/CoreData/MastodonCachePersistentStore.swift index fa4219cc6c..1bf1b6e656 100644 --- a/Tusker/CoreData/MastodonCachePersistentStore.swift +++ b/Tusker/CoreData/MastodonCachePersistentStore.swift @@ -221,10 +221,11 @@ class MastodonCachePersistentStore: NSPersistentContainer { } } - func addAll(accounts: [Account], completion: (() -> Void)? = nil) { - backgroundContext.perform { - accounts.forEach { self.upsert(account: $0, in: self.backgroundContext) } - self.save(context: self.backgroundContext) + func addAll(accounts: [Account], in context: NSManagedObjectContext? = nil, completion: (() -> Void)? = nil) { + let context = context ?? backgroundContext + context.perform { + accounts.forEach { self.upsert(account: $0, in: context) } + self.save(context: context) completion?() accounts.forEach { self.accountSubject.send($0.id) } } diff --git a/Tusker/Screens/Lists/EditListAccountsViewController.swift b/Tusker/Screens/Lists/EditListAccountsViewController.swift index 624a043ff4..4be7cb47f5 100644 --- a/Tusker/Screens/Lists/EditListAccountsViewController.swift +++ b/Tusker/Screens/Lists/EditListAccountsViewController.swift @@ -19,7 +19,7 @@ class EditListAccountsViewController: EnhancedTableViewController { var nextRange: RequestRange? - var searchResultsController: SearchResultsViewController! + var searchResultsController: EditListSearchResultsContainerViewController! var searchController: UISearchController! init(list: List, mastodonController: MastodonController) { @@ -53,14 +53,23 @@ class EditListAccountsViewController: EnhancedTableViewController { }) dataSource.editListAccountsController = self - searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts]) - searchResultsController.delegate = self + searchResultsController = EditListSearchResultsContainerViewController(mastodonController: mastodonController) { [unowned self] accountID in + Task { + await self.addAccount(id: accountID) + } + } searchController = UISearchController(searchResultsController: searchResultsController) searchController.hidesNavigationBarDuringPresentation = false searchController.searchResultsUpdater = searchResultsController + if #available(iOS 16.0, *) { + searchController.scopeBarActivation = .onSearchActivation + } else { + searchController.automaticallyShowsScopeBar = true + } searchController.searchBar.autocapitalizationType = .none searchController.searchBar.placeholder = NSLocalizedString("Search for accounts to add", comment: "edit list search field placeholder") searchController.searchBar.delegate = searchResultsController + searchController.searchBar.scopeButtonTitles = ["Everyone", "People You Follow"] definesPresentationContext = true navigationItem.searchController = searchController @@ -68,28 +77,66 @@ class EditListAccountsViewController: EnhancedTableViewController { navigationItem.rightBarButtonItem = UIBarButtonItem(title: NSLocalizedString("Rename", comment: "rename list button title"), style: .plain, target: self, action: #selector(renameButtonPressed)) - loadAccounts() + Task { + await loadAccounts() + } } - func loadAccounts() { - let request = List.getAccounts(list) - mastodonController.run(request) { (response) in - guard case let .success(accounts, pagination) = response else { - fatalError() - } - + func loadAccounts() async { + do { + let request = List.getAccounts(list) + let (accounts, pagination) = try await mastodonController.run(request) self.nextRange = pagination?.older - self.mastodonController.persistentContainer.addAll(accounts: accounts) { - var snapshot = self.dataSource.snapshot() - snapshot.deleteSections([.accounts]) - snapshot.appendSections([.accounts]) - snapshot.appendItems(accounts.map { .account(id: $0.id) }) - - DispatchQueue.main.async { - self.dataSource.apply(snapshot) + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(accounts: accounts) { + continuation.resume() } } + + var snapshot = self.dataSource.snapshot() + if snapshot.indexOfSection(.accounts) == nil { + snapshot.appendSections([.accounts]) + } else { + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts)) + } + snapshot.appendItems(accounts.map { .account(id: $0.id) }) + await dataSource.apply(snapshot) + } catch { + let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.loadAccounts() + } + self.showToast(configuration: config, animated: true) + } + } + + private func addAccount(id: String) async { + do { + let req = List.add(list, accounts: [id]) + _ = try await mastodonController.run(req) + self.searchController.isActive = false + await self.loadAccounts() + } catch { + let config = ToastConfiguration(from: error, with: "Error Adding Account", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.addAccount(id: id) + } + self.showToast(configuration: config, animated: true) + } + } + + private func removeAccount(id: String) async { + do { + let request = List.remove(list, accounts: [id]) + _ = try await mastodonController.run(request) + await self.loadAccounts() + } catch { + let config = ToastConfiguration(from: error, with: "Error Removing Account", in: self) { [unowned self] toast in + toast.dismissToast(animated: true) + await self.removeAccount(id: id) + } + self.showToast(configuration: config, animated: true) } } @@ -145,29 +192,8 @@ extension EditListAccountsViewController { return } - let request = List.remove(editListAccountsController!.list, accounts: [id]) - editListAccountsController!.mastodonController.run(request) { (response) in - guard case .success(_, _) = response else { - fatalError() - } - - self.editListAccountsController?.loadAccounts() - } - } - } -} - -extension EditListAccountsViewController: SearchResultsViewControllerDelegate { - func selectedSearchResult(account accountID: String) { - let request = List.add(list, accounts: [accountID]) - mastodonController.run(request) { (response) in - guard case .success(_, _) = response else { - fatalError() - } - - self.loadAccounts() - DispatchQueue.main.async { - self.searchController.isActive = false + Task { + await self.editListAccountsController?.removeAccount(id: id) } } } diff --git a/Tusker/Screens/Lists/EditListSearchFollowingViewController.swift b/Tusker/Screens/Lists/EditListSearchFollowingViewController.swift new file mode 100644 index 0000000000..4b68848a55 --- /dev/null +++ b/Tusker/Screens/Lists/EditListSearchFollowingViewController.swift @@ -0,0 +1,178 @@ +// +// EditListSearchFollowingViewController.swift +// Tusker +// +// Created by Shadowfacts on 11/11/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class EditListSearchFollowingViewController: EnhancedTableViewController { + + private let mastodonController: MastodonController + private let didSelectAccount: (String) -> Void + + private var dataSource: UITableViewDiffableDataSource! + + private var query: String? + private var accountIDs: [String] = [] + private var nextRange: RequestRange? + + init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) { + self.mastodonController = mastodonController + self.didSelectAccount = didSelectAccount + + super.init(style: .grouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.register(UINib(nibName: "AccountTableViewCell", bundle: .main), forCellReuseIdentifier: "accountCell") + dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in + let cell = tableView.dequeueReusableCell(withIdentifier: "accountCell", for: indexPath) as! AccountTableViewCell + cell.delegate = self + cell.updateUI(accountID: itemIdentifier) + return cell + } + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if dataSource.snapshot().numberOfItems == 0 { + Task { + await load() + } + } + } + + override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + print("will display: \(indexPath)") + if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { + Task { + await load() + } + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let id = dataSource.itemIdentifier(for: indexPath) else { + return + } + didSelectAccount(id) + } + + private func load() async { + do { + let ownAccount = try await mastodonController.getOwnAccount() + let req = Account.getFollowing(ownAccount.id, range: nextRange ?? .default) + let (following, pagination) = try await mastodonController.run(req) + await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(accounts: following) { + continuation.resume() + } + } + accountIDs.append(contentsOf: following.lazy.map(\.id)) + nextRange = pagination?.older + updateDataSource(appending: following.map(\.id)) + } catch { + let config = ToastConfiguration(from: error, with: "Error Loading Following", in: self) { toast in + toast.dismissToast(animated: true) + await self.load() + } + self.showToast(configuration: config, animated: true) + } + } + + private func updateDataSourceForQueryChanged() { + guard let query, !query.isEmpty else { + let snapshot = NSDiffableDataSourceSnapshot() + dataSource.apply(snapshot, animatingDifferences: true) + return + } + + let ids = filterAccounts(ids: accountIDs, with: query) + + var snapshot = dataSource.snapshot() + if snapshot.indexOfSection(.accounts) == nil { + snapshot.appendSections([.accounts]) + } else { + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .accounts)) + } + snapshot.appendItems(ids) + dataSource.apply(snapshot, animatingDifferences: true) + + // if there aren't any results for the current query, try to load more + if ids.isEmpty { + Task { + await load() + } + } + } + + private func updateDataSource(appending ids: [String]) { + guard let query, !query.isEmpty else { + let snapshot = NSDiffableDataSourceSnapshot() + dataSource.apply(snapshot, animatingDifferences: true) + return + } + + let ids = filterAccounts(ids: ids, with: query) + + var snapshot = dataSource.snapshot() + if snapshot.indexOfSection(.accounts) == nil { + snapshot.appendSections([.accounts]) + } + let existing = snapshot.itemIdentifiers(inSection: .accounts) + snapshot.appendItems(ids.filter { !existing.contains($0) }) + dataSource.apply(snapshot, animatingDifferences: true) + + // if there aren't any results for the current query, try to load more + if ids.isEmpty { + Task { + await load() + } + } + } + + private func filterAccounts(ids: [String], with query: String) -> [String] { + let req = AccountMO.fetchRequest() + req.predicate = NSPredicate(format: "id in %@", ids) + let accounts = try! mastodonController.persistentContainer.viewContext.fetch(req) + + return accounts + .map { (account) -> (AccountMO, Bool) in + let displayNameMatch = FuzzyMatcher.match(pattern: query, str: account.displayNameWithoutCustomEmoji) + let usernameMatch = FuzzyMatcher.match(pattern: query, str: account.acct) + return (account, displayNameMatch.matched || usernameMatch.matched) + } + .filter(\.1) + .map(\.0.id) + } + + func updateQuery(_ query: String) { + self.query = query + updateDataSourceForQueryChanged() + } + +} + +extension EditListSearchFollowingViewController { + enum Section { + case accounts + } +} + +extension EditListSearchFollowingViewController: TuskerNavigationDelegate { + var apiController: MastodonController! { mastodonController } +} + +extension EditListSearchFollowingViewController: MenuActionProvider { +} diff --git a/Tusker/Screens/Lists/EditListSearchResultsContainerViewController.swift b/Tusker/Screens/Lists/EditListSearchResultsContainerViewController.swift new file mode 100644 index 0000000000..769f1be935 --- /dev/null +++ b/Tusker/Screens/Lists/EditListSearchResultsContainerViewController.swift @@ -0,0 +1,115 @@ +// +// EditListSearchResultsContainerViewController.swift +// Tusker +// +// Created by Shadowfacts on 11/11/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit +import Combine + +class EditListSearchResultsContainerViewController: UIViewController { + + private let mastodonController: MastodonController + private let didSelectAccount: (String) -> Void + + private let searchResultsController: SearchResultsViewController + private let searchFollowingController: EditListSearchFollowingViewController + + var mode = Mode.search { + willSet { + currentViewController.removeViewAndController() + } + didSet { + embedChild(currentViewController) + } + } + var currentViewController: UIViewController { + switch mode { + case .search: + return searchResultsController + case .following: + return searchFollowingController + } + } + + private var currentQuery: String? + private var searchSubject = PassthroughSubject() + private var cancellables = Set() + + init(mastodonController: MastodonController, didSelectAccount: @escaping (String) -> Void) { + self.mastodonController = mastodonController + self.didSelectAccount = didSelectAccount + + self.searchResultsController = SearchResultsViewController(mastodonController: mastodonController, resultTypes: [.accounts]) + self.searchFollowingController = EditListSearchFollowingViewController(mastodonController: mastodonController, didSelectAccount: didSelectAccount) + + super.init(nibName: nil, bundle: nil) + + self.searchResultsController.delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + embedChild(currentViewController) + + searchSubject + .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .sink { [unowned self] in self.performSearch(query: $0) } + .store(in: &cancellables) + } + + func performSearch(query: String?) { + guard var query = query?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return + } + if query.starts(with: "@") { + query = String(query.dropFirst()) + } + guard query != self.currentQuery else { + return + } + self.currentQuery = query + + switch mode { + case .search: + searchResultsController.performSearch(query: query) + case .following: + searchFollowingController.updateQuery(query) + } + } + + enum Mode: Equatable { + case search, following + } + +} + +extension EditListSearchResultsContainerViewController: UISearchResultsUpdating { + func updateSearchResults(for searchController: UISearchController) { + searchSubject.send(searchController.searchBar.text) + } +} + +extension EditListSearchResultsContainerViewController: UISearchBarDelegate { + func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + performSearch(query: searchBar.text) + } + + func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { + mode = selectedScope == 0 ? .search : .following + performSearch(query: searchBar.text) + } +} + +extension EditListSearchResultsContainerViewController: SearchResultsViewControllerDelegate { + func selectedSearchResult(account accountID: String) { + didSelectAccount(accountID) + } +} diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.swift b/Tusker/Views/Account Cell/AccountTableViewCell.swift index 2e3979c65d..620ead072c 100644 --- a/Tusker/Views/Account Cell/AccountTableViewCell.swift +++ b/Tusker/Views/Account Cell/AccountTableViewCell.swift @@ -69,6 +69,7 @@ class AccountTableViewCell: UITableViewCell { let accountID = self.accountID + avatarImageView.image = nil if let avatarURL = account.avatar { avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (_, image) in guard let self = self else { return }