Tusker/Tusker/TimelineLikeController.swift

307 lines
12 KiB
Swift

//
// TimelineLikeController.swift
// Tusker
//
// Created by Shadowfacts on 9/19/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import OSLog
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
associatedtype TimelineItem
func loadInitial() async throws -> [TimelineItem]
func loadNewer() async throws -> [TimelineItem]
func loadOlder() async throws -> [TimelineItem]
func canLoadOlder() async -> Bool
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
}
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
actor TimelineLikeController<Item> {
unowned var delegate: any TimelineLikeControllerDelegate<Item>
private(set) var state = State.notLoadedInitial {
willSet {
guard state.canTransition(to: newValue) else {
logger.error("State \(self.state.debugDescription, privacy: .public) cannot transition to \(newValue.debugDescription, privacy: .public)")
fatalError("State \(state) cannot transition to \(newValue)")
}
logger.debug("State: \(self.state.debugDescription, privacy: .public) -> \(newValue.debugDescription, privacy: .public)")
}
}
init(delegate: any TimelineLikeControllerDelegate<Item>) {
self.delegate = delegate
}
func loadInitial() async {
guard state == .notLoadedInitial || state == .idle else {
return
}
let token = LoadAttemptToken()
state = .loadingInitial(token, hasAddedLoadingIndicator: false)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingInitial(token, hasAddedLoadingIndicator: true))
do {
let items = try await delegate.loadInitial()
guard case .loadingInitial(token, _) = state else {
return
}
await loadingIndicator.end()
await emit(event: .replaceAllItems(items, token))
state = .idle
} catch {
await loadingIndicator.end()
await emit(event: .loadAllError(error, token))
state = .idle
}
}
func loadNewer() async {
guard state == .idle else {
return
}
let token = LoadAttemptToken()
state = .loadingNewer(token)
do {
let items = try await delegate.loadNewer()
guard case .loadingNewer(token) = state else {
return
}
await emit(event: .prependItems(items, token))
state = .idle
} catch {
await emit(event: .loadNewerError(error, token))
state = .idle
}
}
func loadOlder() async {
guard state == .idle else {
return
}
let token = LoadAttemptToken()
guard await delegate.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)
let loadingIndicator = DeferredLoadingIndicator(owner: self, state: state, addedIndicatorState: .loadingOlder(token, hasAddedLoadingIndicator: true))
do {
let items = try await delegate.loadOlder()
guard case .loadingOlder(token, _) = state else {
return
}
await loadingIndicator.end()
await emit(event: .appendItems(items, token))
state = .idle
} catch {
await loadingIndicator.end()
await emit(event: .loadOlderError(error, 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("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)
}
}
enum State: Equatable, CustomDebugStringConvertible {
case notLoadedInitial
case idle
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken)
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
var debugDescription: String {
switch self {
case .notLoadedInitial:
return "notLoadedInitial"
case .idle:
return "idle"
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))"
}
}
func canTransition(to: State) -> Bool {
switch self {
case .notLoadedInitial:
switch to {
case .loadingInitial(_, _):
return true
default:
return false
}
case .idle:
switch to {
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
return true
default:
return false
}
case .loadingInitial(let token, let hasAddedLoadingIndicator):
return 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))
}
}
func canEmit(event: Event) -> Bool {
switch event {
case .addLoadingIndicator:
switch self {
case .loadingInitial(_, _), .loadingOlder(_, _):
return true
default:
return false
}
case .removeLoadingIndicator:
switch self {
case .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
}
}
}
}
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)
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 "replcaeAllItems(<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))"
}
}
}
class LoadAttemptToken: Equatable {
static func ==(lhs: LoadAttemptToken, rhs: LoadAttemptToken) -> Bool {
return lhs === rhs
}
}
class DeferredLoadingIndicator {
private let owner: TimelineLikeController<Item>
private let addedIndicatorState: State
private let task: Task<Void, Error>
init(owner: TimelineLikeController<Item>, state: State, addedIndicatorState: State) {
self.owner = owner
self.addedIndicatorState = addedIndicatorState
self.task = Task {
try await Task.sleep(nanoseconds: 150 * NSEC_PER_MSEC)
guard await state == owner.state else {
return
}
await owner.emit(event: .addLoadingIndicator)
await owner.transition(to: addedIndicatorState)
}
}
func end() async {
let state = await owner.state
if state == addedIndicatorState {
await owner.emit(event: .removeLoadingIndicator)
} else {
task.cancel()
}
}
}
}