// // 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.notLoadedInitial { 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 == .notLoadedInitial 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() 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 { precondition(state.canEmit(event: event)) await delegate.handleEvent(event) } 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(_, hasAddedLoadingIndicator: _): return true default: return false } case .idle: switch to { case .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 { 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() } } } }