eleven / ocho Public

Announce group joins, leaves and removals ("Paul added you", "Paul left") (#254)

* web: announce when people are added to a group

Adding people to a group now drops a centered .chatapp-action line in the
thread — "Paul added Alice, Bob and Dave" — so members can see who joined
and when (issue #225).

The adder is the one client that has decrypted names for everyone it just
picked, so it composes the sentence and sends it as a normal thread-key-
encrypted message with `action: true`. That flag routes it through the
existing action-line path in apps/chat.js (live WS fold and stored-history
fold both preserve it), so it renders as a centered line for every member —
including the newcomers, from stored history on first load — using only the
thread key they already hold. The server never sees the names.

The English name-list join ("Alice" / "Alice and Bob" / "Alice, Bob and
Dave") is a pure `nameList` helper in format.js, unit-tested for the
0/1/2/n cases.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: announce leaves, and say "you" to the person added

Extend the group-membership announcements two ways:

- Someone leaving a group now posts a "Paul left" action line, sent as
  their final act before /leave. Because the server gates every msg on
  membership and WS/HTTP race, the client awaits the server echo (proof
  of the durable write) before calling /leave, with a 2s timeout guard.

- Add announcements now carry the added members' user *ids*, not baked
  names, so each recipient renders the line from their own point of view
  via a new pure membershipLine() helper: a newcomer reads "Paul added
  you and Bob", the actor reads "You added Alice and Bob", a bystander
  reads "Paul added Alice and Bob". Names resolve locally (always
  current) and the server still never sees them.

The per-viewer rewrite is applied once in a shared chatMsgEvent() so the
live and history fold paths can't drift. Baked text remains as a
fallback for native clients, the sidebar preview, and push (which do not
run the rewrite). Being removed by someone else stays silent for now.

Co-Authored-By: Claude <noreply@anthropic.com>

* web: announce removals too, and harden the leave path

Add a "Paul removed Bob" action line when a person or bot is removed from
a group (announceRemove + a `removed` case in membershipLine, rendered
from each viewer's point of view like joins). The remover stays a
member, so no echo race — just send after /remove succeeds.

Also harden leaveGroup, which my earlier commit made async:
- capture activeId once as `id` so the leave/teardown can't target the
  wrong thread if the user navigates during the announce await, and only
  tear the view down if that thread is still active;
- make the parting line best-effort (.catch) so it can never abort the
  actual leave;
- sendAndConfirm now schedules its timeout before ws.send and guards the
  send, so a socket death can't leak a pending entry or reject.

Co-Authored-By: Claude <noreply@anthropic.com>

---------
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 464d7705b80153e6f01ffd66257f1530aab3944b parent b5e3c13
4 files changed, +201 −14
  • changelog/2026-07-06-announce-added-members.md +1 −0
  • test/format.test.mjs +47 −1
  • web/static/format.js +35 −0
  • web/static/shell.js +118 −13
diff --git a/changelog/2026-07-06-announce-added-members.md b/changelog/2026-07-06-announce-added-members.md
new file mode 100644
index 0000000..3484b23
--- /dev/null
+++ b/changelog/2026-07-06-announce-added-members.md
@@ -0,0 +1 @@
+🤖 Groups now note who comes and goes. Adding people leaves a small centered line like “Paul added Alice and Bob” — and if you’re one of them, you’ll see “Paul added you.” Removing someone shows “Paul removed Bob,” and leaving shows “Paul left,” so everyone can follow the changes. Like everything else, it’s end-to-end encrypted: the server never sees the names, and each person’s app fills in “you” on their own device.
diff --git a/test/format.test.mjs b/test/format.test.mjs
index c8b8eaa..9e050bc 100644
--- a/test/format.test.mjs
+++ b/test/format.test.mjs
@@ -1,6 +1,6 @@
// Tests for text formatting (link detection, file sizes). node test/format.test.mjs
import assert from "node:assert/strict";
-import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, youtubeId } from "../web/static/format.js";
+import { tokenizeText, richTokens, blockTokens, firstLink, formatBytes, replySnippet, emojiOnlyCount, typingSummary, autoGroupTitle, nameList, membershipLine, youtubeId } from "../web/static/format.js";
import { emojify, emojiMatches } from "../web/static/emoji.js";
let failures = 0;
@@ -316,6 +316,52 @@ await test("autoGroupTitle ignores empty names", () => {
assert.equal(autoGroupTitle(null), "Group");
});
+await test("nameList joins names naturally with 'and'", () => {
+ assert.equal(nameList([]), "");
+ assert.equal(nameList(["Alice"]), "Alice");
+ assert.equal(nameList(["Alice", "Bob"]), "Alice and Bob");
+ assert.equal(nameList(["Alice", "Bob", "Dave"]), "Alice, Bob and Dave");
+ assert.equal(nameList(["Alice", "Bob", "Carol", "Dave"]), "Alice, Bob, Carol and Dave");
+});
+
+await test("nameList ignores empty names and non-arrays", () => {
+ assert.equal(nameList(["Alice", "", "Bob"]), "Alice and Bob");
+ assert.equal(nameList(null), "");
+});
+
+await test("membershipLine renders joins from the viewer's point of view", () => {
+ const names = { u1: "Paul", u2: "Alice", u3: "Bob" };
+ const label = (id) => names[id] || "Someone";
+ // A bystander sees everyone by name.
+ assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u9", label), "Paul added Alice and Bob");
+ // A newcomer sees "you" for themselves.
+ assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u3", label), "Paul added Alice and you");
+ assert.equal(membershipLine({ uid: "u1", join: ["u3"] }, "u3", label), "Paul added you");
+ // The person who did it sees "You".
+ assert.equal(membershipLine({ uid: "u1", join: ["u2", "u3"] }, "u1", label), "You added Alice and Bob");
+});
+
+await test("membershipLine renders removals from the viewer's point of view", () => {
+ const names = { u1: "Paul", u2: "Alice", u3: "Bob" };
+ const label = (id) => names[id] || "Someone";
+ assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u9", label), "Paul removed Bob");
+ assert.equal(membershipLine({ uid: "u1", removed: ["u2", "u3"] }, "u1", label), "You removed Alice and Bob");
+ assert.equal(membershipLine({ uid: "u1", removed: ["u3"] }, "u3", label), "Paul removed you");
+});
+
+await test("membershipLine renders leaves and ignores non-membership payloads", () => {
+ const label = (id) => ({ u1: "Paul" })[id] || "Someone";
+ assert.equal(membershipLine({ uid: "u1", left: true }, "u9", label), "Paul left");
+ assert.equal(membershipLine({ uid: "u1", left: true }, "u1", label), "You left");
+ // No uid → fall back to the baked name.
+ assert.equal(membershipLine({ name: "Paul", left: true }, "u9", label), "Paul left");
+ // Not a membership event.
+ assert.equal(membershipLine({ uid: "u1", text: "hello" }, "u9", label), null);
+ assert.equal(membershipLine({ uid: "u1", join: [] }, "u9", label), null);
+ assert.equal(membershipLine({ uid: "u1", removed: [] }, "u9", label), null);
+ assert.equal(membershipLine(null, "u9", label), null);
+});
+
await test("emojiMatches ranks prefix hits and finds :tada:", () => {
const m = emojiMatches("tada");
assert.equal(m[0].emoji, "🎉");
diff --git a/web/static/format.js b/web/static/format.js
index fa7a5e7..02ce910 100644
--- a/web/static/format.js
+++ b/web/static/format.js
@@ -281,6 +281,41 @@ export function autoGroupTitle(names) {
return `${list[0]}, ${list[1]} & ${n - 2} more`;
}
+// Join names into a natural English list — "Alice" / "Alice and Bob" /
+// "Alice, Bob and Dave". Used to announce who was just added to a group (the
+// centered .chatapp-action line; see addPeople in shell.js). Pure so it stays
+// unit-testable; the names are user-controlled, so the caller renders the
+// result with textContent.
+export function nameList(names) {
+ const list = Array.isArray(names) ? names.filter(Boolean) : [];
+ const n = list.length;
+ if (n === 0) return "";
+ if (n === 1) return list[0];
+ if (n === 2) return `${list[0]} and ${list[1]}`;
+ return `${list.slice(0, -1).join(", ")} and ${list[n - 1]}`;
+}
+
+// Render a group membership announcement (someone added people, removed people, or
+// left) from the *current viewer's* point of view: "you"/"You" is substituted for
+// the reader and every id is resolved to a live name via labelOf, so one stored
+// message reads "Paul added you and Bob" for a newcomer, "You added Alice and Bob"
+// for the person who did it, and "Paul added Alice and Bob" for a bystander. The
+// announcement carries user *ids* (not baked names) precisely so each client can
+// do this locally — see announceJoin/announceRemove/announceLeave in shell.js.
+// Returns null when the payload isn't a membership event. Pure (ids in, string out)
+// so it's unit-testable; the caller renders the result with textContent (names are
+// user-controlled).
+export function membershipLine(payload, meId, labelOf) {
+ if (!payload || typeof payload !== "object") return null;
+ const actor = payload.uid;
+ const actorName = actor ? (actor === meId ? "You" : labelOf(actor)) : payload.name || "Someone";
+ const names = (ids) => nameList(ids.map((uid) => (uid === meId ? "you" : labelOf(uid))));
+ if (Array.isArray(payload.join) && payload.join.length) return `${actorName} added ${names(payload.join)}`;
+ if (Array.isArray(payload.removed) && payload.removed.length) return `${actorName} removed ${names(payload.removed)}`;
+ if (payload.left) return `${actorName} left`;
+ return null;
+}
+
// Human-readable file size, e.g. 1536 → "1.5 KB".
export function formatBytes(n) {
if (!Number.isFinite(n) || n < 0) return "";
diff --git a/web/static/shell.js b/web/static/shell.js
index 3698e9a..2c752ca 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -37,7 +37,7 @@ import {
sanitizeActions,
memberCountLabel,
} from "/static/ui.js";
-import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, emojiOnlyCount, youtubeId } from "/static/format.js";
+import { richTokens, blockTokens, firstLink, typingSummary, replySnippet, formatBytes, autoGroupTitle, nameList, membershipLine, emojiOnlyCount, youtubeId } from "/static/format.js";
import { emojify, emojiMatches } from "/static/emoji.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import {
@@ -2524,6 +2524,87 @@ async function sendChatText(threadId, text) {
return true;
}
+// Blobs we've sent over the socket and are waiting to see echoed back, keyed by
+// the exact blob string → a resolver. The server stores a message before it
+// broadcasts (hub.go), so the echo is proof of a durable write; announceLeave uses
+// this to land the "X left" line before /leave drops our membership.
+const pendingSends = new Map();
+
+// Send a message and resolve once the server echoes it back (durably stored) — or
+// after a timeout, so a WS hiccup can never hang the caller.
+function sendAndConfirm(threadId, blob) {
+ return new Promise((resolve) => {
+ let done = false;
+ const finish = () => {
+ if (done) return;
+ done = true;
+ pendingSends.delete(blob);
+ resolve();
+ };
+ pendingSends.set(blob, finish);
+ setTimeout(finish, 2000);
+ try {
+ ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
+ } catch {
+ finish(); // socket died mid-send — resolve now, don't leak the pending entry
+ }
+ });
+}
+
+// Announce that people were just added to a group: a centered .chatapp-action
+// line, sent as a normal thread-key-encrypted message so it persists in history
+// and renders for every member (including the newcomers, from stored history on
+// their first load) — the server never sees the names. We send the added members'
+// user *ids* (plus our own), not baked names, so each recipient renders the line
+// from their own point of view — "Paul added you and Bob" (membershipLine, applied
+// 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.
+async function announceJoin(threadId, uids) {
+ const key = threadKeys[threadId];
+ if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
+ const payload = { name: session.name, text: `${session.name} added ${nameList(uids.map(rosterLabel))}`, join: uids.slice(), ts: Date.now(), action: true };
+ if (myUserId) payload.uid = myUserId;
+ const blob = await encrypt(key, payload);
+ ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
+}
+
+// Announce that we're leaving a group, as our final act before the server drops
+// our membership. Same shape as announceJoin (recipients render "Paul left"). The
+// server gates every msg on membership, so we must get this stored BEFORE /leave —
+// and the two travel on different channels (WebSocket vs HTTP), so we await the
+// echo (proof of the write) with sendAndConfirm's timeout guard rather than firing
+// and hoping it wins the race.
+function announceLeave(threadId) {
+ const key = threadKeys[threadId];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return Promise.resolve();
+ const payload = { name: session.name, text: `${session.name} left`, left: true, ts: Date.now(), action: true };
+ if (myUserId) payload.uid = myUserId;
+ return encrypt(key, payload).then((blob) => sendAndConfirm(threadId, blob));
+}
+
+// Announce that someone (a person or a bot) was removed from a group ("Paul
+// removed Bob"). Like announceJoin, sent as a normal thread-key-encrypted message
+// carrying the removed members' ids so each recipient renders it locally. No
+// leave-style echo race: the remover stays a member. The removed member is no
+// longer in the thread, so the server won't deliver it to them.
+async function announceRemove(threadId, uids) {
+ const key = threadKeys[threadId];
+ if (!key || !uids.length || !ws || ws.readyState !== WebSocket.OPEN) return;
+ const payload = { name: session.name, text: `${session.name} removed ${nameList(uids.map(rosterLabel))}`, removed: uids.slice(), ts: Date.now(), action: true };
+ if (myUserId) payload.uid = myUserId;
+ const blob = await encrypt(key, payload);
+ ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
+}
+
+// Build the chat.msg fold event for a plain message. Membership announcements
+// (join/leave) get their text rewritten to the viewer's point of view here, so the
+// live and history fold paths share one rewrite and can't drift.
+function chatMsgEvent(payload) {
+ const line = membershipLine(payload, myUserId, rosterLabel);
+ return line ? { ...payload, type: "chat.msg", text: line } : { ...payload, type: "chat.msg" };
+}
+
// createAppThread makes a personal app's single-member thread: a `self` thread
// whose name_blob is the ENCRYPTED app id, wrapped only to us. The server sees
// just another opaque self thread — it never learns which app it hosts.
@@ -2608,7 +2689,7 @@ async function chatAppFoldMessage(threadId, m) {
return;
}
if (data.type || (!data.text && !data.attachments)) return;
- foldHostEvent(threadId, "chat", { ...data, type: "chat.msg" }, { id: m.id, ts: data.ts || m.created_at * 1000 });
+ foldHostEvent(threadId, "chat", chatMsgEvent(data), { id: m.id, ts: data.ts || m.created_at * 1000 });
if (m.reactions && m.reactions.length) {
foldHostEvent(threadId, "chat", { type: "chat.react", msgId: m.id, reactions: m.reactions });
}
@@ -3723,6 +3804,10 @@ function connectWS() {
}
if (msg.type !== "msg") return;
const tid = msg.thread_id;
+ // Echo of a message we're awaiting confirmation for (the server stores before
+ // it broadcasts, so seeing it back means it's durably persisted). Resolve it
+ // before any early return below, so announceLeave can proceed to /leave.
+ if (msg.blob && pendingSends.has(msg.blob)) pendingSends.get(msg.blob)();
// A message in a DM we thought was pending means the invited person has joined
// and started chatting — refresh roster + threads so their identity lands and the
// "waiting to join" state clears (only they can post here until they've joined).
@@ -3800,7 +3885,7 @@ function connectWS() {
// 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.
if ((chatAppFor(tid) || threadApp[tid]) && payload && !payload.type && (payload.text || payload.attachments)) {
- foldHostEvent(tid, "chat", { ...payload, type: "chat.msg" }, { id: msg.id, ts: payload.ts });
+ foldHostEvent(tid, "chat", chatMsgEvent(payload), { id: msg.id, ts: payload.ts });
if (tid === activeId) renderActiveSurfaces();
}
if (tid !== activeId) {
@@ -4208,6 +4293,10 @@ async function removeMember(uid, name, backdrop) {
alert("Couldn't remove them. Try again.");
return;
}
+ // Announce the removal as a centered action line ("Paul removed Bob"). We stay a
+ // member, so no echo race — but send before loadThreads so it goes out while the
+ // thread state is still intact.
+ await announceRemove(id, [uid]);
if (backdrop) backdrop.remove();
await loadThreads(); // refresh members + the (possibly member-derived) name
renderThreadList();
@@ -4322,6 +4411,7 @@ function addPeople() {
add.disabled = true;
try {
const b64 = threadKeyB64[activeId];
+ const added = [];
for (const uid of selected) {
const e = roster.find((x) => x.user_id === uid);
const wrapped = await wrapThreadKey(b64, e.public_key);
@@ -4331,7 +4421,12 @@ function addPeople() {
body: JSON.stringify({ token: session.token, user_id: uid, wrapped }),
});
if (!r.ok) throw new Error("server");
+ added.push(uid);
}
+ // Announce the additions in the thread as a centered action line. We pass
+ // user ids (not names) so each member sees it from their own view — a
+ // newcomer reads "Paul added you and Bob".
+ if (added.length) await announceJoin(activeId, added);
backdrop.remove();
await loadThreads();
renderThreadList();
@@ -4350,11 +4445,19 @@ function addPeople() {
// Set a group photo: downscaled, encrypted with the THREAD key (so only members
// can see it), uploaded, and pointed at via the thread's avatar_blob.
async function leaveGroup() {
- const t = threads.find((x) => x.id === activeId);
+ // Capture the thread up front: announceLeave awaits, and activeId is mutable
+ // module state — the user could navigate away mid-leave, so every step below
+ // must target this id, not whatever's active by the time the await resolves.
+ const id = activeId;
+ const t = threads.find((x) => x.id === id);
if (!t || t.kind !== "group") return;
- if (!confirm(`Leave "${threadName[activeId] || "this group"}"? You won't get new messages.`)) return;
+ if (!confirm(`Leave "${threadName[id] || "this group"}"? You won't get new messages.`)) return;
+ // Leave a parting "Paul left" action line — sent (and confirmed stored) while
+ // we're still a member, since the server drops our posts the moment we leave.
+ // Best-effort: a cosmetic line must never block or abort the actual leave.
+ await announceLeave(id).catch(() => {});
try {
- const r = await fetch(`/api/threads/${encodeURIComponent(activeId)}/leave`, {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/leave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: session.token }),
@@ -4363,14 +4466,16 @@ async function leaveGroup() {
} catch {
return alert("Couldn't leave the group. Try again.");
}
- threads = threads.filter((x) => x.id !== activeId);
- delete threadKeys[activeId];
- activeId = null;
- localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
+ threads = threads.filter((x) => x.id !== id);
+ delete threadKeys[id];
+ if (activeId === id) {
+ activeId = null;
+ localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ showList();
+ }
renderThreadList();
- document.getElementById("thread-view").classList.add("hidden");
- document.getElementById("thread-empty").classList.remove("hidden");
- showList();
}
// ---- new chat (roster picker) -------------------------------------------