web: fold live reaction events — reactions now show without a reload (#520)
Follow-up to #393/#519: the encrypted reaction frame reached the server and came back, but the live ws.onmessage path had no branch for it — a payload with type "reaction" is neither an app event (isAppType) nor a plain chat message (!payload.type), so it was never folded into the chat app. It fell through to the generic tail instead, which set the thread-list preview to previewText of an empty-text payload (the "James: " symptom), lit the unread dot, and pinged the native-notify bridge. Only reload/history worked, because chatAppFoldMessage already handles reactions. Live path: fold chat.reaction and stop — reactions stay silent in the thread list (no preview, no dot, no notify), like the plaintext frames they replace; last_at still advances to match the server's ordering on reload. Load path: a thread whose newest blob is a reaction keeps its stale preview (instead of a blank "Name: ") and never lights the dot, mirroring the silent-app-event precedent just above.
Co-authored-by: Claude <noreply@anthropic.com>
1 file changed,
+20
−1
web/static/shell.js+20 −1
diff --git a/web/static/shell.js b/web/static/shell.js| index eb9ec26..4f261f6 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -1355,7 +1355,9 @@ async function loadThreads() { |
| if (isAppType(payload.type)) { |
| const pv = appPreview(payload); |
| if (pv) threadPreview[t.id] = pv; |
| - } else { |
| + } else if (payload.type !== "reaction") { |
| + // A trailing reaction event stays silent (like the live path) — |
| + // previewText would render it as "Name: " with nothing after. |
| threadPreview[t.id] = previewText(t, payload); |
| } |
| // Seed the unread dot from the synced watermark, applying the same |
| @@ -1366,6 +1368,9 @@ async function loadThreads() { |
| if (t.id !== activeId) { |
| if ((t.last_at || 0) <= readMarks[t.id] || ownMessage(payload, myUserId, session.name)) { |
| unread[t.id] = false; |
| + } else if (payload.type === "reaction") { |
| + // Someone reacting never lights the dot (matching the live path); |
| + // an earlier lit dot stays lit. |
| } else if (!isAppType(payload.type) || appUnread(payload)) { |
| unread[t.id] = true; |
| } |
| @@ -5453,6 +5458,20 @@ function connectWS() { |
| try { |
| payload = await decrypt(threadKeys[tid], msg.blob); |
| } catch {} |
| + // A reaction event: fold it so the target bubble's chips update live, then |
| + // stop — reactions stay silent in the thread list (no preview, no unread |
| + // dot, no native notify), like the plaintext reaction frames they replace. |
| + // last_at still advances (the server counts the event as the thread's |
| + // latest message, so a reload would order the list the same way). |
| + if (payload && payload.type === "reaction") { |
| + foldHostEvent(tid, "chat", { type: "chat.reaction", targetSid: payload.targetSid, emoji: payload.emoji, uid: payload.uid, on: payload.on, ts: payload.ts }); |
| + if (tid === activeId) renderActiveSurfaces(); |
| + const rt = threads.find((x) => x.id === tid); |
| + if (rt) rt.last_at = msg.created_at || Math.floor(Date.now() / 1000); |
| + if (tid === activeId && document.hasFocus()) markThreadRead(tid); |
| + renderThreadList(); |
| + return; |
| + } |
| // A mini-app event, not a chat message: fold it (so the app's state stays |
| // current), then let the OWNING APP decide its presence — its notify hook |
| // may toast/alert (a live event, possibly a scheduled one the server just |