diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 7c435e9b..2b065ca0 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -35,6 +35,7 @@ D611C2D0232DC61100C86A49 /* HashtagTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */; }; D61A45E628DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */; }; D61A45E828DF477D002BE511 /* LoadingCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */; }; + D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */; }; D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */; }; D61AC1D8232EA42D00C54D2D /* InstanceTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */; }; D61AC1D9232EA42D00C54D2D /* InstanceTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */; }; @@ -380,6 +381,7 @@ D611C2CE232DC61100C86A49 /* HashtagTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HashtagTableViewCell.xib; sourceTree = ""; }; D61A45E528DC0F2F002BE511 /* ConfirmLoadMoreCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreCollectionViewCell.swift; sourceTree = ""; }; D61A45E728DF477D002BE511 /* LoadingCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingCollectionViewCell.swift; sourceTree = ""; }; + D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeCollectionViewController.swift; sourceTree = ""; }; D61AC1D4232E9FA600C54D2D /* InstanceSelectorTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceSelectorTableViewController.swift; sourceTree = ""; }; D61AC1D6232EA42D00C54D2D /* InstanceTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceTableViewCell.swift; sourceTree = ""; }; D61AC1D7232EA42D00C54D2D /* InstanceTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = InstanceTableViewCell.xib; sourceTree = ""; }; @@ -1298,6 +1300,7 @@ D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D6B22A0E2560D52D004D82EF /* TabbedPageViewController.swift */, D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */, + D61A45E928DF51EE002BE511 /* TimelineLikeCollectionViewController.swift */, D6DFC69D242C490400ACC392 /* TrackpadScrollGestureRecognizer.swift */, D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */, D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */, @@ -1900,6 +1903,7 @@ D66C900B28DAB7FD00217BF2 /* TimelineViewController.swift in Sources */, D6AEBB432321685E00E5038B /* OpenInSafariActivity.swift in Sources */, D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */, + D61A45EA28DF51EE002BE511 /* TimelineLikeCollectionViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D67C1795266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift in Sources */, D627FF76217E923E00CC0648 /* DraftsManager.swift in Sources */, diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index d41e9cb9..7d41b321 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -14,20 +14,19 @@ import SwiftSoup // TODO: gonna need a thing to replicate all of EnhancedTableViewController -class TimelineViewController: UIViewController { - +class TimelineViewController: UIViewController, TimelineLikeCollectionViewController { let timeline: Timeline weak var mastodonController: MastodonController! - private var controller: TimelineLikeController! - private var confirmLoadMore = PassthroughSubject() + private(set) var controller: TimelineLikeController! + let confirmLoadMore = PassthroughSubject() private var newer: RequestRange? private var older: RequestRange? - private var collectionView: UICollectionView { + var collectionView: UICollectionView { view as! UICollectionView } - private var dataSource: UICollectionViewDiffableDataSource! + private(set) var dataSource: UICollectionViewDiffableDataSource! init(for timeline: Timeline, mastodonController: MastodonController!) { self.timeline = timeline @@ -61,12 +60,12 @@ class TimelineViewController: UIViewController { view = UICollectionView(frame: .zero, collectionViewLayout: layout) // TODO: delegates collectionView.delegate = self -// collectionView.dragDelegate = self + // collectionView.dragDelegate = self dataSource = createDataSource() applyInitialSnapshot() - #if !targetEnvironment(macCatalyst) +#if !targetEnvironment(macCatalyst) let refreshControl = UIRefreshControl(frame: .zero, primaryAction: UIAction(handler: { [unowned self] _ in Task { await self.controller.loadNewer() @@ -74,7 +73,7 @@ class TimelineViewController: UIViewController { } })) collectionView.refreshControl = refreshControl - #endif +#endif } override func viewDidLoad() { @@ -135,18 +134,26 @@ class TimelineViewController: UIViewController { } extension TimelineViewController { - enum Section: Hashable { + enum Section: TimelineLikeCollectionViewSection { case header case statuses case footer + + static var entries: Self { .statuses } } - enum Item: Hashable { + enum Item: TimelineLikeCollectionViewItem { + typealias TimelineItem = String // status ID + case status(id: String, state: StatusState) case loadingIndicator case confirmLoadMore // // TODO: remove local param from this // case publicTimelineDescription(local: Bool) + static func fromTimelineItem(_ id: String) -> Self { + return .status(id: id, state: .unknown) + } + static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { case let (.status(id: a, state: _), .status(id: b, state: _)): @@ -188,7 +195,8 @@ extension TimelineViewController { } } -extension TimelineViewController: TimelineLikeControllerDelegate { +// MARK: - TimelineLikeControllerDelegate +extension TimelineViewController { typealias TimelineItem = String // status ID func loadInitial() async throws -> [TimelineItem] { @@ -255,120 +263,7 @@ extension TimelineViewController: TimelineLikeControllerDelegate { } } - 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 dataSource.apply(snapshot, animatingDifferences: false) - } - for await _ in self.confirmLoadMore.values { - return true - } - fatalError("unreachable") - } else { - return true - } - } - - func handleEvent(_ event: TimelineLikeController.Event) async { - switch event { - case .addLoadingIndicator: - 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 dataSource.apply(snapshot, animatingDifferences: false) - - case .removeLoadingIndicator: - let oldContentOffset = collectionView.contentOffset - var snapshot = dataSource.snapshot() - snapshot.deleteSections([.footer]) - await dataSource.apply(snapshot, animatingDifferences: false) - collectionView.contentOffset = oldContentOffset - - case .loadAllError(let error, _): - let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] (toast: ToastView) in - toast.dismissToast(animated: true) - Task { - await self?.controller.loadInitial() - } - } - self.showToast(configuration: config, animated: true) - - case .replaceAllItems(let ids, _): - var snapshot = dataSource.snapshot() - if snapshot.sectionIdentifiers.contains(.statuses) { - snapshot.deleteSections([.statuses]) - } - snapshot.appendSections([.statuses]) - snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses) - await dataSource.apply(snapshot, animatingDifferences: false) - - case .loadNewerError(Error.allCaughtUp, _): - var config = ToastConfiguration(title: "You're all caught up") - config.edge = .top - config.dismissAutomaticallyAfter = 2 - config.action = { (toast) in - toast.dismissToast(animated: true) - } - self.showToast(configuration: config, animated: true) - - case .loadNewerError(let error, _): - let config = ToastConfiguration(from: error, with: "Error Loading Newer", in: self) { [weak self] (toast: ToastView) in - toast.dismissToast(animated: true) - Task { - await self?.controller.loadNewer() - } - } - self.showToast(configuration: config, animated: true) - - case .prependItems(let ids, _): - var snapshot = dataSource.snapshot() - let items = ids.map { Item.status(id: $0, state: .unknown) } - let first = snapshot.itemIdentifiers(inSection: .statuses).first - if let first { - snapshot.insertItems(items, beforeItem: first) - } else { - snapshot.appendItems(items, toSection: .statuses) - } - await dataSource.apply(snapshot, animatingDifferences: false) - - if let first, - let indexPath = dataSource.indexPath(for: first) { - // TODO: i can't tell if this actually works or not - // maintain the current position in the list (don't scroll to top) - collectionView.scrollToItem(at: indexPath, at: .top, animated: false) - } - - case .loadOlderError(let error, _): - let config = ToastConfiguration(from: error, with: "Error Loading Older", in: self) { [weak self] (toast: ToastView) in - toast.dismissToast(animated: true) - Task { - await self?.controller.loadOlder() - } - } - self.showToast(configuration: config, animated: true) - - case .appendItems(let ids, _): - var snapshot = dataSource.snapshot() - if snapshot.itemIdentifiers.contains(.confirmLoadMore) { - snapshot.deleteItems([.confirmLoadMore]) - } - snapshot.appendItems(ids.map { .status(id: $0, state: .unknown) }, toSection: .statuses) - await dataSource.apply(snapshot, animatingDifferences: false) - } - } - - enum Error: Swift.Error { + enum Error: TimelineLikeCollectionViewError { case noClient case noNewer case noOlder @@ -391,6 +286,3 @@ extension TimelineViewController: UICollectionViewDelegate { } } } - -extension TimelineViewController: ToastableViewController { -} diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift new file mode 100644 index 00000000..ee1e5383 --- /dev/null +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -0,0 +1,187 @@ +// +// 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, ToastableViewController { + 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 } +} + +protocol TimelineLikeCollectionViewSection: Hashable { + static var entries: Self { get } + static var footer: Self { get } +} + +protocol TimelineLikeCollectionViewItem: Hashable { + associatedtype TimelineItem + + static var loadingIndicator: Self { get } + static var confirmLoadMore: Self { get } + + static func fromTimelineItem(_ item: TimelineItem) -> Self +} + +// TODO: equatable might not be the best for this? +protocol TimelineLikeCollectionViewError: Error, Equatable { + static var allCaughtUp: Self { get } +} + +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 dataSource.apply(snapshot, animatingDifferences: false) + } + for await _ in confirmLoadMore.values { + return true + } + fatalError("unreachable") + } else { + return true + } + } + + func handleEvent(_ event: TimelineLikeController.Event) async { + switch event { + case .addLoadingIndicator: + await handleAddLoadingIndicator() + case .removeLoadingIndicator: + await handleRemoveLoadingIndicator() + case .loadAllError(let error, _): + await handleLoadAllError(error) + case .replaceAllItems(let items, _): + await handleReplaceAllItems(items) + case .loadNewerError(let error, _): + await handleLoadNewerError(error) + case .prependItems(let items, _): + await handlePrependItems(items) + case .loadOlderError(let error, _): + await handleLoadOlderError(error) + case .appendItems(let items, _): + await handleAppendItems(items) + } + } + + func handleAddLoadingIndicator() async { + 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 dataSource.apply(snapshot, animatingDifferences: false) + } + + func handleRemoveLoadingIndicator() async { + let oldContentOffset = collectionView.contentOffset + var snapshot = dataSource.snapshot() + snapshot.deleteSections([.footer]) + await dataSource.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 dataSource.apply(snapshot, animatingDifferences: false) + } + + func handleLoadNewerError(_ error: Swift.Error) async { + var config: ToastConfiguration + if let error = error as? Self.Error, + error == .allCaughtUp { + 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 dataSource.apply(snapshot, animatingDifferences: false) + + 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 dataSource.apply(snapshot, animatingDifferences: false) + } +}