// // EditListAccountsViewController.swift // Tusker // // Created by Shadowfacts on 12/17/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class EditListAccountsViewController: UIViewController, CollectionViewController { private var list: List private let mastodonController: MastodonController private var state = State.unloaded private(set) var changedAccounts = false private var dataSource: UICollectionViewDiffableDataSource! var collectionView: UICollectionView! { view as? UICollectionView } private var nextRange: RequestRange? private var searchResultsController: SearchResultsViewController! private var searchController: UISearchController! private var listRenamedCancellable: AnyCancellable? init(list: List, mastodonController: MastodonController) { self.list = list self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) listChanged() listRenamedCancellable = mastodonController.$lists .compactMap { $0.first { $0.id == list.id } } .sink { [unowned self] in self.list = $0 self.listChanged() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemeneted") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in var config = sectionConfig switch dataSource.itemIdentifier(for: indexPath)! { case .loadingIndicator: config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden case .account(id: _): config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in switch dataSource.itemIdentifier(for: indexPath) { case .account(id: let id): let remove = UIContextualAction(style: .destructive, title: "Remove") { [unowned self] _, _, completion in Task { await self.removeAccount(id: id) completion(true) } } return UISwipeActionsConfiguration(actions: [remove]) default: return nil } } let layout = UICollectionViewCompositionalLayout.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.allowsSelection = false collectionView.backgroundColor = .appGroupedBackground dataSource = createDataSource() } override func viewDidLoad() { super.viewDidLoad() searchResultsController = SearchResultsViewController(mastodonController: mastodonController, scope: .people) searchResultsController.following = true searchResultsController.delegate = self searchController = UISearchController(searchResultsController: searchResultsController) searchController.hidesNavigationBarDuringPresentation = false searchController.searchResultsUpdater = searchResultsController searchController.searchBar.autocapitalizationType = .none searchController.searchBar.placeholder = NSLocalizedString("Search accounts you follow", comment: "edit list search field placeholder") searchController.searchBar.delegate = searchResultsController definesPresentationContext = true navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false if #available(iOS 16.0, *) { navigationItem.preferredSearchBarPlacement = .stacked navigationItem.renameDelegate = self navigationItem.titleMenuProvider = { [unowned self] suggested in var children = suggested children.append(contentsOf: self.listSettingsMenuElements()) return UIMenu(children: children) } } else { navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Edit", menu: UIMenu(children: [ // uncached so that menu always reflects the current state of the list UIDeferredMenuElement.uncached({ [unowned self] elementHandler in var elements = self.listSettingsMenuElements() elements.insert(UIAction(title: "Rename…", image: UIImage(systemName: "pencil"), handler: { [unowned self] _ in RenameListService(list: self.list, mastodonController: self.mastodonController, present: { self.present($0, animated: true) }).run() }), at: 0) elementHandler(elements) }) ])) } } private func createDataSource() -> UICollectionViewDiffableDataSource { let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(accountID: itemIdentifier) cell.configurationUpdateHandler = { cell, state in cell.backgroundConfiguration = .appListGroupedCell(for: state) } } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .account(id: let id): return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) Task { await loadAccounts() } } private func listChanged() { title = list.title } private func listSettingsMenuElements() -> [UIMenuElement] { var elements = [UIMenuElement]() if mastodonController.instanceFeatures.listRepliesPolicy { let actions = List.ReplyPolicy.allCases.map { policy in UIAction(title: policy.actionTitle, state: list.replyPolicy == policy ? .on : .off) { [unowned self] _ in self.setReplyPolicy(policy) } } elements.append(UIMenu(title: "Show replies…", image: UIImage(systemName: "arrowshape.turn.up.left"), children: actions)) } if mastodonController.instanceFeatures.exclusiveLists { let actions = [ UIAction(title: "Hidden from Home", state: list.exclusive == true ? .on : .off) { [unowned self] _ in self.setExclusive(true) }, UIAction(title: "Shown on Home", state: list.exclusive == false ? .on : .off) { [unowned self] _ in self.setExclusive(false) }, ] elements.append(UIMenu(title: "Posts from this list are…", children: actions)) } return elements } @MainActor private func loadAccounts() async { guard state == .unloaded else { return } state = .loading async let results = try await mastodonController.run(List.getAccounts(list.id)) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.accounts]) snapshot.appendItems([.loadingIndicator]) await MainActor.run { dataSource.apply(snapshot) } do { let (accounts, pagination) = try await results self.nextRange = pagination?.older await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(accounts: accounts) { continuation.resume() } } var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.accounts]) snapshot.appendItems(accounts.map { .account(id: $0.id) }) await MainActor.run { dataSource.apply(snapshot) } state = .loaded } 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) state = .unloaded await MainActor.run { dataSource.apply(.init()) } } } private func loadNextPage() async { guard state == .loaded, let nextRange else { return } state = .loading async let results = try await mastodonController.run(List.getAccounts(list.id, range: nextRange)) let origSnapshot = dataSource.snapshot() var snapshot = origSnapshot snapshot.appendItems([.loadingIndicator]) await MainActor.run { dataSource.apply(snapshot) } do { let (accounts, pagination) = try await results self.nextRange = pagination?.older await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(accounts: accounts) { continuation.resume() } } var snapshot = origSnapshot snapshot.appendItems(accounts.map { .account(id: $0.id) }) await MainActor.run { dataSource.apply(snapshot) } state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in toast.dismissToast(animated: true) await self.loadNextPage() } self.showToast(configuration: config, animated: true) state = .loaded await MainActor.run { dataSource.apply(origSnapshot) } } } private func addAccount(id: String) async { changedAccounts = true do { let req = List.add(list.id, 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 { changedAccounts = true do { let request = List.remove(list.id, 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) } } private func setReplyPolicy(_ replyPolicy: List.ReplyPolicy) { Task { let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) await service.run(replyPolicy: replyPolicy) } } private func setExclusive(_ exclusive: Bool) { Task { let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) await service.run(exclusive: exclusive) } } } extension EditListAccountsViewController { enum State { case unloaded case loading case loaded case loadingOlder } } extension EditListAccountsViewController { enum Section: Hashable { case accounts } enum Item: Hashable { case loadingIndicator case account(id: String) } } extension EditListAccountsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if state == .loaded, indexPath.item == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { await loadNextPage() } } } } extension EditListAccountsViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension EditListAccountsViewController: ToastableViewController { } extension EditListAccountsViewController: MenuActionProvider { } extension EditListAccountsViewController: SearchResultsViewControllerDelegate { func selectedSearchResult(account accountID: String) { Task { await addAccount(id: accountID) } } } extension EditListAccountsViewController: UINavigationItemRenameDelegate { func navigationItem(_: UINavigationItem, shouldEndRenamingWith title: String) -> Bool { !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } func navigationItem(_: UINavigationItem, didEndRenamingWith title: String) { Task { let service = EditListSettingsService(list: list, mastodonController: mastodonController, present: { self.present($0, animated: true) }) await service.run(title: title) } } } private extension List.ReplyPolicy { var actionTitle: String { switch self { case .followed: "To accounts you follow" case .list: "To other list members" case .none: "Never" } } }