web: open-group intro count updates live on join/leave (#696)
applyMembershipDelta folds a membership announce's roster change (join/left/ removed) into the thread's member list before the re-render, so the intro's 'N people here' is instant rather than waiting for the next full load. Server reconciles authoritatively on the next loadThreads.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+34
−0
web/static/shell.js+34 −0
diff --git a/web/static/shell.js b/web/static/shell.js| index a5f27b2..2c69830 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -4543,6 +4543,34 @@ function sendAndConfirm(threadId, blob) { |
| // at fold time). The baked `text` is a fallback for surfaces that don't run that |
| // rewrite (native clients, the sidebar preview, push). The `action` flag routes it |
| // through the action-line path in apps/chat.js rather than rendering as a bubble. |
| +// Apply a membership announcement's roster delta to the thread's member list, |
| +// so counts and member-derived UI (e.g. an open group's intro "N people here") |
| +// update the moment someone joins/leaves — not only on the next full load. The |
| +// server reconciles authoritatively on the next loadThreads; this just keeps |
| +// the live view honest in between. Returns true if anything changed. |
| +function applyMembershipDelta(threadId, payload) { |
| + const t = threads.find((x) => x.id === threadId); |
| + if (!t || !payload) return false; |
| + const members = t.members || (t.members = []); |
| + let changed = false; |
| + if (Array.isArray(payload.join)) { |
| + for (const uid of payload.join) { |
| + if (uid && !members.includes(uid)) (members.push(uid), (changed = true)); |
| + } |
| + } |
| + if (Array.isArray(payload.removed)) { |
| + for (const uid of payload.removed) { |
| + const i = members.indexOf(uid); |
| + if (i >= 0) (members.splice(i, 1), (changed = true)); |
| + } |
| + } |
| + if (payload.left && payload.uid) { |
| + const i = members.indexOf(payload.uid); |
| + if (i >= 0) (members.splice(i, 1), (changed = true)); |
| + } |
| + return changed; |
| +} |
| + |
| async function announceJoin(threadId, uids) { |
| const key = threadKeys[threadId]; |
| if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return; |
| @@ -6109,6 +6137,12 @@ function connectWS() { |
| // chat app so the feed stays live alongside original clients. Also for an |
| // app-thread's chat view — it's a regular DM, so notes you type there just work |
| // (folded + rendered), whether or not its bot ever replies. |
| + // A membership announce (join/left/removed) carries the roster delta — |
| + // apply it before the render below so live counts (an open group's intro |
| + // "N people here") reflect it immediately. |
| + if (payload && (payload.join || payload.removed || payload.left)) { |
| + if (applyMembershipDelta(tid, payload) && tid !== activeId) renderThreadList(); |
| + } |
| if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) { |
| foldHostEvent(tid, "chat", chatMsgEvent(payload), { id: msg.id, ts: payload.ts }); |
| if (tid === activeId) renderActiveSurfaces(); |