Timeline jump to present

This commit is contained in:
Shadowfacts 2022-11-18 20:49:15 -05:00
parent 8276e99d27
commit 0fddf94292
7 changed files with 145 additions and 85 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

@ -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

@ -10,7 +10,7 @@ import UIKit
class TimelineGapCollectionViewCell: UICollectionViewCell { class TimelineGapCollectionViewCell: UICollectionViewCell {
var fillGap: ((TimelineLikeController<TimelineViewController.TimelineItem>.GapDirection) -> Void)? var fillGap: ((TimelineGapDirection) -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)

View File

@ -16,14 +16,10 @@ 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>!
init(for timeline: Timeline, mastodonController: MastodonController!) { init(for timeline: Timeline, mastodonController: MastodonController!) {
@ -42,7 +38,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()
@ -66,9 +64,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
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,23 +85,31 @@ 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
}
override func viewDidLoad() {
super.viewDidLoad()
#if DEBUG #if DEBUG
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in // navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "square.split.1x2.fill"), primaryAction: UIAction(handler: { [unowned self] _ in
// var snapshot = self.dataSource.snapshot()
// let statuses = snapshot.itemIdentifiers(inSection: .statuses)
// if statuses.count > 20 {
// let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10))
// if !toRemove.isEmpty {
// print("REMOVING MIDDLE \(toRemove.count) STATUSES")
// snapshot.insertItems([.gap], beforeItem: toRemove.first!)
// snapshot.deleteItems(toRemove)
// self.dataSource.apply(snapshot)
// }
// }
// }))
navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "clock.arrow.circlepath"), primaryAction: UIAction(handler: { [unowned self] _ in
var snapshot = self.dataSource.snapshot() var snapshot = self.dataSource.snapshot()
let statuses = snapshot.itemIdentifiers(inSection: .statuses) let statuses = snapshot.itemIdentifiers(inSection: .statuses)
if statuses.count > 20 { if statuses.count > 20 {
let toRemove = Array(Array(statuses.dropFirst(10)).dropLast(10)) let toRemove = Array(statuses.dropLast(20))
if !toRemove.isEmpty { snapshot.deleteItems(toRemove)
print("REMOVING MIDDLE \(toRemove.count) STATUSES") self.dataSource.apply(snapshot)
snapshot.insertItems([.gap], beforeItem: toRemove.first!) // if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).first {
snapshot.deleteItems(toRemove) // self.controller.dataSource.state.newer = .after(id: id, count: nil)
self.dataSource.apply(snapshot) // }
}
} }
})) }))
#else #else
@ -167,7 +181,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
} }
Task { Task {
if case .notLoadedInitial = await controller.state { if case .notLoadedInitial = controller.state {
await controller.loadInitial() await controller.loadInitial()
} }
} }
@ -224,10 +238,53 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
@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,
case .status(id: let id, state: _) = dataSource.snapshot().itemIdentifiers(inSection: .statuses).first {
// if there's no overlap between presentItems and the existing items in the data source, prompt the user to scroll to present
if !presentItems.contains(id) {
var snapshot = self.dataSource.snapshot()
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
guard let item = currentItems.first,
case .status(id: id, state: _) = item else {
return
}
// remove any existing gap if there is one
if let index = currentItems.lastIndex(of: .gap) {
snapshot.deleteItems(Array(currentItems[index...]))
}
snapshot.insertItems([.gap], beforeItem: item)
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
snapshotView.layer.zPosition = 1000
snapshotView.frame = view.bounds
view.addSubview(snapshotView)
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
self.dataSource.apply(snapshot, animatingDifferences: false) {
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
snapshotView.removeFromSuperview()
}
var config = ToastConfiguration(title: "Jump to present")
config.edge = .top
config.systemImageName = "arrow.up"
config.dismissAutomaticallyAfter = 2
config.action = { [unowned self] toast in
toast.dismissToast(animated: true)
self.collectionView.scrollToTop()
}
self.showToast(configuration: config, animated: true)
}
}
} }
#if !targetEnvironment(macCatalyst) #if !targetEnvironment(macCatalyst)
collectionView.refreshControl?.endRefreshing() collectionView.refreshControl?.endRefreshing()
@ -318,50 +375,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)
@ -370,16 +425,16 @@ 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: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] { func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
guard let gapIndexPath = dataSource.indexPath(for: .gap) else { guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
throw Error.noGap throw Error.noGap
} }
@ -410,14 +465,16 @@ extension TimelineViewController {
// NOTE: closing the gap (if necessary) happens in handleFillGap // NOTE: closing the gap (if necessary) happens in handleFillGap
return await withCheckedContinuation { continuation in 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 handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<String>.GapDirection) async { func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
// TODO: better title, involving direction? // TODO: better title, involving direction?
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
@ -428,7 +485,7 @@ extension TimelineViewController {
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
} }
func handleFillGap(_ timelineItems: [String], direction: TimelineLikeController<String>.GapDirection) async { func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
let addedItems: Bool let addedItems: Bool
@ -496,14 +553,14 @@ extension TimelineViewController {
} }
} }
// if we didn't add any items, that implies the gap was removed, and we want to animate that to make clear what's happening // if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
if !addedItems { if !addedItems {
await apply(snapshot, animatingDifferences: true) var config = ToastConfiguration(title: "There's nothing in between!")
let config = ToastConfiguration(title: "There's nothing in between!") config.dismissAutomaticallyAfter = 2
showToast(configuration: config, animated: true) showToast(configuration: config, animated: true)
} else {
await apply(snapshot, animatingDifferences: false)
} }
await apply(snapshot, animatingDifferences: true)
} }
enum Error: TimelineLikeCollectionViewError { enum Error: TimelineLikeCollectionViewError {

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()
@ -180,14 +180,14 @@ extension TimelineLikeCollectionViewController {
await apply(snapshot, animatingDifferences: false) await apply(snapshot, animatingDifferences: false)
} }
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] { func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
fatalError("not supported by \(String(describing: type(of: self)))") fatalError("not supported by \(String(describing: type(of: self)))")
} }
func handleLoadGapError(_ error: Swift.Error, direction: TimelineLikeController<TimelineItem>.GapDirection) async { func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
} }
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) async { func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async {
fatalError("not supported by \(String(describing: type(of: self)))") fatalError("not supported by \(String(describing: type(of: self)))")
} }
} }
@ -217,7 +217,7 @@ extension TimelineLikeCollectionViewController {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell 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,11 +16,11 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
func loadNewer() async throws -> [TimelineItem] func loadNewer() async throws -> [TimelineItem]
func loadOlder() async throws -> [TimelineItem]
func canLoadOlder() async -> Bool func canLoadOlder() async -> Bool
func loadGap(in direction: TimelineLikeController<TimelineItem>.GapDirection) async throws -> [TimelineItem] func loadOlder() async throws -> [TimelineItem]
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
func handleAddLoadingIndicator() async func handleAddLoadingIndicator() async
func handleRemoveLoadingIndicator() async func handleRemoveLoadingIndicator() async
@ -30,15 +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: TimelineLikeController<TimelineItem>.GapDirection) async func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineLikeController<TimelineItem>.GapDirection) 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 {
@ -130,7 +131,7 @@ actor TimelineLikeController<Item> {
} }
} }
func fillGap(in direction: GapDirection) async { func fillGap(in direction: TimelineGapDirection) async {
guard state == .idle else { guard state == .idle else {
return return
} }
@ -190,7 +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, GapDirection) case loadingGap(LoadAttemptToken, TimelineGapDirection)
var debugDescription: String { var debugDescription: String {
switch self { switch self {
@ -293,8 +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, GapDirection, LoadAttemptToken) case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
case fillGap([Item], GapDirection, LoadAttemptToken) case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
var debugDescription: String { var debugDescription: String {
switch self { switch self {
@ -356,11 +357,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 enum TimelineGapDirection {
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap. /// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
case above case below
} /// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
case above
} }