eleven / ocho Public

web: extract group actions into groupactions.js — the thread kebab via the verbs seam (#963)

The 1,070-line kebab surface (edit group, members/profiles, space info,
rename notices, mute/pin, add people, group apps, leave/close/end)
moves verbatim; 25 core behaviours ride verbs.*, state rides
shellstate (threadKeyB64/unread/hidden/dmClosed/embed join it — embed
was the audit's blind spot: a bare reference, not a call, caught by the
boot smoke). Thirteen entry points exported for the header/kebab wiring.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d24fb26afe670df05a5c09e4c6b361aa579b0a64 parent 47a7799
4 files changed, +1101 −1079
  • CLAUDE.md +3 −0
  • web/static/groupactions.js +1089 −0
  • web/static/shell.js +4 −1079
  • web/static/shellstate.js +5 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 1a8aab6..ee4fedd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -196,6 +196,9 @@ Frontend module layout:
(`avatarFor`/`threadAvatarFor`/`paintAvatar`/`avatarEl`).
- `shellverbs.js` — the behaviour seam: an empty registry shell.js fills at
boot; modules call `verbs.openThread(id)` etc. Registration only.
+- `rosterui.js` — the member-display vocabulary (labels, presence lines,
+ picker rows). `groupactions.js` — the thread kebab's whole surface (edit
+ group, members, profiles, mute/pin, leave/close), through the verbs seam.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/groupactions.js b/web/static/groupactions.js
new file mode 100644
index 0000000..15717d2
--- /dev/null
+++ b/web/static/groupactions.js
@@ -0,0 +1,1089 @@
+// Group actions — the thread kebab's whole surface: edit group (photo+name),
+// members and profiles, space info, rename notices, mute/pin, add people,
+// group apps, remove member, leave/close/end. Extracted verbatim from
+// shell.js; core behaviours go through the verbs seam (docs/shell-modules.md),
+// state through shellstate.js.
+import { verbs } from "/static/shellverbs.js";
+import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, instanceKey, threadKeyB64, unread, hidden, dmClosed, setNicknames, setActiveId, setThreads, embed } from "/static/shellstate.js";
+import { wrapThreadKey, decrypt, encrypt, encryptBytes } from "/static/crypto.js";
+import { processImage } from "/static/media.js";
+import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
+import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
+import { emitAppEvent, groupCapableApps, appGlyphInto, groupAppsFor } from "/static/apps.js";
+import { displayNameFor, presenceLabel, renameNotice } from "/static/ui.js";
+import { autoGroupTitle } from "/static/format.js";
+import { clearThread as storeClearThread } from "/static/store.js";
+import { setPersonMuted, setPersonBlocked, openReportSheet, mutedPeople, blockedPeople } from "/static/moderation.js";
+
+// ---- group actions (thread kebab) ---------------------------------------
+
+// Edit group: photo + name in one modal (mirrors editProfile). The group photo
+// rides on the thread key (member-readable, server-blind), like its messages.
+// The explicit name stored for a group (decrypted from name_blob), or "" when
+// none is set — distinct from the display title, which falls back to the member
+// names. The edit field shows THIS, so a nameless group reads as blank (and
+// keeps auto-generating) rather than looking like its members are its name.
+async function storedGroupName(t) {
+ if (t && t.kind === "group" && t.name_blob && threadKeys[t.id]) {
+ try {
+ return (await decrypt(threadKeys[t.id], t.name_blob)).name || "";
+ } catch {
+ return "";
+ }
+ }
+ return "";
+}
+
+export async function editGroup() {
+ const t = threads.find((x) => x.id === activeId);
+ if (!t || t.kind !== "group") return;
+ const id = activeId;
+ // The auto-title (member names) — shown as the field's placeholder, and the
+ // display name to fall back to if the explicit name is cleared.
+ const autoName = autoGroupTitle((t.members || []).filter((x) => x !== myUserId).map(rosterLabel));
+ const stored = await storedGroupName(t);
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Edit group";
+ card.appendChild(h);
+
+ const loader = () => threadAvatarFor(id);
+ const av = avatarEl(threadName[id] || "?", loader);
+ av.classList.add("avatar-lg");
+ const photoBtn = document.createElement("button");
+ photoBtn.className = "linkish";
+ photoBtn.type = "button";
+ photoBtn.textContent = "Change photo";
+ const photoInput = document.createElement("input");
+ photoInput.type = "file";
+ photoInput.accept = "image/*";
+ photoInput.style.display = "none";
+ photoBtn.onclick = () => photoInput.click();
+ photoInput.onchange = async () => {
+ const f = photoInput.files[0];
+ photoInput.value = "";
+ if (!f) return;
+ photoBtn.disabled = true;
+ photoBtn.textContent = "Uploading…";
+ try {
+ const { bytes } = await processImage(f, 400);
+ if (bytes.length > MAX_UPLOAD) throw new Error("too big");
+ const enc = await encryptBytes(threadKeys[id], bytes);
+ const up = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc });
+ if (!up.ok) throw new Error("upload");
+ const { id: blobId } = await up.json();
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/avatar`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, blob_id: blobId }),
+ });
+ if (!r.ok) throw new Error("server");
+ t.avatar = blobId;
+ delete avatarURLs["t:" + id];
+ paintAvatar(av, threadName[id] || "?", loader);
+ if (activeId === id) verbs.paintHeaderAvatar(id);
+ verbs.renderThreadList();
+ photoBtn.textContent = "Change photo";
+ } catch (e) {
+ console.error(e);
+ photoBtn.textContent = "Couldn't upload — try again";
+ } finally {
+ photoBtn.disabled = false;
+ }
+ };
+ const avWrap = document.createElement("div");
+ avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
+ avWrap.appendChild(av);
+ avWrap.appendChild(document.createElement("br"));
+ avWrap.appendChild(photoBtn);
+ avWrap.appendChild(photoInput);
+ card.appendChild(avWrap);
+
+ const label = document.createElement("label");
+ label.className = "field-label";
+ label.textContent = "Group name";
+ const input = document.createElement("input");
+ input.value = stored; // the explicit name, blank if none — not the auto-title
+ input.placeholder = autoName; // ghost text previews the auto-generated name
+ input.maxLength = 60;
+ // A blank field still shows the auto-name's initial on the avatar.
+ input.addEventListener("input", () => paintAvatar(av, input.value || autoName || "?", loader));
+ card.appendChild(label);
+ card.appendChild(input);
+
+ // Open/close for discovery: any member may flip it (groups are flat, like
+ // rename). Opening ships the group key encrypted under the instance key —
+ // once; the server never overwrites a stored open_key — so the choice is
+ // only offered when this device holds both keys it needs to mint the blob.
+ let openCb = null;
+ let defaultCb = null;
+ let gateSel = null;
+ // First open needs wrap keys on this device; a reopen needs none (the
+ // server kept the blob) — so an already-opened group offers the toggle to
+ // any member.
+ if ((instanceKey && threadKeyB64[id]) || t.open_key) {
+ const optRow = (label, descText) => {
+ const row = document.createElement("label");
+ row.className = "opt-row";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ const txt = document.createElement("span");
+ txt.textContent = label;
+ const desc = document.createElement("span");
+ desc.className = "opt-desc";
+ desc.textContent = descText;
+ txt.appendChild(desc);
+ row.appendChild(cb);
+ row.appendChild(txt);
+ card.appendChild(row);
+ return cb;
+ };
+ openCb = optRow("Open group", "Anyone in this space can find it under Discover and join.");
+ openCb.checked = t.visibility === "open";
+ // The level gate: pickable until the group first opens (the open_key is
+ // wrapped under the chosen key), then fixed — server-enforced, so the UI
+ // states it rather than offering a dead control.
+ if (t.open_key) {
+ if (t.open_tier) {
+ const lvl = tiers.find((x) => x.id === t.open_tier);
+ const note = document.createElement("span");
+ note.className = "opt-desc";
+ note.style.margin = "0 0 6px 30px";
+ note.textContent = `Only ${lvl ? lvl.name : "one access level"} can find and join — fixed once a group has opened.`;
+ card.appendChild(note);
+ }
+ } else {
+ const myLevels = tiers.filter((x) => x.mine && tierKeyB64[x.id]);
+ if (myLevels.length) {
+ gateSel = document.createElement("select");
+ gateSel.className = "sheet-input";
+ gateSel.setAttribute("aria-label", "Who can find and join");
+ const all = document.createElement("option");
+ all.value = "";
+ all.textContent = "Everyone in this space";
+ gateSel.appendChild(all);
+ for (const x of myLevels) {
+ const o = document.createElement("option");
+ o.value = x.id;
+ o.textContent = `Only ${x.name}`;
+ gateSel.appendChild(o);
+ }
+ gateSel.style.display = openCb.checked ? "" : "none";
+ openCb.addEventListener("change", () => {
+ gateSel.style.display = openCb.checked ? "" : "none";
+ });
+ card.appendChild(gateSel);
+ }
+ }
+ // Default groups are space policy (who lands where on arrival), so the
+ // choice is admin-only — and only meaningful while the group is open.
+ const meEntry = roster.find((e) => e.user_id === myUserId);
+ if (meEntry && meEntry.role === "admin" && !t.open_tier) {
+ defaultCb = optRow(
+ "New members join automatically",
+ "Everyone who joins the space starts in this group. Nobody already here is added.",
+ );
+ defaultCb.checked = !!t.default_join;
+ const syncDefault = () => {
+ defaultCb.disabled = !openCb.checked;
+ if (!openCb.checked) defaultCb.checked = false;
+ };
+ syncDefault();
+ openCb.addEventListener("change", syncDefault);
+ }
+ }
+
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ save.onclick = async () => {
+ const next = input.value.trim();
+ const flipOpen = openCb && openCb.checked !== (t.visibility === "open");
+ const flipDefault = defaultCb && defaultCb.checked !== !!t.default_join;
+ if (next !== stored || flipOpen || flipDefault) {
+ save.disabled = true;
+ try {
+ if (next !== stored) {
+ // A blank name clears it (name_blob=""), so the group reverts to its
+ // auto-generated member-name title.
+ const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : "";
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, name_blob }),
+ });
+ if (!r.ok) throw new Error("server");
+ t.name_blob = name_blob;
+ threadName[id] = next || autoName;
+ if (activeId === id) document.getElementById("thread-title").textContent = threadName[id];
+ }
+ if (flipOpen) {
+ const body = { token: session.token, visibility: openCb.checked ? "open" : "invite" };
+ if (openCb.checked) {
+ // First open: wrap under the picked gate's key (or the instance
+ // key for everyone). Reopen: send NO key — the server kept the
+ // original open_key (never overwritten), it only wants the
+ // original gate named again. That keeps close/reopen symmetric:
+ // any member can do either, wrap keys not required.
+ const gate = t.open_key ? t.open_tier || "" : (gateSel && gateSel.value) || "";
+ if (!t.open_key) {
+ const src = gate ? tierKeys[gate] : instanceKey;
+ if (!src) throw new Error("no wrapping key");
+ body.open_key = await encrypt(src, { k: threadKeyB64[id] });
+ }
+ if (gate) body.open_tier = gate;
+ }
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/visibility`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) throw new Error("server");
+ t.visibility = openCb.checked ? "open" : "";
+ if (openCb.checked && !t.open_tier && gateSel && gateSel.value) t.open_tier = gateSel.value;
+ if (openCb.checked && !t.open_key) t.open_key = "set"; // gate now fixed; the real blob arrives on the next loadThreads
+ }
+ if (flipDefault) {
+ // After the visibility flip on purpose: making a group open AND
+ // default in one save must open it first (the server refuses a
+ // default flag on a closed group).
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/default`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, default: defaultCb.checked }),
+ });
+ if (!r.ok) throw new Error("server");
+ t.default_join = defaultCb.checked;
+ }
+ verbs.renderThreadList();
+ } catch {
+ save.disabled = false;
+ save.textContent = "Couldn't save — try again";
+ return;
+ }
+ }
+ backdrop.remove();
+ };
+ foot.appendChild(cancel);
+ foot.appendChild(save);
+ card.appendChild(foot);
+ input.focus();
+}
+
+// Open the DM with someone, creating it on first use — the one "Message them"
+// behaviour behind the profile card, the member sheet, and the mention menu.
+export async function openOrCreateDM(uid) {
+ const dm = verbs.existingDM(uid);
+ if (dm) return verbs.openThread(dm.id); // already have a DM — reopen it, don't start a second
+ try {
+ const id = await verbs.createThread([uid], { group: false });
+ await verbs.loadThreads();
+ verbs.renderThreadList();
+ verbs.openThread(id);
+ } catch (e) {
+ // Their DM door is closed (issue #578) — tell the caller so the button
+ // that asked can say so inline instead of failing silently.
+ if (String(e && e.message).includes("dms closed")) return false;
+ console.error(e);
+ }
+}
+
+export function showMembers() {
+ const t = threads.find((x) => x.id === activeId);
+ if (!t) return;
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Members";
+ card.appendChild(h);
+ const isGroup = t.kind === "group";
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ for (const uid of t.members || []) {
+ const name = rosterLabel(uid);
+ const me = uid === myUserId;
+ const row = document.createElement("div");
+ row.className = "list-row" + (me ? " static" : "");
+
+ // The identity area (avatar + name); tapping a member opens their profile
+ // card (the contacts-app convention) — Message lives on the card, so a
+ // stray tap no longer starts a DM you didn't want.
+ const main = document.createElement(me ? "div" : "button");
+ main.className = "member-main";
+ main.appendChild(avatarEl(name, () => avatarFor(uid)));
+ const lbl = document.createElement("span");
+ lbl.style.display = "flex"; // center the name with its badge
+ lbl.style.alignItems = "center";
+ const lblText = document.createElement("span");
+ lblText.style.minWidth = "0"; // keep ellipsis truncation inside the flex line
+ lblText.textContent = me ? `${name} (you)` : name;
+ lbl.appendChild(lblText);
+ // Transparency: a bot in the room stays visibly a bot, not just in the picker.
+ const entry = roster.find((x) => x.user_id === uid);
+ if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind));
+ // Presence beside the name — this sheet is where a mixed room's status
+ // lives (the header shows only the people-online count).
+ if (entry && presenceLabel(entry.presence)) {
+ const st = document.createElement("span");
+ st.className = "member-presence";
+ setPresenceLine(st, entry.presence);
+ lbl.appendChild(st);
+ }
+ // Name (+badge +presence) with their "about me" line stacked beneath.
+ const idcol = document.createElement("span");
+ idcol.style.minWidth = "0";
+ idcol.appendChild(lbl);
+ if (entry && !entry.is_agent && entry.about) {
+ const d = document.createElement("span");
+ d.className = "helper-desc";
+ d.textContent = entry.about;
+ idcol.appendChild(d);
+ }
+ main.appendChild(idcol);
+ if (!me)
+ main.onclick = () => {
+ backdrop.remove();
+ viewProfile(uid);
+ };
+ row.appendChild(main);
+
+ // Remove-from-group (groups only, not yourself — use Leave for that).
+ if (!me && isGroup) {
+ const rm = document.createElement("button");
+ rm.className = "member-remove";
+ rm.textContent = "Remove";
+ rm.setAttribute("aria-label", `Remove ${name}`);
+ rm.onclick = () => removeMember(uid, name, backdrop);
+ row.appendChild(rm);
+ }
+ list.appendChild(row);
+ }
+ card.appendChild(list);
+}
+
+// A member's profile card: photo, name (with the BOT/AI badge for agents), and
+// their "about me" — for agents, the admin's Helpers-catalog blurb. Opened by
+// tapping a DM's header title; groups show the same lines in the member list.
+export function viewProfile(uid) {
+ const e = roster.find((x) => x.user_id === uid);
+ if (!e) return;
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const av = avatarEl(displayNameFor(nicknames[uid], e.label) || "?", () => avatarFor(uid));
+ av.classList.add("avatar-lg");
+ const avWrap = document.createElement("div");
+ avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
+ avWrap.appendChild(av);
+ card.appendChild(avWrap);
+ const name = document.createElement("h2");
+ name.style.cssText = "display:flex;align-items:center;justify-content:center";
+ const nm = document.createElement("span");
+ nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
+ name.appendChild(nm);
+ if (e.is_agent) name.appendChild(agentTag(false, e.kind));
+ card.appendChild(name);
+
+ // Per-viewer names (#577), people only: when I call them something of my
+ // own, their published name shows beneath; a fresh rename of theirs offers
+ // the keep/adopt choice; and "Change the name you see" edits my name for
+ // them (clearing it follows their published name again).
+ if (!e.is_agent && uid !== myUserId && e.public_key) {
+ const nickArea = document.createElement("div");
+ nickArea.className = "nick-area";
+ // The card's name/notice lines change under AT users' feet as they choose —
+ // a polite live region announces the outcome without stealing focus.
+ nickArea.setAttribute("role", "status");
+ card.appendChild(nickArea);
+ // synced=false appends an inline "still syncing" line — a failed save is
+ // applied locally and retries, but must never read as a clean success.
+ const paintNick = (focusEdit, synced = true) => {
+ nickArea.textContent = "";
+ nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
+ const nick = nicknames[uid];
+ const notice = renameNotice(nick, e.label);
+ if (notice) {
+ const row = document.createElement("p");
+ row.className = "rename-note";
+ row.appendChild(renameLine(notice));
+ const choose = async (keepMine) => paintNick(true, await verbs.resolveRename(uid, keepMine));
+ for (const b of renameChoices(notice, choose)) row.appendChild(b);
+ nickArea.appendChild(row);
+ } else if (nick && nick.name && e.label && e.label !== nick.name) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.margin = "0";
+ p.textContent = `Goes by ${e.label}`;
+ nickArea.appendChild(p);
+ }
+ const edit = document.createElement("button");
+ edit.className = "linkish";
+ edit.textContent = "Change the name you see";
+ edit.onclick = () => {
+ nickArea.textContent = "";
+ const input = document.createElement("input");
+ input.type = "text";
+ input.maxLength = 80;
+ input.value = displayNameFor(nick, e.label);
+ input.className = "eleven-input nick-input";
+ input.placeholder = e.label || "";
+ input.setAttribute("aria-label", "Your name for them");
+ const row = document.createElement("div");
+ row.className = "row";
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ save.onclick = async () => {
+ const v = input.value.trim();
+ // Typing their published name back = no override to keep.
+ paintNick(true, await verbs.setNickname(uid, v === e.label ? "" : v));
+ };
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => paintNick(true);
+ input.onkeydown = (ev) => {
+ if (ev.key === "Enter") (ev.preventDefault(), save.onclick());
+ // stopPropagation: Escape here cancels the edit, not the whole sheet
+ // (verbs.modal() closes on a document-level Escape otherwise).
+ if (ev.key === "Escape") (ev.stopPropagation(), paintNick(true));
+ };
+ row.appendChild(save);
+ row.appendChild(cancel);
+ nickArea.appendChild(input);
+ nickArea.appendChild(row);
+ input.focus();
+ input.select();
+ };
+ nickArea.appendChild(edit);
+ if (!synced) {
+ const warn = document.createElement("p");
+ warn.className = "sub";
+ warn.style.margin = "2px 0 0";
+ warn.textContent = "Couldn't reach the server just now — saved here, still syncing.";
+ nickArea.appendChild(warn);
+ }
+ // Save/Cancel/Escape destroy the control that held focus; hand it to the
+ // edit button so keyboard focus never drops out of the sheet's Tab trap.
+ if (focusEdit) edit.focus();
+ };
+ paintNick();
+ }
+
+ const blurb = e.is_agent ? e.description : e.about;
+ if (blurb) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.cssText = "text-align:center;white-space:pre-wrap;margin:8px 0 4px";
+ p.textContent = blurb;
+ card.appendChild(p);
+ }
+
+ // The person's access levels, as chips. Level names are plaintext space
+ // policy already listed to every member by /api/tiers, so this surfaces
+ // what the roster view exposes — no new disclosure. Populated from a fresh
+ // fetch (the cache can lag a just-made level); hidden until there's one.
+ if (!e.is_agent) {
+ const chips = document.createElement("div");
+ chips.className = "level-chips";
+ card.appendChild(chips);
+ verbs.loadTiers().then(() => {
+ const mine = tiers.filter((tt) => (tt.members || []).includes(uid));
+ for (const tt of mine) {
+ const chip = document.createElement("span");
+ chip.className = "level-chip";
+ chip.textContent = tt.name;
+ chips.appendChild(chip);
+ }
+ });
+ }
+
+ // Message them — the card's action when it was reached from somewhere other
+ // than their DM (the member sheet, a mention). Inside their DM it's noise —
+ // and on someone you've blocked it's a contradiction (Unblock is below).
+ const dm = verbs.existingDM(uid);
+ if (uid !== myUserId && !(dm && dm.id === activeId) && !blockedPeople.has(uid)) {
+ const row = document.createElement("div");
+ row.className = "row";
+ row.style.cssText = "justify-content:center;margin-top:14px";
+ const msg = document.createElement("button");
+ msg.textContent = `Message ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ msg.onclick = async () => {
+ // Their DM door may be closed (issue #578) — say so on the button
+ // itself instead of closing the card over a silent failure.
+ msg.disabled = true;
+ const refused = (await openOrCreateDM(uid)) === false;
+ if (refused) {
+ msg.textContent = "They're not accepting messages";
+ return;
+ }
+ backdrop.remove();
+ };
+ row.appendChild(msg);
+ card.appendChild(row);
+ }
+
+ // Mute them (issue #578) — their messages in shared chats fold to a quiet
+ // stub and never notify. Private: they don't find out, and the server can't
+ // read the muted set either. Honest copy: they stop appearing to YOU.
+ if (uid !== myUserId) {
+ const modRow = document.createElement("div");
+ modRow.className = "row";
+ modRow.style.cssText = "justify-content:center;margin-top:10px";
+ const muteBtn = document.createElement("button");
+ muteBtn.className = "secondary";
+ const paintMute = () => {
+ muteBtn.textContent = mutedPeople.has(uid)
+ ? `Unmute ${displayNameFor(nicknames[uid], e.label) || "them"}`
+ : `Mute ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ };
+ paintMute();
+ muteBtn.onclick = () => {
+ setPersonMuted(uid, !mutedPeople.has(uid));
+ paintMute();
+ };
+ modRow.appendChild(muteBtn);
+ // Report them (issue #578) — the same sheet a message long-press opens,
+ // minus the quoted message.
+ if (!e.is_agent) {
+ const reportBtn = document.createElement("button");
+ reportBtn.className = "secondary";
+ reportBtn.textContent = `Report ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ reportBtn.onclick = () => {
+ backdrop.remove();
+ openReportSheet({ uid });
+ };
+ modRow.appendChild(reportBtn);
+ }
+ card.appendChild(modRow);
+
+ // Block (issue #578): mute + end the DM + server-enforced no new contact
+ // — no new DMs, no adding each other to groups. They're never told.
+ if (!e.is_agent) {
+ const blockRow = document.createElement("div");
+ blockRow.className = "row";
+ blockRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px";
+ const blockBtn = document.createElement("button");
+ const blockNote = document.createElement("div");
+ blockNote.className = "field-note";
+ blockNote.style.textAlign = "center";
+ const paintBlock = () => {
+ const on = blockedPeople.has(uid);
+ blockBtn.className = on ? "secondary" : "danger";
+ blockBtn.textContent = on
+ ? `Unblock ${displayNameFor(nicknames[uid], e.label) || "them"}`
+ : `Block ${displayNameFor(nicknames[uid], e.label) || "them"}`;
+ blockNote.textContent = on
+ ? "Unblocking makes contact possible again — nothing reopens by itself."
+ : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told.";
+ };
+ paintBlock();
+ blockBtn.onclick = async () => {
+ blockBtn.disabled = true;
+ const ok = await setPersonBlocked(uid, !blockedPeople.has(uid));
+ blockBtn.disabled = false;
+ if (!ok) {
+ blockNote.textContent = "Couldn't save — try again.";
+ return;
+ }
+ paintBlock();
+ };
+ blockRow.append(blockBtn, blockNote);
+ card.appendChild(blockRow);
+ }
+ }
+}
+
+// A DM partner renamed themself while I hold my own name for them (#577): a
+// slim inline row between the header and the feed — occupying layout space,
+// never floating — offering the choice once. Clears itself when there's
+// nothing to ask (no nickname, acknowledged, or not a DM; the person's profile
+// card — today opened from your DM's header — shows the same choice).
+export function updateRenameBanner(t) {
+ // Never inside a native webview: the rename prompt is native chrome's job
+ // there (RenameBanner.swift), and the embed can't persist a choice anyway
+ // (no userPriv — a tap would fake-succeed and desync from native).
+ if (embed) return;
+ const view = document.getElementById("thread-view");
+ if (!view) return;
+ let bar = document.getElementById("rename-banner");
+ let notice = null;
+ let other = null;
+ if (t && t.kind === "dm") {
+ other = (t.members || []).find((uid) => uid !== myUserId);
+ const e = roster.find((x) => x.user_id === other);
+ notice = renameNotice(nicknames[other], e && e.label);
+ }
+ if (!notice) {
+ if (bar) bar.remove();
+ return;
+ }
+ if (!bar) {
+ bar = document.createElement("div");
+ bar.id = "rename-banner";
+ bar.className = "rename-banner";
+ // A polite live region: the row appears mid-conversation, so AT users are
+ // told the choice exists without focus being stolen from the composer.
+ bar.setAttribute("role", "status");
+ view.insertBefore(bar, document.getElementById("app-surface"));
+ }
+ bar.textContent = "";
+ bar.appendChild(renameLine(notice));
+ // Choosing removes the bar (and the focused button with it) — hand focus to
+ // the composer so keyboard users aren't dropped onto <body>.
+ const choose = async (keepMine) => {
+ await verbs.resolveRename(other, keepMine);
+ const box = view.querySelector(".chatapp-root .composer .composer-input");
+ if (box) box.focus();
+ };
+ for (const b of renameChoices(notice, choose)) bar.appendChild(b);
+}
+
+// "<from> now goes by <to>" with the two NAMES carrying the weight — the
+// emphasis encodes which words matter; the sentence around them stays plain.
+function renameLine(notice) {
+ const msg = document.createElement("span");
+ msg.className = "rename-msg";
+ const from = document.createElement("span");
+ from.className = "rename-name";
+ from.textContent = notice.from;
+ const to = document.createElement("span");
+ to.className = "rename-name";
+ to.textContent = notice.to;
+ msg.appendChild(from);
+ msg.appendChild(document.createTextNode(" now goes by "));
+ msg.appendChild(to);
+ return msg;
+}
+
+// The keep/adopt pair, in the app's one-tap-decision shape (action chips).
+// choose(keepMine) resolves; both surfaces (banner + profile card) share this.
+function renameChoices(notice, choose) {
+ const use = document.createElement("button");
+ use.className = "action-chip";
+ use.textContent = `Use ${notice.to}`;
+ use.onclick = () => choose(false);
+ const keep = document.createElement("button");
+ keep.className = "action-chip";
+ keep.textContent = `Keep ${notice.from}`;
+ keep.onclick = () => choose(true);
+ return [use, keep];
+}
+
+// The group info incl. the space description — plaintext branding-adjacent
+// metadata like the admin name/email, member-gated on /api/group. On a sealed
+// instance the readable values come from the decrypted branding blob.
+async function fetchSpaceInfo() {
+ const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
+ if (g.sealed && sealedBranding) {
+ g.name = sealedBranding.name || "";
+ g.admin_name = sealedBranding.admin_name || "";
+ g.admin_email = sealedBranding.admin_email || "";
+ g.description = sealedBranding.description || "";
+ }
+ return { g, about: g.description || "" };
+}
+
+// "About this space": the group name, its free-text description, and who runs
+// it. The description is admin-authored, plaintext like the group name — it
+// also greets invitees on the join card. Role-admins edit it here (their
+// device token passes the admin API); it's editable from /admin too.
+export async function showSpaceInfo() {
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = document.title; // carries the substituted group name
+ card.appendChild(h);
+
+ let info = { g: {}, about: "" };
+ try {
+ info = await fetchSpaceInfo();
+ } catch {}
+
+ const meEntry = roster.find((x) => x.user_id === myUserId);
+ const canEdit = !!(meEntry && meEntry.role === "admin");
+
+ if (canEdit) {
+ const lbl = document.createElement("label");
+ lbl.className = "field-label";
+ lbl.textContent = "Description";
+ const ta = document.createElement("textarea");
+ ta.rows = 4;
+ ta.maxLength = 500;
+ ta.placeholder = "What is this space? Members and the people they invite can read this.";
+ ta.value = info.about;
+ card.appendChild(lbl);
+ card.appendChild(ta);
+ const note = document.createElement("p");
+ note.className = "sub";
+ card.appendChild(note);
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ const save = document.createElement("button");
+ save.textContent = "Save";
+ save.onclick = async () => {
+ save.disabled = true;
+ try {
+ const r = await fetch("/api/group-description", {
+ method: "POST",
+ headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
+ body: JSON.stringify({ description: ta.value.trim().slice(0, 500) }),
+ });
+ if (!r.ok) throw new Error("save failed");
+ backdrop.remove();
+ } catch {
+ note.textContent = "Couldn't save — try again.";
+ save.disabled = false;
+ }
+ };
+ foot.appendChild(cancel);
+ foot.appendChild(save);
+ card.appendChild(foot);
+ } else {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.style.whiteSpace = "pre-wrap"; // the description keeps its line breaks
+ p.textContent = info.about || "No description yet.";
+ card.appendChild(p);
+ }
+
+ // Who runs the space (member-gated fields; empty until the admin sets them).
+ const who = info.g.admin_name || "";
+ const mail = info.g.admin_email || "";
+ if (who || mail) {
+ const run = document.createElement("p");
+ run.className = "sub";
+ run.textContent = "Run by " + (who || mail) + (who && mail ? ` (${mail})` : "");
+ card.appendChild(run);
+ }
+}
+
+// Remove another member from the current group (any member may; no key rotation —
+// the server just stops delivering to them, like leaving). Confirms first.
+async function removeMember(uid, name, backdrop) {
+ const id = activeId;
+ const t = threads.find((x) => x.id === id);
+ if (!t || t.kind !== "group") return;
+ if (!confirm(`Remove ${name} from the group?`)) return;
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/remove`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, user_id: uid }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ 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 verbs.announceRemove(id, [uid]);
+ if (backdrop) backdrop.remove();
+ await verbs.loadThreads(); // refresh members + the (possibly member-derived) name
+ verbs.renderThreadList();
+ if (activeId === id) verbs.paintThreadHeader(id); // update the member count / title
+}
+
+// Close (hide) the open chat: it drops off the list until there's new activity or
+// you find it via search. Per-user, synced across your devices.
+export async function closeChat() {
+ const id = activeId;
+ if (!id) return;
+ hidden[id] = true;
+ fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, hidden: true }),
+ }).catch(() => {});
+ setActiveId(null);
+ localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
+ verbs.renderThreadList();
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ verbs.showList();
+}
+
+// End the open DM for both people (issue #578): it drops off both lists and
+// the server refuses new messages until someone reopens it. Stronger than
+// Close chat (which quietly returns on activity) and explicitly mutual — hence
+// the confirm.
+export async function endConversation() {
+ const id = activeId;
+ const t = threads.find((x) => x.id === id);
+ if (!t || t.kind !== "dm") return;
+ if (!confirm("End this conversation? It closes for both of you, and no new messages can be sent unless someone reopens it.")) return;
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/close-dm`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ return; // nothing changed server-side; leave the chat as it was
+ }
+ dmClosed[id] = true;
+ hidden[id] = true;
+ setActiveId(null);
+ localStorage.removeItem(ACTIVE_KEY);
+ verbs.renderThreadList();
+ document.getElementById("thread-view").classList.add("hidden");
+ document.getElementById("thread-empty").classList.remove("hidden");
+ verbs.showList();
+}
+
+// Mute/unmute the open chat: no notifications and no unread dot while muted;
+// messages still arrive normally. Per-user, synced across your devices — the
+// server just skips pushing to you for this thread.
+export function toggleMute() {
+ const id = activeId;
+ if (!id) return;
+ muted[id] = !muted[id];
+ fetch(`/api/threads/${encodeURIComponent(id)}/muted`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, muted: muted[id] }),
+ }).catch(() => {});
+ document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
+ verbs.renderThreadList();
+ verbs.syncThreadKeysToSW(); // the SW's own muted check (belt to the server's braces)
+}
+
+// Pin/unpin the open chat: pinned chats float to the top of the list. Per-user,
+// synced across your devices.
+export function togglePin() {
+ const id = activeId;
+ if (!id) return;
+ pinned[id] = !pinned[id];
+ fetch(`/api/threads/${encodeURIComponent(id)}/pinned`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token, pinned: pinned[id] }),
+ }).catch(() => {});
+ document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
+ verbs.renderThreadList();
+}
+
+// addPeople adds joined instance members (not already in the group) by wrapping
+// the group key to each of their roster public keys.
+export function addPeople() {
+ const t = threads.find((x) => x.id === activeId);
+ if (!t || t.kind !== "group") return;
+ const here = new Set(t.members || []);
+ // Joined connections only (keyless = pending invitee, no pubkey to wrap to).
+ const candidates = roster.filter((e) => !here.has(e.user_id) && e.public_key);
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Add people";
+ card.appendChild(h);
+ if (!candidates.length) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = "Everyone who's joined is already in this group.";
+ card.appendChild(p);
+ return;
+ }
+
+ const filter = document.createElement("input");
+ filter.placeholder = "Search people";
+ filter.className = "sheet-input";
+ card.appendChild(filter);
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+
+ const selected = new Set();
+ const add = document.createElement("button");
+ const renderList = () => {
+ const q = filter.value.trim().toLowerCase();
+ list.innerHTML = "";
+ const matches = candidates.filter((x) => rosterMatches(x, q));
+ const addRow = (e) => {
+ const row = document.createElement("label");
+ row.className = "list-row";
+ const cb = document.createElement("input");
+ cb.type = "checkbox";
+ cb.checked = selected.has(e.user_id);
+ cb.onchange = () => {
+ cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
+ add.disabled = selected.size === 0;
+ };
+ row.appendChild(cb);
+ row.appendChild(pickerName(e));
+ list.appendChild(row);
+ };
+ // Same People/Helpers split as the new-chat picker: adding a bot to a group
+ // is a distinct, visible act — never something buried among the people.
+ // And the same bots gate: off hides the helpers here too.
+ const people = matches.filter((e) => !e.is_agent);
+ const helpers = ENABLE_BOTS ? matches.filter((e) => e.is_agent) : [];
+ if (people.length && helpers.length) list.appendChild(pickerSection("People"));
+ for (const e of people) addRow(e);
+ if (helpers.length) list.appendChild(pickerSection("Helpers"));
+ for (const e of helpers) addRow(e);
+ };
+ filter.addEventListener("input", renderList);
+ renderList();
+
+ const foot = document.createElement("div");
+ foot.className = "sheet-foot";
+ const cancel = document.createElement("button");
+ cancel.className = "secondary";
+ cancel.textContent = "Cancel";
+ cancel.onclick = () => backdrop.remove();
+ add.textContent = "Add";
+ add.disabled = true;
+ add.onclick = async () => {
+ 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);
+ const r = await fetch(`/api/threads/${encodeURIComponent(activeId)}/members`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ 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 verbs.announceJoin(activeId, added);
+ backdrop.remove();
+ await verbs.loadThreads();
+ verbs.renderThreadList();
+ if (activeId) verbs.paintThreadHeader(activeId); // refresh the member count / name
+ } catch (e) {
+ console.error(e);
+ add.disabled = false;
+ add.textContent = "Couldn't add — try again";
+ }
+ };
+ foot.appendChild(cancel);
+ foot.appendChild(add);
+ card.appendChild(foot);
+}
+
+// manageGroupApps adds or removes a trusted first-party app in the open group
+// (⋮ → Add apps): a sheet of the group-capable apps, each with an Add / Remove
+// action. The choice is an EVENT in the group's encrypted log (groupapps.add /
+// .remove) — every member folds it, so the header widget bar appears for the
+// whole group and the act shows as an action line in the conversation. The
+// server never learns which app was added, or that one was. Any member can add
+// or remove, like adding people.
+export function manageGroupApps() {
+ const t = threads.find((x) => x.id === activeId);
+ if (!t || t.kind !== "group") return;
+ const tid = activeId;
+ const backdrop = verbs.modal();
+ const card = backdrop.querySelector(".sheet");
+ const h = document.createElement("h2");
+ h.textContent = "Apps";
+ card.appendChild(h);
+ const sub = document.createElement("p");
+ sub.className = "sub";
+ sub.textContent = "An app added here is shared — everyone in the group sees it beside the chat.";
+ card.appendChild(sub);
+ const candidates = groupCapableApps();
+ if (!candidates.length) {
+ const p = document.createElement("p");
+ p.className = "sub";
+ p.textContent = "No apps can be added to groups yet.";
+ card.appendChild(p);
+ return;
+ }
+ const added = new Set(groupAppsFor(tid).map((a) => a.name));
+ const list = document.createElement("div");
+ list.className = "pick-list";
+ card.appendChild(list);
+ for (const app of candidates) {
+ const row = document.createElement("div");
+ row.className = "list-row static";
+ const av = document.createElement("span");
+ av.className = "avatar app-avatar";
+ appGlyphInto(av, app);
+ const body = document.createElement("span");
+ body.style.cssText = "flex:1;min-width:0;";
+ const nm = document.createElement("span");
+ nm.textContent = app.title || app.name;
+ body.appendChild(nm);
+ if (app.blurb) {
+ const bl = document.createElement("span");
+ bl.className = "helper-desc";
+ bl.textContent = app.blurb;
+ body.appendChild(bl);
+ }
+ const btn = document.createElement("button");
+ const isAdded = added.has(app.name);
+ btn.textContent = isAdded ? "Remove" : "Add";
+ if (isAdded) btn.className = "secondary";
+ btn.onclick = async () => {
+ btn.disabled = true;
+ const ok = await emitAppEvent(tid, { type: isAdded ? "groupapps.remove" : "groupapps.add", app: app.name });
+ if (!ok) {
+ // Socket down — nothing sent; let them retry rather than lie.
+ btn.disabled = false;
+ btn.textContent = "Couldn't send — try again";
+ return;
+ }
+ // The echo folds the change for everyone (us included) and refreshes the
+ // widget bar live — the sheet's job is done.
+ backdrop.remove();
+ };
+ row.append(av, body, btn);
+ list.appendChild(row);
+ }
+}
+
+// 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.
+export async function leaveGroup() {
+ // 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[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 verbs.announceLeave(id).catch(() => {});
+ try {
+ const r = await fetch(`/api/threads/${encodeURIComponent(id)}/leave`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: session.token }),
+ });
+ if (!r.ok) throw new Error("server");
+ } catch {
+ return alert("Couldn't leave the group. Try again.");
+ }
+ setThreads(threads.filter((x) => x.id !== id));
+ delete threadKeys[id];
+ storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457)
+ searchIdx.dropThread(id); // …and stops matching in search (#586)
+ if (activeId === id) {
+ setActiveId(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");
+ verbs.showList();
+ }
+ verbs.renderThreadList();
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index b7b12e4..f3f64c0 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -136,11 +136,12 @@ import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI-
import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage } from "/static/apps/chat.js";
import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js";
import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
+import { editGroup, openOrCreateDM, showMembers, viewProfile, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js";
import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md)
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
-import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
+import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
import { openLightbox } from "/static/lightbox.js";
import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js";
@@ -187,6 +188,8 @@ Object.assign(verbs, {
paintThreadHeader, modal, toast, displayName, threadAvatarFor,
announceJoin, announceLeave, announceRemove, sendChatText,
loadBlobBytes, chatAppFor, chatAppLoadOlder, stashAppPane,
+ createThread, existingDM, resolveRename, showList, paintHeaderAvatar,
+ loadTiers, setNickname, syncThreadKeysToSW,
});
onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change
@@ -807,7 +810,6 @@ const app = document.getElementById("app");
// app draws its own sidebar, title bar and composer. The user *private* key is
// never handed to the webview: native unwraps the thread key and passes only it,
// so a single thread's page can't reach any other thread's content.
-const embed = window.__lchatEmbed || null;
setSession(embed ? { token: embed.token, name: embed.name } : loadSession());
// Post a message back to the native shell's "lchat" handler (no-op in a browser).
@@ -860,7 +862,6 @@ window.__lchatMenuPick = (token, id) => {
};
// App state.
-const threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new members
const threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thread
// A thread's chosen VIEW. "chat" (a DM-style conversation) is always the default
// view; an app-thread also offers the "interface" view (its interactive UI). More
@@ -878,15 +879,12 @@ const previewPayload = {};
// session that minted them — the secret never reaches the server, so it can't be
// recovered after a reload (the pending chat offers "re-send" to mint a fresh one).
const inviteLinks = {};
-const unread = {}; // thread id -> bool
// thread id -> read watermark (the newest message time we've read, in server
// seconds). Synced from the server so "read on one device" clears the dot on
// the others; advanced locally first so the UI never waits on the POST.
const readMarks = {};
-const hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server
const pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
const muted = {}; // thread id -> bool: muted (no notifications), synced from the server
-const dmClosed = {}; // thread id -> bool: DM ended by either party (server-enforced)
let chatFilter = ""; // lowercased substring filtering the thread list (search box)
// The funnel menu beside search: online-only plus category checkboxes. All
// categories on + online off = the unfiltered default (funnel unhighlighted).
@@ -7135,1079 +7133,6 @@ function connectWS() {
};
}
-// ---- group actions (thread kebab) ---------------------------------------
-
-// Edit group: photo + name in one modal (mirrors editProfile). The group photo
-// rides on the thread key (member-readable, server-blind), like its messages.
-// The explicit name stored for a group (decrypted from name_blob), or "" when
-// none is set — distinct from the display title, which falls back to the member
-// names. The edit field shows THIS, so a nameless group reads as blank (and
-// keeps auto-generating) rather than looking like its members are its name.
-async function storedGroupName(t) {
- if (t && t.kind === "group" && t.name_blob && threadKeys[t.id]) {
- try {
- return (await decrypt(threadKeys[t.id], t.name_blob)).name || "";
- } catch {
- return "";
- }
- }
- return "";
-}
-
-async function editGroup() {
- const t = threads.find((x) => x.id === activeId);
- if (!t || t.kind !== "group") return;
- const id = activeId;
- // The auto-title (member names) — shown as the field's placeholder, and the
- // display name to fall back to if the explicit name is cleared.
- const autoName = autoGroupTitle((t.members || []).filter((x) => x !== myUserId).map(rosterLabel));
- const stored = await storedGroupName(t);
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Edit group";
- card.appendChild(h);
-
- const loader = () => threadAvatarFor(id);
- const av = avatarEl(threadName[id] || "?", loader);
- av.classList.add("avatar-lg");
- const photoBtn = document.createElement("button");
- photoBtn.className = "linkish";
- photoBtn.type = "button";
- photoBtn.textContent = "Change photo";
- const photoInput = document.createElement("input");
- photoInput.type = "file";
- photoInput.accept = "image/*";
- photoInput.style.display = "none";
- photoBtn.onclick = () => photoInput.click();
- photoInput.onchange = async () => {
- const f = photoInput.files[0];
- photoInput.value = "";
- if (!f) return;
- photoBtn.disabled = true;
- photoBtn.textContent = "Uploading…";
- try {
- const { bytes } = await processImage(f, 400);
- if (bytes.length > MAX_UPLOAD) throw new Error("too big");
- const enc = await encryptBytes(threadKeys[id], bytes);
- const up = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc });
- if (!up.ok) throw new Error("upload");
- const { id: blobId } = await up.json();
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/avatar`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, blob_id: blobId }),
- });
- if (!r.ok) throw new Error("server");
- t.avatar = blobId;
- delete avatarURLs["t:" + id];
- paintAvatar(av, threadName[id] || "?", loader);
- if (activeId === id) paintHeaderAvatar(id);
- renderThreadList();
- photoBtn.textContent = "Change photo";
- } catch (e) {
- console.error(e);
- photoBtn.textContent = "Couldn't upload — try again";
- } finally {
- photoBtn.disabled = false;
- }
- };
- const avWrap = document.createElement("div");
- avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
- avWrap.appendChild(av);
- avWrap.appendChild(document.createElement("br"));
- avWrap.appendChild(photoBtn);
- avWrap.appendChild(photoInput);
- card.appendChild(avWrap);
-
- const label = document.createElement("label");
- label.className = "field-label";
- label.textContent = "Group name";
- const input = document.createElement("input");
- input.value = stored; // the explicit name, blank if none — not the auto-title
- input.placeholder = autoName; // ghost text previews the auto-generated name
- input.maxLength = 60;
- // A blank field still shows the auto-name's initial on the avatar.
- input.addEventListener("input", () => paintAvatar(av, input.value || autoName || "?", loader));
- card.appendChild(label);
- card.appendChild(input);
-
- // Open/close for discovery: any member may flip it (groups are flat, like
- // rename). Opening ships the group key encrypted under the instance key —
- // once; the server never overwrites a stored open_key — so the choice is
- // only offered when this device holds both keys it needs to mint the blob.
- let openCb = null;
- let defaultCb = null;
- let gateSel = null;
- // First open needs wrap keys on this device; a reopen needs none (the
- // server kept the blob) — so an already-opened group offers the toggle to
- // any member.
- if ((instanceKey && threadKeyB64[id]) || t.open_key) {
- const optRow = (label, descText) => {
- const row = document.createElement("label");
- row.className = "opt-row";
- const cb = document.createElement("input");
- cb.type = "checkbox";
- const txt = document.createElement("span");
- txt.textContent = label;
- const desc = document.createElement("span");
- desc.className = "opt-desc";
- desc.textContent = descText;
- txt.appendChild(desc);
- row.appendChild(cb);
- row.appendChild(txt);
- card.appendChild(row);
- return cb;
- };
- openCb = optRow("Open group", "Anyone in this space can find it under Discover and join.");
- openCb.checked = t.visibility === "open";
- // The level gate: pickable until the group first opens (the open_key is
- // wrapped under the chosen key), then fixed — server-enforced, so the UI
- // states it rather than offering a dead control.
- if (t.open_key) {
- if (t.open_tier) {
- const lvl = tiers.find((x) => x.id === t.open_tier);
- const note = document.createElement("span");
- note.className = "opt-desc";
- note.style.margin = "0 0 6px 30px";
- note.textContent = `Only ${lvl ? lvl.name : "one access level"} can find and join — fixed once a group has opened.`;
- card.appendChild(note);
- }
- } else {
- const myLevels = tiers.filter((x) => x.mine && tierKeyB64[x.id]);
- if (myLevels.length) {
- gateSel = document.createElement("select");
- gateSel.className = "sheet-input";
- gateSel.setAttribute("aria-label", "Who can find and join");
- const all = document.createElement("option");
- all.value = "";
- all.textContent = "Everyone in this space";
- gateSel.appendChild(all);
- for (const x of myLevels) {
- const o = document.createElement("option");
- o.value = x.id;
- o.textContent = `Only ${x.name}`;
- gateSel.appendChild(o);
- }
- gateSel.style.display = openCb.checked ? "" : "none";
- openCb.addEventListener("change", () => {
- gateSel.style.display = openCb.checked ? "" : "none";
- });
- card.appendChild(gateSel);
- }
- }
- // Default groups are space policy (who lands where on arrival), so the
- // choice is admin-only — and only meaningful while the group is open.
- const meEntry = roster.find((e) => e.user_id === myUserId);
- if (meEntry && meEntry.role === "admin" && !t.open_tier) {
- defaultCb = optRow(
- "New members join automatically",
- "Everyone who joins the space starts in this group. Nobody already here is added.",
- );
- defaultCb.checked = !!t.default_join;
- const syncDefault = () => {
- defaultCb.disabled = !openCb.checked;
- if (!openCb.checked) defaultCb.checked = false;
- };
- syncDefault();
- openCb.addEventListener("change", syncDefault);
- }
- }
-
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => backdrop.remove();
- const save = document.createElement("button");
- save.textContent = "Save";
- save.onclick = async () => {
- const next = input.value.trim();
- const flipOpen = openCb && openCb.checked !== (t.visibility === "open");
- const flipDefault = defaultCb && defaultCb.checked !== !!t.default_join;
- if (next !== stored || flipOpen || flipDefault) {
- save.disabled = true;
- try {
- if (next !== stored) {
- // A blank name clears it (name_blob=""), so the group reverts to its
- // auto-generated member-name title.
- const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : "";
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, name_blob }),
- });
- if (!r.ok) throw new Error("server");
- t.name_blob = name_blob;
- threadName[id] = next || autoName;
- if (activeId === id) document.getElementById("thread-title").textContent = threadName[id];
- }
- if (flipOpen) {
- const body = { token: session.token, visibility: openCb.checked ? "open" : "invite" };
- if (openCb.checked) {
- // First open: wrap under the picked gate's key (or the instance
- // key for everyone). Reopen: send NO key — the server kept the
- // original open_key (never overwritten), it only wants the
- // original gate named again. That keeps close/reopen symmetric:
- // any member can do either, wrap keys not required.
- const gate = t.open_key ? t.open_tier || "" : (gateSel && gateSel.value) || "";
- if (!t.open_key) {
- const src = gate ? tierKeys[gate] : instanceKey;
- if (!src) throw new Error("no wrapping key");
- body.open_key = await encrypt(src, { k: threadKeyB64[id] });
- }
- if (gate) body.open_tier = gate;
- }
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/visibility`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(body),
- });
- if (!r.ok) throw new Error("server");
- t.visibility = openCb.checked ? "open" : "";
- if (openCb.checked && !t.open_tier && gateSel && gateSel.value) t.open_tier = gateSel.value;
- if (openCb.checked && !t.open_key) t.open_key = "set"; // gate now fixed; the real blob arrives on the next loadThreads
- }
- if (flipDefault) {
- // After the visibility flip on purpose: making a group open AND
- // default in one save must open it first (the server refuses a
- // default flag on a closed group).
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/default`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, default: defaultCb.checked }),
- });
- if (!r.ok) throw new Error("server");
- t.default_join = defaultCb.checked;
- }
- renderThreadList();
- } catch {
- save.disabled = false;
- save.textContent = "Couldn't save — try again";
- return;
- }
- }
- backdrop.remove();
- };
- foot.appendChild(cancel);
- foot.appendChild(save);
- card.appendChild(foot);
- input.focus();
-}
-
-// Open the DM with someone, creating it on first use — the one "Message them"
-// behaviour behind the profile card, the member sheet, and the mention menu.
-async function openOrCreateDM(uid) {
- const dm = existingDM(uid);
- if (dm) return openThread(dm.id); // already have a DM — reopen it, don't start a second
- try {
- const id = await createThread([uid], { group: false });
- await loadThreads();
- renderThreadList();
- openThread(id);
- } catch (e) {
- // Their DM door is closed (issue #578) — tell the caller so the button
- // that asked can say so inline instead of failing silently.
- if (String(e && e.message).includes("dms closed")) return false;
- console.error(e);
- }
-}
-
-function showMembers() {
- const t = threads.find((x) => x.id === activeId);
- if (!t) return;
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Members";
- card.appendChild(h);
- const isGroup = t.kind === "group";
- const list = document.createElement("div");
- list.className = "pick-list";
- for (const uid of t.members || []) {
- const name = rosterLabel(uid);
- const me = uid === myUserId;
- const row = document.createElement("div");
- row.className = "list-row" + (me ? " static" : "");
-
- // The identity area (avatar + name); tapping a member opens their profile
- // card (the contacts-app convention) — Message lives on the card, so a
- // stray tap no longer starts a DM you didn't want.
- const main = document.createElement(me ? "div" : "button");
- main.className = "member-main";
- main.appendChild(avatarEl(name, () => avatarFor(uid)));
- const lbl = document.createElement("span");
- lbl.style.display = "flex"; // center the name with its badge
- lbl.style.alignItems = "center";
- const lblText = document.createElement("span");
- lblText.style.minWidth = "0"; // keep ellipsis truncation inside the flex line
- lblText.textContent = me ? `${name} (you)` : name;
- lbl.appendChild(lblText);
- // Transparency: a bot in the room stays visibly a bot, not just in the picker.
- const entry = roster.find((x) => x.user_id === uid);
- if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind));
- // Presence beside the name — this sheet is where a mixed room's status
- // lives (the header shows only the people-online count).
- if (entry && presenceLabel(entry.presence)) {
- const st = document.createElement("span");
- st.className = "member-presence";
- setPresenceLine(st, entry.presence);
- lbl.appendChild(st);
- }
- // Name (+badge +presence) with their "about me" line stacked beneath.
- const idcol = document.createElement("span");
- idcol.style.minWidth = "0";
- idcol.appendChild(lbl);
- if (entry && !entry.is_agent && entry.about) {
- const d = document.createElement("span");
- d.className = "helper-desc";
- d.textContent = entry.about;
- idcol.appendChild(d);
- }
- main.appendChild(idcol);
- if (!me)
- main.onclick = () => {
- backdrop.remove();
- viewProfile(uid);
- };
- row.appendChild(main);
-
- // Remove-from-group (groups only, not yourself — use Leave for that).
- if (!me && isGroup) {
- const rm = document.createElement("button");
- rm.className = "member-remove";
- rm.textContent = "Remove";
- rm.setAttribute("aria-label", `Remove ${name}`);
- rm.onclick = () => removeMember(uid, name, backdrop);
- row.appendChild(rm);
- }
- list.appendChild(row);
- }
- card.appendChild(list);
-}
-
-// A member's profile card: photo, name (with the BOT/AI badge for agents), and
-// their "about me" — for agents, the admin's Helpers-catalog blurb. Opened by
-// tapping a DM's header title; groups show the same lines in the member list.
-function viewProfile(uid) {
- const e = roster.find((x) => x.user_id === uid);
- if (!e) return;
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const av = avatarEl(displayNameFor(nicknames[uid], e.label) || "?", () => avatarFor(uid));
- av.classList.add("avatar-lg");
- const avWrap = document.createElement("div");
- avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
- avWrap.appendChild(av);
- card.appendChild(avWrap);
- const name = document.createElement("h2");
- name.style.cssText = "display:flex;align-items:center;justify-content:center";
- const nm = document.createElement("span");
- nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
- name.appendChild(nm);
- if (e.is_agent) name.appendChild(agentTag(false, e.kind));
- card.appendChild(name);
-
- // Per-viewer names (#577), people only: when I call them something of my
- // own, their published name shows beneath; a fresh rename of theirs offers
- // the keep/adopt choice; and "Change the name you see" edits my name for
- // them (clearing it follows their published name again).
- if (!e.is_agent && uid !== myUserId && e.public_key) {
- const nickArea = document.createElement("div");
- nickArea.className = "nick-area";
- // The card's name/notice lines change under AT users' feet as they choose —
- // a polite live region announces the outcome without stealing focus.
- nickArea.setAttribute("role", "status");
- card.appendChild(nickArea);
- // synced=false appends an inline "still syncing" line — a failed save is
- // applied locally and retries, but must never read as a clean success.
- const paintNick = (focusEdit, synced = true) => {
- nickArea.textContent = "";
- nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
- const nick = nicknames[uid];
- const notice = renameNotice(nick, e.label);
- if (notice) {
- const row = document.createElement("p");
- row.className = "rename-note";
- row.appendChild(renameLine(notice));
- const choose = async (keepMine) => paintNick(true, await resolveRename(uid, keepMine));
- for (const b of renameChoices(notice, choose)) row.appendChild(b);
- nickArea.appendChild(row);
- } else if (nick && nick.name && e.label && e.label !== nick.name) {
- const p = document.createElement("p");
- p.className = "sub";
- p.style.margin = "0";
- p.textContent = `Goes by ${e.label}`;
- nickArea.appendChild(p);
- }
- const edit = document.createElement("button");
- edit.className = "linkish";
- edit.textContent = "Change the name you see";
- edit.onclick = () => {
- nickArea.textContent = "";
- const input = document.createElement("input");
- input.type = "text";
- input.maxLength = 80;
- input.value = displayNameFor(nick, e.label);
- input.className = "eleven-input nick-input";
- input.placeholder = e.label || "";
- input.setAttribute("aria-label", "Your name for them");
- const row = document.createElement("div");
- row.className = "row";
- const save = document.createElement("button");
- save.textContent = "Save";
- save.onclick = async () => {
- const v = input.value.trim();
- // Typing their published name back = no override to keep.
- paintNick(true, await setNickname(uid, v === e.label ? "" : v));
- };
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => paintNick(true);
- input.onkeydown = (ev) => {
- if (ev.key === "Enter") (ev.preventDefault(), save.onclick());
- // stopPropagation: Escape here cancels the edit, not the whole sheet
- // (modal() closes on a document-level Escape otherwise).
- if (ev.key === "Escape") (ev.stopPropagation(), paintNick(true));
- };
- row.appendChild(save);
- row.appendChild(cancel);
- nickArea.appendChild(input);
- nickArea.appendChild(row);
- input.focus();
- input.select();
- };
- nickArea.appendChild(edit);
- if (!synced) {
- const warn = document.createElement("p");
- warn.className = "sub";
- warn.style.margin = "2px 0 0";
- warn.textContent = "Couldn't reach the server just now — saved here, still syncing.";
- nickArea.appendChild(warn);
- }
- // Save/Cancel/Escape destroy the control that held focus; hand it to the
- // edit button so keyboard focus never drops out of the sheet's Tab trap.
- if (focusEdit) edit.focus();
- };
- paintNick();
- }
-
- const blurb = e.is_agent ? e.description : e.about;
- if (blurb) {
- const p = document.createElement("p");
- p.className = "sub";
- p.style.cssText = "text-align:center;white-space:pre-wrap;margin:8px 0 4px";
- p.textContent = blurb;
- card.appendChild(p);
- }
-
- // The person's access levels, as chips. Level names are plaintext space
- // policy already listed to every member by /api/tiers, so this surfaces
- // what the roster view exposes — no new disclosure. Populated from a fresh
- // fetch (the cache can lag a just-made level); hidden until there's one.
- if (!e.is_agent) {
- const chips = document.createElement("div");
- chips.className = "level-chips";
- card.appendChild(chips);
- loadTiers().then(() => {
- const mine = tiers.filter((tt) => (tt.members || []).includes(uid));
- for (const tt of mine) {
- const chip = document.createElement("span");
- chip.className = "level-chip";
- chip.textContent = tt.name;
- chips.appendChild(chip);
- }
- });
- }
-
- // Message them — the card's action when it was reached from somewhere other
- // than their DM (the member sheet, a mention). Inside their DM it's noise —
- // and on someone you've blocked it's a contradiction (Unblock is below).
- const dm = existingDM(uid);
- if (uid !== myUserId && !(dm && dm.id === activeId) && !blockedPeople.has(uid)) {
- const row = document.createElement("div");
- row.className = "row";
- row.style.cssText = "justify-content:center;margin-top:14px";
- const msg = document.createElement("button");
- msg.textContent = `Message ${displayNameFor(nicknames[uid], e.label) || "them"}`;
- msg.onclick = async () => {
- // Their DM door may be closed (issue #578) — say so on the button
- // itself instead of closing the card over a silent failure.
- msg.disabled = true;
- const refused = (await openOrCreateDM(uid)) === false;
- if (refused) {
- msg.textContent = "They're not accepting messages";
- return;
- }
- backdrop.remove();
- };
- row.appendChild(msg);
- card.appendChild(row);
- }
-
- // Mute them (issue #578) — their messages in shared chats fold to a quiet
- // stub and never notify. Private: they don't find out, and the server can't
- // read the muted set either. Honest copy: they stop appearing to YOU.
- if (uid !== myUserId) {
- const modRow = document.createElement("div");
- modRow.className = "row";
- modRow.style.cssText = "justify-content:center;margin-top:10px";
- const muteBtn = document.createElement("button");
- muteBtn.className = "secondary";
- const paintMute = () => {
- muteBtn.textContent = mutedPeople.has(uid)
- ? `Unmute ${displayNameFor(nicknames[uid], e.label) || "them"}`
- : `Mute ${displayNameFor(nicknames[uid], e.label) || "them"}`;
- };
- paintMute();
- muteBtn.onclick = () => {
- setPersonMuted(uid, !mutedPeople.has(uid));
- paintMute();
- };
- modRow.appendChild(muteBtn);
- // Report them (issue #578) — the same sheet a message long-press opens,
- // minus the quoted message.
- if (!e.is_agent) {
- const reportBtn = document.createElement("button");
- reportBtn.className = "secondary";
- reportBtn.textContent = `Report ${displayNameFor(nicknames[uid], e.label) || "them"}`;
- reportBtn.onclick = () => {
- backdrop.remove();
- openReportSheet({ uid });
- };
- modRow.appendChild(reportBtn);
- }
- card.appendChild(modRow);
-
- // Block (issue #578): mute + end the DM + server-enforced no new contact
- // — no new DMs, no adding each other to groups. They're never told.
- if (!e.is_agent) {
- const blockRow = document.createElement("div");
- blockRow.className = "row";
- blockRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px";
- const blockBtn = document.createElement("button");
- const blockNote = document.createElement("div");
- blockNote.className = "field-note";
- blockNote.style.textAlign = "center";
- const paintBlock = () => {
- const on = blockedPeople.has(uid);
- blockBtn.className = on ? "secondary" : "danger";
- blockBtn.textContent = on
- ? `Unblock ${displayNameFor(nicknames[uid], e.label) || "them"}`
- : `Block ${displayNameFor(nicknames[uid], e.label) || "them"}`;
- blockNote.textContent = on
- ? "Unblocking makes contact possible again — nothing reopens by itself."
- : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told.";
- };
- paintBlock();
- blockBtn.onclick = async () => {
- blockBtn.disabled = true;
- const ok = await setPersonBlocked(uid, !blockedPeople.has(uid));
- blockBtn.disabled = false;
- if (!ok) {
- blockNote.textContent = "Couldn't save — try again.";
- return;
- }
- paintBlock();
- };
- blockRow.append(blockBtn, blockNote);
- card.appendChild(blockRow);
- }
- }
-}
-
-// A DM partner renamed themself while I hold my own name for them (#577): a
-// slim inline row between the header and the feed — occupying layout space,
-// never floating — offering the choice once. Clears itself when there's
-// nothing to ask (no nickname, acknowledged, or not a DM; the person's profile
-// card — today opened from your DM's header — shows the same choice).
-function updateRenameBanner(t) {
- // Never inside a native webview: the rename prompt is native chrome's job
- // there (RenameBanner.swift), and the embed can't persist a choice anyway
- // (no userPriv — a tap would fake-succeed and desync from native).
- if (embed) return;
- const view = document.getElementById("thread-view");
- if (!view) return;
- let bar = document.getElementById("rename-banner");
- let notice = null;
- let other = null;
- if (t && t.kind === "dm") {
- other = (t.members || []).find((uid) => uid !== myUserId);
- const e = roster.find((x) => x.user_id === other);
- notice = renameNotice(nicknames[other], e && e.label);
- }
- if (!notice) {
- if (bar) bar.remove();
- return;
- }
- if (!bar) {
- bar = document.createElement("div");
- bar.id = "rename-banner";
- bar.className = "rename-banner";
- // A polite live region: the row appears mid-conversation, so AT users are
- // told the choice exists without focus being stolen from the composer.
- bar.setAttribute("role", "status");
- view.insertBefore(bar, document.getElementById("app-surface"));
- }
- bar.textContent = "";
- bar.appendChild(renameLine(notice));
- // Choosing removes the bar (and the focused button with it) — hand focus to
- // the composer so keyboard users aren't dropped onto <body>.
- const choose = async (keepMine) => {
- await resolveRename(other, keepMine);
- const box = view.querySelector(".chatapp-root .composer .composer-input");
- if (box) box.focus();
- };
- for (const b of renameChoices(notice, choose)) bar.appendChild(b);
-}
-
-// "<from> now goes by <to>" with the two NAMES carrying the weight — the
-// emphasis encodes which words matter; the sentence around them stays plain.
-function renameLine(notice) {
- const msg = document.createElement("span");
- msg.className = "rename-msg";
- const from = document.createElement("span");
- from.className = "rename-name";
- from.textContent = notice.from;
- const to = document.createElement("span");
- to.className = "rename-name";
- to.textContent = notice.to;
- msg.appendChild(from);
- msg.appendChild(document.createTextNode(" now goes by "));
- msg.appendChild(to);
- return msg;
-}
-
-// The keep/adopt pair, in the app's one-tap-decision shape (action chips).
-// choose(keepMine) resolves; both surfaces (banner + profile card) share this.
-function renameChoices(notice, choose) {
- const use = document.createElement("button");
- use.className = "action-chip";
- use.textContent = `Use ${notice.to}`;
- use.onclick = () => choose(false);
- const keep = document.createElement("button");
- keep.className = "action-chip";
- keep.textContent = `Keep ${notice.from}`;
- keep.onclick = () => choose(true);
- return [use, keep];
-}
-
-// The group info incl. the space description — plaintext branding-adjacent
-// metadata like the admin name/email, member-gated on /api/group. On a sealed
-// instance the readable values come from the decrypted branding blob.
-async function fetchSpaceInfo() {
- const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
- if (g.sealed && sealedBranding) {
- g.name = sealedBranding.name || "";
- g.admin_name = sealedBranding.admin_name || "";
- g.admin_email = sealedBranding.admin_email || "";
- g.description = sealedBranding.description || "";
- }
- return { g, about: g.description || "" };
-}
-
-// "About this space": the group name, its free-text description, and who runs
-// it. The description is admin-authored, plaintext like the group name — it
-// also greets invitees on the join card. Role-admins edit it here (their
-// device token passes the admin API); it's editable from /admin too.
-async function showSpaceInfo() {
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = document.title; // carries the substituted group name
- card.appendChild(h);
-
- let info = { g: {}, about: "" };
- try {
- info = await fetchSpaceInfo();
- } catch {}
-
- const meEntry = roster.find((x) => x.user_id === myUserId);
- const canEdit = !!(meEntry && meEntry.role === "admin");
-
- if (canEdit) {
- const lbl = document.createElement("label");
- lbl.className = "field-label";
- lbl.textContent = "Description";
- const ta = document.createElement("textarea");
- ta.rows = 4;
- ta.maxLength = 500;
- ta.placeholder = "What is this space? Members and the people they invite can read this.";
- ta.value = info.about;
- card.appendChild(lbl);
- card.appendChild(ta);
- const note = document.createElement("p");
- note.className = "sub";
- card.appendChild(note);
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => backdrop.remove();
- const save = document.createElement("button");
- save.textContent = "Save";
- save.onclick = async () => {
- save.disabled = true;
- try {
- const r = await fetch("/api/group-description", {
- method: "POST",
- headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
- body: JSON.stringify({ description: ta.value.trim().slice(0, 500) }),
- });
- if (!r.ok) throw new Error("save failed");
- backdrop.remove();
- } catch {
- note.textContent = "Couldn't save — try again.";
- save.disabled = false;
- }
- };
- foot.appendChild(cancel);
- foot.appendChild(save);
- card.appendChild(foot);
- } else {
- const p = document.createElement("p");
- p.className = "sub";
- p.style.whiteSpace = "pre-wrap"; // the description keeps its line breaks
- p.textContent = info.about || "No description yet.";
- card.appendChild(p);
- }
-
- // Who runs the space (member-gated fields; empty until the admin sets them).
- const who = info.g.admin_name || "";
- const mail = info.g.admin_email || "";
- if (who || mail) {
- const run = document.createElement("p");
- run.className = "sub";
- run.textContent = "Run by " + (who || mail) + (who && mail ? ` (${mail})` : "");
- card.appendChild(run);
- }
-}
-
-// Remove another member from the current group (any member may; no key rotation —
-// the server just stops delivering to them, like leaving). Confirms first.
-async function removeMember(uid, name, backdrop) {
- const id = activeId;
- const t = threads.find((x) => x.id === id);
- if (!t || t.kind !== "group") return;
- if (!confirm(`Remove ${name} from the group?`)) return;
- try {
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/remove`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, user_id: uid }),
- });
- if (!r.ok) throw new Error("server");
- } catch {
- 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();
- if (activeId === id) paintThreadHeader(id); // update the member count / title
-}
-
-// Close (hide) the open chat: it drops off the list until there's new activity or
-// you find it via search. Per-user, synced across your devices.
-async function closeChat() {
- const id = activeId;
- if (!id) return;
- hidden[id] = true;
- fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, hidden: true }),
- }).catch(() => {});
- setActiveId(null);
- localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
- renderThreadList();
- document.getElementById("thread-view").classList.add("hidden");
- document.getElementById("thread-empty").classList.remove("hidden");
- showList();
-}
-
-// End the open DM for both people (issue #578): it drops off both lists and
-// the server refuses new messages until someone reopens it. Stronger than
-// Close chat (which quietly returns on activity) and explicitly mutual — hence
-// the confirm.
-async function endConversation() {
- const id = activeId;
- const t = threads.find((x) => x.id === id);
- if (!t || t.kind !== "dm") return;
- if (!confirm("End this conversation? It closes for both of you, and no new messages can be sent unless someone reopens it.")) return;
- try {
- const r = await fetch(`/api/threads/${encodeURIComponent(id)}/close-dm`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token }),
- });
- if (!r.ok) throw new Error("server");
- } catch {
- return; // nothing changed server-side; leave the chat as it was
- }
- dmClosed[id] = true;
- hidden[id] = true;
- setActiveId(null);
- localStorage.removeItem(ACTIVE_KEY);
- renderThreadList();
- document.getElementById("thread-view").classList.add("hidden");
- document.getElementById("thread-empty").classList.remove("hidden");
- showList();
-}
-
-// Mute/unmute the open chat: no notifications and no unread dot while muted;
-// messages still arrive normally. Per-user, synced across your devices — the
-// server just skips pushing to you for this thread.
-function toggleMute() {
- const id = activeId;
- if (!id) return;
- muted[id] = !muted[id];
- fetch(`/api/threads/${encodeURIComponent(id)}/muted`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, muted: muted[id] }),
- }).catch(() => {});
- document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
- renderThreadList();
- syncThreadKeysToSW(); // the SW's own muted check (belt to the server's braces)
-}
-
-// Pin/unpin the open chat: pinned chats float to the top of the list. Per-user,
-// synced across your devices.
-function togglePin() {
- const id = activeId;
- if (!id) return;
- pinned[id] = !pinned[id];
- fetch(`/api/threads/${encodeURIComponent(id)}/pinned`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token, pinned: pinned[id] }),
- }).catch(() => {});
- document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
- renderThreadList();
-}
-
-// addPeople adds joined instance members (not already in the group) by wrapping
-// the group key to each of their roster public keys.
-function addPeople() {
- const t = threads.find((x) => x.id === activeId);
- if (!t || t.kind !== "group") return;
- const here = new Set(t.members || []);
- // Joined connections only (keyless = pending invitee, no pubkey to wrap to).
- const candidates = roster.filter((e) => !here.has(e.user_id) && e.public_key);
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Add people";
- card.appendChild(h);
- if (!candidates.length) {
- const p = document.createElement("p");
- p.className = "sub";
- p.textContent = "Everyone who's joined is already in this group.";
- card.appendChild(p);
- return;
- }
-
- const filter = document.createElement("input");
- filter.placeholder = "Search people";
- filter.className = "sheet-input";
- card.appendChild(filter);
- const list = document.createElement("div");
- list.className = "pick-list";
- card.appendChild(list);
-
- const selected = new Set();
- const add = document.createElement("button");
- const renderList = () => {
- const q = filter.value.trim().toLowerCase();
- list.innerHTML = "";
- const matches = candidates.filter((x) => rosterMatches(x, q));
- const addRow = (e) => {
- const row = document.createElement("label");
- row.className = "list-row";
- const cb = document.createElement("input");
- cb.type = "checkbox";
- cb.checked = selected.has(e.user_id);
- cb.onchange = () => {
- cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
- add.disabled = selected.size === 0;
- };
- row.appendChild(cb);
- row.appendChild(pickerName(e));
- list.appendChild(row);
- };
- // Same People/Helpers split as the new-chat picker: adding a bot to a group
- // is a distinct, visible act — never something buried among the people.
- // And the same bots gate: off hides the helpers here too.
- const people = matches.filter((e) => !e.is_agent);
- const helpers = ENABLE_BOTS ? matches.filter((e) => e.is_agent) : [];
- if (people.length && helpers.length) list.appendChild(pickerSection("People"));
- for (const e of people) addRow(e);
- if (helpers.length) list.appendChild(pickerSection("Helpers"));
- for (const e of helpers) addRow(e);
- };
- filter.addEventListener("input", renderList);
- renderList();
-
- const foot = document.createElement("div");
- foot.className = "sheet-foot";
- const cancel = document.createElement("button");
- cancel.className = "secondary";
- cancel.textContent = "Cancel";
- cancel.onclick = () => backdrop.remove();
- add.textContent = "Add";
- add.disabled = true;
- add.onclick = async () => {
- 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);
- const r = await fetch(`/api/threads/${encodeURIComponent(activeId)}/members`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- 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();
- if (activeId) paintThreadHeader(activeId); // refresh the member count / name
- } catch (e) {
- console.error(e);
- add.disabled = false;
- add.textContent = "Couldn't add — try again";
- }
- };
- foot.appendChild(cancel);
- foot.appendChild(add);
- card.appendChild(foot);
-}
-
-// manageGroupApps adds or removes a trusted first-party app in the open group
-// (⋮ → Add apps): a sheet of the group-capable apps, each with an Add / Remove
-// action. The choice is an EVENT in the group's encrypted log (groupapps.add /
-// .remove) — every member folds it, so the header widget bar appears for the
-// whole group and the act shows as an action line in the conversation. The
-// server never learns which app was added, or that one was. Any member can add
-// or remove, like adding people.
-function manageGroupApps() {
- const t = threads.find((x) => x.id === activeId);
- if (!t || t.kind !== "group") return;
- const tid = activeId;
- const backdrop = modal();
- const card = backdrop.querySelector(".sheet");
- const h = document.createElement("h2");
- h.textContent = "Apps";
- card.appendChild(h);
- const sub = document.createElement("p");
- sub.className = "sub";
- sub.textContent = "An app added here is shared — everyone in the group sees it beside the chat.";
- card.appendChild(sub);
- const candidates = groupCapableApps();
- if (!candidates.length) {
- const p = document.createElement("p");
- p.className = "sub";
- p.textContent = "No apps can be added to groups yet.";
- card.appendChild(p);
- return;
- }
- const added = new Set(groupAppsFor(tid).map((a) => a.name));
- const list = document.createElement("div");
- list.className = "pick-list";
- card.appendChild(list);
- for (const app of candidates) {
- const row = document.createElement("div");
- row.className = "list-row static";
- const av = document.createElement("span");
- av.className = "avatar app-avatar";
- appGlyphInto(av, app);
- const body = document.createElement("span");
- body.style.cssText = "flex:1;min-width:0;";
- const nm = document.createElement("span");
- nm.textContent = app.title || app.name;
- body.appendChild(nm);
- if (app.blurb) {
- const bl = document.createElement("span");
- bl.className = "helper-desc";
- bl.textContent = app.blurb;
- body.appendChild(bl);
- }
- const btn = document.createElement("button");
- const isAdded = added.has(app.name);
- btn.textContent = isAdded ? "Remove" : "Add";
- if (isAdded) btn.className = "secondary";
- btn.onclick = async () => {
- btn.disabled = true;
- const ok = await emitAppEvent(tid, { type: isAdded ? "groupapps.remove" : "groupapps.add", app: app.name });
- if (!ok) {
- // Socket down — nothing sent; let them retry rather than lie.
- btn.disabled = false;
- btn.textContent = "Couldn't send — try again";
- return;
- }
- // The echo folds the change for everyone (us included) and refreshes the
- // widget bar live — the sheet's job is done.
- backdrop.remove();
- };
- row.append(av, body, btn);
- list.appendChild(row);
- }
-}
-
-// 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() {
- // 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[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(id)}/leave`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ token: session.token }),
- });
- if (!r.ok) throw new Error("server");
- } catch {
- return alert("Couldn't leave the group. Try again.");
- }
- setThreads(threads.filter((x) => x.id !== id));
- delete threadKeys[id];
- storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457)
- searchIdx.dropThread(id); // …and stops matching in search (#586)
- if (activeId === id) {
- setActiveId(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();
-}
-
// ---- new chat (roster picker) -------------------------------------------
// The existing DM thread with a person, if any. There's exactly one DM per pair
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index d8af2f8..bb6e8f5 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -6,6 +6,7 @@
// the setters are: assignment, no logic. Shared *behaviour* does not belong
// here; helpers keep moving into their own modules.
+export const embed = window.__lchatEmbed || null; // native-webview embed config, or null in a browser
export let session = null; // { token, name } — set at boot (embed or localStorage)
export let roster = []; // [{user_id, label, public_key}]
export let myUserId = "";
@@ -17,6 +18,10 @@ export let nicknames = {}; // uid -> the name I call them (per-viewer names, #57
export let threadName = {}; // thread id -> decrypted display name
export let instanceKey = null; // the all-members AES key (profile photos), or null until granted
export let instanceKeyB64 = null;
+export let threadKeyB64 = {}; // thread id -> raw key (base64url), to wrap for new members
+export let unread = {}; // thread id -> bool
+export let hidden = {}; // thread id -> bool: closed (hidden from the list), synced from the server
+export let dmClosed = {}; // thread id -> bool: DM ended by either party (server-enforced)
export function setSession(v) { session = v; }
export function setRoster(v) { roster = v; }