eleven / messenger Public

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>
Paul Campbell eba49e371d4c82a8247ac7db10f48adc93e57f18 parent d85c1bc
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();