eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Combine
2import Foundation
3import SwiftUI
4import AVFoundation
5import ImageIO
6
7// One thread's decrypted message list, folded natively: history via
8// GET /api/messages + live frames via ChatSocket, every blob opened on-device
9// with the thread key (GroupKey — the same wire format chat.js encrypts;
10// message-payload-vector.json is the drift gate). This is the native twin of
11// the web feed's fold; rendering on top of it starts as the Mac debug pane
12// (docs/native-chat.md commit 4) and grows into the native bubbles.
13@MainActor
14public final class ChatFeedModel: ObservableObject {
15 // One rendered line — deliberately view-ready and minimal (plain text for
16 // now; attachments/replies/reactions arrive one message type at a time).
17 public struct Message: Identifiable, Equatable {
18 // Server message id — the stable identity and sort key. For an OPTIMISTIC
19 // copy (a message shown the instant you send it, before the server echoes
20 // it back) this is a synthetic pending id that sorts after every real one;
21 // its own echo upgrades this to the server id in place. `var`, not `let`,
22 // for exactly that upgrade — see foldOptimistic.
23 public var id: Int64
24 public var name: String
25 public var text: String
26 public var mine: Bool
27 public var at: Int64 // server created_at, seconds
28 public var action: Bool = false // /me-style line, centred, no bubble
29 public var edited: Bool
30 public var attachments: [Attachment] = []
31 public var reply: ReplyRef? // set when this message quotes another
32 public var reactions: [Reaction] = []
33 // The reaction EVENTS folded so far, keyed by (emoji, uid) — the same
34 // accumulate-then-derive fold as chat.js: the latest event per key wins
35 // (on/off), `reactions` above is derived from it, and replaying history
36 // is idempotent so a re-pull can never double-count.
37 public var reactionEvents: [ReactionEvent] = []
38 // Folded from the other direction: how many messages are in the reply
39 // thread THIS message tops, and where that thread starts (the web's
40 // "N Replies" tag). Tallies group on the resolved ROOT message.
41 public var replyCount: Int = 0
42 public var firstReplyID: Int64?
43 // For a reply: the resolved sid of its thread's TOP message (the web's
44 // `replyRoot`) — one hop for new replies, chased for legacy chains.
45 public var threadRootID: Int64?
46 // The decrypted payload as sent — an edit re-encrypts {…raw, text}, so
47 // attachments/reply metadata survive the rewrite (chat.js does the same).
48 public var raw = MessagePayload()
49 // Optimistic-send state (web parity — chat.js pending/slow). `cid` is the
50 // client id this copy was sent under; it's non-nil ONLY while unconfirmed,
51 // and the matching key the echo upgrades on. `pending` = shown but not yet
52 // acknowledged by the server. `slow` flips true if it's still unconfirmed
53 // after 5s (the surface shows a subtle "Sending…"); `sentAt` (epoch ms) is
54 // when the optimistic copy was folded. A confirmed/received message carries
55 // cid == nil, pending == false.
56 public var cid: String? = nil
57 public var pending: Bool = false
58 public var slow: Bool = false
59 public var sentAt: Int64? = nil
60 }
61
62 // One decrypted reaction event (type="reaction" payload) as folded into a
63 // message — see Message.reactionEvents.
64 public struct ReactionEvent: Equatable, Sendable {
65 public var emoji: String
66 public var uid: String
67 public var on: Bool
68 public var ts: Int64?
69 }
70
71 /// The fixed reaction set the UI offers (mirrors chat.js REACTIONS — the
72 /// server can no longer enforce it, reactions are ciphertext to it now).
73 public static let allowedReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"]
74
75 /// The web's EDIT_WINDOW_MS: own messages are editable for 15 minutes
76 /// (the server enforces it; this just decides whether to offer the action).
77 public func canEdit(_ m: Message) -> Bool {
78 m.mine && !m.action && Date().timeIntervalSince1970 - TimeInterval(m.at) < 15 * 60
79 }
80
81 /// The name to show above a bubble: the sender's CURRENT roster name
82 /// (looked up by the uid inside the ciphertext), falling back to the name
83 /// snapshot the sender stamped into the payload at send time. The lookup
84 /// heals messages sent before a member picked a name — permanently
85 /// nameless snapshots — and retitles history after a rename; the snapshot
86 /// still covers uid-less legacy messages and members gone from the roster.
87 /// Twin: the senderName resolution in chat.js (buildMessageEl).
88 public func senderName(_ m: Message) -> String {
89 if let uid = m.raw.uid, let label = senderNames[uid], !label.isEmpty { return label }
90 return m.name
91 }
92
93 /// Whether two feed messages came from the same sender — the sender-run
94 /// rule (consecutive same-sender bubbles share one name header). Prefer
95 /// the uid (stable across renames, present even when the sender hadn't
96 /// picked a name yet); the payload's name snapshot is only the tiebreak
97 /// for uid-less legacy messages, and two nameless legacy messages never
98 /// group — "" == "" says nothing about who sent them.
99 /// Twin: sameSender in web/static/ui.js (chat.js viewItems).
100 public static func sameSender(_ a: Message?, _ b: Message) -> Bool {
101 guard let a else { return false }
102 if let au = a.raw.uid, !au.isEmpty, let bu = b.raw.uid, !bu.isEmpty { return au == bu }
103 return !a.name.isEmpty && a.name == b.name
104 }
105
106 @Published public private(set) var messages: [Message] = []
107 @Published public private(set) var status = "loading…"
108 /// The roster's current decrypted names by uid, seeded and refreshed by
109 /// NativeFeedModelCache from each ThreadStore fold — the bubble-header
110 /// lookup (see senderName below).
111 @Published public var senderNames: [String: String] = [:]
112 /// Who this member has muted (#578) — seeded and refreshed by
113 /// NativeFeedModelCache from the ThreadStore's sealed prefs blob, the same
114 /// route senderNames takes. Render-level only: the fold below is
115 /// untouched, so unmuting reveals history intact.
116 @Published public var mutedPeople: Set<String> = []
117 /// The roster's @mention index (Mentions.swift) — seeded and refreshed by
118 /// NativeFeedModelCache from each ThreadStore fold, the senderNames route.
119 /// Render-level only: bubbles style "@PublishedName" runs against it.
120 @Published public var mentionIndex: MentionIndex?
121 /// Muted stubs revealed this session, by message id. Deliberately not
122 /// persisted: reopening the app re-folds — a mute means muted (the web's
123 /// revealedMuted set, mirrored).
124 @Published public private(set) var revealedMuted: Set<Int64> = []
125
126 public func revealMuted(_ id: Int64) { revealedMuted.insert(id) }
127
128 /// Whether a message renders as the quiet "Muted message" stub: a muted
129 /// member's, not mine, not yet revealed. uid-less legacy messages never
130 /// fold — there's nothing to key the mute on (web renderItem's exact rule).
131 public func foldsAsMuted(_ m: Message) -> Bool {
132 guard !m.mine, let uid = m.raw.uid, !uid.isEmpty else { return false }
133 return mutedPeople.contains(uid) && !revealedMuted.contains(m.id)
134 }
135 /// Names currently typing (insertion order), for typingSummary above the
136 /// composer. Fed by ephemeral chat.typing beats; entries expire on their
137 /// own clock.
138 @Published public private(set) var typing: [String] = []
139 private var typingExpiry: [String: Date] = [:]
140 private var typingSweep: Timer?
141 // Outgoing beat throttle (chat.js: a beat every 10s while typing, each
142 // carrying a 15s expiry; an explicit 0 clears on empty/send/idle).
143 private var lastBeatSent = Date.distantPast
144 private var beating = false
145 private var idleTimer: Timer?
146 /// True once scroll-back has reached the beginning of the thread.
147 @Published public private(set) var exhausted = false
148 /// An ended DM (issue #578): the server refuses new messages for both
149 /// parties, so the feed's composer gives way to the closed banner. Seeded
150 /// from the thread row, kept live by the dm_closed/dm_reopened frames.
151 @Published public private(set) var dmClosed = false
152 /// True once the first history page is in — the scroll-back trigger must
153 /// not fire during initial render.
154 @Published public private(set) var ready = false
155 private var loadingOlder = false
156 private var lastOlderLoad = Date.distantPast
157 // The connect-time re-pull's debounce inputs: only a RE-connect can have
158 // missed frames, and a pull in flight (or one that just landed) already
159 // covers the socket's first connect — see onStatus in start().
160 private var hasConnected = false
161 private var loadingHistory = false
162 private var lastHistoryLoad = Date.distantPast
163
164 private let instance: SavedInstance
165 /// The sender's screen name — stamped into every outgoing payload, and the
166 /// composer's /me line.
167 ///
168 /// `instance.screenName` is THIS DEVICE's copy, and it is routinely empty:
169 /// a space claimed from anything but an add-device link starts nameless
170 /// (SpaceCreate.claim) and the setup conversation names us later — on
171 /// whichever device answered, so a second device keeps its blank forever.
172 /// Everything it stamped then read as "Someone" wherever a payload's own
173 /// `name` is the only source (sidebar previews, push copy). So fall back to
174 /// my roster label: the name every other member already sees. Web twin:
175 /// stampName() in shell.js.
176 public var myScreenName: String {
177 instance.screenName.isEmpty ? (senderNames[myUserId] ?? "") : instance.screenName
178 }
179 private let threadID: String
180 private let threadKind: String
181 private let threadKeyB64: String
182 private let myUserId: String
183 private let api: LChatAPI?
184 private let key: GroupKey?
185 private var socket: ChatSocket?
186
187 // Tamper evidence (docs/tamper-evidence.md, #573): the per-instance monitor,
188 // set by the app after creating the model. Outgoing messages carry its
189 // verified head as `_ckpt`; incoming `_ckpt` is compared to detect a server
190 // that showed members different histories. nil = feature inert.
191 public var integrity: IntegrityMonitor?
192
193 /// Compare a decrypted payload's gossip (if any) against our verified chain
194 /// head. Called on EVERY inbound decrypt path — live, history, reactions —
195 /// because the server chooses which path a message arrives by. `own`
196 /// messages still compare (per-device split view) but don't count as peers.
197 private func noteGossip(_ payload: MessagePayload?, own: Bool) {
198 if let g = payload?._ckpt { integrity?.gossipIn(g, countPeer: !own) }
199 }
200
201 // ---- group apps (the web's groupapps registry, folded natively) ---------
202
203 /// The mini-apps added to this GROUP chat (⋮ → Add apps on the web),
204 /// already filtered to the catalog like the web's groupAppsFor — always
205 /// empty for DMs/self threads. Drives the header's Chat|App switch.
206 @Published public private(set) var groupApps: [GroupApps.Item] = []
207 // The fold inputs: the encrypted slot-"apps" snapshot's seed + horizon,
208 // plus every groupapps.* event seen (keyed by server message id so pages
209 // can arrive in any order and the recompute stays deterministic).
210 private var groupAppsSeed: [GroupApps.Item] = []
211 private var groupAppsUpTo: Int64 = 0
212 private var groupAppsEvents: [Int64: GroupApps.Event] = [:]
213
214 // The off-main crypto lane: every per-frame decrypt and every outgoing
215 // encrypt runs detached through this serial chain — the main actor never
216 // touches AES — while arrival/send ORDER is preserved (a message and its
217 // edit must fold in sequence; two sends must reach the socket in the order
218 // they were typed). One frame's work is tiny; the chain exists for order,
219 // not throughput.
220 private var offMainChain: Task<Void, Never>?
221
222 private func enqueueOffMain(_ job: @escaping @Sendable () async -> Void) {
223 offMainChain = Task.detached(priority: .userInitiated) { [prev = offMainChain] in
224 await prev?.value
225 await job()
226 }
227 }
228
229 // decrypt (detached) → fold (main): the shape every inbound frame uses.
230 private func decryptThenFold<T: Sendable>(_ decrypt: @escaping @Sendable () -> T?,
231 fold: @escaping @MainActor (ChatFeedModel, T) -> Void) {
232 enqueueOffMain { [weak self] in
233 guard let value = decrypt(), let self else { return }
234 await MainActor.run { fold(self, value) }
235 }
236 }
237
238 public init(instance: SavedInstance, thread: ChatThread, myUserId: String) {
239 self.instance = instance
240 self.threadID = thread.id
241 self.threadKind = thread.kind
242 self.threadKeyB64 = thread.threadKeyB64
243 self.myUserId = myUserId
244 self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
245 self.key = Data(base64urlEncoded: thread.threadKeyB64).flatMap { try? GroupKey(rawKey: $0) }
246 self.dmClosed = thread.dmClosed
247 }
248
249 /// Adopt a fresher closed-flag than the live frames delivered — the cache
250 /// syncs a parked model from the row it's re-handed (the other side may
251 /// have acted while the app was suspended), and ThreadStore's own
252 /// close/reopen applies here without waiting for the echo frame.
253 public func noteDMClosed(_ closed: Bool) {
254 if dmClosed != closed { dmClosed = closed }
255 }
256
257 #if DEBUG
258 /// Feed-container tests drive the published stream directly — the setters
259 /// are private so nothing in the app can bypass the fold, but geometry
260 /// tests need a model with known content and no server.
261 public func _testInject(messages: [Message]? = nil, typing: [String]? = nil) {
262 if let messages { self.messages = messages; ready = true }
263 if let typing { self.typing = typing }
264 }
265 #endif
266
267 /// Where an attachment's encrypted bytes live + the key to open them — an
268 /// AvatarSource because it's the same fetch+decrypt (BlobImageLoader), just
269 /// with the thread key instead of the instance key.
270 public func blobSource(_ att: Attachment) -> AvatarSource? {
271 guard let base = instance.baseURL else { return nil }
272 return AvatarSource(blobID: att.id, keyB64: threadKeyB64,
273 baseURL: base, token: instance.deviceToken)
274 }
275
276 /// An attachment's decrypted bytes — the download path (file chips, video).
277 /// Cached in memory by blob id (RAM only, like the web's object-URL cache —
278 /// nothing decrypted ever lands on disk), so replaying a clip is instant.
279 /// `onProgress` (0…1, main thread) drives the radial ring on the video
280 /// card, mirroring the web's streamed loadBlob.
281 public func blobData(_ att: Attachment,
282 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Data? {
283 if let hit = BlobDataCache.shared.get(att.id) { return hit }
284 guard let source = blobSource(att),
285 var comps = URLComponents(
286 url: source.baseURL.appending(path: "api/blob").appending(path: source.blobID),
287 resolvingAgainstBaseURL: false) else { return nil }
288 comps.queryItems = [URLQueryItem(name: "token", value: source.token)]
289 guard let url = comps.url, let key else { return nil }
290 let data: Data?
291 if let onProgress {
292 data = await fetchWithProgress(url: url, onProgress: onProgress)
293 } else {
294 data = await plainFetch(url: url)
295 }
296 // A video/file blob is tens of MB — its AES-GCM open must never run on
297 // the main actor (this class's home).
298 guard let plain = await Task.detached(priority: .userInitiated, operation: {
299 data.flatMap { try? key.decryptBytes($0) }
300 }).value else { return nil }
301 BlobDataCache.shared.put(att.id, plain)
302 return plain
303 }
304
305 // Unfurl results: successes cache for the model's lifetime; failures only
306 // briefly. Caching nil forever meant a recipient who raced the poster's
307 // first fetch (or crossed a network blip) never got the card until they
308 // reopened the thread — the web evicts failures for the same reason
309 // (chat.js addLinkPreview); the TTL is what stops re-render retry storms.
310 private var unfurlCache: [URL: LinkPreview] = [:]
311 private var unfurlEmptyAt: [URL: Date] = [:]
312 private var unfurlInFlight: Set<URL> = []
313 private static let unfurlRetryAfter: TimeInterval = 60
314
315 /// Synchronous peek at the unfurl cache — height pre-measurement needs to
316 /// know NOW whether a card will render (.some(nil) = known-empty).
317 public func cachedUnfurl(_ url: URL) -> LinkPreview?? {
318 if let hit = unfurlCache[url] { return hit }
319 if let at = unfurlEmptyAt[url], Date().timeIntervalSince(at) < Self.unfurlRetryAfter {
320 return .some(nil)
321 }
322 return .none
323 }
324
325 /// The link-preview card for a URL (cached; nil = nothing to show).
326 public func unfurl(_ url: URL) async -> LinkPreview? {
327 if let known = cachedUnfurl(url) { return known }
328 guard let api, !unfurlInFlight.contains(url) else { return nil }
329 unfurlInFlight.insert(url)
330 defer { unfurlInFlight.remove(url) }
331 let preview = await api.unfurl(url)
332 if let preview {
333 unfurlCache[url] = preview
334 unfurlEmptyAt[url] = nil
335 } else {
336 unfurlEmptyAt[url] = Date()
337 }
338 return preview
339 }
340
341 // ---- uploads (the native chatAppUpload) -------------------------------
342
343 /// Stage an image: downscale + re-encode (EXIF gone), encrypt, upload.
344 /// Returns the attachment metadata that rides INSIDE the encrypted payload
345 /// — the server only ever sees ciphertext tagged with the thread for GC.
346 public func uploadImage(_ data: Data, name: String,
347 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
348 // Decode + downscale + re-encode is the heaviest client-side work a
349 // send can do — off the main actor with the crypto.
350 guard let processed = await Task.detached(priority: .userInitiated,
351 operation: { processImage(data) }).value,
352 processed.bytes.count <= maxUploadBytes,
353 let id = await upload(processed.bytes, onProgress: onProgress) else { return nil }
354 // The BlurHash placeholder, hashed from a ~32px re-decode of the upload
355 // bytes (DCT cost is O(pixels) — never hash the full image).
356 let bh = await Task.detached(priority: .utility, operation: { () -> String? in
357 guard let src = CGImageSourceCreateWithData(processed.bytes as CFData, nil),
358 let tiny = CGImageSourceCreateThumbnailAtIndex(src, 0, [
359 kCGImageSourceCreateThumbnailFromImageAlways: true,
360 kCGImageSourceThumbnailMaxPixelSize: 32,
361 ] as CFDictionary) else { return nil }
362 return Blurhash.encode(tiny)
363 }).value
364 let base = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression)
365 return Attachment(id: id, name: base + ".jpg", type: "image/jpeg",
366 w: processed.w, h: processed.h, size: Int64(processed.bytes.count),
367 bh: bh)
368 }
369
370 /// Stage a video, the web's send-and-play shape: the clip uploads whole
371 /// (≤50MB) plus a client-extracted poster frame — the server can't make
372 /// one, it only holds ciphertext. Dims (rotation applied), duration and
373 /// the poster's blob id ride inside the encrypted payload; a failed poster
374 /// is tolerated, exactly like the web.
375 public func uploadVideo(_ data: Data, name: String, mimeType: String?,
376 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
377 guard data.count <= maxVideoUploadBytes,
378 let id = await upload(data, onProgress: onProgress) else { return nil }
379 var att = Attachment(id: id, name: name, type: mimeType ?? "video/mp4",
380 size: Int64(data.count))
381 // Metadata + poster via the same in-memory loader the player uses —
382 // the plaintext clip never touches disk here either.
383 let loader = MemoryVideoAsset(data: data, mimeType: mimeType)
384 let asset = loader.makeAsset()
385 if let track = try? await asset.loadTracks(withMediaType: .video).first {
386 if let (size, transform) = try? await track.load(.naturalSize, .preferredTransform) {
387 let rotated = size.applying(transform)
388 att.w = Int(abs(rotated.width).rounded())
389 att.h = Int(abs(rotated.height).rounded())
390 }
391 }
392 if let dur = try? await asset.load(.duration), dur.seconds.isFinite {
393 att.dur = dur.seconds.rounded()
394 }
395 let generator = AVAssetImageGenerator(asset: asset)
396 generator.appliesPreferredTrackTransform = true
397 generator.maximumSize = CGSize(width: maxImageDim, height: maxImageDim)
398 if let poster = try? await generator.image(at: .zero).image,
399 let bytes = jpegData(from: poster) {
400 att.poster = await upload(bytes) // optional — nil just means no poster
401 }
402 return att
403 }
404
405 /// Stage an allowlisted document (fileAllowed) verbatim: encrypt + upload.
406 public func uploadFile(_ data: Data, name: String, mimeType: String?,
407 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
408 guard fileAllowed(name), data.count <= maxUploadBytes,
409 let id = await upload(data, onProgress: onProgress) else { return nil }
410 return Attachment(id: id, name: name, type: mimeType ?? "application/octet-stream",
411 size: Int64(data.count))
412 }
413
414 // Encrypt + POST /api/upload?token&thread (raw bytes body) → the blob id.
415 // onProgress (0…1, main thread) reports request-body bytes sent — the
416 // upload twin of fetchWithProgress, via a per-task delegate.
417 private func upload(_ plain: Data,
418 onProgress: (@MainActor (Double) -> Void)? = nil) async -> String? {
419 guard let key, let base = instance.baseURL,
420 var comps = URLComponents(url: base.appending(path: "api/upload"),
421 resolvingAgainstBaseURL: false) else { return nil }
422 comps.queryItems = [
423 URLQueryItem(name: "token", value: instance.deviceToken),
424 URLQueryItem(name: "thread", value: threadID),
425 ]
426 // Encrypting a full-size attachment (up to 50 MB video) off main.
427 guard let url = comps.url,
428 let enc = await Task.detached(priority: .userInitiated,
429 operation: { try? key.encryptBytes(plain) }).value
430 else { return nil }
431 var req = URLRequest(url: url)
432 req.httpMethod = "POST"
433 struct Resp: Decodable { let id: String? }
434 let delegate = onProgress.map(UploadProgress.init)
435 guard let (data, resp) = try? await URLSession.shared.upload(for: req, from: enc,
436 delegate: delegate),
437 let http = resp as? HTTPURLResponse, http.statusCode < 300,
438 let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
439 return r.id
440 }
441
442 private final class UploadProgress: NSObject, URLSessionTaskDelegate {
443 private let onProgress: @MainActor (Double) -> Void
444 init(onProgress: @escaping @MainActor (Double) -> Void) { self.onProgress = onProgress }
445
446 func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64,
447 totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
448 guard totalBytesExpectedToSend > 0 else { return }
449 let p = min(1, Double(totalBytesSent) / Double(totalBytesExpectedToSend))
450 Task { @MainActor in self.onProgress(p) }
451 }
452 }
453
454 private func plainFetch(url: URL) async -> Data? {
455 guard let (data, resp) = try? await URLSession.shared.data(from: url),
456 let http = resp as? HTTPURLResponse, http.statusCode == 200 else { return nil }
457 return data
458 }
459
460 // A delegate-based fetch, because the one-shot async API can't observe
461 // download progress. The session retains its delegate until invalidated.
462 private func fetchWithProgress(url: URL,
463 onProgress: @escaping @MainActor (Double) -> Void) async -> Data? {
464 await withCheckedContinuation { cont in
465 let delegate = ProgressFetch(onProgress: onProgress) { cont.resume(returning: $0) }
466 let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
467 session.dataTask(with: url).resume()
468 session.finishTasksAndInvalidate()
469 }
470 }
471
472 private final class ProgressFetch: NSObject, URLSessionDataDelegate {
473 private let onProgress: @MainActor (Double) -> Void
474 private let done: (Data?) -> Void
475 // A URLSession serialises its delegate callbacks on its delegateQueue
476 // (nil → a private serial queue), so these are only ever touched one at
477 // a time — despite the delegate protocol requiring Sendable. The
478 // compiler can't prove that serialisation, hence nonisolated(unsafe).
479 private nonisolated(unsafe) var buffer = Data()
480 private nonisolated(unsafe) var expected: Int64 = 0
481 private nonisolated(unsafe) var ok = false
482
483 init(onProgress: @escaping @MainActor (Double) -> Void, done: @escaping (Data?) -> Void) {
484 self.onProgress = onProgress
485 self.done = done
486 }
487
488 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
489 didReceive response: URLResponse,
490 completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
491 ok = (response as? HTTPURLResponse)?.statusCode == 200
492 expected = response.expectedContentLength
493 if expected > 0 { buffer.reserveCapacity(Int(expected)) }
494 completionHandler(ok ? .allow : .cancel)
495 }
496
497 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
498 buffer.append(data)
499 guard expected > 0 else { return }
500 let p = min(1, Double(buffer.count) / Double(expected))
501 Task { @MainActor in self.onProgress(p) }
502 }
503
504 func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
505 done(error == nil && ok ? buffer : nil)
506 }
507 }
508
509 public func start() {
510 // Idempotent: a parked model (NativeFeedModelCache) is re-attached on
511 // every visit — its socket and messages are already live.
512 guard socket == nil else { return }
513 guard let api, key != nil, let base = instance.baseURL else {
514 status = "no usable key for this chat"
515 return
516 }
517 Task { await loadHistory(api) }
518 if threadKind == "group" { Task { await seedGroupApps(api) } }
519 let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
520 socket?.onFrame = { [weak self] frame in self?.apply(frame) }
521 socket?.onStatus = { [weak self] s in
522 guard let self else { return }
523 self.status = s
524 guard s == "connected" else { return }
525 // A reconnect may have missed frames; re-pull history (upsert by id,
526 // so this is idempotent) to converge. The socket's FIRST connect is
527 // exempt while the open's own pull is in flight or just landed —
528 // re-pulling then re-ran the whole fetch→fold→publish moments after
529 // the initial page, doubling the open's layout work for no new data.
530 let reconnect = self.hasConnected
531 self.hasConnected = true
532 if !reconnect,
533 self.loadingHistory || Date().timeIntervalSince(self.lastHistoryLoad) < 3 {
534 return
535 }
536 if !self.messages.isEmpty { Task { await self.loadHistory(api) } }
537 }
538 self.socket = socket
539 socket?.start()
540 }
541
542 // Nils the socket so a later start() can revive the model — an evicted
543 // parked model that a live view still holds must be restartable, never
544 // permanently dead.
545 public func stop() {
546 socket?.stop()
547 socket = nil
548 }
549
550 // The socket's URLSession retains its delegate (the socket) — stop() is
551 // what breaks that, so a model released without it would leak its socket.
552 deinit { socket?.stop() }
553
554 // The outgoing envelope — EXACTLY what shell.js emit builds: the app event
555 // ({text}, later +replyTo/attachments) stamped with name, ts (ms) and uid.
556 // Untyped, like every message the web sends today (message.js's type stamp
557 // is still future work there too). native-message-vector.json pins this
558 // shape from the other direction: the web must decrypt what we encrypt.
559 private struct Outgoing: Encodable {
560 let text: String
561 let name: String
562 let ts: Int64
563 var uid: String?
564 var action: Bool? // /me-style centred line, not a bubble
565 var attachments: [Attachment]?
566 var replyTo: ReplyRef?
567 var _ckpt: CkptGossip? // tamper-evidence gossip (nil = omitted)
568 var _cid: String? // optimistic-send client id (rides back in our own echo)
569 }
570
571 // A typed mini-app event (todo.add, remind.add, …) — the web's ctx.emit
572 // envelope: the event fields plus the transport-stamped name/ts/uid. The id
573 // is the app entity's stable identity; `when`/`sid` are remind.add's extras
574 // (nil optionals are omitted, so a todo.add carries neither — the event
575 // vectors pin both shapes).
576 private struct AppEventOut: Encodable {
577 let type: String
578 let id: String
579 let text: String
580 let name: String
581 let ts: Int64
582 var uid: String?
583 var when: Double? // remind.add: fire time, epoch ms
584 var sid: String? // remind.add: server schedule id ("" = offline fallback)
585 var _cid: String? // optimistic-send client id (rides back in our own echo)
586 }
587
588 // Encrypt an outgoing payload and send it, on the off-main lane (the lane
589 // also guarantees two sends leave in the order they were typed). Callers
590 // check socket.isLive FIRST so "keep the typed text on a down socket"
591 // still gets its synchronous answer; a socket that dies in the gap just
592 // drops the frame, exactly as today's fire-and-forget send did.
593 private func encryptAndSend<T: Encodable & Sendable>(_ payload: T, via socket: ChatSocket) {
594 guard let key else { return }
595 let tid = threadID
596 enqueueOffMain {
597 guard let blob = try? key.encryptJSON(payload),
598 let frame = try? JSONEncoder().encode(OutFrame(thread_id: tid, blob: blob)),
599 let json = String(data: frame, encoding: .utf8) else { return }
600 socket.send(json)
601 }
602 }
603
604 /// Emit a mini-app event into this thread (the default-command path: plain
605 /// text typed into To-do becomes todo.add). Encrypted + sent like any
606 /// message; the echo folds back through the app projection, so the action
607 /// line appears exactly as it would from the web. `id` defaults to a fresh
608 /// entity id; remind.add passes its own so the scheduled fire matches.
609 @discardableResult
610 public func sendAppEvent(type: String, text: String, id: String = UUID().uuidString,
611 when: Double? = nil, sid: String? = nil) -> Bool {
612 guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
613 var payload = AppEventOut(type: type, id: id, text: text,
614 name: myScreenName,
615 ts: Int64(Date().timeIntervalSince1970 * 1000))
616 if !myUserId.isEmpty { payload.uid = myUserId }
617 payload.when = when
618 payload.sid = sid
619 // The same transport-level client id chat carries. App events project
620 // asynchronously through the app host, so there's no optimistic projection
621 // to render yet — but the wire field is uniform, so that can build on this
622 // seam later without a protocol change.
623 payload._cid = UUID().uuidString
624 encryptAndSend(payload, via: socket)
625 typingBeat(active: false)
626 return true
627 }
628
629 // The scheduled remind.fire payload — encrypted NOW and held by the server
630 // until the due moment (remind.js addReminder's shape: the reminder text
631 // rides ONLY inside the encrypted notice).
632 private struct RemindFireOut: Encodable {
633 let type = "remind.fire"
634 let id: String
635 let notice: Notice
636 let name: String
637 let ts: Int64
638 var uid: String?
639 struct Notice: Encodable {
640 let title: String
641 let body: String
642 }
643 }
644
645 /// The full native reminder write path, mirroring remind.js addReminder:
646 /// schedule the future remind.fire FIRST (its encrypted notice is what
647 /// alerts every device), then record the reminder via remind.add carrying
648 /// the schedule id — or "" when scheduling failed (offline), which the
649 /// render ticker fires best-effort, exactly like the web. If the emit then
650 /// fails (socket down), the fresh schedule is CANCELLED before returning —
651 /// otherwise no remind.add would ever record its sid, and the user's retry
652 /// would stack a second, uncancellable alarm on top of the orphan.
653 public func addReminder(text: String, when: Double) async -> Bool {
654 guard let key else { return false }
655 let id = UUID().uuidString
656 var sid: String? = nil
657 var fire = RemindFireOut(id: id, notice: .init(title: "⏰ Reminder", body: text),
658 name: myScreenName,
659 ts: Int64(Date().timeIntervalSince1970 * 1000))
660 if !myUserId.isEmpty { fire.uid = myUserId }
661 // The seconds conversion must never trap: Int64(Double) crashes on
662 // out-of-range/infinite input, and the parser accepts arbitrarily big
663 // relative times exactly like the web — whose server just answers 400
664 // ("deliver_at out of range", the scheduleHorizon check) and the add
665 // falls back to sid "". So the guard here is ONLY trap-safety; the
666 // server stays the one range authority for both platforms.
667 let sec = when / 1000
668 if let api, sec.isFinite, sec > 0, sec < 9e18 { // Int64-conversion safety, not policy
669 let sendable = fire
670 let blob = await Task.detached(priority: .userInitiated,
671 operation: { try? key.encryptJSON(sendable) }).value
672 if let blob {
673 sid = await api.schedule(thread: threadID, deliverAt: Int64(sec), blob: blob)
674 }
675 }
676 let sent = sendAppEvent(type: "remind.add", text: text, id: id, when: when, sid: sid ?? "")
677 if !sent, let sid, let api {
678 await api.unschedule(sid) // never orphan a fire no remind.add points at
679 }
680 return sent
681 }
682
683 // ---- group apps: seed + fold + emit -------------------------------------
684
685 /// Seed the group-apps fold from the thread's encrypted slot-"apps"
686 /// snapshot (what the web saves on every fold advance). History windows at
687 /// 100 messages, so without this an app added long ago would be invisible;
688 /// with it, only events NEWER than the snapshot's horizon matter — exactly
689 /// the window loadHistory already fetches.
690 private func seedGroupApps(_ api: LChatAPI) async {
691 guard let key else { return }
692 guard let state = await api.threadState(thread: threadID, slot: "apps"),
693 !state.blob.isEmpty else { return }
694 let blob = state.blob
695 let parsed = await Task.detached(priority: .userInitiated) {
696 (try? key.decryptData(blob: blob)).flatMap(GroupApps.parseSnapshot)
697 }.value
698 guard let parsed else { return }
699 groupAppsSeed = parsed.seed
700 groupAppsUpTo = parsed.upTo
701 recomputeGroupApps()
702 }
703
704 // The groupapps.* wire shape — the event fields plus the transport
705 // envelope's name/ts (ts is ms; tolerate a float, JS numbers are doubles).
706 private struct GroupAppsWire: Decodable {
707 let type: String?
708 let app: String?
709 let name: String?
710 let ts: Double?
711 }
712
713 nonisolated static func groupAppsEvent(_ blob: String, key: GroupKey) -> GroupApps.Event? {
714 guard let w = try? key.decryptJSON(GroupAppsWire.self, blob: blob),
715 let type = w.type, type.hasPrefix("groupapps."),
716 let app = w.app, !app.isEmpty else { return nil }
717 return GroupApps.Event(type: type, app: app, by: w.name ?? "",
718 ts: Int64(w.ts ?? 0))
719 }
720
721 private func foldGroupAppsEvent(id: Int64, _ e: GroupApps.Event) {
722 guard threadKind == "group", groupAppsEvents[id] != e else { return }
723 groupAppsEvents[id] = e
724 recomputeGroupApps()
725 }
726
727 /// This space's app registry — the group-apps read filter and the Apps
728 /// sheet both draw from it (docs/native-apps.md phase 2).
729 public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
730 private var registryWatch: AnyCancellable?
731
732 /// The read-time install filter depends on the catalogue, which can learn
733 /// an app AFTER the fold ran (a member app deployed mid-session): refold
734 /// when it changes, or a fresh install stays invisible until the next
735 /// event happens to arrive.
736 private func watchRegistry() {
737 guard registryWatch == nil else { return }
738 registryWatch = registry.$manifests
739 .dropFirst()
740 .receive(on: DispatchQueue.main)
741 .sink { [weak self] _ in self?.recomputeGroupApps() }
742 }
743
744 private func recomputeGroupApps() {
745 watchRegistry()
746 // Read-time filter against the registry (the web's groupAppsFor):
747 // installs of apps this client knows AND that are chat-capable
748 // surface; anything else folds but stays inert.
749 let folded = registry.known(GroupApps.fold(seed: groupAppsSeed,
750 upTo: groupAppsUpTo,
751 events: groupAppsEvents))
752 if folded != groupApps { groupApps = folded }
753 }
754
755 /// Add or remove a mini-app on this group — an ordinary encrypted event in
756 /// the group's log (the web's ⋮ → Add apps emit), folded by every member.
757 @discardableResult
758 public func sendGroupAppEvent(add: Bool, app: String) -> Bool {
759 guard threadKind == "group", !app.isEmpty, key != nil,
760 let socket, socket.isLive else { return false }
761 var payload = GroupAppsOut(type: add ? "groupapps.add" : "groupapps.remove",
762 app: app, name: myScreenName,
763 ts: Int64(Date().timeIntervalSince1970 * 1000))
764 if !myUserId.isEmpty { payload.uid = myUserId }
765 encryptAndSend(payload, via: socket)
766 // Apps that gate their content behind an "open" event (board) fire it
767 // right after the add, exactly like the web's installGroupApp —
768 // without it the app renders silently empty for every member.
769 if add, let ev = registry.manifest(app)?.openEvent, !ev.isEmpty {
770 var open = OpenEventOut(type: ev, name: myScreenName,
771 ts: Int64(Date().timeIntervalSince1970 * 1000))
772 if !myUserId.isEmpty { open.uid = myUserId }
773 encryptAndSend(open, via: socket)
774 }
775 return true
776 }
777
778 // The bare emit envelope for an app's openEvent — {type} stamped with
779 // name/ts/uid like every app event.
780 private struct OpenEventOut: Encodable {
781 let type: String
782 let name: String
783 let ts: Int64
784 var uid: String?
785 }
786
787 // The outgoing groupapps event — shell.js's emit envelope around
788 // {type, app}: stamped with name, ts (ms) and uid like every app event.
789 private struct GroupAppsOut: Encodable {
790 let type: String
791 let app: String
792 let name: String
793 let ts: Int64
794 var uid: String?
795 }
796
797 /// Broadcast a /me-style action line ("Paul waves") — the composer builds
798 /// the full text; here it rides with action:true, like the web's ctx.emit.
799 @discardableResult
800 public func sendAction(_ text: String) -> Bool {
801 guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
802 var payload = Outgoing(text: text, name: myScreenName,
803 ts: Int64(Date().timeIntervalSince1970 * 1000))
804 if !myUserId.isEmpty { payload.uid = myUserId }
805 payload.action = true
806 encryptAndSend(payload, via: socket)
807 typingBeat(active: false)
808 return true
809 }
810
811 // One hub action frame (react/unreact/delete/edit) — nil fields are
812 // omitted, matching the web's hand-built JSON.
813 private struct ActionFrame: Encodable {
814 let type: String
815 let thread_id: String
816 var message_id: Int64?
817 var emoji: String?
818 var blob: String?
819 }
820
821 @discardableResult
822 private func sendFrame<T: Encodable>(_ frame: T) -> Bool {
823 guard let socket,
824 let data = try? JSONEncoder().encode(frame),
825 let json = String(data: data, encoding: .utf8) else { return false }
826 return socket.send(json)
827 }
828
829 /// Toggle a reaction, like the web's toggleReaction: react if it isn't
830 /// yours yet, unreact if it is. A reaction is an end-to-end encrypted event
831 /// (type="reaction") sent as an ordinary message — the server never sees
832 /// the emoji or the target; every client folds the event into the target's
833 /// tally. targetUid (the reacted-to message's author) rides inside the
834 /// ciphertext so push handlers can show the notification only to the author;
835 /// targetText (its snippet) rides along so the sidebar can quote what was
836 /// reacted to without loading the thread.
837 public func toggleReaction(_ id: Int64, emoji: String) {
838 guard Self.allowedReactions.contains(emoji), key != nil,
839 let socket, socket.isLive,
840 let m = messages.first(where: { $0.id == id }) else { return }
841 let mine = m.reactions.first { $0.emoji == emoji }?.isMine ?? false
842 // The quote goes through the markdown plain projection, like every
843 // other preview line — raw text would show the marks the bubble renders
844 // as styling. Projected at the stamp, so no payload carries them.
845 var quoted = m.raw
846 quoted.text = markdownPlainText(m.raw.text ?? "")
847 var payload = MessagePayload(type: "reaction",
848 name: myScreenName,
849 ts: Int64(Date().timeIntervalSince1970 * 1000),
850 targetSid: id,
851 targetUid: m.raw.uid,
852 emoji: emoji,
853 on: !mine,
854 targetText: replySnippet(quoted))
855 if !myUserId.isEmpty { payload.uid = myUserId }
856 encryptAndSend(payload, via: socket)
857 // Optimistic local fold so the chip responds instantly; the echo frame
858 // replays the same event and the (emoji, uid)-keyed fold is idempotent.
859 applyReactionEvent(payload)
860 }
861
862 /// Delete your own message. The server verifies authorship; the "delete"
863 /// broadcast removes it from every feed, ours included.
864 public func deleteMessage(_ id: Int64) {
865 sendFrame(ActionFrame(type: "delete", thread_id: threadID, message_id: id))
866 }
867
868 /// Edit your own message (within the 15-minute window — server-enforced).
869 /// Re-encrypts {…raw, text} like the web, so attachments and the reply
870 /// reference survive the rewrite. Returns false on a down socket.
871 public func edit(_ id: Int64, text: String) -> Bool {
872 guard let key, let socket, socket.isLive,
873 let m = messages.first(where: { $0.id == id }) else { return false }
874 var raw = m.raw
875 raw.text = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
876 guard !(raw.text ?? "").isEmpty else { return false }
877 let tid = threadID
878 let edited = raw
879 enqueueOffMain {
880 guard let blob = try? key.encryptJSON(edited),
881 let data = try? JSONEncoder().encode(ActionFrame(
882 type: "edit", thread_id: tid, message_id: id, blob: blob)),
883 let json = String(data: data, encoding: .utf8) else { return }
884 socket.send(json)
885 }
886 return true
887 }
888
889 private struct OutFrame: Encodable {
890 let type = "msg"
891 let thread_id: String
892 let blob: String
893 }
894
895 /// Encrypt and send a message — the first native write path. Returns false
896 /// (and sends nothing) when the socket is down or encryption is impossible,
897 /// so the composer keeps the typed text to resend, like the web's.
898 public func send(_ text: String, attachments: [Attachment] = [],
899 replyTo: ReplyRef? = nil) -> Bool {
900 // Shortcodes interpolate at send time (emoji.js emojify), so the
901 // ciphertext stores the real glyph and renders everywhere.
902 let trimmed = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
903 guard !trimmed.isEmpty || !attachments.isEmpty, key != nil,
904 let socket, socket.isLive else { return false }
905 let ts = Int64(Date().timeIntervalSince1970 * 1000)
906 var payload = Outgoing(text: trimmed, name: myScreenName, ts: ts)
907 if !myUserId.isEmpty { payload.uid = myUserId }
908 if !attachments.isEmpty { payload.attachments = attachments }
909 payload.replyTo = replyTo
910 payload._ckpt = integrity?.gossipOut() // tamper-evidence gossip (inside the ciphertext)
911 // Optimistic send (web parity — shell.js): show the message immediately as
912 // a pending copy stamped with a client id, then let the hub's broadcast —
913 // which carries the same _cid back — upgrade it in place. Do it before the
914 // send so it lands with no round-trip wait.
915 let cid = UUID().uuidString
916 payload._cid = cid
917 foldOptimisticSend(cid: cid, text: trimmed, ts: ts,
918 attachments: attachments, replyTo: replyTo)
919 // The hub stores + broadcasts to everyone including us; that echo folds
920 // over this optimistic copy (matched on _cid), replacing it silently.
921 encryptAndSend(payload, via: socket)
922 typingBeat(active: false) // a send clears the indicator now
923 return true
924 }
925
926 /// Send over REST — the share extension has no WebSocket. Same encrypted
927 /// payload as send(); the hub still broadcasts to every member.
928 public func sendViaREST(_ text: String, attachments: [Attachment]) async -> Bool {
929 guard let key, let base = instance.baseURL,
930 !text.isEmpty || !attachments.isEmpty else { return false }
931 var payload = Outgoing(text: emojify(text), name: myScreenName,
932 ts: Int64(Date().timeIntervalSince1970 * 1000))
933 if !myUserId.isEmpty { payload.uid = myUserId }
934 if !attachments.isEmpty { payload.attachments = attachments }
935 payload._ckpt = integrity?.gossipOut()
936 let sendable = payload
937 guard let blob = await Task.detached(priority: .userInitiated,
938 operation: { try? key.encryptJSON(sendable) }).value,
939 var comps = URLComponents(url: base.appending(path: "api/messages"),
940 resolvingAgainstBaseURL: false) else { return false }
941 comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
942 guard let url = comps.url,
943 let body = try? JSONSerialization.data(withJSONObject:
944 ["thread_id": threadID, "blob": blob]) else { return false }
945 var req = URLRequest(url: url)
946 req.httpMethod = "POST"
947 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
948 req.httpBody = body
949 guard let (_, resp) = try? await URLSession.shared.data(for: req),
950 let http = resp as? HTTPURLResponse else { return false }
951 return http.statusCode < 300
952 }
953
954 private func loadHistory(_ api: LChatAPI) async {
955 loadingHistory = true
956 // The perceived "open a chat" latency: store render + fetch + fold.
957 let span = Perf.begin("feed.openThread", slowMs: 1500)
958 defer {
959 loadingHistory = false
960 lastHistoryLoad = Date()
961 span.end("\(messages.count) messages")
962 }
963 // INSTANT open: the local store (decrypted once, at ingest) renders
964 // before the network answers; the fetch below then converges by
965 // upsert. Cold threads just wait for the fetch as before.
966 if messages.isEmpty {
967 let cached = await MessageStore.shared.page(instance: instance.id, thread: threadID)
968 if !cached.isEmpty, messages.isEmpty {
969 publishStored(cached)
970 // Only mark ready if something actually rendered: rows stored
971 // before a projection existed (app-thread events) can filter
972 // to NOTHING, and ready-but-empty is a blank chat.
973 if !messages.isEmpty {
974 ready = true
975 lastOlderLoad = Date()
976 }
977 }
978 }
979 let rows = await api.messages(thread: threadID, after: 0)
980 await foldPageDetached(rows)
981 ready = true
982 // Start the scroll-back cooldown NOW: the top row materialises during
983 // this very layout pass (before the pin-to-bottom runs), and an
984 // immediate loadOlder would anchor the view at the top instead.
985 lastOlderLoad = Date()
986 status = "\(messages.count) messages"
987 }
988
989 /// Fold the previous page of history (the web's scroll-back). Idempotent
990 /// (fold upserts by id) and self-limiting: an empty page marks the thread
991 /// exhausted, and a cooldown stops the prepend itself re-triggering the
992 /// materialisation-based trigger (which once cascaded the entire history
993 /// into the list in one go).
994 public func loadOlder() async {
995 guard ready, !loadingOlder, !exhausted, let api,
996 Date().timeIntervalSince(lastOlderLoad) > 0.5,
997 let oldest = messages.first?.id else { return }
998 loadingOlder = true
999 // The scroll-back stall's fold half (#1211): fetch + decrypt + merge.
1000 // The layout storm the publish triggers lands in the frames AFTER this
1001 // span — the hitch recorder owns that side; adjacency in the ledger
1002 // ties them together.
1003 let span = Perf.begin("feed.loadOlder", slowMs: 1000)
1004 var folded = 0
1005 defer {
1006 loadingOlder = false
1007 lastOlderLoad = Date()
1008 span.end("\(folded) rows")
1009 }
1010 let rows = await api.messagesBefore(thread: threadID, before: oldest)
1011 guard !rows.isEmpty else {
1012 exhausted = true
1013 status = "beginning of chat · \(messages.count) messages"
1014 return
1015 }
1016 folded = rows.count
1017 await foldPageDetached(rows)
1018 status = "\(messages.count) messages"
1019 }
1020
1021 // A 200-message page is 200 AES-GCM opens + JSON
1022 // decodes + Message builds — done on the main actor (this class's home)
1023 // it froze the UI on open, worst on long chats and the prewarm burst.
1024 // The whole page builds detached; main only merges (dictionary upsert,
1025 // ONE sort — per-message fold was a linear search + full re-sort EACH,
1026 // the length-scaled stall).
1027 // Map a stored (already-decrypted) page into view messages and publish.
1028 private func publishStored(_ stored: [MessageStore.StoredMessage]) {
1029 var list: [Message] = stored.compactMap(viewMessage)
1030 Self.refreshReplyCounts(&list)
1031 messages = list
1032 }
1033
1034 // One stored row → one view message (nil for non-chat payloads).
1035 private func viewMessage(_ s: MessageStore.StoredMessage) -> Message? {
1036 guard s.payload.isRenderableChat else { return nil }
1037 let p = s.payload
1038 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1039 ?? (!myScreenName.isEmpty && p.name == myScreenName)
1040 return Message(id: s.id, name: p.name ?? "", text: p.text ?? "",
1041 mine: mine, at: s.at, action: p.action ?? false,
1042 edited: s.edited, attachments: p.attachments ?? [],
1043 reply: p.replyTo, reactions: s.reactions, raw: p)
1044 }
1045
1046 /// Merge store rows this model never folded live. The NSE ingests pushed
1047 /// messages (and background refresh syncs) while the app is suspended —
1048 /// its sockets down, parked models frozen — so re-attaching a parked model
1049 /// (NativeFeedModelCache) or foregrounding pulls those rows from disk and
1050 /// the tapped chat is caught up instantly; the socket's history re-pull
1051 /// then converges reactions/edits. Rows already in memory are left alone
1052 /// (the live fold's state is richer than an NSE row).
1053 public func catchUpFromStore() async {
1054 guard ready else { return } // the initial load reads the store itself
1055 let stored = await MessageStore.shared.page(instance: instance.id, thread: threadID)
1056 var list = messages
1057 let known = Set(list.map(\.id))
1058 var changed = false
1059 for s in stored where !known.contains(s.id) {
1060 guard let msg = viewMessage(s) else { continue }
1061 list.append(msg)
1062 changed = true
1063 }
1064 guard changed else { return }
1065 list.sort { $0.id < $1.id }
1066 Self.refreshReplyCounts(&list)
1067 messages = list
1068 }
1069
1070 /// The message ids a fetched page's delete tombstones evict (#582): each
1071 /// tombstone row is the stored form of the live "delete" frame, for
1072 /// clients that were offline when it fired. Pure (hence nonisolated) —
1073 /// shared with BackgroundSync and pinned by the tombstone_delete vector.
1074 nonisolated public static func tombstoneEvictions(_ rows: [ServerMessage]) -> [Int64] {
1075 rows.compactMap { row in
1076 guard let t = row.tombstone_of, t > 0 else { return nil }
1077 return t
1078 }
1079 }
1080
1081 private func foldPageDetached(_ rows: [ServerMessage]) async {
1082 guard let key else { return }
1083 // Deletes that happened while this device was away: evict before the
1084 // fold, mirroring the live "delete" frame (list, group-app events,
1085 // store) — minus its implosion animation; a reconcile is silent.
1086 let evict = Self.tombstoneEvictions(rows)
1087 if !evict.isEmpty {
1088 var pruned = messages
1089 pruned.removeAll { evict.contains($0.id) }
1090 if pruned.count != messages.count {
1091 Self.refreshReplyCounts(&pruned)
1092 messages = pruned
1093 }
1094 var unfolded = false
1095 for id in evict where groupAppsEvents.removeValue(forKey: id) != nil { unfolded = true }
1096 if unfolded { recomputeGroupApps() }
1097 let instanceID = instance.id
1098 let tid = threadID
1099 Task.detached(priority: .utility) {
1100 for id in evict {
1101 await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id)
1102 }
1103 }
1104 }
1105 let myUserId = self.myUserId
1106 let screenName = self.myScreenName
1107 let wantGroupApps = threadKind == "group"
1108 let foldSpan = Perf.begin("feed.foldPage", slowMs: 250)
1109 let (fresh, stored, appEvents, reactionRows, unprojectedRows):
1110 ([Message], [MessageStore.StoredMessage],
1111 [(Int64, GroupApps.Event)], [MessagePayload],
1112 [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) =
1113 await Task.detached(priority: .userInitiated) {
1114 var out: [Message] = []
1115 var toStore: [MessageStore.StoredMessage] = []
1116 var events: [(Int64, GroupApps.Event)] = []
1117 var reacts: [MessagePayload] = []
1118 var unproj: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)] = []
1119 for row in rows {
1120 if (row.tombstone_of ?? 0) > 0 { continue } // evicted above; empty blob, nothing to fold
1121 if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) {
1122 events.append((row.id, e))
1123 }
1124 // Reaction events live in history as ordinary encrypted rows —
1125 // collect them here (row order = chronological) and fold them
1126 // after the page's messages have merged.
1127 if let p = try? key.decryptJSON(MessagePayload.self, blob: row.blob),
1128 p.isReactionEvent {
1129 reacts.append(p)
1130 continue
1131 }
1132 guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps,
1133 myUserId: myUserId)
1134 else {
1135 // An app event with no built-in projection: stash it for
1136 // the app-host query (docs/native-apps.md phase 5) — the
1137 // web engine answers with the app's own chat line, or
1138 // null (silent, exactly as it folds today).
1139 if let ev = AppChatProjection.unprojected(row.blob, key: key) {
1140 unproj.append((row.id, row.created_at, ev))
1141 }
1142 continue
1143 }
1144 toStore.append(.init(id: row.id, at: row.created_at,
1145 edited: (row.edited_at ?? 0) > 0,
1146 reactions: [], payload: p))
1147 guard p.isRenderableChat else { continue }
1148 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1149 ?? (!screenName.isEmpty && p.name == screenName)
1150 out.append(Message(id: row.id, name: p.name ?? "", text: p.text ?? "",
1151 mine: mine, at: row.created_at,
1152 action: p.action ?? false,
1153 edited: (row.edited_at ?? 0) > 0,
1154 attachments: p.attachments ?? [], reply: p.replyTo,
1155 reactions: [], raw: p))
1156 }
1157 return (out, toStore, events, reacts, unproj)
1158 }.value
1159 foldSpan.end("\(rows.count) rows")
1160 // Tamper-evidence (history): compare gossip from every message + reaction
1161 // this page decrypted — the server can route forged messages via
1162 // catch-up, so history must count too, not just live frames.
1163 if let integrity {
1164 // One coalesced save for the whole page — gossipIn otherwise writes
1165 // UserDefaults once per message (see IntegrityMonitor.withCoalescedSave).
1166 integrity.withCoalescedSave {
1167 for s in stored { noteGossip(s.payload, own: s.payload.uid == myUserId) }
1168 for p in reactionRows { noteGossip(p, own: p.uid == myUserId) }
1169 }
1170 }
1171 if !appEvents.isEmpty {
1172 for (id, e) in appEvents { groupAppsEvents[id] = e }
1173 recomputeGroupApps()
1174 }
1175 let instanceID = instance.id
1176 let tid = threadID
1177 Task.detached(priority: .utility) {
1178 await MessageStore.shared.upsert(instance: instanceID, thread: tid, stored)
1179 }
1180 var list = messages
1181 var indexByID = Dictionary(uniqueKeysWithValues: list.enumerated().map { ($1.id, $0) })
1182 for var msg in fresh {
1183 if let i = indexByID[msg.id] {
1184 // Page rows carry no tally — reactions fold from events below.
1185 // Keep whatever this model already folded (the keyed fold makes
1186 // the page's events re-apply idempotently on top).
1187 msg.reactionEvents = list[i].reactionEvents
1188 msg.reactions = list[i].reactions
1189 if !msg.edited { msg.edited = list[i].edited }
1190 list[i] = msg
1191 } else {
1192 list.append(msg)
1193 indexByID[msg.id] = list.count - 1
1194 }
1195 }
1196 // Fold the page's reaction events into their targets, then mirror the
1197 // touched tallies so a reopened chat renders them from disk. Events
1198 // whose target is outside the loaded window are dropped, like the web.
1199 var touched: [Int64] = []
1200 for p in reactionRows {
1201 guard let sid = p.targetSid, let i = indexByID[sid] else { continue }
1202 if Self.foldReaction(p, into: &list[i], me: myUserId), !touched.contains(sid) {
1203 touched.append(sid)
1204 }
1205 }
1206 if !touched.isEmpty {
1207 let tallies = touched.map { sid in (sid, list[indexByID[sid]!].reactions) }
1208 Task.detached(priority: .utility) {
1209 for (sid, tally) in tallies {
1210 await MessageStore.shared.updateReactions(instance: instanceID, thread: tid,
1211 id: sid, tally)
1212 }
1213 }
1214 }
1215 list.sort { $0.id < $1.id }
1216 Self.refreshReplyCounts(&list)
1217 messages = list
1218 noteUnprojected(unprojectedRows)
1219 }
1220
1221 private func apply(_ frame: ChatSocket.Frame) {
1222 guard frame.thread_id == threadID else { return }
1223 switch frame.type {
1224 case "msg":
1225 // Ephemeral frames are relay-only presence — fold typing beats,
1226 // render nothing.
1227 if frame.ephemeral == true {
1228 if let blob = frame.blob { foldTypingBlob(blob) }
1229 return
1230 }
1231 guard let id = frame.id, let blob = frame.blob, let key else { return }
1232 let at = frame.created_at ?? 0
1233 let wantGroupApps = threadKind == "group"
1234 let myUserId = self.myUserId // captured — the decrypt closure runs off-main
1235 // A stored frame is either an encrypted reaction event or a
1236 // chat/app message (with an optional group-apps projection) —
1237 // decrypt off-main either way, then fold the right shape on main.
1238 decryptThenFold({ () -> InboundStored? in
1239 if let p = try? key.decryptJSON(MessagePayload.self, blob: blob),
1240 p.isReactionEvent {
1241 return .reaction(p)
1242 }
1243 let payload = Self.renderable(blob, key: key, group: wantGroupApps,
1244 myUserId: myUserId)
1245 return .message(payload,
1246 wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil,
1247 payload == nil ? AppChatProjection.unprojected(blob, key: key) : nil)
1248 }) { model, inbound in
1249 switch inbound {
1250 case .reaction(let p):
1251 model.applyReactionEvent(p)
1252 case .message(let payload, let groupApps, let unprojected):
1253 model.foldDecrypted(id: id, at: at, payload: payload,
1254 edited: false, reactions: [])
1255 if let e = groupApps { model.foldGroupAppsEvent(id: id, e) }
1256 if let ev = unprojected { model.noteUnprojected([(id, at, ev)]) }
1257 }
1258 }
1259 case "typing":
1260 // The dedicated relay frame — legacy-client compat only; everything
1261 // current (web, native, both bot sidecars) sends the chat.typing
1262 // ephemeral instead. Same payload, so fold it the same way.
1263 if let blob = frame.blob { foldTypingBlob(blob) }
1264 case "edit":
1265 guard let id = frame.id, let blob = frame.blob, let key else { return }
1266 decryptThenFold({ try? key.decryptJSON(MessagePayload.self, blob: blob) }) { model, p in
1267 guard let i = model.messages.firstIndex(where: { $0.id == id }) else { return }
1268 model.messages[i].text = p.text ?? ""
1269 model.messages[i].edited = true
1270 // Keep the source payload current too — edit() re-encrypts from
1271 // it and reply snippets quote it; a stale raw silently reverted
1272 // a second edit back to the pre-first-edit text.
1273 model.messages[i].raw = p
1274 model.storeMirror(.init(id: id, at: model.messages[i].at, edited: true,
1275 reactions: model.messages[i].reactions, payload: p))
1276 }
1277 case "delete":
1278 guard let id = frame.id else { return }
1279 // A deleted groupapps event un-folds like the web's refold-on-delete.
1280 if groupAppsEvents.removeValue(forKey: id) != nil { recomputeGroupApps() }
1281 var list = messages
1282 list.removeAll { $0.id == id }
1283 Self.refreshReplyCounts(&list)
1284 // The implosion: rows carry a scale+fade removal transition, and
1285 // deletes are the one mutation that publishes inside an animation
1286 // (arrivals/prepends stay unanimated by design).
1287 withAnimation(.easeIn(duration: 0.22)) { messages = list }
1288 let instanceID = instance.id
1289 let tid = threadID
1290 Task.detached(priority: .utility) {
1291 await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id)
1292 }
1293 case "dm_closed", "dm_reopened":
1294 // Either side ended (or revived) this DM (issue #578) — the open
1295 // pane swaps between composer and closed banner live, the web's
1296 // renderActiveSurfaces on these frames. The sidebar row rides the
1297 // store's socket separately.
1298 dmClosed = frame.type == "dm_closed"
1299 default:
1300 break // presence/read ride the store's socket; unknown types ignored
1301 }
1302 }
1303
1304 // What a stored "msg" frame decrypts to: a reaction event folds into its
1305 // target's tally; anything else is a message (or app-event projection),
1306 // possibly carrying a group-apps event alongside.
1307 private enum InboundStored: Sendable {
1308 case reaction(MessagePayload)
1309 case message(MessagePayload?, GroupApps.Event?, AppChatProjection.UnprojectedAppEvent?)
1310 }
1311
1312 // Fold one reaction event into a message and re-derive its rendered tally
1313 // (per-emoji count + mine, emoji-sorted — chat.js's derived array; the
1314 // chat.json vectors pin the shape). Keyed by (emoji, uid): the latest
1315 // event wins, so echoes and history re-pulls fold idempotently. Returns
1316 // false when the event is malformed, targets another message, or removes
1317 // a reaction that was never folded.
1318 nonisolated static func foldReaction(_ p: MessagePayload, into m: inout Message,
1319 me: String) -> Bool {
1320 guard p.isReactionEvent, let emoji = p.emoji, let on = p.on,
1321 let uid = p.uid, p.targetSid == m.id else { return false }
1322 if let j = m.reactionEvents.firstIndex(where: { $0.emoji == emoji && $0.uid == uid }) {
1323 m.reactionEvents[j].on = on
1324 m.reactionEvents[j].ts = p.ts
1325 } else if on {
1326 m.reactionEvents.append(ReactionEvent(emoji: emoji, uid: uid, on: true, ts: p.ts))
1327 } else {
1328 return false
1329 }
1330 var reactors: [String: [String]] = [:]
1331 for ev in m.reactionEvents where ev.on {
1332 reactors[ev.emoji, default: []].append(ev.uid)
1333 }
1334 m.reactions = reactors.keys.sorted().map {
1335 Reaction(emoji: $0, count: reactors[$0]!.count,
1336 mine: !me.isEmpty && reactors[$0]!.contains(me))
1337 }
1338 return true
1339 }
1340
1341 /// Fold a live reaction event (or the local optimistic copy) into the
1342 /// target message, and mirror the derived tally into the local store so a
1343 /// reopened chat renders it from disk.
1344 private func applyReactionEvent(_ payload: MessagePayload) {
1345 noteGossip(payload, own: payload.uid == myUserId) // tamper-evidence (live reactions)
1346 guard let sid = payload.targetSid,
1347 let i = messages.firstIndex(where: { $0.id == sid }) else { return }
1348 var m = messages[i]
1349 guard Self.foldReaction(payload, into: &m, me: myUserId) else { return }
1350 messages[i] = m
1351 let tally = m.reactions
1352 let instanceID = instance.id
1353 let tid = threadID
1354 Task.detached(priority: .utility) {
1355 await MessageStore.shared.updateReactions(instance: instanceID, thread: tid,
1356 id: sid, tally)
1357 }
1358 }
1359
1360 // ---- typing (the web's chat.typing beats) ------------------------------
1361
1362 private struct TypingPayload: Decodable, Sendable {
1363 let type: String?
1364 let name: String?
1365 let uid: String?
1366 let expiry: Double?
1367 }
1368
1369 // A beat arrives for every keystroke burst of every other member — decrypt
1370 // it off main like any other frame, then fold the plaintext here.
1371 private func foldTypingBlob(_ blob: String) {
1372 guard let key else { return }
1373 decryptThenFold({ try? key.decryptJSON(TypingPayload.self, blob: blob) }) { model, p in
1374 model.foldTyping(p)
1375 }
1376 }
1377
1378 // Fold one beat, mirroring chat.js: skip our own (uid, else name), clamp
1379 // expiry to 0…60s, 0 removes; a sweep timer retires stale names.
1380 private func foldTyping(_ p: TypingPayload) {
1381 guard p.type == "chat.typing", let name = p.name, !name.isEmpty else { return }
1382 if let uid = p.uid, !myUserId.isEmpty {
1383 if uid == myUserId { return }
1384 } else if !myScreenName.isEmpty, name == myScreenName {
1385 return
1386 }
1387 let secs = min(max(p.expiry ?? 0, 0), 60)
1388 if secs == 0 {
1389 typingExpiry.removeValue(forKey: name)
1390 } else {
1391 typingExpiry[name] = Date().addingTimeInterval(secs)
1392 }
1393 refreshTyping()
1394 typingSweep?.invalidate()
1395 if !typingExpiry.isEmpty {
1396 typingSweep = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
1397 Task { @MainActor in self?.refreshTyping() }
1398 }
1399 }
1400 }
1401
1402 private func refreshTyping() {
1403 let now = Date()
1404 typingExpiry = typingExpiry.filter { $0.value > now }
1405 let names = typingExpiry.keys.sorted()
1406 if names != typing { typing = names }
1407 if typingExpiry.isEmpty {
1408 typingSweep?.invalidate()
1409 typingSweep = nil
1410 }
1411 }
1412
1413 private struct TypingOut: Encodable {
1414 let type = "chat.typing"
1415 let name: String
1416 let ts: Int64
1417 var uid: String?
1418 let expiry: Int
1419 }
1420
1421 /// Composer keystroke plumbing (chat.js's beats): call with `true` on
1422 /// every keystroke while there's text — a beat goes out at most every 10s,
1423 /// each carrying a 15s expiry — and `false` when the draft empties or a
1424 /// message sends, which clears the indicator immediately for everyone.
1425 public func typingBeat(active: Bool) {
1426 idleTimer?.invalidate()
1427 if active {
1428 // Dry-up guard: no keystroke for 10s → clear without waiting for expiry.
1429 idleTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in
1430 Task { @MainActor in self?.typingBeat(active: false) }
1431 }
1432 guard Date().timeIntervalSince(lastBeatSent) > 10 else { return }
1433 sendTypingBeat(expiry: 15)
1434 beating = true
1435 } else if beating {
1436 sendTypingBeat(expiry: 0)
1437 beating = false
1438 lastBeatSent = .distantPast
1439 }
1440 }
1441
1442 private func sendTypingBeat(expiry: Int) {
1443 guard let key, let socket else { return }
1444 var payload = TypingOut(name: myScreenName,
1445 ts: Int64(Date().timeIntervalSince1970 * 1000), expiry: expiry)
1446 if !myUserId.isEmpty { payload.uid = myUserId }
1447 lastBeatSent = Date()
1448 let tid = threadID
1449 let sendable = payload
1450 enqueueOffMain {
1451 guard let blob = try? key.encryptJSON(sendable),
1452 let data = try? JSONEncoder().encode(EphemeralFrame(thread_id: tid, blob: blob)),
1453 let json = String(data: data, encoding: .utf8) else { return }
1454 socket.send(json)
1455 }
1456 }
1457
1458 private struct EphemeralFrame: Encodable {
1459 let type = "msg"
1460 let thread_id: String
1461 let blob: String
1462 let ephemeral = true
1463 }
1464
1465 // A live frame's already-decrypted payload folds into the published list
1466 // (the decrypt happened on the off-main lane), mirrored into the local
1467 // store.
1468 private func foldDecrypted(id: Int64, at: Int64, payload: MessagePayload?,
1469 edited: Bool, reactions: [Reaction]) {
1470 noteGossip(payload, own: payload?.uid == myUserId) // tamper-evidence (live)
1471 // Our own message echoing back: cancel its "sending…" timer — the fold
1472 // below upgrades the optimistic copy in place (matched on _cid).
1473 if let c = payload?._cid { clearSlowTimer(c) }
1474 var list = messages
1475 fold(id: id, at: at, payload: payload,
1476 edited: edited, reactions: reactions, into: &list)
1477 Self.refreshReplyCounts(&list)
1478 messages = list
1479 if let payload {
1480 storeMirror(.init(id: id, at: at, edited: edited,
1481 reactions: reactions, payload: payload))
1482 }
1483 }
1484
1485 // Fire-and-forget mirror writes — the store is an actor, so nothing here
1486 // touches the main thread.
1487 private func storeMirror(_ m: MessageStore.StoredMessage) {
1488 let instanceID = instance.id
1489 let tid = threadID
1490 Task.detached(priority: .utility) {
1491 await MessageStore.shared.upsert(instance: instanceID, thread: tid, [m])
1492 }
1493 }
1494
1495 // Fold one already-decrypted payload into a working list (upsert by id —
1496 // history refetch and the live echo of an already-seen message must not
1497 // duplicate). Batch callers fold a whole page before publishing once.
1498
1499 // ---- app-host projection queries (docs/native-apps.md phase 5) --------
1500 // AppChatProjection is the FAST PATH (schema apps + installs, sync, no
1501 // flash); every other app event queries the app host — the web engine
1502 // that owns chatMessage/groupFeedMessage — and its answer folds in as the
1503 // same synthesized bubble renderable() builds. The platform feed views
1504 // install the projector (a CommandHost call); without one, unknown
1505 // events stay silent exactly as before.
1506
1507 /// Set by the platform feed view: project one decrypted event JSON
1508 /// (group = a group feed) into its chat line, or nil for silent.
1509 public var projector: (@MainActor (String, Bool) async -> AppChatProjection.ProjectedLine?)? {
1510 didSet { fillProjections() }
1511 }
1512 private var pendingProjection: [Int64: (at: Int64, ev: AppChatProjection.UnprojectedAppEvent)] = [:]
1513 private var projectionAsked: Set<Int64> = []
1514 private var projectionTask: Task<Void, Never>?
1515
1516 private func noteUnprojected(_ batch: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) {
1517 var added = false
1518 for (id, at, ev) in batch where !projectionAsked.contains(id) {
1519 pendingProjection[id] = (at, ev)
1520 added = true
1521 }
1522 if added { fillProjections() }
1523 }
1524
1525 private func fillProjections() {
1526 guard projectionTask == nil, projector != nil, !pendingProjection.isEmpty else { return }
1527 projectionTask = Task { @MainActor [weak self] in
1528 defer { self?.projectionTask = nil }
1529 let group = self?.threadKind == "group"
1530 while let self, let projector = self.projector,
1531 let (id, item) = self.pendingProjection.min(by: { $0.key < $1.key }) {
1532 self.pendingProjection.removeValue(forKey: id)
1533 self.projectionAsked.insert(id)
1534 guard let line = await projector(item.ev.eventJSON, group),
1535 !line.text.isEmpty else { continue }
1536 // The same synthesized payload renderable() builds: an action
1537 // line keeps the actor's attribution; a bot bubble wears the
1538 // app's title. Folding via foldDecrypted also mirrors it into
1539 // the store, so a reopen renders it without re-querying.
1540 var text = line.text
1541 if group, line.action {
1542 let you = item.ev.uid == self.myUserId && !self.myUserId.isEmpty
1543 text = "\(you ? "You" : (item.ev.name.isEmpty ? "Someone" : item.ev.name)) \(text)"
1544 }
1545 let p = MessagePayload(type: "chat",
1546 uid: line.action ? item.ev.uid : nil,
1547 name: line.action ? item.ev.name : line.title,
1548 text: text,
1549 ts: item.ev.ts.map { Int64($0) },
1550 action: line.action,
1551 attachments: nil,
1552 replyTo: nil)
1553 self.foldDecrypted(id: id, at: item.at, payload: p, edited: false, reactions: [])
1554 }
1555 }
1556 }
1557
1558 // App-thread events (todo.add, remind.fire, …) aren't type:"chat" — the
1559 // companion apps' Chat view projects them into chat lines on the web, and
1560 // this is that projection natively: decode the richer event and synthesize
1561 // a renderable payload (bot bubble or action line).
1562 nonisolated public static func renderable(_ blob: String, key: GroupKey,
1563 group: Bool = false,
1564 myUserId: String = "") -> MessagePayload? {
1565 guard let p = try? key.decryptJSON(MessagePayload.self, blob: blob) else { return nil }
1566 if p.isRenderableChat { return p }
1567 guard let ev = try? key.decryptJSON(AppChatProjection.AppEvent.self, blob: blob),
1568 let proj = AppChatProjection.project(ev, group: group) else { return nil }
1569 // A GROUP action line wears its actor in the text itself ("You added
1570 // “buy milk” to the to-dos") — the web's foldGroupAppEventBubble
1571 // prefix, mirrored, so the centred native action row (which renders
1572 // text alone) says who acted. Personal threads keep the terse line.
1573 var text = proj.text
1574 if group, proj.action {
1575 let you = !myUserId.isEmpty && p.uid == myUserId
1576 text = "\(you ? "You" : (p.name ?? "Someone")) \(text)"
1577 }
1578 return MessagePayload(type: "chat",
1579 uid: proj.action ? p.uid : nil,
1580 name: proj.action ? p.name : AppChatProjection.title(proj.appID),
1581 text: text,
1582 ts: p.ts,
1583 action: proj.action,
1584 attachments: nil,
1585 replyTo: nil)
1586 }
1587
1588 private func fold(id: Int64, at: Int64, payload: MessagePayload?,
1589 edited: Bool, reactions: [Reaction], into list: inout [Message],
1590 cid: String? = nil, pending: Bool = false) {
1591 guard let p = payload, p.isRenderableChat else { return }
1592 let attachments = p.attachments ?? []
1593 let text = p.text ?? ""
1594 // Own-message check mirrors the web (ownMessage in ui.js): uid when the
1595 // payload carries one, else the screen name (old payloads).
1596 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1597 ?? (!myScreenName.isEmpty && p.name == myScreenName)
1598 // The client id to pair on: the explicit optimistic-fold argument, else
1599 // the one riding in the echo's payload. A confirming echo has pending ==
1600 // false and reads it from `p._cid`; a fresh optimistic copy passes it in.
1601 let pairCid = cid ?? p._cid
1602 var msg = Message(id: id, name: p.name ?? "", text: text, mine: mine, at: at,
1603 action: p.action ?? false, edited: edited,
1604 attachments: attachments, reply: p.replyTo, reactions: reactions,
1605 raw: p)
1606 msg.cid = pending ? pairCid : nil
1607 msg.pending = pending
1608 msg.sentAt = pending ? Int64(Date().timeIntervalSince1970 * 1000) : nil
1609 Self.foldOptimistic(msg, pairCid: pairCid, pending: pending, into: &list)
1610 }
1611
1612 // The optimistic-send lifecycle on the message list — factored out (nonisolated
1613 // static, like foldReaction) so the event vectors can drive it directly. Three
1614 // cases, in order:
1615 // • a fresh OPTIMISTIC copy (pending) → appended, sorted last by its synthetic
1616 // pending id;
1617 // • our own ECHO confirming one (same cid, now a real server id) → the pending
1618 // copy is UPGRADED in place: it adopts the server id/time and drops its
1619 // pending flags, keeping its row so nothing is destroyed and re-created;
1620 // • anything else (another member's message, a history re-pull, a reconnect
1621 // replay) → the existing upsert-by-id, which stays idempotent.
1622 nonisolated static func foldOptimistic(_ msg: Message, pairCid: String?,
1623 pending: Bool, into list: inout [Message]) {
1624 if !pending, let c = pairCid,
1625 let i = list.firstIndex(where: { $0.cid == c && $0.pending }) {
1626 var up = list[i]
1627 up.id = msg.id // adopt the real server id (re-sorts into place)
1628 up.at = msg.at
1629 up.pending = false
1630 up.slow = false
1631 up.cid = nil
1632 up.text = msg.text
1633 up.attachments = msg.attachments
1634 up.reply = msg.reply
1635 up.raw = msg.raw
1636 if !msg.reactions.isEmpty { up.reactions = msg.reactions }
1637 if msg.edited { up.edited = true }
1638 list[i] = up
1639 list.sort { $0.id < $1.id }
1640 return
1641 }
1642 if let i = list.firstIndex(where: { $0.id == msg.id }) {
1643 // A live echo carries no reactions/edit state — keep what we have
1644 // unless the refold supplies fresher values.
1645 var m = msg
1646 if m.reactions.isEmpty { m.reactions = list[i].reactions }
1647 if !m.edited { m.edited = list[i].edited }
1648 list[i] = m
1649 } else {
1650 list.append(msg)
1651 list.sort { $0.id < $1.id }
1652 }
1653 }
1654
1655 // Pending optimistic copies live above every real server id so they sort to
1656 // the bottom of the feed until confirmed. 2^62 is unreachable by real
1657 // (auto-increment) message ids; the counter keeps concurrent pending sends in
1658 // send order.
1659 private static let pendingIDBase: Int64 = 1 << 62
1660 private var pendingIDSeq: Int64 = 0
1661 // The "still sending" timers, keyed by client id — each flips its copy to the
1662 // slow state 5s on if the echo hasn't landed. The echo cancels it.
1663 private var slowTimers: [String: Task<Void, Never>] = [:]
1664
1665 // Fold our own just-sent message straight into the list as a pending copy
1666 // (mirrors shell.js optimisticEmit). It shows instantly; its echo upgrades it.
1667 private func foldOptimisticSend(cid: String, text: String, ts: Int64,
1668 attachments: [Attachment], replyTo: ReplyRef?) {
1669 let localID = Self.pendingIDBase &+ pendingIDSeq
1670 pendingIDSeq &+= 1
1671 var p = MessagePayload()
1672 p.text = text
1673 p.name = myScreenName
1674 p.ts = ts
1675 if !myUserId.isEmpty { p.uid = myUserId }
1676 if !attachments.isEmpty { p.attachments = attachments }
1677 p.replyTo = replyTo
1678 p._cid = cid
1679 var list = messages
1680 fold(id: localID, at: ts / 1000, payload: p, edited: false, reactions: [],
1681 into: &list, cid: cid, pending: true)
1682 Self.refreshReplyCounts(&list)
1683 messages = list
1684 // Stay-pending on failure (web parity): we never remove the copy, only
1685 // surface a delay. After 5s unconfirmed, flip it to the slow state.
1686 slowTimers[cid] = Task { [weak self] in
1687 try? await Task.sleep(nanoseconds: 5_000_000_000)
1688 guard !Task.isCancelled else { return }
1689 self?.markSlow(cid: cid)
1690 }
1691 }
1692
1693 private func markSlow(cid: String) {
1694 slowTimers[cid] = nil
1695 guard let i = messages.firstIndex(where: { $0.cid == cid && $0.pending }) else { return }
1696 messages[i].slow = true
1697 }
1698
1699 // Our own echo has landed (or a confirming path ran) — cancel any slow timer.
1700 private func clearSlowTimer(_ cid: String) {
1701 slowTimers[cid]?.cancel()
1702 slowTimers[cid] = nil
1703 }
1704
1705 // Resolve every reply's thread ROOT (message id → root id). New replies
1706 // point straight at the top message, so this is one hop; legacy chains (a
1707 // reply quoting another reply) are chased through the loaded window,
1708 // stopping at the deepest pointer we can see. Hop-capped so a hostile
1709 // payload can't cycle forever. Mirrors chat.js's rootRef — pinned by the
1710 // reply_thread_grouping vector (test/event-vectors/chat.json).
1711 nonisolated static func threadRoots(_ list: [Message]) -> [Int64: Int64] {
1712 var byID: [Int64: Message] = [:]
1713 for m in list { byID[m.id] = m }
1714 var roots: [Int64: Int64] = [:]
1715 for m in list {
1716 guard var ref = m.reply, let refID = ref.id, refID > 0 else { continue }
1717 var hops = 0
1718 while hops < 100, let id = ref.id,
1719 let parent = byID[id], let up = parent.reply, (up.id ?? 0) > 0 {
1720 ref = up
1721 hops += 1
1722 }
1723 if let rootID = ref.id { roots[m.id] = rootID }
1724 }
1725 return roots
1726 }
1727
1728 /// The ReplyRef a reply to `m` should carry: replying to any message in a
1729 /// thread continues that thread, so the ref always names the thread's TOP
1730 /// message — never the tapped reply. When the root is loaded its
1731 /// name/snippet are snapshotted fresh; an unloaded root falls back to the
1732 /// deepest quoted ref we can see. Mirrors chat.js's replyTarget.
1733 nonisolated static func replyTarget(in list: [Message], for m: Message) -> ReplyRef {
1734 guard let reply = m.reply, (reply.id ?? 0) > 0 else {
1735 return ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw))
1736 }
1737 let rootID = threadRoots(list)[m.id] ?? reply.id
1738 if let id = rootID, let root = list.first(where: { $0.id == id }) {
1739 return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
1740 }
1741 return ReplyRef(id: rootID, name: reply.name, snippet: reply.snippet)
1742 }
1743
1744 /// Composer entry point for both platforms' startReply.
1745 public func replyTarget(for m: Message) -> ReplyRef {
1746 Self.replyTarget(in: messages, for: m)
1747 }
1748
1749 /// What ⌘R replies to: the newest real message, yours or anyone else's
1750 /// (chat.js's `latest(false)`). Action lines are skipped — a /me-style
1751 /// line has no bubble to quote — and so are optimistic copies: their id is
1752 /// a synthetic pending one, so a reply staged on it would carry a sid the
1753 /// server has never heard of. (The web has that wart; there's no reason to
1754 /// port it.)
1755 nonisolated public static func latestReplyTarget(in list: [Message]) -> Message? {
1756 list.last { !$0.action && !$0.pending }
1757 }
1758
1759 /// What ⌘T reacts to: the newest message that ISN'T yours (chat.js's
1760 /// `latest(true)`) — reacting to your own message is what the right-click
1761 /// menu is for. Attribution falls back to `mine`, which already resolves
1762 /// uid-less legacy messages by name.
1763 nonisolated public static func latestReactionTarget(in list: [Message]) -> Message? {
1764 list.last { !$0.action && !$0.pending && !$0.mine }
1765 }
1766
1767 /// The reply thread rooted at `rootID`: the root message (when loaded)
1768 /// first, then every message whose RESOLVED thread root is that id, in
1769 /// server order. Legacy chains (a reply quoting another reply) resolve
1770 /// transitively via threadRoots, so they land in the same thread as their
1771 /// ancestors; when the root itself has scrolled out of the loaded window
1772 /// the replies still fold (the view renders the quoted snapshot as its
1773 /// header instead). Mirrors chat.js's threadItems — the thread view
1774 /// re-derives this from current state on every publish, never a cached
1775 /// list. Pinned by the web twin in test/apps.test.mjs.
1776 nonisolated public static func threadItems(in list: [Message], root rootID: Int64) -> [Message] {
1777 let roots = threadRoots(list)
1778 var out: [Message] = []
1779 if let root = list.first(where: { $0.id == rootID }) { out.append(root) }
1780 out.append(contentsOf: list.filter { roots[$0.id] == rootID })
1781 return out.sorted { $0.id < $1.id }
1782 }
1783
1784 /// The ReplyRef the open thread view stages on the composer (always the
1785 /// thread's root) — doubling as the header stand-in while the root isn't
1786 /// loaded. A loaded root snapshots its name/snippet fresh; otherwise fall
1787 /// back to the deepest quoted ref any loaded member of the thread carries
1788 /// (the web hands openThread the tapped message's replyTarget as this
1789 /// fallback; natively the tap only carries the root id, so it's re-derived
1790 /// from state).
1791 nonisolated public static func threadReplyRef(in list: [Message], root rootID: Int64) -> ReplyRef {
1792 if let root = list.first(where: { $0.id == rootID }) {
1793 return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
1794 }
1795 let roots = threadRoots(list)
1796 if let m = list.first(where: { roots[$0.id] == rootID }) {
1797 return replyTarget(in: list, for: m)
1798 }
1799 return ReplyRef(id: rootID)
1800 }
1801
1802 /// Live convenience for the containers' openThread/stageThreadReply.
1803 public func threadReplyRef(root rootID: Int64) -> ReplyRef {
1804 Self.threadReplyRef(in: messages, root: rootID)
1805 }
1806
1807 // Re-derive each message's reply-thread tally (chat.js's replyCounts fold):
1808 // every reply counts toward its resolved ROOT, so a legacy chain tallies on
1809 // the thread's top message. Runs once per batch, before the list publishes.
1810 // Static so the reply_thread_grouping vector test can drive it directly.
1811 nonisolated static func refreshReplyCounts(_ list: inout [Message]) {
1812 let roots = Self.threadRoots(list)
1813 var counts: [Int64: Int] = [:]
1814 var first: [Int64: Int64] = [:]
1815 for m in list {
1816 guard let root = roots[m.id], root > 0 else { continue }
1817 counts[root, default: 0] += 1
1818 if first[root] == nil { first[root] = m.id }
1819 }
1820 for i in list.indices {
1821 list[i].replyCount = counts[list[i].id] ?? 0
1822 list[i].firstReplyID = first[list[i].id]
1823 list[i].threadRootID = roots[list[i].id]
1824 }
1825 }
1826}
1827