Timeline gaps and gap filling
This commit is contained in:
parent
a5ad8e43b1
commit
8276e99d27
|
@ -270,6 +270,7 @@
|
||||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.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 */; };
|
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.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 = "<group>"; };
|
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
||||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||||
|
@ -924,6 +926,7 @@
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
||||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||||
|
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
||||||
);
|
);
|
||||||
path = Timeline;
|
path = Timeline;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1832,6 +1835,7 @@
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
||||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||||
|
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
|
||||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||||
|
|
|
@ -51,8 +51,12 @@ class MainTabBarViewController: UITabBarController, UITabBarControllerDelegate {
|
||||||
composePlaceholder.title = "Compose"
|
composePlaceholder.title = "Compose"
|
||||||
composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil")
|
composePlaceholder.tabBarItem.image = UIImage(systemName: "pencil")
|
||||||
|
|
||||||
|
#if !DEBUG
|
||||||
|
#error("remove me")
|
||||||
|
#endif
|
||||||
viewControllers = [
|
viewControllers = [
|
||||||
embedInNavigationController(Tab.timelines.createViewController(mastodonController)),
|
// embedInNavigationController(Tab.timelines.createViewController(mastodonController)),
|
||||||
|
embedInNavigationController(TimelineViewController(for: .home, mastodonController: mastodonController)),
|
||||||
embedInNavigationController(Tab.notifications.createViewController(mastodonController)),
|
embedInNavigationController(Tab.notifications.createViewController(mastodonController)),
|
||||||
composePlaceholder,
|
composePlaceholder,
|
||||||
embedInNavigationController(Tab.explore.createViewController(mastodonController)),
|
embedInNavigationController(Tab.explore.createViewController(mastodonController)),
|
||||||
|
|
|
@ -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<TimelineViewController.TimelineItem>.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -83,6 +83,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.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
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -95,6 +113,13 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||||
}
|
}
|
||||||
|
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
|
||||||
|
cell.fillGap = { [unowned self] direction in
|
||||||
|
Task {
|
||||||
|
await self.controller.fillGap(in: direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||||
guard case .public(let local) = timeline else {
|
guard case .public(let local) = timeline else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -109,6 +134,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, state: let state):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||||
|
case .gap:
|
||||||
|
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
|
@ -222,6 +249,7 @@ extension TimelineViewController {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: StatusState)
|
||||||
|
case gap
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
case publicTimelineDescription
|
case publicTimelineDescription
|
||||||
|
@ -234,6 +262,8 @@ extension TimelineViewController {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||||
return a == b
|
return a == b
|
||||||
|
case (.gap, .gap):
|
||||||
|
return true
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
return true
|
return true
|
||||||
case (.confirmLoadMore, .confirmLoadMore):
|
case (.confirmLoadMore, .confirmLoadMore):
|
||||||
|
@ -250,12 +280,14 @@ extension TimelineViewController {
|
||||||
case .status(id: let id, state: _):
|
case .status(id: let id, state: _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .loadingIndicator:
|
case .gap:
|
||||||
hasher.combine(1)
|
hasher.combine(1)
|
||||||
case .confirmLoadMore:
|
case .loadingIndicator:
|
||||||
hasher.combine(2)
|
hasher.combine(2)
|
||||||
case .publicTimelineDescription:
|
case .confirmLoadMore:
|
||||||
hasher.combine(3)
|
hasher.combine(3)
|
||||||
|
case .publicTimelineDescription:
|
||||||
|
hasher.combine(4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -347,11 +379,139 @@ extension TimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadGap(in direction: TimelineLikeController<TimelineItem>.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<String>.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<String>.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[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
||||||
|
if toInsert.isEmpty {
|
||||||
|
addedItems = false
|
||||||
|
} else {
|
||||||
|
snapshot.insertItems(toInsert, beforeItem: .gap)
|
||||||
|
addedItems = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there's any overlap between the items we loaded to insert above the gap
|
||||||
|
// and the items that already exist below the gap, we've completely filled the gap
|
||||||
|
if indexOfFirstTimelineItemExistingBelowGap != nil {
|
||||||
|
snapshot.deleteItems([.gap])
|
||||||
|
}
|
||||||
|
|
||||||
|
case .below:
|
||||||
|
let beforeGap = statusItems[..<gapIndex].suffix(20)
|
||||||
|
precondition(!beforeGap.contains(.gap))
|
||||||
|
|
||||||
|
// if there's any overlap, last overlapping item will be the last item below the gap
|
||||||
|
var indexOfLastTimelineItemExistingAboveGap: Int?
|
||||||
|
if case .status(id: let id, state: _) = beforeGap.last {
|
||||||
|
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the start index of the reange of timeline items that don't yet exist in the data source
|
||||||
|
let startIndex: Int
|
||||||
|
if let indexOfLastTimelineItemExistingAboveGap {
|
||||||
|
// index(after:) because the beginning of the range is inclusive, but we don't want the item at indexOfLastTimelineItemExistingAboveGap
|
||||||
|
startIndex = timelineItems.index(after: indexOfLastTimelineItemExistingAboveGap)
|
||||||
|
} else {
|
||||||
|
startIndex = timelineItems.startIndex
|
||||||
|
}
|
||||||
|
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
||||||
|
if toInsert.isEmpty {
|
||||||
|
addedItems = false
|
||||||
|
} else {
|
||||||
|
snapshot.insertItems(toInsert, afterItem: .gap)
|
||||||
|
addedItems = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there's any overlap between the items we loaded to insert below the gap
|
||||||
|
// and the items that already exist above the gap, we've completely filled the gap
|
||||||
|
if indexOfLastTimelineItemExistingAboveGap != nil {
|
||||||
|
snapshot.deleteItems([.gap])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 !addedItems {
|
||||||
|
await apply(snapshot, animatingDifferences: true)
|
||||||
|
let config = ToastConfiguration(title: "There's nothing in between!")
|
||||||
|
showToast(configuration: config, animated: true)
|
||||||
|
} else {
|
||||||
|
await apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
case noClient
|
case noClient
|
||||||
case noNewer
|
case noNewer
|
||||||
case noOlder
|
case noOlder
|
||||||
case allCaughtUp
|
case allCaughtUp
|
||||||
|
case noGap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -384,7 +544,7 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
selected(status: status.reblog?.id ?? id, state: state.copy())
|
||||||
case .loadingIndicator, .confirmLoadMore:
|
case .gap, .loadingIndicator, .confirmLoadMore:
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -179,6 +179,17 @@ extension TimelineLikeCollectionViewController {
|
||||||
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
||||||
await apply(snapshot, animatingDifferences: false)
|
await apply(snapshot, animatingDifferences: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] {
|
||||||
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<TimelineItem>.GapDirection) async {
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) async {
|
||||||
|
fatalError("not supported by \(String(describing: type(of: self)))")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineLikeCollectionViewController {
|
extension TimelineLikeCollectionViewController {
|
||||||
|
|
|
@ -20,6 +20,8 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
|
|
||||||
func canLoadOlder() async -> Bool
|
func canLoadOlder() async -> Bool
|
||||||
|
|
||||||
|
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem]
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async
|
func handleAddLoadingIndicator() async
|
||||||
func handleRemoveLoadingIndicator() async
|
func handleRemoveLoadingIndicator() async
|
||||||
func handleLoadAllError(_ error: Swift.Error) async
|
func handleLoadAllError(_ error: Swift.Error) async
|
||||||
|
@ -28,6 +30,8 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadOlderError(_ error: Swift.Error) async
|
func handleLoadOlderError(_ error: Swift.Error) async
|
||||||
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
||||||
|
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<TimelineItem>.GapDirection) async
|
||||||
|
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) async
|
||||||
}
|
}
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||||
|
@ -126,6 +130,27 @@ actor TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private func transition(to newState: State) {
|
||||||
self.state = newState
|
self.state = newState
|
||||||
}
|
}
|
||||||
|
@ -152,6 +177,10 @@ actor TimelineLikeController<Item> {
|
||||||
await delegate.handleLoadOlderError(error)
|
await delegate.handleLoadOlderError(error)
|
||||||
case .appendItems(let items, _):
|
case .appendItems(let items, _):
|
||||||
await delegate.handleAppendItems(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<Item> {
|
||||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingNewer(LoadAttemptToken)
|
case loadingNewer(LoadAttemptToken)
|
||||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
|
case loadingGap(LoadAttemptToken, GapDirection)
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -174,6 +204,8 @@ actor TimelineLikeController<Item> {
|
||||||
return "loadingNewer(\(ObjectIdentifier(token)))"
|
return "loadingNewer(\(ObjectIdentifier(token)))"
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
|
case .loadingGap(let token, let direction):
|
||||||
|
return "loadingGap(\(ObjectIdentifier(token)), \(direction)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,7 +220,7 @@ actor TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
case .idle:
|
case .idle:
|
||||||
switch to {
|
switch to {
|
||||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
|
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -199,6 +231,8 @@ actor TimelineLikeController<Item> {
|
||||||
return to == .idle
|
return to == .idle
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||||
|
case .loadingGap(_, _):
|
||||||
|
return to == .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -239,6 +273,13 @@ actor TimelineLikeController<Item> {
|
||||||
default:
|
default:
|
||||||
return false
|
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<Item> {
|
||||||
case prependItems([Item], LoadAttemptToken)
|
case prependItems([Item], LoadAttemptToken)
|
||||||
case loadOlderError(Error, LoadAttemptToken)
|
case loadOlderError(Error, LoadAttemptToken)
|
||||||
case appendItems([Item], LoadAttemptToken)
|
case appendItems([Item], LoadAttemptToken)
|
||||||
|
case loadGapError(Error, GapDirection, LoadAttemptToken)
|
||||||
|
case fillGap([Item], GapDirection, LoadAttemptToken)
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -271,6 +314,10 @@ actor TimelineLikeController<Item> {
|
||||||
return "loadOlderError(\(error), \(token))"
|
return "loadOlderError(\(error), \(token))"
|
||||||
case .appendItems(_, let token):
|
case .appendItems(_, let token):
|
||||||
return "appendItems(<omitted>, \(token))"
|
return "appendItems(<omitted>, \(token))"
|
||||||
|
case .loadGapError(let error, let direction, let token):
|
||||||
|
return "loadGapError(\(error), \(direction), \(token))"
|
||||||
|
case .fillGap(_, let direction, let token):
|
||||||
|
return "loadGapError(<omitted>, \(direction), \(token))"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -309,4 +356,11 @@ actor TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue