eleven / messenger Public

web: fold stored delete tombstones through the live delete path (#583) (#764)

A history row carrying tombstone_of (#581) now folds the identical
chat.delete event the live frame produces — in chatAppFoldMessage (history
open, scroll-back, reconnect/refocus catch-up) and foldAppThreadMessage
(app-thread chat view). One delete path, no gap-inference.

- event vector tombstone_delete pins the translation (npm test)
- changelog: unsends now reach devices that were offline
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d44cce2150124e436243fca6d1ab26309a552b0d parent d22ae19
4 files changed, +58 −0
  • changelog/2026-07-12-delete-catchup.md +6 −0
  • test/event-vectors.test.mjs +23 −0
  • test/event-vectors/chat.json +15 −0
  • web/static/shell.js +14 −0
diff --git a/changelog/2026-07-12-delete-catchup.md b/changelog/2026-07-12-delete-catchup.md
new file mode 100644
index 0000000..5c0331b
--- /dev/null
+++ b/changelog/2026-07-12-delete-catchup.md
@@ -0,0 +1,6 @@
+🤖 Unsending a message now reaches everyone — including devices that were
+offline at the time. Before, if you deleted a message while someone's app was
+closed, their screen could keep showing it until they left and reopened the
+chat. Now their device quietly drops it the moment it next catches up. As
+always, deleting really deletes: the message's encrypted content is gone from
+the server the instant you unsend it.
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs
index e7db2b9..b966e81 100644
--- a/test/event-vectors.test.mjs
+++ b/test/event-vectors.test.mjs
@@ -312,6 +312,29 @@ allVectors.chat.forEach((vector) => {
assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present");
});
}
+
+ if (vector.name === "tombstone_delete") {
+ test(`tombstone: ${vector.name} — stored row folds like the live delete frame`, () => {
+ // The translation rule (shell.js chatAppFoldMessage / foldAppThreadMessage):
+ // a history row carrying tombstone_of folds { type:"chat.delete",
+ // msgId: tombstone_of } — byte-identical to the live frame's fold event,
+ // so there is exactly one delete path.
+ const row = vector.server_message;
+ assert.strictEqual(row.blob, "", "tombstone rows carry no ciphertext");
+ assert(row.id > row.tombstone_of, "tombstone id is fresh — above the id it unsends");
+ const event = { type: "chat.delete", msgId: row.tombstone_of };
+ assert.deepStrictEqual(event, vector.expected_fold_event, "translation matches the pinned fold event");
+
+ const state = { msgs: [{ sid: row.tombstone_of, text: "unsent" }, { sid: row.tombstone_of + 1, text: "after" }] };
+ const result = testChatDeleteFold(state, event);
+ assert(!result.msgs.some((m) => m.sid === row.tombstone_of), "target dropped from state");
+ assert.strictEqual(result.msgs.length, 1, "only the target dropped");
+
+ // A client that never held the message (fresh load) folds it as a no-op.
+ const fresh = testChatDeleteFold({ msgs: [{ sid: 1, text: "x" }] }, event);
+ assert.strictEqual(fresh.msgs.length, 1, "no-op on a client with no local copy");
+ });
+ }
});
// Test todo vectors
diff --git a/test/event-vectors/chat.json b/test/event-vectors/chat.json
index 8fd8274..ceda38d 100644
--- a/test/event-vectors/chat.json
+++ b/test/event-vectors/chat.json
@@ -366,6 +366,21 @@
"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.",
diff --git a/web/static/shell.js b/web/static/shell.js
index 9e2fe18..bd56552 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -5556,6 +5556,14 @@ function chatAppFor(id) {
// edited flag). Shared by history open, scroll-back pages, and reconnect
// catch-up; foldEvent's per-message-id dedupe makes repeats no-ops.
async function chatAppFoldMessage(threadId, m) {
+ // A delete tombstone (#583): the server hard-deleted this row's target and
+ // left an empty marker so history/catch-up reads convey the delete. Fold it
+ // through the exact event the live "delete" frame produces — same code path,
+ // no parallel delete logic. (Nothing to decrypt; a tombstone's blob is empty.)
+ if (m.tombstone_of) {
+ foldHostEvent(threadId, "chat", { type: "chat.delete", msgId: m.tombstone_of });
+ return;
+ }
try {
const data = await decrypt(threadKeys[threadId], m.blob);
if (data.type === "reaction") {
@@ -5674,6 +5682,12 @@ function foldGroupAppEventBubble(threadId, app, event, meta) {
// app's state (App view) and a bot bubble (Chat view). A plain untyped note you
// typed in the chat view folds as an ordinary chat message.
async function foldAppThreadMessage(threadId, ownerApp, m) {
+ // Delete tombstones fold in app-threads too — a note unsent in the Chat view
+ // must drop from a device that missed the live frame (#583).
+ if (m.tombstone_of) {
+ foldHostEvent(threadId, "chat", { type: "chat.delete", msgId: m.tombstone_of });
+ return;
+ }
try {
const data = await decrypt(threadKeys[threadId], m.blob);
if (isAppType(data.type)) {