// // TimelineLikeCollectionViewController.swift // Tusker // // Created by Shadowfacts on 9/24/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import UIKit import Combine @MainActor protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeControllerDelegate, TuskerNavigationDelegate { associatedtype Section: TimelineLikeCollectionViewSection associatedtype Item: TimelineLikeCollectionViewItem where Item.TimelineItem == Self.TimelineItem associatedtype Error: TimelineLikeCollectionViewError // this needs to be an IUO because it can't be set until after the super init is called, so that self can be passed as the delegate param var controller: TimelineLikeController! { get } var confirmLoadMore: PassthroughSubject { get } var collectionView: UICollectionView! { get } var dataSource: UICollectionViewDiffableDataSource! { get } var reconfigureVisibleItemsOnEndDecelerating: Bool { get set } } protocol TimelineLikeCollectionViewSection: Hashable, Sendable { static var entries: Self { get } static var footer: Self { get } } protocol TimelineLikeCollectionViewItem: Hashable, Sendable { associatedtype TimelineItem static var loadingIndicator: Self { get } static var confirmLoadMore: Self { get } @MainActor static func fromTimelineItem(_ item: TimelineItem) -> Self } // TODO: equatable might not be the best for this? protocol TimelineLikeCollectionViewError: Error, Equatable { static var allCaughtUp: Self { get } } // MARK: TimelineLikeControllerDelegate extension TimelineLikeCollectionViewController { func canLoadOlder() async -> Bool { if Preferences.shared.disableInfiniteScrolling { var snapshot = dataSource.snapshot() if !snapshot.itemIdentifiers.contains(.confirmLoadMore) { if !snapshot.sectionIdentifiers.contains(.footer) { snapshot.appendSections([.footer]) } snapshot.appendItems([.confirmLoadMore], toSection: .footer) await apply(snapshot, animatingDifferences: false) } for await _ in confirmLoadMore.values { return true } fatalError("unreachable") } else { return true } } func handleAddLoadingIndicator() async { if case .loadingInitial(_, _) = controller.state, let refreshControl = collectionView.refreshControl, refreshControl.isRefreshing { refreshControl.beginRefreshing() // if we're loading initial and the refresh control is already going, we don't need to add another indicator return } var snapshot = dataSource.snapshot() if !snapshot.sectionIdentifiers.contains(.footer) { snapshot.appendSections([.footer]) } if snapshot.itemIdentifiers.contains(.confirmLoadMore) { snapshot.reconfigureItems([.confirmLoadMore]) } else { snapshot.appendItems([.loadingIndicator], toSection: .footer) } await apply(snapshot, animatingDifferences: false) } func handleRemoveLoadingIndicator() async { if case .loadingInitial(_, _) = controller.state, let refreshControl = collectionView.refreshControl, refreshControl.isRefreshing { refreshControl.endRefreshing() return } let oldContentOffset = collectionView.contentOffset var snapshot = dataSource.snapshot() snapshot.deleteSections([.footer]) await apply(snapshot, animatingDifferences: false) // prevent the collection view from scrolling as we remove the loading indicator and add the timeline items collectionView.contentOffset = oldContentOffset } func handleLoadAllError(_ error: Swift.Error) async { let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.loadInitial() } } self.showToast(configuration: config, animated: true) } func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async { var snapshot = dataSource.snapshot() if snapshot.sectionIdentifiers.contains(.entries) { snapshot.deleteSections([.entries]) } snapshot.appendSections([.entries]) snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries) await apply(snapshot, animatingDifferences: false) } func handleLoadNewerError(_ error: Swift.Error) async { var config: ToastConfiguration if let error = error as? Self.Error, error == .allCaughtUp { // Reconfigure visible items to update timestamps. #if targetEnvironment(macCatalyst) let isRefreshing = false #else let isRefreshing = collectionView.refreshControl?.isRefreshing ?? false #endif if isRefreshing { reconfigureVisibleItemsOnEndDecelerating = true } else { reconfigureVisibleCells() } config = ToastConfiguration(title: "You're all caught up") config.edge = .top config.dismissAutomaticallyAfter = 2 config.action = { toast in toast.dismissToast(animated: true) } } else { config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.loadNewer() } } } self.showToast(configuration: config, animated: true) } func handlePrependItems(_ timelineItems: [TimelineItem]) async { let items = timelineItems.map { Item.fromTimelineItem($0) } var snapshot = dataSource.snapshot() let first = snapshot.itemIdentifiers(inSection: .entries).first if let first { snapshot.insertItems(items, beforeItem: first) } else { snapshot.appendItems(items, toSection: .entries) } await apply(snapshot, animatingDifferences: false) // todo: this won't work for cmd+r when not at top if let first, let indexPath = dataSource.indexPath(for: first) { // TODO: i can't tell if this actually works or not // maintain the current scroll position in the list (don't scroll to top) collectionView.scrollToItem(at: indexPath, at: .top, animated: false) } } func handleLoadOlderError(_ error: Swift.Error) async { let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] toast in toast.dismissToast(animated: true) Task { await self?.controller.loadOlder() } } self.showToast(configuration: config, animated: true) } func handleAppendItems(_ timelineItems: [TimelineItem]) async { var snapshot = dataSource.snapshot() // TODO: this might not be necessary, isn't the confirm item removed separately? if snapshot.itemIdentifiers.contains(.confirmLoadMore) { snapshot.deleteItems([.confirmLoadMore]) } snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries) await apply(snapshot, animatingDifferences: false) } func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] { fatalError("not supported by \(String(describing: type(of: self)))") } func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async { } func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async { fatalError("not supported by \(String(describing: type(of: self)))") } } extension TimelineLikeCollectionViewController { // apply(_:animatingDifferences:) is marked as nonisolated, so just awaiting it doesn't dispatch to the main thread, unlike other async @MainActor methods // but we always want to update the data source on the main thread for consistency, so this method does that func apply(_ snapshot: NSDiffableDataSourceSnapshot, animatingDifferences: Bool) async { await MainActor.run { dataSource?.apply(snapshot, animatingDifferences: animatingDifferences) } } @MainActor func reconfigureVisibleCells() { let items = collectionView.indexPathsForVisibleItems.compactMap { dataSource.itemIdentifier(for: $0) } if !items.isEmpty { var snapshot = dataSource.snapshot() snapshot.reconfigureItems(items) dataSource.apply(snapshot, animatingDifferences: false) } } func registerTimelineLikeCells() { collectionView.register(LoadingCollectionViewCell.self, forCellWithReuseIdentifier: "loadingIndicator") collectionView.register(ConfirmLoadMoreCollectionViewCell.self, forCellWithReuseIdentifier: "confirmLoadMore") } func loadingIndicatorCell(for indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "loadingIndicator", for: indexPath) as! LoadingCollectionViewCell cell.indicator.startAnimating() return cell } func confirmLoadMoreCell(for indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell cell.confirmLoadMore = self.confirmLoadMore Task { if case .loadingOlder(_, _) = controller.state { cell.isLoading = true } else { cell.isLoading = false } } return cell } }