eleven / ocho Public

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

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). The tally counts THREAD replies only — replies
41 // someone deliberately made in the thread (replyTo.thread); an
42 // ordinary inline quote reply is already visible in the feed and adds
43 // nothing here.
44 public var replyCount: Int = 0
45 public var firstReplyID: Int64?
46 // For a THREAD reply: the id of the thread it's in — the id its
47 // `replyTo` points straight at (the web's `replyRoot`). nil for
48 // everything else, inline quote replies included: they're in no
49 // thread. No container reads this today — they call the static
50 // `threadRootID(of:)` — but it stays as the native twin of the web's
51 // viewItems `replyRoot` (which IS read, for `data-reply-to`), and
52 // it's what the vectors' expected_threads.roots is asserted against on
53 // this side. Drop it only when the web drops `replyRoot`.
54 public var threadRootID: Int64?
55 // For a THREAD reply: its own view of that thread's tally — the SAME
56 // number its root's `replyCount` shows (the web's viewItems
57 // `threadCount`), so a quote card that doubles as a thread link can
58 // never disagree with the root it points at. 0 for anything that isn't
59 // a thread reply.
60 public var threadReplyCount: Int = 0
61 // "Convert to thread" (chat.js's `movedTo`): the thread a CONVERSION
62 // moved this message into, after the fact. Derived, never authoritative
63 // — the claim map on the model is the state, and applyConverts stamps
64 // this from it on every publish, so a message that arrives after its
65 // own conversion is stamped on arrival. nil for everything else.
66 public var movedTo: Int64?
67 // Whether an inline reply chain hangs under this message — the whole
68 // gate on offering "Convert to thread", and exactly
69 // `!inlineChainIDs(in:root:).isEmpty` (the vectors pin the two
70 // together). Folded once per publish rather than asked per bubble,
71 // because the containers build a MessageActions for every row on
72 // screen and the walk itself is O(list).
73 public var hasInlineChain: Bool = false
74 // The decrypted payload as sent — an edit re-encrypts {…raw, text}, so
75 // attachments/reply metadata survive the rewrite (chat.js does the same).
76 public var raw = MessagePayload()
77 // Optimistic-send state (web parity — chat.js pending/slow). `cid` is the
78 // client id this copy was sent under; it's non-nil ONLY while unconfirmed,
79 // and the matching key the echo upgrades on. `pending` = shown but not yet
80 // acknowledged by the server. `slow` flips true if it's still unconfirmed
81 // after 5s (the surface shows a subtle "Sending…"); `sentAt` (epoch ms) is
82 // when the optimistic copy was folded. A confirmed/received message carries
83 // cid == nil, pending == false.
84 public var cid: String? = nil
85 public var pending: Bool = false
86 public var slow: Bool = false
87 public var sentAt: Int64? = nil
88 }
89
90 // One decrypted reaction event (type="reaction" payload) as folded into a
91 // message — see Message.reactionEvents.
92 public struct ReactionEvent: Equatable, Sendable {
93 public var emoji: String
94 public var uid: String
95 public var on: Bool
96 public var ts: Int64?
97 }
98
99 /// The fixed reaction set the UI offers (mirrors chat.js REACTIONS — the
100 /// server can no longer enforce it, reactions are ciphertext to it now).
101 public static let allowedReactions = ["👍", "❤️", "😂", "😮", "😢", "🎉"]
102
103 /// The web's EDIT_WINDOW_MS: own messages are editable for 15 minutes
104 /// (the server enforces it; this just decides whether to offer the action).
105 public func canEdit(_ m: Message) -> Bool {
106 m.mine && !m.action && Date().timeIntervalSince1970 - TimeInterval(m.at) < 15 * 60
107 }
108
109 /// The name to show above a bubble: the sender's CURRENT roster name
110 /// (looked up by the uid inside the ciphertext), falling back to the name
111 /// snapshot the sender stamped into the payload at send time. The lookup
112 /// heals messages sent before a member picked a name — permanently
113 /// nameless snapshots — and retitles history after a rename; the snapshot
114 /// still covers uid-less legacy messages and members gone from the roster.
115 /// Twin: the senderName resolution in chat.js (buildMessageEl).
116 public func senderName(_ m: Message) -> String {
117 if let uid = m.raw.uid, let label = senderNames[uid], !label.isEmpty { return label }
118 return m.name
119 }
120
121 /// Whether two feed messages came from the same sender — the sender-run
122 /// rule (consecutive same-sender bubbles share one name header). Prefer
123 /// the uid (stable across renames, present even when the sender hadn't
124 /// picked a name yet); the payload's name snapshot is only the tiebreak
125 /// for uid-less legacy messages, and two nameless legacy messages never
126 /// group — "" == "" says nothing about who sent them.
127 /// Twin: sameSender in web/static/ui.js (chat.js viewItems).
128 public static func sameSender(_ a: Message?, _ b: Message) -> Bool {
129 guard let a else { return false }
130 if let au = a.raw.uid, !au.isEmpty, let bu = b.raw.uid, !bu.isEmpty { return au == bu }
131 return !a.name.isEmpty && a.name == b.name
132 }
133
134 /// EVERY loaded message, in server order — the fold's single source of
135 /// truth: reply tallies, the thread views (threadItems), jump targets and
136 /// unread/notification state all read this. The main feed does NOT: it
137 /// renders `feedMessages` below.
138 @Published public private(set) var messages: [Message] = [] {
139 didSet {
140 feedMessages = Self.mainFeedItems(messages)
141 // The Threads list is re-derived here rather than in a view body so
142 // the menu's row and the open list read one answer. Assigned only
143 // when it actually changed: a fold that leaves the list looking
144 // identical must not invalidate the header (the web guards the same
145 // repaint with threadListSignature).
146 let rows = Self.threadListItems(messages)
147 if rows != threadListRows { threadListRows = rows }
148 }
149 }
150 /// This chat's threads, newest activity first — the Threads list's rows,
151 /// and (empty or not) the gate on offering the list at all. Kept in step
152 /// with `messages` by the didSet above; the rule itself is the pure
153 /// `threadListItems(_:)`, vector-pinned.
154 @Published public private(set) var threadListRows: [ThreadListRow] = []
155 /// The MAIN FEED's list: `messages` minus the replies made in a thread
156 /// (replyTo.thread — see ReplyRef), the Slack model: those live only in
157 /// the thread view, and the feed shows their root's "N replies" tally
158 /// instead. Kept in step with `messages` by its didSet so every fold path
159 /// (history, live frames, edits, deletes, optimistic sends) publishes both
160 /// without knowing; the feed containers subscribe to THIS one, so a
161 /// thread reply arriving never scrolls, pins or regroups the transcript.
162 /// The rule itself is the pure mainFeedItems(_:), vector-pinned.
163 @Published public private(set) var feedMessages: [Message] = []
164 /// "Convert to thread": every conversion this chat has folded, as claims
165 /// (message id → the thread it moved into, plus the convert event's own id
166 /// as the tie-break). This is the STATE — `movedTo` on each message is its
167 /// projection, re-derived by refreshRollup on every publish, so a message
168 /// arriving after its own conversion still lands in the thread.
169 private var converts: [Int64: ThreadConvert] = [:]
170 @Published public private(set) var status = "loading…"
171 /// The roster's current decrypted names by uid, seeded and refreshed by
172 /// NativeFeedModelCache from each ThreadStore fold — the bubble-header
173 /// lookup (see senderName below).
174 @Published public var senderNames: [String: String] = [:]
175 /// Who this member has muted (#578) — seeded and refreshed by
176 /// NativeFeedModelCache from the ThreadStore's sealed prefs blob, the same
177 /// route senderNames takes. Render-level only: the fold below is
178 /// untouched, so unmuting reveals history intact.
179 @Published public var mutedPeople: Set<String> = []
180 /// The roster's @mention index (Mentions.swift) — seeded and refreshed by
181 /// NativeFeedModelCache from each ThreadStore fold, the senderNames route.
182 /// Render-level only: bubbles style "@PublishedName" runs against it.
183 @Published public var mentionIndex: MentionIndex?
184 /// Muted stubs revealed this session, by message id. Deliberately not
185 /// persisted: reopening the app re-folds — a mute means muted (the web's
186 /// revealedMuted set, mirrored).
187 @Published public private(set) var revealedMuted: Set<Int64> = []
188
189 public func revealMuted(_ id: Int64) { revealedMuted.insert(id) }
190
191 /// Whether a message renders as the quiet "Muted message" stub: a muted
192 /// member's, not mine, not yet revealed. uid-less legacy messages never
193 /// fold — there's nothing to key the mute on (web renderItem's exact rule).
194 public func foldsAsMuted(_ m: Message) -> Bool {
195 guard !m.mine, let uid = m.raw.uid, !uid.isEmpty else { return false }
196 return mutedPeople.contains(uid) && !revealedMuted.contains(m.id)
197 }
198 /// Names currently typing (insertion order), for typingSummary above the
199 /// composer. Fed by ephemeral chat.typing beats; entries expire on their
200 /// own clock.
201 @Published public private(set) var typing: [String] = []
202 private var typingExpiry: [String: Date] = [:]
203 private var typingSweep: Timer?
204 // Outgoing beat throttle (chat.js: a beat every 10s while typing, each
205 // carrying a 15s expiry; an explicit 0 clears on empty/send/idle).
206 private var lastBeatSent = Date.distantPast
207 private var beating = false
208 private var idleTimer: Timer?
209 /// True once scroll-back has reached the beginning of the thread.
210 @Published public private(set) var exhausted = false
211 /// An ended DM (issue #578): the server refuses new messages for both
212 /// parties, so the feed's composer gives way to the closed banner. Seeded
213 /// from the thread row, kept live by the dm_closed/dm_reopened frames.
214 @Published public private(set) var dmClosed = false
215 /// True once the first history page is in — the scroll-back trigger must
216 /// not fire during initial render.
217 @Published public private(set) var ready = false
218 private var loadingOlder = false
219 private var lastOlderLoad = Date.distantPast
220 // The connect-time re-pull's debounce inputs: only a RE-connect can have
221 // missed frames, and a pull in flight (or one that just landed) already
222 // covers the socket's first connect — see onStatus in start().
223 private var hasConnected = false
224 private var loadingHistory = false
225 private var lastHistoryLoad = Date.distantPast
226
227 private let instance: SavedInstance
228 /// The sender's screen name — stamped into every outgoing payload, and the
229 /// composer's /me line.
230 ///
231 /// `instance.screenName` is THIS DEVICE's copy, and it is routinely empty:
232 /// a space claimed from anything but an add-device link starts nameless
233 /// (SpaceCreate.claim) and the setup conversation names us later — on
234 /// whichever device answered, so a second device keeps its blank forever.
235 /// Everything it stamped then read as "Someone" wherever a payload's own
236 /// `name` is the only source (sidebar previews, push copy). So fall back to
237 /// my roster label: the name every other member already sees. Web twin:
238 /// stampName() in shell.js.
239 public var myScreenName: String {
240 instance.screenName.isEmpty ? (senderNames[myUserId] ?? "") : instance.screenName
241 }
242 private let threadID: String
243 private let threadKind: String
244 private let threadKeyB64: String
245 private let myUserId: String
246 private let api: LChatAPI?
247 private let key: GroupKey?
248 private var socket: ChatSocket?
249
250 // Tamper evidence (docs/tamper-evidence.md, #573): the per-instance monitor,
251 // set by the app after creating the model. Outgoing messages carry its
252 // verified head as `_ckpt`; incoming `_ckpt` is compared to detect a server
253 // that showed members different histories. nil = feature inert.
254 public var integrity: IntegrityMonitor?
255
256 /// Compare a decrypted payload's gossip (if any) against our verified chain
257 /// head. Called on EVERY inbound decrypt path — live, history, reactions —
258 /// because the server chooses which path a message arrives by. `own`
259 /// messages still compare (per-device split view) but don't count as peers.
260 private func noteGossip(_ payload: MessagePayload?, own: Bool) {
261 if let g = payload?._ckpt { integrity?.gossipIn(g, countPeer: !own) }
262 }
263
264 // ---- group apps (the web's groupapps registry, folded natively) ---------
265
266 /// The mini-apps added to this GROUP chat (⋮ → Add apps on the web),
267 /// already filtered to the catalog like the web's groupAppsFor — always
268 /// empty for DMs/self threads. Drives the header's Chat|App switch.
269 @Published public private(set) var groupApps: [GroupApps.Item] = []
270 // The fold inputs: the encrypted slot-"apps" snapshot's seed + horizon,
271 // plus every groupapps.* event seen (keyed by server message id so pages
272 // can arrive in any order and the recompute stays deterministic).
273 private var groupAppsSeed: [GroupApps.Item] = []
274 private var groupAppsUpTo: Int64 = 0
275 private var groupAppsEvents: [Int64: GroupApps.Event] = [:]
276
277 // The off-main crypto lane: every per-frame decrypt and every outgoing
278 // encrypt runs detached through this serial chain — the main actor never
279 // touches AES — while arrival/send ORDER is preserved (a message and its
280 // edit must fold in sequence; two sends must reach the socket in the order
281 // they were typed). One frame's work is tiny; the chain exists for order,
282 // not throughput.
283 private var offMainChain: Task<Void, Never>?
284
285 private func enqueueOffMain(_ job: @escaping @Sendable () async -> Void) {
286 offMainChain = Task.detached(priority: .userInitiated) { [prev = offMainChain] in
287 await prev?.value
288 await job()
289 }
290 }
291
292 // decrypt (detached) → fold (main): the shape every inbound frame uses.
293 private func decryptThenFold<T: Sendable>(_ decrypt: @escaping @Sendable () -> T?,
294 fold: @escaping @MainActor (ChatFeedModel, T) -> Void) {
295 enqueueOffMain { [weak self] in
296 guard let value = decrypt(), let self else { return }
297 await MainActor.run { fold(self, value) }
298 }
299 }
300
301 public init(instance: SavedInstance, thread: ChatThread, myUserId: String) {
302 self.instance = instance
303 self.threadID = thread.id
304 self.threadKind = thread.kind
305 self.threadKeyB64 = thread.threadKeyB64
306 self.myUserId = myUserId
307 self.api = instance.baseURL.map { LChatAPI(baseURL: $0, token: instance.deviceToken) }
308 self.key = Data(base64urlEncoded: thread.threadKeyB64).flatMap { try? GroupKey(rawKey: $0) }
309 self.dmClosed = thread.dmClosed
310 }
311
312 /// Adopt a fresher closed-flag than the live frames delivered — the cache
313 /// syncs a parked model from the row it's re-handed (the other side may
314 /// have acted while the app was suspended), and ThreadStore's own
315 /// close/reopen applies here without waiting for the echo frame.
316 public func noteDMClosed(_ closed: Bool) {
317 if dmClosed != closed { dmClosed = closed }
318 }
319
320 #if DEBUG
321 /// Feed-container tests drive the published stream directly — the setters
322 /// are private so nothing in the app can bypass the fold, but geometry
323 /// tests need a model with known content and no server.
324 public func _testInject(messages: [Message]? = nil, typing: [String]? = nil) {
325 if let messages { self.messages = messages; ready = true }
326 if let typing { self.typing = typing }
327 }
328 #endif
329
330 /// Where an attachment's encrypted bytes live + the key to open them — an
331 /// AvatarSource because it's the same fetch+decrypt (BlobImageLoader), just
332 /// with the thread key instead of the instance key.
333 public func blobSource(_ att: Attachment) -> AvatarSource? {
334 guard let base = instance.baseURL else { return nil }
335 return AvatarSource(blobID: att.id, keyB64: threadKeyB64,
336 baseURL: base, token: instance.deviceToken)
337 }
338
339 /// An attachment's decrypted bytes — the download path (file chips, video).
340 /// Cached in memory by blob id (RAM only, like the web's object-URL cache —
341 /// nothing decrypted ever lands on disk), so replaying a clip is instant.
342 /// `onProgress` (0…1, main thread) drives the radial ring on the video
343 /// card, mirroring the web's streamed loadBlob.
344 public func blobData(_ att: Attachment,
345 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Data? {
346 if let hit = BlobDataCache.shared.get(att.id) { return hit }
347 guard let source = blobSource(att),
348 var comps = URLComponents(
349 url: source.baseURL.appending(path: "api/blob").appending(path: source.blobID),
350 resolvingAgainstBaseURL: false) else { return nil }
351 comps.queryItems = [URLQueryItem(name: "token", value: source.token)]
352 guard let url = comps.url, let key else { return nil }
353 let data: Data?
354 if let onProgress {
355 data = await fetchWithProgress(url: url, onProgress: onProgress)
356 } else {
357 data = await plainFetch(url: url)
358 }
359 // A video/file blob is tens of MB — its AES-GCM open must never run on
360 // the main actor (this class's home).
361 guard let plain = await Task.detached(priority: .userInitiated, operation: {
362 data.flatMap { try? key.decryptBytes($0) }
363 }).value else { return nil }
364 BlobDataCache.shared.put(att.id, plain)
365 return plain
366 }
367
368 // Unfurl results: successes cache for the model's lifetime; failures only
369 // briefly. Caching nil forever meant a recipient who raced the poster's
370 // first fetch (or crossed a network blip) never got the card until they
371 // reopened the thread — the web evicts failures for the same reason
372 // (chat.js addLinkPreview); the TTL is what stops re-render retry storms.
373 private var unfurlCache: [URL: LinkPreview] = [:]
374 private var unfurlEmptyAt: [URL: Date] = [:]
375 // The live fetch per URL, shared by every caller — NOT a bare in-flight
376 // flag. A second caller used to get nil back and cache that as "nothing to
377 // show", which is exactly what happens to your OWN message: its row is
378 // rebuilt the instant the echo swaps the pending id for the real one, and
379 // the replacement row asks again mid-fetch.
380 private var unfurlInFlight: [URL: Task<LinkPreview?, Never>] = [:]
381 private static let unfurlRetryAfter: TimeInterval = 60
382
383 /// Synchronous peek at the unfurl cache — height pre-measurement needs to
384 /// know NOW whether a card will render (.some(nil) = known-empty).
385 public func cachedUnfurl(_ url: URL) -> LinkPreview?? {
386 if let hit = unfurlCache[url] { return hit }
387 if let at = unfurlEmptyAt[url], Date().timeIntervalSince(at) < Self.unfurlRetryAfter {
388 return .some(nil)
389 }
390 return .none
391 }
392
393 #if DEBUG
394 /// Stands in for the server fetch, so unfurl's sharing and cancellation
395 /// rules are testable without a network.
396 public var _testUnfurlFetch: ((URL) async -> LinkPreview?)?
397 #endif
398
399 private var unfurlFetch: ((URL) async -> LinkPreview?)? {
400 #if DEBUG
401 if let _testUnfurlFetch { return _testUnfurlFetch }
402 #endif
403 guard let api else { return nil }
404 return { await api.unfurl($0) }
405 }
406
407 /// The link-preview card for a URL (cached; nil = nothing to show).
408 public func unfurl(_ url: URL) async -> LinkPreview? {
409 if let known = cachedUnfurl(url) { return known }
410 guard let fetch = unfurlFetch else { return nil }
411 // The fetch must OUTLIVE its caller. A row's `.task` is cancelled when
412 // the row goes away, and the poster's own row goes away a beat after
413 // the send — a child fetch died with it, came back as a failure, and
414 // negative-cached the card away for a minute, so the card only ever
415 // appeared after a reload. An unstructured Task doesn't inherit that
416 // cancellation, so the replacement row simply awaits the same fetch.
417 if let live = unfurlInFlight[url] { return await live.value }
418 let task = Task { [weak self] () -> LinkPreview? in
419 let preview = await fetch(url)
420 guard let self else { return preview }
421 if let preview {
422 self.unfurlCache[url] = preview
423 self.unfurlEmptyAt[url] = nil
424 } else {
425 self.unfurlEmptyAt[url] = Date()
426 }
427 self.unfurlInFlight[url] = nil
428 return preview
429 }
430 unfurlInFlight[url] = task
431 return await task.value
432 }
433
434 // ---- uploads (the native chatAppUpload) -------------------------------
435
436 /// Stage an image: downscale + re-encode (EXIF gone), encrypt, upload.
437 /// Returns the attachment metadata that rides INSIDE the encrypted payload
438 /// — the server only ever sees ciphertext tagged with the thread for GC.
439 public func uploadImage(_ data: Data, name: String,
440 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
441 // Decode + downscale + re-encode is the heaviest client-side work a
442 // send can do — off the main actor with the crypto.
443 guard let processed = await Task.detached(priority: .userInitiated,
444 operation: { processImage(data) }).value,
445 processed.bytes.count <= maxUploadBytes,
446 let id = await upload(processed.bytes, onProgress: onProgress) else { return nil }
447 // The BlurHash placeholder, hashed from a ~32px re-decode of the upload
448 // bytes (DCT cost is O(pixels) — never hash the full image).
449 let bh = await Task.detached(priority: .utility, operation: { () -> String? in
450 guard let src = CGImageSourceCreateWithData(processed.bytes as CFData, nil),
451 let tiny = CGImageSourceCreateThumbnailAtIndex(src, 0, [
452 kCGImageSourceCreateThumbnailFromImageAlways: true,
453 kCGImageSourceThumbnailMaxPixelSize: 32,
454 ] as CFDictionary) else { return nil }
455 return Blurhash.encode(tiny)
456 }).value
457 let base = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression)
458 return Attachment(id: id, name: base + ".jpg", type: "image/jpeg",
459 w: processed.w, h: processed.h, size: Int64(processed.bytes.count),
460 bh: bh)
461 }
462
463 /// Stage a video, the web's send-and-play shape: the clip uploads whole
464 /// (≤50MB) plus a client-extracted poster frame — the server can't make
465 /// one, it only holds ciphertext. Dims (rotation applied), duration and
466 /// the poster's blob id ride inside the encrypted payload; a failed poster
467 /// is tolerated, exactly like the web.
468 public func uploadVideo(_ data: Data, name: String, mimeType: String?,
469 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
470 guard data.count <= maxVideoUploadBytes,
471 let id = await upload(data, onProgress: onProgress) else { return nil }
472 var att = Attachment(id: id, name: name, type: mimeType ?? "video/mp4",
473 size: Int64(data.count))
474 // Metadata + poster via the same in-memory loader the player uses —
475 // the plaintext clip never touches disk here either.
476 let loader = MemoryVideoAsset(data: data, mimeType: mimeType)
477 let asset = loader.makeAsset()
478 if let track = try? await asset.loadTracks(withMediaType: .video).first {
479 if let (size, transform) = try? await track.load(.naturalSize, .preferredTransform) {
480 let rotated = size.applying(transform)
481 att.w = Int(abs(rotated.width).rounded())
482 att.h = Int(abs(rotated.height).rounded())
483 }
484 }
485 if let dur = try? await asset.load(.duration), dur.seconds.isFinite {
486 att.dur = dur.seconds.rounded()
487 }
488 let generator = AVAssetImageGenerator(asset: asset)
489 generator.appliesPreferredTrackTransform = true
490 generator.maximumSize = CGSize(width: maxImageDim, height: maxImageDim)
491 if let poster = try? await generator.image(at: .zero).image,
492 let bytes = jpegData(from: poster) {
493 att.poster = await upload(bytes) // optional — nil just means no poster
494 }
495 return att
496 }
497
498 /// Stage an allowlisted document (fileAllowed) verbatim: encrypt + upload.
499 public func uploadFile(_ data: Data, name: String, mimeType: String?,
500 onProgress: (@MainActor (Double) -> Void)? = nil) async -> Attachment? {
501 guard fileAllowed(name), data.count <= maxUploadBytes,
502 let id = await upload(data, onProgress: onProgress) else { return nil }
503 return Attachment(id: id, name: name, type: mimeType ?? "application/octet-stream",
504 size: Int64(data.count))
505 }
506
507 // Encrypt + POST /api/upload?token&thread (raw bytes body) → the blob id.
508 // onProgress (0…1, main thread) reports request-body bytes sent — the
509 // upload twin of fetchWithProgress, via a per-task delegate.
510 private func upload(_ plain: Data,
511 onProgress: (@MainActor (Double) -> Void)? = nil) async -> String? {
512 guard let key, let base = instance.baseURL,
513 var comps = URLComponents(url: base.appending(path: "api/upload"),
514 resolvingAgainstBaseURL: false) else { return nil }
515 comps.queryItems = [
516 URLQueryItem(name: "token", value: instance.deviceToken),
517 URLQueryItem(name: "thread", value: threadID),
518 ]
519 // Encrypting a full-size attachment (up to 50 MB video) off main.
520 guard let url = comps.url,
521 let enc = await Task.detached(priority: .userInitiated,
522 operation: { try? key.encryptBytes(plain) }).value
523 else { return nil }
524 var req = URLRequest(url: url)
525 req.httpMethod = "POST"
526 struct Resp: Decodable { let id: String? }
527 let delegate = onProgress.map(UploadProgress.init)
528 guard let (data, resp) = try? await URLSession.shared.upload(for: req, from: enc,
529 delegate: delegate),
530 let http = resp as? HTTPURLResponse, http.statusCode < 300,
531 let r = try? JSONDecoder().decode(Resp.self, from: data) else { return nil }
532 return r.id
533 }
534
535 private final class UploadProgress: NSObject, URLSessionTaskDelegate {
536 private let onProgress: @MainActor (Double) -> Void
537 init(onProgress: @escaping @MainActor (Double) -> Void) { self.onProgress = onProgress }
538
539 func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64,
540 totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
541 guard totalBytesExpectedToSend > 0 else { return }
542 let p = min(1, Double(totalBytesSent) / Double(totalBytesExpectedToSend))
543 Task { @MainActor in self.onProgress(p) }
544 }
545 }
546
547 private func plainFetch(url: URL) async -> Data? {
548 guard let (data, resp) = try? await URLSession.shared.data(from: url),
549 let http = resp as? HTTPURLResponse, http.statusCode == 200 else { return nil }
550 return data
551 }
552
553 // A delegate-based fetch, because the one-shot async API can't observe
554 // download progress. The session retains its delegate until invalidated.
555 private func fetchWithProgress(url: URL,
556 onProgress: @escaping @MainActor (Double) -> Void) async -> Data? {
557 await withCheckedContinuation { cont in
558 let delegate = ProgressFetch(onProgress: onProgress) { cont.resume(returning: $0) }
559 let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
560 session.dataTask(with: url).resume()
561 session.finishTasksAndInvalidate()
562 }
563 }
564
565 private final class ProgressFetch: NSObject, URLSessionDataDelegate {
566 private let onProgress: @MainActor (Double) -> Void
567 private let done: (Data?) -> Void
568 // A URLSession serialises its delegate callbacks on its delegateQueue
569 // (nil → a private serial queue), so these are only ever touched one at
570 // a time — despite the delegate protocol requiring Sendable. The
571 // compiler can't prove that serialisation, hence nonisolated(unsafe).
572 private nonisolated(unsafe) var buffer = Data()
573 private nonisolated(unsafe) var expected: Int64 = 0
574 private nonisolated(unsafe) var ok = false
575
576 init(onProgress: @escaping @MainActor (Double) -> Void, done: @escaping (Data?) -> Void) {
577 self.onProgress = onProgress
578 self.done = done
579 }
580
581 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask,
582 didReceive response: URLResponse,
583 completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
584 ok = (response as? HTTPURLResponse)?.statusCode == 200
585 expected = response.expectedContentLength
586 if expected > 0 { buffer.reserveCapacity(Int(expected)) }
587 completionHandler(ok ? .allow : .cancel)
588 }
589
590 func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
591 buffer.append(data)
592 guard expected > 0 else { return }
593 let p = min(1, Double(buffer.count) / Double(expected))
594 Task { @MainActor in self.onProgress(p) }
595 }
596
597 func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
598 done(error == nil && ok ? buffer : nil)
599 }
600 }
601
602 public func start() {
603 // Idempotent: a parked model (NativeFeedModelCache) is re-attached on
604 // every visit — its socket and messages are already live.
605 guard socket == nil else { return }
606 guard let api, key != nil, let base = instance.baseURL else {
607 status = "no usable key for this chat"
608 return
609 }
610 Task { await loadHistory(api) }
611 if threadKind == "group" { Task { await seedGroupApps(api) } }
612 let socket = ChatSocket(baseURL: base, token: instance.deviceToken)
613 socket?.onFrame = { [weak self] frame in self?.apply(frame) }
614 socket?.onStatus = { [weak self] s in
615 guard let self else { return }
616 self.status = s
617 guard s == "connected" else { return }
618 // A reconnect may have missed frames; re-pull history (upsert by id,
619 // so this is idempotent) to converge. The socket's FIRST connect is
620 // exempt while the open's own pull is in flight or just landed —
621 // re-pulling then re-ran the whole fetch→fold→publish moments after
622 // the initial page, doubling the open's layout work for no new data.
623 let reconnect = self.hasConnected
624 self.hasConnected = true
625 if !reconnect,
626 self.loadingHistory || Date().timeIntervalSince(self.lastHistoryLoad) < 3 {
627 return
628 }
629 if !self.messages.isEmpty { Task { await self.loadHistory(api) } }
630 }
631 self.socket = socket
632 socket?.start()
633 }
634
635 // Nils the socket so a later start() can revive the model — an evicted
636 // parked model that a live view still holds must be restartable, never
637 // permanently dead.
638 public func stop() {
639 socket?.stop()
640 socket = nil
641 }
642
643 // The socket's URLSession retains its delegate (the socket) — stop() is
644 // what breaks that, so a model released without it would leak its socket.
645 deinit { socket?.stop() }
646
647 // The outgoing envelope — EXACTLY what shell.js emit builds: the app event
648 // ({text}, later +replyTo/attachments) stamped with name, ts (ms) and uid.
649 // Untyped, like every message the web sends today (message.js's type stamp
650 // is still future work there too). native-message-vector.json pins this
651 // shape from the other direction: the web must decrypt what we encrypt.
652 private struct Outgoing: Encodable {
653 let text: String
654 let name: String
655 let ts: Int64
656 var uid: String?
657 var action: Bool? // /me-style centred line, not a bubble
658 var attachments: [Attachment]?
659 var replyTo: ReplyRef?
660 var _ckpt: CkptGossip? // tamper-evidence gossip (nil = omitted)
661 var _cid: String? // optimistic-send client id (rides back in our own echo)
662 }
663
664 // A typed mini-app event (todo.add, remind.add, …) — the web's ctx.emit
665 // envelope: the event fields plus the transport-stamped name/ts/uid. The id
666 // is the app entity's stable identity; `when`/`sid` are remind.add's extras
667 // (nil optionals are omitted, so a todo.add carries neither — the event
668 // vectors pin both shapes).
669 private struct AppEventOut: Encodable {
670 let type: String
671 let id: String
672 let text: String
673 let name: String
674 let ts: Int64
675 var uid: String?
676 var when: Double? // remind.add: fire time, epoch ms
677 var sid: String? // remind.add: server schedule id ("" = offline fallback)
678 var _cid: String? // optimistic-send client id (rides back in our own echo)
679 }
680
681 // Encrypt an outgoing payload and send it, on the off-main lane (the lane
682 // also guarantees two sends leave in the order they were typed). Callers
683 // check socket.isLive FIRST so "keep the typed text on a down socket"
684 // still gets its synchronous answer; a socket that dies in the gap just
685 // drops the frame, exactly as today's fire-and-forget send did.
686 private func encryptAndSend<T: Encodable & Sendable>(_ payload: T, via socket: ChatSocket) {
687 guard let key else { return }
688 let tid = threadID
689 enqueueOffMain {
690 guard let blob = try? key.encryptJSON(payload),
691 let frame = try? JSONEncoder().encode(OutFrame(thread_id: tid, blob: blob)),
692 let json = String(data: frame, encoding: .utf8) else { return }
693 socket.send(json)
694 }
695 }
696
697 /// Emit a mini-app event into this thread (the default-command path: plain
698 /// text typed into To-do becomes todo.add). Encrypted + sent like any
699 /// message; the echo folds back through the app projection, so the action
700 /// line appears exactly as it would from the web. `id` defaults to a fresh
701 /// entity id; remind.add passes its own so the scheduled fire matches.
702 @discardableResult
703 public func sendAppEvent(type: String, text: String, id: String = UUID().uuidString,
704 when: Double? = nil, sid: String? = nil) -> Bool {
705 guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
706 var payload = AppEventOut(type: type, id: id, text: text,
707 name: myScreenName,
708 ts: Int64(Date().timeIntervalSince1970 * 1000))
709 if !myUserId.isEmpty { payload.uid = myUserId }
710 payload.when = when
711 payload.sid = sid
712 // The same transport-level client id chat carries. App events project
713 // asynchronously through the app host, so there's no optimistic projection
714 // to render yet — but the wire field is uniform, so that can build on this
715 // seam later without a protocol change.
716 payload._cid = UUID().uuidString
717 encryptAndSend(payload, via: socket)
718 typingBeat(active: false)
719 return true
720 }
721
722 // The scheduled remind.fire payload — encrypted NOW and held by the server
723 // until the due moment (remind.js addReminder's shape: the reminder text
724 // rides ONLY inside the encrypted notice).
725 private struct RemindFireOut: Encodable {
726 let type = "remind.fire"
727 let id: String
728 let notice: Notice
729 let name: String
730 let ts: Int64
731 var uid: String?
732 struct Notice: Encodable {
733 let title: String
734 let body: String
735 }
736 }
737
738 /// The full native reminder write path, mirroring remind.js addReminder:
739 /// schedule the future remind.fire FIRST (its encrypted notice is what
740 /// alerts every device), then record the reminder via remind.add carrying
741 /// the schedule id — or "" when scheduling failed (offline), which the
742 /// render ticker fires best-effort, exactly like the web. If the emit then
743 /// fails (socket down), the fresh schedule is CANCELLED before returning —
744 /// otherwise no remind.add would ever record its sid, and the user's retry
745 /// would stack a second, uncancellable alarm on top of the orphan.
746 public func addReminder(text: String, when: Double) async -> Bool {
747 guard let key else { return false }
748 let id = UUID().uuidString
749 var sid: String? = nil
750 var fire = RemindFireOut(id: id, notice: .init(title: "⏰ Reminder", body: text),
751 name: myScreenName,
752 ts: Int64(Date().timeIntervalSince1970 * 1000))
753 if !myUserId.isEmpty { fire.uid = myUserId }
754 // The seconds conversion must never trap: Int64(Double) crashes on
755 // out-of-range/infinite input, and the parser accepts arbitrarily big
756 // relative times exactly like the web — whose server just answers 400
757 // ("deliver_at out of range", the scheduleHorizon check) and the add
758 // falls back to sid "". So the guard here is ONLY trap-safety; the
759 // server stays the one range authority for both platforms.
760 let sec = when / 1000
761 if let api, sec.isFinite, sec > 0, sec < 9e18 { // Int64-conversion safety, not policy
762 let sendable = fire
763 let blob = await Task.detached(priority: .userInitiated,
764 operation: { try? key.encryptJSON(sendable) }).value
765 if let blob {
766 sid = await api.schedule(thread: threadID, deliverAt: Int64(sec), blob: blob)
767 }
768 }
769 let sent = sendAppEvent(type: "remind.add", text: text, id: id, when: when, sid: sid ?? "")
770 if !sent, let sid, let api {
771 await api.unschedule(sid) // never orphan a fire no remind.add points at
772 }
773 return sent
774 }
775
776 // ---- group apps: seed + fold + emit -------------------------------------
777
778 /// Seed the group-apps fold from the thread's encrypted slot-"apps"
779 /// snapshot (what the web saves on every fold advance). History windows at
780 /// 100 messages, so without this an app added long ago would be invisible;
781 /// with it, only events NEWER than the snapshot's horizon matter — exactly
782 /// the window loadHistory already fetches.
783 private func seedGroupApps(_ api: LChatAPI) async {
784 guard let key else { return }
785 guard let state = await api.threadState(thread: threadID, slot: "apps"),
786 !state.blob.isEmpty else { return }
787 let blob = state.blob
788 let parsed = await Task.detached(priority: .userInitiated) {
789 (try? key.decryptData(blob: blob)).flatMap(GroupApps.parseSnapshot)
790 }.value
791 guard let parsed else { return }
792 groupAppsSeed = parsed.seed
793 groupAppsUpTo = parsed.upTo
794 recomputeGroupApps()
795 }
796
797 // The groupapps.* wire shape — the event fields plus the transport
798 // envelope's name/ts (ts is ms; tolerate a float, JS numbers are doubles).
799 private struct GroupAppsWire: Decodable {
800 let type: String?
801 let app: String?
802 let name: String?
803 let ts: Double?
804 }
805
806 nonisolated static func groupAppsEvent(_ blob: String, key: GroupKey) -> GroupApps.Event? {
807 guard let w = try? key.decryptJSON(GroupAppsWire.self, blob: blob),
808 let type = w.type, type.hasPrefix("groupapps."),
809 let app = w.app, !app.isEmpty else { return nil }
810 return GroupApps.Event(type: type, app: app, by: w.name ?? "",
811 ts: Int64(w.ts ?? 0))
812 }
813
814 private func foldGroupAppsEvent(id: Int64, _ e: GroupApps.Event) {
815 guard threadKind == "group", groupAppsEvents[id] != e else { return }
816 groupAppsEvents[id] = e
817 recomputeGroupApps()
818 }
819
820 /// This space's app registry — the group-apps read filter and the Apps
821 /// sheet both draw from it (docs/native-apps.md phase 2).
822 public var registry: AppRegistry { AppRegistry.shared(origin: instance.origin) }
823 private var registryWatch: AnyCancellable?
824
825 /// The read-time install filter depends on the catalogue, which can learn
826 /// an app AFTER the fold ran (a member app deployed mid-session): refold
827 /// when it changes, or a fresh install stays invisible until the next
828 /// event happens to arrive.
829 private func watchRegistry() {
830 guard registryWatch == nil else { return }
831 registryWatch = registry.$manifests
832 .dropFirst()
833 .receive(on: DispatchQueue.main)
834 .sink { [weak self] _ in self?.recomputeGroupApps() }
835 }
836
837 private func recomputeGroupApps() {
838 watchRegistry()
839 // Read-time filter against the registry (the web's groupAppsFor):
840 // installs of apps this client knows AND that are chat-capable
841 // surface; anything else folds but stays inert.
842 let folded = registry.known(GroupApps.fold(seed: groupAppsSeed,
843 upTo: groupAppsUpTo,
844 events: groupAppsEvents))
845 if folded != groupApps { groupApps = folded }
846 }
847
848 /// Add or remove a mini-app on this group — an ordinary encrypted event in
849 /// the group's log (the web's ⋮ → Add apps emit), folded by every member.
850 @discardableResult
851 public func sendGroupAppEvent(add: Bool, app: String) -> Bool {
852 guard threadKind == "group", !app.isEmpty, key != nil,
853 let socket, socket.isLive else { return false }
854 var payload = GroupAppsOut(type: add ? "groupapps.add" : "groupapps.remove",
855 app: app, name: myScreenName,
856 ts: Int64(Date().timeIntervalSince1970 * 1000))
857 if !myUserId.isEmpty { payload.uid = myUserId }
858 encryptAndSend(payload, via: socket)
859 // Apps that gate their content behind an "open" event (board) fire it
860 // right after the add, exactly like the web's installGroupApp —
861 // without it the app renders silently empty for every member.
862 if add, let ev = registry.manifest(app)?.openEvent, !ev.isEmpty {
863 var open = OpenEventOut(type: ev, name: myScreenName,
864 ts: Int64(Date().timeIntervalSince1970 * 1000))
865 if !myUserId.isEmpty { open.uid = myUserId }
866 encryptAndSend(open, via: socket)
867 }
868 return true
869 }
870
871 // The bare emit envelope for an app's openEvent — {type} stamped with
872 // name/ts/uid like every app event.
873 private struct OpenEventOut: Encodable {
874 let type: String
875 let name: String
876 let ts: Int64
877 var uid: String?
878 }
879
880 // The outgoing groupapps event — shell.js's emit envelope around
881 // {type, app}: stamped with name, ts (ms) and uid like every app event.
882 private struct GroupAppsOut: Encodable {
883 let type: String
884 let app: String
885 let name: String
886 let ts: Int64
887 var uid: String?
888 }
889
890 /// Broadcast a /me-style action line ("Paul waves") — the composer builds
891 /// the full text; here it rides with action:true, like the web's ctx.emit.
892 @discardableResult
893 public func sendAction(_ text: String) -> Bool {
894 guard !text.isEmpty, key != nil, let socket, socket.isLive else { return false }
895 var payload = Outgoing(text: text, name: myScreenName,
896 ts: Int64(Date().timeIntervalSince1970 * 1000))
897 if !myUserId.isEmpty { payload.uid = myUserId }
898 payload.action = true
899 encryptAndSend(payload, via: socket)
900 typingBeat(active: false)
901 return true
902 }
903
904 // One hub action frame (react/unreact/delete/edit) — nil fields are
905 // omitted, matching the web's hand-built JSON.
906 private struct ActionFrame: Encodable {
907 let type: String
908 let thread_id: String
909 var message_id: Int64?
910 var emoji: String?
911 var blob: String?
912 }
913
914 @discardableResult
915 private func sendFrame<T: Encodable>(_ frame: T) -> Bool {
916 guard let socket,
917 let data = try? JSONEncoder().encode(frame),
918 let json = String(data: data, encoding: .utf8) else { return false }
919 return socket.send(json)
920 }
921
922 /// Toggle a reaction, like the web's toggleReaction: react if it isn't
923 /// yours yet, unreact if it is. A reaction is an end-to-end encrypted event
924 /// (type="reaction") sent as an ordinary message — the server never sees
925 /// the emoji or the target; every client folds the event into the target's
926 /// tally. targetUid (the reacted-to message's author) rides inside the
927 /// ciphertext so push handlers can show the notification only to the author;
928 /// targetText (its snippet) rides along so the sidebar can quote what was
929 /// reacted to without loading the thread.
930 public func toggleReaction(_ id: Int64, emoji: String) {
931 guard Self.allowedReactions.contains(emoji), key != nil,
932 let socket, socket.isLive,
933 let m = messages.first(where: { $0.id == id }) else { return }
934 let mine = m.reactions.first { $0.emoji == emoji }?.isMine ?? false
935 // The quote goes through the markdown plain projection, like every
936 // other preview line — raw text would show the marks the bubble renders
937 // as styling. Projected at the stamp, so no payload carries them.
938 var quoted = m.raw
939 quoted.text = markdownPlainText(m.raw.text ?? "")
940 var payload = MessagePayload(type: "reaction",
941 name: myScreenName,
942 ts: Int64(Date().timeIntervalSince1970 * 1000),
943 targetSid: id,
944 targetUid: m.raw.uid,
945 emoji: emoji,
946 on: !mine,
947 targetText: replySnippet(quoted))
948 if !myUserId.isEmpty { payload.uid = myUserId }
949 encryptAndSend(payload, via: socket)
950 // Optimistic local fold so the chip responds instantly; the echo frame
951 // replays the same event and the (emoji, uid)-keyed fold is idempotent.
952 applyReactionEvent(payload)
953 }
954
955 /// "Convert to thread": move the inline reply chain hanging under `root`
956 /// out of the transcript and into a thread rooted on it. Like a reaction
957 /// this is an end-to-end encrypted event sent as an ordinary message — the
958 /// server never sees the root, the ids, or that a thread exists at all;
959 /// every client folds the event and derives the same feed.
960 ///
961 /// `ids` is derived by the CALLER from inlineChainIDs, so what moved is
962 /// fixed at the moment of the tap rather than re-derived differently on
963 /// each device. No optimistic fold (the reaction path's local echo exists
964 /// because a tap must feel instant; a conversion is a confirmed, one-way
965 /// act and can wait for its own echo, which keeps the claim map keyed
966 /// purely on real server ids).
967 ///
968 /// Returns false on a down socket, and the caller MUST say so: nothing
969 /// about a failed conversion is visible — the chain simply stays where it
970 /// was, which is exactly what it looks like when nobody converted anything
971 /// — so after confirming a one-way act the reader would be left believing
972 /// it happened.
973 @discardableResult
974 public func convertToThread(root: Int64, ids: [Int64]) -> Bool {
975 guard root > 0, !ids.isEmpty, key != nil, let socket, socket.isLive else { return false }
976 var payload = MessagePayload(type: "threadconvert",
977 name: myScreenName,
978 ts: Int64(Date().timeIntervalSince1970 * 1000))
979 payload.root = root
980 payload.ids = Array(ids.prefix(Self.maxConvertIDs))
981 if !myUserId.isEmpty { payload.uid = myUserId }
982 // The guard above IS the check: the encrypt + write happen off-main and
983 // report nothing back, exactly as every other send here does.
984 encryptAndSend(payload, via: socket)
985 return true
986 }
987
988 /// Fold one convert event (live or from history). The claim map is the
989 /// state; where each message renders is derived from it on every publish,
990 /// so folds in any order — and repeated folds of the same event — settle on
991 /// the same feed. `at` is the convert MESSAGE's own server id, which is the
992 /// tie-break between two people converting overlapping chains.
993 func applyConvertEvent(_ p: MessagePayload, at: Int64) {
994 // Tamper-evidence, like every other live payload — and like the SAME
995 // event arriving in a history page, which gossips via `stored`. Before
996 // the guards below: a malformed conversion is still a message that
997 // reached us, and its chain head still counts.
998 noteGossip(p, own: p.uid == myUserId)
999 guard p.isThreadConvertEvent, let root = p.root, let ids = p.ids else { return }
1000 guard Self.claimConverts(&converts, root: root, ids: ids, at: at) else { return }
1001 var list = messages
1002 refreshRollup(&list)
1003 messages = list
1004 }
1005
1006 /// Delete your own message. The server verifies authorship; the "delete"
1007 /// broadcast removes it from every feed, ours included.
1008 public func deleteMessage(_ id: Int64) {
1009 sendFrame(ActionFrame(type: "delete", thread_id: threadID, message_id: id))
1010 }
1011
1012 /// Edit your own message (within the 15-minute window — server-enforced).
1013 /// Re-encrypts {…raw, text} like the web, so attachments and the reply
1014 /// reference survive the rewrite. Returns false on a down socket.
1015 public func edit(_ id: Int64, text: String) -> Bool {
1016 guard let key, let socket, socket.isLive,
1017 let m = messages.first(where: { $0.id == id }) else { return false }
1018 var raw = m.raw
1019 raw.text = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
1020 guard !(raw.text ?? "").isEmpty else { return false }
1021 let tid = threadID
1022 let edited = raw
1023 enqueueOffMain {
1024 guard let blob = try? key.encryptJSON(edited),
1025 let data = try? JSONEncoder().encode(ActionFrame(
1026 type: "edit", thread_id: tid, message_id: id, blob: blob)),
1027 let json = String(data: data, encoding: .utf8) else { return }
1028 socket.send(json)
1029 }
1030 return true
1031 }
1032
1033 private struct OutFrame: Encodable {
1034 let type = "msg"
1035 let thread_id: String
1036 let blob: String
1037 }
1038
1039 /// Encrypt and send a message — the first native write path. Returns false
1040 /// (and sends nothing) when the socket is down or encryption is impossible,
1041 /// so the composer keeps the typed text to resend, like the web's.
1042 public func send(_ text: String, attachments: [Attachment] = [],
1043 replyTo: ReplyRef? = nil) -> Bool {
1044 // Shortcodes interpolate at send time (emoji.js emojify), so the
1045 // ciphertext stores the real glyph and renders everywhere.
1046 let trimmed = emojify(text.trimmingCharacters(in: .whitespacesAndNewlines))
1047 guard !trimmed.isEmpty || !attachments.isEmpty, key != nil,
1048 let socket, socket.isLive else { return false }
1049 let ts = Int64(Date().timeIntervalSince1970 * 1000)
1050 var payload = Outgoing(text: trimmed, name: myScreenName, ts: ts)
1051 if !myUserId.isEmpty { payload.uid = myUserId }
1052 if !attachments.isEmpty { payload.attachments = attachments }
1053 payload.replyTo = replyTo
1054 payload._ckpt = integrity?.gossipOut() // tamper-evidence gossip (inside the ciphertext)
1055 // Optimistic send (web parity — shell.js): show the message immediately as
1056 // a pending copy stamped with a client id, then let the hub's broadcast —
1057 // which carries the same _cid back — upgrade it in place. Do it before the
1058 // send so it lands with no round-trip wait.
1059 let cid = UUID().uuidString
1060 payload._cid = cid
1061 foldOptimisticSend(cid: cid, text: trimmed, ts: ts,
1062 attachments: attachments, replyTo: replyTo)
1063 // The hub stores + broadcasts to everyone including us; that echo folds
1064 // over this optimistic copy (matched on _cid), replacing it silently.
1065 encryptAndSend(payload, via: socket)
1066 typingBeat(active: false) // a send clears the indicator now
1067 return true
1068 }
1069
1070 /// Send over REST — the share extension has no WebSocket. Same encrypted
1071 /// payload as send(); the hub still broadcasts to every member.
1072 public func sendViaREST(_ text: String, attachments: [Attachment]) async -> Bool {
1073 guard let key, let base = instance.baseURL,
1074 !text.isEmpty || !attachments.isEmpty else { return false }
1075 var payload = Outgoing(text: emojify(text), name: myScreenName,
1076 ts: Int64(Date().timeIntervalSince1970 * 1000))
1077 if !myUserId.isEmpty { payload.uid = myUserId }
1078 if !attachments.isEmpty { payload.attachments = attachments }
1079 payload._ckpt = integrity?.gossipOut()
1080 let sendable = payload
1081 guard let blob = await Task.detached(priority: .userInitiated,
1082 operation: { try? key.encryptJSON(sendable) }).value,
1083 var comps = URLComponents(url: base.appending(path: "api/messages"),
1084 resolvingAgainstBaseURL: false) else { return false }
1085 comps.queryItems = [URLQueryItem(name: "token", value: instance.deviceToken)]
1086 guard let url = comps.url,
1087 let body = try? JSONSerialization.data(withJSONObject:
1088 ["thread_id": threadID, "blob": blob]) else { return false }
1089 var req = URLRequest(url: url)
1090 req.httpMethod = "POST"
1091 req.setValue("application/json", forHTTPHeaderField: "Content-Type")
1092 req.httpBody = body
1093 guard let (_, resp) = try? await URLSession.shared.data(for: req),
1094 let http = resp as? HTTPURLResponse else { return false }
1095 return http.statusCode < 300
1096 }
1097
1098 private func loadHistory(_ api: LChatAPI) async {
1099 loadingHistory = true
1100 // The perceived "open a chat" latency: store render + fetch + fold.
1101 let span = Perf.begin("feed.openThread", slowMs: 1500)
1102 defer {
1103 loadingHistory = false
1104 lastHistoryLoad = Date()
1105 span.end("\(messages.count) messages")
1106 }
1107 // INSTANT open: the local store (decrypted once, at ingest) renders
1108 // before the network answers; the fetch below then converges by
1109 // upsert. Cold threads just wait for the fetch as before.
1110 if messages.isEmpty {
1111 let cached = await MessageStore.shared.page(instance: instance.id, thread: threadID)
1112 if !cached.isEmpty, messages.isEmpty {
1113 publishStored(cached)
1114 // Only mark ready if something actually rendered: rows stored
1115 // before a projection existed (app-thread events) can filter
1116 // to NOTHING, and ready-but-empty is a blank chat.
1117 if !messages.isEmpty {
1118 ready = true
1119 lastOlderLoad = Date()
1120 }
1121 }
1122 }
1123 let rows = await api.messages(thread: threadID, after: 0)
1124 await foldPageDetached(rows)
1125 ready = true
1126 // Start the scroll-back cooldown NOW: the top row materialises during
1127 // this very layout pass (before the pin-to-bottom runs), and an
1128 // immediate loadOlder would anchor the view at the top instead.
1129 lastOlderLoad = Date()
1130 status = "\(messages.count) messages"
1131 }
1132
1133 /// Fold the previous page of history (the web's scroll-back). Idempotent
1134 /// (fold upserts by id) and self-limiting: an empty page marks the thread
1135 /// exhausted, and a cooldown stops the prepend itself re-triggering the
1136 /// materialisation-based trigger (which once cascaded the entire history
1137 /// into the list in one go).
1138 public func loadOlder() async {
1139 guard ready, !loadingOlder, !exhausted, let api,
1140 Date().timeIntervalSince(lastOlderLoad) > 0.5,
1141 let oldest = messages.first?.id else { return }
1142 loadingOlder = true
1143 // The scroll-back stall's fold half (#1211): fetch + decrypt + merge.
1144 // The layout storm the publish triggers lands in the frames AFTER this
1145 // span — the hitch recorder owns that side; adjacency in the ledger
1146 // ties them together.
1147 let span = Perf.begin("feed.loadOlder", slowMs: 1000)
1148 var folded = 0
1149 defer {
1150 loadingOlder = false
1151 lastOlderLoad = Date()
1152 span.end("\(folded) rows")
1153 }
1154 let rows = await api.messagesBefore(thread: threadID, before: oldest)
1155 guard !rows.isEmpty else {
1156 exhausted = true
1157 status = "beginning of chat · \(messages.count) messages"
1158 return
1159 }
1160 folded = rows.count
1161 await foldPageDetached(rows)
1162 status = "\(messages.count) messages"
1163 }
1164
1165 /// What a jump found when it went looking for its target — three genuinely
1166 /// different answers, because "gone" is final and "capped" means keep
1167 /// scrolling. Mirrors the web's pageBackTo return.
1168 public enum JumpReach: Sendable { case found, gone, capped }
1169
1170 /// How many history pages ONE jump walks back looking for its target. A
1171 /// message thousands back is a scroll, not a jump — and an unbounded walk
1172 /// would pull an entire history into memory on a stray tap.
1173 ///
1174 /// PINNED to the web's PAGE_BACK_CAP (chat.js): both fetch the server's
1175 /// 100-row default page — native by passing no `limit`, web by asking for
1176 /// 100 — so one jump reaches ~800 messages on every platform and the same
1177 /// message can't answer "capped" here and land there.
1178 private static let jumpPageCap = 8
1179
1180 /// How long loadOlder's own cooldown runs (see its guard). Waited out
1181 /// deliberately in pageBack rather than discovered by burning a call.
1182 private static let olderCooldown: UInt64 = 550_000_000
1183
1184 /// How many of a walk's passes a target ABOVE the loaded window may spend
1185 /// simply WAITING to arrive (see pageBack). Four cooldowns ≈ two seconds —
1186 /// generous for a row the notification extension already filed to disk,
1187 /// and short enough that a genuinely unreachable target still gets its
1188 /// answer inside one walk.
1189 private static let arrivalWaitPasses = 4
1190
1191 /// Page history backward until message `id` is in the loaded window.
1192 ///
1193 /// The native twin of the web's `pageBackTo`, and the fix for a jump whose
1194 /// target simply isn't loaded — a quote card above a plain reply whose
1195 /// parent is older than the loaded page (the DEFAULT for every reply in
1196 /// existing history, none of which carries the thread flag), a search hit,
1197 /// a notification. Without it those taps did nothing at all.
1198 ///
1199 /// **Terminates, always.** At most `jumpPageCap` iterations, and each one
1200 /// either returns or must make progress: the target turning up returns
1201 /// `.found`; a loaded window that starts BELOW the target without holding
1202 /// it returns `.gone` (pages are contiguous, so the message was deleted);
1203 /// `exhausted` (an empty page — the start of history) returns `.gone`; and
1204 /// a pass that leaves the oldest loaded id unmoved gives up rather than
1205 /// spinning.
1206 ///
1207 /// `loadOlder()` re-arms its own half-second cooldown in a `defer`, so a
1208 /// call made straight after a successful page is a GUARANTEED no-op. The
1209 /// wait is therefore taken up front on every pass but the first — burning
1210 /// a call to discover it, then sleeping anyway, cost an 8-page walk about
1211 /// four extra seconds. The first pass skips the wait (the cooldown is
1212 /// usually idle) and retries once if it turns out not to be, which is the
1213 /// case where the reader had just scroll-loaded.
1214 ///
1215 /// One kind of target is NOT a history question: a message ABOVE the
1216 /// loaded window. A notification tap knows its message id from the banner
1217 /// and can land before this model has folded the row — the store catch-up
1218 /// (or the socket's re-pull) brings it in a beat later. Page contiguity
1219 /// only reasons about messages BELOW the window, so the `.gone` verdict
1220 /// would be a flat lie there: "that message isn't available any more"
1221 /// about one that is arriving as we speak. Such a target waits instead,
1222 /// for a few passes of the same budget.
1223 public func pageBack(to id: Int64) async -> JumpReach {
1224 guard id > 0 else { return .gone }
1225 var waited = 0
1226 for pass in 0..<Self.jumpPageCap {
1227 if messages.contains(where: { $0.id == id }) { return .found }
1228 // Above every real row = still arriving; wait rather than page.
1229 // Measured against the newest SERVER row: our own unconfirmed
1230 // sends carry synthetic ids above all of them (pendingIDBase) and
1231 // would otherwise make every target look like settled history.
1232 if id > (messages.last(where: { !$0.pending })?.id ?? 0),
1233 waited < Self.arrivalWaitPasses {
1234 waited += 1
1235 try? await Task.sleep(nanoseconds: Self.olderCooldown)
1236 continue
1237 }
1238 guard let oldest = messages.first?.id else { return .gone }
1239 if oldest < id || exhausted { return .gone }
1240 if pass > 0 { try? await Task.sleep(nanoseconds: Self.olderCooldown) }
1241 await loadOlder()
1242 if pass == 0, messages.first?.id == oldest, !exhausted {
1243 try? await Task.sleep(nanoseconds: Self.olderCooldown)
1244 await loadOlder()
1245 }
1246 if messages.first?.id == oldest { return exhausted ? .gone : .capped }
1247 }
1248 return messages.contains(where: { $0.id == id }) ? .found : .capped
1249 }
1250
1251 /// The inline wording for a jump that couldn't land — one string, so web,
1252 /// Mac and iOS say the same thing. nil for `.found` (nothing to say).
1253 nonisolated public static func jumpReachNote(_ reach: JumpReach) -> String? {
1254 switch reach {
1255 case .found: return nil
1256 case .capped: return "That message is further back than this jump reaches — keep scrolling up for it."
1257 case .gone: return "That message isn't available any more."
1258 }
1259 }
1260
1261 // A 200-message page is 200 AES-GCM opens + JSON
1262 // decodes + Message builds — done on the main actor (this class's home)
1263 // it froze the UI on open, worst on long chats and the prewarm burst.
1264 // The whole page builds detached; main only merges (dictionary upsert,
1265 // ONE sort — per-message fold was a linear search + full re-sort EACH,
1266 // the length-scaled stall).
1267 // Map a stored (already-decrypted) page into view messages and publish.
1268 private func publishStored(_ stored: [MessageStore.StoredMessage]) {
1269 claimStoredConverts(stored)
1270 var list: [Message] = stored.compactMap(viewMessage)
1271 refreshRollup(&list)
1272 messages = list
1273 }
1274
1275 // Conversions ("Convert to thread") reach the store like any other row —
1276 // they're ordinary messages — but render as nothing, so viewMessage drops
1277 // them. Fold their claims before the list publishes and a cold open from
1278 // disk shows a converted chain already tucked into its thread, rather than
1279 // inline for the moment before the network page re-folds it.
1280 private func claimStoredConverts(_ stored: [MessageStore.StoredMessage]) {
1281 for s in stored where s.payload.isThreadConvertEvent {
1282 guard let root = s.payload.root, let ids = s.payload.ids else { continue }
1283 Self.claimConverts(&converts, root: root, ids: ids, at: s.id)
1284 }
1285 }
1286
1287 // One stored row → one view message (nil for non-chat payloads).
1288 private func viewMessage(_ s: MessageStore.StoredMessage) -> Message? {
1289 guard s.payload.isRenderableChat else { return nil }
1290 let p = s.payload
1291 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1292 ?? (!myScreenName.isEmpty && p.name == myScreenName)
1293 return Message(id: s.id, name: p.name ?? "", text: p.text ?? "",
1294 mine: mine, at: s.at, action: p.action ?? false,
1295 edited: s.edited, attachments: p.attachments ?? [],
1296 reply: p.replyTo, reactions: s.reactions, raw: p)
1297 }
1298
1299 /// Merge store rows this model never folded live. The NSE ingests pushed
1300 /// messages (and background refresh syncs) while the app is suspended —
1301 /// its sockets down, parked models frozen — so re-attaching a parked model
1302 /// (NativeFeedModelCache) or foregrounding pulls those rows from disk and
1303 /// the tapped chat is caught up instantly; the socket's history re-pull
1304 /// then converges reactions/edits. Rows already in memory are left alone
1305 /// (the live fold's state is richer than an NSE row).
1306 public func catchUpFromStore() async {
1307 guard ready else { return } // the initial load reads the store itself
1308 let stored = await MessageStore.shared.page(instance: instance.id, thread: threadID)
1309 var list = messages
1310 let known = Set(list.map(\.id))
1311 var changed = false
1312 for s in stored where !known.contains(s.id) {
1313 guard let msg = viewMessage(s) else { continue }
1314 list.append(msg)
1315 changed = true
1316 }
1317 // A conversion the NSE or a background sync stored while we were
1318 // suspended counts as a change too, even when no new message came with
1319 // it: it moves messages already on screen.
1320 let before = converts
1321 claimStoredConverts(stored)
1322 if converts != before { changed = true }
1323 guard changed else { return }
1324 list.sort { $0.id < $1.id }
1325 refreshRollup(&list)
1326 messages = list
1327 }
1328
1329 /// The message ids a fetched page's delete tombstones evict (#582): each
1330 /// tombstone row is the stored form of the live "delete" frame, for
1331 /// clients that were offline when it fired. Pure (hence nonisolated) —
1332 /// shared with BackgroundSync and pinned by the tombstone_delete vector.
1333 nonisolated public static func tombstoneEvictions(_ rows: [ServerMessage]) -> [Int64] {
1334 rows.compactMap { row in
1335 guard let t = row.tombstone_of, t > 0 else { return nil }
1336 return t
1337 }
1338 }
1339
1340 private func foldPageDetached(_ rows: [ServerMessage]) async {
1341 guard let key else { return }
1342 // Deletes that happened while this device was away: evict before the
1343 // fold, mirroring the live "delete" frame (list, group-app events,
1344 // store) — minus its implosion animation; a reconcile is silent.
1345 let evict = Self.tombstoneEvictions(rows)
1346 if !evict.isEmpty {
1347 var pruned = messages
1348 pruned.removeAll { evict.contains($0.id) }
1349 if pruned.count != messages.count {
1350 refreshRollup(&pruned)
1351 messages = pruned
1352 }
1353 var unfolded = false
1354 for id in evict where groupAppsEvents.removeValue(forKey: id) != nil { unfolded = true }
1355 if unfolded { recomputeGroupApps() }
1356 let instanceID = instance.id
1357 let tid = threadID
1358 Task.detached(priority: .utility) {
1359 for id in evict {
1360 await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id)
1361 }
1362 }
1363 }
1364 let myUserId = self.myUserId
1365 let screenName = self.myScreenName
1366 let wantGroupApps = threadKind == "group"
1367 let foldSpan = Perf.begin("feed.foldPage", slowMs: 250)
1368 let (fresh, stored, appEvents, reactionRows, convertRows, unprojectedRows):
1369 ([Message], [MessageStore.StoredMessage],
1370 [(Int64, GroupApps.Event)], [MessagePayload], [(Int64, MessagePayload)],
1371 [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) =
1372 await Task.detached(priority: .userInitiated) {
1373 var out: [Message] = []
1374 var toStore: [MessageStore.StoredMessage] = []
1375 var events: [(Int64, GroupApps.Event)] = []
1376 var reacts: [MessagePayload] = []
1377 var converted: [(Int64, MessagePayload)] = []
1378 var unproj: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)] = []
1379 for row in rows {
1380 if (row.tombstone_of ?? 0) > 0 { continue } // evicted above; empty blob, nothing to fold
1381 if wantGroupApps, let e = Self.groupAppsEvent(row.blob, key: key) {
1382 events.append((row.id, e))
1383 }
1384 // Reaction and conversion events live in history as ordinary
1385 // encrypted rows. ONE decrypt for both questions (and the
1386 // renderable projection below re-reads the blob only for rows
1387 // that are neither) — this loop is the one Perf watches, and
1388 // every extra decrypt here is paid per row of every page.
1389 let event = try? key.decryptJSON(MessagePayload.self, blob: row.blob)
1390 // Collected in row order (chronological) and folded after the
1391 // page's messages have merged.
1392 if let p = event, p.isReactionEvent {
1393 reacts.append(p)
1394 continue
1395 }
1396 // A conversion renders as nothing, so it's kept for its claims —
1397 // and mirrored to the store, so a cold open from disk shows a
1398 // converted chain already in its thread instead of inline for
1399 // the moment before the network page re-folds it.
1400 if let p = event, p.isThreadConvertEvent {
1401 converted.append((row.id, p))
1402 toStore.append(.init(id: row.id, at: row.created_at, edited: false,
1403 reactions: [], payload: p))
1404 continue
1405 }
1406 guard let p = Self.renderable(row.blob, key: key, group: wantGroupApps,
1407 myUserId: myUserId)
1408 else {
1409 // An app event with no built-in projection: stash it for
1410 // the app-host query (docs/native-apps.md phase 5) — the
1411 // web engine answers with the app's own chat line, or
1412 // null (silent, exactly as it folds today).
1413 if let ev = AppChatProjection.unprojected(row.blob, key: key) {
1414 unproj.append((row.id, row.created_at, ev))
1415 }
1416 continue
1417 }
1418 toStore.append(.init(id: row.id, at: row.created_at,
1419 edited: (row.edited_at ?? 0) > 0,
1420 reactions: [], payload: p))
1421 guard p.isRenderableChat else { continue }
1422 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1423 ?? (!screenName.isEmpty && p.name == screenName)
1424 out.append(Message(id: row.id, name: p.name ?? "", text: p.text ?? "",
1425 mine: mine, at: row.created_at,
1426 action: p.action ?? false,
1427 edited: (row.edited_at ?? 0) > 0,
1428 attachments: p.attachments ?? [], reply: p.replyTo,
1429 reactions: [], raw: p))
1430 }
1431 return (out, toStore, events, reacts, converted, unproj)
1432 }.value
1433 foldSpan.end("\(rows.count) rows")
1434 // Tamper-evidence (history): compare gossip from every message + reaction
1435 // this page decrypted — the server can route forged messages via
1436 // catch-up, so history must count too, not just live frames.
1437 if let integrity {
1438 // One coalesced save for the whole page — gossipIn otherwise writes
1439 // UserDefaults once per message (see IntegrityMonitor.withCoalescedSave).
1440 integrity.withCoalescedSave {
1441 for s in stored { noteGossip(s.payload, own: s.payload.uid == myUserId) }
1442 for p in reactionRows { noteGossip(p, own: p.uid == myUserId) }
1443 }
1444 }
1445 if !appEvents.isEmpty {
1446 for (id, e) in appEvents { groupAppsEvents[id] = e }
1447 recomputeGroupApps()
1448 }
1449 let instanceID = instance.id
1450 let tid = threadID
1451 Task.detached(priority: .utility) {
1452 await MessageStore.shared.upsert(instance: instanceID, thread: tid, stored)
1453 }
1454 var list = messages
1455 var indexByID = Dictionary(uniqueKeysWithValues: list.enumerated().map { ($1.id, $0) })
1456 for var msg in fresh {
1457 if let i = indexByID[msg.id] {
1458 // Page rows carry no tally — reactions fold from events below.
1459 // Keep whatever this model already folded (the keyed fold makes
1460 // the page's events re-apply idempotently on top).
1461 msg.reactionEvents = list[i].reactionEvents
1462 msg.reactions = list[i].reactions
1463 if !msg.edited { msg.edited = list[i].edited }
1464 list[i] = msg
1465 } else {
1466 list.append(msg)
1467 indexByID[msg.id] = list.count - 1
1468 }
1469 }
1470 // Fold the page's reaction events into their targets, then mirror the
1471 // touched tallies so a reopened chat renders them from disk. Events
1472 // whose target is outside the loaded window are dropped, like the web.
1473 var touched: [Int64] = []
1474 for p in reactionRows {
1475 guard let sid = p.targetSid, let i = indexByID[sid] else { continue }
1476 if Self.foldReaction(p, into: &list[i], me: myUserId), !touched.contains(sid) {
1477 touched.append(sid)
1478 }
1479 }
1480 if !touched.isEmpty {
1481 let tallies = touched.map { sid in (sid, list[indexByID[sid]!].reactions) }
1482 Task.detached(priority: .utility) {
1483 for (sid, tally) in tallies {
1484 await MessageStore.shared.updateReactions(instance: instanceID, thread: tid,
1485 id: sid, tally)
1486 }
1487 }
1488 }
1489 // The page's conversions, before the rollup that projects them: the
1490 // claim map is order-independent, so a page carrying a conversion whose
1491 // members are still further back settles the same way when they land.
1492 for (id, p) in convertRows {
1493 guard let root = p.root, let ids = p.ids else { continue }
1494 Self.claimConverts(&converts, root: root, ids: ids, at: id)
1495 }
1496 list.sort { $0.id < $1.id }
1497 refreshRollup(&list)
1498 messages = list
1499 noteUnprojected(unprojectedRows)
1500 }
1501
1502 private func apply(_ frame: ChatSocket.Frame) {
1503 guard frame.thread_id == threadID else { return }
1504 switch frame.type {
1505 case "msg":
1506 // Ephemeral frames are relay-only presence — fold typing beats,
1507 // render nothing.
1508 if frame.ephemeral == true {
1509 if let blob = frame.blob { foldTypingBlob(blob) }
1510 return
1511 }
1512 guard let id = frame.id, let blob = frame.blob, let key else { return }
1513 let at = frame.created_at ?? 0
1514 let wantGroupApps = threadKind == "group"
1515 let myUserId = self.myUserId // captured — the decrypt closure runs off-main
1516 // A stored frame is an encrypted reaction event, a conversion, or a
1517 // chat/app message (with an optional group-apps projection) —
1518 // decrypt off-main either way, then fold the right shape on main.
1519 decryptThenFold({ () -> InboundStored? in
1520 let event = try? key.decryptJSON(MessagePayload.self, blob: blob)
1521 if let p = event, p.isReactionEvent { return .reaction(p) }
1522 if let p = event, p.isThreadConvertEvent { return .convert(p) }
1523 let payload = Self.renderable(blob, key: key, group: wantGroupApps,
1524 myUserId: myUserId)
1525 return .message(payload,
1526 wantGroupApps ? Self.groupAppsEvent(blob, key: key) : nil,
1527 payload == nil ? AppChatProjection.unprojected(blob, key: key) : nil)
1528 }) { model, inbound in
1529 switch inbound {
1530 case .reaction(let p):
1531 model.applyReactionEvent(p)
1532 case .convert(let p):
1533 // The frame's own id is the tie-break between overlapping
1534 // conversions — never the payload's, which anyone could
1535 // make up.
1536 model.applyConvertEvent(p, at: id)
1537 model.storeMirror(.init(id: id, at: at, edited: false,
1538 reactions: [], payload: p))
1539 case .message(let payload, let groupApps, let unprojected):
1540 model.foldDecrypted(id: id, at: at, payload: payload,
1541 edited: false, reactions: [])
1542 if let e = groupApps { model.foldGroupAppsEvent(id: id, e) }
1543 if let ev = unprojected { model.noteUnprojected([(id, at, ev)]) }
1544 }
1545 }
1546 case "typing":
1547 // The dedicated relay frame — legacy-client compat only; everything
1548 // current (web, native, both bot sidecars) sends the chat.typing
1549 // ephemeral instead. Same payload, so fold it the same way.
1550 if let blob = frame.blob { foldTypingBlob(blob) }
1551 case "edit":
1552 guard let id = frame.id, let blob = frame.blob, let key else { return }
1553 decryptThenFold({ try? key.decryptJSON(MessagePayload.self, blob: blob) }) { model, p in
1554 guard let i = model.messages.firstIndex(where: { $0.id == id }) else { return }
1555 model.messages[i].text = p.text ?? ""
1556 model.messages[i].edited = true
1557 // Keep the source payload current too — edit() re-encrypts from
1558 // it and reply snippets quote it; a stale raw silently reverted
1559 // a second edit back to the pre-first-edit text.
1560 model.messages[i].raw = p
1561 model.storeMirror(.init(id: id, at: model.messages[i].at, edited: true,
1562 reactions: model.messages[i].reactions, payload: p))
1563 }
1564 case "delete":
1565 guard let id = frame.id else { return }
1566 // A deleted groupapps event un-folds like the web's refold-on-delete.
1567 if groupAppsEvents.removeValue(forKey: id) != nil { recomputeGroupApps() }
1568 var list = messages
1569 list.removeAll { $0.id == id }
1570 refreshRollup(&list)
1571 // The implosion: rows carry a scale+fade removal transition, and
1572 // deletes are the one mutation that publishes inside an animation
1573 // (arrivals/prepends stay unanimated by design).
1574 withAnimation(.easeIn(duration: 0.22)) { messages = list }
1575 let instanceID = instance.id
1576 let tid = threadID
1577 Task.detached(priority: .utility) {
1578 await MessageStore.shared.delete(instance: instanceID, thread: tid, id: id)
1579 }
1580 case "dm_closed", "dm_reopened":
1581 // Either side ended (or revived) this DM (issue #578) — the open
1582 // pane swaps between composer and closed banner live, the web's
1583 // renderActiveSurfaces on these frames. The sidebar row rides the
1584 // store's socket separately.
1585 dmClosed = frame.type == "dm_closed"
1586 default:
1587 break // presence/read ride the store's socket; unknown types ignored
1588 }
1589 }
1590
1591 // What a stored "msg" frame decrypts to: a reaction event folds into its
1592 // target's tally, a conversion into the claim map that moves an inline
1593 // chain into a thread; anything else is a message (or app-event
1594 // projection), possibly carrying a group-apps event alongside.
1595 private enum InboundStored: Sendable {
1596 case reaction(MessagePayload)
1597 case convert(MessagePayload)
1598 case message(MessagePayload?, GroupApps.Event?, AppChatProjection.UnprojectedAppEvent?)
1599 }
1600
1601 // Fold one reaction event into a message and re-derive its rendered tally
1602 // (per-emoji count + mine, emoji-sorted — chat.js's derived array; the
1603 // chat.json vectors pin the shape). Keyed by (emoji, uid): the latest
1604 // event wins, so echoes and history re-pulls fold idempotently. Returns
1605 // false when the event is malformed, targets another message, or removes
1606 // a reaction that was never folded.
1607 nonisolated static func foldReaction(_ p: MessagePayload, into m: inout Message,
1608 me: String) -> Bool {
1609 guard p.isReactionEvent, let emoji = p.emoji, let on = p.on,
1610 let uid = p.uid, p.targetSid == m.id else { return false }
1611 if let j = m.reactionEvents.firstIndex(where: { $0.emoji == emoji && $0.uid == uid }) {
1612 m.reactionEvents[j].on = on
1613 m.reactionEvents[j].ts = p.ts
1614 } else if on {
1615 m.reactionEvents.append(ReactionEvent(emoji: emoji, uid: uid, on: true, ts: p.ts))
1616 } else {
1617 return false
1618 }
1619 var reactors: [String: [String]] = [:]
1620 for ev in m.reactionEvents where ev.on {
1621 reactors[ev.emoji, default: []].append(ev.uid)
1622 }
1623 m.reactions = reactors.keys.sorted().map {
1624 Reaction(emoji: $0, count: reactors[$0]!.count,
1625 mine: !me.isEmpty && reactors[$0]!.contains(me))
1626 }
1627 return true
1628 }
1629
1630 /// Fold a live reaction event (or the local optimistic copy) into the
1631 /// target message, and mirror the derived tally into the local store so a
1632 /// reopened chat renders it from disk.
1633 private func applyReactionEvent(_ payload: MessagePayload) {
1634 noteGossip(payload, own: payload.uid == myUserId) // tamper-evidence (live reactions)
1635 guard let sid = payload.targetSid,
1636 let i = messages.firstIndex(where: { $0.id == sid }) else { return }
1637 var m = messages[i]
1638 guard Self.foldReaction(payload, into: &m, me: myUserId) else { return }
1639 messages[i] = m
1640 let tally = m.reactions
1641 let instanceID = instance.id
1642 let tid = threadID
1643 Task.detached(priority: .utility) {
1644 await MessageStore.shared.updateReactions(instance: instanceID, thread: tid,
1645 id: sid, tally)
1646 }
1647 }
1648
1649 // ---- typing (the web's chat.typing beats) ------------------------------
1650
1651 private struct TypingPayload: Decodable, Sendable {
1652 let type: String?
1653 let name: String?
1654 let uid: String?
1655 let expiry: Double?
1656 }
1657
1658 // A beat arrives for every keystroke burst of every other member — decrypt
1659 // it off main like any other frame, then fold the plaintext here.
1660 private func foldTypingBlob(_ blob: String) {
1661 guard let key else { return }
1662 decryptThenFold({ try? key.decryptJSON(TypingPayload.self, blob: blob) }) { model, p in
1663 model.foldTyping(p)
1664 }
1665 }
1666
1667 // Fold one beat, mirroring chat.js: skip our own (uid, else name), clamp
1668 // expiry to 0…60s, 0 removes; a sweep timer retires stale names.
1669 private func foldTyping(_ p: TypingPayload) {
1670 guard p.type == "chat.typing", let name = p.name, !name.isEmpty else { return }
1671 if let uid = p.uid, !myUserId.isEmpty {
1672 if uid == myUserId { return }
1673 } else if !myScreenName.isEmpty, name == myScreenName {
1674 return
1675 }
1676 let secs = min(max(p.expiry ?? 0, 0), 60)
1677 if secs == 0 {
1678 typingExpiry.removeValue(forKey: name)
1679 } else {
1680 typingExpiry[name] = Date().addingTimeInterval(secs)
1681 }
1682 refreshTyping()
1683 typingSweep?.invalidate()
1684 if !typingExpiry.isEmpty {
1685 typingSweep = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
1686 Task { @MainActor in self?.refreshTyping() }
1687 }
1688 }
1689 }
1690
1691 private func refreshTyping() {
1692 let now = Date()
1693 typingExpiry = typingExpiry.filter { $0.value > now }
1694 let names = typingExpiry.keys.sorted()
1695 if names != typing { typing = names }
1696 if typingExpiry.isEmpty {
1697 typingSweep?.invalidate()
1698 typingSweep = nil
1699 }
1700 }
1701
1702 private struct TypingOut: Encodable {
1703 let type = "chat.typing"
1704 let name: String
1705 let ts: Int64
1706 var uid: String?
1707 let expiry: Int
1708 }
1709
1710 /// Composer keystroke plumbing (chat.js's beats): call with `true` on
1711 /// every keystroke while there's text — a beat goes out at most every 10s,
1712 /// each carrying a 15s expiry — and `false` when the draft empties or a
1713 /// message sends, which clears the indicator immediately for everyone.
1714 public func typingBeat(active: Bool) {
1715 idleTimer?.invalidate()
1716 if active {
1717 // Dry-up guard: no keystroke for 10s → clear without waiting for expiry.
1718 idleTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { [weak self] _ in
1719 Task { @MainActor in self?.typingBeat(active: false) }
1720 }
1721 guard Date().timeIntervalSince(lastBeatSent) > 10 else { return }
1722 sendTypingBeat(expiry: 15)
1723 beating = true
1724 } else if beating {
1725 sendTypingBeat(expiry: 0)
1726 beating = false
1727 lastBeatSent = .distantPast
1728 }
1729 }
1730
1731 private func sendTypingBeat(expiry: Int) {
1732 guard let key, let socket else { return }
1733 var payload = TypingOut(name: myScreenName,
1734 ts: Int64(Date().timeIntervalSince1970 * 1000), expiry: expiry)
1735 if !myUserId.isEmpty { payload.uid = myUserId }
1736 lastBeatSent = Date()
1737 let tid = threadID
1738 let sendable = payload
1739 enqueueOffMain {
1740 guard let blob = try? key.encryptJSON(sendable),
1741 let data = try? JSONEncoder().encode(EphemeralFrame(thread_id: tid, blob: blob)),
1742 let json = String(data: data, encoding: .utf8) else { return }
1743 socket.send(json)
1744 }
1745 }
1746
1747 private struct EphemeralFrame: Encodable {
1748 let type = "msg"
1749 let thread_id: String
1750 let blob: String
1751 let ephemeral = true
1752 }
1753
1754 // A live frame's already-decrypted payload folds into the published list
1755 // (the decrypt happened on the off-main lane), mirrored into the local
1756 // store.
1757 private func foldDecrypted(id: Int64, at: Int64, payload: MessagePayload?,
1758 edited: Bool, reactions: [Reaction]) {
1759 noteGossip(payload, own: payload?.uid == myUserId) // tamper-evidence (live)
1760 // Our own message echoing back: cancel its "sending…" timer — the fold
1761 // below upgrades the optimistic copy in place (matched on _cid).
1762 if let c = payload?._cid { clearSlowTimer(c) }
1763 var list = messages
1764 fold(id: id, at: at, payload: payload,
1765 edited: edited, reactions: reactions, into: &list)
1766 refreshRollup(&list)
1767 messages = list
1768 if let payload {
1769 storeMirror(.init(id: id, at: at, edited: edited,
1770 reactions: reactions, payload: payload))
1771 }
1772 }
1773
1774 // Fire-and-forget mirror writes — the store is an actor, so nothing here
1775 // touches the main thread.
1776 private func storeMirror(_ m: MessageStore.StoredMessage) {
1777 let instanceID = instance.id
1778 let tid = threadID
1779 Task.detached(priority: .utility) {
1780 await MessageStore.shared.upsert(instance: instanceID, thread: tid, [m])
1781 }
1782 }
1783
1784 // Fold one already-decrypted payload into a working list (upsert by id —
1785 // history refetch and the live echo of an already-seen message must not
1786 // duplicate). Batch callers fold a whole page before publishing once.
1787
1788 // ---- app-host projection queries (docs/native-apps.md phase 5) --------
1789 // AppChatProjection is the FAST PATH (schema apps + installs, sync, no
1790 // flash); every other app event queries the app host — the web engine
1791 // that owns chatMessage/groupFeedMessage — and its answer folds in as the
1792 // same synthesized bubble renderable() builds. The platform feed views
1793 // install the projector (a CommandHost call); without one, unknown
1794 // events stay silent exactly as before.
1795
1796 /// Set by the platform feed view: project one decrypted event JSON
1797 /// (group = a group feed) into its chat line, or nil for silent.
1798 public var projector: (@MainActor (String, Bool) async -> AppChatProjection.ProjectedLine?)? {
1799 didSet { fillProjections() }
1800 }
1801 private var pendingProjection: [Int64: (at: Int64, ev: AppChatProjection.UnprojectedAppEvent)] = [:]
1802 private var projectionAsked: Set<Int64> = []
1803 private var projectionTask: Task<Void, Never>?
1804
1805 private func noteUnprojected(_ batch: [(Int64, Int64, AppChatProjection.UnprojectedAppEvent)]) {
1806 var added = false
1807 for (id, at, ev) in batch where !projectionAsked.contains(id) {
1808 pendingProjection[id] = (at, ev)
1809 added = true
1810 }
1811 if added { fillProjections() }
1812 }
1813
1814 private func fillProjections() {
1815 guard projectionTask == nil, projector != nil, !pendingProjection.isEmpty else { return }
1816 projectionTask = Task { @MainActor [weak self] in
1817 defer { self?.projectionTask = nil }
1818 let group = self?.threadKind == "group"
1819 while let self, let projector = self.projector,
1820 let (id, item) = self.pendingProjection.min(by: { $0.key < $1.key }) {
1821 self.pendingProjection.removeValue(forKey: id)
1822 self.projectionAsked.insert(id)
1823 guard let line = await projector(item.ev.eventJSON, group),
1824 !line.text.isEmpty else { continue }
1825 // The same synthesized payload renderable() builds: an action
1826 // line keeps the actor's attribution; a bot bubble wears the
1827 // app's title. Folding via foldDecrypted also mirrors it into
1828 // the store, so a reopen renders it without re-querying.
1829 var text = line.text
1830 if group, line.action {
1831 let you = item.ev.uid == self.myUserId && !self.myUserId.isEmpty
1832 text = "\(you ? "You" : (item.ev.name.isEmpty ? "Someone" : item.ev.name)) \(text)"
1833 }
1834 let p = MessagePayload(type: "chat",
1835 uid: line.action ? item.ev.uid : nil,
1836 name: line.action ? item.ev.name : line.title,
1837 text: text,
1838 ts: item.ev.ts.map { Int64($0) },
1839 action: line.action,
1840 attachments: nil,
1841 replyTo: nil)
1842 self.foldDecrypted(id: id, at: item.at, payload: p, edited: false, reactions: [])
1843 }
1844 }
1845 }
1846
1847 // App-thread events (todo.add, remind.fire, …) aren't type:"chat" — the
1848 // companion apps' Chat view projects them into chat lines on the web, and
1849 // this is that projection natively: decode the richer event and synthesize
1850 // a renderable payload (bot bubble or action line).
1851 nonisolated public static func renderable(_ blob: String, key: GroupKey,
1852 group: Bool = false,
1853 myUserId: String = "") -> MessagePayload? {
1854 guard let p = try? key.decryptJSON(MessagePayload.self, blob: blob) else { return nil }
1855 if p.isRenderableChat { return p }
1856 guard let ev = try? key.decryptJSON(AppChatProjection.AppEvent.self, blob: blob),
1857 let proj = AppChatProjection.project(ev, group: group) else { return nil }
1858 // A GROUP action line wears its actor in the text itself ("You added
1859 // “buy milk” to the to-dos") — the web's foldGroupAppEventBubble
1860 // prefix, mirrored, so the centred native action row (which renders
1861 // text alone) says who acted. Personal threads keep the terse line.
1862 var text = proj.text
1863 if group, proj.action {
1864 let you = !myUserId.isEmpty && p.uid == myUserId
1865 text = "\(you ? "You" : (p.name ?? "Someone")) \(text)"
1866 }
1867 return MessagePayload(type: "chat",
1868 uid: proj.action ? p.uid : nil,
1869 name: proj.action ? p.name : AppChatProjection.title(proj.appID),
1870 text: text,
1871 ts: p.ts,
1872 action: proj.action,
1873 attachments: nil,
1874 replyTo: nil)
1875 }
1876
1877 private func fold(id: Int64, at: Int64, payload: MessagePayload?,
1878 edited: Bool, reactions: [Reaction], into list: inout [Message],
1879 cid: String? = nil, pending: Bool = false) {
1880 guard let p = payload, p.isRenderableChat else { return }
1881 let attachments = p.attachments ?? []
1882 let text = p.text ?? ""
1883 // Own-message check mirrors the web (ownMessage in ui.js): uid when the
1884 // payload carries one, else the screen name (old payloads).
1885 let mine = p.uid.map { !myUserId.isEmpty && $0 == myUserId }
1886 ?? (!myScreenName.isEmpty && p.name == myScreenName)
1887 // The client id to pair on: the explicit optimistic-fold argument, else
1888 // the one riding in the echo's payload. A confirming echo has pending ==
1889 // false and reads it from `p._cid`; a fresh optimistic copy passes it in.
1890 let pairCid = cid ?? p._cid
1891 var msg = Message(id: id, name: p.name ?? "", text: text, mine: mine, at: at,
1892 action: p.action ?? false, edited: edited,
1893 attachments: attachments, reply: p.replyTo, reactions: reactions,
1894 raw: p)
1895 msg.cid = pending ? pairCid : nil
1896 msg.pending = pending
1897 msg.sentAt = pending ? Int64(Date().timeIntervalSince1970 * 1000) : nil
1898 Self.foldOptimistic(msg, pairCid: pairCid, pending: pending, into: &list)
1899 }
1900
1901 // The optimistic-send lifecycle on the message list — factored out (nonisolated
1902 // static, like foldReaction) so the event vectors can drive it directly. Three
1903 // cases, in order:
1904 // • a fresh OPTIMISTIC copy (pending) → appended, sorted last by its synthetic
1905 // pending id;
1906 // • our own ECHO confirming one (same cid, now a real server id) → the pending
1907 // copy is UPGRADED in place: it adopts the server id/time and drops its
1908 // pending flags, keeping its row so nothing is destroyed and re-created;
1909 // • anything else (another member's message, a history re-pull, a reconnect
1910 // replay) → the existing upsert-by-id, which stays idempotent.
1911 nonisolated static func foldOptimistic(_ msg: Message, pairCid: String?,
1912 pending: Bool, into list: inout [Message]) {
1913 if !pending, let c = pairCid,
1914 let i = list.firstIndex(where: { $0.cid == c && $0.pending }) {
1915 var up = list[i]
1916 up.id = msg.id // adopt the real server id (re-sorts into place)
1917 up.at = msg.at
1918 up.pending = false
1919 up.slow = false
1920 up.cid = nil
1921 up.text = msg.text
1922 up.attachments = msg.attachments
1923 up.reply = msg.reply
1924 up.raw = msg.raw
1925 if !msg.reactions.isEmpty { up.reactions = msg.reactions }
1926 if msg.edited { up.edited = true }
1927 list[i] = up
1928 list.sort { $0.id < $1.id }
1929 return
1930 }
1931 if let i = list.firstIndex(where: { $0.id == msg.id }) {
1932 // A live echo carries no reactions/edit state — keep what we have
1933 // unless the refold supplies fresher values.
1934 var m = msg
1935 if m.reactions.isEmpty { m.reactions = list[i].reactions }
1936 if !m.edited { m.edited = list[i].edited }
1937 list[i] = m
1938 } else {
1939 list.append(msg)
1940 list.sort { $0.id < $1.id }
1941 }
1942 }
1943
1944 // Pending optimistic copies live above every real server id so they sort to
1945 // the bottom of the feed until confirmed. 2^62 is unreachable by real
1946 // (auto-increment) message ids; the counter keeps concurrent pending sends in
1947 // send order.
1948 private static let pendingIDBase: Int64 = 1 << 62
1949 private var pendingIDSeq: Int64 = 0
1950 // The "still sending" timers, keyed by client id — each flips its copy to the
1951 // slow state 5s on if the echo hasn't landed. The echo cancels it.
1952 private var slowTimers: [String: Task<Void, Never>] = [:]
1953
1954 // Fold our own just-sent message straight into the list as a pending copy
1955 // (mirrors shell.js optimisticEmit). It shows instantly; its echo upgrades it.
1956 private func foldOptimisticSend(cid: String, text: String, ts: Int64,
1957 attachments: [Attachment], replyTo: ReplyRef?) {
1958 let localID = Self.pendingIDBase &+ pendingIDSeq
1959 pendingIDSeq &+= 1
1960 var p = MessagePayload()
1961 p.text = text
1962 p.name = myScreenName
1963 p.ts = ts
1964 if !myUserId.isEmpty { p.uid = myUserId }
1965 if !attachments.isEmpty { p.attachments = attachments }
1966 p.replyTo = replyTo
1967 p._cid = cid
1968 var list = messages
1969 fold(id: localID, at: ts / 1000, payload: p, edited: false, reactions: [],
1970 into: &list, cid: cid, pending: true)
1971 refreshRollup(&list)
1972 messages = list
1973 // Stay-pending on failure (web parity): we never remove the copy, only
1974 // surface a delay. After 5s unconfirmed, flip it to the slow state.
1975 slowTimers[cid] = Task { [weak self] in
1976 try? await Task.sleep(nanoseconds: 5_000_000_000)
1977 guard !Task.isCancelled else { return }
1978 self?.markSlow(cid: cid)
1979 }
1980 }
1981
1982 private func markSlow(cid: String) {
1983 slowTimers[cid] = nil
1984 guard let i = messages.firstIndex(where: { $0.cid == cid && $0.pending }) else { return }
1985 messages[i].slow = true
1986 }
1987
1988 // Our own echo has landed (or a confirming path ran) — cancel any slow timer.
1989 private func clearSlowTimer(_ cid: String) {
1990 slowTimers[cid]?.cancel()
1991 slowTimers[cid] = nil
1992 }
1993
1994 /// The id of the thread a message BELONGS to, or nil for a message in no
1995 /// thread. A thread exists only because someone chose "Reply in thread",
1996 /// so membership is exactly the wire flag: a reply carrying
1997 /// `replyTo.thread` points DIRECTLY at its thread's root (every thread
1998 /// send derives its target from the root — threadReplyRef), so this is a
1999 /// straight read, never a walk. Plain quote replies — the ordinary Reply
2000 /// verb, and every reply in the history from before the two verbs split —
2001 /// belong to no thread: an inline chain is conversation in the feed, not
2002 /// a thread nobody opened. Mirrors chat.js's threadRootOf.
2003 nonisolated public static func threadRootID(of m: Message) -> Int64? {
2004 if isThreadOnly(m), let id = m.reply?.id, id > 0 { return id }
2005 // …or the thread a CONVERSION moved it into (movedTo). The wire flag is
2006 // read first on purpose: a reply someone composed in a thread belongs
2007 // to that thread, and no conversion can pull it out of one.
2008 if let moved = m.movedTo, moved > 0 { return moved }
2009 return nil
2010 }
2011
2012 // ---- converting an inline chain into a thread ---------------------------
2013 //
2014 // Reply and Reply in Thread are separate verbs, and every reply in the
2015 // history from before that split is inline — so a long chain only ever
2016 // becomes a thread after the fact. The messages can't be rewritten to say
2017 // so (only their author may edit one, only for fifteen minutes, and it
2018 // would churn N authorships to change a display rule), so a conversion is
2019 // its own encrypted event that every client folds, exactly like a reaction.
2020 // Web twin: chat.js's chat.convert fold. Pinned by the convert_* vectors in
2021 // test/event-vectors/chat.json.
2022
2023 /// How many ids one conversion may claim (web twin: MAX_CONVERT_IDS).
2024 static let maxConvertIDs = 2000
2025
2026 /// One conversion's claim on a message: the thread it moved into, and the
2027 /// server id of the CONVERT EVENT that said so (the tie-break).
2028 public struct ThreadConvert: Equatable, Sendable {
2029 public let root: Int64
2030 public let at: Int64
2031 public init(root: Int64, at: Int64) {
2032 self.root = root
2033 self.at = at
2034 }
2035 }
2036
2037 /// Merge one convert event's claims into the map. Two people can convert
2038 /// overlapping chains, and the same event folds twice routinely (a history
2039 /// page and the live echo, a reconnect catch-up), so the merge is a pure
2040 /// function of the SET of events and never of their arrival order: the
2041 /// OLDEST conversion (lowest `at`) owns a message, and re-folding one
2042 /// changes nothing. Ties are impossible — `at` is a server message id.
2043 /// Returns false when nothing changed. Mirrors chat.js's claimConverts.
2044 ///
2045 /// `ids` is capped: a hostile client could name thousands in one payload
2046 /// (the server caps a blob at 64KB and can't read it) and the claim map is
2047 /// never pruned. The cap is far above any real chain — a conversation with
2048 /// more than this many inline replies to one message doesn't exist.
2049 @discardableResult
2050 nonisolated static func claimConverts(_ claims: inout [Int64: ThreadConvert],
2051 root: Int64, ids: [Int64], at: Int64) -> Bool {
2052 guard root > 0, at > 0 else { return false }
2053 var changed = false
2054 for sid in ids.prefix(maxConvertIDs) {
2055 guard sid > 0, sid != root else { continue } // a root is never inside its own thread
2056 if let prev = claims[sid], prev.at <= at { continue } // an older conversion owns it
2057 claims[sid] = ThreadConvert(root: root, at: at)
2058 changed = true
2059 }
2060 return changed
2061 }
2062
2063 /// The claims resolved to the thread each message actually lands in — a
2064 /// pure function of the map, so every client and every arrival order
2065 /// derives the same answer.
2066 ///
2067 /// Claims can nest: one conversion moves [b, c] under root a while another
2068 /// moves [c] under root b. A claimed root is followed through to the thread
2069 /// IT ended up in, so claim-rooted nesting flattens onto the outermost
2070 /// root. (That is what's guaranteed — NOT "threads never nest". A reply
2071 /// composed in a thread carries the wire flag, which is authoritative and
2072 /// can't be re-pointed, so if a conversion moves the message that reply's
2073 /// thread is rooted on, its thread ends up inside the converted one. Benign
2074 /// and reachable — it renders from its root's bubble in the outer thread —
2075 /// and only a race produces it: a "Reply in Thread" in flight while someone
2076 /// converts the chain it lives in.)
2077 ///
2078 /// A cycle can only come from a malformed or hostile payload (a real
2079 /// reply's id is always higher than the message it quotes) and resolves to
2080 /// no conversion at all rather than hanging. Mirrors chat.js's
2081 /// convertedRoots.
2082 nonisolated static func convertedRoots(_ claims: [Int64: ThreadConvert]) -> [Int64: Int64] {
2083 var out: [Int64: Int64] = [:]
2084 for (sid, claim) in claims {
2085 var cur = claim.root
2086 var seen: Set<Int64> = [sid]
2087 while let next = claims[cur], !seen.contains(cur) {
2088 seen.insert(cur)
2089 cur = next.root
2090 }
2091 if claims[cur] != nil { continue } // cycled — leave every message in it inline
2092 if cur > 0, cur != sid { out[sid] = cur }
2093 }
2094 return out
2095 }
2096
2097 /// Does a conversion's claim on `m` actually hold? The sender's id list is
2098 /// a CLAIM, not a fact, and the receiver checks it against the message
2099 /// itself: a conversion moves a reply CHAIN, so each message it names must
2100 /// really hang off the thread it's being moved into — its own quote is
2101 /// either the root, or another message the conversions claimed.
2102 ///
2103 /// Without this the shipped primitive would be "move any N messages under
2104 /// any root", which is worse than it sounds: a conversion renders no bubble
2105 /// (its only trace is a transient chat-list line), the oldest claim wins
2106 /// forever so no later event can undo it, and deleting the convert row
2107 /// isn't an undo either — so one crafted payload could quietly hide a
2108 /// couple of hundred unrelated messages under some old root,
2109 /// unattributably. Now such an id is ignored and the message stays put.
2110 ///
2111 /// Order-independent, and it needs no loaded window beyond the message
2112 /// itself: a claimed message that hasn't arrived has no quote to check, so
2113 /// it stays inline until it does — the same degradation as a thread reply
2114 /// whose root is paged out. Mirrors chat.js's convertHolds.
2115 nonisolated static func convertHolds(_ m: Message, _ claims: [Int64: ThreadConvert]) -> Bool {
2116 guard let claim = claims[m.id] else { return false }
2117 let quoted = m.reply?.id ?? 0
2118 return quoted == claim.root || claims[quoted] != nil
2119 }
2120
2121 /// Stamp each message with the thread a conversion moved it into. Derived,
2122 /// never authoritative: the claim map is the state and this is its
2123 /// projection onto the loaded window, re-run on every publish so a message
2124 /// that arrives AFTER its own conversion (an older page paging in, an
2125 /// out-of-order live frame) lands in the thread just the same.
2126 /// Mirrors chat.js's applyConverts.
2127 nonisolated static func applyConverts(_ list: inout [Message],
2128 _ claims: [Int64: ThreadConvert]) {
2129 guard !claims.isEmpty else { return }
2130 let roots = convertedRoots(claims)
2131 for i in list.indices {
2132 list[i].movedTo = convertHolds(list[i], claims) ? roots[list[i].id] : nil
2133 }
2134 }
2135
2136 /// The inline reply chain hanging under `rootID` — every message that
2137 /// quotes it, everything that quotes those, and so on — which is exactly
2138 /// what "Convert to thread" moves. Empty when there's nothing to move,
2139 /// which is also the whole gate on offering the verb.
2140 ///
2141 /// This is the transitive quote walk the two-verb split deleted, back in
2142 /// the one narrow place that needs it: deciding what a conversion covers.
2143 /// It is NOT a membership rule — nothing reads it to decide where a message
2144 /// renders (that's threadRootID, still a straight read) — and it runs on
2145 /// the converting client only, at menu time, to build the event's id list.
2146 ///
2147 /// One forward pass suffices because `list` is in server order and a
2148 /// reply's id is always higher than the message it quotes. Messages already
2149 /// living in a thread are skipped, so the chain is only ever inline
2150 /// conversation — and a bubble inside a thread has no chain at all, which
2151 /// is what keeps the verb off the thread view's menus.
2152 /// Mirrors chat.js's inlineChainIds.
2153 nonisolated public static func inlineChainIDs(in list: [Message], root rootID: Int64) -> [Int64] {
2154 guard rootID > 0,
2155 let root = list.first(where: { $0.id == rootID }),
2156 threadRootID(of: root) == nil else { return [] } // unloaded, or already in a thread
2157 var chain: Set<Int64> = [rootID]
2158 var out: [Int64] = []
2159 for m in list {
2160 guard !m.pending, threadRootID(of: m) == nil,
2161 let quoted = m.reply?.id, chain.contains(quoted) else { continue }
2162 chain.insert(m.id)
2163 out.append(m.id)
2164 }
2165 return out
2166 }
2167
2168 /// The one wording for the conversion's confirm, so web, Mac and iOS ask
2169 /// the same question — pluralised and locale-grouped like every displayed
2170 /// count. It says what everyone will see and that it's one-way, because it
2171 /// is: v1 has no undo. Mirrors chat.js's convertConfirm.
2172 nonisolated public static func convertConfirm(_ n: Int) -> String {
2173 let what = n == 1 ? "this reply" : "these \(n.formatted(.number)) replies"
2174 return "Move \(what) into a thread? Everyone will see the conversation there instead of in the chat, and this can't be undone."
2175 }
2176
2177 // Every thread reply's root (message id → root id). Mirrors chat.js's
2178 // threadRootOf fold — pinned by the reply_thread_grouping and
2179 // inline_reply_chain_is_not_a_thread vectors (test/event-vectors/chat.json).
2180 nonisolated static func threadRoots(_ list: [Message]) -> [Int64: Int64] {
2181 var roots: [Int64: Int64] = [:]
2182 for m in list {
2183 if let root = threadRootID(of: m) { roots[m.id] = root }
2184 }
2185 return roots
2186 }
2187
2188 /// The ReplyRef a PLAIN reply to `m` carries — the exact message tapped,
2189 /// nothing resolved. Replying to a reply quotes that reply; the chain
2190 /// stacks inline in the feed and starts no thread. Mirrors chat.js's
2191 /// quoteTarget — pinned by reply_thread_grouping's
2192 /// expected_reply_targets.quote.
2193 nonisolated static func quoteTarget(for m: Message) -> ReplyRef {
2194 ReplyRef(id: m.id, name: m.name, snippet: replySnippet(m.raw))
2195 }
2196
2197 /// Composer entry point for both platforms' startReply.
2198 public func quoteTarget(for m: Message) -> ReplyRef {
2199 Self.quoteTarget(for: m)
2200 }
2201
2202 /// The ReplyRef a "Reply in Thread" on `m` carries — the ROOT of the
2203 /// thread the reply belongs in. A message already IN a thread continues
2204 /// that thread (its root, snapshotted fresh when loaded, the quoted
2205 /// snapshot until then); anything else — a plain message, a quote reply,
2206 /// a whole inline chain — roots a NEW thread on ITSELF, because until
2207 /// someone chose this verb there was no thread to join. Mirrors chat.js's
2208 /// threadTarget. The `thread: true` wire flag is added at the compose
2209 /// seam (.inThread()), never here.
2210 nonisolated static func threadTarget(in list: [Message], for m: Message) -> ReplyRef {
2211 guard let rootID = threadRootID(of: m) else { return quoteTarget(for: m) }
2212 if let root = list.first(where: { $0.id == rootID }) {
2213 return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
2214 }
2215 // Root paged out. The frozen snapshot a FLAGGED reply carries is the
2216 // root's own (every thread send derives its ref from the root), so it
2217 // stands in. A CONVERTED message's ref is the message it quoted —
2218 // which is not the root — so there is nothing truthful to fall back on
2219 // and the id travels alone: the reply still lands in the right thread,
2220 // and the view says the original isn't available rather than naming
2221 // the wrong message.
2222 guard isThreadOnly(m) else { return ReplyRef(id: rootID) }
2223 return ReplyRef(id: rootID, name: m.reply?.name, snippet: m.reply?.snippet)
2224 }
2225
2226 /// Composer entry point for both platforms' "Reply in Thread".
2227 public func threadTarget(for m: Message) -> ReplyRef {
2228 Self.threadTarget(in: messages, for: m)
2229 }
2230
2231 /// What ⌘R replies to: the newest real message, yours or anyone else's
2232 /// (chat.js's `latest(false)`). Action lines are skipped — a /me-style
2233 /// line has no bubble to quote — and so are optimistic copies: their id is
2234 /// a synthetic pending one, so a reply staged on it would carry a sid the
2235 /// server has never heard of. (The web has that wart; there's no reason to
2236 /// port it.)
2237 nonisolated public static func latestReplyTarget(in list: [Message]) -> Message? {
2238 list.last { !$0.action && !$0.pending }
2239 }
2240
2241 /// What ⌘T reacts to: the newest message that ISN'T yours (chat.js's
2242 /// `latest(true)`) — reacting to your own message is what the right-click
2243 /// menu is for. Attribution falls back to `mine`, which already resolves
2244 /// uid-less legacy messages by name.
2245 nonisolated public static func latestReactionTarget(in list: [Message]) -> Message? {
2246 list.last { !$0.action && !$0.pending && !$0.mine }
2247 }
2248
2249 /// The reply thread rooted at `rootID`: the root message (when loaded)
2250 /// first, then every message whose thread root is that id, in server
2251 /// order. Membership is the flag and a direct pointer (threadRootID(of:)):
2252 /// an inline quote chain hanging off the same root is NOT part of the
2253 /// thread, it's ordinary feed conversation. When the root itself has
2254 /// scrolled out of the loaded window the replies still fold — the list
2255 /// just starts at the oldest loaded reply, under the view's note.
2256 /// Mirrors chat.js's
2257 /// threadItems — the thread view re-derives this from current state on
2258 /// every publish, never a cached list. Pinned by the web twin in
2259 /// test/apps.test.mjs and by expected_thread_items in chat.json.
2260 nonisolated public static func threadItems(in list: [Message], root rootID: Int64) -> [Message] {
2261 guard rootID > 0 else { return [] }
2262 var out: [Message] = []
2263 if let root = list.first(where: { $0.id == rootID }) { out.append(root) }
2264 out.append(contentsOf: list.filter { threadRootID(of: $0) == rootID })
2265 return out.sorted(by: feedPrecedes)
2266 }
2267
2268 /// Feed order between two messages: confirmed ones by server id (the stable
2269 /// identity), with any still-pending optimistic copy LAST — never ahead of
2270 /// the root or of a confirmed reply. That was the cause of the thread
2271 /// view's "blink" when a just-sent reply sorted above the root. Native
2272 /// pending ids already sit above every real id (1<<62, foldOptimisticSend),
2273 /// so this is the explicit form of that order. Mirrors chat.js's
2274 /// `bySidPendingLast`, and is shared by threadItems and threadListItems so
2275 /// a thread's rows and the list's ordering can't fall out of step.
2276 nonisolated static func feedPrecedes(_ a: Message, _ b: Message) -> Bool {
2277 if a.pending != b.pending { return !a.pending }
2278 return a.id < b.id
2279 }
2280
2281 /// One row of the Threads list: a thread as its index shows it — the line
2282 /// you recognise the thread by (its root's author + snippet), how many
2283 /// replies it holds, and whether that root is actually loaded. Equatable so
2284 /// SwiftUI repaints a row exactly when what it shows changed, which is what
2285 /// the web achieves with `threadListSignature`.
2286 ///
2287 /// Not quite the same fields, though: the web's signature is
2288 /// `rootSid:count:name:snippet` and omits `rootLoaded`, which IS shown —
2289 /// an unheaded row (a converted chain whose root paged out, so both name
2290 /// and snippet are empty) reads "The original message isn't available" off
2291 /// that flag alone. So a root paging in as a nameless, textless message
2292 /// changes the web's rendered line without changing its signature, and its
2293 /// open panel skips a repaint native would make. A corner of a corner, and
2294 /// a WEB-side fix (add rootLoaded to threadListSignature) — noted here
2295 /// rather than fixed, because this change is scoped to native.
2296 public struct ThreadListRow: Identifiable, Equatable, Sendable {
2297 /// The thread's root message id — the row's identity, and what a tap opens.
2298 public let rootID: Int64
2299 /// Is that root in the loaded window? False = the row is headed by the
2300 /// snapshot its replies carry, or by nothing at all (see below).
2301 public let rootLoaded: Bool
2302 public let name: String
2303 public let snippet: String
2304 /// Replies in the thread — the same number its root's tally wears.
2305 public let count: Int
2306 public var id: Int64 { rootID }
2307
2308 public init(rootID: Int64, rootLoaded: Bool, name: String, snippet: String, count: Int) {
2309 self.rootID = rootID
2310 self.rootLoaded = rootLoaded
2311 self.name = name
2312 self.snippet = snippet
2313 self.count = count
2314 }
2315 }
2316
2317 /// Every thread in the loaded window, newest activity first — what the
2318 /// Threads list shows. One row per thread ROOT.
2319 ///
2320 /// Membership is threadRootID(of:) — the same read the "N Replies" tally
2321 /// makes, so the count on a row and the count on its root can never
2322 /// disagree. Plain quote replies therefore never appear: an inline chain is
2323 /// a conversation already visible in the feed, not a thread anyone opened,
2324 /// and it has no thread to come back to.
2325 ///
2326 /// Ordering is by LAST REPLY, not root age: the list answers "where is the
2327 /// conversation", so this morning's root nobody answered sits below a
2328 /// week-old one someone replied to a minute ago. The last message (and the
2329 /// row order) is picked with the feed's own comparator, so a still-pending
2330 /// optimistic reply counts as the newest thing in its thread — as it
2331 /// visibly is.
2332 ///
2333 /// A thread whose root has scrolled out of the loaded window keeps its row,
2334 /// headed by the quoted snapshot its replies carry — the same fallback the
2335 /// thread view's own header uses (`rootLoaded` says which one a row is).
2336 /// Mirrors chat.js's `threadListItems`; pinned for both platforms by
2337 /// `expected_thread_list` in test/event-vectors/chat.json.
2338 nonisolated public static func threadListItems(_ list: [Message]) -> [ThreadListRow] {
2339 var order: [Int64] = [] // roots in first-seen order
2340 var counts: [Int64: Int] = [:]
2341 var last: [Int64: Message] = [:] // newest message in each thread
2342 var stand: [Int64: ReplyRef] = [:] // the header a rootless row wears
2343 for m in list {
2344 guard let rootID = threadRootID(of: m) else { continue }
2345 if counts[rootID] == nil {
2346 order.append(rootID)
2347 counts[rootID] = 0
2348 last[rootID] = m
2349 // Unloaded root: only a FLAGGED reply's ref is the root's own
2350 // snapshot (see threadTarget) — a CONVERTED message quotes
2351 // something else, so its row is headed by nothing rather than
2352 // by the wrong message.
2353 if isThreadOnly(m), let ref = m.reply { stand[rootID] = ref }
2354 }
2355 counts[rootID]! += 1
2356 if feedPrecedes(last[rootID]!, m) { last[rootID] = m }
2357 }
2358 // The overwhelmingly common chat has no threads at all: leave without
2359 // building an index of the window (this runs on every publish).
2360 guard !order.isEmpty else { return [] }
2361 var byID: [Int64: Message] = [:]
2362 byID.reserveCapacity(list.count)
2363 for m in list { byID[m.id] = m }
2364 let rows = order.map { rootID -> ThreadListRow in
2365 let root = byID[rootID]
2366 return ThreadListRow(rootID: rootID,
2367 rootLoaded: root != nil,
2368 name: root?.name ?? stand[rootID]?.name ?? "",
2369 snippet: root.map { replySnippet($0.raw) } ?? stand[rootID]?.snippet ?? "",
2370 count: counts[rootID] ?? 0)
2371 }
2372 // Newest activity first. Every row's `last` is a different message, so
2373 // the comparator is a strict total order and no two rows can tie.
2374 return rows.sorted { feedPrecedes(last[$1.rootID]!, last[$0.rootID]!) }
2375 }
2376
2377 /// The ReplyRef the open thread view stages on the composer (always the
2378 /// thread's root) — doubling as the header stand-in while the root isn't
2379 /// loaded. A loaded root snapshots its name/snippet fresh; otherwise fall
2380 /// back to the quoted snapshot a loaded member of the thread carries (the
2381 /// web hands openThread the tapped message's threadTarget as this
2382 /// fallback; natively the tap only carries the root id, so it's re-derived
2383 /// from state).
2384 nonisolated public static func threadReplyRef(in list: [Message], root rootID: Int64) -> ReplyRef {
2385 if let root = list.first(where: { $0.id == rootID }) {
2386 return ReplyRef(id: root.id, name: root.name, snippet: replySnippet(root.raw))
2387 }
2388 if let m = list.first(where: { threadRootID(of: $0) == rootID }) {
2389 return threadTarget(in: list, for: m)
2390 }
2391 return ReplyRef(id: rootID)
2392 }
2393
2394 /// Live convenience for the containers' openThread/stageThreadReply.
2395 public func threadReplyRef(root rootID: Int64) -> ReplyRef {
2396 Self.threadReplyRef(in: messages, root: rootID)
2397 }
2398
2399 /// A reply made IN a thread (its payload's replyTo carries thread:true —
2400 /// ReplyRef): it belongs to the thread view, not the main feed (see
2401 /// mainFeedItems for the one exception). The flag is also what makes it a
2402 /// thread reply at all — it tallies on its root (refreshReplyCounts) and
2403 /// folds into threadItems where a plain quote reply does neither — and it
2404 /// counts as an arrival for unread/notification purposes either way.
2405 nonisolated public static func isThreadOnly(_ m: Message) -> Bool {
2406 m.reply?.thread == true
2407 }
2408
2409 /// The main feed's item list from the full fold: every message except
2410 /// the thread-only replies whose ROOT is loaded (Slack's model —
2411 /// the feed shows a threaded root with its "N replies" tally; the replies
2412 /// themselves live in the thread view). ROOT-AWARE, deliberately: the
2413 /// tally only exists on a loaded root (refreshReplyCounts), so a thread
2414 /// reply whose root is older than the loaded history page would
2415 /// otherwise render NOWHERE — invisible on every surface until the reader
2416 /// blind-scrolled past the root. Such a reply degrades to an inline
2417 /// bubble until its root pages in, then collapses into the tally. Inline
2418 /// quote replies (no flag) stay in the feed as before. Order is
2419 /// preserved, so bubble grouping (same-sender runs) and day separators
2420 /// computed over THIS list treat two feed messages as neighbours even
2421 /// when a hidden reply sits between them in the fold. Mirrors the web's
2422 /// inMainFeed/viewItems; pinned by the message_in_thread,
2423 /// reply_thread_grouping + thread_reply_root_unloaded vectors
2424 /// (test/event-vectors/chat.json).
2425 nonisolated public static func mainFeedItems(_ list: [Message]) -> [Message] {
2426 let roots = threadRoots(list)
2427 var loaded = Set<Int64>()
2428 loaded.reserveCapacity(list.count)
2429 for m in list { loaded.insert(m.id) }
2430 return list.filter { m in
2431 // Unflagged, or flagged but pointing nowhere resolvable: a feed bubble.
2432 // In no thread — unflagged and unconverted, or flagged but pointing
2433 // nowhere resolvable: a feed bubble. (`roots` is threadRootID's
2434 // answer, which covers both ways in, so there's no second flag
2435 // check here to fall out of step with it.)
2436 guard let root = roots[m.id] else { return true }
2437 // Hidden only while its root is on screen to carry the tally.
2438 return !loaded.contains(root)
2439 }
2440 }
2441
2442 /// A notification tap landed on this chat and every unread message is a
2443 /// reply living in its thread: the id to open that thread on and land
2444 /// upon (the newest of them), else nil.
2445 ///
2446 /// A push for a thread-only reply routes at THREAD granularity — no
2447 /// message id — and the feed's own unread anchor searches the VISIBLE
2448 /// rows, so it can't see the reply the reader was sent to: they tap the
2449 /// banner and find an unchanged transcript. This is the containers'
2450 /// answer, and it is deliberately all-or-nothing: it fires only when NO
2451 /// unread message is in the feed. With even one visible unread, the
2452 /// feed's anchor is doing the right thing (it lands on the OLDEST unread
2453 /// — reading continues where it left off), and auto-opening a thread
2454 /// would cover those unreads with a view the reader never asked for,
2455 /// composer armed on someone else's root.
2456 ///
2457 /// `cutoff` is the pre-tap read watermark; `feed` is mainFeedItems of the
2458 /// same fold. Own messages never count as unread.
2459 nonisolated public static func unreadThreadJump(in list: [Message], feed: [Message],
2460 since cutoff: Int64) -> Int64? {
2461 let unread = list.filter { $0.at > cutoff && !$0.mine }
2462 guard !unread.isEmpty else { return nil }
2463 let visible = Set(feed.map(\.id))
2464 guard !unread.contains(where: { visible.contains($0.id) }) else { return nil }
2465 return unread.last?.id
2466 }
2467
2468 /// Every publish's rollup: apply the conversions this chat has folded, then
2469 /// re-derive the thread tallies over the result. One seam for both, so no
2470 /// fold path (history, live frame, edit, delete, optimistic send) can
2471 /// publish a list that forgot a conversion. The static halves stay pure so
2472 /// the vector tests can drive them directly.
2473 private func refreshRollup(_ list: inout [Message]) {
2474 Self.applyConverts(&list, converts)
2475 Self.refreshReplyCounts(&list)
2476 }
2477
2478 // Re-derive each message's reply-thread tally (chat.js's replyCounts fold):
2479 // the "N Replies" on a root counts its THREAD — only the replies someone
2480 // deliberately made in it (threadRootID(of:)). An ordinary inline reply
2481 // contributes nothing: it's already visible right there in the feed, so a
2482 // tally on top of it would count the same conversation twice and read as a
2483 // thread nobody started. Runs once per batch, before the list publishes.
2484 // Static so the reply_thread_grouping vector test can drive it directly.
2485 nonisolated static func refreshReplyCounts(_ list: inout [Message]) {
2486 let roots = Self.threadRoots(list)
2487 var counts: [Int64: Int] = [:]
2488 var first: [Int64: Int64] = [:]
2489 // Which messages have an INLINE reply hanging off them — "Convert to
2490 // thread"'s gate. A direct inline reply is all it takes: the chain
2491 // under a message is non-empty exactly when one exists (the walk from
2492 // there finds at least it). Pending copies don't count — the server has
2493 // never heard of their ids, so no other client could resolve one.
2494 var answered = Set<Int64>()
2495 for m in list {
2496 if roots[m.id] == nil, !m.pending, let quoted = m.reply?.id, quoted > 0 {
2497 answered.insert(quoted)
2498 }
2499 guard let root = roots[m.id], root > 0 else { continue }
2500 counts[root, default: 0] += 1
2501 if first[root] == nil { first[root] = m.id }
2502 }
2503 for i in list.indices {
2504 list[i].replyCount = counts[list[i].id] ?? 0
2505 list[i].firstReplyID = first[list[i].id]
2506 list[i].threadRootID = roots[list[i].id]
2507 // A reply reads its thread's size off the same tally its root wears.
2508 list[i].threadReplyCount = roots[list[i].id].flatMap { counts[$0] } ?? 0
2509 // A message already in a thread has no inline chain under it — its
2510 // answers are in the thread, not the transcript.
2511 list[i].hasInlineChain = roots[list[i].id] == nil && answered.contains(list[i].id)
2512 }
2513 }
2514
2515 /// The one wording for a thread's size, wherever it's shown: the root's
2516 /// tally, a quoted reply's thread link and the thread view's own header
2517 /// read as the same thing because they ARE the same string. Pluralised,
2518 /// and grouped for the locale (1,204 Replies). Mirrors chat.js's
2519 /// `replyCountLabel`.
2520 nonisolated public static func replyCountLabel(_ n: Int) -> String {
2521 n == 1 ? "1 Reply" : "\(n.formatted(.number)) Replies"
2522 }
2523
2524 /// How many REPLIES a `threadItems` list holds — the list minus its root,
2525 /// when the root is loaded (it's the first item). The thread view renders
2526 /// root and replies as one list, so "how many rows" and "how many answers"
2527 /// are no longer the same number: a thread nobody has answered yet is one
2528 /// row and must still read "0 Replies", never "1 Reply".
2529 /// Mirrors chat.js's `threadReplyCount`.
2530 nonisolated public static func threadReplyCount(_ items: [Message], root rootID: Int64) -> Int {
2531 guard let first = items.first else { return 0 }
2532 return first.id == rootID ? items.count - 1 : items.count
2533 }
2534
2535 /// Does this reply's quote card double as a link into its thread, and with
2536 /// what count? Only a THREAD reply ever wears one (an inline reply's
2537 /// threadReplyCount is 0 — its card quotes and jumps, nothing more), and
2538 /// only while its thread holds more than just it: rendered inline because
2539 /// its root is paged out, its card is the one way back into the
2540 /// conversation it belongs to. The number is the thread's WHOLE tally
2541 /// (the same one the root wears), never "the others besides me". Returns 0
2542 /// for "no thread link". Mirrors chat.js's `threadLinkCount`.
2543 nonisolated public static func threadLinkCount(_ m: Message) -> Int {
2544 m.threadReplyCount > 1 ? m.threadReplyCount : 0
2545 }
2546}
2547