From 8276e99d279ae6911aa740d6051cec04d91b5bbd Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Fri, 18 Nov 2022 17:29:55 -0500 Subject: [PATCH] Timeline gaps and gap filling --- Tusker.xcodeproj/project.pbxproj | 4 + .../Main/MainTabBarViewController.swift | 6 +- .../TimelineGapCollectionViewCell.swift | 58 ++++++ .../Timeline/TimelineViewController.swift | 168 +++++++++++++++++- ...TimelineLikeCollectionViewController.swift | 11 ++ Tusker/TimelineLikeController.swift | 56 +++++- 6 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index b21ddef2..c6a27eb7 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -270,6 +270,7 @@ D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; }; D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; }; D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; }; + D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; }; D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; }; D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; }; @@ -632,6 +633,7 @@ D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = ""; }; D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = ""; }; D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = ""; }; + D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = ""; }; D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = ""; }; D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = ""; }; D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = ""; }; @@ -924,6 +926,7 @@ D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */, D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */, D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */, + D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */, ); path = Timeline; sourceTree = ""; @@ -1832,6 +1835,7 @@ D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */, D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */, D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */, + D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */, D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */, D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */, D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */, diff --git a/Tusker/Screens/Main/MainTabBarViewController.swift b/Tusker/Screens/Main/MainTabBarViewController.swift index f4026d21..010c40e0 100644 --- a/Tusker/Screens/Main/MainTabBarViewController.swift +++ b/Tusker/Screens/Main/MainTabBarViewController.swift @@ -51,8 +51,12 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate { composePlaceholder.title = "Compose" composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil") + #if !DEBUG + #error("remove me") + #endif viewControllers = [ - embedInNavigationController(Tab.timelines.createViewController(mastodonController)), +// embedInNavigationController(Tab.timelines.createViewController(mastodonController)), + embedInNavigationController(TimelineViewController(for: .home, mastodonController: mastodonController)), embedInNavigationController(Tab.notifications.createViewController(mastodonController)), composePlaceholder, embedInNavigationController(Tab.explore.createViewController(mastodonController)), diff --git a/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift b/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift new file mode 100644 index 00000000..e9342674 --- /dev/null +++ b/Tusker/Screens/Timeline/TimelineGapCollectionViewCell.swift @@ -0,0 +1,58 @@ +// +// TimelineGapCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 11/16/22. +// Copyright © 2022 Shadowfacts. All rights reserved. +// + +import UIKit + +class TimelineGapCollectionViewCell: UICollectionViewCell { + + var fillGap: ((TimelineLikeController.GapDirection) -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .red + + var belowConfig = UIButton.Configuration.borderless() + belowConfig.title = "Below" + let below = UIButton(configuration: belowConfig) + below.addTarget(self, action: #selector(loadBelowPressed), for: .touchUpInside) + + var aboveConfig = UIButton.Configuration.borderless() + aboveConfig.title = "Above" + let above = UIButton(configuration: aboveConfig) + above.addTarget(self, action: #selector(loadAbovePressed), for: .touchUpInside) + + let stack = UIStackView(arrangedSubviews: [ + above, + below, + ]) + stack.axis = .vertical + stack.spacing = 4 + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + stack.topAnchor.constraint(equalTo: contentView.topAnchor), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func loadBelowPressed() { + fillGap?(.below) + } + + @objc private func loadAbovePressed() { + fillGap?(.above) + } + +} diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 3e365eab..b9b39a76 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -83,6 +83,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro override func viewDidLoad() { super.viewDidLoad() + + #if DEBUG + 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) + } + } + })) + #else + #error("remove me") + #endif } // separate method because InstanceTimelineViewController needs to be able to customize it @@ -95,6 +113,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in self.configureStatusCell(cell, id: item.0, state: item.1) } + let gapCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, _ in + cell.fillGap = { [unowned self] direction in + Task { + await self.controller.fillGap(in: direction) + } + } + } let timelineDescriptionCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in guard case .public(let local) = timeline else { fatalError() @@ -109,6 +134,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro switch itemIdentifier { case .status(id: let id, state: let state): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state)) + case .gap: + return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ()) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) case .confirmLoadMore: @@ -222,6 +249,7 @@ extension TimelineViewController { typealias TimelineItem = String // status ID case status(id: String, state: StatusState) + case gap case loadingIndicator case confirmLoadMore case publicTimelineDescription @@ -234,6 +262,8 @@ extension TimelineViewController { switch (lhs, rhs) { case let (.status(id: a, state: _), .status(id: b, state: _)): return a == b + case (.gap, .gap): + return true case (.loadingIndicator, .loadingIndicator): return true case (.confirmLoadMore, .confirmLoadMore): @@ -250,12 +280,14 @@ extension TimelineViewController { case .status(id: let id, state: _): hasher.combine(0) hasher.combine(id) - case .loadingIndicator: + case .gap: hasher.combine(1) - case .confirmLoadMore: + case .loadingIndicator: hasher.combine(2) - case .publicTimelineDescription: + case .confirmLoadMore: hasher.combine(3) + case .publicTimelineDescription: + hasher.combine(4) } } @@ -347,11 +379,139 @@ extension TimelineViewController { } } + func loadGap(in direction: TimelineLikeController.GapDirection) async throws -> [TimelineItem] { + guard let gapIndexPath = dataSource.indexPath(for: .gap) else { + throw Error.noGap + } + let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section) + let range: RequestRange + switch direction { + case .above: + guard gapIndexPath.row > 0, + case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else { + // not really the right error but w/e + throw Error.noGap + } + range = .before(id: id, count: nil) + case .below: + guard gapIndexPath.row < statusItemsCount - 1, + case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else { + throw Error.noGap + } + range = .after(id: id, count: nil) + } + + let request = Client.getStatuses(timeline: timeline, range: range) + let (statuses, _) = try await mastodonController.run(request) + + guard !statuses.isEmpty else { + return [] + } + + // NOTE: closing the gap (if necessary) happens in handleFillGap + + return await withCheckedContinuation { continuation in + mastodonController.persistentContainer.addAll(statuses: statuses) { + continuation.resume(returning: statuses.map(\.id)) + } + } + } + + func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController.GapDirection) async { + // TODO: better title, involving direction? + let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in + toast.dismissToast(animated: true) + Task { + await self?.controller.fillGap(in: direction) + } + } + self.showToast(configuration: config, animated: true) + } + + func handleFillGap(_ timelineItems: [String], direction: TimelineLikeController.GapDirection) async { + var snapshot = dataSource.snapshot() + let addedItems: Bool + + let statusItems = snapshot.itemIdentifiers(inSection: .statuses) + let gapIndex = statusItems.firstIndex(of: .gap)! + + switch direction { + case .above: + // dropFirst to remove .gap item + let afterGap = statusItems[gapIndex...].dropFirst().prefix(20) + precondition(!afterGap.contains(.gap)) + + // if there is any overlap, the first overlapping item will be the first item below the gap + var indexOfFirstTimelineItemExistingBelowGap: Int? + if case .status(id: let id, state: _) = afterGap.first { + indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id) + } + + // the end index of the range of timelineItems that don't yet exist in the data source + let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex + let toInsert = timelineItems[...GapDirection) async throws -> [TimelineItem] { + fatalError("not supported by \(String(describing: type(of: self)))") + } + + func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController.GapDirection) async { + } + + func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController.GapDirection) async { + fatalError("not supported by \(String(describing: type(of: self)))") + } } extension TimelineLikeCollectionViewController { diff --git a/Tusker/TimelineLikeController.swift b/Tusker/TimelineLikeController.swift index 8d05d566..f3395d70 100644 --- a/Tusker/TimelineLikeController.swift +++ b/Tusker/TimelineLikeController.swift @@ -20,6 +20,8 @@ protocol TimelineLikeControllerDelegate: AnyObject { func canLoadOlder() async -> Bool + func loadGap(in direction: TimelineLikeController.GapDirection) async throws -> [TimelineItem] + func handleAddLoadingIndicator() async func handleRemoveLoadingIndicator() async func handleLoadAllError(_ error: Swift.Error) async @@ -28,6 +30,8 @@ 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 } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController") @@ -126,6 +130,27 @@ actor TimelineLikeController { } } + func fillGap(in direction: GapDirection) async { + guard state == .idle else { + return + } + let token = LoadAttemptToken() + state = .loadingGap(token, direction) + do { + let items = try await delegate.loadGap(in: direction) + guard case .loadingGap(token, direction) = state else { + return + } + await emit(event: .fillGap(items, direction, token)) + state = .idle + } catch is CancellationError { + return + } catch { + await emit(event: .loadGapError(error, direction, token)) + state = .idle + } + } + private func transition(to newState: State) { self.state = newState } @@ -152,6 +177,10 @@ actor TimelineLikeController { await delegate.handleLoadOlderError(error) case .appendItems(let items, _): await delegate.handleAppendItems(items) + case .loadGapError(let error, let direction, _): + await delegate.handleLoadGapError(error, direction: direction) + case .fillGap(let items, let direction, _): + await delegate.handleFillGap(items, direction: direction) } } @@ -161,6 +190,7 @@ actor TimelineLikeController { case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool) case loadingNewer(LoadAttemptToken) case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool) + case loadingGap(LoadAttemptToken, GapDirection) var debugDescription: String { switch self { @@ -174,6 +204,8 @@ actor TimelineLikeController { return "loadingNewer(\(ObjectIdentifier(token)))" case .loadingOlder(let token, let hasAddedLoadingIndicator): return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))" + case .loadingGap(let token, let direction): + return "loadingGap(\(ObjectIdentifier(token)), \(direction)" } } @@ -188,7 +220,7 @@ actor TimelineLikeController { } case .idle: switch to { - case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _): + case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _): return true default: return false @@ -199,6 +231,8 @@ actor TimelineLikeController { return to == .idle case .loadingOlder(let token, let hasAddedLoadingIndicator): return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true)) + case .loadingGap(_, _): + return to == .idle } } @@ -239,6 +273,13 @@ actor TimelineLikeController { default: return false } + case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token): + switch self { + case .loadingGap(token, direction): + return true + default: + return false + } } } } @@ -252,6 +293,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) var debugDescription: String { switch self { @@ -271,6 +314,10 @@ actor TimelineLikeController { return "loadOlderError(\(error), \(token))" case .appendItems(_, let token): return "appendItems(, \(token))" + case .loadGapError(let error, let direction, let token): + return "loadGapError(\(error), \(direction), \(token))" + case .fillGap(_, let direction, let token): + return "loadGapError(, \(direction), \(token))" } } } @@ -309,4 +356,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 + } + }