diff --git a/Pachyderm/Sources/Pachyderm/Request/RequestRange.swift b/Pachyderm/Sources/Pachyderm/Request/RequestRange.swift index 4b7a71b9..cf2e3cba 100644 --- a/Pachyderm/Sources/Pachyderm/Request/RequestRange.swift +++ b/Pachyderm/Sources/Pachyderm/Request/RequestRange.swift @@ -11,7 +11,9 @@ import Foundation public enum RequestRange { case `default` case count(Int) + /// Chronologically immediately before the given ID case before(id: String, count: Int?) + /// Chronologically immediately after the given ID case after(id: String, count: Int?) } diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index c640bc9c..be39dc2e 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController { do { statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 } catch { - var snapshot = NSDiffableDataSourceSnapshot() + let snapshot = NSDiffableDataSourceSnapshot() await dataSource.apply(snapshot) let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in toast.dismissToast(animated: true) diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 995a4991..1b7df2c5 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie private var older: RequestRange? private var cancellables = Set() - var collectionView: UICollectionView { - view as! UICollectionView + var collectionView: UICollectionView! { + view as? UICollectionView } private(set) var dataSource: UICollectionViewDiffableDataSource! private(set) var headerCell: ProfileHeaderCollectionViewCell? @@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie } Task { - if case .notLoadedInitial = await controller.state { + if case .notLoadedInitial = controller.state { await load() } } diff --git a/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift b/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift index e9342674..ffbf4376 100644 --- a/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift +++ b/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift @@ -10,7 +10,7 @@ import UIKit class TimelineGapCollectionViewCell: UICollectionViewCell { - var fillGap: ((TimelineLikeController.GapDirection) -> Void)? + var fillGap: ((TimelineGapDirection) -> Void)? override init(frame: CGRect) { super.init(frame: frame) diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index b9b39a76..2df02199 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -16,14 +16,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() - private var newer: RequestRange? - private var older: RequestRange? // stored separately because i don't want to query the snapshot every time the user scrolls private var isShowingTimelineDescription = false - var collectionView: UICollectionView { - view as! UICollectionView - } + private(set) var collectionView: UICollectionView! private(set) var dataSource: UICollectionViewDiffableDataSource! init(for timeline: Timeline, mastodonController: MastodonController!) { @@ -42,7 +38,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro fatalError("init(coder:) has not been implemented") } - override func loadView() { + override func viewDidLoad() { + super.viewDidLoad() + var config = UICollectionLayoutListConfiguration(appearance: .plain) config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() @@ -66,9 +64,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return config } let layout = UICollectionViewCompositionalLayout.list(using: config) - view = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self collectionView.dragDelegate = self + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) registerTimelineLikeCells() collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") @@ -79,23 +85,31 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif - } - - override func viewDidLoad() { - super.viewDidLoad() #if DEBUG - navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in +// navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in +// var snapshot = self.dataSource.snapshot() +// let statuses = snapshot.itemIdentifiers(inSection: .statuses) +// if statuses.count > 20 { +// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10)) +// if !toRemove.isEmpty { +// print("REMOVING MIDDLE \(toRemove.count) STATUSES") +// snapshot.insertItems([.gap], beforeItem: toRemove.first!) +// snapshot.deleteItems(toRemove) +// self.dataSource.apply(snapshot) +// } +// } +// })) + navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in var snapshot = self.dataSource.snapshot() let statuses = snapshot.itemIdentifiers(inSection: .statuses) if statuses.count > 20 { - let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10)) - if !toRemove.isEmpty { - print("REMOVING MIDDLE \(toRemove.count) STATUSES") - snapshot.insertItems([.gap], beforeItem: toRemove.first!) - snapshot.deleteItems(toRemove) - self.dataSource.apply(snapshot) - } + let toRemove = Array(statuses.dropLast(20)) + snapshot.deleteItems(toRemove) + self.dataSource.apply(snapshot) +// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first { +// self.controller.dataSource.state.newer = .after(id: id, count: nil) +// } } })) #else @@ -167,7 +181,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } Task { - if case .notLoadedInitial = await controller.state { + if case .notLoadedInitial = controller.state { await controller.loadInitial() } } @@ -224,10 +238,53 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro @objc func refresh() { Task { - if case .notLoadedInitial = await controller.state { + if case .notLoadedInitial = controller.state { await controller.loadInitial() } else { - await controller.loadNewer() + // I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController + let (_, presentItems) = await (controller.loadNewer(), try? loadInitial()) + if let presentItems, + case .status(id: let id, state: _) = dataSource.snapshot().itemIdentifiers(inSection: .statuses).first { + // if there's no overlap between presentItems and the existing items in the data source, prompt the user to scroll to present + if !presentItems.contains(id) { + var snapshot = self.dataSource.snapshot() + let currentItems = snapshot.itemIdentifiers(inSection: .statuses) + guard let item = currentItems.first, + case .status(id: id, state: _) = item else { + return + } + + // remove any existing gap if there is one + if let index = currentItems.lastIndex(of: .gap) { + snapshot.deleteItems(Array(currentItems[index...])) + } + snapshot.insertItems([.gap], beforeItem: item) + snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap) + + // use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back + let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)! + snapshotView.layer.zPosition = 1000 + snapshotView.frame = view.bounds + view.addSubview(snapshotView) + + let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y + self.dataSource.apply(snapshot, animatingDifferences: false) { + self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset) + snapshotView.removeFromSuperview() + } + + var config = ToastConfiguration(title: "Jump to present") + config.edge = .top + config.systemImageName = "arrow.up" + config.dismissAutomaticallyAfter = 2 + config.action = { [unowned self] toast in + toast.dismissToast(animated: true) + + self.collectionView.scrollToTop() + } + self.showToast(configuration: config, animated: true) + } + } } #if !targetEnvironment(macCatalyst) collectionView.refreshControl?.endRefreshing() @@ -318,50 +375,48 @@ extension TimelineViewController { func loadInitial() async throws -> [TimelineItem] { try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) - guard let mastodonController else { - throw Error.noClient - } - let request = Client.getStatuses(timeline: timeline) let (statuses, _) = try await mastodonController.run(request) - if !statuses.isEmpty { - newer = .after(id: statuses.first!.id, count: nil) - older = .before(id: statuses.last!.id, count: nil) - } - - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) + continuation.resume() } } + + return statuses.map(\.id) } func loadNewer() async throws -> [TimelineItem] { - guard let newer else { + let statusesSection = dataSource.snapshot().indexOfSection(.statuses)! + guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else { throw Error.noNewer } + let newer = RequestRange.after(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: newer) let (statuses, _) = try await mastodonController.run(request) guard !statuses.isEmpty else { - throw Error.allCaughtUp + throw TimelineViewController.Error.allCaughtUp } - self.newer = .after(id: statuses.first!.id, count: nil) - - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) + continuation.resume() } } + + return statuses.map(\.id) } func loadOlder() async throws -> [TimelineItem] { - guard let older else { - throw Error.noOlder + let snapshot = dataSource.snapshot() + let statusesSection = snapshot.indexOfSection(.statuses)! + guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else { + throw Error.noNewer } + let older = RequestRange.before(id: id, count: nil) let request = Client.getStatuses(timeline: timeline, range: older) let (statuses, _) = try await mastodonController.run(request) @@ -370,16 +425,16 @@ extension TimelineViewController { return [] } - self.older = .before(id: statuses.last!.id, count: nil) - - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) + continuation.resume() } } + + return statuses.map(\.id) } - func loadGap(in direction: TimelineLikeController.GapDirection) async throws -> [TimelineItem] { + func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] { guard let gapIndexPath = dataSource.indexPath(for: .gap) else { throw Error.noGap } @@ -410,14 +465,16 @@ extension TimelineViewController { // NOTE: closing the gap (if necessary) happens in handleFillGap - return await withCheckedContinuation { continuation in + await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(statuses: statuses) { - continuation.resume(returning: statuses.map(\.id)) + continuation.resume() } } + + return statuses.map(\.id) } - func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController.GapDirection) async { + func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async { // TODO: better title, involving direction? let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in toast.dismissToast(animated: true) @@ -428,7 +485,7 @@ extension TimelineViewController { self.showToast(configuration: config, animated: true) } - func handleFillGap(_ timelineItems: [String], direction: TimelineLikeController.GapDirection) async { + func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async { var snapshot = dataSource.snapshot() let addedItems: Bool @@ -496,14 +553,14 @@ extension TimelineViewController { } } - // if we didn't add any items, that implies the gap was removed, and we want to animate that to make clear what's happening + // if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening if !addedItems { - await apply(snapshot, animatingDifferences: true) - let config = ToastConfiguration(title: "There's nothing in between!") + var config = ToastConfiguration(title: "There's nothing in between!") + config.dismissAutomaticallyAfter = 2 showToast(configuration: config, animated: true) - } else { - await apply(snapshot, animatingDifferences: false) } + + await apply(snapshot, animatingDifferences: true) } enum Error: TimelineLikeCollectionViewError { diff --git a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift index 3172eca8..8736d9d3 100644 --- a/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift +++ b/Tusker/Screens/Utilities/TimelineLikeCollectionViewController.swift @@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon var controller: TimelineLikeController! { get } var confirmLoadMore: PassthroughSubject { get } - var collectionView: UICollectionView { get } + var collectionView: UICollectionView! { get } var dataSource: UICollectionViewDiffableDataSource! { get } } @@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController { } func handleAddLoadingIndicator() async { - if case .loadingInitial(_, _) = await controller.state, + if case .loadingInitial(_, _) = controller.state, let refreshControl = collectionView.refreshControl, refreshControl.isRefreshing { refreshControl.beginRefreshing() @@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController { } func handleRemoveLoadingIndicator() async { - if case .loadingInitial(_, _) = await controller.state, + if case .loadingInitial(_, _) = controller.state, let refreshControl = collectionView.refreshControl, refreshControl.isRefreshing { refreshControl.endRefreshing() @@ -180,14 +180,14 @@ extension TimelineLikeCollectionViewController { await apply(snapshot, animatingDifferences: false) } - func loadGap(in direction: TimelineLikeController.GapDirection) async throws -> [TimelineItem] { + func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] { fatalError("not supported by \(String(describing: type(of: self)))") } - func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController.GapDirection) async { + func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async { } - func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController.GapDirection) async { + func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async { fatalError("not supported by \(String(describing: type(of: self)))") } } @@ -217,7 +217,7 @@ extension TimelineLikeCollectionViewController { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell cell.confirmLoadMore = self.confirmLoadMore Task { - if case .loadingOlder(_, _) = await controller.state { + if case .loadingOlder(_, _) = controller.state { cell.isLoading = true } else { cell.isLoading = false diff --git a/Tusker/TimelineLikeController.swift b/Tusker/TimelineLikeController.swift index f3395d70..d1b3307a 100644 --- a/Tusker/TimelineLikeController.swift +++ b/Tusker/TimelineLikeController.swift @@ -16,11 +16,11 @@ protocol TimelineLikeControllerDelegate: AnyObject { func loadNewer() async throws -> [TimelineItem] - func loadOlder() async throws -> [TimelineItem] - func canLoadOlder() async -> Bool - func loadGap(in direction: TimelineLikeController.GapDirection) async throws -> [TimelineItem] + func loadOlder() async throws -> [TimelineItem] + + func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] func handleAddLoadingIndicator() async func handleRemoveLoadingIndicator() async @@ -30,15 +30,16 @@ protocol TimelineLikeControllerDelegate: AnyObject { func handlePrependItems(_ timelineItems: [TimelineItem]) async func handleLoadOlderError(_ error: Swift.Error) async func handleAppendItems(_ timelineItems: [TimelineItem]) async - func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController.GapDirection) async - func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController.GapDirection) async + func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async + func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController") -actor TimelineLikeController { +@MainActor +class TimelineLikeController { - unowned var delegate: any TimelineLikeControllerDelegate + private unowned var delegate: any TimelineLikeControllerDelegate private(set) var state = State.notLoadedInitial { willSet { @@ -130,7 +131,7 @@ actor TimelineLikeController { } } - func fillGap(in direction: GapDirection) async { + func fillGap(in direction: TimelineGapDirection) async { guard state == .idle else { return } @@ -190,7 +191,7 @@ actor TimelineLikeController { case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingNewer(LoadAttemptToken) case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool) - case loadingGap(LoadAttemptToken, GapDirection) + case loadingGap(LoadAttemptToken, TimelineGapDirection) var debugDescription: String { switch self { @@ -293,8 +294,8 @@ actor TimelineLikeController { case prependItems([Item], LoadAttemptToken) case loadOlderError(Error, LoadAttemptToken) case appendItems([Item], LoadAttemptToken) - case loadGapError(Error, GapDirection, LoadAttemptToken) - case fillGap([Item], GapDirection, LoadAttemptToken) + case loadGapError(Error, TimelineGapDirection, LoadAttemptToken) + case fillGap([Item], TimelineGapDirection, LoadAttemptToken) var debugDescription: String { switch self { @@ -356,11 +357,11 @@ actor TimelineLikeController { } } - enum GapDirection { - /// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap. - case below - /// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap. - case above - } - +} + +enum TimelineGapDirection { + /// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap. + case below + /// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap. + case above }