web: optimistic sends — show events instantly, replace silently on echo (#1372)
Every emitted event now shows in the UI immediately (a local echo), before the server round-trips it back, and is silently replaced by the authoritative broadcast when it lands — no flicker. The generic seam: - transport tags each event with a client id (_cid) inside the ciphertext; the server relays the same blob back, so the echo carries it verbatim and pairs to its optimistic copy. No server change — E2EE untouched. - apps.js `optimisticFold` folds an emitted event locally with no server id; the authoritative echo re-folds the same event to become the real record. Gated behind an explicit `optimistic: true` opt-in, since it relies on the reducer being idempotent by the event's own id (the property every reducer already needs, as the window re-folds on each thread re-open). Chat: an outgoing bubble appears at once (pending), keyed on its cid for life so the confirm patches it in place (never a rebuild -> no photo/embed flash); if still unconfirmed after 5s it dims with a subtle "Sending..." state. Stays pending on failure — never auto-removed. Whiteboard: a stroke lands the instant you lift the pen instead of vanishing until the echo (optimistic: true). Event vectors updated (the reconstruction is the spec) + optimistic-lifecycle tests. Native parity to follow.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8 files changed,
+368
−57
changelog/2026-07-28-optimistic-sends.md+7 −0test/event-vectors.test.mjs+75 −4web/static/apps.js+18 −0web/static/apps/README.md+29 −0web/static/apps/board.js+7 −0web/static/apps/chat.js+143 −52web/static/shell.js+58 −1web/static/style.css+31 −0
diff --git a/changelog/2026-07-28-optimistic-sends.md b/changelog/2026-07-28-optimistic-sends.md| new file mode 100644 |
| index 0000000..0cd5bde |
| --- /dev/null |
| +++ b/changelog/2026-07-28-optimistic-sends.md |
| @@ -0,0 +1,7 @@ |
| +🤖 **Messages appear the instant you send them.** Your message now shows in the |
| +conversation immediately, without waiting for the server to acknowledge it — and |
| +it's replaced silently by the delivered copy once it lands, so nothing flickers. |
| +If a send is taking a while (a flaky connection), the message dims and shows a |
| +subtle "Sending…" so you know it's still on its way. The whiteboard gets the same |
| +treatment: a stroke stays put the moment you lift the pen, instead of flashing |
| +away and back. |
diff --git a/test/event-vectors.test.mjs b/test/event-vectors.test.mjs| index 066f219..eb997ae 100644 |
| --- a/test/event-vectors.test.mjs |
| +++ b/test/event-vectors.test.mjs |
| @@ -20,12 +20,34 @@ import "../web/static/apps/webhooks.js"; |
| // The app.hostEvents from web/static/apps/chat.js. |
| // For testing purposes, we'll reconstruct them here so the test is self-contained. |
| +// Mirrors chat.js's chat.msg hostEvent — the SPEC, not a snapshot. Includes the |
| +// optimistic-send lifecycle: an emitted message folds PENDING (meta.pending, a |
| +// client id `_cid`, no server id) so it shows instantly; its own broadcast echo |
| +// (same _cid, now with a server id) UPGRADES that record in place rather than |
| +// duplicating it. Keep this in step with the real fold in web/static/apps/chat.js. |
| +function bySidPendingLast(a, b) { |
| + if (a.sid && b.sid) return a.sid - b.sid; |
| + if (!a.sid && !b.sid) return a.ts - b.ts; |
| + return a.sid ? -1 : 1; |
| +} |
| function testChatMsgFold(state, event, meta) { |
| const text = typeof event.text === "string" ? event.text : ""; |
| const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : []; |
| const sid = (meta && meta.id) || 0; |
| - if ((!text.trim() && !atts.length) || !sid) return state; |
| - if (state.msgs.some((m) => m.sid === sid)) return state; |
| + const cid = typeof event._cid === "string" ? event._cid : ""; |
| + const pending = !!(meta && meta.pending); |
| + if ((!text.trim() && !atts.length) || (!sid && !pending)) return state; |
| + if (sid && state.msgs.some((m) => m.sid === sid)) return state; |
| + if (cid) { |
| + const idx = state.msgs.findIndex((m) => m.cid === cid); |
| + if (idx >= 0) { |
| + if (!sid) return state; |
| + const upgraded = state.msgs.slice(); |
| + upgraded[idx] = { ...upgraded[idx], sid, ts: event.ts || upgraded[idx].ts, pending: false, slow: false }; |
| + upgraded.sort(bySidPendingLast); |
| + return { msgs: upgraded }; |
| + } |
| + } |
| const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) }; |
| if (event.uid) raw.uid = event.uid; |
| if (atts.length) raw.attachments = atts.map((a) => ({ ...a })); |
| @@ -36,8 +58,11 @@ function testChatMsgFold(state, event, meta) { |
| const msgs = [ |
| ...state.msgs, |
| { |
| - id: "m" + sid, |
| + id: sid ? "m" + sid : "c" + cid, |
| sid, |
| + cid, |
| + pending, |
| + slow: false, |
| text, |
| name: raw.name, |
| uid: event.uid || "", |
| @@ -53,7 +78,7 @@ function testChatMsgFold(state, event, meta) { |
| rev: 0, |
| }, |
| ]; |
| - msgs.sort((a, b) => a.sid - b.sid); |
| + msgs.sort(bySidPendingLast); |
| return { msgs }; |
| } |
| @@ -188,6 +213,52 @@ function test(name, fn) { |
| } |
| } |
| +// Optimistic-send lifecycle (the web/native contract for `_cid`). A message |
| +// folds PENDING the instant it's emitted, then its own echo silently upgrades |
| +// that same record — never a duplicate. Native must carry `_cid` through and |
| +// apply the same pairing. |
| +test("chat.optimistic: an emitted message folds pending, then its echo upgrades it in place", () => { |
| + const cid = "cid-abc"; |
| + // 1. Optimistic local echo: no server id yet, pending, keyed by cid. |
| + let state = testChatMsgFold({ msgs: [] }, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { cid, ts: 1000, pending: true }); |
| + assert.strictEqual(state.msgs.length, 1, "pending message shows immediately"); |
| + let m = state.msgs[0]; |
| + assert.strictEqual(m.sid, 0, "no server id while pending"); |
| + assert.strictEqual(m.pending, true, "flagged pending"); |
| + assert.strictEqual(m.cid, cid, "keyed by the client id"); |
| + assert.strictEqual(m.id, "c" + cid, "keyedList id is cid-stable so the bubble is never rebuilt"); |
| + // 2. The server broadcasts our own message back (same _cid, now a real id). |
| + state = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 }); |
| + assert.strictEqual(state.msgs.length, 1, "echo upgrades in place — no duplicate"); |
| + m = state.msgs[0]; |
| + assert.strictEqual(m.sid, 42, "adopts the server id"); |
| + assert.strictEqual(m.pending, false, "no longer pending"); |
| + assert.strictEqual(m.slow, false, "slow cleared"); |
| + assert.strictEqual(m.id, "c" + cid, "keyedList id stays stable across the upgrade"); |
| + // 3. A duplicate echo (catch-up on reopen) is idempotent. |
| + const again = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 }); |
| + assert.strictEqual(again.msgs.length, 1, "re-folding the confirmed message changes nothing"); |
| +}); |
| + |
| +test("chat.optimistic: a pending copy sorts after confirmed messages, then slots into id order on confirm", () => { |
| + let state = testChatMsgFold({ msgs: [] }, { text: "first", ts: 10 }, { id: 100, ts: 10 }); |
| + state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { cid: "z", ts: 20, pending: true }); |
| + assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine"], "pending sits at the bottom"); |
| + // A higher-id message arriving before our echo still lands before the pending copy. |
| + state = testChatMsgFold(state, { text: "later", ts: 30 }, { id: 200, ts: 30 }); |
| + assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "later", "mine"], "pending stays last until confirmed"); |
| + state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { id: 150, ts: 20 }); |
| + assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine", "later"], "on confirm it slots into server order"); |
| +}); |
| + |
| +test("chat.optimistic: a normal server message carrying _cid still folds its text (native carry-and-ignore)", () => { |
| + const state = testChatMsgFold({ msgs: [] }, { text: "hello", name: "Bo", ts: 5, _cid: "unmatched" }, { id: 7, ts: 5 }); |
| + assert.strictEqual(state.msgs.length, 1, "folds like any message"); |
| + assert.strictEqual(state.msgs[0].text, "hello", "_cid never leaks into the text"); |
| + assert.strictEqual(state.msgs[0].sid, 7, "real id"); |
| + assert.strictEqual(state.msgs[0].pending, false, "not pending — it arrived confirmed"); |
| +}); |
| + |
| allVectors.chat.forEach((vector) => { |
| if (vector.expected_fold_event?.type === "chat.msg") { |
| test(`chat.msg: ${vector.name} — folds correctly and produces expected event shape`, () => { |
diff --git a/web/static/apps.js b/web/static/apps.js| index fc326e0..dbd0d53 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -1086,6 +1086,24 @@ export function foldEvent(threadId, event, meta) { |
| return foldInto(threadId, app, event, meta); |
| } |
| +// optimisticFold shows a just-emitted event immediately — a LOCAL echo folded |
| +// before the server round-trips it back — so a send lands in the UI with no |
| +// network wait (and, for a drawing, no flash of the line vanishing until the |
| +// echo). It carries no server message id (meta.id): the fold-level de-dupe is |
| +// skipped, and the AUTHORITATIVE echo (which does carry one) re-folds the same |
| +// event to become the real record. That's only safe when the reducer is |
| +// idempotent by the event's OWN id — the same property every reducer already |
| +// needs, since the message window re-folds on every thread re-open — so an app |
| +// opts in explicitly with `optimistic: true` rather than us assuming it. `_cid` |
| +// (a client id shell.js tucks into the payload) rides back in the echo verbatim, |
| +// so a surface can pair the pending copy with its confirmation. Chat is host-fed |
| +// (chat.msg) and handled by shell.js directly. |
| +export function optimisticFold(threadId, event, cid) { |
| + const app = typeOwner.get(event.type); |
| + if (!app || !app.reduce || !app.optimistic) return null; |
| + return foldInto(threadId, app, event, { cid, ts: event.ts, pending: true }); |
| +} |
| + |
| // foldHostEvent folds a HOST-SYNTHESIZED event into a named app, reaching its |
| // hostEvents handlers — the doorway shell.js uses to feed chat messages and |
| // server-enforced mutations (reactions, edits, deletes) into the chat app. |
diff --git a/web/static/apps/README.md b/web/static/apps/README.md| index c57d5a6..5895de3 100644 |
| --- a/web/static/apps/README.md |
| +++ b/web/static/apps/README.md |
| @@ -745,6 +745,35 @@ Encryption is automatic: `emit` encrypts the event to the thread key and sends i |
| as a normal message. You never touch keys, and never send plaintext to the |
| server. |
| +### Optimistic sends (`optimistic: true`) |
| + |
| +By default an emitted event only appears once the server broadcasts it back — a |
| +round-trip's wait, and for a drawing a visible *flash* (the line vanishes on |
| +pen-up, then reappears). Set **`optimistic: true`** on your app and the framework |
| +folds each emitted event into your state **immediately**, locally, then lets the |
| +authoritative echo quietly replace it: |
| + |
| +```js |
| +export const app = defineApp({ |
| + name: "board", |
| + optimistic: true, // a stroke shows the instant you lift the pen |
| + // … |
| +}); |
| +``` |
| + |
| +How it stays correct: the transport tucks a client id (`_cid`) inside every |
| +event; the server relays the same ciphertext back, so the echo carries it |
| +verbatim. The optimistic copy folds with **no server message id**, and the echo |
| +re-folds the *same event* with the real id. That means your reducer runs on the |
| +event **twice** — so `optimistic: true` is only safe when your reducer is |
| +**idempotent by the event's own `id`** (mint it with `ctx.id()`, land it with a |
| +collection helper like `addItem`). This is the same property every reducer |
| +already needs — the message window re-folds on each thread re-open — so most apps |
| +qualify already; opt in explicitly rather than assume it. An app whose reducer |
| +*counts* or otherwise applies an event more than once per id must **not** set the |
| +flag. (Chat messages get this treatment built in — an outgoing bubble shows at |
| +once and wears a subtle "Sending…" state if it's still unconfirmed after 5s.) |
| + |
| ## `ctx` — what a handler and a render get |
| | member | in | what it does | |
diff --git a/web/static/apps/board.js b/web/static/apps/board.js| index 38187cb..c4d9f00 100644 |
| --- a/web/static/apps/board.js |
| +++ b/web/static/apps/board.js |
| @@ -67,6 +67,13 @@ export const app = defineApp({ |
| // to fire this the moment the app is added, so the canvas shows up right |
| // away rather than sitting invisible until someone remembers to run /board. |
| openEvent: "board.open", |
| + // Optimistic local echo (docs: apps/README.md § Optimistic sends): a finished |
| + // stroke folds into state the instant you lift the pen, so the line stays put |
| + // instead of vanishing on pen-up and flashing back when the server echoes it. |
| + // Safe because every board reducer is idempotent by the event's own id (strokes |
| + // via addItem; open/close/clear are self-checked), so the authoritative echo |
| + // re-folds onto the same entity rather than duplicating it. |
| + optimistic: true, |
| initialState: () => ({ open: false, strokes: [] }), |
| events: { |
| [N.ev("open")]: (s) => (s.open ? s : { ...s, open: true }), |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 59df70f..c6998d9 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -390,6 +390,15 @@ export function threadItems(msgs, rootSid) { |
| return out.sort((a, b) => a.sid - b.sid); |
| } |
| +// Message order: confirmed messages by server id (the stable identity), with any |
| +// still-pending optimistic copies (sid 0) after all of them, ordered by send time |
| +// — so a just-sent bubble sits at the bottom until its echo slots it into place. |
| +function bySidPendingLast(a, b) { |
| + if (a.sid && b.sid) return a.sid - b.sid; |
| + if (!a.sid && !b.sid) return a.ts - b.ts; |
| + return a.sid ? -1 : 1; |
| +} |
| + |
| export function viewItems(msgs) { |
| // Reply threads group on the resolved ROOT message: every reply tallies on |
| // its thread's top message (legacy chains resolve transitively), and each |
| @@ -426,7 +435,10 @@ export function viewItems(msgs) { |
| replyCount, |
| replyRoot: roots.get(m.sid) || 0, |
| vrev: `${m.rev}|${timeSep ? (timeSep.withDay ? "D" : "t") : ""}|${cont ? 1 : 0}`, |
| - srev: `${reactSig}|${replyCount}`, |
| + // Delivery state rides the SOFT rev: the pending→slow→confirmed transitions |
| + // patch the bubble in place (data-id, the "sending…" chip, action affordances) |
| + // without rebuilding it — so an optimistic photo never flashes on confirm. |
| + srev: `${reactSig}|${replyCount}|${m.pending ? (m.slow ? "slow" : "send") : "ok"}|${m.sid ? 1 : 0}`, |
| }); |
| prev = m; |
| } |
| @@ -464,8 +476,27 @@ export const app = defineApp({ |
| const text = typeof e.text === "string" ? e.text : ""; |
| const atts = Array.isArray(e.attachments) ? e.attachments.filter((a) => a && a.id) : []; |
| const sid = (meta && meta.id) || 0; |
| - if ((!text.trim() && !atts.length) || !sid) return s; |
| - if (s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present |
| + const cid = typeof e._cid === "string" ? e._cid : ""; |
| + const pending = !!(meta && meta.pending); |
| + // A server message needs its id; an optimistic local echo has none yet |
| + // (it's pending, keyed only by cid until its own broadcast confirms it). |
| + if ((!text.trim() && !atts.length) || (!sid && !pending)) return s; |
| + if (sid && s.msgs.some((m) => m.sid === sid)) return s; // re-fold — already present |
| + // Our own echo confirming an optimistic copy: upgrade that pending record |
| + // IN PLACE — adopt the server id, drop the pending flags — keeping its |
| + // stable keyedList id (its bubble is patched, never rebuilt, so a photo or |
| + // playing embed never flashes). A second optimistic fold of the same cid |
| + // (no sid yet) is a no-op. |
| + if (cid) { |
| + const idx = s.msgs.findIndex((m) => m.cid === cid); |
| + if (idx >= 0) { |
| + if (!sid) return s; |
| + const msgs = s.msgs.slice(); |
| + msgs[idx] = { ...msgs[idx], sid, ts: e.ts || msgs[idx].ts, pending: false, slow: false }; |
| + msgs.sort(bySidPendingLast); |
| + return { msgs }; |
| + } |
| + } |
| const raw = { name: e.name || "", text, ts: e.ts || ((meta && meta.ts) || 0) }; |
| if (e.uid) raw.uid = e.uid; |
| if (atts.length) raw.attachments = atts.map((a) => ({ ...a })); |
| @@ -478,8 +509,13 @@ export const app = defineApp({ |
| const msgs = [ |
| ...s.msgs, |
| { |
| - id: "m" + sid, |
| + // A pending optimistic copy keys on its cid for life (stable across the |
| + // upgrade to a real sid), so its bubble is never destroyed/recreated. |
| + id: sid ? "m" + sid : "c" + cid, |
| sid, |
| + cid, |
| + pending, // shown, but not yet acknowledged by the server |
| + slow: false, // flipped true by chat.pending after 5s unconfirmed |
| text, |
| name: raw.name, |
| uid: e.uid || "", |
| @@ -499,7 +535,18 @@ export const app = defineApp({ |
| rev: 0, |
| }, |
| ]; |
| - msgs.sort((a, b) => a.sid - b.sid); |
| + msgs.sort(bySidPendingLast); |
| + return { msgs }; |
| + }, |
| + // A still-unconfirmed optimistic send, 5s on: mark it slow so the bubble |
| + // surfaces a subtle "sending…" state (host-fed by shell.js's markSlow timer). |
| + [N.ev("pending")]: (s, e) => { |
| + const cid = typeof e._cid === "string" ? e._cid : ""; |
| + if (!cid) return s; |
| + const idx = s.msgs.findIndex((m) => m.cid === cid && m.pending); |
| + if (idx < 0) return s; |
| + const msgs = s.msgs.slice(); |
| + msgs[idx] = { ...msgs[idx], slow: true }; |
| return { msgs }; |
| }, |
| [N.ev("reaction")]: (s, e, meta, ctx) => { |
| @@ -1018,7 +1065,7 @@ function renderItem(m, ctx) { |
| const wrap = document.createElement("div"); |
| wrap.className = "msg " + (mine ? "me" : "them") + (m.cont ? " cont" : ""); |
| if (Date.now() - m.ts < 3000) wrap.classList.add("appear"); // live arrivals ease in, history doesn't |
| - wrap.dataset.id = m.sid; // lets a reply quote scroll back to this message |
| + wrap.dataset.id = m.sid || ""; // its server id (empty while pending) — lets a reply quote scroll back here |
| wrap.dataset.t = m.ts; |
| wrap.dataset.sender = m.name; |
| // Tag with the resolved thread root (not the quoted id) so the "N Replies" |
| @@ -1157,45 +1204,6 @@ function renderItem(m, ctx) { |
| if (chips) content.appendChild(chips); |
| } |
| - if (!coarse) { |
| - // Desktop: the hover strip beside the bubble — action icons + exact time, |
| - // revealed on hover (see .msg-actions in style.css). |
| - const actions = document.createElement("div"); |
| - actions.className = "msg-actions"; |
| - actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget))); |
| - actions.appendChild( |
| - hoverBtn("reply-hover", "Reply", icon("reply"), () => { |
| - const ctl = composers.get(ctx.threadId); |
| - if (ctl) ctl.startReply(m); |
| - }), |
| - ); |
| - const remaining = m.ts + EDIT_WINDOW_MS - Date.now(); |
| - if (mine && m.text.trim() && remaining > 0) { |
| - const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => { |
| - const ctl = composers.get(ctx.threadId); |
| - if (ctl) ctl.startEdit(m); |
| - }); |
| - actions.appendChild(editBtn); |
| - setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses |
| - } |
| - // The exact-time hover affordance doubles as a "Copy link" trigger (issue |
| - // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's |
| - // keyboard-reachable and gets a real accessible name, not just visible text |
| - // that reads as a plain timestamp. |
| - const timeBtn = document.createElement("button"); |
| - timeBtn.type = "button"; |
| - timeBtn.className = "msg-time"; |
| - timeBtn.textContent = formatTime(m.ts); |
| - timeBtn.title = "Copy link to this message"; |
| - timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`); |
| - timeBtn.onclick = (e) => { |
| - e.stopPropagation(); |
| - copyMessageLink(ctx, m); |
| - }; |
| - actions.appendChild(timeBtn); |
| - content.appendChild(actions); |
| - } |
| - |
| wrap.appendChild(content); |
| // Reactions below the bubble (buildReactionsRow seeds the load-time "mine" |
| @@ -1213,20 +1221,87 @@ function renderItem(m, ctx) { |
| wrap.appendChild(e); |
| } |
| - // Gestures, per platform like the original feed: touch gets swipe-right to |
| - // reply plus long-press / double-tap for the picker; desktop gets |
| - // press-and-hold (the hover strip covers the rest). |
| - if (coarse) { |
| - attachSwipeReply(wrap, m, ctx); |
| - attachTouchPicker(wrap, m, ctx); |
| + // A still-pending optimistic send withholds its interactive actions — you can't |
| + // react to, reply to, link, or edit a message that hasn't been sent yet — and |
| + // wears a subtle status the CSS only reveals once it's `.slow` (5s unconfirmed). |
| + // On confirmation patchBubble promotes it: attaches these actions with the real |
| + // server id, no rebuild. A confirmed message gets them straight away. |
| + if (m.pending) { |
| + wrap.dataset.pending = "1"; |
| + wrap.classList.add("pending"); |
| + if (m.slow) wrap.classList.add("slow"); |
| + wrap.appendChild(buildSendStatus()); |
| } else { |
| - attachPressHold(wrap, m, ctx); |
| + attachActions(wrap, content, m, ctx); |
| } |
| item.appendChild(wrap); |
| return item; |
| } |
| +// The interactive message actions — desktop's hover strip (react / reply / edit / |
| +// copy-link) plus its press-and-hold, or touch's swipe-reply + long-press picker. |
| +// Split out of renderItem so patchBubble can attach them to a bubble that first |
| +// rendered while pending (actions withheld) the moment its echo confirms it — with |
| +// the now-real server id, and without rebuilding the bubble (no photo flash). |
| +function attachActions(wrap, content, m, ctx) { |
| + if (coarse) { |
| + attachSwipeReply(wrap, m, ctx); |
| + attachTouchPicker(wrap, m, ctx); |
| + return; |
| + } |
| + // Desktop: the hover strip beside the bubble — action icons + exact time, |
| + // revealed on hover (see .msg-actions in style.css). |
| + const mine = ownMessage({ uid: m.uid, name: m.name }, ctx.me, ctx.meName); |
| + const actions = document.createElement("div"); |
| + actions.className = "msg-actions"; |
| + actions.appendChild(hoverBtn("react-hover", "React", icon("react"), (e) => openMessagePicker(m, ctx, e.currentTarget))); |
| + actions.appendChild( |
| + hoverBtn("reply-hover", "Reply", icon("reply"), () => { |
| + const ctl = composers.get(ctx.threadId); |
| + if (ctl) ctl.startReply(m); |
| + }), |
| + ); |
| + const remaining = m.ts + EDIT_WINDOW_MS - Date.now(); |
| + if (mine && m.text.trim() && remaining > 0) { |
| + const editBtn = hoverBtn("edit-hover", "Edit", icon("edit"), () => { |
| + const ctl = composers.get(ctx.threadId); |
| + if (ctl) ctl.startEdit(m); |
| + }); |
| + actions.appendChild(editBtn); |
| + setTimeout(() => editBtn.remove(), remaining); // drop it when the window lapses |
| + } |
| + // The exact-time hover affordance doubles as a "Copy link" trigger (issue |
| + // #893) — the Slack/Discord convention. A <button> (not a bare span) so it's |
| + // keyboard-reachable and gets a real accessible name, not just visible text |
| + // that reads as a plain timestamp. |
| + const timeBtn = document.createElement("button"); |
| + timeBtn.type = "button"; |
| + timeBtn.className = "msg-time"; |
| + timeBtn.textContent = formatTime(m.ts); |
| + timeBtn.title = "Copy link to this message"; |
| + timeBtn.setAttribute("aria-label", `Copy link to this message, sent ${formatTime(m.ts)}`); |
| + timeBtn.onclick = (e) => { |
| + e.stopPropagation(); |
| + copyMessageLink(ctx, m); |
| + }; |
| + actions.appendChild(timeBtn); |
| + content.appendChild(actions); |
| + attachPressHold(wrap, m, ctx); |
| +} |
| + |
| +// The pending-send status footer: a spinner + "Sending…", kept out of the tab |
| +// order and hidden by CSS until the bubble goes `.slow`. `role="status"` + |
| +// aria-live so a screen reader announces the delay when it finally surfaces |
| +// rather than the instant every message is sent. |
| +function buildSendStatus() { |
| + const s = document.createElement("div"); |
| + s.className = "send-status"; |
| + s.setAttribute("role", "status"); |
| + s.textContent = "Sending…"; |
| + return s; |
| +} |
| + |
| // The reactions row below a bubble (or null when there are none). Seeds the |
| // load-time "mine" flags first — add-only and idempotent (live frames carry |
| // counts only, never a mine flag), so it's safe to run from BOTH the initial |
| @@ -1284,6 +1359,22 @@ function patchBubble(node, m, ctx) { |
| const wrap = node.querySelector(":scope > .msg"); |
| if (!wrap) return; |
| const content = wrap.querySelector(":scope > .content"); |
| + // Delivery state (soft rev). A bubble that rendered while pending either goes |
| + // `.slow` (5s unconfirmed) or gets PROMOTED here the moment its echo lands: drop |
| + // the pending chrome, adopt the real server id, and attach the interactive |
| + // actions it was withheld — all outside .content, so the bubble is never rebuilt. |
| + if (wrap.dataset.pending === "1") { |
| + if (m.pending) { |
| + wrap.classList.toggle("slow", !!m.slow); |
| + } else { |
| + wrap.dataset.pending = "0"; |
| + wrap.classList.remove("pending", "slow"); |
| + wrap.dataset.id = m.sid || ""; |
| + const status = wrap.querySelector(":scope > .send-status"); |
| + if (status) status.remove(); |
| + attachActions(wrap, content, m, ctx); |
| + } |
| + } |
| // Reactions: rebuild the row and reinsert it right after .content, so it stays |
| // above the reply/edited tags regardless of which of them are present. |
| const oldRow = wrap.querySelector(":scope > .reactions"); |
diff --git a/web/static/shell.js b/web/static/shell.js| index 2eb0983..364577d 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -81,6 +81,7 @@ import { |
| isAppType, |
| foldEvent, |
| foldHostEvent, |
| + optimisticFold, |
| deliverEphemeral, |
| appPreview, |
| appUnread, |
| @@ -4335,18 +4336,71 @@ setEmitTransport(async (threadId, event) => { |
| // giving up — the web app proper never hits this (it unwraps every thread). |
| if (!key && embed && (await requestNativeKey(threadId))) key = threadKeys[threadId]; |
| if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false; |
| - const payload = { ...event, name: session.name, ts: Date.now() }; |
| + // A client id for this event: it rides inside the ciphertext (the server never |
| + // sees it), and the server relays the SAME blob back, so the echo carries it |
| + // verbatim — that's how we pair our optimistic local copy with its |
| + // authoritative confirmation and replace it silently (see optimisticEmit). |
| + const cid = newCid(); |
| + const payload = { ...event, name: session.name, ts: Date.now(), _cid: cid }; |
| if (myUserId) payload.uid = myUserId; |
| // Tuck our verified chain head into the encrypted payload (tamper-evidence |
| // gossip) — peers compare it against their own view; apps never see it. |
| const head = integrity.gossipOut(); |
| if (head) payload._ckpt = head; |
| + optimisticEmit(threadId, payload, cid); |
| return encrypt(key, payload).then((blob) => { |
| ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob })); |
| return true; |
| }); |
| }); |
| +// A fresh client id for an outgoing event (crypto.randomUUID where available; a |
| +// cheap unique fallback otherwise — it only needs to be unique within this tab). |
| +let cidSeq = 0; |
| +function newCid() { |
| + if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID(); |
| + return "c" + Date.now() + "-" + (cidSeq++); |
| +} |
| + |
| +// Optimistic local echo: fold our own event straight into its surface so a send |
| +// appears with no network wait, then let the server's broadcast quietly replace |
| +// it. A CHAT message becomes a pending bubble (and, if unconfirmed after 5s, a |
| +// subtle "sending…" state via markSlow); a typed app event folds only when its |
| +// app opted in (optimistic: true) with an id-idempotent reducer, so the echo |
| +// re-fold lands on the same entity rather than duplicating it. |
| +function optimisticEmit(threadId, payload, cid) { |
| + if (payload.type) { |
| + if (optimisticFold(threadId, payload, cid) && threadId === activeId) renderActiveSurfaces(); |
| + return; |
| + } |
| + if ((chatAppFor(threadId) || threadApp[threadId]) && (payload.text || payload.attachments)) { |
| + foldHostEvent(threadId, "chat", chatMsgEvent(payload), { cid, ts: payload.ts, pending: true }); |
| + if (threadId === activeId) renderActiveSurfaces(); |
| + markSlow(threadId, cid); |
| + } |
| +} |
| + |
| +// Pending optimistic sends, keyed by client id → a timer that, 5s after emit and |
| +// still unconfirmed, flips the bubble to a subtle "sending…" state (delivery is |
| +// taking a while and might not have gone through). The echo clears it. We never |
| +// remove the optimistic copy on failure — stay-pending — only surface the delay. |
| +const slowTimers = new Map(); |
| +function markSlow(threadId, cid) { |
| + const t = setTimeout(() => { |
| + slowTimers.delete(cid); |
| + foldHostEvent(threadId, "chat", { type: "chat.pending", _cid: cid }); |
| + if (threadId === activeId) renderActiveSurfaces(); |
| + }, 5000); |
| + slowTimers.set(cid, t); |
| +} |
| +function clearSlow(cid) { |
| + const t = slowTimers.get(cid); |
| + if (t) { |
| + clearTimeout(t); |
| + slowTimers.delete(cid); |
| + } |
| +} |
| + |
| // Connected third-party services (docs/connected-services.md). The privileged |
| // half of the connect app — the parts an app can't do itself because they need |
| // the session keypair, roster public keys, and the socket. Two moves: |
| @@ -7698,6 +7752,9 @@ function connectWS() { |
| if (applyMembershipDelta(tid, payload) && tid !== activeId) renderThreadList(); |
| } |
| if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) { |
| + // Our own echo landing: the fold upgrades the pending bubble in place |
| + // (matched on _cid) — cancel its "sending…" timer so it never trips. |
| + if (payload._cid) clearSlow(payload._cid); |
| foldHostEvent(tid, "chat", chatMsgEvent(payload), { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |
| } |
diff --git a/web/static/style.css b/web/static/style.css| index c1745df..dc69fa7 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -588,6 +588,37 @@ select:disabled { opacity: 0.5; cursor: default; } |
| /* persistent "Edited" label under an edited bubble */ |
| .edited-tag { font-size: 11px; color: var(--muted); margin: 1px 6px 0; } |
| +/* Optimistic send: an outgoing message renders instantly (`.pending`) and looks |
| + completely normal — a sub-second send is never decorated. Only once it's gone |
| + unconfirmed for a while (`.slow`, flipped 5s in) does the bubble dim and a |
| + "Sending…" status appear, hinting delivery is taking a while and may not have |
| + gone through. The text carries the meaning (not colour alone); the spinner is |
| + decoration and stops under Reduce Motion. */ |
| +.send-status { |
| + display: none; |
| + align-items: center; |
| + gap: 5px; |
| + font-size: 11px; |
| + color: var(--muted); |
| + margin: 1px 6px 0; |
| +} |
| +.msg.me .send-status { align-self: flex-end; } |
| +.msg.slow .content { opacity: 0.6; } /* dims the bubble/photo/card together; the status sits outside .content */ |
| +.msg.slow .send-status { display: inline-flex; } |
| +.send-status::before { |
| + content: ""; |
| + width: 9px; |
| + height: 9px; |
| + border-radius: 50%; |
| + border: 1.5px solid currentColor; |
| + border-top-color: transparent; |
| + animation: send-spin 0.7s linear infinite; |
| +} |
| +@media (prefers-reduced-motion: reduce) { |
| + .send-status::before { animation: none; border-top-color: currentColor; opacity: 0.4; } |
| +} |
| +@keyframes send-spin { to { transform: rotate(360deg); } } |
| + |
| /* Hover affordances live in a full-height strip beside the bubble: the hover zone |
| spans the whole message height, so you can move straight from anywhere in the |
| bubble to an icon (no need to find the vertical centre), and the action icons + |