// Moderation (issue #578): muted people, report-to-admins, and blocked // people. Mute/block sets ride a sealed prefs blob under the member's own // keypair (server-blind, synced across devices, same shape as nicknames); // reports seal to each admin's public key. Extracted verbatim from shell.js — // state comes from shellstate.js; the one outward effect (repainting the open // thread / thread list after a change) is the injected `repaint` sink. import { openSealed, sealForPub } from "/static/crypto.js"; import { displayNameFor } from "/static/ui.js"; import { session, roster, myUserId, nicknames, hidden, dmClosed, threadName } from "/static/shellstate.js"; import { verbs } from "/static/shellverbs.js"; let repaint = () => {}; export function onModerationChange(fn) { repaint = fn; } // ---- 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. export 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; } } export 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). export function setPersonMuted(uid, on) { if (!uid || uid === myUserId) return; if (on) mutedPeople.add(uid); else mutedPeople.delete(uid); mutedOps.set(uid, on); saveModPrefs(); verbs.syncThreadKeysToSW(); // keep the SW's muted-people set current repaint(false); // 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. export 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 = verbs.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. export let blockedPeople = new Set(); export 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 {} } export 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 = verbs.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 } repaint(true); // blocked-set changes also re-badge the thread list return true; }