// // TimelineLikeController.swift // Tusker // // Created by Shadowfacts on 9/19/22. // Copyright © 2022 Shadowfacts. All rights reserved. // import Foundation import OSLog import Combine protocol TimelineLikeControllerDataSource: 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: 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 { private unowned var delegate: any TimelineLikeControllerDelegate private unowned var dataSource: any TimelineLikeControllerDataSource 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, dataSource: any TimelineLikeControllerDataSource, 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(, \(token))" case .loadNewerError(let error, let token): return "loadNewerError(\(error), \(token))" case .prependItems(_, let token): return "prependItems(, \(token))" case .loadOlderError(let error, let token): return "loadOlderError(\(error), \(token))" case .appendItems(_, let token): return "appendItems(, \(token))" case .loadGapError(let error, let direction, let token): return "loadGapError(\(error), \(direction), \(token))" case .fillGap(_, let direction, let token): return "loadGapError(, \(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: ObservableObject { @Published var wrappedValue: Value var projectedValue: AsyncPublisher.Publisher> { $wrappedValue.values } init(wrappedValue: Value) { self.wrappedValue = wrappedValue } }