// // StatusActionAccountListCollectionViewController.swift // Tusker // // Created by Shadowfacts on 9/5/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController { private static let pageSize = 40 private let statusID: String private let actionType: StatusActionAccountListViewController.ActionType private let mastodonController: MastodonController /// If `true`, a warning will be shown below the account list describing that the total favs/reblogs may be innacurate. var showInacurateCountWarning = false var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! private var state: State = .unloaded private var older: RequestRange? /** Creates a new view controller showing the accounts that performed the given action on the given status. - Parameter mastodonController The `MastodonController` instance this view controller uses. */ init(statusID: String, actionType: StatusActionAccountListViewController.ActionType, mastodonController: MastodonController) { self.statusID = statusID self.actionType = actionType self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var accountsConfig = UICollectionLayoutListConfiguration(appearance: .grouped) accountsConfig.backgroundColor = .appGroupedBackground accountsConfig.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 { [unowned self] sectionIndex, environment in switch dataSource.sectionIdentifier(for: sectionIndex)! { case .status: var config = UICollectionLayoutListConfiguration(appearance: .grouped) config.footerMode = self.showInacurateCountWarning ? .supplementary : .none config.backgroundColor = .appGroupedBackground config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) case .accounts: return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment) } } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.configurationUpdateHandler = { cell, state in var config = UIBackgroundConfiguration.listGroupedCell().updated(for: state) if state.isHighlighted || state.isSelected { config.backgroundColor = .appSelectedCellBackground } else { config.backgroundColor = .appGroupedCellBackground } cell.backgroundConfiguration = config } } let accountCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.updateUI(accountID: item) cell.configurationUpdateHandler = { cell, state in var config = UIBackgroundConfiguration.listGroupedCell().updated(for: state) if state.isHighlighted || state.isSelected { config.backgroundColor = .appSelectedCellBackground } else { config.backgroundColor = .appGroupedCellBackground } cell.backgroundConfiguration = config } } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, item in cell.indicator.startAnimating() } let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(let id, let state): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case .account(let id): return collectionView.dequeueConfiguredReusableCell(using: accountCell, for: indexPath, item: id) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } } let sectionHeaderCell = UICollectionView.SupplementaryRegistration(elementKind: UICollectionView.elementKindSectionFooter) { (headerView, collectionView, indexPath) in var config = headerView.defaultContentConfiguration() config.text = NSLocalizedString("Favorite and reblog counts for posts originating from instances other than your own may not be accurate.", comment: "shown on lists of status total actions") headerView.contentConfiguration = config } dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in return collectionView.dequeueConfiguredReusableSupplementary(using: sectionHeaderCell, for: indexPath) } return dataSource } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .unloaded = state { Task { await loadAccounts() } } } func addStatus(_ status: StatusMO, state: CollapseState) { loadViewIfNeeded() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.status, .accounts]) snapshot.appendItems([.status(status.id, state)], toSection: .status) dataSource.apply(snapshot, animatingDifferences: false) } func setAccounts(_ accountIDs: [String], animated: Bool) { guard case .unloaded = state else { return } var snapshot = dataSource.snapshot() snapshot.appendItems(accountIDs.map { .account($0) }, toSection: .accounts) dataSource.apply(snapshot, animatingDifferences: animated) self.state = .loaded } private func request(for range: RequestRange) -> Request<[Account]> { switch actionType { case .favorite: return Status.getFavourites(statusID, range: range.withCount(Self.pageSize)) case .reblog: return Status.getReblogs(statusID, range: range.withCount(Self.pageSize)) } } func apply(snapshot: NSDiffableDataSourceSnapshot) async { await Task { @MainActor in self.dataSource.apply(snapshot) }.value } @MainActor private func loadAccounts() async { guard case .unloaded = state else { return } self.state = .loadingInitial var snapshot = dataSource.snapshot() snapshot.appendItems([.loadingIndicator], toSection: .accounts) 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.older = pagination?.older var snapshot = dataSource.snapshot() snapshot.deleteItems([.loadingIndicator]) snapshot.appendItems(accounts.map { .account($0.id) }, toSection: .accounts) await apply(snapshot: snapshot) } catch { self.state = .unloaded let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in toast.dismissToast(animated: true) await self.loadAccounts() } self.showToast(configuration: config, animated: true) } } @MainActor private func loadOlder() async { guard case .loaded = state, let older else { return } self.state = .loadingOlder var snapshot = self.dataSource.snapshot() snapshot.appendItems([.loadingIndicator], toSection: .accounts) 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) }, toSection: .accounts) 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 StatusActionAccountListCollectionViewController { enum State { case unloaded case loadingInitial case loaded case loadingOlder } } extension StatusActionAccountListCollectionViewController { enum Section { case status case accounts } enum Item: Hashable { case status(String, CollapseState) case account(String) case loadingIndicator var hideSeparators: Bool { switch self { case .loadingIndicator: return true default: return false } } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.status(let a, _), .status(let b, _)): return a == b case (.account(let a), .account(let b)): return a == b case (.loadingIndicator, .loadingIndicator): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .status(let id, _): hasher.combine(0) hasher.combine(id) case .account(let id): hasher.combine(1) hasher.combine(id) case .loadingIndicator: hasher.combine(2) } } } } extension StatusActionAccountListCollectionViewController: 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) { switch dataSource.itemIdentifier(for: indexPath) { case nil: return case .status(let id, let state): selected(status: id, state: state.copy()) case .account(let id): selected(account: id) case .loadingIndicator: return } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let item = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) else { return nil } switch item { case .status: return (cell as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() case .account(let id): return UIContextMenuConfiguration { ProfileViewController(accountID: id, mastodonController: self.mastodonController) } actionProvider: { _ in UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) } case .loadingIndicator: return nil } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension StatusActionAccountListCollectionViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { guard let currentAccountID = mastodonController.accountInfo?.id, let item = dataSource.itemIdentifier(for: indexPath) else { return [] } let provider: NSItemProvider switch item { case .status(let id, _): guard let status = mastodonController.persistentContainer.status(for: id) else { return [] } provider = NSItemProvider(object: status.url! as NSURL) let activity = UserActivityManager.showConversationActivity(mainStatusID: id, accountID: currentAccountID) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) case .account(let id): guard let account = mastodonController.persistentContainer.account(for: id) else { return [] } provider = NSItemProvider(object: account.url as NSURL) let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) case .loadingIndicator: return [] } return [UIDragItem(itemProvider: provider)] } } extension StatusActionAccountListCollectionViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension StatusActionAccountListCollectionViewController: MenuActionProvider { } extension StatusActionAccountListCollectionViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { fatalError() } } extension StatusActionAccountListCollectionViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }