diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift index eba79cf9..8c11bc65 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListCollectionViewController.swift @@ -11,6 +11,8 @@ import Pachyderm class StatusActionAccountListCollectionViewController: UIViewController, CollectionViewController { + 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. @@ -21,12 +23,17 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect } 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(mastodonController: MastodonController) { + init(statusID: String, actionType: StatusActionAccountListViewController.ActionType, mastodonController: MastodonController) { + self.statusID = statusID + self.actionType = actionType self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) @@ -38,6 +45,18 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect } override func loadView() { + var accountsConfig = UICollectionLayoutListConfiguration(appearance: .grouped) + 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: @@ -51,7 +70,7 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect } return NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) case .accounts: - return NSCollectionLayoutSection.list(using: .init(appearance: .grouped), layoutEnvironment: environment) + return NSCollectionLayoutSection.list(using: accountsConfig, layoutEnvironment: environment) } } view = UICollectionView(frame: .zero, collectionViewLayout: layout) @@ -70,12 +89,17 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect cell.delegate = self cell.updateUI(accountID: item) } + 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 @@ -93,6 +117,12 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) + + if case .unloaded = state { + Task { + await loadAccounts() + } + } } func addStatus(_ status: StatusMO, state: CollapseState) { @@ -104,12 +134,115 @@ class StatusActionAccountListCollectionViewController: UIViewController, Collect dataSource.apply(snapshot, animatingDifferences: false) } - func addAccounts(_ accountIDs: [String], animated: Bool) { + 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) + case .reblog: + return Status.getReblogs(statusID, range: range) + } + } + + 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 { + try! await Task.sleep(nanoseconds: 2 * NSEC_PER_SEC) + 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 { @@ -120,6 +253,16 @@ extension StatusActionAccountListCollectionViewController { 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) { @@ -127,6 +270,8 @@ extension StatusActionAccountListCollectionViewController { return a == b case (.account(let a), .account(let b)): return a == b + case (.loadingIndicator, .loadingIndicator): + return true default: return false } @@ -140,12 +285,23 @@ extension StatusActionAccountListCollectionViewController { 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: @@ -154,6 +310,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg selected(status: id, state: state.copy()) case .account(let id): selected(account: id) + case .loadingIndicator: + return } } @@ -171,6 +329,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDeleg } actionProvider: { _ in UIMenu(children: self.actionsForProfile(accountID: id, source: .view(cell))) } + case .loadingIndicator: + return nil } } @@ -203,6 +363,8 @@ extension StatusActionAccountListCollectionViewController: UICollectionViewDragD let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: currentAccountID) activity.displaysAuxiliaryScene = true provider.registerObject(activity, visibility: .all) + case .loadingIndicator: + return [] } return [UIDragItem(itemProvider: provider)] } diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index 1150ebe0..eeab6247 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -95,8 +95,10 @@ class StatusActionAccountListViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - Task { - await loadStatus() + if case .unloaded = state { + Task { + await loadStatus() + } } } @@ -143,45 +145,13 @@ class StatusActionAccountListViewController: UIViewController { } private func statusLoaded(_ status: StatusMO) async { - let vc = StatusActionAccountListCollectionViewController(mastodonController: mastodonController) + let vc = StatusActionAccountListCollectionViewController(statusID: statusID, actionType: actionType, mastodonController: mastodonController) vc.addStatus(status, state: statusState) vc.showInacurateCountWarning = showInacurateCountWarning - state = .displaying(vc) - if let accountIDs { - vc.addAccounts(accountIDs, animated: false) - } else { - await loadAccounts(list: vc) - } - } - - private func loadAccounts(list: StatusActionAccountListCollectionViewController) async { - let request: Request<[Account]> - switch actionType { - case .favorite: - request = Status.getFavourites(statusID) - case .reblog: - request = Status.getReblogs(statusID) - } - do { - // TODO: pagination - let (accounts, _) = try await mastodonController.run(request) - - await withCheckedContinuation { continuation in - mastodonController.persistentContainer.addAll(accounts: accounts) { - continuation.resume() - } - } - - list.addAccounts(accounts.map(\.id), animated: true) - - } catch { - let config = ToastConfiguration(from: error, with: "Error Loading Accounts", in: self) { toast in - toast.dismissToast(animated: true) - await self.loadAccounts(list: list) - } - self.showToast(configuration: config, animated: true) + vc.setAccounts(accountIDs, animated: false) } + state = .displaying(vc) } private func showStatusNotFound() {