403 lines
16 KiB
Swift
403 lines
16 KiB
Swift
//
|
|
// TimelineLikeController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 9/19/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
import Combine
|
|
|
|
protocol TimelineLikeControllerDataSource<TimelineItem>: AnyObject {
|
|
associatedtype TimelineItem: Sendable
|
|
|
|
func loadInitial() async throws -> [TimelineItem]
|
|
func loadNewer() async throws -> [TimelineItem]
|
|
func canLoadOlder() async -> Bool
|
|
func loadOlder() async throws -> [TimelineItem]
|
|
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
|
|
}
|
|
|
|
@MainActor
|
|
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
|
associatedtype TimelineItem: Sendable
|
|
|
|
func handleAddLoadingIndicator() async
|
|
func handleRemoveLoadingIndicator() async
|
|
func handleLoadAllError(_ error: Swift.Error) async
|
|
func handleReplaceAllItems(_ timelineItems: [TimelineItem]) async
|
|
func handleLoadNewerError(_ error: Swift.Error) async
|
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
|
func handleLoadOlderError(_ error: Swift.Error) 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")
|
|
|
|
@MainActor
|
|
class TimelineLikeController<Item: Sendable> {
|
|
|
|
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
|
private unowned var dataSource: any TimelineLikeControllerDataSource<Item>
|
|
private let ownerType: String
|
|
|
|
@AsyncObservable private(set) var state = State.notLoadedInitial {
|
|
willSet {
|
|
guard state.canTransition(to: newValue) else {
|
|
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
|
|
fatalError("State \(state) cannot transition to \(newValue)")
|
|
}
|
|
logger.debug("\(self.ownerType, privacy: .public) State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
|
|
}
|
|
}
|
|
|
|
init(delegate: any TimelineLikeControllerDelegate<Item>, dataSource: any TimelineLikeControllerDataSource<Item>, ownerType: String) {
|
|
self.delegate = delegate
|
|
self.dataSource = dataSource
|
|
self.ownerType = ownerType
|
|
}
|
|
|
|
/// Waits for the controller to finish the current operation and arrive at the idle state.
|
|
///
|
|
/// If the current state is `notLoadedInitial`, this will wait until the controller
|
|
/// settles after the initial load.
|
|
func finishPendingOperation() async {
|
|
guard state != .idle else {
|
|
return
|
|
}
|
|
for await state in $state where state == .idle {
|
|
break
|
|
}
|
|
}
|
|
|
|
func loadInitial() async {
|
|
guard state == .notLoadedInitial || state == .idle else {
|
|
return
|
|
}
|
|
let token = LoadAttemptToken()
|
|
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
|
|
await emit(event: .addLoadingIndicator)
|
|
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
|
|
do {
|
|
let items = try await dataSource.loadInitial()
|
|
guard case .loadingInitial(token, _) = state else {
|
|
return
|
|
}
|
|
await emit(event: .replaceAllItems(items, token))
|
|
await emit(event: .removeLoadingIndicator)
|
|
state = .idle
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
await emit(event: .removeLoadingIndicator)
|
|
await emit(event: .loadAllError(error, token))
|
|
state = .notLoadedInitial
|
|
}
|
|
}
|
|
|
|
/// Used to indicate to the controller that the initial set of posts have been restored externally.
|
|
func restoreInitial(doRestore: () async -> Void) async {
|
|
guard state == .notLoadedInitial || state == .idle else {
|
|
return
|
|
}
|
|
let token = LoadAttemptToken()
|
|
state = .restoringInitial(token, hasAddedLoadingIndicator: false)
|
|
await emit(event: .addLoadingIndicator)
|
|
state = .restoringInitial(token, hasAddedLoadingIndicator: true)
|
|
await doRestore()
|
|
await emit(event: .removeLoadingIndicator)
|
|
state = .idle
|
|
}
|
|
|
|
func loadNewer() async {
|
|
guard state == .idle else {
|
|
return
|
|
}
|
|
let token = LoadAttemptToken()
|
|
state = .loadingNewer(token)
|
|
do {
|
|
let items = try await dataSource.loadNewer()
|
|
guard case .loadingNewer(token) = state else {
|
|
return
|
|
}
|
|
await emit(event: .prependItems(items, token))
|
|
state = .idle
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
await emit(event: .loadNewerError(error, token))
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
func loadOlder() async {
|
|
guard state == .idle else {
|
|
return
|
|
}
|
|
let token = LoadAttemptToken()
|
|
guard await dataSource.canLoadOlder(),
|
|
// Make sure we're still in the idle state before continuing on, since that may have chnaged while waiting for user input.
|
|
// If the load more cell appears, then the users scrolls up and back down, the VC may kick off a second loadOlder task
|
|
// but we only want one to proceed. The actor prevents a data race, and this prevents multiple simultaneousl loadOlder tasks from running.
|
|
state == .idle else {
|
|
return
|
|
}
|
|
state = .loadingOlder(token, hasAddedLoadingIndicator: false)
|
|
await emit(event: .addLoadingIndicator)
|
|
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
|
|
do {
|
|
let items = try await dataSource.loadOlder()
|
|
guard case .loadingOlder(token, _) = state else {
|
|
return
|
|
}
|
|
await emit(event: .appendItems(items, token))
|
|
await emit(event: .removeLoadingIndicator)
|
|
state = .idle
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
await emit(event: .removeLoadingIndicator)
|
|
await emit(event: .loadOlderError(error, token))
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
func fillGap(in direction: TimelineGapDirection) async {
|
|
guard state == .idle else {
|
|
return
|
|
}
|
|
let token = LoadAttemptToken()
|
|
state = .loadingGap(token, direction)
|
|
do {
|
|
let items = try await dataSource.loadGap(in: direction)
|
|
guard case .loadingGap(token, direction) = state else {
|
|
return
|
|
}
|
|
await emit(event: .fillGap(items, direction, token))
|
|
state = .idle
|
|
} catch is CancellationError {
|
|
return
|
|
} catch {
|
|
await emit(event: .loadGapError(error, direction, token))
|
|
state = .idle
|
|
}
|
|
}
|
|
|
|
private func transition(to newState: State) {
|
|
self.state = newState
|
|
}
|
|
|
|
private func emit(event: Event) async {
|
|
guard state.canEmit(event: event) else {
|
|
logger.error("\(self.ownerType, privacy: .public) State \(self.state.debugDescription, privacy: .public) cannot emit event: \(event.debugDescription, privacy: .public)")
|
|
fatalError("State \(state) cannot emit event: \(event)")
|
|
}
|
|
switch event {
|
|
case .addLoadingIndicator:
|
|
await delegate.handleAddLoadingIndicator()
|
|
case .removeLoadingIndicator:
|
|
await delegate.handleRemoveLoadingIndicator()
|
|
case .loadAllError(let error, _):
|
|
await delegate.handleLoadAllError(error)
|
|
case .replaceAllItems(let items, _):
|
|
await delegate.handleReplaceAllItems(items)
|
|
case .loadNewerError(let error, _):
|
|
await delegate.handleLoadNewerError(error)
|
|
case .prependItems(let items, _):
|
|
await delegate.handlePrependItems(items)
|
|
case .loadOlderError(let error, _):
|
|
await delegate.handleLoadOlderError(error)
|
|
case .appendItems(let items, _):
|
|
await delegate.handleAppendItems(items)
|
|
case .loadGapError(let error, let direction, _):
|
|
await delegate.handleLoadGapError(error, direction: direction)
|
|
case .fillGap(let items, let direction, _):
|
|
await delegate.handleFillGap(items, direction: direction)
|
|
}
|
|
}
|
|
|
|
enum State: Equatable, CustomDebugStringConvertible, Sendable {
|
|
case notLoadedInitial
|
|
case idle
|
|
case restoringInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
|
case loadingNewer(LoadAttemptToken)
|
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
|
case loadingGap(LoadAttemptToken, TimelineGapDirection)
|
|
|
|
var debugDescription: String {
|
|
switch self {
|
|
case .notLoadedInitial:
|
|
return "notLoadedInitial"
|
|
case .idle:
|
|
return "idle"
|
|
case .restoringInitial(let token, let hasAddedLoadingIndicator):
|
|
return "restoringInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
|
return "loadingInitial(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
|
case .loadingNewer(let token):
|
|
return "loadingNewer(\(ObjectIdentifier(token)))"
|
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
|
case .loadingGap(let token, let direction):
|
|
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
|
|
}
|
|
}
|
|
|
|
func canTransition(to: State) -> Bool {
|
|
switch self {
|
|
case .notLoadedInitial:
|
|
switch to {
|
|
case .restoringInitial, .loadingInitial(_, _):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .idle:
|
|
switch to {
|
|
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .restoringInitial(let token, let hasAddedLoadingIndicator):
|
|
return to == .idle || (!hasAddedLoadingIndicator && to == .restoringInitial(token, hasAddedLoadingIndicator: true))
|
|
case .loadingInitial(let token, let hasAddedLoadingIndicator):
|
|
return to == .notLoadedInitial || to == .idle || (!hasAddedLoadingIndicator && to == .loadingInitial(token, hasAddedLoadingIndicator: true))
|
|
case .loadingNewer(_):
|
|
return to == .idle
|
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
|
case .loadingGap(_, _):
|
|
return to == .idle
|
|
}
|
|
}
|
|
|
|
func canEmit(event: Event) -> Bool {
|
|
switch event {
|
|
case .addLoadingIndicator:
|
|
switch self {
|
|
case .restoringInitial(_, _), .loadingInitial(_, _), .loadingOlder(_, _):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .removeLoadingIndicator:
|
|
switch self {
|
|
case .restoringInitial(_, hasAddedLoadingIndicator: true), .loadingInitial(_, hasAddedLoadingIndicator: true), .loadingOlder(_, hasAddedLoadingIndicator: true):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .loadAllError(_, let token), .replaceAllItems(_, let token):
|
|
switch self {
|
|
case .loadingInitial(token, _):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .loadNewerError(_, let token), .prependItems(_, let token):
|
|
switch self {
|
|
case .loadingNewer(token):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .loadOlderError(_, let token), .appendItems(_, let token):
|
|
switch self {
|
|
case .loadingOlder(token, _):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token):
|
|
switch self {
|
|
case .loadingGap(token, direction):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Event: CustomDebugStringConvertible {
|
|
case addLoadingIndicator
|
|
case removeLoadingIndicator
|
|
case loadAllError(Error, LoadAttemptToken)
|
|
case replaceAllItems([Item], LoadAttemptToken)
|
|
case loadNewerError(Error, LoadAttemptToken)
|
|
case prependItems([Item], LoadAttemptToken)
|
|
case loadOlderError(Error, LoadAttemptToken)
|
|
case appendItems([Item], LoadAttemptToken)
|
|
case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
|
|
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
|
|
|
|
var debugDescription: String {
|
|
switch self {
|
|
case .addLoadingIndicator:
|
|
return "addLoadingIndicator"
|
|
case .removeLoadingIndicator:
|
|
return "removeLoadingIndicator"
|
|
case .loadAllError(let error, let token):
|
|
return "loadAllError(\(error), \(token))"
|
|
case .replaceAllItems(_, let token):
|
|
return "replaceAllItems(<omitted>, \(token))"
|
|
case .loadNewerError(let error, let token):
|
|
return "loadNewerError(\(error), \(token))"
|
|
case .prependItems(_, let token):
|
|
return "prependItems(<omitted>, \(token))"
|
|
case .loadOlderError(let error, let token):
|
|
return "loadOlderError(\(error), \(token))"
|
|
case .appendItems(_, let 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))"
|
|
}
|
|
}
|
|
}
|
|
|
|
final class LoadAttemptToken: Equatable, Sendable {
|
|
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
|
|
return lhs === rhs
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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
|
|
|
|
var accessibilityLabel: String {
|
|
switch self {
|
|
case .below:
|
|
return "Newer"
|
|
case .above:
|
|
return "Older"
|
|
}
|
|
}
|
|
}
|
|
|
|
// I would love to be able to do this with @Observable, but it's not clear how to do so.
|
|
@propertyWrapper
|
|
private class AsyncObservable<Value>: ObservableObject {
|
|
@Published var wrappedValue: Value
|
|
|
|
var projectedValue: AsyncPublisher<Published<Value>.Publisher> {
|
|
$wrappedValue.values
|
|
}
|
|
|
|
init(wrappedValue: Value) {
|
|
self.wrappedValue = wrappedValue
|
|
}
|
|
}
|