| 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. |
| 6 | import { verbs } from "/static/shellverbs.js"; |
| 7 | import { session, roster, myUserId, threads, threadKeys, activeId, nicknames, threadName, instanceKey, threadKeyB64, unread, hidden, dmClosed, setNicknames, setActiveId, setThreads, embed, ENABLE_BOTS, ENABLE_MEMBER_APPS, PLACES_UI, ACTIVE_KEY, MAX_UPLOAD, tiers, tierKeys, tierKeyB64, pinned, muted, sealedBranding } from "/static/shellstate.js"; |
| 8 | import { wrapThreadKey, decrypt, encrypt, encryptBytes } from "/static/crypto.js"; |
| 9 | import { processImage } from "/static/media.js"; |
| 10 | import { avatarFor, threadAvatarFor, paintAvatar, avatarEl, avatarURLs } from "/static/avatars.js"; |
| 11 | import { searchIdx } from "/static/searchui.js"; |
| 12 | import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| 13 | import { emitAppEvent, appGlyphInto, groupAppsFor, listedApps, spaceAppsFor, spaceAppInfo, installSpaceApp, installGroupApp, spaceThreadId } from "/static/apps.js"; |
| 14 | import { displayNameFor, presenceLabel, renameNotice } from "/static/ui.js"; |
| 15 | import { autoGroupTitle } from "/static/format.js"; |
| 16 | import { clearThread as storeClearThread } from "/static/store.js"; |
| 17 | import { 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. |
| 27 | async 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 | |
| 38 | export 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. |
| 290 | export 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 | |
| 306 | export 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. |
| 382 | export 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. |
| 663 | export 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). |
| 719 | export 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. |
| 761 | function 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. |
| 778 | function 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. |
| 793 | async 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. |
| 808 | export 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. |
| 884 | async 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. |
| 914 | export 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). |
| 932 | export 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. |
| 940 | export 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. |
| 968 | export 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. |
| 984 | export 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. |
| 999 | export 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. |
| 1108 | function 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. |
| 1196 | export 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.) |
| 1516 | function 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. |
| 1525 | export 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 | |