| 1 | import Combine |
| 2 | import Foundation |
| 3 | import SwiftUI |
| 4 | import AVFoundation |
| 5 | import 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 |
| 14 | public 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 | |