aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-23 12:58:40 -0700
committerAdam Soltys <[email protected]>2026-09-23 12:58:40 -0700
commit2fbfe122ae0e7d59a6b1df08cccd091010bc94ec (patch)
tree0e9eebdc4f89eba61ec078a3a4345f35d507ca07
parentf4c2f914e8349802feb3d4b5e10165437ef9750d (diff)
downloadcoinosv3-2fbfe122ae0e7d59a6b1df08cccd091010bc94ec.tar.gz
coinosv3-2fbfe122ae0e7d59a6b1df08cccd091010bc94ec.zip
Moderation: the whole mute list everywhere, follows' reports fold posts, strangers' spam hidden.
Mutes were only applied when the feed merged and painted. One predicate (hiddenWhy / hiddenPk) now gates the feed, thread replies, notifications, the who-reacted panel and counts, quote cards, rooms, the DM list and the DM push. The list itself is read in full — people, words, hashtags, whole threads, and the nip44 private half — and edited through one publish path (mute thread and hidden words from the ⋯ menu and a Settings → Nostr card). Reports (NIP-56) from the people you follow are read from their outbox relays every half hour; a post or author they flagged is folded behind who said so, with Show. Report… in the ⋯ menu publishes a kind 1984 and hides the post for you. Amethyst's stranger rules: a post of 60+ characters repeated under another id is a duplicate and five duplicates hide the author for the session; more than ten hashtags hides the post. Both switchable in Settings. Co-Authored-By: Claude Fable 5.1 <[email protected]>
-rw-r--r--src/dm-inbox.js2
-rw-r--r--src/features/messages.js365
-rw-r--r--src/i18n.js30
-rw-r--r--tools/moderation-test.js186
-rw-r--r--tools/post-actions-test.js6
5 files changed, 553 insertions, 36 deletions
diff --git a/src/dm-inbox.js b/src/dm-inbox.js
index a91a8e0..b9eec3a 100644
--- a/src/dm-inbox.js
+++ b/src/dm-inbox.js
@@ -90,6 +90,7 @@ export async function classifyDm(wrap, inboxes) {
author,
mine: author === rec.pubkey,
followed: (rec.follows || []).includes(author),
+ muted: (rec.muted || []).includes(author),
// Someone you're already talking to is not a stranger, whether or not
// you ever followed them.
known: (rec.known || []).includes(author),
@@ -110,6 +111,7 @@ export async function classifyDm(wrap, inboxes) {
export function shouldNotifyDm(verdict) {
if (!verdict) return true;
if (verdict.mine) return false;
+ if (verdict.muted) return false; // muted in the app: no buzz either
if (verdict.followed || verdict.known) return true;
return !verdict.hasList;
}
diff --git a/src/features/messages.js b/src/features/messages.js
index 34765a9..92bf030 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -1959,6 +1959,7 @@ export function messagesFeature(ctx) {
pubkey: id.pubkey,
sk: id.signer instanceof Uint8Array ? bytesToHex(id.signer) : null,
follows, known, names, hasList: !!newest, updated: Date.now(),
+ muted: [...mutesNow().set], // no buzz from a muted sender either
});
} catch { inboxAt = 0; }
}
@@ -4170,7 +4171,8 @@ export function messagesFeature(ctx) {
const feedStaged = new Set();
async function mergeFeed(evs, opts = {}, c = feedNow()) {
const known = new Set([...c.notes, ...(c.catchup || [])].map((e) => e.id));
- const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !isMuted(e.pubkey)
+ for (const e of evs || []) noteForSpam(e);
+ const add = (evs || []).filter((e) => e.kind === 1 && !isReply(e) && !hidden(e)
&& !known.has(e.id) && !feedStaged.has(e.id) && known.add(e.id) && feedStaged.add(e.id));
if (!add.length) return false;
await notesReady(add);
@@ -4486,6 +4488,7 @@ export function messagesFeature(ctx) {
h('span', { class: 'small faint' }, t('noteRefNotFound')));
}
const ev = c.ev;
+ if (hidden(ev)) return h('div', { class: 'quote-card' }, h('span', { class: 'small faint' }, t('quoteHidden')));
return h('div', {
class: 'quote-card clickable',
onClick: (e) => { e.stopPropagation(); openNoteThread(ev); },
@@ -5002,21 +5005,39 @@ export function messagesFeature(ctx) {
// list, so it follows you to other clients. Blocking is the same list plus
// an unfollow — the stronger, more deliberate door.
const MUTES = 'mutes';
- let mutes = null; // { set, tags, content, at }
+ let mutes = null; // { set, words, hashtags, threads, tags, priv, content, at }
let mutesAt = 0;
+ // Everything a mute list holds (NIP-51): people, words, hashtags, whole
+ // threads — public tags and the private half (nip44 to yourself) alike.
+ const muteSets = (tags) => ({
+ set: new Set(pTags(tags).map((x) => x[1])),
+ words: [...new Set(tags.filter((x) => x[0] === 'word' && x[1]).map((x) => String(x[1]).toLowerCase().trim()).filter(Boolean))],
+ hashtags: new Set(tags.filter((x) => x[0] === 't' && x[1]).map((x) => normTopic(x[1])).filter(Boolean)),
+ threads: new Set(tags.filter((x) => x[0] === 'e' && /^[0-9a-f]{64}$/.test(x[1] || '')).map((x) => x[1])),
+ });
+ const withSets = (m) => ({ ...m, priv: m.priv || [], ...muteSets([...(m.tags || []), ...(m.priv || [])]) });
function mutesNow() {
if (!mutes) {
let st2 = null;
try { st2 = wallet.loadFeatureState(MUTES, null); } catch {}
- const tags = (st2 && st2.tags) || [];
- mutes = { set: new Set(pTags(tags).map((x) => x[1])), tags, content: (st2 && st2.c) || '', at: (st2 && st2.at) || 0 };
+ mutes = withSets({ tags: (st2 && st2.tags) || [], priv: (st2 && st2.priv) || [], content: (st2 && st2.c) || '', at: (st2 && st2.at) || 0 });
}
return mutes;
}
const isMuted = (pk) => mutesNow().set.has(pk);
function saveMutes(m) {
- mutes = m;
- try { wallet.saveFeatureState(MUTES, { tags: m.tags, c: m.content, at: m.at }); } catch {}
+ mutes = withSets(m);
+ try { wallet.saveFeatureState(MUTES, { tags: mutes.tags, priv: mutes.priv, c: mutes.content, at: mutes.at }); } catch {}
+ }
+ // The private half of a list: nip44 to yourself, a JSON array of tags.
+ async function privateTagsOf(ev) {
+ if (!ev || !ev.content) return [];
+ const c = (await selfCryptors()).find((x) => x.pk === ev.pubkey);
+ if (!c) return [];
+ try {
+ const tags = JSON.parse(await c.dec(ev.content));
+ return Array.isArray(tags) ? tags.filter((x) => Array.isArray(x) && x[0] && x[1]) : [];
+ } catch { return []; } // nip04 from an older client, or not ours to read
}
async function syncMutes({ force = false } = {}) {
const me = mePk();
@@ -5028,41 +5049,298 @@ export function messagesFeature(ctx) {
const newest = (evs || []).sort((a, b) => b.created_at - a.created_at)[0];
const cur = mutesNow();
if (newest && newest.created_at > cur.at) {
- saveMutes({ set: new Set(pTags(newest.tags).map((x) => x[1])), tags: newest.tags || [], content: newest.content || '', at: newest.created_at });
+ saveMutes({ tags: newest.tags || [], priv: await privateTagsOf(newest), content: newest.content || '', at: newest.created_at });
scheduleRepaint();
}
} catch { mutesAt = 0; }
return mutesNow();
}
- // Same care as the follow list: publish onto the freshest copy the relays
- // will give us, preserving anything in it we didn't write (the encrypted
- // content other clients keep their private mutes in, above all).
- async function toggleMute(pk, { block = false } = {}) {
+ // Same care as the follow list: a change is applied to the freshest copy
+ // the relays will give us (the note in toggleFollow), never to our own
+ // optimistic paint. The private half is re-encrypted only when the change
+ // touched it and we hold the key; otherwise it rides along untouched.
+ async function updateMutes(change) {
const id = await requireIdentity();
- if (pk === id.pubkey) return;
const before = mutesNow();
- saveMutes({ ...before, set: new Set(before.set.has(pk) ? [...before.set].filter((x) => x !== pk) : [...before.set, pk]) });
+ const paint = change(before);
+ if (!paint) return;
+ saveMutes({ ...before, ...paint });
render();
try {
await syncMutes({ force: true });
- const fetched = mutesNow(); // see the note in toggleFollow
+ const fetched = mutesNow();
const base = fetched.at > before.at ? fetched : before;
- const had = base.set.has(pk);
- const tags = had ? base.tags.filter((x) => !(x[0] === 'p' && x[1] === pk)) : [...base.tags, ['p', pk]];
+ const next = change(base) || {};
+ const tags = next.tags || base.tags;
+ const priv = next.priv || base.priv || [];
+ let content = base.content || '';
+ if (next.priv && JSON.stringify(next.priv) !== JSON.stringify(base.priv || [])) {
+ const c = (await selfCryptors()).find((x) => x.pk === id.pubkey);
+ if (c) content = priv.length ? await c.enc(JSON.stringify(priv)) : '';
+ }
const created_at = Math.max(Math.floor(Date.now() / 1000), base.at + 1);
- const partial = { kind: 10000, content: base.content || '', created_at, tags };
+ const partial = { kind: 10000, content, created_at, tags };
const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
const ok = await publishOn(zapRelays(), evt);
if (!ok) throw new Error(t('msgSendFailed'));
- saveMutes({ set: new Set(pTags(tags).map((x) => x[1])), tags, content: base.content || '', at: created_at });
- if (block && !had && isFollowing(pk)) await toggleFollow(pk);
- toast(had ? t('postUnmuted') : block ? t('postBlocked') : t('postMuted'));
+ saveMutes({ tags, priv, content, at: created_at });
render();
} catch (e) {
saveMutes(before);
if (!(e instanceof NoIdentity)) toast(e.message || String(e));
render();
+ throw e;
+ }
+ }
+ const tagIn = (tags, k, v) => (tags || []).some((x) => x[0] === k && x[1] === v);
+ const tagOut = (tags, k, v) => (tags || []).filter((x) => !(x[0] === k && x[1] === v));
+ // One entry in or out (want: true/false, or null to flip), both halves.
+ const muteToggle = (k, v, want = null) => (m) => {
+ const has = tagIn(m.tags, k, v) || tagIn(m.priv, k, v);
+ const on = want == null ? !has : want;
+ if (on === has) return null;
+ return on ? { tags: [...m.tags, [k, v]] } : { tags: tagOut(m.tags, k, v), priv: tagOut(m.priv, k, v) };
+ };
+ async function toggleMute(pk, { block = false } = {}) {
+ const id = await requireIdentity();
+ if (pk === id.pubkey) return;
+ const had = isMuted(pk);
+ try {
+ await updateMutes(muteToggle('p', pk));
+ if (block && !had && isFollowing(pk)) await toggleFollow(pk);
+ toast(had ? t('postUnmuted') : block ? t('postBlocked') : t('postMuted'));
+ } catch {}
+ }
+ const toggleMuteThread = (rootId) => updateMutes(muteToggle('e', rootId)).catch(() => {});
+ const toggleMuteWord = (word, want = null) => updateMutes(muteToggle('word', String(word || '').trim().toLowerCase(), want)).catch(() => {});
+ const toggleMuteTag = (tag, want = null) => updateMutes(muteToggle('t', normTopic(tag), want)).catch(() => {});
+
+ // ---- moderation: one door for everything that shows a post --------------
+ // hiddenWhy(ev) says why a post is not shown (or null); hiddenPk(pk) the
+ // same for a person (their zaps, reactions, DMs, chat). Every list in this
+ // file asks here: the feed, threads, notifications, the who-reacted panel,
+ // rooms, DMs, quote cards. Two rules are Amethyst's, and are what makes
+ // its feeds feel clean: repeated content from strangers is spam, and a
+ // post your follows reported is folded (not hidden) behind who said so.
+ const modOn = (k) => (st().mod || {})[k] !== false;
+ const setMod = (k, v) => { const s = st(); (s.mod ||= {})[k] = v; save(s); render(); };
+ // A post of 60+ characters seen again under another id is a duplicate;
+ // five duplicates and the author is hidden for the session. Short posts
+ // ("GM") and nostr:-mention commands are exempt. Session memory only.
+ const spamSeen = new Map(); // content+tags -> first id
+ const spamDup = new Set(); // ids that were repeats
+ const spamBy = new Map(); // pk -> Set of repeated ids
+ function noteForSpam(ev) {
+ if (!ev || ev.kind !== 1 || !ev.id || ev.pending) return;
+ const c = String(ev.content || '');
+ if (c.length < 60) return;
+ if (c.length < 180 && c.startsWith('nostr:')) return;
+ const key = c + '\u0000' + JSON.stringify(ev.tags || []);
+ const first = spamSeen.get(key);
+ if (!first) {
+ spamSeen.set(key, ev.id);
+ if (spamSeen.size > 3000) spamSeen.delete(spamSeen.keys().next().value);
+ return;
+ }
+ if (first === ev.id) return;
+ spamDup.add(ev.id);
+ const s = spamBy.get(ev.pubkey) || spamBy.set(ev.pubkey, new Set()).get(ev.pubkey);
+ s.add(first); s.add(ev.id);
+ }
+ const SPAM_DUPES = 5;
+ const isSpammer = (pk) => modOn('spam') && (spamBy.get(pk) || { size: 0 }).size >= SPAM_DUPES && !isFollowing(pk) && !isMe(pk);
+ const HASHTAG_MAX = 10;
+ const hashtagSpam = (ev) => ev.kind === 1 && modOn('spam') && !isFollowing(ev.pubkey)
+ && ((ev.tags || []).filter((x) => x[0] === 't').length > HASHTAG_MAX
+ || (String(ev.content || '').match(/(^|\s)#[\p{L}\p{N}_]{2,}/gu) || []).length > HASHTAG_MAX);
+ function hiddenWhy(ev) {
+ if (!ev || !ev.pubkey || isMe(ev.pubkey)) return null;
+ const m = mutesNow();
+ if (m.set.has(ev.pubkey)) return 'muted';
+ if (ev.kind === 1) {
+ if (m.threads.size && m.threads.has(rootIdOf(ev))) return 'thread';
+ const text = String(ev.content || '').toLowerCase();
+ if (m.words.length && m.words.some((w) => text.includes(w))) return 'word';
+ if (m.hashtags.size) {
+ if ((ev.tags || []).some((x) => x[0] === 't' && m.hashtags.has(normTopic(x[1])))) return 'hashtag';
+ for (const mm of text.matchAll(/#([\p{L}\p{N}_]+)/gu)) if (m.hashtags.has(normTopic(mm[1]))) return 'hashtag';
+ }
+ }
+ if (reportsNow().mine.has(ev.id)) return 'reported';
+ if (!isFollowing(ev.pubkey)) {
+ if (isSpammer(ev.pubkey) || (modOn('spam') && spamDup.has(ev.id))) return 'spam';
+ if (hashtagSpam(ev)) return 'hashtags';
}
+ return null;
+ }
+ const hidden = (ev) => !!hiddenWhy(ev);
+ const hiddenPk = (pk) => !!pk && !isMe(pk) && (isMuted(pk) || isSpammer(pk) || reportsNow().mine.has(pk));
+ const visiblePks = (pks) => [...(pks || [])].filter((pk) => !hiddenPk(pk));
+
+ // ---- reports (NIP-56 kind 1984) from the people you follow --------------
+ // The web-of-trust piece — not a score from a provider, just your follows'
+ // own flags, read from their outbox relays and ours once every half hour,
+ // indexed by post and by person. A post (or its author) flagged by anyone
+ // you follow is folded; anything you flagged yourself is hidden.
+ const REPORTS = 'reports';
+ let reportsCache = null; // { ev: Map id -> Set reporter, pk: Map pk -> Set reporter, mine: Set, at }
+ let reportsAt = 0;
+ function reportsNow() {
+ if (!reportsCache) {
+ let s = null;
+ try { s = wallet.loadFeatureState(REPORTS, null); } catch {}
+ const toMap = (o) => new Map(Object.entries(o || {}).map(([k, v]) => [k, new Set(v)]));
+ reportsCache = { ev: toMap(s && s.ev), pk: toMap(s && s.pk), mine: new Set((s && s.mine) || []), at: (s && s.at) || 0 };
+ }
+ return reportsCache;
+ }
+ function saveReports() {
+ const r = reportsNow();
+ const toObj = (m) => Object.fromEntries([...m.entries()].slice(-1500).map(([k, v]) => [k, [...v].slice(0, 50)]));
+ try { wallet.saveFeatureState(REPORTS, { ev: toObj(r.ev), pk: toObj(r.pk), mine: [...r.mine].slice(-500), at: r.at }); } catch {}
+ }
+ function noteReport(ev) {
+ const r = reportsNow();
+ const mine = myPubkeys().includes(ev.pubkey);
+ const hex = (v) => /^[0-9a-f]{64}$/.test(v || '');
+ let es = 0;
+ for (const x of ev.tags || []) {
+ if (x[0] !== 'e' || !hex(x[1])) continue;
+ es++;
+ (r.ev.get(x[1]) || r.ev.set(x[1], new Set()).get(x[1])).add(ev.pubkey);
+ if (mine) r.mine.add(x[1]);
+ }
+ for (const x of ev.tags || []) {
+ // a PERSON is reported when the p tag carries a reason, or when the
+ // report names no post at all; a post report's p tag merely credits
+ // the author and is not held against them
+ if (x[0] !== 'p' || !hex(x[1]) || x[1] === ev.pubkey || (es && !x[2])) continue;
+ (r.pk.get(x[1]) || r.pk.set(x[1], new Set()).get(x[1])).add(ev.pubkey);
+ if (mine) r.mine.add(x[1]);
+ }
+ }
+ const REPORTS_MS = 30 * 60_000;
+ async function syncReports({ force = false } = {}) {
+ if (!modOn('reports') || !myPubkeys().length) return;
+ if (!force && Date.now() - reportsAt < REPORTS_MS) return;
+ reportsAt = Date.now();
+ const authors = [...new Set([...myPubkeys(), ...followsNow().set])].slice(0, FEED_AUTHORS_MAX);
+ const from = new Set(authors);
+ try {
+ await fetchRelayLists(authors);
+ const plan = outboxPlan(authors);
+ const got = await Promise.all(plan.flatMap(({ relays, authors: a }) => {
+ const chunks = [];
+ for (let i = 0; i < a.length; i += REQ_AUTHORS) chunks.push(a.slice(i, i + REQ_AUTHORS));
+ return chunks.map((chunk) => queryOn(relays, { kinds: [1984], authors: chunk, limit: 500 }, 5000).catch(() => []));
+ }));
+ for (const ev of got.flat()) if (ev && ev.kind === 1984 && from.has(ev.pubkey)) noteReport(ev);
+ reportsNow().at = Date.now();
+ saveReports();
+ scheduleRepaint();
+ } catch { reportsAt = 0; }
+ }
+ // Who among your follows flagged this post or its author.
+ function reportsOn(ev) {
+ if (!ev || !ev.id || !modOn('reports') || isMe(ev.pubkey)) return null;
+ const r = reportsNow();
+ const f = followsNow().set;
+ const by = new Set();
+ for (const pk of r.ev.get(ev.id) || []) if (f.has(pk) && pk !== ev.pubkey) by.add(pk);
+ for (const pk of r.pk.get(ev.pubkey) || []) if (f.has(pk) && pk !== ev.pubkey) by.add(pk);
+ return by.size ? [...by] : null;
+ }
+ // A flagged post, folded: who flagged it, and a tap to see it anyway.
+ function foldedRow(ev, by) {
+ for (const pk of by.slice(0, 3)) profileOf(pk);
+ const who = by.length === 1 ? t('foldReported1', { name: displayName(by[0]) }) : t('foldReportedN', { n: by.length });
+ return h('div', { class: 'row note-folded', style: 'gap:10px;align-items:center;padding:10px 0;opacity:.75' },
+ avatar(ev.pubkey, 'chat-avatar note-avatar', false),
+ h('div', { class: 'grow small muted', style: 'min-width:0' }, '\u26a0 ' + who),
+ h('button', {
+ class: 'btn-sm', type: 'button', style: 'flex-shrink:0',
+ onClick: (e) => { e.stopPropagation(); (ui.revealed ||= new Set()).add(ev.id); render(); },
+ }, t('foldShow')));
+ }
+ // Publish a report (NIP-56): the post with a reason, its author credited.
+ async function reportNote(ev, type) {
+ const id = await requireIdentity();
+ const partial = { kind: 1984, content: '', created_at: Math.floor(Date.now() / 1000), tags: [['e', ev.id, type], ['p', ev.pubkey]] };
+ const evt = id.signer instanceof Uint8Array ? finalizeEvent(partial, id.signer) : await id.signer.signEvent(partial);
+ const ok = await publishOn(zapRelays(), evt);
+ if (!ok) throw new Error(t('msgSendFailed'));
+ noteReport(evt);
+ saveReports();
+ toast(t('reportSent'));
+ render();
+ }
+ function reportSheet() {
+ if (!ui.reportPick) return null;
+ const ev = ui.reportPick;
+ const close = () => { ui.reportPick = null; render(); };
+ const reason = (type, label) => h('button', {
+ class: 'btn-block report-reason', style: 'text-align:left', type: 'button', 'data-reason': type,
+ onClick: () => { close(); reportNote(ev, type).catch((e) => { if (!(e instanceof NoIdentity)) toast(e.message || String(e)); }); },
+ }, label);
+ return h('div', { class: 'confirm-pop-backdrop', onClick: (e) => { if (e.target === e.currentTarget) close(); } },
+ h('div', { class: 'card col confirm-pop report-pick', style: 'gap:8px' },
+ h('div', { class: 'chat-name' }, t('reportTitle')),
+ h('div', { class: 'small muted' }, t('reportHelp')),
+ reason('spam', t('reportSpam')),
+ reason('impersonation', t('reportImpersonation')),
+ reason('nudity', t('reportNudity')),
+ reason('profanity', t('reportProfanity')),
+ reason('illegal', t('reportIllegal')),
+ reason('malware', t('reportMalware')),
+ reason('other', t('reportOther')),
+ h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
+ }
+ // Settings → Nostr: the mute list laid out, and the two spam rules.
+ function moderationCard() {
+ const m = mutesNow();
+ const e = ui.modEdit || (ui.modEdit = { word: '' });
+ const people = [...m.set];
+ for (const pk of people.slice(0, 30)) profileOf(pk);
+ const add = () => {
+ const w = e.word.trim();
+ if (!w) return;
+ e.word = '';
+ if (w.startsWith('#')) toggleMuteTag(w, true); else toggleMuteWord(w, true);
+ };
+ const chip = (label, onX) => h('span', { class: 'feed-chip on mod-chip', style: 'display:inline-flex;align-items:center;gap:6px' },
+ label, h('button', { class: 'linklike', type: 'button', 'aria-label': t('remove'), style: 'padding:0 2px', onClick: onX }, '\u00d7'));
+ const toggle = (k, label, help) => h('div', { class: 'col', style: 'gap:4px' },
+ h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' },
+ h('input', { type: 'checkbox', class: 'mod-' + k, checked: modOn(k), style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', onChange: (ev) => setMod(k, ev.target.checked) }),
+ h('span', {}, label)),
+ h('div', { class: 'small faint' }, help));
+ return h('div', { class: 'card col mod-card', style: 'gap:10px' },
+ h('h3', {}, t('modTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('modDesc')),
+ h('div', { class: 'small muted' }, t('modPeople')),
+ people.length
+ ? h('div', { class: 'col', style: 'gap:6px' }, ...people.slice(0, 50).map((pk) => h('div', { class: 'row gap6 mod-person', style: 'align-items:center' },
+ avatar(pk, 'chat-avatar mini', false),
+ h('span', { class: 'grow', style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, displayName(pk)),
+ h('button', { class: 'btn-sm', type: 'button', onClick: () => toggleMute(pk) }, t('postUnmute')))))
+ : h('div', { class: 'small faint' }, t('modNoPeople')),
+ h('div', { class: 'small muted' }, t('modWords')),
+ m.words.length || m.hashtags.size
+ ? h('div', { class: 'row wrap', style: 'gap:6px' },
+ ...m.words.map((w) => chip(w, () => toggleMuteWord(w, false))),
+ ...[...m.hashtags].map((x) => chip('#' + x, () => toggleMuteTag(x, false))))
+ : null,
+ h('div', { class: 'row gap6' },
+ h('input', {
+ type: 'text', class: 'grow mod-word', placeholder: t('modWordHint'), value: e.word,
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ onInput: (ev) => { e.word = ev.target.value; },
+ onKeydown: (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } },
+ }),
+ h('button', { class: 'btn-sm', type: 'button', onClick: add }, t('modAdd'))),
+ m.threads.size ? h('div', { class: 'small faint' }, t('modThreads', { n: m.threads.size })) : null,
+ toggle('spam', t('modSpamToggle'), t('modSpamHelp')),
+ toggle('reports', t('modReportsToggle'), t('modReportsHelp')));
}
const noteLink = (ev) => location.origin + '/' + (neventOf(ev.id, ev.pubkey) || ev.id);
@@ -5098,6 +5376,8 @@ export function messagesFeature(ctx) {
? item('\u2197', t('postShare'), () => { navigator.share({ url: noteLink(ev) }).catch(() => {}); })
: null,
mine || !myPubkeys().length ? null : item('\u2630', t('listAddTo'), () => { ui.listPick = ev.pubkey; render(); }),
+ mine ? null : item('\u{1F6A9}', t('postReport'), () => { ui.reportPick = ev; render(); }),
+ item('\u{1F515}', mutesNow().threads.has(rootIdOf(ev)) ? t('postUnmuteThread') : t('postMuteThread'), () => toggleMuteThread(rootIdOf(ev))),
mine ? null : item('\u{1F507}', isMuted(ev.pubkey) ? t('postUnmute') : t('postMute'), () => toggleMute(ev.pubkey).catch(() => {})),
mine || isMuted(ev.pubkey) ? null : item('⛔', t('postBlock'), () => toggleMute(ev.pubkey, { block: true }).catch(() => {}), true),
h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back'))));
@@ -5120,8 +5400,8 @@ export function messagesFeature(ctx) {
if (canZap && zapSoundOn()) warmZapSound(); // the clip is decoded before the first tap
const mineReact = myReactOn(ev.id);
const rm = reacts.get(ev.id);
- const likeN = rm ? [...rm.values()].reduce((n, { who }) => n + who.size, 0) : 0;
- const boostN = (boosts.get(ev.id) || new Set()).size;
+ const likeN = rm ? [...rm.values()].reduce((n, { who }) => n + visiblePks(who).length, 0) : 0;
+ const boostN = visiblePks(boosts.get(ev.id) || new Set()).length;
const btn = (icon, label, count, on, onClick, cls = '', extra = null) => h('button', {
class: 'note-act' + (on ? ' on' : '') + (cls ? ' ' + cls : ''), title: label, 'aria-label': label,
'aria-disabled': onClick ? undefined : 'true',
@@ -5180,15 +5460,15 @@ export function messagesFeature(ctx) {
const toggleWho = (id) => { if (whoOpenIds.has(id)) whoOpenIds.delete(id); else whoOpenIds.add(id); render(); };
function whoCount(id) {
const rm = reacts.get(id);
- let n = rm ? [...rm.values()].reduce((k, { who }) => k + who.size, 0) : 0;
- n += (boosts.get(id) || new Set()).size;
- n += (zapWho.get(id) || new Map()).size;
+ let n = rm ? [...rm.values()].reduce((k, { who }) => k + visiblePks(who).length, 0) : 0;
+ n += visiblePks(boosts.get(id) || new Set()).length;
+ n += [...(zapWho.get(id) || new Map()).values()].filter((z) => !hiddenPk(z.pk)).length;
return n;
}
function whoPanel(ev) {
- const rm = reacts.get(ev.id) || new Map();
- const bs = boosts.get(ev.id) || new Set();
- const zs = [...(zapWho.get(ev.id) || new Map()).values()].sort((a, b) => b.sats - a.sats || b.ts - a.ts);
+ const rm = new Map([...(reacts.get(ev.id) || new Map())].map(([k, r]) => [k, { ...r, who: new Set(visiblePks(r.who)) }]).filter(([, r]) => r.who.size));
+ const bs = new Set(visiblePks(boosts.get(ev.id) || new Set()));
+ const zs = [...(zapWho.get(ev.id) || new Map()).values()].filter((z) => !hiddenPk(z.pk)).sort((a, b) => b.sats - a.sats || b.ts - a.ts);
const person = (pk, extra) => h('button', {
class: 'note-who-person',
onClick: (e) => { e.stopPropagation(); openProfile(pk); },
@@ -5244,6 +5524,9 @@ export function messagesFeature(ctx) {
// an optimistic post mid-publish: visible but not yet a real event —
// dimmed, and no thread/reply/zap until its signed self takes over
const pending = !!ev.pending;
+ // flagged by people you follow: folded behind a line that says who
+ const flagged = !pending && !(ui.revealed && ui.revealed.has(ev.id)) ? reportsOn(ev) : null;
+ if (flagged) return foldedRow(ev, flagged);
const openable = open && !pending;
if (!pending) watchZaps([ev.id]);
return h('div', {
@@ -5345,6 +5628,7 @@ export function messagesFeature(ctx) {
threadCache.set(topId, c);
if (ui.noteThread && ui.noteThread.rootId === rootId) ui.noteThread.rootId = topId;
}
+ for (const e of all) noteForSpam(e);
const seen = new Set([c.rootId]);
c.replies = all
.filter((e) => e && e.id !== c.rootId && !seen.has(e.id) && seen.add(e.id))
@@ -5571,6 +5855,7 @@ export function messagesFeature(ctx) {
const walk = (pid, depth) => {
const list = (children.get(pid) || []).sort((a, b) => (isMe(b.pubkey) - isMe(a.pubkey)) || (a.created_at - b.created_at));
for (const ev of list) {
+ if (hidden(ev)) { walk(ev.id, depth); continue; } // its answers keep their place
const indent = Math.min(depth, 3) * 14;
kids.push(noteSep(), indent ? h('div', { style: 'margin-left:' + indent + 'px' }, row(ev)) : row(ev));
place(ev);
@@ -6038,6 +6323,12 @@ export function messagesFeature(ctx) {
onClick: () => openListFeed(p),
}, p.title, ' ', h('span', { class: 'faint' }, String(p.n))))));
})(),
+ // muted: say so, with the way back, where their posts still show
+ !mine && isMuted(pk)
+ ? h('div', { class: 'row gap6 small muted prof-muted', style: 'align-items:center' },
+ h('span', { class: 'grow' }, t('profMutedBanner')),
+ h('button', { class: 'btn-sm', type: 'button', onClick: () => toggleMute(pk) }, t('postUnmute')))
+ : null,
// Their public notes: the PAGE scrolls (no inner scrollbox), older
// pages stream in as you near the bottom (the init() scroll listener →
// loadOlderNotes), and on phones the feed goes full-bleed — edge to
@@ -6199,14 +6490,15 @@ export function messagesFeature(ctx) {
// The sheets a post (or a person) can open: the ⋯ menu, the reaction
// picker, the list picker. Every screen that shows a post draws them —
// the thread and profile pages once left the ⋯ tap doing nothing.
- const noteOverlays = () => [noteSheet(), reactPicker(), listPickSheet()];
+ const noteOverlays = () => [noteSheet(), reactPicker(), listPickSheet(), reportSheet()];
function feedView() {
syncFollowSets().catch(() => {}); // throttled inside
+ syncReports().catch(() => {}); // likewise
const c = feedNow();
const def = feedDef();
const authors = feedAuthors(def);
const hasQuery = feedHasQuery(def);
- const visible = c.notes.filter((ev) => !isMuted(ev.pubkey));
+ const visible = c.notes.filter((ev) => !hidden(ev));
// Every child keyed by its post, hairlines included, so the morph
// reconciles the list by post: a new one at the top is inserted as its
// own node (and can open itself up), the rest keep theirs.
@@ -6727,6 +7019,7 @@ export function messagesFeature(ctx) {
function homeView() {
if (myPubkeys().length) syncFollowSets().catch(() => {}); // lists made elsewhere join the feeds (throttled inside)
+ syncReports().catch(() => {}); // your follows' flags (throttled inside)
startDMs();
// Threads you've since replied to should stop being strangers to the
// worker; throttled inside, so this is cheap on every render.
@@ -6740,7 +7033,7 @@ export function messagesFeature(ctx) {
const last = [...m.values()].sort((a, b) => a.rumor.created_at - b.rumor.created_at).at(-1);
return { peer, last, unread: dmUnread(peer, m) };
})
- .filter((x) => x.last)
+ .filter((x) => x.last && !isMuted(x.peer))
.sort((a, b) => b.last.rumor.created_at - a.last.rumor.created_at);
// Being here answers the header dot — including anything that lands while
@@ -6993,6 +7286,7 @@ export function messagesFeature(ctx) {
const msgs = [...(room.byChannel.get(chId)?.values() || [])]
.filter((m) => !room.deletes.has(m.rumor.id))
.filter((m) => !(room.folded && room.folded.banned.has(m.author)))
+ .filter((m) => !hiddenPk(m.author)) // muted here, or a spammer
.sort((a, b) => eventMs(a.rumor) - eventMs(b.rumor));
markRead(chRead(chId), newestFrom(msgs, (m) => !my.includes(m.author)));
if (!msgs.length)
@@ -7377,7 +7671,9 @@ export function messagesFeature(ctx) {
function mergeNotifs(evs) {
const c = notifNow();
const known = new Set(c.items.map((x) => x.id));
- const add = (evs || []).map(notifItem).filter((x) => x && !known.has(x.id) && known.add(x.id));
+ for (const ev of evs || []) noteForSpam(ev);
+ const add = (evs || []).map((ev) => (ev.kind === 1 && hidden(ev) ? null : notifItem(ev)))
+ .filter((x) => x && !hiddenPk(x.actor) && !known.has(x.id) && known.add(x.id));
if (!add.length) return false;
// One zap, two receipts: a coinos zap publishes its own (9737) beside the
// LNURL server's (9735). The same person, post and amount within a few
@@ -7420,7 +7716,7 @@ export function messagesFeature(ctx) {
(ev) => { if (mergeNotifs([ev])) scheduleRepaint(); });
}
function stopNotifWatch() { if (notifUnsub) { try { notifUnsub(); } catch {} notifUnsub = null; } }
- const notifUnread = () => notifNow().items.filter((x) => x.ts > notifSeen()).length;
+ const notifUnread = () => notifNow().items.filter((x) => x.ts > notifSeen() && !hiddenPk(x.actor)).length;
function markNotifsSeen() {
const newest = Math.max(0, ...notifNow().items.map((x) => x.ts));
if (newest > notifSeen()) { const s2 = st(); s2.notifSeen = newest; save(s2); }
@@ -7521,7 +7817,7 @@ export function messagesFeature(ctx) {
function notifView() {
const c = notifNow();
markNotifsSeen(); // anything that lands while you look is seen too
- const items = c.items;
+ const items = c.items.filter((x) => !hiddenPk(x.actor)); // a mute made later applies to what was kept
const rows = items.flatMap((x, i) => [i ? noteSep() : null, notifRow(x)]);
return h('div', { class: 'card col chat-page', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
@@ -7548,6 +7844,7 @@ export function messagesFeature(ctx) {
return {
id: 'messages',
+ nostrSettingsCards() { return [moderationCard()]; },
// The app came back after being backgrounded. A phone freezes a hidden
// tab: the relay sockets are cut and every post made in the meantime is
// simply missing, which is why the feed used to sit there looking stale
diff --git a/src/i18n.js b/src/i18n.js
index 0f4ff19..3307c19 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -337,6 +337,36 @@ const STR = {
postMuted: 'Muted — their posts are hidden',
postUnmuted: 'Unmuted',
postBlock: 'Block and unfollow',
+ postMuteThread: 'Mute this thread',
+ postUnmuteThread: 'Unmute this thread',
+ postReport: 'Report…',
+ reportTitle: 'Report this post',
+ reportHelp: 'A report is public (NIP-56). People who follow you see this post folded; the post is hidden for you.',
+ reportSpam: 'Spam',
+ reportImpersonation: 'Impersonation',
+ reportNudity: 'Nudity',
+ reportProfanity: 'Harassment or abuse',
+ reportIllegal: 'Illegal content',
+ reportMalware: 'Scam or malware',
+ reportOther: 'Something else',
+ reportSent: 'Reported',
+ foldReported1: 'Reported by {name}',
+ foldReportedN: 'Reported by {n} people you follow',
+ foldShow: 'Show',
+ quoteHidden: 'Hidden post',
+ profMutedBanner: 'Muted — their posts are hidden everywhere else',
+ modTitle: 'Muted and hidden',
+ modDesc: 'Your mute list (NIP-51) follows you to other nostr apps, and theirs applies here.',
+ modPeople: 'Muted people',
+ modNoPeople: 'Nobody muted',
+ modWords: 'Hidden words and hashtags',
+ modWordHint: 'Add a word, phrase or #hashtag',
+ modAdd: 'Add',
+ modThreads: '{n} muted threads',
+ modSpamToggle: 'Hide repeated posts from strangers',
+ modSpamHelp: 'A post of 60+ characters posted again and again is spam; someone you don\'t follow who does it five times is hidden for the session. Posts stuffed with more than ten hashtags are hidden too.',
+ modReportsToggle: 'Fold posts reported by people you follow',
+ modReportsHelp: 'Their reports (NIP-56) are read from the relays. A flagged post is shown collapsed, with who flagged it; tap to see it anyway.',
postBlocked: 'Blocked',
zapSending: 'Sending your zap…',
msgReply: 'Reply',
diff --git a/tools/moderation-test.js b/tools/moderation-test.js
new file mode 100644
index 0000000..4ac7f69
--- /dev/null
+++ b/tools/moderation-test.js
@@ -0,0 +1,186 @@
+// Moderation: the mute list (people, words, hashtags, threads) applies to
+// the feed and to thread replies; a post reported by someone you follow is
+// folded with a Show; Report… from the ⋯ menu publishes a kind 1984 and
+// hides the post; repeated posts and hashtag-stuffed posts from strangers
+// are hidden; Settings → Nostr lays the mute list out and edits it.
+// No relay is written for real — the WebSocket shim swallows EVENT.
+// Run: bun tools/moderation-test.js
+import puppeteer from 'puppeteer-core';
+import { cacheKeyFor } from '../src/wallet.js';
+import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
+import { privateKeyFromSeedWords } from 'nostr-tools/nip06';
+import { buildHtml } from '../build.js';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let ok = true;
+const check = (n, c, d = '') => { console.log(` ${c ? '✓' : '✗'} ${n}${d ? ' — ' + d : ''}`); if (!c) ok = false; };
+const key = () => { const sk = generateSecretKey(); return { sk, pk: getPublicKey(sk) }; };
+const AUTHOR = key(), MUTED = key(), REPORTED = key(), OTHER = key(), SPAMMER = key(), TAGGER = key();
+const now = Math.floor(Date.now() / 1000);
+const post = (who, content, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content }, who.sk);
+const ROOT = post(AUTHOR, 'a post to open, about #bitcoin and other things', 300);
+const THREADMUTED = post(AUTHOR, 'another post, in a thread I muted', 400);
+const seeded = [
+ ROOT,
+ THREADMUTED,
+ post(MUTED, 'muted person post', 500),
+ post(AUTHOR, 'my friend loves lasagna apparently', 600),
+ post(AUTHOR, 'tagged post', 700, [['t', 'nsfw']]),
+ post(REPORTED, 'reported post here, flagged by my friend', 800),
+];
+const reply = (who, text, ago) => post(who, text, ago, [['e', ROOT.id, '', 'root'], ['p', AUTHOR.pk]]);
+const REPLIES = [reply(OTHER, 'reply from ok person', 200), reply(MUTED, 'reply from muted person', 150)];
+const LONG = 'buy the dip now, best exchange ever, sign up with my link and get free sats today!!!';
+
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5299, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const page = await browser.newPage();
+await page.evaluateOnNewDocument((fx) => {
+ window.__reqs = [];
+ window.__published = [];
+ const subs = [];
+ const Real = window.WebSocket;
+ const answer = (ws, id, evs) => setTimeout(() => {
+ for (const ev of evs) ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', id]) }));
+ }, 0);
+ window.WebSocket = function (...a) {
+ const ws = new Real(...a);
+ const send = ws.send.bind(ws);
+ ws.send = (data) => {
+ try {
+ const m = JSON.parse(data);
+ if (m[0] === 'REQ' && m[2]) {
+ const f = m[2];
+ window.__reqs.push(f);
+ const kinds = f.kinds || [];
+ if (kinds.includes(10000) && window.__mutes && (f.authors || []).includes(window.__mutes.pubkey)) answer(ws, m[1], [window.__mutes]);
+ else if (kinds.includes(1984) && (f.authors || []).includes(fx.author)) answer(ws, m[1], [fx.report]);
+ else if (kinds.includes(1) && f['#e'] && f['#e'].includes(fx.root)) answer(ws, m[1], fx.replies);
+ else if (kinds.includes(1)) subs.push([ws, m[1], f]);
+ }
+ if (m[0] === 'EVENT' && m[1]) {
+ window.__published.push(m[1]);
+ setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['OK', m[1].id, true, '']) })), 0);
+ return;
+ }
+ } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype;
+ Object.assign(window.WebSocket, Real);
+ window.__inject = (ev) => {
+ let n = 0;
+ for (const [ws, id, f] of subs) {
+ if (ws.readyState !== 1) continue;
+ if (f.authors && !f.authors.includes(ev.pubkey)) continue;
+ if (f['#t'] && !ev.tags.some((t) => t[0] === 't' && f['#t'].includes(t[1]))) continue;
+ ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EVENT', id, ev]) }));
+ n++;
+ }
+ return n;
+ };
+}, { author: AUTHOR.pk, root: ROOT.id, replies: REPLIES, report: finalizeEvent({ kind: 1984, created_at: now - 100, tags: [['e', seeded[5].id, 'spam'], ['p', REPORTED.pk]], content: '' }, AUTHOR.sk) });
+const errs = [];
+page.on('pageerror', (e) => errs.push(String(e)));
+const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t);
+const text = () => page.evaluate(() => document.body.innerText);
+const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await text()).toLowerCase().includes(x)) return true; await sleep(250); } return false; };
+const waitFor = async (fn, ms = 10000) => { for (let i = 0; i < ms / 250; i++) { if (await page.evaluate(fn)) return true; await sleep(250); } return false; };
+const has = async (x) => (await text()).includes(x);
+const openFeed = async () => {
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });
+ await sleep(800);
+ await page.evaluate(() => { const e = [...document.querySelectorAll('.item')].find((n) => (n.querySelector('.chat-name') || {}).textContent === 'Feed'); if (e) e.click(); });
+ await sleep(1200);
+};
+
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5299/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ await click('create a new wallet'); await sleep(600);
+ await page.waitForSelector('.words .w .t');
+ const mn = (await page.$$eval('.words .w .t', (l) => l.map((e) => e.textContent.trim()))).join(' ');
+ await click('skip verification');
+ await waitText('receive', 20000);
+ const base = cacheKeyFor(mn + '\n');
+ const ME_SK = privateKeyFromSeedWords(mn);
+ const ME = getPublicKey(ME_SK);
+ // the mute list as another client left it: a person, a word, a hashtag, a thread
+ const MUTES = finalizeEvent({ kind: 10000, created_at: now - 50, tags: [['p', MUTED.pk], ['word', 'Lasagna'], ['t', 'NSFW'], ['e', THREADMUTED.id]], content: '' }, ME_SK);
+ await page.evaluateOnNewDocument((m) => { window.__mutes = m; }, MUTES);
+ await page.evaluate(([k, pk, ns]) => {
+ localStorage.setItem(k + ':follows', JSON.stringify({ tags: [['p', pk]], c: '', at: Math.floor(Date.now() / 1000) }));
+ localStorage.setItem(k + ':feedNotes', JSON.stringify(ns));
+ }, [base, AUTHOR.pk, seeded]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await waitText('receive', 20000);
+ await openFeed();
+
+ console.log('\n[the mute list, every kind of entry, on the feed]');
+ check('the relays are asked for my mute list', await waitFor(() => window.__reqs.some((f) => (f.kinds || []).includes(10000))));
+ check('a friend\'s post shows', await waitText('a post to open', 8000));
+ check('...a muted person\'s does not', await waitFor(() => !document.body.innerText.includes('muted person post'), 8000));
+ check('...nor one with a hidden word', !(await has('lasagna')));
+ check('...nor one on a muted hashtag', !(await has('tagged post')));
+ check('...nor one in a muted thread', !(await has('another post, in a thread')));
+
+ console.log('\n[a post my friend reported is folded]');
+ check('the relays are asked for my follows\' reports', await waitFor(() => window.__reqs.some((f) => (f.kinds || []).includes(1984)), 15000));
+ check('the flagged post is folded, naming who', await waitText('reported by', 12000) && !(await has('reported post here')), (await text()).match(/Reported by[^\n]*/)?.[0]);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.note-folded button')].find((x) => /show/i.test(x.textContent)); b.click(); }); await sleep(400);
+ check('Show unfolds it', await has('reported post here'));
+
+ console.log('\n[thread replies go through the same door; Report… hides and publishes]');
+ await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /a post to open/.test(n.textContent)); r.click(); }); await sleep(1500);
+ check('an ordinary reply shows in the thread', await waitText('reply from ok person', 8000));
+ check('...a muted person\'s reply does not', !(await has('reply from muted person')));
+ await page.evaluate(() => { const r = [...document.querySelectorAll('[data-zap-post]')].find((n) => /reply from ok person/.test(n.textContent)); [...r.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === 'More').click(); }); await sleep(300);
+ check('the ⋯ menu offers Report and Mute this thread', await has('Report…') && await has('Mute this thread'));
+ await page.evaluate(() => { window.__published = []; [...document.querySelectorAll('.confirm-pop button')].find((b) => /report/i.test(b.textContent)).click(); }); await sleep(300);
+ check('a reason sheet opens', await page.evaluate(() => !!document.querySelector('.report-pick')));
+ await page.evaluate(() => document.querySelector('.report-reason[data-reason="spam"]').click()); await sleep(1200);
+ const rep = (await page.evaluate(() => window.__published)).find((e) => e.kind === 1984);
+ check('a kind 1984 goes out: the reply, as spam, its author credited', !!rep && rep.pubkey === ME && rep.tags.some((x) => x[0] === 'e' && x[1] === REPLIES[0].id && x[2] === 'spam') && rep.tags.some((x) => x[0] === 'p' && x[1] === OTHER.pk), JSON.stringify(rep && rep.tags));
+ check('...and the reply is hidden for me', !(await has('reply from ok person')));
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].filter((x) => /back/i.test(x.textContent)).pop(); b.click(); }); await sleep(800);
+
+ console.log('\n[strangers: repeated posts and hashtag stuffing]');
+ await page.evaluate(() => { const a = [...document.querySelectorAll('.notes-feed a')].find((x) => x.textContent === '#bitcoin'); a.click(); }); await sleep(1500);
+ check('a #bitcoin topic feed is open', await page.evaluate(() => [...document.querySelectorAll('.feed-chip.on')].some((c) => c.textContent.trim() === '#bitcoin')));
+ const dupes = [];
+ for (let i = 0; i < 6; i++) dupes.push(post(SPAMMER, LONG, 60 - i, [['t', 'bitcoin']]));
+ let n = 0;
+ for (const ev of dupes) { n += await page.evaluate((e) => window.__inject(e), ev); await sleep(150); }
+ const control = post(OTHER, 'a fine post from a stranger on #bitcoin', 10, [['t', 'bitcoin']]);
+ await page.evaluate((e) => window.__inject(e), control);
+ const stuffed = post(TAGGER, 'airdrop ' + Array.from({ length: 14 }, (_, i) => '#tag' + i).join(' '), 5, [['t', 'bitcoin'], ...Array.from({ length: 14 }, (_, i) => ['t', 'tag' + i])]);
+ await page.evaluate((e) => window.__inject(e), stuffed);
+ await sleep(3500);
+ check('a stranger\'s ordinary post lands', n > 0 && await waitText('a fine post from a stranger', 6000), n + ' sub(s)');
+ check('six copies of the same pitch do not: the spammer is hidden', !(await has('buy the dip now')));
+ check('...nor does a post stuffed with hashtags', !(await has('airdrop')));
+
+ console.log('\n[Settings → Nostr: the mute list, laid out and editable]');
+ await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => e.getAttribute('aria-label') === 'Settings'); b.click(); }); await sleep(600);
+ await page.evaluate(() => { const b = [...document.querySelectorAll('.settings-tile')].find((e) => [...e.querySelectorAll('span')].some((x) => x.textContent.trim() === 'Nostr')); if (b) b.click(); }); await sleep(800);
+ check('the card lists the muted person and the hidden word and hashtag', await waitText('muted and hidden', 5000) && await page.evaluate(() => !!document.querySelector('.mod-person')) && await has('lasagna') && await has('#nsfw') && await has('1 muted thread'));
+ await page.click('.mod-word'); await page.type('.mod-word', 'pineapple');
+ await page.evaluate(() => { window.__published = []; });
+ await page.keyboard.press('Enter'); await sleep(1500);
+ const ml = (await page.evaluate(() => window.__published)).find((e) => e.kind === 10000);
+ check('adding a word publishes the list with it, keeping the rest', !!ml && ml.tags.some((x) => x[0] === 'word' && x[1] === 'pineapple') && ml.tags.some((x) => x[0] === 'p' && x[1] === MUTED.pk) && ml.tags.some((x) => x[0] === 't' && x[1] === 'NSFW'), JSON.stringify(ml && ml.tags));
+ check('...and it is on the card', await page.evaluate(() => [...document.querySelectorAll('.mod-chip')].some((c) => c.textContent.includes('pineapple'))));
+ await page.evaluate(() => { window.__published = []; document.querySelector('.mod-person button').click(); }); await sleep(1500);
+ const un = (await page.evaluate(() => window.__published)).find((e) => e.kind === 10000);
+ check('Unmute publishes the list without them', !!un && !un.tags.some((x) => x[0] === 'p' && x[1] === MUTED.pk) && un.tags.some((x) => x[0] === 'word' && x[1] === 'pineapple'), JSON.stringify(un && un.tags));
+ check('nobody muted now', await has('Nobody muted'));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\n✅ moderation: mutes everywhere, reports fold, spam hidden' : '\n❌ failed');
+process.exit(ok ? 0 : 1);
diff --git a/tools/post-actions-test.js b/tools/post-actions-test.js
index 22347b6..b7d56e6 100644
--- a/tools/post-actions-test.js
+++ b/tools/post-actions-test.js
@@ -117,12 +117,14 @@ try {
check('...and goes onto the published mute list', after.muted.some((tg) => tg[1] === AUTHOR), JSON.stringify(after.muted).slice(0, 50));
// An image in a DM opens the same way a post's does — both bubbles render
// their text through the same function, so this is a check that they keep
- // doing so.
+ // doing so. From someone else: the author was just muted, and a muted
+ // person's conversation stays off the list.
+ const PEER = 'b'.repeat(64);
await page.evaluate(([k, peer]) => {
const st = JSON.parse(localStorage.getItem(k + ':messages') || '{}');
st.dms = { [peer]: [{ id: '2'.repeat(64), from: peer, text: 'look http://localhost:5258/pic.png', t: Math.floor(Date.now() / 1000) }] };
localStorage.setItem(k + ':messages', JSON.stringify(st));
- }, [base, AUTHOR]);
+ }, [base, PEER]);
await page.reload({ waitUntil: 'domcontentloaded' });
await waitText('receive', 20000);
await page.evaluate(() => { const b = [...document.querySelectorAll('button')].find((e) => /message/i.test(e.getAttribute('aria-label') || '')); if (b) b.click(); });