| 1 | // Moderation (issue #578): muted people, report-to-admins, and blocked |
| 2 | // people. Mute/block sets ride a sealed prefs blob under the member's own |
| 3 | // keypair (server-blind, synced across devices, same shape as nicknames); |
| 4 | // reports seal to each admin's public key. Extracted verbatim from shell.js — |
| 5 | // state comes from shellstate.js; the one outward effect (repainting the open |
| 6 | // thread / thread list after a change) is the injected `repaint` sink. |
| 7 | import { openSealed, sealForPub } from "/static/crypto.js"; |
| 8 | import { displayNameFor } from "/static/ui.js"; |
| 9 | import { session, roster, myUserId, nicknames, hidden, dmClosed, threadName } from "/static/shellstate.js"; |
| 10 | import { verbs } from "/static/shellverbs.js"; |
| 11 | |
| 12 | let repaint = () => {}; |
| 13 | export function onModerationChange(fn) { |
| 14 | repaint = fn; |
| 15 | } |
| 16 | |
| 17 | // ---- Muted people (issue #578) ---------------------------------------------- |
| 18 | // Who I've muted, as a private set synced across my devices via the sealed |
| 19 | // prefs blob (/api/prefs) — the same seal-to-my-own-key + compare-and-swap |
| 20 | // shape as nicknames. Fully client-side: a muted person's messages fold to a |
| 21 | // quiet stub and never notify, but nothing changes for them and the server |
| 22 | // never learns who's muted. |
| 23 | export let mutedPeople = new Set(); |
| 24 | const mutedOps = new Map(); // uid -> true (mute) | false (unmute), pending sync |
| 25 | let mutedRetryTimer = 0; |
| 26 | let prefsVersion = 0; |
| 27 | |
| 28 | async function fetchServerPrefs() { |
| 29 | if (!session.userPriv) return null; |
| 30 | try { |
| 31 | const r = await fetch(`/api/prefs?token=${encodeURIComponent(session.token)}`); |
| 32 | if (!r.ok) return null; |
| 33 | const body = await r.json(); |
| 34 | prefsVersion = body.version || 0; |
| 35 | if (!body.prefs_blob) return {}; |
| 36 | const obj = await openSealed(body.prefs_blob, session.userPriv); |
| 37 | return obj || {}; |
| 38 | } catch { |
| 39 | return null; |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | export async function loadModPrefs() { |
| 44 | const server = await fetchServerPrefs(); |
| 45 | if (server) mutedPeople = new Set(server.mutedPeople || []); |
| 46 | } |
| 47 | |
| 48 | // Read-modify-write with the same serialized chain + retry the nicknames blob |
| 49 | // uses: replay only this device's pending ops over a fresh server read. |
| 50 | let prefsSaveChain = Promise.resolve(true); |
| 51 | function saveModPrefs() { |
| 52 | prefsSaveChain = prefsSaveChain.then(saveModPrefsNow, saveModPrefsNow); |
| 53 | return prefsSaveChain; |
| 54 | } |
| 55 | |
| 56 | async function saveModPrefsNow() { |
| 57 | if (!session.userPriv || !session.userPub) return true; |
| 58 | if (!mutedOps.size) return true; |
| 59 | const included = [...mutedOps.entries()]; |
| 60 | const base = await fetchServerPrefs(); |
| 61 | if (!base) return queuePrefsRetry(); |
| 62 | const baseVersion = prefsVersion; |
| 63 | const merged = new Set(base.mutedPeople || []); |
| 64 | for (const [uid, on] of included) { |
| 65 | if (on) merged.add(uid); |
| 66 | else merged.delete(uid); |
| 67 | } |
| 68 | try { |
| 69 | const blob = await sealForPub(session.userPub, { v: 1, mutedPeople: [...merged] }); |
| 70 | const r = await fetch("/api/prefs", { |
| 71 | method: "POST", |
| 72 | headers: { "Content-Type": "application/json" }, |
| 73 | body: JSON.stringify({ token: session.token, prefs_blob: blob, version: baseVersion }), |
| 74 | }); |
| 75 | if (!r.ok) return queuePrefsRetry(); |
| 76 | prefsVersion = ((await r.json()) || {}).version || baseVersion + 1; |
| 77 | mutedPeople = merged; // adopt another device's writes too |
| 78 | for (const [uid, on] of included) if (mutedOps.get(uid) === on) mutedOps.delete(uid); |
| 79 | return true; |
| 80 | } catch { |
| 81 | return queuePrefsRetry(); |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | function queuePrefsRetry() { |
| 86 | clearTimeout(mutedRetryTimer); |
| 87 | mutedRetryTimer = setTimeout(() => { |
| 88 | if (mutedOps.size) saveModPrefs(); |
| 89 | }, 5000); |
| 90 | return false; |
| 91 | } |
| 92 | |
| 93 | // Mute or unmute a person, applying locally at once (the sync is eventual). |
| 94 | export function setPersonMuted(uid, on) { |
| 95 | if (!uid || uid === myUserId) return; |
| 96 | if (on) mutedPeople.add(uid); |
| 97 | else mutedPeople.delete(uid); |
| 98 | mutedOps.set(uid, on); |
| 99 | saveModPrefs(); |
| 100 | verbs.syncThreadKeysToSW(); // keep the SW's muted-people set current |
| 101 | repaint(false); // fold/unfold their bubbles in the open thread |
| 102 | } |
| 103 | |
| 104 | // ---- Report to admins (issue #578) ------------------------------------------ |
| 105 | // The report sheet: category + optional words + the quoted message, sealed by |
| 106 | // THIS client to each admin's public key and posted as opaque blobs. The |
| 107 | // server keeps only queue metadata (who reported whom, where) — it cannot |
| 108 | // read a report, and the sheet says plainly that admins will. |
| 109 | export function openReportSheet({ uid, threadId, message }) { |
| 110 | const e = roster.find((x) => x.user_id === uid); |
| 111 | if (!e) return; |
| 112 | const who = displayNameFor(nicknames[uid], e.label) || "this member"; |
| 113 | const backdrop = verbs.modal(); |
| 114 | const card = backdrop.querySelector(".sheet"); |
| 115 | const h = document.createElement("h2"); |
| 116 | h.textContent = `Report ${who}`; |
| 117 | card.appendChild(h); |
| 118 | |
| 119 | // Category — the familiar triad, as radio chips. |
| 120 | const catWrap = document.createElement("div"); |
| 121 | catWrap.className = "report-cats"; |
| 122 | catWrap.setAttribute("role", "radiogroup"); |
| 123 | catWrap.setAttribute("aria-label", "Reason"); |
| 124 | let category = ""; |
| 125 | const cats = [ |
| 126 | ["spam", "Spam"], |
| 127 | ["harassment", "Harassment"], |
| 128 | ["other", "Something else"], |
| 129 | ]; |
| 130 | for (const [val, label] of cats) { |
| 131 | const b = document.createElement("button"); |
| 132 | b.type = "button"; |
| 133 | b.className = "report-cat"; |
| 134 | b.textContent = label; |
| 135 | b.setAttribute("role", "radio"); |
| 136 | b.setAttribute("aria-checked", "false"); |
| 137 | b.onclick = () => { |
| 138 | category = val; |
| 139 | for (const x of catWrap.children) { |
| 140 | x.classList.toggle("on", x === b); |
| 141 | x.setAttribute("aria-checked", x === b ? "true" : "false"); |
| 142 | } |
| 143 | send.disabled = false; |
| 144 | }; |
| 145 | catWrap.appendChild(b); |
| 146 | } |
| 147 | card.appendChild(catWrap); |
| 148 | |
| 149 | // The quoted evidence — shown so the reporter knows exactly what travels. |
| 150 | if (message && (message.text || (message.attachments || []).length)) { |
| 151 | const q = document.createElement("blockquote"); |
| 152 | q.className = "report-quote"; |
| 153 | const qwho = document.createElement("strong"); |
| 154 | qwho.textContent = who + ": "; |
| 155 | q.appendChild(qwho); |
| 156 | q.appendChild( |
| 157 | document.createTextNode(message.text || (message.attachments || []).map((a) => a.name || "attachment").join(", ")), |
| 158 | ); |
| 159 | card.appendChild(q); |
| 160 | } |
| 161 | |
| 162 | const ta = document.createElement("textarea"); |
| 163 | ta.rows = 3; |
| 164 | ta.maxLength = 1000; |
| 165 | ta.placeholder = "Anything the admins should know (optional)"; |
| 166 | card.appendChild(ta); |
| 167 | |
| 168 | // Honest copy — testimony, not proof; admins see what you send. |
| 169 | const note = document.createElement("div"); |
| 170 | note.className = "field-note"; |
| 171 | note.textContent = |
| 172 | "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."; |
| 173 | card.appendChild(note); |
| 174 | |
| 175 | // One tap to also stop seeing them. |
| 176 | const muteRow = document.createElement("label"); |
| 177 | muteRow.className = "report-mute-row"; |
| 178 | const muteCb = document.createElement("input"); |
| 179 | muteCb.type = "checkbox"; |
| 180 | muteRow.appendChild(muteCb); |
| 181 | muteRow.appendChild(document.createTextNode(` Also mute ${who}`)); |
| 182 | if (!mutedPeople.has(uid)) card.appendChild(muteRow); |
| 183 | const blockRow2 = document.createElement("label"); |
| 184 | blockRow2.className = "report-mute-row"; |
| 185 | const blockCb = document.createElement("input"); |
| 186 | blockCb.type = "checkbox"; |
| 187 | blockRow2.appendChild(blockCb); |
| 188 | blockRow2.appendChild(document.createTextNode(` Also block ${who} (mutes, ends your DM, stops new contact)`)); |
| 189 | if (!blockedPeople.has(uid)) card.appendChild(blockRow2); |
| 190 | |
| 191 | const foot = document.createElement("div"); |
| 192 | foot.className = "row"; |
| 193 | foot.style.cssText = "justify-content:flex-end;gap:8px;margin-top:14px"; |
| 194 | const cancel = document.createElement("button"); |
| 195 | cancel.className = "secondary"; |
| 196 | cancel.textContent = "Cancel"; |
| 197 | cancel.onclick = () => backdrop.remove(); |
| 198 | const send = document.createElement("button"); |
| 199 | send.textContent = "Send report"; |
| 200 | send.disabled = true; // until a category is picked |
| 201 | send.onclick = async () => { |
| 202 | // Sealing to every admin key + the POST takes a beat — freeze the whole |
| 203 | // form and spin on the button so the pause reads as work, not a hang. |
| 204 | const controls = [send, cancel, ta, muteCb, blockCb, ...catWrap.querySelectorAll("button")]; |
| 205 | const freeze = (on) => { |
| 206 | for (const c of controls) c.disabled = on; |
| 207 | }; |
| 208 | freeze(true); |
| 209 | send.textContent = ""; |
| 210 | const spin = document.createElement("span"); |
| 211 | spin.className = "btn-spinner"; |
| 212 | spin.setAttribute("aria-hidden", "true"); |
| 213 | send.append(spin, document.createTextNode(" Sending…")); |
| 214 | send.setAttribute("aria-busy", "true"); |
| 215 | const unfreeze = (label) => { |
| 216 | freeze(false); |
| 217 | send.removeAttribute("aria-busy"); |
| 218 | send.textContent = label; |
| 219 | }; |
| 220 | try { |
| 221 | const admins = roster.filter((x) => x.role === "admin" && x.public_key && !x.is_agent); |
| 222 | if (!admins.length) throw new Error("no admins"); |
| 223 | const content = { |
| 224 | v: 1, |
| 225 | category, |
| 226 | text: ta.value.trim(), |
| 227 | reported: { uid, name: e.label || "" }, |
| 228 | threadName: threadId ? threadName[threadId] || "" : "", |
| 229 | }; |
| 230 | if (message) { |
| 231 | content.evidence = [ |
| 232 | { |
| 233 | sid: message.sid || 0, |
| 234 | uid: message.uid || "", |
| 235 | name: message.name || "", |
| 236 | ts: message.ts || 0, |
| 237 | text: message.text || "", |
| 238 | attachments: (message.attachments || []).map((a) => a.name || a.type || "attachment"), |
| 239 | }, |
| 240 | ]; |
| 241 | } |
| 242 | const blobs = []; |
| 243 | for (const a of admins) { |
| 244 | blobs.push({ admin_id: a.user_id, blob: await sealForPub(a.public_key, content) }); |
| 245 | } |
| 246 | const r = await fetch("/api/reports", { |
| 247 | method: "POST", |
| 248 | headers: { "Content-Type": "application/json" }, |
| 249 | body: JSON.stringify({ token: session.token, reported_id: uid, thread_id: threadId || "", blobs }), |
| 250 | }); |
| 251 | if (r.status === 429) { |
| 252 | unfreeze("Too many reports just now — try later"); |
| 253 | send.disabled = true; // the brake decides when, not another click |
| 254 | return; |
| 255 | } |
| 256 | if (!r.ok) throw new Error("server"); |
| 257 | if (blockCb.checked) await setPersonBlocked(uid, true); |
| 258 | else if (muteCb.checked) setPersonMuted(uid, true); |
| 259 | // Success replaces the WHOLE sheet — a frozen form with its buttons |
| 260 | // gone reads as broken, not done. role=status so AT announces it. |
| 261 | card.textContent = ""; |
| 262 | const done = document.createElement("div"); |
| 263 | done.className = "report-done"; |
| 264 | done.setAttribute("role", "status"); |
| 265 | done.textContent = "✓ Report sent — the admins will take it from here."; |
| 266 | card.appendChild(done); |
| 267 | setTimeout(() => backdrop.remove(), 1400); |
| 268 | } catch { |
| 269 | unfreeze("Couldn't send — try again"); |
| 270 | } |
| 271 | }; |
| 272 | foot.appendChild(cancel); |
| 273 | foot.appendChild(send); |
| 274 | card.appendChild(foot); |
| 275 | } |
| 276 | |
| 277 | // ---- Blocked people (issue #578) --------------------------------------------- |
| 278 | // Who I've blocked. The record lives server-side (it's what ENFORCES no new |
| 279 | // contact — DMs, group adds, pushes); blocking also mutes client-side so their |
| 280 | // existing messages fold away. They're never told. |
| 281 | export let blockedPeople = new Set(); |
| 282 | |
| 283 | export async function loadBlocks() { |
| 284 | try { |
| 285 | const r = await fetch(`/api/blocks?token=${encodeURIComponent(session.token)}`); |
| 286 | if (!r.ok) return; |
| 287 | const body = await r.json(); |
| 288 | blockedPeople = new Set(body.blocked || []); |
| 289 | } catch {} |
| 290 | } |
| 291 | |
| 292 | export async function setPersonBlocked(uid, on) { |
| 293 | if (!uid || uid === myUserId) return false; |
| 294 | try { |
| 295 | const r = on |
| 296 | ? await fetch("/api/blocks", { |
| 297 | method: "POST", |
| 298 | headers: { "Content-Type": "application/json" }, |
| 299 | body: JSON.stringify({ token: session.token, user_id: uid }), |
| 300 | }) |
| 301 | : await fetch(`/api/blocks/${encodeURIComponent(uid)}`, { |
| 302 | method: "DELETE", |
| 303 | headers: { "Content-Type": "application/json" }, |
| 304 | body: JSON.stringify({ token: session.token }), |
| 305 | }); |
| 306 | if (!r.ok) return false; |
| 307 | } catch { |
| 308 | return false; |
| 309 | } |
| 310 | if (on) { |
| 311 | blockedPeople.add(uid); |
| 312 | setPersonMuted(uid, true); // block implies you stop seeing them |
| 313 | const dm = verbs.existingDM(uid); |
| 314 | if (dm) { |
| 315 | dmClosed[dm.id] = true; // the server just closed it; mirror at once |
| 316 | hidden[dm.id] = true; |
| 317 | } |
| 318 | } else { |
| 319 | blockedPeople.delete(uid); // the mute stays until they unmute — a separate choice |
| 320 | } |
| 321 | repaint(true); // blocked-set changes also re-badge the thread list |
| 322 | return true; |
| 323 | } |
| 324 | |