Tusker/Tusker/TimelineLikeController.swift

256 lines
9.1 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 handleEvent(_ event: TimelineLikeController<TimelineItem>.Event) 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.idle {
willSet {
precondition(state.canTransition(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 == .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()
// TODO: does the waiting state need to include the token?
// TODO: does this even need to be a separate state? maybe we should just await the delegate's permission, since it can suspend until user input. then the prompt could appear, and the user could scroll back to the top and still be able to refresh
// state = .waitingForLoadOlderPermission
guard await delegate.canLoadOlder() else {
// state = .idle
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 {
precondition(state.canEmit(event: event))
await delegate.handleEvent(event)
}
enum State: Equatable, CustomDebugStringConvertible {
case idle
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
case loadingNewer(LoadAttemptToken)
// case waitingForLoadOlderPermission
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
var debugDescription: String {
switch self {
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 .waitingForLoadOlderPermission:
// return "waitingForLoadOlderPermission"
case .loadingOlder(let token, let hasAddedLoadingIndicator):
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
}
}
func canTransition(to: State) -> Bool {
switch self {
case .idle:
switch to {
case .loadingInitial(_, _), .loadingNewer(_)/*, .waitingForLoadOlderPermission*/, .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))
// case .waitingForLoadOlderPermission:
// switch to {
// case .idle, .loadingOlder(_, _):
// return true
// default:
// return false
// }
}
}
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 {
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)
}
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()
}
}
}
}