eleven / messenger Public

web: extract moderation (#578 cluster) into moderation.js (#943)

Verbatim move of the muted/report/blocked sections — the sealed-prefs
sync chain, the report sheet, and the block set. State comes from
shellstate.js (nicknames joins it — same shared-state class as the
seven; two inline assignment forms the greps missed were caught by the
boot smoke and converted). The two repaint calls become the injected
onModerationChange sink. The 'Blocked people' banner span's other
tenants (roster helpers, instance-key bootstrap) were traced as
non-moderation and stay put.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell fcf99567fad0450eaa2a25f5bd047f8f04b6ebf7 parent c2f4d10
4 files changed, +337 −313
  • CLAUDE.md +3 −0
  • web/static/moderation.js +322 −0
  • web/static/shell.js +10 −313
  • web/static/shellstate.js +2 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index 61efba0..218da63 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -186,6 +186,9 @@ Frontend module layout:
- `shellstate.js` — the shell's shared mutable state (session, threads,
threadKeys, activeId, ws, roster, myUserId): `export let` + assignment-only
setters. Design: `docs/shell-modules.md`. State only, never behaviour.
+- `moderation.js` — muted people, report-to-admins, blocked people (#578):
+ sealed prefs under the member's own keypair; repaints via the
+ `onModerationChange` sink.
- `store.js` — the local message store (#457): IndexedDB cache of RAW server
responses (ciphertext rows + wrapped keys, never plaintext) that boot and
thread-opens hydrate from before reconciling over the network. Its pure
diff --git a/web/static/moderation.js b/web/static/moderation.js
new file mode 100644
index 0000000..71f22be
--- /dev/null
+++ b/web/static/moderation.js
@@ -0,0 +1,322 @@
+// 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 } from "/static/shellstate.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();
+ 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 = 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 = 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;
+}
diff --git a/web/static/shell.js b/web/static/shell.js
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);
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index 4e15a9e..cdaeee6 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -13,6 +13,7 @@ export let threads = []; // server Thread objects (id, kind, name_blob, members,
export let threadKeys = {}; // thread id -> CryptoKey
export let activeId = null; // the open thread's id, or null
export let ws = null; // the live WebSocket, or null between connects
+export let nicknames = {}; // uid -> the name I call them (per-viewer names, #577)
export function setSession(v) { session = v; }
export function setRoster(v) { roster = v; }
@@ -21,3 +22,4 @@ export function setThreads(v) { threads = v; }
export function setThreadKeys(v) { threadKeys = v; }
export function setActiveId(v) { activeId = v; }
export function setWs(v) { ws = v; }
+export function setNicknames(v) { nicknames = v; }