// 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, ENABLE_BOTS, ENABLE_MEMBER_APPS, PLACES_UI, ACTIVE_KEY, MAX_UPLOAD, tiers, tierKeys, tierKeyB64, pinned, muted, sealedBranding } from "/static/shellstate.js"; import { wrapThreadKey, decrypt, encrypt, encryptBytes } from "/static/crypto.js"; import { processImage } from "/static/media.js"; import { avatarFor, threadAvatarFor, paintAvatar, avatarEl, avatarURLs } from "/static/avatars.js"; import { searchIdx } from "/static/searchui.js"; import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; import { emitAppEvent, appGlyphInto, groupAppsFor, listedApps, spaceAppsFor, spaceAppInfo, installSpaceApp, installGroupApp, spaceThreadId } 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"; // Enter in the name field submits, like any rename dialog (blur first so the // trimmed value is read). input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); save.click(); } }); 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); } // The admin rungs (issue #578): the same Remove / Remove & ban the Admin // app offers on a report, reachable straight from the card — an admin // shouldn't need a report on file to act on a duplicate or unwanted // account. Showing them here is a nicety only; the server re-checks the // admin role on both endpoints. People only — bots are managed from the // admin console. if (!e.is_agent && verbs.isAdmin()) { const who = displayNameFor(nicknames[uid], e.label) || "them"; const adminRow = document.createElement("div"); adminRow.className = "row"; adminRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px"; const adminNote = document.createElement("div"); adminNote.className = "field-note"; adminNote.style.textAlign = "center"; // A polite live region so a failure is announced without moving focus. adminNote.setAttribute("role", "status"); adminNote.textContent = "Removing deletes their messages and access. Banning also stops them rejoining with any invite."; const removeBtn = document.createElement("button"); removeBtn.className = "danger"; removeBtn.textContent = `Remove ${who}`; // The dialog carries the whole-space warning; closing this card on // success leaves nothing showing a member who no longer exists. removeBtn.onclick = () => confirmRemoveFromSpace(uid, () => backdrop.remove()); const banBtn = document.createElement("button"); banBtn.className = "danger"; banBtn.textContent = `Remove & ban ${who}`; banBtn.onclick = async () => { if (!confirm(`Ban ${who}? They're removed from the whole space and can't rejoin with any invite. This can't be undone.`)) return; banBtn.disabled = removeBtn.disabled = true; if (await verbs.banFromSpace(uid)) { // Same husk cleanup as the remove dialog: close your DM with them. const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid)); if (dm) closeThread(dm.id); // Gone from the roster (the verb reloaded it) — repaint the list // and any open thread so their rows/bubbles fold away. backdrop.remove(); verbs.renderThreadList(); verbs.renderActiveSurfaces(); return; } banBtn.disabled = removeBtn.disabled = false; adminNote.textContent = "Couldn't ban them — try again."; }; adminRow.append(removeBtn, banBtn, adminNote); card.appendChild(adminRow); } } } // The remove-from-space dialog (issue #578): the heading names the person AND // the space, so an admin sees the blast radius before confirming — this is // whole-space removal, not leaving one chat. Opened from the DM kebab's // "Remove " and the profile card's admin rung. document.title is the // one runtime source of the space's name (shell.js paintSpaceName). onDone // runs after a successful removal — the profile card passes its own close. export function confirmRemoveFromSpace(uid, onDone) { const e = roster.find((x) => x.user_id === uid); if (!e) return; const who = displayNameFor(nicknames[uid], e.label) || "them"; const backdrop = verbs.modal(); const card = backdrop.querySelector(".sheet"); const h = document.createElement("h2"); h.textContent = `Remove ${who} from ${document.title || "this space"}`; card.appendChild(h); const p = document.createElement("p"); p.className = "sub"; p.textContent = "This completely removes them from the whole space — every chat, their messages, and their access. It can't be undone."; card.appendChild(p); const note = document.createElement("div"); note.className = "field-note"; note.style.textAlign = "center"; // A polite live region so a failure is announced without moving focus. note.setAttribute("role", "status"); const row = document.createElement("div"); row.className = "row"; row.style.cssText = "justify-content:center;gap:10px;margin-top:12px"; const go = document.createElement("button"); go.className = "danger"; go.textContent = `Remove ${who}`; const cancel = document.createElement("button"); cancel.className = "secondary"; cancel.textContent = "Cancel"; cancel.onclick = () => backdrop.remove(); go.onclick = async () => { go.disabled = cancel.disabled = true; if (await verbs.removeFromSpace(uid)) { // Your DM with them (if any) is a husk now — close it like Close chat, // so it drops off the list (and off screen) instead of lingering // headed by a ghost. const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid)); if (dm) closeThread(dm.id); // Gone from the roster (the verb reloaded it) — repaint the list and // any open thread so their rows/bubbles fold away. backdrop.remove(); verbs.renderThreadList(); verbs.renderActiveSurfaces(); if (onDone) onDone(); return; } go.disabled = cancel.disabled = false; note.textContent = "Couldn't remove them — try again."; }; row.append(go, cancel); card.append(row, note); } // 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 . 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); } // " now goes by " 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) a chat by id: it drops off the list until there's new activity // or you find it via search. Per-user, synced across your devices. Leaves the // pane only when the closed chat is the one on screen — remove-from-space // closes a DM that may not be active. export function closeThread(id) { 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(() => {}); if (activeId === id) { setActiveId(null); localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh document.getElementById("thread-view").classList.add("hidden"); document.getElementById("thread-empty").classList.remove("hidden"); verbs.showList(); } verbs.renderThreadList(); } // Close the open chat (the kebab's Close chat item). export async function closeChat() { if (activeId) closeThread(activeId); } // 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); } // manageChatApps — the places-UI-off Apps sheet (the default; docs/ // space-apps.md § The pullback): ONE job, in the pre-places shape — add a // shared copy of an app to THIS group chat, or remove it. Personal copies // live in the + picker; the space place is parked. Adds/removes are the same // groupapps.* events the full directory emits — every member folds them; the // server never learns which app was chosen, or that one was. function manageChatApps(chat) { if (!chat || chat.kind !== "group") return; // the kebab item only shows in groups const tid = chat.id; 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 = "A shared copy for everyone in this chat."; card.appendChild(sub); const candidates = listedApps().filter((a) => a.places.includes("chat") && (ENABLE_MEMBER_APPS || a.name !== "appstudio")); if (!candidates.length) { const p = document.createElement("p"); p.className = "sub"; p.textContent = "No apps can be added here yet."; card.appendChild(p); return; } const list = document.createElement("div"); list.className = "pick-list"; card.appendChild(list); for (const app of candidates) { const title = app.title || app.name; 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 = title; // member-app titles are user content — textContent body.appendChild(nm); if (app.blurb) { const bl = document.createElement("span"); bl.className = "helper-desc"; bl.textContent = app.blurb; body.appendChild(bl); } const added = groupAppsFor(tid).some((a) => a.name === app.name); const btn = document.createElement("button"); btn.type = "button"; btn.textContent = added ? "Remove" : "Add"; if (added) btn.className = "secondary"; btn.setAttribute("aria-label", `${added ? "Remove" : "Add"} ${title} ${added ? "from" : "to"} this chat`); btn.onclick = async () => { btn.disabled = true; // Truthy = the event was sent; the echo folds the change for everyone // (the widget bar + action line are the visible record) — done here. const ok = added ? await emitAppEvent(tid, { type: "groupapps.remove", app: app.name }) : await installGroupApp(tid, app); if (ok) { backdrop.remove(); return; } btn.disabled = false; btn.textContent = "Couldn't send — try again"; }; row.append(av, body, btn); list.appendChild(row); } } // manageGroupApps — the Apps directory sheet (⋮ → Apps in a chat, or the // space menu's Apps with opts.space), redesigned as FOCUS-AND-EXPLAIN: // // 1. A list of every app, each row wearing badges for the places it already // occupies and a "›" go button. // 2. Tapping a row (or its ›) FOCUSES that app: the other rows drop away // (they're a distraction now), the › becomes ✕ (back to the list), and // the row grows the places sentence, three place SELECTORS — Just for // you | This chat | Everyone — and an explanation panel. Selectors name // places; verbs live only on the footer action. The selection starts on // the chat place when this chat could hold the app (that's what you // most likely came for), else on the personal place. // 3. Every button always shows; ones that can't act here are dimmed but // STAY CLICKABLE — tapping any button explains in the panel what that // place means, and a dimmed one explains exactly WHY it isn't available // (the app's own placeNote, an inferred reason for credential-backed // apps, no-chat-behind-this-menu, the admins-only policy…). The sheet's // right-aligned footer always shows the selected place's action (Open / // Add / Remove) — live when it can act, disabled when it can't — so // reading and doing are one motion and nothing just vanishes. // // Adds/removes are EVENTS in the target place's encrypted log (groupapps.* in // this chat's, spaceapps.* in the hidden space thread's) — every member folds // them; the server never learns which app was chosen, or that one was. export function manageGroupApps(opts = {}) { const t = threads.find((x) => x.id === activeId); // The chat context is the conversation OPEN behind the sheet, whichever // door you came through: the chat kebab requires one; the space menu and // the + picker doorway (opts.space) take the open chat too — "This chat" // targets the one you're looking at — and only fall back to the dimmed // no-chat explanation when nothing is open (fresh boot, the space home). const chat = t && (t.kind === "group" || t.kind === "dm") ? t : null; // With the places UI hidden (the default — docs/space-apps.md § The // pullback), ⋮ → Apps does ONE job: add or remove shared copies for THIS // chat. The focus-and-explain directory below stays intact behind // PLACES_UI for experiments. if (!PLACES_UI) return manageChatApps(chat); if (!opts.space && !chat) return; // a chat's kebab needs a chat behind it const isGroup = !!chat && chat.kind === "group"; const tid = chat ? activeId : null; const backdrop = verbs.modal(); const card = backdrop.querySelector(".sheet"); const h = document.createElement("h2"); h.textContent = "Apps"; card.appendChild(h); // The full directory — personal-only apps included, so the sheet can also // answer "why can't I add this one here?". Same member-apps gate as the // picker (newchat.js pickerApps): with member-made apps off, App Studio's // entry point hides — deploys would 403 anyway; deployed apps stay listed. const candidates = listedApps().filter((a) => ENABLE_MEMBER_APPS || a.name !== "appstudio"); if (!candidates.length) { const p = document.createElement("p"); p.className = "sub"; p.textContent = "No apps yet."; card.appendChild(p); return; } const list = document.createElement("div"); list.className = "pick-list"; card.appendChild(list); // The one action footer, shared by whichever app is focused: the selected // place's Open / Add / Remove lands here, right-aligned at the sheet's // bottom — the explanation reads above, the commitment sits below. const foot = document.createElement("div"); foot.className = "sheet-foot place-foot"; card.appendChild(foot); // What each PLACE means for `app`, from here: { ok, text, action }. The // text ALWAYS renders — that's the panel's whole job — saying what the // action does when it's available, or exactly why it isn't right now. The // action ALWAYS carries the would-be label too: an unavailable place still // shows its button in the footer, disabled — the option exists, the panel // says why it can't act, and nothing pretends otherwise. const optionState = (app, opt) => { const title = app.title || app.name; // The builder's own reason an app skips a place, else an inferred one for // credential-backed apps, else the truthful default. const builderWhy = app.placeNote || (app.requiresService ? "Works through your own linked account — each person connects their own, so there's no shared copy to add." : "This app's builder hasn't enabled this place yet."); if (opt === "open") { if (!app.places.includes("you")) return { ok: false, text: builderWhy, action: { label: "Open" } }; return { ok: true, text: `Your own private ${title} — a copy only you can see, in your sidebar.`, action: { label: "Open", run: async () => { const id = await verbs.resolveAppThread(app.name); if (!id) return false; verbs.openThread(id); return true; }, }, }; } if (opt === "chat") { if (!chat) return { ok: false, text: "There's no chat behind this menu — open the chat you want, then ⋮ → Apps there.", action: { label: "Add to this chat" } }; if (!isGroup) return { ok: false, text: "Sharing into a chat works in group chats — this is a direct message.", action: { label: "Add to this chat" } }; if (!app.places.includes("chat")) return { ok: false, text: builderWhy, action: { label: "Add to this chat" } }; if (groupAppsFor(tid).some((a) => a.name === app.name)) { return { ok: true, text: `${title} is already shared in this chat — everyone here uses the same copy.`, action: { label: "Remove from this chat", secondary: true, run: () => emitAppEvent(tid, { type: "groupapps.remove", app: app.name }) }, }; } return { ok: true, text: `One shared ${title} for everyone in this chat — and only this chat. Everyone here sees the same copy, beside the conversation.`, action: { label: "Add to this chat", run: () => installGroupApp(tid, app) }, }; } // opt === "space" if (!app.places.includes("space")) return { ok: false, text: builderWhy, action: { label: "Add for everyone" } }; const inSpace = spaceAppsFor().some((a) => a.name === app.name); const info = inSpace ? spaceAppInfo(app.name) : null; const by = info && info.by ? ` Added for everyone by ${info.by}.` : ""; if (!installPolicyCanAdd()) { return { ok: false, text: (inSpace ? `One copy for the whole space, in everyone's sidebar.${by} ` : "") + "Only admins can add or remove apps for everyone in this space.", // The act a non-admin is being told about — visible, just not theirs. action: inSpace ? { label: "Remove for everyone", secondary: true } : { label: "Add for everyone" }, }; } if (inSpace) { return { ok: true, text: `One copy for the whole space — its commands work in every chat, and it has its own home in everyone's sidebar.${by}`, action: { label: "Remove for everyone", secondary: true, run: () => { const stid = spaceThreadId(); return stid ? emitAppEvent(stid, { type: "spaceapps.remove", app: app.name }) : false; }, }, }; } return { ok: true, text: `One copy for your whole space — its commands work in every chat, and it gets its own home in everyone's sidebar.`, action: { label: "Add for everyone", run: async () => { if (!(await installSpaceApp(app))) return false; // Land in the home you just created — the row shouldn't have to be // discovered. (Command installs via @everyone stay answer-in-place; // this navigation is the sheet's, where adding was the point.) if (verbs.openSpaceApp) verbs.openSpaceApp(app.name); return true; }, }, }; }; // Focus mechanics: entries hide each other; ✕ restores the whole list. const entries = []; for (const app of candidates) { const title = app.title || app.name; const inChat = isGroup && groupAppsFor(tid).some((a) => a.name === app.name); const inSpace = spaceAppsFor().some((a) => a.name === app.name); const info = inSpace ? spaceAppInfo(app.name) : null; const entry = document.createElement("div"); entry.className = "app-entry"; 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 = title; // member-app titles are user content — textContent body.appendChild(nm); for (const [on, text] of [ [inChat, "This chat"], [inSpace, "Everyone"], ]) { if (!on) continue; const b = document.createElement("span"); b.className = "place-badge"; b.textContent = text; body.appendChild(b); } const detailLine = (info && info.by && `Added for everyone by ${info.by}`) || app.blurb; if (detailLine) { const bl = document.createElement("span"); bl.className = "helper-desc"; bl.textContent = detailLine; body.appendChild(bl); } const go = document.createElement("button"); go.type = "button"; go.className = "app-go"; go.textContent = "›"; go.setAttribute("aria-label", `Choose where ${title} goes`); go.setAttribute("aria-expanded", "false"); row.append(av, body, go); const places = document.createElement("div"); places.className = "app-places"; // The grid-collapse animation (see .app-entry CSS) needs a single inner // child to clip — same for the places drawer below. const inner = document.createElement("div"); inner.className = "app-entry-inner"; inner.append(row, places); entry.appendChild(inner); list.appendChild(entry); let focused = false; const buildPlaces = () => { places.textContent = ""; const placesInner = document.createElement("div"); placesInner.className = "app-places-inner"; places.appendChild(placesInner); // The places sentence sits with the choice it explains — under the // focused app's name, above its place buttons ("this chat" only means // something when a chat is behind the sheet). const grammar = document.createElement("p"); grammar.className = "place-grammar"; grammar.textContent = `An app copy lives in a place — just for you, ${chat ? "this chat" : "a chat"}, or everyone in this space.`; const bar = document.createElement("div"); bar.className = "place-options"; bar.setAttribute("role", "group"); bar.setAttribute("aria-label", `Places for ${title}`); const panel = document.createElement("div"); panel.className = "place-panel"; panel.setAttribute("aria-live", "polite"); // the panel narrates each pick const btns = new Map(); const select = (opt) => { const st = optionState(app, opt); for (const [o, b] of btns) b.setAttribute("aria-pressed", String(o === opt)); panel.textContent = ""; const p = document.createElement("p"); p.textContent = st.text; panel.appendChild(p); // The footer always shows the selected place's action — disabled // when the place can't act here, so the button never just vanishes // (the panel above says why it's off). foot.textContent = ""; const act = document.createElement("button"); act.type = "button"; act.textContent = st.action.label; if (st.action.secondary) act.className = "secondary"; if (st.ok && st.action.run) { act.onclick = async () => { act.disabled = true; // Truthy = the event was sent (or the thread opened) — the echo // folds the change for everyone; the sheet's job is done. if (await st.action.run()) { backdrop.remove(); return; } act.disabled = false; act.textContent = "Couldn't send — try again"; }; } else { act.disabled = true; } foot.appendChild(act); }; // The selectors name PLACES, in the words the sentence above and the // row badges already use — the verb lives only on the footer button // that actually performs it (Open / Add … / Remove …). for (const [opt, label, aria] of [ ["open", "Just for you"], ["chat", "This chat"], ["space", "Everyone", "Everyone in this space"], ]) { const b = document.createElement("button"); b.type = "button"; // Dimmed ≠ disabled: a dimmed place still explains itself on tap — // that's the panel's whole point — so it stays a live button. b.className = "place-option" + (optionState(app, opt).ok ? "" : " dim"); b.textContent = label; if (aria) b.setAttribute("aria-label", aria); b.setAttribute("aria-pressed", "false"); b.onclick = () => select(opt); btns.set(opt, b); bar.appendChild(b); } placesInner.append(grammar, bar, panel); // Opened from a chat's own kebab, the chat place is what you most // likely came for — start there. The space menu and picker doorway // start on the personal place (the chat stays one tap away). select(!opts.space && optionState(app, "chat").ok ? "chat" : "open"); }; // Collapse/restore ride CSS transitions (.collapsed / .open — the sheet // shrinks to the chosen app and grows back, no jump cuts). The stale // places content stays through the collapse so it animates at its real // height; the next focus rebuilds it anyway. const restore = () => { focused = false; entry.classList.remove("collapsed"); places.classList.remove("open"); foot.textContent = ""; // the footer belongs to the focused app only go.textContent = "›"; go.setAttribute("aria-label", `Choose where ${title} goes`); go.setAttribute("aria-expanded", "false"); }; const focusThis = () => { for (const e of entries) if (e.entry !== entry) e.hide(); focused = true; go.textContent = "✕"; go.setAttribute("aria-label", "Back to all apps"); go.setAttribute("aria-expanded", "true"); buildPlaces(); // Next frame, so the freshly-filled drawer transitions from 0fr instead // of appearing at full height. requestAnimationFrame(() => places.classList.add("open")); }; const blurAll = () => { for (const e of entries) e.restore(); }; go.onclick = (ev) => { ev.stopPropagation(); focused ? blurAll() : focusThis(); }; row.onclick = () => { if (!focused) focusThis(); }; row.style.cursor = "pointer"; entries.push({ entry, name: app.name, hide: () => entry.classList.add("collapsed"), restore, focus: focusThis }); } // opts.focus pre-focuses one app — the + picker's doorway: an app with no // personal copy yet lands here to choose its place (its birth is a PLACE // decision, never a silent create). The full list stays one ✕ away. if (opts.focus) { const e = entries.find((x) => x.name === opts.focus); if (e) e.focus(); } } // installPolicyCanAdd: may THIS member add/remove space-wide apps right now? // (The sheet's view of the same policy apps.js enforces — see // setSpaceInstallPolicy for the mechanism and its E2EE-honest limits.) function installPolicyCanAdd() { const adminsOnly = ((document.querySelector('meta[name="space-installs-admins"]') || {}).content || "0") === "1"; if (!adminsOnly) return true; const me = (roster || []).find((e) => e.user_id === myUserId); return !!(me && me.role === "admin"); } // 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(); }