eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// Group actions — the thread kebab's whole surface: edit group (photo+name),
2// members and profiles, space info, rename notices, mute/pin, add people,
3// group apps, remove member, leave/close/end. Extracted verbatim from
4// shell.js; core behaviours go through the verbs seam (docs/shell-modules.md),
5// state through shellstate.js.
6import { verbs } from "/static/shellverbs.js";
7import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, instanceKey, threadKeyB64, unread, hidden, dmClosed, setNicknames, setActiveId, setThreads, embed, ENABLE_BOTS, ENABLE_MEMBER_APPS, PLACES_UI, ACTIVE_KEY, MAX_UPLOAD, tiers, tierKeys, tierKeyB64, pinned, muted, sealedBranding } from "/static/shellstate.js";
8import { wrapThreadKey, decrypt, encrypt, encryptBytes } from "/static/crypto.js";
9import { processImage } from "/static/media.js";
10import { avatarFor, threadAvatarFor, paintAvatar, avatarEl, avatarURLs } from "/static/avatars.js";
11import { searchIdx } from "/static/searchui.js";
12import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js";
13import { emitAppEvent, appGlyphInto, groupAppsFor, listedApps, spaceAppsFor, spaceAppInfo, installSpaceApp, installGroupApp, spaceThreadId } from "/static/apps.js";
14import { displayNameFor, presenceLabel, renameNotice } from "/static/ui.js";
15import { autoGroupTitle } from "/static/format.js";
16import { clearThread as storeClearThread } from "/static/store.js";
17import { setPersonMuted, setPersonBlocked, openReportSheet, mutedPeople, blockedPeople } from "/static/moderation.js";
18
19// ---- group actions (thread kebab) ---------------------------------------
20
21// Edit group: photo + name in one modal (mirrors editProfile). The group photo
22// rides on the thread key (member-readable, server-blind), like its messages.
23// The explicit name stored for a group (decrypted from name_blob), or "" when
24// none is set — distinct from the display title, which falls back to the member
25// names. The edit field shows THIS, so a nameless group reads as blank (and
26// keeps auto-generating) rather than looking like its members are its name.
27async function storedGroupName(t) {
28 if (t && t.kind === "group" && t.name_blob && threadKeys[t.id]) {
29 try {
30 return (await decrypt(threadKeys[t.id], t.name_blob)).name || "";
31 } catch {
32 return "";
33 }
34 }
35 return "";
36}
37
38export async function editGroup() {
39 const t = threads.find((x) => x.id === activeId);
40 if (!t || t.kind !== "group") return;
41 const id = activeId;
42 // The auto-title (member names) — shown as the field's placeholder, and the
43 // display name to fall back to if the explicit name is cleared.
44 const autoName = autoGroupTitle((t.members || []).filter((x) => x !== myUserId).map(rosterLabel));
45 const stored = await storedGroupName(t);
46 const backdrop = verbs.modal();
47 const card = backdrop.querySelector(".sheet");
48 const h = document.createElement("h2");
49 h.textContent = "Edit group";
50 card.appendChild(h);
51
52 const loader = () => threadAvatarFor(id);
53 const av = avatarEl(threadName[id] || "?", loader);
54 av.classList.add("avatar-lg");
55 const photoBtn = document.createElement("button");
56 photoBtn.className = "linkish";
57 photoBtn.type = "button";
58 photoBtn.textContent = "Change photo";
59 const photoInput = document.createElement("input");
60 photoInput.type = "file";
61 photoInput.accept = "image/*";
62 photoInput.style.display = "none";
63 photoBtn.onclick = () => photoInput.click();
64 photoInput.onchange = async () => {
65 const f = photoInput.files[0];
66 photoInput.value = "";
67 if (!f) return;
68 photoBtn.disabled = true;
69 photoBtn.textContent = "Uploading…";
70 try {
71 const { bytes } = await processImage(f, 400);
72 if (bytes.length > MAX_UPLOAD) throw new Error("too big");
73 const enc = await encryptBytes(threadKeys[id], bytes);
74 const up = await fetch(`/api/upload?token=${encodeURIComponent(session.token)}`, { method: "POST", body: enc });
75 if (!up.ok) throw new Error("upload");
76 const { id: blobId } = await up.json();
77 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/avatar`, {
78 method: "POST",
79 headers: { "Content-Type": "application/json" },
80 body: JSON.stringify({ token: session.token, blob_id: blobId }),
81 });
82 if (!r.ok) throw new Error("server");
83 t.avatar = blobId;
84 delete avatarURLs["t:" + id];
85 paintAvatar(av, threadName[id] || "?", loader);
86 if (activeId === id) verbs.paintHeaderAvatar(id);
87 verbs.renderThreadList();
88 photoBtn.textContent = "Change photo";
89 } catch (e) {
90 console.error(e);
91 photoBtn.textContent = "Couldn't upload — try again";
92 } finally {
93 photoBtn.disabled = false;
94 }
95 };
96 const avWrap = document.createElement("div");
97 avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
98 avWrap.appendChild(av);
99 avWrap.appendChild(document.createElement("br"));
100 avWrap.appendChild(photoBtn);
101 avWrap.appendChild(photoInput);
102 card.appendChild(avWrap);
103
104 const label = document.createElement("label");
105 label.className = "field-label";
106 label.textContent = "Group name";
107 const input = document.createElement("input");
108 input.value = stored; // the explicit name, blank if none — not the auto-title
109 input.placeholder = autoName; // ghost text previews the auto-generated name
110 input.maxLength = 60;
111 // A blank field still shows the auto-name's initial on the avatar.
112 input.addEventListener("input", () => paintAvatar(av, input.value || autoName || "?", loader));
113 card.appendChild(label);
114 card.appendChild(input);
115
116 // Open/close for discovery: any member may flip it (groups are flat, like
117 // rename). Opening ships the group key encrypted under the instance key —
118 // once; the server never overwrites a stored open_key — so the choice is
119 // only offered when this device holds both keys it needs to mint the blob.
120 let openCb = null;
121 let defaultCb = null;
122 let gateSel = null;
123 // First open needs wrap keys on this device; a reopen needs none (the
124 // server kept the blob) — so an already-opened group offers the toggle to
125 // any member.
126 if ((instanceKey && threadKeyB64[id]) || t.open_key) {
127 const optRow = (label, descText) => {
128 const row = document.createElement("label");
129 row.className = "opt-row";
130 const cb = document.createElement("input");
131 cb.type = "checkbox";
132 const txt = document.createElement("span");
133 txt.textContent = label;
134 const desc = document.createElement("span");
135 desc.className = "opt-desc";
136 desc.textContent = descText;
137 txt.appendChild(desc);
138 row.appendChild(cb);
139 row.appendChild(txt);
140 card.appendChild(row);
141 return cb;
142 };
143 openCb = optRow("Open group", "Anyone in this space can find it under Discover and join.");
144 openCb.checked = t.visibility === "open";
145 // The level gate: pickable until the group first opens (the open_key is
146 // wrapped under the chosen key), then fixed — server-enforced, so the UI
147 // states it rather than offering a dead control.
148 if (t.open_key) {
149 if (t.open_tier) {
150 const lvl = tiers.find((x) => x.id === t.open_tier);
151 const note = document.createElement("span");
152 note.className = "opt-desc";
153 note.style.margin = "0 0 6px 30px";
154 note.textContent = `Only ${lvl ? lvl.name : "one access level"} can find and join — fixed once a group has opened.`;
155 card.appendChild(note);
156 }
157 } else {
158 const myLevels = tiers.filter((x) => x.mine && tierKeyB64[x.id]);
159 if (myLevels.length) {
160 gateSel = document.createElement("select");
161 gateSel.className = "sheet-input";
162 gateSel.setAttribute("aria-label", "Who can find and join");
163 const all = document.createElement("option");
164 all.value = "";
165 all.textContent = "Everyone in this space";
166 gateSel.appendChild(all);
167 for (const x of myLevels) {
168 const o = document.createElement("option");
169 o.value = x.id;
170 o.textContent = `Only ${x.name}`;
171 gateSel.appendChild(o);
172 }
173 gateSel.style.display = openCb.checked ? "" : "none";
174 openCb.addEventListener("change", () => {
175 gateSel.style.display = openCb.checked ? "" : "none";
176 });
177 card.appendChild(gateSel);
178 }
179 }
180 // Default groups are space policy (who lands where on arrival), so the
181 // choice is admin-only — and only meaningful while the group is open.
182 const meEntry = roster.find((e) => e.user_id === myUserId);
183 if (meEntry && meEntry.role === "admin" && !t.open_tier) {
184 defaultCb = optRow(
185 "New members join automatically",
186 "Everyone who joins the space starts in this group. Nobody already here is added.",
187 );
188 defaultCb.checked = !!t.default_join;
189 const syncDefault = () => {
190 defaultCb.disabled = !openCb.checked;
191 if (!openCb.checked) defaultCb.checked = false;
192 };
193 syncDefault();
194 openCb.addEventListener("change", syncDefault);
195 }
196 }
197
198 const foot = document.createElement("div");
199 foot.className = "sheet-foot";
200 const cancel = document.createElement("button");
201 cancel.className = "secondary";
202 cancel.textContent = "Cancel";
203 cancel.onclick = () => backdrop.remove();
204 const save = document.createElement("button");
205 save.textContent = "Save";
206 // Enter in the name field submits, like any rename dialog (blur first so the
207 // trimmed value is read).
208 input.addEventListener("keydown", (e) => {
209 if (e.key === "Enter") {
210 e.preventDefault();
211 save.click();
212 }
213 });
214 save.onclick = async () => {
215 const next = input.value.trim();
216 const flipOpen = openCb && openCb.checked !== (t.visibility === "open");
217 const flipDefault = defaultCb && defaultCb.checked !== !!t.default_join;
218 if (next !== stored || flipOpen || flipDefault) {
219 save.disabled = true;
220 try {
221 if (next !== stored) {
222 // A blank name clears it (name_blob=""), so the group reverts to its
223 // auto-generated member-name title.
224 const name_blob = next ? await encrypt(threadKeys[id], { name: next }) : "";
225 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/name`, {
226 method: "POST",
227 headers: { "Content-Type": "application/json" },
228 body: JSON.stringify({ token: session.token, name_blob }),
229 });
230 if (!r.ok) throw new Error("server");
231 t.name_blob = name_blob;
232 threadName[id] = next || autoName;
233 if (activeId === id) document.getElementById("thread-title").textContent = threadName[id];
234 }
235 if (flipOpen) {
236 const body = { token: session.token, visibility: openCb.checked ? "open" : "invite" };
237 if (openCb.checked) {
238 // First open: wrap under the picked gate's key (or the instance
239 // key for everyone). Reopen: send NO key — the server kept the
240 // original open_key (never overwritten), it only wants the
241 // original gate named again. That keeps close/reopen symmetric:
242 // any member can do either, wrap keys not required.
243 const gate = t.open_key ? t.open_tier || "" : (gateSel && gateSel.value) || "";
244 if (!t.open_key) {
245 const src = gate ? tierKeys[gate] : instanceKey;
246 if (!src) throw new Error("no wrapping key");
247 body.open_key = await encrypt(src, { k: threadKeyB64[id] });
248 }
249 if (gate) body.open_tier = gate;
250 }
251 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/visibility`, {
252 method: "POST",
253 headers: { "Content-Type": "application/json" },
254 body: JSON.stringify(body),
255 });
256 if (!r.ok) throw new Error("server");
257 t.visibility = openCb.checked ? "open" : "";
258 if (openCb.checked && !t.open_tier && gateSel && gateSel.value) t.open_tier = gateSel.value;
259 if (openCb.checked && !t.open_key) t.open_key = "set"; // gate now fixed; the real blob arrives on the next loadThreads
260 }
261 if (flipDefault) {
262 // After the visibility flip on purpose: making a group open AND
263 // default in one save must open it first (the server refuses a
264 // default flag on a closed group).
265 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/default`, {
266 method: "POST",
267 headers: { "Content-Type": "application/json" },
268 body: JSON.stringify({ token: session.token, default: defaultCb.checked }),
269 });
270 if (!r.ok) throw new Error("server");
271 t.default_join = defaultCb.checked;
272 }
273 verbs.renderThreadList();
274 } catch {
275 save.disabled = false;
276 save.textContent = "Couldn't save — try again";
277 return;
278 }
279 }
280 backdrop.remove();
281 };
282 foot.appendChild(cancel);
283 foot.appendChild(save);
284 card.appendChild(foot);
285 input.focus();
286}
287
288// Open the DM with someone, creating it on first use — the one "Message them"
289// behaviour behind the profile card, the member sheet, and the mention menu.
290export async function openOrCreateDM(uid) {
291 const dm = verbs.existingDM(uid);
292 if (dm) return verbs.openThread(dm.id); // already have a DM — reopen it, don't start a second
293 try {
294 const id = await verbs.createThread([uid], { group: false });
295 await verbs.loadThreads();
296 verbs.renderThreadList();
297 verbs.openThread(id);
298 } catch (e) {
299 // Their DM door is closed (issue #578) — tell the caller so the button
300 // that asked can say so inline instead of failing silently.
301 if (String(e && e.message).includes("dms closed")) return false;
302 console.error(e);
303 }
304}
305
306export function showMembers() {
307 const t = threads.find((x) => x.id === activeId);
308 if (!t) return;
309 const backdrop = verbs.modal();
310 const card = backdrop.querySelector(".sheet");
311 const h = document.createElement("h2");
312 h.textContent = "Members";
313 card.appendChild(h);
314 const isGroup = t.kind === "group";
315 const list = document.createElement("div");
316 list.className = "pick-list";
317 for (const uid of t.members || []) {
318 const name = rosterLabel(uid);
319 const me = uid === myUserId;
320 const row = document.createElement("div");
321 row.className = "list-row" + (me ? " static" : "");
322
323 // The identity area (avatar + name); tapping a member opens their profile
324 // card (the contacts-app convention) — Message lives on the card, so a
325 // stray tap no longer starts a DM you didn't want.
326 const main = document.createElement(me ? "div" : "button");
327 main.className = "member-main";
328 main.appendChild(avatarEl(name, () => avatarFor(uid)));
329 const lbl = document.createElement("span");
330 lbl.style.display = "flex"; // center the name with its badge
331 lbl.style.alignItems = "center";
332 const lblText = document.createElement("span");
333 lblText.style.minWidth = "0"; // keep ellipsis truncation inside the flex line
334 lblText.textContent = me ? `${name} (you)` : name;
335 lbl.appendChild(lblText);
336 // Transparency: a bot in the room stays visibly a bot, not just in the picker.
337 const entry = roster.find((x) => x.user_id === uid);
338 if (entry && entry.is_agent) lbl.appendChild(agentTag(false, entry.kind));
339 // Presence beside the name — this sheet is where a mixed room's status
340 // lives (the header shows only the people-online count).
341 if (entry && presenceLabel(entry.presence)) {
342 const st = document.createElement("span");
343 st.className = "member-presence";
344 setPresenceLine(st, entry.presence);
345 lbl.appendChild(st);
346 }
347 // Name (+badge +presence) with their "about me" line stacked beneath.
348 const idcol = document.createElement("span");
349 idcol.style.minWidth = "0";
350 idcol.appendChild(lbl);
351 if (entry && !entry.is_agent && entry.about) {
352 const d = document.createElement("span");
353 d.className = "helper-desc";
354 d.textContent = entry.about;
355 idcol.appendChild(d);
356 }
357 main.appendChild(idcol);
358 if (!me)
359 main.onclick = () => {
360 backdrop.remove();
361 viewProfile(uid);
362 };
363 row.appendChild(main);
364
365 // Remove-from-group (groups only, not yourself — use Leave for that).
366 if (!me && isGroup) {
367 const rm = document.createElement("button");
368 rm.className = "member-remove";
369 rm.textContent = "Remove";
370 rm.setAttribute("aria-label", `Remove ${name}`);
371 rm.onclick = () => removeMember(uid, name, backdrop);
372 row.appendChild(rm);
373 }
374 list.appendChild(row);
375 }
376 card.appendChild(list);
377}
378
379// A member's profile card: photo, name (with the BOT/AI badge for agents), and
380// their "about me" — for agents, the admin's Helpers-catalog blurb. Opened by
381// tapping a DM's header title; groups show the same lines in the member list.
382export function viewProfile(uid) {
383 const e = roster.find((x) => x.user_id === uid);
384 if (!e) return;
385 const backdrop = verbs.modal();
386 const card = backdrop.querySelector(".sheet");
387 const av = avatarEl(displayNameFor(nicknames[uid], e.label) || "?", () => avatarFor(uid));
388 av.classList.add("avatar-lg");
389 const avWrap = document.createElement("div");
390 avWrap.style.cssText = "text-align:center;margin:6px 0 12px";
391 avWrap.appendChild(av);
392 card.appendChild(avWrap);
393 const name = document.createElement("h2");
394 name.style.cssText = "display:flex;align-items:center;justify-content:center";
395 const nm = document.createElement("span");
396 nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
397 name.appendChild(nm);
398 if (e.is_agent) name.appendChild(agentTag(false, e.kind));
399 card.appendChild(name);
400
401 // Per-viewer names (#577), people only: when I call them something of my
402 // own, their published name shows beneath; a fresh rename of theirs offers
403 // the keep/adopt choice; and "Change the name you see" edits my name for
404 // them (clearing it follows their published name again).
405 if (!e.is_agent && uid !== myUserId && e.public_key) {
406 const nickArea = document.createElement("div");
407 nickArea.className = "nick-area";
408 // The card's name/notice lines change under AT users' feet as they choose —
409 // a polite live region announces the outcome without stealing focus.
410 nickArea.setAttribute("role", "status");
411 card.appendChild(nickArea);
412 // synced=false appends an inline "still syncing" line — a failed save is
413 // applied locally and retries, but must never read as a clean success.
414 const paintNick = (focusEdit, synced = true) => {
415 nickArea.textContent = "";
416 nm.textContent = displayNameFor(nicknames[uid], e.label) || "Someone";
417 const nick = nicknames[uid];
418 const notice = renameNotice(nick, e.label);
419 if (notice) {
420 const row = document.createElement("p");
421 row.className = "rename-note";
422 row.appendChild(renameLine(notice));
423 const choose = async (keepMine) => paintNick(true, await verbs.resolveRename(uid, keepMine));
424 for (const b of renameChoices(notice, choose)) row.appendChild(b);
425 nickArea.appendChild(row);
426 } else if (nick && nick.name && e.label && e.label !== nick.name) {
427 const p = document.createElement("p");
428 p.className = "sub";
429 p.style.margin = "0";
430 p.textContent = `Goes by ${e.label}`;
431 nickArea.appendChild(p);
432 }
433 const edit = document.createElement("button");
434 edit.className = "linkish";
435 edit.textContent = "Change the name you see";
436 edit.onclick = () => {
437 nickArea.textContent = "";
438 const input = document.createElement("input");
439 input.type = "text";
440 input.maxLength = 80;
441 input.value = displayNameFor(nick, e.label);
442 input.className = "eleven-input nick-input";
443 input.placeholder = e.label || "";
444 input.setAttribute("aria-label", "Your name for them");
445 const row = document.createElement("div");
446 row.className = "row";
447 const save = document.createElement("button");
448 save.textContent = "Save";
449 save.onclick = async () => {
450 const v = input.value.trim();
451 // Typing their published name back = no override to keep.
452 paintNick(true, await verbs.setNickname(uid, v === e.label ? "" : v));
453 };
454 const cancel = document.createElement("button");
455 cancel.className = "secondary";
456 cancel.textContent = "Cancel";
457 cancel.onclick = () => paintNick(true);
458 input.onkeydown = (ev) => {
459 if (ev.key === "Enter") (ev.preventDefault(), save.onclick());
460 // stopPropagation: Escape here cancels the edit, not the whole sheet
461 // (verbs.modal() closes on a document-level Escape otherwise).
462 if (ev.key === "Escape") (ev.stopPropagation(), paintNick(true));
463 };
464 row.appendChild(save);
465 row.appendChild(cancel);
466 nickArea.appendChild(input);
467 nickArea.appendChild(row);
468 input.focus();
469 input.select();
470 };
471 nickArea.appendChild(edit);
472 if (!synced) {
473 const warn = document.createElement("p");
474 warn.className = "sub";
475 warn.style.margin = "2px 0 0";
476 warn.textContent = "Couldn't reach the server just now — saved here, still syncing.";
477 nickArea.appendChild(warn);
478 }
479 // Save/Cancel/Escape destroy the control that held focus; hand it to the
480 // edit button so keyboard focus never drops out of the sheet's Tab trap.
481 if (focusEdit) edit.focus();
482 };
483 paintNick();
484 }
485
486 const blurb = e.is_agent ? e.description : e.about;
487 if (blurb) {
488 const p = document.createElement("p");
489 p.className = "sub";
490 p.style.cssText = "text-align:center;white-space:pre-wrap;margin:8px 0 4px";
491 p.textContent = blurb;
492 card.appendChild(p);
493 }
494
495 // The person's access levels, as chips. Level names are plaintext space
496 // policy already listed to every member by /api/tiers, so this surfaces
497 // what the roster view exposes — no new disclosure. Populated from a fresh
498 // fetch (the cache can lag a just-made level); hidden until there's one.
499 if (!e.is_agent) {
500 const chips = document.createElement("div");
501 chips.className = "level-chips";
502 card.appendChild(chips);
503 verbs.loadTiers().then(() => {
504 const mine = tiers.filter((tt) => (tt.members || []).includes(uid));
505 for (const tt of mine) {
506 const chip = document.createElement("span");
507 chip.className = "level-chip";
508 chip.textContent = tt.name;
509 chips.appendChild(chip);
510 }
511 });
512 }
513
514 // Message them — the card's action when it was reached from somewhere other
515 // than their DM (the member sheet, a mention). Inside their DM it's noise —
516 // and on someone you've blocked it's a contradiction (Unblock is below).
517 const dm = verbs.existingDM(uid);
518 if (uid !== myUserId && !(dm && dm.id === activeId) && !blockedPeople.has(uid)) {
519 const row = document.createElement("div");
520 row.className = "row";
521 row.style.cssText = "justify-content:center;margin-top:14px";
522 const msg = document.createElement("button");
523 msg.textContent = `Message ${displayNameFor(nicknames[uid], e.label) || "them"}`;
524 msg.onclick = async () => {
525 // Their DM door may be closed (issue #578) — say so on the button
526 // itself instead of closing the card over a silent failure.
527 msg.disabled = true;
528 const refused = (await openOrCreateDM(uid)) === false;
529 if (refused) {
530 msg.textContent = "They're not accepting messages";
531 return;
532 }
533 backdrop.remove();
534 };
535 row.appendChild(msg);
536 card.appendChild(row);
537 }
538
539 // Mute them (issue #578) — their messages in shared chats fold to a quiet
540 // stub and never notify. Private: they don't find out, and the server can't
541 // read the muted set either. Honest copy: they stop appearing to YOU.
542 if (uid !== myUserId) {
543 const modRow = document.createElement("div");
544 modRow.className = "row";
545 modRow.style.cssText = "justify-content:center;margin-top:10px";
546 const muteBtn = document.createElement("button");
547 muteBtn.className = "secondary";
548 const paintMute = () => {
549 muteBtn.textContent = mutedPeople.has(uid)
550 ? `Unmute ${displayNameFor(nicknames[uid], e.label) || "them"}`
551 : `Mute ${displayNameFor(nicknames[uid], e.label) || "them"}`;
552 };
553 paintMute();
554 muteBtn.onclick = () => {
555 setPersonMuted(uid, !mutedPeople.has(uid));
556 paintMute();
557 };
558 modRow.appendChild(muteBtn);
559 // Report them (issue #578) — the same sheet a message long-press opens,
560 // minus the quoted message.
561 if (!e.is_agent) {
562 const reportBtn = document.createElement("button");
563 reportBtn.className = "secondary";
564 reportBtn.textContent = `Report ${displayNameFor(nicknames[uid], e.label) || "them"}`;
565 reportBtn.onclick = () => {
566 backdrop.remove();
567 openReportSheet({ uid });
568 };
569 modRow.appendChild(reportBtn);
570 }
571 card.appendChild(modRow);
572
573 // Block (issue #578): mute + end the DM + server-enforced no new contact
574 // — no new DMs, no adding each other to groups. They're never told.
575 if (!e.is_agent) {
576 const blockRow = document.createElement("div");
577 blockRow.className = "row";
578 blockRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px";
579 const blockBtn = document.createElement("button");
580 const blockNote = document.createElement("div");
581 blockNote.className = "field-note";
582 blockNote.style.textAlign = "center";
583 const paintBlock = () => {
584 const on = blockedPeople.has(uid);
585 blockBtn.className = on ? "secondary" : "danger";
586 blockBtn.textContent = on
587 ? `Unblock ${displayNameFor(nicknames[uid], e.label) || "them"}`
588 : `Block ${displayNameFor(nicknames[uid], e.label) || "them"}`;
589 blockNote.textContent = on
590 ? "Unblocking makes contact possible again — nothing reopens by itself."
591 : "Blocking mutes them, ends your direct messages, and stops new contact both ways. They won't be told.";
592 };
593 paintBlock();
594 blockBtn.onclick = async () => {
595 blockBtn.disabled = true;
596 const ok = await setPersonBlocked(uid, !blockedPeople.has(uid));
597 blockBtn.disabled = false;
598 if (!ok) {
599 blockNote.textContent = "Couldn't save — try again.";
600 return;
601 }
602 paintBlock();
603 };
604 blockRow.append(blockBtn, blockNote);
605 card.appendChild(blockRow);
606 }
607
608 // The admin rungs (issue #578): the same Remove / Remove & ban the Admin
609 // app offers on a report, reachable straight from the card — an admin
610 // shouldn't need a report on file to act on a duplicate or unwanted
611 // account. Showing them here is a nicety only; the server re-checks the
612 // admin role on both endpoints. People only — bots are managed from the
613 // admin console.
614 if (!e.is_agent && verbs.isAdmin()) {
615 const who = displayNameFor(nicknames[uid], e.label) || "them";
616 const adminRow = document.createElement("div");
617 adminRow.className = "row";
618 adminRow.style.cssText = "justify-content:center;margin-top:10px;flex-direction:column;align-items:center;gap:6px";
619 const adminNote = document.createElement("div");
620 adminNote.className = "field-note";
621 adminNote.style.textAlign = "center";
622 // A polite live region so a failure is announced without moving focus.
623 adminNote.setAttribute("role", "status");
624 adminNote.textContent = "Removing deletes their messages and access. Banning also stops them rejoining with any invite.";
625 const removeBtn = document.createElement("button");
626 removeBtn.className = "danger";
627 removeBtn.textContent = `Remove ${who}`;
628 // The dialog carries the whole-space warning; closing this card on
629 // success leaves nothing showing a member who no longer exists.
630 removeBtn.onclick = () => confirmRemoveFromSpace(uid, () => backdrop.remove());
631 const banBtn = document.createElement("button");
632 banBtn.className = "danger";
633 banBtn.textContent = `Remove & ban ${who}`;
634 banBtn.onclick = async () => {
635 if (!confirm(`Ban ${who}? They're removed from the whole space and can't rejoin with any invite. This can't be undone.`)) return;
636 banBtn.disabled = removeBtn.disabled = true;
637 if (await verbs.banFromSpace(uid)) {
638 // Same husk cleanup as the remove dialog: close your DM with them.
639 const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid));
640 if (dm) closeThread(dm.id);
641 // Gone from the roster (the verb reloaded it) — repaint the list
642 // and any open thread so their rows/bubbles fold away.
643 backdrop.remove();
644 verbs.renderThreadList();
645 verbs.renderActiveSurfaces();
646 return;
647 }
648 banBtn.disabled = removeBtn.disabled = false;
649 adminNote.textContent = "Couldn't ban them — try again.";
650 };
651 adminRow.append(removeBtn, banBtn, adminNote);
652 card.appendChild(adminRow);
653 }
654 }
655}
656
657// The remove-from-space dialog (issue #578): the heading names the person AND
658// the space, so an admin sees the blast radius before confirming — this is
659// whole-space removal, not leaving one chat. Opened from the DM kebab's
660// "Remove <name>" and the profile card's admin rung. document.title is the
661// one runtime source of the space's name (shell.js paintSpaceName). onDone
662// runs after a successful removal — the profile card passes its own close.
663export function confirmRemoveFromSpace(uid, onDone) {
664 const e = roster.find((x) => x.user_id === uid);
665 if (!e) return;
666 const who = displayNameFor(nicknames[uid], e.label) || "them";
667 const backdrop = verbs.modal();
668 const card = backdrop.querySelector(".sheet");
669 const h = document.createElement("h2");
670 h.textContent = `Remove ${who} from ${document.title || "this space"}`;
671 card.appendChild(h);
672 const p = document.createElement("p");
673 p.className = "sub";
674 p.textContent = "This completely removes them from the whole space — every chat, their messages, and their access. It can't be undone.";
675 card.appendChild(p);
676 const note = document.createElement("div");
677 note.className = "field-note";
678 note.style.textAlign = "center";
679 // A polite live region so a failure is announced without moving focus.
680 note.setAttribute("role", "status");
681 const row = document.createElement("div");
682 row.className = "row";
683 row.style.cssText = "justify-content:center;gap:10px;margin-top:12px";
684 const go = document.createElement("button");
685 go.className = "danger";
686 go.textContent = `Remove ${who}`;
687 const cancel = document.createElement("button");
688 cancel.className = "secondary";
689 cancel.textContent = "Cancel";
690 cancel.onclick = () => backdrop.remove();
691 go.onclick = async () => {
692 go.disabled = cancel.disabled = true;
693 if (await verbs.removeFromSpace(uid)) {
694 // Your DM with them (if any) is a husk now — close it like Close chat,
695 // so it drops off the list (and off screen) instead of lingering
696 // headed by a ghost.
697 const dm = threads.find((t) => t.kind === "dm" && (t.members || []).includes(uid));
698 if (dm) closeThread(dm.id);
699 // Gone from the roster (the verb reloaded it) — repaint the list and
700 // any open thread so their rows/bubbles fold away.
701 backdrop.remove();
702 verbs.renderThreadList();
703 verbs.renderActiveSurfaces();
704 if (onDone) onDone();
705 return;
706 }
707 go.disabled = cancel.disabled = false;
708 note.textContent = "Couldn't remove them — try again.";
709 };
710 row.append(go, cancel);
711 card.append(row, note);
712}
713
714// A DM partner renamed themself while I hold my own name for them (#577): a
715// slim inline row between the header and the feed — occupying layout space,
716// never floating — offering the choice once. Clears itself when there's
717// nothing to ask (no nickname, acknowledged, or not a DM; the person's profile
718// card — today opened from your DM's header — shows the same choice).
719export function updateRenameBanner(t) {
720 // Never inside a native webview: the rename prompt is native chrome's job
721 // there (RenameBanner.swift), and the embed can't persist a choice anyway
722 // (no userPriv — a tap would fake-succeed and desync from native).
723 if (embed) return;
724 const view = document.getElementById("thread-view");
725 if (!view) return;
726 let bar = document.getElementById("rename-banner");
727 let notice = null;
728 let other = null;
729 if (t && t.kind === "dm") {
730 other = (t.members || []).find((uid) => uid !== myUserId);
731 const e = roster.find((x) => x.user_id === other);
732 notice = renameNotice(nicknames[other], e && e.label);
733 }
734 if (!notice) {
735 if (bar) bar.remove();
736 return;
737 }
738 if (!bar) {
739 bar = document.createElement("div");
740 bar.id = "rename-banner";
741 bar.className = "rename-banner";
742 // A polite live region: the row appears mid-conversation, so AT users are
743 // told the choice exists without focus being stolen from the composer.
744 bar.setAttribute("role", "status");
745 view.insertBefore(bar, document.getElementById("app-surface"));
746 }
747 bar.textContent = "";
748 bar.appendChild(renameLine(notice));
749 // Choosing removes the bar (and the focused button with it) — hand focus to
750 // the composer so keyboard users aren't dropped onto <body>.
751 const choose = async (keepMine) => {
752 await verbs.resolveRename(other, keepMine);
753 const box = view.querySelector(".chatapp-root .composer .composer-input");
754 if (box) box.focus();
755 };
756 for (const b of renameChoices(notice, choose)) bar.appendChild(b);
757}
758
759// "<from> now goes by <to>" with the two NAMES carrying the weight — the
760// emphasis encodes which words matter; the sentence around them stays plain.
761function renameLine(notice) {
762 const msg = document.createElement("span");
763 msg.className = "rename-msg";
764 const from = document.createElement("span");
765 from.className = "rename-name";
766 from.textContent = notice.from;
767 const to = document.createElement("span");
768 to.className = "rename-name";
769 to.textContent = notice.to;
770 msg.appendChild(from);
771 msg.appendChild(document.createTextNode(" now goes by "));
772 msg.appendChild(to);
773 return msg;
774}
775
776// The keep/adopt pair, in the app's one-tap-decision shape (action chips).
777// choose(keepMine) resolves; both surfaces (banner + profile card) share this.
778function renameChoices(notice, choose) {
779 const use = document.createElement("button");
780 use.className = "action-chip";
781 use.textContent = `Use ${notice.to}`;
782 use.onclick = () => choose(false);
783 const keep = document.createElement("button");
784 keep.className = "action-chip";
785 keep.textContent = `Keep ${notice.from}`;
786 keep.onclick = () => choose(true);
787 return [use, keep];
788}
789
790// The group info incl. the space description — plaintext branding-adjacent
791// metadata like the admin name/email, member-gated on /api/group. On a sealed
792// instance the readable values come from the decrypted branding blob.
793async function fetchSpaceInfo() {
794 const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
795 if (g.sealed && sealedBranding) {
796 g.name = sealedBranding.name || "";
797 g.admin_name = sealedBranding.admin_name || "";
798 g.admin_email = sealedBranding.admin_email || "";
799 g.description = sealedBranding.description || "";
800 }
801 return { g, about: g.description || "" };
802}
803
804// "About this space": the group name, its free-text description, and who runs
805// it. The description is admin-authored, plaintext like the group name — it
806// also greets invitees on the join card. Role-admins edit it here (their
807// device token passes the admin API); it's editable from /admin too.
808export async function showSpaceInfo() {
809 const backdrop = verbs.modal();
810 const card = backdrop.querySelector(".sheet");
811 const h = document.createElement("h2");
812 h.textContent = document.title; // carries the substituted group name
813 card.appendChild(h);
814
815 let info = { g: {}, about: "" };
816 try {
817 info = await fetchSpaceInfo();
818 } catch {}
819
820 const meEntry = roster.find((x) => x.user_id === myUserId);
821 const canEdit = !!(meEntry && meEntry.role === "admin");
822
823 if (canEdit) {
824 const lbl = document.createElement("label");
825 lbl.className = "field-label";
826 lbl.textContent = "Description";
827 const ta = document.createElement("textarea");
828 ta.rows = 4;
829 ta.maxLength = 500;
830 ta.placeholder = "What is this space? Members and the people they invite can read this.";
831 ta.value = info.about;
832 card.appendChild(lbl);
833 card.appendChild(ta);
834 const note = document.createElement("p");
835 note.className = "sub";
836 card.appendChild(note);
837 const foot = document.createElement("div");
838 foot.className = "sheet-foot";
839 const cancel = document.createElement("button");
840 cancel.className = "secondary";
841 cancel.textContent = "Cancel";
842 cancel.onclick = () => backdrop.remove();
843 const save = document.createElement("button");
844 save.textContent = "Save";
845 save.onclick = async () => {
846 save.disabled = true;
847 try {
848 const r = await fetch("/api/group-description", {
849 method: "POST",
850 headers: { Authorization: "Bearer " + session.token, "Content-Type": "application/json" },
851 body: JSON.stringify({ description: ta.value.trim().slice(0, 500) }),
852 });
853 if (!r.ok) throw new Error("save failed");
854 backdrop.remove();
855 } catch {
856 note.textContent = "Couldn't save — try again.";
857 save.disabled = false;
858 }
859 };
860 foot.appendChild(cancel);
861 foot.appendChild(save);
862 card.appendChild(foot);
863 } else {
864 const p = document.createElement("p");
865 p.className = "sub";
866 p.style.whiteSpace = "pre-wrap"; // the description keeps its line breaks
867 p.textContent = info.about || "No description yet.";
868 card.appendChild(p);
869 }
870
871 // Who runs the space (member-gated fields; empty until the admin sets them).
872 const who = info.g.admin_name || "";
873 const mail = info.g.admin_email || "";
874 if (who || mail) {
875 const run = document.createElement("p");
876 run.className = "sub";
877 run.textContent = "Run by " + (who || mail) + (who && mail ? ` (${mail})` : "");
878 card.appendChild(run);
879 }
880}
881
882// Remove another member from the current group (any member may; no key rotation —
883// the server just stops delivering to them, like leaving). Confirms first.
884async function removeMember(uid, name, backdrop) {
885 const id = activeId;
886 const t = threads.find((x) => x.id === id);
887 if (!t || t.kind !== "group") return;
888 if (!confirm(`Remove ${name} from the group?`)) return;
889 try {
890 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/remove`, {
891 method: "POST",
892 headers: { "Content-Type": "application/json" },
893 body: JSON.stringify({ token: session.token, user_id: uid }),
894 });
895 if (!r.ok) throw new Error("server");
896 } catch {
897 alert("Couldn't remove them. Try again.");
898 return;
899 }
900 // Announce the removal as a centered action line ("Paul removed Bob"). We stay a
901 // member, so no echo race — but send before loadThreads so it goes out while the
902 // thread state is still intact.
903 await verbs.announceRemove(id, [uid]);
904 if (backdrop) backdrop.remove();
905 await verbs.loadThreads(); // refresh members + the (possibly member-derived) name
906 verbs.renderThreadList();
907 if (activeId === id) verbs.paintThreadHeader(id); // update the member count / title
908}
909
910// Close (hide) a chat by id: it drops off the list until there's new activity
911// or you find it via search. Per-user, synced across your devices. Leaves the
912// pane only when the closed chat is the one on screen — remove-from-space
913// closes a DM that may not be active.
914export function closeThread(id) {
915 hidden[id] = true;
916 fetch(`/api/threads/${encodeURIComponent(id)}/hidden`, {
917 method: "POST",
918 headers: { "Content-Type": "application/json" },
919 body: JSON.stringify({ token: session.token, hidden: true }),
920 }).catch(() => {});
921 if (activeId === id) {
922 setActiveId(null);
923 localStorage.removeItem(ACTIVE_KEY); // don't reopen a closed chat on refresh
924 document.getElementById("thread-view").classList.add("hidden");
925 document.getElementById("thread-empty").classList.remove("hidden");
926 verbs.showList();
927 }
928 verbs.renderThreadList();
929}
930
931// Close the open chat (the kebab's Close chat item).
932export async function closeChat() {
933 if (activeId) closeThread(activeId);
934}
935
936// End the open DM for both people (issue #578): it drops off both lists and
937// the server refuses new messages until someone reopens it. Stronger than
938// Close chat (which quietly returns on activity) and explicitly mutual — hence
939// the confirm.
940export async function endConversation() {
941 const id = activeId;
942 const t = threads.find((x) => x.id === id);
943 if (!t || t.kind !== "dm") return;
944 if (!confirm("End this conversation? It closes for both of you, and no new messages can be sent unless someone reopens it.")) return;
945 try {
946 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/close-dm`, {
947 method: "POST",
948 headers: { "Content-Type": "application/json" },
949 body: JSON.stringify({ token: session.token }),
950 });
951 if (!r.ok) throw new Error("server");
952 } catch {
953 return; // nothing changed server-side; leave the chat as it was
954 }
955 dmClosed[id] = true;
956 hidden[id] = true;
957 setActiveId(null);
958 localStorage.removeItem(ACTIVE_KEY);
959 verbs.renderThreadList();
960 document.getElementById("thread-view").classList.add("hidden");
961 document.getElementById("thread-empty").classList.remove("hidden");
962 verbs.showList();
963}
964
965// Mute/unmute the open chat: no notifications and no unread dot while muted;
966// messages still arrive normally. Per-user, synced across your devices — the
967// server just skips pushing to you for this thread.
968export function toggleMute() {
969 const id = activeId;
970 if (!id) return;
971 muted[id] = !muted[id];
972 fetch(`/api/threads/${encodeURIComponent(id)}/muted`, {
973 method: "POST",
974 headers: { "Content-Type": "application/json" },
975 body: JSON.stringify({ token: session.token, muted: muted[id] }),
976 }).catch(() => {});
977 document.getElementById("thread-mute").textContent = muted[id] ? "Unmute chat" : "Mute chat";
978 verbs.renderThreadList();
979 verbs.syncThreadKeysToSW(); // the SW's own muted check (belt to the server's braces)
980}
981
982// Pin/unpin the open chat: pinned chats float to the top of the list. Per-user,
983// synced across your devices.
984export function togglePin() {
985 const id = activeId;
986 if (!id) return;
987 pinned[id] = !pinned[id];
988 fetch(`/api/threads/${encodeURIComponent(id)}/pinned`, {
989 method: "POST",
990 headers: { "Content-Type": "application/json" },
991 body: JSON.stringify({ token: session.token, pinned: pinned[id] }),
992 }).catch(() => {});
993 document.getElementById("thread-pin").textContent = pinned[id] ? "Unpin chat" : "Pin chat";
994 verbs.renderThreadList();
995}
996
997// addPeople adds joined instance members (not already in the group) by wrapping
998// the group key to each of their roster public keys.
999export function addPeople() {
1000 const t = threads.find((x) => x.id === activeId);
1001 if (!t || t.kind !== "group") return;
1002 const here = new Set(t.members || []);
1003 // Joined connections only (keyless = pending invitee, no pubkey to wrap to).
1004 const candidates = roster.filter((e) => !here.has(e.user_id) && e.public_key);
1005 const backdrop = verbs.modal();
1006 const card = backdrop.querySelector(".sheet");
1007 const h = document.createElement("h2");
1008 h.textContent = "Add people";
1009 card.appendChild(h);
1010 if (!candidates.length) {
1011 const p = document.createElement("p");
1012 p.className = "sub";
1013 p.textContent = "Everyone who's joined is already in this group.";
1014 card.appendChild(p);
1015 return;
1016 }
1017
1018 const filter = document.createElement("input");
1019 filter.placeholder = "Search people";
1020 filter.className = "sheet-input";
1021 card.appendChild(filter);
1022 const list = document.createElement("div");
1023 list.className = "pick-list";
1024 card.appendChild(list);
1025
1026 const selected = new Set();
1027 const add = document.createElement("button");
1028 const renderList = () => {
1029 const q = filter.value.trim().toLowerCase();
1030 list.innerHTML = "";
1031 const matches = candidates.filter((x) => rosterMatches(x, q));
1032 const addRow = (e) => {
1033 const row = document.createElement("label");
1034 row.className = "list-row";
1035 const cb = document.createElement("input");
1036 cb.type = "checkbox";
1037 cb.checked = selected.has(e.user_id);
1038 cb.onchange = () => {
1039 cb.checked ? selected.add(e.user_id) : selected.delete(e.user_id);
1040 add.disabled = selected.size === 0;
1041 };
1042 row.appendChild(cb);
1043 row.appendChild(pickerName(e));
1044 list.appendChild(row);
1045 };
1046 // Same People/Helpers split as the new-chat picker: adding a bot to a group
1047 // is a distinct, visible act — never something buried among the people.
1048 // And the same bots gate: off hides the helpers here too.
1049 const people = matches.filter((e) => !e.is_agent);
1050 const helpers = ENABLE_BOTS ? matches.filter((e) => e.is_agent) : [];
1051 if (people.length && helpers.length) list.appendChild(pickerSection("People"));
1052 for (const e of people) addRow(e);
1053 if (helpers.length) list.appendChild(pickerSection("Helpers"));
1054 for (const e of helpers) addRow(e);
1055 };
1056 filter.addEventListener("input", renderList);
1057 renderList();
1058
1059 const foot = document.createElement("div");
1060 foot.className = "sheet-foot";
1061 const cancel = document.createElement("button");
1062 cancel.className = "secondary";
1063 cancel.textContent = "Cancel";
1064 cancel.onclick = () => backdrop.remove();
1065 add.textContent = "Add";
1066 add.disabled = true;
1067 add.onclick = async () => {
1068 add.disabled = true;
1069 try {
1070 const b64 = threadKeyB64[activeId];
1071 const added = [];
1072 for (const uid of selected) {
1073 const e = roster.find((x) => x.user_id === uid);
1074 const wrapped = await wrapThreadKey(b64, e.public_key);
1075 const r = await fetch(`/api/threads/${encodeURIComponent(activeId)}/members`, {
1076 method: "POST",
1077 headers: { "Content-Type": "application/json" },
1078 body: JSON.stringify({ token: session.token, user_id: uid, wrapped }),
1079 });
1080 if (!r.ok) throw new Error("server");
1081 added.push(uid);
1082 }
1083 // Announce the additions in the thread as a centered action line. We pass
1084 // user ids (not names) so each member sees it from their own view — a
1085 // newcomer reads "Paul added you and Bob".
1086 if (added.length) await verbs.announceJoin(activeId, added);
1087 backdrop.remove();
1088 await verbs.loadThreads();
1089 verbs.renderThreadList();
1090 if (activeId) verbs.paintThreadHeader(activeId); // refresh the member count / name
1091 } catch (e) {
1092 console.error(e);
1093 add.disabled = false;
1094 add.textContent = "Couldn't add — try again";
1095 }
1096 };
1097 foot.appendChild(cancel);
1098 foot.appendChild(add);
1099 card.appendChild(foot);
1100}
1101
1102// manageChatApps — the places-UI-off Apps sheet (the default; docs/
1103// space-apps.md § The pullback): ONE job, in the pre-places shape — add a
1104// shared copy of an app to THIS group chat, or remove it. Personal copies
1105// live in the + picker; the space place is parked. Adds/removes are the same
1106// groupapps.* events the full directory emits — every member folds them; the
1107// server never learns which app was chosen, or that one was.
1108function manageChatApps(chat) {
1109 if (!chat || chat.kind !== "group") return; // the kebab item only shows in groups
1110 const tid = chat.id;
1111 const backdrop = verbs.modal();
1112 const card = backdrop.querySelector(".sheet");
1113 const h = document.createElement("h2");
1114 h.textContent = "Apps";
1115 card.appendChild(h);
1116 const sub = document.createElement("p");
1117 sub.className = "sub";
1118 sub.textContent = "A shared copy for everyone in this chat.";
1119 card.appendChild(sub);
1120 const candidates = listedApps().filter((a) => a.places.includes("chat") && (ENABLE_MEMBER_APPS || a.name !== "appstudio"));
1121 if (!candidates.length) {
1122 const p = document.createElement("p");
1123 p.className = "sub";
1124 p.textContent = "No apps can be added here yet.";
1125 card.appendChild(p);
1126 return;
1127 }
1128 const list = document.createElement("div");
1129 list.className = "pick-list";
1130 card.appendChild(list);
1131 for (const app of candidates) {
1132 const title = app.title || app.name;
1133 const row = document.createElement("div");
1134 row.className = "list-row static";
1135 const av = document.createElement("span");
1136 av.className = "avatar app-avatar";
1137 appGlyphInto(av, app);
1138 const body = document.createElement("span");
1139 body.style.cssText = "flex:1;min-width:0;";
1140 const nm = document.createElement("span");
1141 nm.textContent = title; // member-app titles are user content — textContent
1142 body.appendChild(nm);
1143 if (app.blurb) {
1144 const bl = document.createElement("span");
1145 bl.className = "helper-desc";
1146 bl.textContent = app.blurb;
1147 body.appendChild(bl);
1148 }
1149 const added = groupAppsFor(tid).some((a) => a.name === app.name);
1150 const btn = document.createElement("button");
1151 btn.type = "button";
1152 btn.textContent = added ? "Remove" : "Add";
1153 if (added) btn.className = "secondary";
1154 btn.setAttribute("aria-label", `${added ? "Remove" : "Add"} ${title} ${added ? "from" : "to"} this chat`);
1155 btn.onclick = async () => {
1156 btn.disabled = true;
1157 // Truthy = the event was sent; the echo folds the change for everyone
1158 // (the widget bar + action line are the visible record) — done here.
1159 const ok = added ? await emitAppEvent(tid, { type: "groupapps.remove", app: app.name }) : await installGroupApp(tid, app);
1160 if (ok) {
1161 backdrop.remove();
1162 return;
1163 }
1164 btn.disabled = false;
1165 btn.textContent = "Couldn't send — try again";
1166 };
1167 row.append(av, body, btn);
1168 list.appendChild(row);
1169 }
1170}
1171
1172// manageGroupApps — the Apps directory sheet (⋮ → Apps in a chat, or the
1173// space menu's Apps with opts.space), redesigned as FOCUS-AND-EXPLAIN:
1174//
1175// 1. A list of every app, each row wearing badges for the places it already
1176// occupies and a "›" go button.
1177// 2. Tapping a row (or its ›) FOCUSES that app: the other rows drop away
1178// (they're a distraction now), the › becomes ✕ (back to the list), and
1179// the row grows the places sentence, three place SELECTORS — Just for
1180// you | This chat | Everyone — and an explanation panel. Selectors name
1181// places; verbs live only on the footer action. The selection starts on
1182// the chat place when this chat could hold the app (that's what you
1183// most likely came for), else on the personal place.
1184// 3. Every button always shows; ones that can't act here are dimmed but
1185// STAY CLICKABLE — tapping any button explains in the panel what that
1186// place means, and a dimmed one explains exactly WHY it isn't available
1187// (the app's own placeNote, an inferred reason for credential-backed
1188// apps, no-chat-behind-this-menu, the admins-only policy…). The sheet's
1189// right-aligned footer always shows the selected place's action (Open /
1190// Add / Remove) — live when it can act, disabled when it can't — so
1191// reading and doing are one motion and nothing just vanishes.
1192//
1193// Adds/removes are EVENTS in the target place's encrypted log (groupapps.* in
1194// this chat's, spaceapps.* in the hidden space thread's) — every member folds
1195// them; the server never learns which app was chosen, or that one was.
1196export function manageGroupApps(opts = {}) {
1197 const t = threads.find((x) => x.id === activeId);
1198 // The chat context is the conversation OPEN behind the sheet, whichever
1199 // door you came through: the chat kebab requires one; the space menu and
1200 // the + picker doorway (opts.space) take the open chat too — "This chat"
1201 // targets the one you're looking at — and only fall back to the dimmed
1202 // no-chat explanation when nothing is open (fresh boot, the space home).
1203 const chat = t && (t.kind === "group" || t.kind === "dm") ? t : null;
1204 // With the places UI hidden (the default — docs/space-apps.md § The
1205 // pullback), ⋮ → Apps does ONE job: add or remove shared copies for THIS
1206 // chat. The focus-and-explain directory below stays intact behind
1207 // PLACES_UI for experiments.
1208 if (!PLACES_UI) return manageChatApps(chat);
1209 if (!opts.space && !chat) return; // a chat's kebab needs a chat behind it
1210 const isGroup = !!chat && chat.kind === "group";
1211 const tid = chat ? activeId : null;
1212 const backdrop = verbs.modal();
1213 const card = backdrop.querySelector(".sheet");
1214 const h = document.createElement("h2");
1215 h.textContent = "Apps";
1216 card.appendChild(h);
1217 // The full directory — personal-only apps included, so the sheet can also
1218 // answer "why can't I add this one here?". Same member-apps gate as the
1219 // picker (newchat.js pickerApps): with member-made apps off, App Studio's
1220 // entry point hides — deploys would 403 anyway; deployed apps stay listed.
1221 const candidates = listedApps().filter((a) => ENABLE_MEMBER_APPS || a.name !== "appstudio");
1222 if (!candidates.length) {
1223 const p = document.createElement("p");
1224 p.className = "sub";
1225 p.textContent = "No apps yet.";
1226 card.appendChild(p);
1227 return;
1228 }
1229 const list = document.createElement("div");
1230 list.className = "pick-list";
1231 card.appendChild(list);
1232 // The one action footer, shared by whichever app is focused: the selected
1233 // place's Open / Add / Remove lands here, right-aligned at the sheet's
1234 // bottom — the explanation reads above, the commitment sits below.
1235 const foot = document.createElement("div");
1236 foot.className = "sheet-foot place-foot";
1237 card.appendChild(foot);
1238
1239 // What each PLACE means for `app`, from here: { ok, text, action }. The
1240 // text ALWAYS renders — that's the panel's whole job — saying what the
1241 // action does when it's available, or exactly why it isn't right now. The
1242 // action ALWAYS carries the would-be label too: an unavailable place still
1243 // shows its button in the footer, disabled — the option exists, the panel
1244 // says why it can't act, and nothing pretends otherwise.
1245 const optionState = (app, opt) => {
1246 const title = app.title || app.name;
1247 // The builder's own reason an app skips a place, else an inferred one for
1248 // credential-backed apps, else the truthful default.
1249 const builderWhy =
1250 app.placeNote ||
1251 (app.requiresService
1252 ? "Works through your own linked account — each person connects their own, so there's no shared copy to add."
1253 : "This app's builder hasn't enabled this place yet.");
1254 if (opt === "open") {
1255 if (!app.places.includes("you")) return { ok: false, text: builderWhy, action: { label: "Open" } };
1256 return {
1257 ok: true,
1258 text: `Your own private ${title} — a copy only you can see, in your sidebar.`,
1259 action: {
1260 label: "Open",
1261 run: async () => {
1262 const id = await verbs.resolveAppThread(app.name);
1263 if (!id) return false;
1264 verbs.openThread(id);
1265 return true;
1266 },
1267 },
1268 };
1269 }
1270 if (opt === "chat") {
1271 if (!chat)
1272 return { ok: false, text: "There's no chat behind this menu — open the chat you want, then ⋮ → Apps there.", action: { label: "Add to this chat" } };
1273 if (!isGroup)
1274 return { ok: false, text: "Sharing into a chat works in group chats — this is a direct message.", action: { label: "Add to this chat" } };
1275 if (!app.places.includes("chat")) return { ok: false, text: builderWhy, action: { label: "Add to this chat" } };
1276 if (groupAppsFor(tid).some((a) => a.name === app.name)) {
1277 return {
1278 ok: true,
1279 text: `${title} is already shared in this chat — everyone here uses the same copy.`,
1280 action: { label: "Remove from this chat", secondary: true, run: () => emitAppEvent(tid, { type: "groupapps.remove", app: app.name }) },
1281 };
1282 }
1283 return {
1284 ok: true,
1285 text: `One shared ${title} for everyone in this chat — and only this chat. Everyone here sees the same copy, beside the conversation.`,
1286 action: { label: "Add to this chat", run: () => installGroupApp(tid, app) },
1287 };
1288 }
1289 // opt === "space"
1290 if (!app.places.includes("space")) return { ok: false, text: builderWhy, action: { label: "Add for everyone" } };
1291 const inSpace = spaceAppsFor().some((a) => a.name === app.name);
1292 const info = inSpace ? spaceAppInfo(app.name) : null;
1293 const by = info && info.by ? ` Added for everyone by ${info.by}.` : "";
1294 if (!installPolicyCanAdd()) {
1295 return {
1296 ok: false,
1297 text:
1298 (inSpace ? `One copy for the whole space, in everyone's sidebar.${by} ` : "") +
1299 "Only admins can add or remove apps for everyone in this space.",
1300 // The act a non-admin is being told about — visible, just not theirs.
1301 action: inSpace ? { label: "Remove for everyone", secondary: true } : { label: "Add for everyone" },
1302 };
1303 }
1304 if (inSpace) {
1305 return {
1306 ok: true,
1307 text: `One copy for the whole space — its commands work in every chat, and it has its own home in everyone's sidebar.${by}`,
1308 action: {
1309 label: "Remove for everyone",
1310 secondary: true,
1311 run: () => {
1312 const stid = spaceThreadId();
1313 return stid ? emitAppEvent(stid, { type: "spaceapps.remove", app: app.name }) : false;
1314 },
1315 },
1316 };
1317 }
1318 return {
1319 ok: true,
1320 text: `One copy for your whole space — its commands work in every chat, and it gets its own home in everyone's sidebar.`,
1321 action: {
1322 label: "Add for everyone",
1323 run: async () => {
1324 if (!(await installSpaceApp(app))) return false;
1325 // Land in the home you just created — the row shouldn't have to be
1326 // discovered. (Command installs via @everyone stay answer-in-place;
1327 // this navigation is the sheet's, where adding was the point.)
1328 if (verbs.openSpaceApp) verbs.openSpaceApp(app.name);
1329 return true;
1330 },
1331 },
1332 };
1333 };
1334
1335 // Focus mechanics: entries hide each other; ✕ restores the whole list.
1336 const entries = [];
1337 for (const app of candidates) {
1338 const title = app.title || app.name;
1339 const inChat = isGroup && groupAppsFor(tid).some((a) => a.name === app.name);
1340 const inSpace = spaceAppsFor().some((a) => a.name === app.name);
1341 const info = inSpace ? spaceAppInfo(app.name) : null;
1342
1343 const entry = document.createElement("div");
1344 entry.className = "app-entry";
1345 const row = document.createElement("div");
1346 row.className = "list-row static";
1347 const av = document.createElement("span");
1348 av.className = "avatar app-avatar";
1349 appGlyphInto(av, app);
1350 const body = document.createElement("span");
1351 body.style.cssText = "flex:1;min-width:0;";
1352 const nm = document.createElement("span");
1353 nm.textContent = title; // member-app titles are user content — textContent
1354 body.appendChild(nm);
1355 for (const [on, text] of [
1356 [inChat, "This chat"],
1357 [inSpace, "Everyone"],
1358 ]) {
1359 if (!on) continue;
1360 const b = document.createElement("span");
1361 b.className = "place-badge";
1362 b.textContent = text;
1363 body.appendChild(b);
1364 }
1365 const detailLine = (info && info.by && `Added for everyone by ${info.by}`) || app.blurb;
1366 if (detailLine) {
1367 const bl = document.createElement("span");
1368 bl.className = "helper-desc";
1369 bl.textContent = detailLine;
1370 body.appendChild(bl);
1371 }
1372 const go = document.createElement("button");
1373 go.type = "button";
1374 go.className = "app-go";
1375 go.textContent = "›";
1376 go.setAttribute("aria-label", `Choose where ${title} goes`);
1377 go.setAttribute("aria-expanded", "false");
1378 row.append(av, body, go);
1379 const places = document.createElement("div");
1380 places.className = "app-places";
1381 // The grid-collapse animation (see .app-entry CSS) needs a single inner
1382 // child to clip — same for the places drawer below.
1383 const inner = document.createElement("div");
1384 inner.className = "app-entry-inner";
1385 inner.append(row, places);
1386 entry.appendChild(inner);
1387 list.appendChild(entry);
1388
1389 let focused = false;
1390 const buildPlaces = () => {
1391 places.textContent = "";
1392 const placesInner = document.createElement("div");
1393 placesInner.className = "app-places-inner";
1394 places.appendChild(placesInner);
1395 // The places sentence sits with the choice it explains — under the
1396 // focused app's name, above its place buttons ("this chat" only means
1397 // something when a chat is behind the sheet).
1398 const grammar = document.createElement("p");
1399 grammar.className = "place-grammar";
1400 grammar.textContent = `An app copy lives in a place — just for you, ${chat ? "this chat" : "a chat"}, or everyone in this space.`;
1401 const bar = document.createElement("div");
1402 bar.className = "place-options";
1403 bar.setAttribute("role", "group");
1404 bar.setAttribute("aria-label", `Places for ${title}`);
1405 const panel = document.createElement("div");
1406 panel.className = "place-panel";
1407 panel.setAttribute("aria-live", "polite"); // the panel narrates each pick
1408 const btns = new Map();
1409 const select = (opt) => {
1410 const st = optionState(app, opt);
1411 for (const [o, b] of btns) b.setAttribute("aria-pressed", String(o === opt));
1412 panel.textContent = "";
1413 const p = document.createElement("p");
1414 p.textContent = st.text;
1415 panel.appendChild(p);
1416 // The footer always shows the selected place's action — disabled
1417 // when the place can't act here, so the button never just vanishes
1418 // (the panel above says why it's off).
1419 foot.textContent = "";
1420 const act = document.createElement("button");
1421 act.type = "button";
1422 act.textContent = st.action.label;
1423 if (st.action.secondary) act.className = "secondary";
1424 if (st.ok && st.action.run) {
1425 act.onclick = async () => {
1426 act.disabled = true;
1427 // Truthy = the event was sent (or the thread opened) — the echo
1428 // folds the change for everyone; the sheet's job is done.
1429 if (await st.action.run()) {
1430 backdrop.remove();
1431 return;
1432 }
1433 act.disabled = false;
1434 act.textContent = "Couldn't send — try again";
1435 };
1436 } else {
1437 act.disabled = true;
1438 }
1439 foot.appendChild(act);
1440 };
1441 // The selectors name PLACES, in the words the sentence above and the
1442 // row badges already use — the verb lives only on the footer button
1443 // that actually performs it (Open / Add … / Remove …).
1444 for (const [opt, label, aria] of [
1445 ["open", "Just for you"],
1446 ["chat", "This chat"],
1447 ["space", "Everyone", "Everyone in this space"],
1448 ]) {
1449 const b = document.createElement("button");
1450 b.type = "button";
1451 // Dimmed ≠ disabled: a dimmed place still explains itself on tap —
1452 // that's the panel's whole point — so it stays a live button.
1453 b.className = "place-option" + (optionState(app, opt).ok ? "" : " dim");
1454 b.textContent = label;
1455 if (aria) b.setAttribute("aria-label", aria);
1456 b.setAttribute("aria-pressed", "false");
1457 b.onclick = () => select(opt);
1458 btns.set(opt, b);
1459 bar.appendChild(b);
1460 }
1461 placesInner.append(grammar, bar, panel);
1462 // Opened from a chat's own kebab, the chat place is what you most
1463 // likely came for — start there. The space menu and picker doorway
1464 // start on the personal place (the chat stays one tap away).
1465 select(!opts.space && optionState(app, "chat").ok ? "chat" : "open");
1466 };
1467 // Collapse/restore ride CSS transitions (.collapsed / .open — the sheet
1468 // shrinks to the chosen app and grows back, no jump cuts). The stale
1469 // places content stays through the collapse so it animates at its real
1470 // height; the next focus rebuilds it anyway.
1471 const restore = () => {
1472 focused = false;
1473 entry.classList.remove("collapsed");
1474 places.classList.remove("open");
1475 foot.textContent = ""; // the footer belongs to the focused app only
1476 go.textContent = "›";
1477 go.setAttribute("aria-label", `Choose where ${title} goes`);
1478 go.setAttribute("aria-expanded", "false");
1479 };
1480 const focusThis = () => {
1481 for (const e of entries) if (e.entry !== entry) e.hide();
1482 focused = true;
1483 go.textContent = "✕";
1484 go.setAttribute("aria-label", "Back to all apps");
1485 go.setAttribute("aria-expanded", "true");
1486 buildPlaces();
1487 // Next frame, so the freshly-filled drawer transitions from 0fr instead
1488 // of appearing at full height.
1489 requestAnimationFrame(() => places.classList.add("open"));
1490 };
1491 const blurAll = () => {
1492 for (const e of entries) e.restore();
1493 };
1494 go.onclick = (ev) => {
1495 ev.stopPropagation();
1496 focused ? blurAll() : focusThis();
1497 };
1498 row.onclick = () => {
1499 if (!focused) focusThis();
1500 };
1501 row.style.cursor = "pointer";
1502 entries.push({ entry, name: app.name, hide: () => entry.classList.add("collapsed"), restore, focus: focusThis });
1503 }
1504 // opts.focus pre-focuses one app — the + picker's doorway: an app with no
1505 // personal copy yet lands here to choose its place (its birth is a PLACE
1506 // decision, never a silent create). The full list stays one ✕ away.
1507 if (opts.focus) {
1508 const e = entries.find((x) => x.name === opts.focus);
1509 if (e) e.focus();
1510 }
1511}
1512
1513// installPolicyCanAdd: may THIS member add/remove space-wide apps right now?
1514// (The sheet's view of the same policy apps.js enforces — see
1515// setSpaceInstallPolicy for the mechanism and its E2EE-honest limits.)
1516function installPolicyCanAdd() {
1517 const adminsOnly = ((document.querySelector('meta[name="space-installs-admins"]') || {}).content || "0") === "1";
1518 if (!adminsOnly) return true;
1519 const me = (roster || []).find((e) => e.user_id === myUserId);
1520 return !!(me && me.role === "admin");
1521}
1522
1523// Set a group photo: downscaled, encrypted with the THREAD key (so only members
1524// can see it), uploaded, and pointed at via the thread's avatar_blob.
1525export async function leaveGroup() {
1526 // Capture the thread up front: announceLeave awaits, and activeId is mutable
1527 // module state — the user could navigate away mid-leave, so every step below
1528 // must target this id, not whatever's active by the time the await resolves.
1529 const id = activeId;
1530 const t = threads.find((x) => x.id === id);
1531 if (!t || t.kind !== "group") return;
1532 if (!confirm(`Leave "${threadName[id] || "this group"}"? You won't get new messages.`)) return;
1533 // Leave a parting "Paul left" action line — sent (and confirmed stored) while
1534 // we're still a member, since the server drops our posts the moment we leave.
1535 // Best-effort: a cosmetic line must never block or abort the actual leave.
1536 await verbs.announceLeave(id).catch(() => {});
1537 try {
1538 const r = await fetch(`/api/threads/${encodeURIComponent(id)}/leave`, {
1539 method: "POST",
1540 headers: { "Content-Type": "application/json" },
1541 body: JSON.stringify({ token: session.token }),
1542 });
1543 if (!r.ok) throw new Error("server");
1544 } catch {
1545 return alert("Couldn't leave the group. Try again.");
1546 }
1547 setThreads(threads.filter((x) => x.id !== id));
1548 delete threadKeys[id];
1549 storeClearThread(id).catch(() => {}); // a left thread keeps no local rows (#457)
1550 searchIdx.dropThread(id); // …and stops matching in search (#586)
1551 if (activeId === id) {
1552 setActiveId(null);
1553 localStorage.removeItem(ACTIVE_KEY); // the thread we were in is gone
1554 document.getElementById("thread-view").classList.add("hidden");
1555 document.getElementById("thread-empty").classList.remove("hidden");
1556 verbs.showList();
1557 }
1558 verbs.renderThreadList();
1559}
1560