| 1 | |
| 2 | import Foundation |
| 3 | |
| 4 | // Keeps each thread's ChatFeedModel alive after you leave the chat — its |
| 5 | // folded messages AND its live socket — so switching back renders instantly |
| 6 | // with zero flash, and NativeChatView prewarms the top chats after a thread- |
| 7 | // list load. The native mirror of FeedWebCache (which parks whole webviews; |
| 8 | // models are far lighter). Same reuse rule: a re-wrapped thread key means a |
| 9 | // stale park, rebuild. |
| 10 | @MainActor |
| 11 | public final class NativeFeedModelCache { |
| 12 | public static let shared = NativeFeedModelCache() |
| 13 | public static let prewarmCount = 5 |
| 14 | |
| 15 | private struct Entry { |
| 16 | let model: ChatFeedModel |
| 17 | let seedKey: String |
| 18 | var lastUsed = Date() |
| 19 | var lastCatchUp = Date.distantPast |
| 20 | } |
| 21 | |
| 22 | private var entries: [String: Entry] = [:] // "instanceID|threadID" |
| 23 | private let maxEntries = 8 |
| 24 | |
| 25 | // Where the reader left each thread's feed — the scroll position's half of |
| 26 | // "parking". The model survives a thread switch but the VIEW (and its |
| 27 | // scroll offset) is rebuilt per thread, so without this every switch-back |
| 28 | // landed at the bottom regardless of where the reader was. An absent entry |
| 29 | // means "was at the bottom": the next open pins to newest, exactly the |
| 30 | // pre-anchor behaviour. Same lifetime as the parked model on purpose — |
| 31 | // an evicted or rebuilt model drops its anchor too, so a cold reload |
| 32 | // never chases a row it may no longer hold. |
| 33 | public struct FeedScrollAnchor: Equatable { |
| 34 | public let rowID: Int64 // the message row to put back on screen |
| 35 | public let fraction: CGFloat // its top's 0…1 travel within the viewport |
| 36 | public init(rowID: Int64, fraction: CGFloat) { |
| 37 | self.rowID = rowID |
| 38 | self.fraction = fraction |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | private var anchors: [String: FeedScrollAnchor] = [:] |
| 43 | |
| 44 | public func scrollAnchor(instance instanceID: String, thread threadID: String) -> FeedScrollAnchor? { |
| 45 | anchors[instanceID + "|" + threadID] |
| 46 | } |
| 47 | |
| 48 | public func setScrollAnchor(_ anchor: FeedScrollAnchor?, instance instanceID: String, thread threadID: String) { |
| 49 | anchors[instanceID + "|" + threadID] = anchor |
| 50 | } |
| 51 | |
| 52 | // One tamper-evidence monitor per instance (docs/tamper-evidence.md, #573), |
| 53 | // shared by every thread's model so gossip from any chat feeds one chain |
| 54 | // view. The app polls it (pollIntegrity) on connect/foreground and mounts |
| 55 | // the failure banner on it. |
| 56 | private var monitors: [String: IntegrityMonitor] = [:] |
| 57 | |
| 58 | /// The instance's integrity monitor, lazily created (per-instance |
| 59 | /// UserDefaults storage). Observe its `unresolved` for the failure banner; |
| 60 | /// read `reportLines()` for /integrity. |
| 61 | public func integrityMonitor(for instance: SavedInstance) -> IntegrityMonitor { |
| 62 | if let m = monitors[instance.id] { return m } |
| 63 | let m = IntegrityMonitor(store: UserDefaultsIntegrityStore(host: instance.id)) |
| 64 | monitors[instance.id] = m |
| 65 | return m |
| 66 | } |
| 67 | |
| 68 | /// Verify the instance's checkpoint chain now — the app calls this on |
| 69 | /// connect + foreground (the native twin of the web's poll on ws.onopen + |
| 70 | /// slow interval). Silent on a network error; alerts surface via the |
| 71 | /// monitor's `unresolved` (the banner) and /integrity. |
| 72 | public func pollIntegrity(for instance: SavedInstance) async { |
| 73 | guard let base = instance.baseURL else { return } |
| 74 | let api = LChatAPI(baseURL: base, token: instance.deviceToken) |
| 75 | let monitor = integrityMonitor(for: instance) |
| 76 | await monitor.poll { after in await api.checkpoints(after: after) } |
| 77 | await sampleInclusion(instance: instance, api: api, monitor: monitor) |
| 78 | } |
| 79 | |
| 80 | /// Prove a few messages this device can see are committed to the verified |
| 81 | /// chain (docs/tamper-evidence.md client sampling). Native doesn't cache raw |
| 82 | /// ciphertext, so we re-fetch a couple of recent rows (ServerMessage.blob), |
| 83 | /// hash them the way the server did, and verify the inclusion proofs against |
| 84 | /// roots the monitor trusts. Best-effort; a forged proof raises a bad-proof |
| 85 | /// alert, a no-proof is never alarmed (could be too recent). |
| 86 | private func sampleInclusion(instance: SavedInstance, api: LChatAPI, monitor: IntegrityMonitor) async { |
| 87 | // A couple of threads, a few messages each — across many members over |
| 88 | // time this covers the DB probabilistically at trivial per-poll cost. |
| 89 | let threads = await api.threads().shuffled().prefix(2) |
| 90 | var samples: [(msgID: Int64, blobHash: String)] = [] |
| 91 | for t in threads { |
| 92 | let msgs = await api.messages(thread: t.id, after: 0).suffix(20).shuffled().prefix(3) |
| 93 | for m in msgs where !m.blob.isEmpty { |
| 94 | samples.append((msgID: m.id, blobHash: Checkpoint.blobHashHex(m.blob))) |
| 95 | } |
| 96 | } |
| 97 | guard !samples.isEmpty else { return } |
| 98 | await monitor.sampleAndVerify(samples) { msgID in await api.checkpointProof(msgID: msgID) } |
| 99 | } |
| 100 | |
| 101 | // The latest decrypted roster names per instance (uid → name) — seeded |
| 102 | // into every model this cache hands out and pushed into parked ones on |
| 103 | // each ThreadStore fold, so bubble headers resolve against the current |
| 104 | // roster without a model rebuild. |
| 105 | private var senderNames: [String: [String: String]] = [:] |
| 106 | |
| 107 | /// The latest resolved names for an instance (uid → the name this member |
| 108 | /// shows, per-viewer names included) — the webview embed seed reads this, |
| 109 | /// so the page can display my names without ever holding a key. |
| 110 | public func names(instance instanceID: String) -> [String: String] { |
| 111 | senderNames[instanceID] ?? [:] |
| 112 | } |
| 113 | |
| 114 | /// Fired on every fresh fold's names, after the models update — the app |
| 115 | /// layer pushes the map into its cached webviews (FeedWebCache), whose |
| 116 | /// baked boot seeds otherwise go stale when someone renames. |
| 117 | public var onNamesUpdate: ((String, [String: String]) -> Void)? |
| 118 | |
| 119 | /// A fresh roster fold's names: remember them for models created later, |
| 120 | /// and update the live/parked ones now. |
| 121 | public func updateSenderNames(instance instanceID: String, labels: [String: String]) { |
| 122 | senderNames[instanceID] = labels |
| 123 | for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| 124 | if entry.model.senderNames != labels { entry.model.senderNames = labels } |
| 125 | } |
| 126 | onNamesUpdate?(instanceID, labels) |
| 127 | } |
| 128 | |
| 129 | // The @mention index per instance — the senderNames treatment: seeded |
| 130 | // into models handed out later, pushed into live/parked ones now, so a |
| 131 | // rename (or a late label decrypt) restyles chips in every open feed. |
| 132 | private var mentionIndexes: [String: MentionIndex] = [:] |
| 133 | |
| 134 | /// A fresh roster fold's mention index — from ThreadStore, beside its |
| 135 | /// updateSenderNames call. |
| 136 | public func updateMentionIndex(instance instanceID: String, index: MentionIndex) { |
| 137 | mentionIndexes[instanceID] = index |
| 138 | for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| 139 | if entry.model.mentionIndex != index { entry.model.mentionIndex = index } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // The muted-people set per instance (#578) — the senderNames treatment: |
| 144 | // seeded into models handed out later, pushed into live/parked ones now, |
| 145 | // so a mute folds the person's bubbles in every open feed at once. |
| 146 | private var mutedPeople: [String: Set<String>] = [:] |
| 147 | |
| 148 | /// A fresh prefs load's (or local toggle's) muted set — from ThreadStore. |
| 149 | public func updateMutedPeople(instance instanceID: String, muted: Set<String>) { |
| 150 | mutedPeople[instanceID] = muted |
| 151 | for (key, entry) in entries where key.hasPrefix(instanceID + "|") { |
| 152 | if entry.model.mutedPeople != muted { entry.model.mutedPeople = muted } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// The live model for a thread — reused (and re-marked recent) when parked, |
| 157 | /// created and started when not. |
| 158 | public func model(for instance: SavedInstance, thread: ChatThread, myUserId: String) -> ChatFeedModel { |
| 159 | let key = instance.id + "|" + thread.id |
| 160 | if var entry = entries[key], entry.seedKey == thread.threadKeyB64 { |
| 161 | entry.lastUsed = Date() |
| 162 | // A parked model missed whatever the NSE / background refresh |
| 163 | // filed while the app was suspended — merge it from disk now, so |
| 164 | // a notification tap lands on an already-caught-up chat. Throttled |
| 165 | // (#465): the open chat screen re-inits (and lands back here) on |
| 166 | // EVERY ThreadStore publish, and each catch-up is a full store |
| 167 | // page read; while the app is live the model's own socket keeps |
| 168 | // it current anyway (the NSE only files while we're suspended, |
| 169 | // and foregrounding runs its own catchUpFromStore). |
| 170 | let model = entry.model |
| 171 | model.integrity = integrityMonitor(for: instance) |
| 172 | if Date().timeIntervalSince(entry.lastCatchUp) > 2 { |
| 173 | entry.lastCatchUp = Date() |
| 174 | Task { await model.catchUpFromStore() } |
| 175 | } |
| 176 | entries[key] = entry |
| 177 | // Same staleness for an ended DM (#578): the row the caller holds |
| 178 | // is fresher than a park that slept through the dm_closed frame. |
| 179 | model.noteDMClosed(thread.dmClosed) |
| 180 | return entry.model |
| 181 | } |
| 182 | entries.removeValue(forKey: key)?.model.stop() |
| 183 | anchors.removeValue(forKey: key) // a rebuilt model starts at the bottom, like new |
| 184 | let model = ChatFeedModel(instance: instance, thread: thread, myUserId: myUserId) |
| 185 | model.senderNames = senderNames[instance.id] ?? [:] |
| 186 | model.mutedPeople = mutedPeople[instance.id] ?? [] |
| 187 | model.mentionIndex = mentionIndexes[instance.id] |
| 188 | model.integrity = integrityMonitor(for: instance) // tamper-evidence gossip |
| 189 | model.start() |
| 190 | entries[key] = Entry(model: model, seedKey: thread.threadKeyB64) |
| 191 | evictIfNeeded() |
| 192 | return model |
| 193 | } |
| 194 | |
| 195 | /// Flip a parked model's ended-DM flag (#578): ThreadStore's own |
| 196 | /// close/reopen reaches an open pane immediately, without waiting for the |
| 197 | /// server's echo frame. A thread with no parked model is a no-op — the |
| 198 | /// next model is seeded from its (already updated) row. |
| 199 | public func noteDMClosed(instance instanceID: String, thread threadID: String, closed: Bool) { |
| 200 | entries[instanceID + "|" + threadID]?.model.noteDMClosed(closed) |
| 201 | } |
| 202 | |
| 203 | /// Warm the top of the sidebar so first taps land on already-folded feeds. |
| 204 | public func warm(instance: SavedInstance, threads: [ChatThread], myUserId: String) { |
| 205 | for thread in threads.prefix(Self.prewarmCount) where !thread.threadKeyB64.isEmpty { |
| 206 | _ = model(for: instance, thread: thread, myUserId: myUserId) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | private func evictIfNeeded() { |
| 211 | while entries.count > maxEntries, |
| 212 | let oldest = entries.min(by: { $0.value.lastUsed < $1.value.lastUsed }) { |
| 213 | entries.removeValue(forKey: oldest.key)?.model.stop() |
| 214 | anchors.removeValue(forKey: oldest.key) |
| 215 | } |
| 216 | } |
| 217 | } |
| 218 | |