field_ios

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

TeraSupportingStores.swift (6610B)


      1 import Foundation
      2 
      3 enum TeraSupportingLoadState: Sendable, Equatable {
      4     case idle
      5     case loading
      6     case loaded
      7     case empty
      8     case failed(String)
      9 }
     10 
     11 @MainActor
     12 final class TeraSearchStore: ObservableObject {
     13     @Published private(set) var query = ""
     14     @Published private(set) var results: [TeraSearchResult] = []
     15     @Published private(set) var state: TeraSupportingLoadState = .idle
     16 
     17     private let runtimeClient: TeraRuntimeClient
     18     private let clock: TeraClock
     19     private var context: TeraLocalNetwork?
     20     private var generation = TeraSessionGeneration.initial
     21 
     22     init(
     23       runtimeClient: TeraRuntimeClient,
     24       clock: TeraClock = .system
     25     ) {
     26         self.runtimeClient = runtimeClient
     27         self.clock = clock
     28     }
     29 
     30     func configure(context: TeraLocalNetwork?) {
     31         guard self.context != context else { return }
     32         generation = generation.invalidated()
     33         self.context = context
     34         query = ""
     35         results = []
     36         state = .idle
     37     }
     38 
     39     func updateQuery(_ value: String) {
     40         generation = generation.invalidated()
     41         query = String(value.prefix(256))
     42         results = []
     43         state = .idle
     44     }
     45 
     46     func search() async {
     47         guard let context else {
     48             state = .failed("Choose a local network before searching.")
     49             return
     50         }
     51         let normalized = query.trimmingCharacters(in: .whitespacesAndNewlines)
     52         guard !normalized.isEmpty,
     53               normalized.utf8.count <= 256,
     54               !normalized.contains(where: \.isNewline),
     55               !normalized.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
     56         else {
     57             results = []
     58             state = .idle
     59             return
     60         }
     61 
     62         generation = generation.invalidated()
     63         let requestedGeneration = generation
     64         state = .loading
     65         do {
     66             let loaded = try await runtimeClient.search(
     67               context: context,
     68               query: normalized,
     69               limit: 50,
     70               asOfUnixSeconds: clock.unixSeconds()
     71             )
     72             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
     73             results = Self.unique(loaded)
     74             state = results.isEmpty ? .empty : .loaded
     75         } catch {
     76             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
     77             results = []
     78             state = .failed(Self.message(for: error))
     79         }
     80     }
     81 
     82     func stop() {
     83         generation = generation.invalidated()
     84         query = ""
     85         results = []
     86         state = .idle
     87     }
     88 
     89     private static func unique(_ values: [TeraSearchResult]) -> [TeraSearchResult] {
     90         var identifiers = Set<String>()
     91         return values.filter { identifiers.insert("\($0.type):\($0.id)").inserted }
     92     }
     93 
     94     private static func message(for error: Error) -> String {
     95         TeraUserMessages.text(for: error, fallback: .searchUnavailable)
     96     }
     97 }
     98 
     99 @MainActor
    100 final class TeraMeStore: ObservableObject {
    101     @Published private(set) var snapshot: TeraMeSnapshot?
    102     @Published private(set) var state: TeraSupportingLoadState = .idle
    103     @Published private(set) var observationState: TeraRuntimeObservationState = .inactive
    104 
    105     private let runtimeClient: TeraRuntimeClient
    106     private let clock: TeraClock
    107     private let observationDelay: @Sendable (UInt32) async throws -> Void
    108     private var context: TeraLocalNetwork?
    109     private var generation = TeraSessionGeneration.initial
    110     private let observation = TeraStoreObservation()
    111     private var reloadTask: Task<Void, Never>?
    112 
    113     deinit { reloadTask?.cancel() }
    114 
    115     init(
    116       runtimeClient: TeraRuntimeClient,
    117       clock: TeraClock = .system,
    118       observationDelay: @escaping @Sendable (UInt32) async throws -> Void =
    119             TeraRuntimeObservationBackoff.sleep
    120     ) {
    121         self.runtimeClient = runtimeClient
    122         self.clock = clock
    123         self.observationDelay = observationDelay
    124     }
    125 
    126     func configure(context: TeraLocalNetwork?) {
    127         guard self.context != context else { return }
    128         generation = generation.invalidated()
    129         reloadTask?.cancel()
    130         reloadTask = nil
    131         self.context = context
    132         snapshot = nil
    133         state = .idle
    134         if observation.isActive {
    135             observation.stop()
    136             startObservation()
    137         }
    138     }
    139 
    140     func start() async {
    141         startObservation()
    142         reloadTask?.cancel()
    143         reloadTask = Task { [weak self] in await self?.reload() }
    144         let task = reloadTask
    145         await withTaskCancellationHandler { await task?.value } onCancel: { task?.cancel() }
    146     }
    147 
    148     func reload() async {
    149         guard let context else {
    150             snapshot = nil
    151             state = .failed("Choose a local network before loading your profile.")
    152             return
    153         }
    154         generation = generation.invalidated()
    155         let requestedGeneration = generation
    156         if snapshot == nil {
    157             state = .loading
    158         }
    159         do {
    160             let loaded = try await runtimeClient.me(
    161               context: context,
    162               asOfUnixSeconds: clock.unixSeconds()
    163             )
    164             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    165             snapshot = loaded
    166             state = loaded.cards.isEmpty && loaded.profile == nil ? .empty : .loaded
    167         } catch {
    168             guard requestedGeneration == generation, generation.isActive, !Task.isCancelled else { return }
    169             state = .failed(Self.message(for: error))
    170         }
    171     }
    172 
    173     func stop() {
    174         generation = generation.invalidated()
    175         observation.stop()
    176         reloadTask?.cancel()
    177         reloadTask = nil
    178         observationState = .stopped
    179         snapshot = nil
    180         state = .idle
    181     }
    182 
    183     private func startObservation() {
    184         observation.start(
    185           client: runtimeClient, buffer: (capacity: 8, delay: observationDelay),
    186           state: { [weak self] in self?.observationState = $0 },
    187           accepts: { [weak self] in $0.matches(context: self?.context) },
    188           refresh: { [weak self] batch in
    189                 guard batch.contains(anyOf: [.today, .identity, .profile, .media, .drafts]) else { return }
    190                 await self?.reloadTask?.value
    191                 guard !Task.isCancelled else { return }
    192                 await self?.reload()
    193             }
    194         )
    195     }
    196 
    197     private static func message(for error: Error) -> String {
    198         TeraUserMessages.text(for: error, fallback: .profileUnavailable)
    199     }
    200 }