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 }