eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
7import { openSealed, sealForPub } from "/static/crypto.js";
8import { displayNameFor } from "/static/ui.js";
9import { session, roster, myUserId, nicknames, hidden, dmClosed, threadName } from "/static/shellstate.js";
10import { verbs } from "/static/shellverbs.js";
11
12let repaint = () => {};
13export 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.
23export let mutedPeople = new Set();
24const mutedOps = new Map(); // uid -> true (mute) | false (unmute), pending sync
25let mutedRetryTimer = 0;
26let prefsVersion = 0;
27
28async 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
43export 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.
50let prefsSaveChain = Promise.resolve(true);
51function saveModPrefs() {
52 prefsSaveChain = prefsSaveChain.then(saveModPrefsNow, saveModPrefsNow);
53 return prefsSaveChain;
54}
55
56async 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
85function 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).
94export 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.
109export 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.
281export let blockedPeople = new Set();
282
283export 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
292export 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