eleven / messenger Public

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>
James Adam 0d891df4ef530ba41731735420cbb712707f8633 parent e27f0fd
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