| index f7af543..f913cff 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -136,9 +136,10 @@ import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendN |
| import { SearchIndex, snippetFor } from "/static/search.js"; // message search over the local store (#586) |
| import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md) |
| import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import |
| -import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md) |
| +import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md) |
| |
| import { openLightbox } from "/static/lightbox.js"; |
| +import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js"; |
| import { parseInviteFragment, buildInviteFragment } from "/static/invite.js"; |
| import { qrCanvas } from "/static/qrcode.js"; |
| import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn, launchEnrol, isEnrolled, deferBackupNudges } from "/static/backup.js"; |
| @@ -176,6 +177,11 @@ import { |
| import { anyComposerHasText, formatAsYouTypePref, setFormatAsYouType, classicComposerPref, setClassicComposer } from "/static/composer.js"; |
| |
| onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change |
| +// A mute folds bubbles in the open thread; a block also re-badges the list. |
| +onModerationChange((threadsToo) => { |
| + renderActiveSurfaces(); |
| + if (threadsToo) renderThreadList(); |
| +}); |
| |
| const SESSION_KEY = "lchat_session"; |
| const ACTIVE_KEY = "lchat_active"; // last-open thread id, so a refresh lands back in it |
| @@ -1019,7 +1025,7 @@ async function hydrateFromCache() { |
| } |
| if (snap.nick) { |
| const n = await applyNicknamesBody(snap.nick).catch(() => null); |
| - if (n) nicknames = n; |
| + if (n) setNicknames(n); |
| } |
| if (Array.isArray(snap.tiers)) await applyTiers(snap.tiers, false); |
| await applyThreads(snap.threads); |
| @@ -1628,7 +1634,6 @@ async function publishMyDescription(text) { |
| // group's, so other members can't open it. Devices share the keypair, so it |
| // syncs across my devices for free. The native embed never holds userPriv, so |
| // there this stays empty and published names render (native parity follows). |
| -let nicknames = {}; |
| // Ops this device has made that the server copy may not have yet: uid → the |
| // entry to write, or null for "cleared". Saves re-read the server blob and |
| // replay only these on top — so a stale tab never clobbers names set on |
| @@ -1667,7 +1672,7 @@ async function applyNicknamesBody(body) { |
| |
| async function loadNicknames() { |
| const server = await fetchServerNicknames(); |
| - if (server) nicknames = server; |
| + if (server) setNicknames(server); |
| } |
| |
| // Read-modify-write: re-read the server copy and replay only this device's |
| @@ -1704,7 +1709,7 @@ async function saveNicknamesNow() { |
| // queued ops retry over a fresh read (read-modify-write converges). |
| if (!r.ok) return queueNickRetry(); |
| nicknamesVersion = ((await r.json()) || {}).version || baseVersion + 1; |
| - nicknames = merged; // adopt anything another device stored meanwhile too |
| + setNicknames(merged); // adopt anything another device stored meanwhile too |
| // Clear only what this save wrote — an op re-set mid-save stays queued. |
| for (const [uid, entry] of included) if (nicknameOps.get(uid) === entry) nicknameOps.delete(uid); |
| return true; |
| @@ -1752,314 +1757,6 @@ async function resolveRename(uid, keep) { |
| return ok; |
| } |
| |
| -// ---- Muted people (issue #578) ---------------------------------------------- |
| -// Who I've muted, as a private set synced across my devices via the sealed |
| -// prefs blob (/api/prefs) — the same seal-to-my-own-key + compare-and-swap |
| -// shape as nicknames. Fully client-side: a muted person's messages fold to a |
| -// quiet stub and never notify, but nothing changes for them and the server |
| -// never learns who's muted. |
| -let mutedPeople = new Set(); |
| -const mutedOps = new Map(); // uid -> true (mute) | false (unmute), pending sync |
| -let mutedRetryTimer = 0; |
| -let prefsVersion = 0; |
| - |
| -async function fetchServerPrefs() { |
| - if (!session.userPriv) return null; |
| - try { |
| - const r = await fetch(`/api/prefs?token=${encodeURIComponent(session.token)}`); |
| - if (!r.ok) return null; |
| - const body = await r.json(); |
| - prefsVersion = body.version || 0; |
| - if (!body.prefs_blob) return {}; |
| - const obj = await openSealed(body.prefs_blob, session.userPriv); |
| - return obj || {}; |
| - } catch { |
| - return null; |
| - } |
| -} |
| - |
| -async function loadModPrefs() { |
| - const server = await fetchServerPrefs(); |
| - if (server) mutedPeople = new Set(server.mutedPeople || []); |
| -} |
| - |
| -// Read-modify-write with the same serialized chain + retry the nicknames blob |
| -// uses: replay only this device's pending ops over a fresh server read. |
| -let prefsSaveChain = Promise.resolve(true); |
| -function saveModPrefs() { |
| - prefsSaveChain = prefsSaveChain.then(saveModPrefsNow, saveModPrefsNow); |
| - return prefsSaveChain; |
| -} |
| - |
| -async function saveModPrefsNow() { |
| - if (!session.userPriv || !session.userPub) return true; |
| - if (!mutedOps.size) return true; |
| - const included = [...mutedOps.entries()]; |
| - const base = await fetchServerPrefs(); |
| - if (!base) return queuePrefsRetry(); |
| - const baseVersion = prefsVersion; |
| - const merged = new Set(base.mutedPeople || []); |
| - for (const [uid, on] of included) { |
| - if (on) merged.add(uid); |
| - else merged.delete(uid); |
| - } |
| - try { |
| - const blob = await sealForPub(session.userPub, { v: 1, mutedPeople: [...merged] }); |
| - const r = await fetch("/api/prefs", { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, prefs_blob: blob, version: baseVersion }), |
| - }); |
| - if (!r.ok) return queuePrefsRetry(); |
| - prefsVersion = ((await r.json()) || {}).version || baseVersion + 1; |
| - mutedPeople = merged; // adopt another device's writes too |
| - for (const [uid, on] of included) if (mutedOps.get(uid) === on) mutedOps.delete(uid); |
| - return true; |
| - } catch { |
| - return queuePrefsRetry(); |
| - } |
| -} |
| - |
| -function queuePrefsRetry() { |
| - clearTimeout(mutedRetryTimer); |
| - mutedRetryTimer = setTimeout(() => { |
| - if (mutedOps.size) saveModPrefs(); |
| - }, 5000); |
| - return false; |
| -} |
| - |
| -// Mute or unmute a person, applying locally at once (the sync is eventual). |
| -function setPersonMuted(uid, on) { |
| - if (!uid || uid === myUserId) return; |
| - if (on) mutedPeople.add(uid); |
| - else mutedPeople.delete(uid); |
| - mutedOps.set(uid, on); |
| - saveModPrefs(); |
| - syncThreadKeysToSW(); // keep the SW's muted-people set current |
| - renderActiveSurfaces(); // fold/unfold their bubbles in the open thread |
| -} |
| - |
| -// ---- Report to admins (issue #578) ------------------------------------------ |
| -// The report sheet: category + optional words + the quoted message, sealed by |
| -// THIS client to each admin's public key and posted as opaque blobs. The |
| -// server keeps only queue metadata (who reported whom, where) — it cannot |
| -// read a report, and the sheet says plainly that admins will. |
| -function openReportSheet({ uid, threadId, message }) { |
| - const e = roster.find((x) => x.user_id === uid); |
| - if (!e) return; |
| - const who = displayNameFor(nicknames[uid], e.label) || "this member"; |
| - const backdrop = modal(); |
| - const card = backdrop.querySelector(".sheet"); |
| - const h = document.createElement("h2"); |
| - h.textContent = `Report ${who}`; |
| - card.appendChild(h); |
| - |
| - // Category — the familiar triad, as radio chips. |
| - const catWrap = document.createElement("div"); |
| - catWrap.className = "report-cats"; |
| - catWrap.setAttribute("role", "radiogroup"); |
| - catWrap.setAttribute("aria-label", "Reason"); |
| - let category = ""; |
| - const cats = [ |
| - ["spam", "Spam"], |
| - ["harassment", "Harassment"], |
| - ["other", "Something else"], |
| - ]; |
| - for (const [val, label] of cats) { |
| - const b = document.createElement("button"); |
| - b.type = "button"; |
| - b.className = "report-cat"; |
| - b.textContent = label; |
| - b.setAttribute("role", "radio"); |
| - b.setAttribute("aria-checked", "false"); |
| - b.onclick = () => { |
| - category = val; |
| - for (const x of catWrap.children) { |
| - x.classList.toggle("on", x === b); |
| - x.setAttribute("aria-checked", x === b ? "true" : "false"); |
| - } |
| - send.disabled = false; |
| - }; |
| - catWrap.appendChild(b); |
| - } |
| - card.appendChild(catWrap); |
| - |
| - // The quoted evidence — shown so the reporter knows exactly what travels. |
| - if (message && (message.text || (message.attachments || []).length)) { |
| - const q = document.createElement("blockquote"); |
| - q.className = "report-quote"; |
| - const qwho = document.createElement("strong"); |
| - qwho.textContent = who + ": "; |
| - q.appendChild(qwho); |
| - q.appendChild( |
| - document.createTextNode(message.text || (message.attachments || []).map((a) => a.name || "attachment").join(", ")), |
| - ); |
| - card.appendChild(q); |
| - } |
| - |
| - const ta = document.createElement("textarea"); |
| - ta.rows = 3; |
| - ta.maxLength = 1000; |
| - ta.placeholder = "Anything the admins should know (optional)"; |
| - card.appendChild(ta); |
| - |
| - // Honest copy — testimony, not proof; admins see what you send. |
| - const note = document.createElement("div"); |
| - note.className = "field-note"; |
| - note.textContent = |
| - "Your report goes to this space's admins, including the message above and what you write. Reports can't be verified automatically — admins read them and use their judgment."; |
| - card.appendChild(note); |
| - |
| - // One tap to also stop seeing them. |
| - const muteRow = document.createElement("label"); |
| - muteRow.className = "report-mute-row"; |
| - const muteCb = document.createElement("input"); |
| - muteCb.type = "checkbox"; |
| - muteRow.appendChild(muteCb); |
| - muteRow.appendChild(document.createTextNode(` Also mute ${who}`)); |
| - if (!mutedPeople.has(uid)) card.appendChild(muteRow); |
| - const blockRow2 = document.createElement("label"); |
| - blockRow2.className = "report-mute-row"; |
| - const blockCb = document.createElement("input"); |
| - blockCb.type = "checkbox"; |
| - blockRow2.appendChild(blockCb); |
| - blockRow2.appendChild(document.createTextNode(` Also block ${who} (mutes, ends your DM, stops new contact)`)); |
| - if (!blockedPeople.has(uid)) card.appendChild(blockRow2); |
| - |
| - const foot = document.createElement("div"); |
| - foot.className = "row"; |
| - foot.style.cssText = "justify-content:flex-end;gap:8px;margin-top:14px"; |
| - const cancel = document.createElement("button"); |
| - cancel.className = "secondary"; |
| - cancel.textContent = "Cancel"; |
| - cancel.onclick = () => backdrop.remove(); |
| - const send = document.createElement("button"); |
| - send.textContent = "Send report"; |
| - send.disabled = true; // until a category is picked |
| - send.onclick = async () => { |
| - // Sealing to every admin key + the POST takes a beat — freeze the whole |
| - // form and spin on the button so the pause reads as work, not a hang. |
| - const controls = [send, cancel, ta, muteCb, blockCb, ...catWrap.querySelectorAll("button")]; |
| - const freeze = (on) => { |
| - for (const c of controls) c.disabled = on; |
| - }; |
| - freeze(true); |
| - send.textContent = ""; |
| - const spin = document.createElement("span"); |
| - spin.className = "btn-spinner"; |
| - spin.setAttribute("aria-hidden", "true"); |
| - send.append(spin, document.createTextNode(" Sending…")); |
| - send.setAttribute("aria-busy", "true"); |
| - const unfreeze = (label) => { |
| - freeze(false); |
| - send.removeAttribute("aria-busy"); |
| - send.textContent = label; |
| - }; |
| - try { |
| - const admins = roster.filter((x) => x.role === "admin" && x.public_key && !x.is_agent); |
| - if (!admins.length) throw new Error("no admins"); |
| - const content = { |
| - v: 1, |
| - category, |
| - text: ta.value.trim(), |
| - reported: { uid, name: e.label || "" }, |
| - threadName: threadId ? threadName[threadId] || "" : "", |
| - }; |
| - if (message) { |
| - content.evidence = [ |
| - { |
| - sid: message.sid || 0, |
| - uid: message.uid || "", |
| - name: message.name || "", |
| - ts: message.ts || 0, |
| - text: message.text || "", |
| - attachments: (message.attachments || []).map((a) => a.name || a.type || "attachment"), |
| - }, |
| - ]; |
| - } |
| - const blobs = []; |
| - for (const a of admins) { |
| - blobs.push({ admin_id: a.user_id, blob: await sealForPub(a.public_key, content) }); |
| - } |
| - const r = await fetch("/api/reports", { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, reported_id: uid, thread_id: threadId || "", blobs }), |
| - }); |
| - if (r.status === 429) { |
| - unfreeze("Too many reports just now — try later"); |
| - send.disabled = true; // the brake decides when, not another click |
| - return; |
| - } |
| - if (!r.ok) throw new Error("server"); |
| - if (blockCb.checked) await setPersonBlocked(uid, true); |
| - else if (muteCb.checked) setPersonMuted(uid, true); |
| - // Success replaces the WHOLE sheet — a frozen form with its buttons |
| - // gone reads as broken, not done. role=status so AT announces it. |
| - card.textContent = ""; |
| - const done = document.createElement("div"); |
| - done.className = "report-done"; |
| - done.setAttribute("role", "status"); |
| - done.textContent = "✓ Report sent — the admins will take it from here."; |
| - card.appendChild(done); |
| - setTimeout(() => backdrop.remove(), 1400); |
| - } catch { |
| - unfreeze("Couldn't send — try again"); |
| - } |
| - }; |
| - foot.appendChild(cancel); |
| - foot.appendChild(send); |
| - card.appendChild(foot); |
| -} |
| - |
| -// ---- Blocked people (issue #578) --------------------------------------------- |
| -// Who I've blocked. The record lives server-side (it's what ENFORCES no new |
| -// contact — DMs, group adds, pushes); blocking also mutes client-side so their |
| -// existing messages fold away. They're never told. |
| -let blockedPeople = new Set(); |
| - |
| -async function loadBlocks() { |
| - try { |
| - const r = await fetch(`/api/blocks?token=${encodeURIComponent(session.token)}`); |
| - if (!r.ok) return; |
| - const body = await r.json(); |
| - blockedPeople = new Set(body.blocked || []); |
| - } catch {} |
| -} |
| - |
| -async function setPersonBlocked(uid, on) { |
| - if (!uid || uid === myUserId) return false; |
| - try { |
| - const r = on |
| - ? await fetch("/api/blocks", { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, user_id: uid }), |
| - }) |
| - : await fetch(`/api/blocks/${encodeURIComponent(uid)}`, { |
| - method: "DELETE", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token }), |
| - }); |
| - if (!r.ok) return false; |
| - } catch { |
| - return false; |
| - } |
| - if (on) { |
| - blockedPeople.add(uid); |
| - setPersonMuted(uid, true); // block implies you stop seeing them |
| - const dm = existingDM(uid); |
| - if (dm) { |
| - dmClosed[dm.id] = true; // the server just closed it; mirror at once |
| - hidden[dm.id] = true; |
| - } |
| - } else { |
| - blockedPeople.delete(uid); // the mute stays until they unmute — a separate choice |
| - } |
| - renderThreadList(); |
| - renderActiveSurfaces(); |
| - return true; |
| -} |
| |
| function rosterLabel(userId) { |
| const e = roster.find((x) => x.user_id === userId); |