field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

TeraTodayStore.swift (15733B)


      1 import Foundation
      2 
      3 @MainActor
      4 final class TeraTodayStore: ObservableObject {
      5     @Published private(set) var contexts: [TeraLocalNetwork]
      6     @Published private(set) var selectedContextID: String?
      7     @Published private(set) var cards: [TeraTodayCard] = []
      8     @Published private(set) var presentation = TeraTodayPresentation()
      9     @Published private(set) var discovery = TeraTodayDiscoveryPresentation()
     10     private var discoveryGeneration = TeraSessionGeneration.initial
     11     @Published private(set) var isLoadingNextPage = false
     12     @Published private(set) var observationState: TeraRuntimeObservationState = .inactive
     13     @Published private(set) var scopeGeneration = TeraSessionGeneration.initial
     14     @Published private(set) var hasPendingContent = false
     15     var mediaWillChange: ([TeraMediaReference], [TeraMediaReference], TeraLocalNetwork?) -> Void = { _, _, _ in }
     16     private var projectionGeneration: UInt64?
     17     var scopeWillChange: (TeraLocalNetwork?) -> Void = { _ in }
     18 
     19     private let runtimeClient: TeraRuntimeClient
     20     private let pageSize: UInt16
     21     private let clock: TeraClock
     22     private let observationDelay: @Sendable (UInt32) async throws -> Void
     23     @Published private(set) var viewerCalendar: TeraViewerCalendarContext?
     24     private let viewerTimeZone: @MainActor () -> TimeZone
     25     private var nextCursor: String?
     26     private var requestGeneration = TeraSessionGeneration.initial
     27     private let observation = TeraStoreObservation()
     28     private let reconciliation = TeraTodayReconciliationTask()
     29     private var configuration: TeraPresentationConfiguration?
     30     private var reloadTask: Task<Void, Never>?
     31 
     32     init(
     33       runtimeClient: TeraRuntimeClient,
     34       contexts: [TeraLocalNetwork] = [],
     35       selectedContextID: String? = nil,
     36       pageSize: UInt16 = 20,
     37       clock: TeraClock = .system,
     38       viewerTimeZone: @escaping @MainActor () -> TimeZone = { .current },
     39       observationDelay: @escaping @Sendable (UInt32) async throws -> Void =
     40             TeraRuntimeObservationBackoff.sleep
     41     ) {
     42         self.runtimeClient = runtimeClient
     43         self.contexts = TeraTodayReconciler.unique(contexts)
     44         self.pageSize = min(max(pageSize, 1), 100)
     45         self.clock = clock
     46         self.viewerTimeZone = viewerTimeZone
     47         self.observationDelay = observationDelay
     48         if let selectedContextID,
     49            self.contexts.contains(where: { $0.id == selectedContextID })
     50         {
     51             self.selectedContextID = selectedContextID
     52         } else {
     53             self.selectedContextID = self.contexts.first?.id
     54         }
     55     }
     56 
     57     deinit {
     58         reloadTask?.cancel()
     59     }
     60 
     61     func configure(snapshot: TeraRuntimeSnapshot) {
     62         let updated = TeraPresentationConfiguration(snapshot: snapshot)
     63         guard configuration != updated else { return }
     64         let reload = observation.isActive
     65         invalidatePresentation(for: updated.context)
     66         configuration = updated
     67         // The runtime snapshot currently supplies one default local network.
     68         // Reconcile even the first configuration; injected choices are not an
     69         // authority for a different account or runtime profile.
     70         contexts = [updated.context]
     71         selectedContextID = updated.context.id
     72         if reload {
     73           scheduleReload()
     74         }
     75     }
     76 
     77     func start() async {
     78         guard !observation.isActive, !Task.isCancelled else { return }
     79         startObservation()
     80         scheduleReload()
     81         let task = reloadTask
     82         await withTaskCancellationHandler { await task?.value } onCancel: { task?.cancel() }
     83     }
     84 
     85     func stop() {
     86         observation.stop()
     87         reconciliation.cancel()
     88         observationState = .stopped
     89         discoveryGeneration = discoveryGeneration.invalidated()
     90         discovery.stop()
     91         requestGeneration = requestGeneration.invalidated()
     92         reloadTask?.cancel()
     93         reloadTask = nil
     94         isLoadingNextPage = false
     95         presentation.stop()
     96     }
     97 
     98     private func invalidatePresentation(for context: TeraLocalNetwork?) {
     99         resetDiscovery()
    100         reconciliation.cancel()
    101         hasPendingContent = false
    102         projectionGeneration = nil
    103         requestGeneration = requestGeneration.invalidated()
    104         reloadTask?.cancel()
    105         reloadTask = nil
    106         cards = []
    107         viewerCalendar = nil
    108         nextCursor = nil
    109         isLoadingNextPage = false
    110         presentation = TeraTodayPresentation()
    111         scopeWillChange(context)
    112         scopeGeneration = scopeGeneration.invalidated()
    113         if observation.isActive {
    114             observation.stop()
    115             startObservation()
    116         }
    117     }
    118 
    119     func selectContext(id: String) {
    120         guard id != selectedContextID,
    121               let context = contexts.first(where: { $0.id == id })
    122         else {
    123             return
    124         }
    125         invalidatePresentation(for: context)
    126         selectedContextID = id
    127         scheduleReload()
    128     }
    129 
    130     func replaceContexts(_ updatedContexts: [TeraLocalNetwork], selectedID: String?) {
    131         let updatedContexts = TeraTodayReconciler.unique(updatedContexts)
    132         let selected = [selectedID, selectedContextID].compactMap(\.self).first { requested in
    133             updatedContexts.contains(where: { $0.id == requested })
    134         } ?? updatedContexts.first?.id
    135         guard contexts != updatedContexts || selectedContextID != selected else { return }
    136         invalidatePresentation(for: updatedContexts.first(where: { $0.id == selected }))
    137         contexts = updatedContexts
    138         selectedContextID = selected
    139         scheduleReload()
    140     }
    141 
    142     private func scheduleReload() {
    143         reloadTask = Task { [weak self] in await self?.reload() }
    144     }
    145 
    146     func reload(
    147       refreshProjection: Bool = true,
    148       update: TeraTodayProjectionUpdate = .incremental
    149     ) async {
    150         guard !Task.isCancelled else { return }
    151         guard let context = selectedContext else {
    152             cards = []
    153             presentation = TeraTodayPresentation()
    154             presentation.failRead(.failed(message: "Choose a local network to load Today."))
    155             return
    156         }
    157 
    158         if refreshProjection {
    159           resetDiscovery()
    160         }
    161         requestGeneration = requestGeneration.invalidated()
    162         var generation = requestGeneration
    163         defer {
    164             if generation == requestGeneration {
    165               presentation.stop()
    166             }
    167         }
    168         viewerCalendar = nil
    169         nextCursor = nil
    170         isLoadingNextPage = false
    171         hasPendingContent = false
    172         presentation.beginReload()
    173         guard generation.isActive else { return }
    174         await readFirstPage(context: context, generation: generation, receipt: nil)
    175         guard refreshProjection, generation == requestGeneration, !Task.isCancelled else { return }
    176         let receipt = await refresh(context: context, update: update, generation: generation)
    177         guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    178         // Invalidate pagination from the cached page before reading the updated
    179         // projection, even when the new page has the same as-of timestamp.
    180         requestGeneration = requestGeneration.invalidated()
    181         generation = requestGeneration
    182         viewerCalendar = nil
    183         nextCursor = nil
    184         isLoadingNextPage = false
    185         guard generation.isActive else { return }
    186         await readFirstPage(context: context, generation: generation, receipt: receipt)
    187     }
    188 
    189     private func refresh(
    190       context: TeraLocalNetwork, update: TeraTodayProjectionUpdate, generation: TeraSessionGeneration
    191     ) async -> TeraTodayRefreshReceipt? {
    192         presentation.beginRefresh()
    193         do {
    194             let receipt = try await runtimeClient.refreshToday(
    195               context: context, nowUnixSeconds: clock.unixSeconds(), update: update
    196             )
    197             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return nil }
    198             presentation.refreshCompleted(receipt)
    199             discovery.accept(receipt.discovery)
    200             return receipt.projection
    201         } catch {
    202             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return nil }
    203             presentation.refreshFailed(error)
    204             return nil
    205         }
    206     }
    207 
    208     private func readFirstPage(
    209       context: TeraLocalNetwork, generation: TeraSessionGeneration, receipt: TeraTodayRefreshReceipt?
    210     ) async {
    211         presentation.beginRead()
    212         defer {
    213             if generation == requestGeneration {
    214               presentation.finishReading()
    215             }
    216         }
    217         do {
    218             let asOf = try clock.unixSeconds()
    219             let page = try await runtimeClient.todayPage(
    220                 request: .first(
    221                   context: context,
    222                   limit: pageSize,
    223                   asOfUnixSeconds: asOf, timeZone: viewerTimeZone()
    224                 )
    225             )
    226             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    227             viewerCalendar = page.calendar
    228             nextCursor = page.nextCursor
    229             projectionGeneration = page.projectionGeneration
    230             replaceCards(TeraTodayReconciler.unique(page.items))
    231             presentation.acceptPage(count: cards.count, receipt: receipt)
    232         } catch {
    233             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    234             presentation.failRead(TeraTodayFailure(error))
    235         }
    236     }
    237 
    238     func loadNextPage() async {
    239         let requestedScope = scopeGeneration
    240         await reconciliation.wait()
    241         guard requestedScope == scopeGeneration, !Task.isCancelled else { return }
    242         guard let context = selectedContext,
    243               let cursor = nextCursor,
    244               !isLoadingNextPage
    245         else {
    246             return
    247         }
    248         let generation = requestGeneration
    249         isLoadingNextPage = true
    250         defer {
    251             if generation == requestGeneration {
    252                 isLoadingNextPage = false
    253             }
    254         }
    255 
    256         do {
    257             let page = try await runtimeClient.todayPage(
    258                 request: .after(context: context, limit: pageSize, cursor: cursor)
    259             )
    260             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    261             guard projectionGeneration == nil || projectionGeneration == page.projectionGeneration,
    262               viewerCalendar == page.calendar, page.calendar.asOfUnixSeconds == page.asOfUnixSeconds
    263             else {
    264                 failPagination(.staleCursor(message: "Today changed while loading. Refresh to continue."))
    265                 return
    266             }
    267             viewerCalendar = page.calendar
    268             nextCursor = page.nextCursor
    269             replaceCards(TeraTodayReconciler.unique(cards + page.items))
    270             presentation.acceptPage(count: cards.count)
    271         } catch {
    272             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    273             failPagination(TeraTodayFailure(error))
    274         }
    275     }
    276 }
    277 
    278 private extension TeraTodayStore {
    279     private func startObservation() {
    280         observation.start(
    281           client: runtimeClient, buffer: (capacity: 16, delay: observationDelay),
    282           state: { [weak self] in self?.observationState = $0 },
    283           accepts: { [weak self] in $0.matches(context: self?.selectedContext) },
    284           refresh: { [weak self] batch in
    285                 // Ordinary media progress belongs to the media presentation;
    286                 // it must not reset the feed's loaded pages and cursor.
    287                 guard batch.contains(anyOf: [.today, .drafts, .identity, .profile]) else { return }
    288                 await self?.reloadTask?.value
    289                 guard !Task.isCancelled else { return }
    290                 await self?.reconciliation.run { [weak self] in await self?.reconcileLoadedCards() }
    291             }
    292         )
    293     }
    294 
    295     func replaceCards(_ updated: [TeraTodayCard]) {
    296         mediaWillChange(cards.flatMap(\.media), updated.flatMap(\.media), selectedContext)
    297         cards = updated
    298     }
    299 
    300     func reconcileLoadedCards() async {
    301         guard let context = selectedContext, let calendar = viewerCalendar else { return }
    302         requestGeneration = requestGeneration.invalidated()
    303         let generation = requestGeneration
    304         isLoadingNextPage = false
    305         presentation.beginRead()
    306         do {
    307             let page = try await TeraTodayReconciler.read(
    308               client: runtimeClient, context: context, calendar: calendar, cards: cards
    309             )
    310             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    311             if projectionGeneration != page.projectionGeneration {
    312                 hasPendingContent = true
    313                 nextCursor = nil
    314             }
    315             replaceCards(page.items)
    316             presentation.acceptPage(count: cards.count)
    317         } catch {
    318             guard generation == requestGeneration, generation.isActive, !Task.isCancelled else { return }
    319             // Visibility could not be established. An old card or photo must
    320             // not remain authoritative after a failed mandatory resnapshot.
    321             replaceCards([])
    322             nextCursor = nil
    323             hasPendingContent = true
    324             presentation.acceptPage(count: 0)
    325             presentation.failRead(TeraTodayFailure(error))
    326         }
    327     }
    328 
    329     func resetDiscovery() {
    330         discoveryGeneration = discoveryGeneration.invalidated()
    331         discovery = TeraTodayDiscoveryPresentation()
    332     }
    333 
    334     func failPagination(_ failure: TeraTodayFailure) {
    335         if failure.requiresRefresh {
    336           nextCursor = nil
    337         }
    338         presentation.failRead(failure)
    339     }
    340 }
    341 
    342 extension TeraTodayStore {
    343     func invalidateVisibility() {
    344         invalidatePresentation(for: selectedContext)
    345     }
    346 
    347     func currentCard(id: String) -> TeraTodayCard? {
    348       cards.first { $0.id == id }
    349     }
    350 
    351     var selectedContext: TeraLocalNetwork? {
    352         contexts.first(where: { $0.id == selectedContextID })
    353     }
    354 
    355     var canLoadNextPage: Bool {
    356         nextCursor != nil && !isLoadingNextPage
    357     }
    358 
    359     func searchOlderPosts() async {
    360         guard !Task.isCancelled, let context = selectedContext,
    361               let cursor = discovery.continuation, discovery.canSearchOlder
    362         else { return }
    363         discoveryGeneration = discoveryGeneration.invalidated()
    364         let generation = discoveryGeneration
    365         guard generation.isActive else { return }
    366         discovery.begin()
    367         defer {
    368             if generation == discoveryGeneration {
    369               discovery.stop()
    370             }
    371         }
    372         do {
    373             let receipt = try await runtimeClient.refreshToday(
    374               context: context, nowUnixSeconds: clock.unixSeconds(), backfillCursor: cursor
    375             )
    376             guard generation == discoveryGeneration, !Task.isCancelled else { return }
    377             discovery.accept(receipt.discovery)
    378             // An explicit older search can refresh the local view. Ordinary
    379             // local reloads cannot erase this search's continuation/evidence.
    380             await reload(refreshProjection: false)
    381             guard generation == discoveryGeneration, !Task.isCancelled else { return }
    382             presentation.refreshCompleted(receipt)
    383             if presentation.readFailure == nil {
    384                 presentation.acceptPage(count: cards.count, receipt: receipt.projection)
    385             }
    386         } catch {
    387             guard generation == discoveryGeneration, !Task.isCancelled else { return }
    388             discovery.fail(error)
    389         }
    390     }
    391 }