// // AccountFollowsListViewController.swift // Tusker // // Created by Shadowfacts on 1/18/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class AccountFollowsListViewController: UIViewController, CollectionViewController { private static nonisolated let pageSize = 40 let accountID: String let mastodonController: MastodonController let mode: AccountFollowsViewController.Mode var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! private var state: State = .unloaded private var newer: RequestRange? private var older: RequestRange? init(accountID: String, mastodonController: MastodonController, mode: AccountFollowsViewController.Mode) { self.accountID = accountID self.mastodonController = mastodonController self.mode = mode super.init(nibName: nil, bundle: nil) title = mode.title } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in guard let item = self.dataSource.itemIdentifier(for: indexPath) else { return sectionConfig } var config = sectionConfig if item.hideSeparators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } return config } let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) section.readableContentInset(in: environment) return section } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.updateUI(accountID: item) cell.configurationUpdateHandler = { cell, state in cell.backgroundConfiguration = .appListPlainCell(for: state) } } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, item in cell.indicator.startAnimating() } return UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .account(let id): return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } }) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .unloaded = state { Task { await loadInitial() } } } private nonisolated func request(for range: RequestRange) -> Request<[Account]> { switch mode { case .following: return Account.getFollowing(accountID, range: range.withCount(Self.pageSize)) case .followers: return Account.getFollowers(accountID, range: range.withCount(Self.pageSize)) } } private func apply(snapshot: NSDiffableDataSourceSnapshot) async { await Task { @MainActor in self.dataSource.apply(snapshot) }.value } @MainActor private func loadInitial() async { guard case .unloaded = state else { return } self.state = .loadingInitial var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.accounts]) snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot) do { let (accounts, pagination) = try await mastodonController.run(request(for: .default)) await mastodonController.persistentContainer.addAll(accounts: accounts) guard case .loadingInitial = self.state else { return } self.state = .loaded self.newer = pagination?.newer self.older = pagination?.older var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.accounts]) snapshot.appendItems(accounts.map { .account($0.id) }) await apply(snapshot: snapshot) } catch { self.state = .unloaded let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { [unowned self] toast in toast.dismissToast(animated: true) await self.loadInitial() } self.showToast(configuration: config, animated: true) } } @MainActor private func loadOlder() async { guard case .loaded = state, let older else { return } self.state = .loadingOlder var snapshot = dataSource.snapshot() snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot) do { let (accounts, pagination) = try await mastodonController.run(request(for: older)) await mastodonController.persistentContainer.addAll(accounts: accounts) guard case .loadingOlder = self.state else { return } self.state = .loaded self.older = pagination?.older var snapshot = dataSource.snapshot() snapshot.deleteItems([.loadingIndicator]) snapshot.appendItems(accounts.map { .account($0.id) }) await apply(snapshot: snapshot) } catch { self.state = .loaded let config = ToastConfiguration(from: error, with: "Error Loading More", in: self) { [unowned self] toast in toast.dismissToast(animated: true) await self.loadOlder() } self.showToast(configuration: config, animated: true) } } } extension AccountFollowsListViewController { enum State { case unloaded case loadingInitial case loaded case loadingOlder } } extension AccountFollowsListViewController { enum Section { case accounts } enum Item: Hashable { case account(String) case loadingIndicator var hideSeparators: Bool { switch self { case .account(_): return false case .loadingIndicator: return true } } } } extension AccountFollowsListViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if indexPath.section == collectionView.numberOfSections - 1, indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { await self.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard case .account(let id) = dataSource.itemIdentifier(for: indexPath) else { return } selected(account: id) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard case .account(let id) = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) else { return nil } return UIContextMenuConfiguration { ProfileViewController(accountID: id, mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension AccountFollowsListViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard case .account(let id) = dataSource.itemIdentifier(for: indexPath), let currentAccountID = mastodonController.accountInfo?.id, let account = mastodonController.persistentContainer.account(for: id) else { return [] } let provider = NSItemProvider(object: account.url as NSURL) let activity = UserActivityManager.showProfileActivity(id: id, accountID: currentAccountID) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) return [UIDragItem(itemProvider: provider)] } } extension AccountFollowsListViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension AccountFollowsListViewController: MenuActionProvider { } extension AccountFollowsListViewController: ToastableViewController { } extension AccountFollowsListViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }