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 }