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>
4 files changed,
+1101
−1079
CLAUDE.md+3 −0web/static/groupactions.js+1089 −0web/static/shell.js+4 −1079web/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; } |