diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index ce7433c8..02e2ebb2 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -539,6 +539,10 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) if indexPath.row == itemsInSection - 1 { Task { + // Because of grouping, all cells from the first load may fit on screen, + // in which case, we try to load older while still in the loadingInitial state. + // So, wait for that to finish before trying to load more. + await controller.finishPendingOperation() await controller.loadOlder() } } diff --git a/Tusker/TimelineLikeController.swift b/Tusker/TimelineLikeController.swift index 9c45e69d..b02c1b35 100644 --- a/Tusker/TimelineLikeController.swift +++ b/Tusker/TimelineLikeController.swift @@ -8,6 +8,7 @@ import Foundation import OSLog +import Combine protocol TimelineLikeControllerDelegate: AnyObject { associatedtype TimelineItem: Sendable @@ -42,7 +43,7 @@ class TimelineLikeController { private unowned var delegate: any TimelineLikeControllerDelegate private let ownerType: String - private(set) var state = State.notLoadedInitial { + @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)") @@ -57,6 +58,19 @@ class TimelineLikeController { 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 @@ -369,3 +383,17 @@ enum TimelineGapDirection { } } } + +// 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 + } +}