Compare commits

...

5 Commits

10 changed files with 561 additions and 84 deletions

View File

@ -11,7 +11,9 @@ import Foundation
public enum RequestRange { public enum RequestRange {
case `default` case `default`
case count(Int) case count(Int)
/// Chronologically immediately before the given ID
case before(id: String, count: Int?) case before(id: String, count: Int?)
/// Chronologically immediately after the given ID
case after(id: String, count: Int?) case after(id: String, count: Int?)
} }

View File

@ -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 */,

View File

@ -99,6 +99,11 @@
value = "1" value = "1"
isEnabled = "NO"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
<EnvironmentVariable
key = "CG_CONTEXT_SHOW_BACKTRACE"
value = ""
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable <EnvironmentVariable
key = "CG_NUMERICS_SHOW_BACKTRACE" key = "CG_NUMERICS_SHOW_BACKTRACE"
value = "" value = ""

View File

@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController {
do { do {
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0 statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
} catch { } catch {
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
await dataSource.apply(snapshot) await dataSource.apply(snapshot)
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)

View File

@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private var older: RequestRange? private var older: RequestRange?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
var collectionView: UICollectionView { var collectionView: UICollectionView! {
view as! UICollectionView view as? UICollectionView
} }
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private(set) var headerCell: ProfileHeaderCollectionViewCell? private(set) var headerCell: ProfileHeaderCollectionViewCell?
@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
} }
Task { Task {
if case .notLoadedInitial = await controller.state { if case .notLoadedInitial = controller.state {
await load() await load()
} }
} }

View File

@ -0,0 +1,164 @@
//
// TimelineGapCollectionViewCell.swift
// Tusker
//
// Created by Shadowfacts on 11/16/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
class TimelineGapCollectionViewCell: UICollectionViewCell {
private(set) var direction = TimelineGapDirection.above
private let indicator = UIActivityIndicatorView(style: .medium)
private let chevronView = AnimatingChevronView()
override var isHighlighted: Bool {
didSet {
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
}
}
var showsIndicator: Bool = false {
didSet {
if showsIndicator {
indicator.isHidden = false
indicator.startAnimating()
} else {
indicator.isHidden = true
indicator.stopAnimating()
}
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemGroupedBackground
indicator.isHidden = true
indicator.color = .tintColor
let label = UILabel()
label.text = "Load more"
label.font = .preferredFont(forTextStyle: .headline)
label.textColor = .tintColor
chevronView.update(direction: .above)
let stack = UIStackView(arrangedSubviews: [
label,
chevronView,
])
stack.axis = .horizontal
stack.spacing = 8
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
indicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(indicator)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
contentView.heightAnchor.constraint(equalToConstant: 44),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
update()
}
func update() {
guard let superview = superview as? UICollectionView else {
return
}
let yInParent = frame.minY - superview.contentOffset.y
let centerInParent = yInParent + bounds.height / 2
let centerOfParent = superview.frame.height / 2
let newDirection: TimelineGapDirection
if centerInParent > centerOfParent {
newDirection = .above
} else {
newDirection = .below
}
if newDirection != direction {
direction = newDirection
chevronView.update(direction: newDirection)
}
}
}
private class AnimatingChevronView: UIView {
override class var layerClass: AnyClass { CAShapeLayer.self }
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
var animator: UIViewPropertyAnimator?
let upPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
let downPath: CGPath = {
let path = UIBezierPath()
let width: CGFloat = 20
let height: CGFloat = 25
path.move(to: CGPoint(x: 0, y: height / 2))
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
path.addLine(to: CGPoint(x: width, y: height / 2))
return path.cgPath
}()
init() {
super.init(frame: .zero)
shapeLayer.fillColor = nil
shapeLayer.strokeColor = tintColor.cgColor
shapeLayer.lineCap = .round
shapeLayer.lineJoin = .round
shapeLayer.lineWidth = 3
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func tintColorDidChange() {
super.tintColorDidChange()
shapeLayer.strokeColor = tintColor.cgColor
}
func update(direction: TimelineGapDirection) {
if animator?.isRunning == true {
animator!.stopAnimation(true)
}
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
if direction == .below {
self.shapeLayer.path = self.upPath
} else {
self.shapeLayer.path = self.downPath
}
}
animator!.startAnimation()
}
}

View File

@ -16,16 +16,14 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
private(set) var controller: TimelineLikeController<TimelineItem>! private(set) var controller: TimelineLikeController<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>() let confirmLoadMore = PassthroughSubject<Void, Never>()
private var newer: RequestRange?
private var older: RequestRange?
// stored separately because i don't want to query the snapshot every time the user scrolls // stored separately because i don't want to query the snapshot every time the user scrolls
private var isShowingTimelineDescription = false private var isShowingTimelineDescription = false
var collectionView: UICollectionView { private(set) var collectionView: UICollectionView!
view as! UICollectionView
}
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var contentOffsetObservation: NSKeyValueObservation?
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
self.timeline = timeline self.timeline = timeline
self.mastodonController = mastodonController self.mastodonController = mastodonController
@ -42,7 +40,9 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
override func loadView() { override func viewDidLoad() {
super.viewDidLoad()
var config = UICollectionLayoutListConfiguration(appearance: .plain) var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
@ -58,17 +58,24 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
if item.hideSeparators { if item.hideSeparators {
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden
} } else {
if case .status(_, _) = item {
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
} }
return config return config
} }
let layout = UICollectionViewCompositionalLayout.list(using: config) let layout = UICollectionViewCompositionalLayout.list(using: config)
view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self collectionView.delegate = self
collectionView.dragDelegate = 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() registerTimelineLikeCells()
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription") collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
@ -79,10 +86,15 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif #endif
}
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
override func viewDidLoad() { if let indexPath = self?.dataSource.indexPath(for: .gap),
super.viewDidLoad() let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
cell.update()
}
}
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
} }
// separate method because InstanceTimelineViewController needs to be able to customize it // separate method because InstanceTimelineViewController needs to be able to customize it
@ -95,6 +107,9 @@ 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> { cell, indexPath, _ in
cell.showsIndicator = false
}
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 +124,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:
@ -140,7 +157,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
Task { Task {
if case .notLoadedInitial = await controller.state { if case .notLoadedInitial = controller.state {
await controller.loadInitial() await controller.loadInitial()
} }
} }
@ -159,12 +176,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// pruneOffscreenRows()
}
private func removeTimelineDescriptionCell() { private func removeTimelineDescriptionCell() {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
snapshot.deleteSections([.header]) snapshot.deleteSections([.header])
@ -172,35 +183,30 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false isShowingTimelineDescription = false
} }
// private func pruneOffscreenRows() { @objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else { guard let scene = notification.object as? UIScene,
// return // view.window is nil when the VC is not on screen
// } view.window?.windowScene == scene else {
// var snapshot = dataSource.snapshot() return
// guard snapshot.indexOfSection(.statuses) != nil else { }
// return Task {
// } if case .idle = controller.state,
// let items = snapshot.itemIdentifiers(inSection: .statuses) let presentItems = try? await loadInitial() {
// let pageSize = 20 insertPresentItemsIfNecessary(presentItems)
// let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize }
// if numberOfPagesToPrune > 0 { }
// let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize)) }
// snapshot.deleteItems(itemsToRemove)
//
// dataSource.apply(snapshot, animatingDifferences: false)
//
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
// older = .before(id: id, count: nil)
// }
// }
// }
@objc func refresh() { @objc func refresh() {
Task { Task {
if case .notLoadedInitial = await controller.state { if case .notLoadedInitial = controller.state {
await controller.loadInitial() await controller.loadInitial()
} else { } 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 {
insertPresentItemsIfNecessary(presentItems)
}
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
@ -208,6 +214,68 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
} }
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
var snapshot = dataSource.snapshot()
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
if case .status(id: let firstID, state: _) = currentItems.first,
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
!presentItems.contains(firstID) {
let applySnapshotBeforeScrolling: Bool
// remove any existing gap, if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
let statusesSection = snapshot.indexOfSection(.statuses)!
if collectionView.indexPathsForVisibleItems.contains(IndexPath(row: index, section: statusesSection)) {
// the gap cell is on screen
applySnapshotBeforeScrolling = false
} else if let topMostVisibleCell = collectionView.indexPathsForVisibleItems.first(where: { $0.section == statusesSection }),
index < topMostVisibleCell.row {
// the gap cell is above the top, so applying the snapshot would remove the currently-viewed statuses
applySnapshotBeforeScrolling = false
} else {
// the gap cell is below the bottom of the screen
applySnapshotBeforeScrolling = true
}
} else {
// there is no existing gap
applySnapshotBeforeScrolling = true
}
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
if applySnapshotBeforeScrolling {
// 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 = 4
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
if !applySnapshotBeforeScrolling {
self.dataSource.apply(snapshot, animatingDifferences: false)
}
self.collectionView.scrollToTop()
}
self.showToast(configuration: config, animated: true)
}
}
} }
extension TimelineViewController { extension TimelineViewController {
@ -222,6 +290,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 +303,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 +321,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)
} }
} }
@ -270,7 +343,7 @@ extension TimelineViewController {
var isSelectable: Bool { var isSelectable: Bool {
switch self { switch self {
case .publicTimelineDescription, .status(id: _, state: _): case .publicTimelineDescription, .gap, .status(id: _, state: _):
return true return true
default: default:
return false return false
@ -286,50 +359,48 @@ extension TimelineViewController {
func loadInitial() async throws -> [TimelineItem] { func loadInitial() async throws -> [TimelineItem] {
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC) try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
guard let mastodonController else {
throw Error.noClient
}
let request = Client.getStatuses(timeline: timeline) let request = Client.getStatuses(timeline: timeline)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
if !statuses.isEmpty { await withCheckedContinuation { continuation in
newer = .after(id: statuses.first!.id, count: nil)
older = .before(id: statuses.last!.id, count: nil)
}
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
} }
func loadNewer() async throws -> [TimelineItem] { 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 throw Error.noNewer
} }
let newer = RequestRange.after(id: id, count: nil)
let request = Client.getStatuses(timeline: timeline, range: newer) let request = Client.getStatuses(timeline: timeline, range: newer)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
guard !statuses.isEmpty else { guard !statuses.isEmpty else {
throw Error.allCaughtUp throw TimelineViewController.Error.allCaughtUp
} }
self.newer = .after(id: statuses.first!.id, count: nil) await withCheckedContinuation { continuation in
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
} }
func loadOlder() async throws -> [TimelineItem] { func loadOlder() async throws -> [TimelineItem] {
guard let older else { let snapshot = dataSource.snapshot()
throw Error.noOlder 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 request = Client.getStatuses(timeline: timeline, range: older)
let (statuses, _) = try await mastodonController.run(request) let (statuses, _) = try await mastodonController.run(request)
@ -338,13 +409,170 @@ extension TimelineViewController {
return [] return []
} }
self.older = .before(id: statuses.last!.id, count: nil) await withCheckedContinuation { continuation in
return await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) { mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume(returning: statuses.map(\.id)) continuation.resume()
} }
} }
return statuses.map(\.id)
}
func loadGap(in direction: TimelineGapDirection) 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
await withCheckedContinuation { continuation in
mastodonController.persistentContainer.addAll(statuses: statuses) {
continuation.resume()
}
}
return statuses.map(\.id)
}
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)
Task {
await self?.controller.fillGap(in: direction)
}
}
self.showToast(configuration: config, animated: true)
}
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) 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])
}
await apply(snapshot, animatingDifferences: !addedItems)
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 addedItems {
// 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)
// Yes, these are all load bearing. Setting the contentOffset seems to cause the collection view to recalculate the size
// of some cells, thus changing the contentSize and the offset necessary to match to match the bottom offset.
// Three DispatchQueue.main.async's seems to be the fewest we can reliably get away with.
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
dataSource.apply(snapshot, animatingDifferences: false) {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
DispatchQueue.main.async {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
snapshotView.removeFromSuperview()
}
}
}
}
} else {
dataSource.apply(snapshot, animatingDifferences: true) {}
}
}
// 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 {
var config = ToastConfiguration(title: "There's nothing in between!")
config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true)
}
} }
enum Error: TimelineLikeCollectionViewError { enum Error: TimelineLikeCollectionViewError {
@ -352,6 +580,7 @@ extension TimelineViewController {
case noNewer case noNewer
case noOlder case noOlder
case allCaughtUp case allCaughtUp
case noGap
} }
} }
@ -384,6 +613,13 @@ 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 .gap:
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
cell.showsIndicator = true
Task {
await controller.fillGap(in: cell.direction)
cell.showsIndicator = false
}
case .loadingIndicator, .confirmLoadMore: case .loadingIndicator, .confirmLoadMore:
fatalError("unreachable") fatalError("unreachable")
} }

View File

@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
var controller: TimelineLikeController<TimelineItem>! { get } var controller: TimelineLikeController<TimelineItem>! { get }
var confirmLoadMore: PassthroughSubject<Void, Never> { get } var confirmLoadMore: PassthroughSubject<Void, Never> { get }
var collectionView: UICollectionView { get } var collectionView: UICollectionView! { get }
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get } var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
} }
@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController {
} }
func handleAddLoadingIndicator() async { func handleAddLoadingIndicator() async {
if case .loadingInitial(_, _) = await controller.state, if case .loadingInitial(_, _) = controller.state,
let refreshControl = collectionView.refreshControl, let refreshControl = collectionView.refreshControl,
refreshControl.isRefreshing { refreshControl.isRefreshing {
refreshControl.beginRefreshing() refreshControl.beginRefreshing()
@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController {
} }
func handleRemoveLoadingIndicator() async { func handleRemoveLoadingIndicator() async {
if case .loadingInitial(_, _) = await controller.state, if case .loadingInitial(_, _) = controller.state,
let refreshControl = collectionView.refreshControl, let refreshControl = collectionView.refreshControl,
refreshControl.isRefreshing { refreshControl.isRefreshing {
refreshControl.endRefreshing() refreshControl.endRefreshing()
@ -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: 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 { extension TimelineLikeCollectionViewController {
@ -206,7 +217,7 @@ extension TimelineLikeCollectionViewController {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
cell.confirmLoadMore = self.confirmLoadMore cell.confirmLoadMore = self.confirmLoadMore
Task { Task {
if case .loadingOlder(_, _) = await controller.state { if case .loadingOlder(_, _) = controller.state {
cell.isLoading = true cell.isLoading = true
} else { } else {
cell.isLoading = false cell.isLoading = false

View File

@ -16,9 +16,11 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
func loadNewer() async throws -> [TimelineItem] func loadNewer() async throws -> [TimelineItem]
func canLoadOlder() async -> Bool
func loadOlder() async throws -> [TimelineItem] func loadOlder() async throws -> [TimelineItem]
func canLoadOlder() async -> Bool func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
func handleAddLoadingIndicator() async func handleAddLoadingIndicator() async
func handleRemoveLoadingIndicator() async func handleRemoveLoadingIndicator() async
@ -28,13 +30,16 @@ 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: TimelineGapDirection) async
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async
} }
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController") private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
actor TimelineLikeController<Item> { @MainActor
class TimelineLikeController<Item> {
unowned var delegate: any TimelineLikeControllerDelegate<Item> private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.notLoadedInitial { private(set) var state = State.notLoadedInitial {
willSet { willSet {
@ -126,6 +131,27 @@ actor TimelineLikeController<Item> {
} }
} }
func fillGap(in direction: TimelineGapDirection) 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 +178,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 +191,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, TimelineGapDirection)
var debugDescription: String { var debugDescription: String {
switch self { switch self {
@ -174,6 +205,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 +221,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 +232,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 +274,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 +294,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, TimelineGapDirection, LoadAttemptToken)
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
var debugDescription: String { var debugDescription: String {
switch self { switch self {
@ -271,6 +315,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))"
} }
} }
} }
@ -310,3 +358,10 @@ actor TimelineLikeController<Item> {
} }
} }
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
}

View File

@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .secondarySystemBackground backgroundColor = .systemGroupedBackground
let label = UILabel() let label = UILabel()
label.text = "Infinite scrolling is off. Do you want to keep going?" label.text = "Infinite scrolling is off. Do you want to keep going?"