// // TimelineLikeController.swift // Tusker // // Created by Shadowfacts on 9/19/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import Foundation import OSLog protocol TimelineLikeControllerDelegate: 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.Event) async } private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController") actor TimelineLikeController { unowned var delegate: any TimelineLikeControllerDelegate 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) { 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 private let addedIndicatorState: State private let task: Task init(owner: TimelineLikeController, 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() } } } }