{ "description": "Chat event vectors: ensures chat.js (web) and native (iOS/Mac) fold message/reaction/edit/delete events identically. Reactions are now encrypted events (type='reaction' stored like messages). Used by test/event-vectors.test.mjs and ios/.../EventVectorTests.swift.", "app": "chat", "source": "web/static/apps/chat.js", "vectors": [ { "name": "plain_text_message", "description": "A simple text message with no attachments or reply.", "decrypted_payload": { "name": "Alice", "text": "Hello everyone", "ts": 1700000000000, "uid": "user-alice-1" }, "server_message": { "id": 12345, "created_at": 1700000000, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Alice", "text": "Hello everyone", "ts": 1700000000000, "uid": "user-alice-1", "attachments": [] }, "expected_native_fields": { "name": "Alice", "text": "Hello everyone", "uid": "user-alice-1", "isRenderableChat": true } }, { "name": "message_with_attachments", "description": "A message with an image and a file.", "decrypted_payload": { "name": "Bob", "text": "Check these out", "ts": 1700000001000, "uid": "user-bob-1", "attachments": [ { "id": "blob-img-1", "name": "photo.jpg", "type": "image/jpeg", "w": 1920, "h": 1080, "size": 524288 }, { "id": "blob-file-1", "name": "report.pdf", "type": "application/pdf", "size": 2097152 } ] }, "server_message": { "id": 12346, "created_at": 1700000001, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Bob", "text": "Check these out", "ts": 1700000001000, "uid": "user-bob-1", "attachments": [ { "id": "blob-img-1", "name": "photo.jpg", "type": "image/jpeg", "w": 1920, "h": 1080, "size": 524288 }, { "id": "blob-file-1", "name": "report.pdf", "type": "application/pdf", "size": 2097152 } ] } }, { "name": "message_with_reply", "description": "A reply-to message with quoted context.", "decrypted_payload": { "name": "Charlie", "text": "I agree!", "ts": 1700000002000, "uid": "user-charlie-1", "replyTo": { "id": 12345, "name": "Alice", "snippet": "Hello everyone" } }, "server_message": { "id": 12347, "created_at": 1700000002, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Charlie", "text": "I agree!", "ts": 1700000002000, "uid": "user-charlie-1", "replyTo": { "id": 12345, "name": "Alice", "snippet": "Hello everyone" }, "attachments": [] } }, { "name": "reply_in_thread_targets_root", "description": "Replying to a message that is itself a reply continues that thread: the composer stages the thread's TOP message, so the payload's replyTo carries the ROOT's id/name/snippet — never the tapped reply's.", "note": "Compose-side contract (web replyTarget in chat.js; native ChatFeedModel.replyTarget). Here Dana replied to message 12347 (Charlie's reply), but the staged replyTo names root 12345. The fold treats it like any reply; grouping is pinned by reply_thread_grouping.", "decrypted_payload": { "name": "Dana", "text": "Same here!", "ts": 1700000004000, "uid": "user-dana-1", "replyTo": { "id": 12345, "name": "Alice", "snippet": "Hello everyone" } }, "server_message": { "id": 12349, "created_at": 1700000004, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Dana", "text": "Same here!", "ts": 1700000004000, "uid": "user-dana-1", "replyTo": { "id": 12345, "name": "Alice", "snippet": "Hello everyone" }, "attachments": [] } }, { "name": "reply_thread_grouping", "description": "Reply threads group on the resolved ROOT message: new replies point straight at the root; a legacy chain (a reply quoting another reply) resolves transitively through loaded messages. The root's tally counts every message in its thread, and the first reply is the tally's jump target.", "note": "Multi-message vector: fold the sequence, then derive the thread rollup (web viewItems replyRoot/replyCount in chat.js; native ChatFeedModel.threadRoots/refreshReplyCounts). Message 102 is a LEGACY chain — it quotes reply 101, not the root — and must still resolve to root 100.", "message_sequence": [ { "server_message": { "id": 100, "created_at": 1700000000 }, "decrypted_payload": { "name": "Alice", "text": "Hello everyone", "ts": 1700000000000, "uid": "user-alice-1" } }, { "server_message": { "id": 101, "created_at": 1700000001 }, "decrypted_payload": { "name": "Bob", "text": "Hi Alice", "ts": 1700000001000, "uid": "user-bob-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } } }, { "server_message": { "id": 102, "created_at": 1700000002 }, "decrypted_payload": { "name": "Charlie", "text": "Welcome!", "ts": 1700000002000, "uid": "user-charlie-1", "replyTo": { "id": 101, "name": "Bob", "snippet": "Hi Alice" } } }, { "server_message": { "id": 103, "created_at": 1700000003 }, "decrypted_payload": { "name": "Dana", "text": "Good to be here", "ts": 1700000003000, "uid": "user-dana-1", "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } } } ], "expected_threads": { "roots": { "101": 100, "102": 100, "103": 100 }, "reply_counts": { "100": 3 }, "first_reply": { "100": 101 } }, "expected_reply_target": { "note": "Replying to ANY message in the thread (here the legacy chain reply 102) stages the root's id with a fresh snapshot of the root's name/text.", "for_message": 102, "replyTo": { "id": 100, "name": "Alice", "snippet": "Hello everyone" } } }, { "name": "action_line", "description": "A /me-style action line (e.g., 'added milk' from a to-do app).", "decrypted_payload": { "name": "Todo Bot", "text": "added 'milk'", "ts": 1700000003000, "uid": "app:todo", "action": true }, "server_message": { "id": 12348, "created_at": 1700000003, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Todo Bot", "text": "added 'milk'", "ts": 1700000003000, "uid": "app:todo", "action": true, "attachments": [] } }, { "name": "message_with_video", "description": "A message with an embedded video (dimensions + duration for layout before blob loads).", "decrypted_payload": { "name": "Diana", "text": "Check this video", "ts": 1700000004000, "uid": "user-diana-1", "attachments": [ { "id": "blob-vid-1", "name": "clip.mp4", "type": "video/mp4", "w": 1280, "h": 720, "dur": 45.5, "poster": "blob-vid-1-poster", "size": 5242880 } ] }, "server_message": { "id": 12349, "created_at": 1700000004, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Diana", "text": "Check this video", "ts": 1700000004000, "uid": "user-diana-1", "attachments": [ { "id": "blob-vid-1", "name": "clip.mp4", "type": "video/mp4", "w": 1280, "h": 720, "dur": 45.5, "poster": "blob-vid-1-poster", "size": 5242880 } ] } }, { "name": "reaction_add", "description": "A member reacting to a message with a single emoji. Reactions are encrypted events (type='reaction') that fold into the target message's reactions array.", "decrypted_payload": { "type": "reaction", "targetSid": 12345, "targetUid": "user-eve-1", "emoji": "👍", "targetText": "buy milk", "name": "Alice", "uid": "user-alice-1", "ts": 1700000010000, "on": true }, "server_message": { "id": 12353, "created_at": 1700000010, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.reaction", "targetSid": 12345, "emoji": "👍", "uid": "user-alice-1", "on": true }, "expected_native_fields": { "type": "reaction", "targetSid": 12345, "targetUid": "user-eve-1", "emoji": "👍", "targetText": "buy milk", "uid": "user-alice-1", "on": true }, "note": "Reactions are encrypted events stored as messages. Clients decrypt and fold them into the target message's reactions array. Count and mine-flag are derived client-side from the accumulated reaction events. on=true adds the reaction; on=false removes it. The mine-flag is true if uid===me, false otherwise. targetUid is the reacted-to message's author, stamped by the reacting client so push handlers (sw.js, the iOS NSE) can show the notification only to the author; the fold ignores it. Omitted when the target is a legacy uid-less message — then no one is notified. targetText is a replySnippet of the reacted-to message, likewise stamped by the reacting client and likewise ignored by the fold: it exists so the chat list can say 'Alice reacted 👍 to “buy milk”' for a thread built from last_blob alone (format.js reactionLine / the Swift twin). Optional — reactions from clients that predate it carry none, and the line falls back to 'Alice reacted 👍'." }, { "name": "reaction_remove", "description": "A member removing their reaction from a message.", "decrypted_payload": { "type": "reaction", "targetSid": 12346, "targetUid": "user-eve-1", "emoji": "❤️", "targetText": "shipped it 🎉", "name": "Bob", "uid": "user-bob-1", "ts": 1700000015000, "on": false }, "server_message": { "id": 12354, "created_at": 1700000015, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.reaction", "targetSid": 12346, "emoji": "❤️", "uid": "user-bob-1", "on": false }, "expected_native_fields": { "type": "reaction", "targetSid": 12346, "targetUid": "user-eve-1", "emoji": "❤️", "targetText": "shipped it 🎉", "uid": "user-bob-1", "on": false }, "note": "Removal is signaled by on=false. Clients fold both add and remove into the target message's reaction array; the current state is derived by filtering for on=true and counting. Removals never notify anyone (push handlers suppress on=false) — but they DO speak in the chat list ('Bob removed ❤️ from “shipped it 🎉”'), because on reload this event is all the list has to render the row from." }, { "name": "edit", "description": "A message edited by its author within the edit window.", "original_payload": { "name": "Eve", "text": "typo here", "ts": 1700000005000, "uid": "user-eve-1" }, "edited_payload": { "name": "Eve", "text": "no typo here", "ts": 1700000005000, "uid": "user-eve-1" }, "server_message": { "id": 12350, "created_at": 1700000005, "edited_at": 1700000010, "blob": "encrypted(edited_payload)" }, "expected_fold_event": { "type": "chat.edit", "msgId": 12350, "text": "no typo here" }, "note": "History/catch-up folds of an edited row (edited_at set) emit this SAME event, text included (#457): over a pre-seeded local store the chat.msg fold dedupes against the cached text, so this event is what carries an edit made while the device was away." }, { "name": "delete", "description": "A message deleted by its author (hard-deleted server-side).", "server_delete_frame": { "type": "delete", "id": 12351 }, "expected_fold_event": { "type": "chat.delete", "msgId": 12351 } }, { "name": "tombstone_delete", "description": "A stored delete tombstone (#581/#583): the server hard-deleted message 12345 and left a fresh empty row naming it in tombstone_of. History pages and reconnect catch-up must fold it to the SAME event the live delete frame produces — one delete path, no gap-inference.", "server_message": { "id": 12360, "created_at": 1751900000, "blob": "", "tombstone_of": 12345 }, "expected_fold_event": { "type": "chat.delete", "msgId": 12345 }, "note": "The tombstone's own id (12360) is deliberately fresh — above every client watermark — so forward-only after: sync always delivers it. Clients with no local copy of 12345 fold it as a no-op. Old clients that predate tombstone_of skip the row entirely (an empty blob fails decrypt)." }, { "name": "legacy_message_no_uid", "description": "An older message (before uid was added) — both web and native fall back to matching by name.", "decrypted_payload": { "name": "Frank", "text": "This is old", "ts": 1699000000000 }, "server_message": { "id": 12352, "created_at": 1699000000, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Frank", "text": "This is old", "ts": 1699000000000, "attachments": [] }, "note": "Both implementations should treat missing uid as valid and fall back to name-based matching for reply attribution." }, { "name": "message_with_transport_gossip", "description": "A message whose payload carries the host-level _ckpt tamper-evidence gossip field (docs/tamper-evidence.md). _ckpt is transport metadata injected/stripped by the web host: folds must drop it (never render or store it), and native decode must tolerate and ignore it — it is not app or chat content and must never be re-emitted.", "note": "Added with #573 slice 2. The web host strips _ckpt before folding; this vector pins that even an unstripped payload folds without it (the reduce allow-lists fields). Native: Codable ignores unknown keys — this vector documents that as contract, not accident.", "decrypted_payload": { "name": "Alice", "text": "Hello with gossip", "ts": 1700000000000, "uid": "user-alice-1", "_ckpt": [ 9, "b95fd584ded27920b2b8aa67add5b59269ac572f2924d1fb5a1387e650b5344e" ] }, "server_message": { "id": 12399, "created_at": 1700000000, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Alice", "text": "Hello with gossip", "ts": 1700000000000, "uid": "user-alice-1", "attachments": [] }, "expected_native_fields": { "name": "Alice", "text": "Hello with gossip", "uid": "user-alice-1", "isRenderableChat": true } }, { "name": "admin_warning_message", "description": "An admin warning (issue #578): an ordinary E2EE DM message whose payload carries warn:true. Clients render the same bubble with a distinct 'notice from an admin' framing; the flag rides inside the ciphertext, so the server never knows a warning from a hello.", "decrypted_payload": { "name": "Root", "text": "A report was filed about your behaviour here. Please keep this space welcoming.", "ts": 1700000300000, "uid": "user-admin-1", "warn": true }, "server_message": { "id": 12399, "created_at": 1700000300, "blob": "encrypted(decrypted_payload)" }, "expected_fold_event": { "type": "chat.msg", "name": "Root", "text": "A report was filed about your behaviour here. Please keep this space welcoming.", "ts": 1700000300000, "uid": "user-admin-1", "warn": true, "attachments": [] }, "expected_native_fields": { "name": "Root", "text": "A report was filed about your behaviour here. Please keep this space welcoming.", "uid": "user-admin-1", "warn": true, "isRenderableChat": true } } ] }