// // BookmarksViewController.swift // Tusker // // Created by Shadowfacts on 12/15/19. // Copyright © 2019 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class BookmarksViewController: UIViewController, CollectionViewController { private static let pageSize = 40 let mastodonController: MastodonController var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! private var state = State.unloaded private var newer: RequestRange? private var older: RequestRange? init(mastodonController: MastodonController) { self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) title = NSLocalizedString("Bookmarks", comment: "bookmarks screen title") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in guard let item = dataSource.itemIdentifier(for: indexPath) else { return sectionConfig } var config = sectionConfig if item.hideIndicators { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets } return config } let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.contentInsetsReference = .readableContent } return section } 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) } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(id: let id, state: let state): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } } } override func viewDidLoad() { super.viewDidLoad() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .unloaded = state { Task { await loadInitial() } } } private func apply(snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { await Task { @MainActor in self.dataSource.apply(snapshot, animatingDifferences: animatingDifferences) }.value } @MainActor private func loadInitial() async { state = .loadingInitial var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.bookmarks]) snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) do { let req = Client.getBookmarks(range: .count(BookmarksViewController.pageSize)) let (statuses, pagination) = try await mastodonController.run(req) newer = pagination?.newer older = pagination?.older await mastodonController.persistentContainer.addAll(statuses: statuses) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.bookmarks]) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }) await apply(snapshot: snapshot, animatingDifferences: true) state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading Bookmarks", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadInitial() } showToast(configuration: config, animated: true) await apply(snapshot: NSDiffableDataSourceSnapshot(), animatingDifferences: false) state = .unloaded } } @MainActor private func loadOlder() async { guard case .loaded = state, let older else { return } state = .loadingOlder var snapshot = dataSource.snapshot() snapshot.appendItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) do { let req = Client.getBookmarks(range: older.withCount(BookmarksViewController.pageSize)) let (statuses, pagination) = try await mastodonController.run(req) self.older = pagination?.older await mastodonController.persistentContainer.addAll(statuses: statuses) snapshot.deleteItems([.loadingIndicator]) snapshot.appendItems(statuses.map { .status(id: $0.id, state: .unknown) }) await apply(snapshot: snapshot, animatingDifferences: true) state = .loaded } catch { let config = ToastConfiguration(from: error, with: "Error Loading Older Bookmarks", in: self) { [weak self] toast in toast.dismissToast(animated: true) await self?.loadOlder() } showToast(configuration: config, animated: true) snapshot.deleteItems([.loadingIndicator]) await apply(snapshot: snapshot, animatingDifferences: false) state = .loaded } } } extension BookmarksViewController { enum Section { case bookmarks } enum Item: Equatable, Hashable { case status(id: String, state: CollapseState) case loadingIndicator var hideIndicators: Bool { switch self { case .loadingIndicator: return true default: return false } } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case (.status(id: let a, state: _), .status(id: let b, state: _)): return a == b case (.loadingIndicator, .loadingIndicator): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case .status(id: let id, state: _): hasher.combine(0) hasher.combine(id) case .loadingIndicator: hasher.combine(1) } } } } extension BookmarksViewController { enum State { case unloaded case loadingInitial case loaded case loadingOlder } } extension BookmarksViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { if indexPath.section == 0, indexPath.row == collectionView.numberOfItems(inSection: indexPath.section) - 1 { Task { await self.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { if case .status(id: _, state: _) = dataSource.itemIdentifier(for: indexPath) { return true } else { return false } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { if case .status(id: let id, state: let state) = dataSource.itemIdentifier(for: indexPath) { selected(status: id, state: state.copy()) } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension BookmarksViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension BookmarksViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension BookmarksViewController: 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) { // bookmarks aren't filtered } } extension BookmarksViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension BookmarksViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }