eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1
2import 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
11public 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