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