aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 20:55:41 -0700
committerAdam Soltys <[email protected]>2026-10-09 20:55:41 -0700
commit75ca3da7f87846f26743d0c27afc90d9f0d19a7c (patch)
tree12bc30b3c00dead4ba1e8d41651c04a3fdbecc2f
parent557831a04d71a155699124395f55d394ad5b2f4f (diff)
downloadcoinosv3-75ca3da7f87846f26743d0c27afc90d9f0d19a7c.tar.gz
coinosv3-75ca3da7f87846f26743d0c27afc90d9f0d19a7c.zip
Sensitive posts: Popular leaves them out. The NSFW posts Adam saw (and blocked) in Popular were Misskey artists over the mostr bridge β€” every one carried a content-warning tag (Misskey's "sensitive" media), and their profiles said so too (":role_nsfw:", "πŸ”žζˆδΊΊε‘γ‘"); the posts had no words to fail the language test, and nothing read the tag. Popular (and its picture/video grids) now drops a post marked sensitive (NIP-36 content-warning, a NIP-32 content-warning/nsfw label, an NSFW hashtag, :nsfw: or πŸ”ž in the text) and the posts of accounts whose own name or bio says they post adult work. Everywhere else a marked post is folded behind its warning ("Sensitive: <reason>") with Show, and left out of media grids β€” Settings β†’ Nostr β†’ "Fold posts marked sensitive" (on by default). On the live snapshot: 6 of 184 posts dropped. tools/sensitive-feed-test.js
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--src/features/messages.js49
-rw-r--r--src/i18n.js4
-rw-r--r--tools/sensitive-feed-test.js102
3 files changed, 151 insertions, 4 deletions
diff --git a/src/features/messages.js b/src/features/messages.js
index b3066c0..c803d4d 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -5231,7 +5231,7 @@ export function messagesFeature(ctx) {
// people, then the posts enough people touched. Older pages walk the
// reactions back in time. The prose and language tests run at the door.
const POPULAR_ASK = 600; // reactions per round
- const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang)); };
+ const curateFeed = (evs) => { const lang = getLang(); return evs.filter((e) => !isMachinePost(e.content) && inLanguage(e.content, lang) && !notForPublic(e)); };
async function popularPass(extra, merge, c, def) {
// Only the default public feed shares a snapshot. Custom relays and older
// pages keep their own queries. A static/offline install falls back quickly;
@@ -8513,6 +8513,39 @@ export function messagesFeature(ctx) {
// 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;
+ // ---- sensitive posts (NIP-36 content warnings, NSFW accounts) -------------
+ // A post its author marked sensitive carries a content-warning tag β€” what a
+ // Misskey artist's "sensitive" media becomes on the mostr bridge, and what
+ // Amethyst and Damus put on a CW'd note. Popular, shown to anyone, leaves
+ // such posts out, and the posts of accounts whose own profile says they're
+ // adult or NSFW (":role_nsfw:", "πŸ”ž", "R18", "ζˆδΊΊε‘γ‘"…); elsewhere a marked
+ // post is folded behind its warning with a Show, as NIP-36 asks.
+ const NSFW_TAG = /^(nsfw|porn|porno|r18|r-18|18\+|nude|nudes|nudity|hentai|lewd|onlyfans|xxx|adult|エロ|ζˆδΊΊε‘γ‘|r18g)$/i;
+ const NSFW_TEXT = /:nsfw:|:role_nsfw:|πŸ”ž|(^|\s)#(nsfw|porn|r18|hentai|lewd)\b/i;
+ const NSFW_PROFILE = /(^|[^a-z])nsfw([^a-z]|$)|:role_nsfw:|πŸ”ž|(^|[^a-z0-9])r-?18(g)?([^0-9]|$)|(^|[^0-9])18\+|ζˆδΊΊε‘γ‘|γ‚’γƒ€γƒ«γƒˆ|エロ|porn|onlyfans|fansly|hentai|(^|[^a-z])lewd/i;
+ // the post's own warning: its reason (possibly ''), or null when unmarked
+ function sensitiveWhy(ev) {
+ if (!ev || !ev.tags) return null;
+ const cw = ev.tags.find((x) => x[0] === 'content-warning');
+ if (cw) return cw[1] || '';
+ // NIP-32 labels in the content-warning namespace, and NSFW topics
+ if (ev.tags.some((x) => (x[0] === 'l' && /^(content-warning|nsfw)$/i.test(x[2] || '')) || (x[0] === 't' && NSFW_TAG.test(x[1] || '')))) return '';
+ if (NSFW_TEXT.test(String(ev.content || ''))) return '';
+ return null;
+ }
+ const nsfwAuthor = (pk) => { const p = profiles.get(pk); return !!p && NSFW_PROFILE.test((p.name || '') + ' ' + (p.about || '')); };
+ // left out of the public feed (and of every media grid): marked, or by an
+ // account that says it posts adult work
+ const notForPublic = (ev) => !isMe(ev.pubkey) && (sensitiveWhy(ev) !== null || nsfwAuthor(ev.pubkey));
+ function sensitiveFold(ev, why) {
+ 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 ' + (why ? t('foldSensitiveWhy', { why: why.slice(0, 80) }) : t('foldSensitive'))),
+ 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')));
+ }
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
@@ -9086,7 +9119,8 @@ export function messagesFeature(ctx) {
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')));
+ toggle('reports', t('modReportsToggle'), t('modReportsHelp')),
+ toggle('sensitive', t('modSensitiveToggle'), t('modSensitiveHelp')));
}
const noteLink = (ev) => location.origin + '/' + (neventOf(ev.id, ev.pubkey) || ev.id);
@@ -9339,6 +9373,8 @@ export function messagesFeature(ctx) {
// 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 warned = !pending && modOn('sensitive') && !isMe(ev.pubkey) && !(ui.revealed && ui.revealed.has(ev.id)) ? sensitiveWhy(ev) : null;
+ if (warned !== null) return sensitiveFold(ev, warned);
// deleted by its author: a thread's root or a reply in view keeps its
// place (the conversation around it still reads), saying only that
if (isDeleted(ev)) return h('div', { class: 'note-post small faint', 'data-zap-post': ev.id, style: 'padding:10px 0' }, t('postDeletedNote'));
@@ -11141,8 +11177,11 @@ export function messagesFeature(ctx) {
const r = mediaIndex(c.id);
indexMedia(r, c.notes);
const evs = [...r[kind].values()].sort((a, b) => b.created_at - a.created_at);
+ const curated = !!(feedDef(c.id) || {}).curated;
for (const ev of evs) {
if (hidden(ev)) continue;
+ // a grid shows the picture itself β€” no warning to fold it behind
+ if (curated ? notForPublic(ev) : modOn('sensitive') && sensitiveWhy(ev) !== null && !isMe(ev.pubkey)) continue;
if (kind === 'videos') learnVideoMeta(ev);
const urls = kind === 'videos' ? noteVideoUrls(ev.content).filter(verticalClip)
: noteMediaUrls(ev.content).filter((u) => !u.startsWith('https://i.ytimg.com/'));
@@ -11557,12 +11596,14 @@ export function messagesFeature(ctx) {
// paint as they are (live profiles, pictures as they load) and take
// their settled presentation once the warm-up lands. The spinner is
// for a feed with nothing cached at all.
- const visible = c.notes.filter((ev) => !hidden(ev));
+ const curatedFeed = !!(feedDef(c.id) || {}).curated;
+ const shownHere = (ev) => !hidden(ev) && !(curatedFeed && notForPublic(ev));
+ const visible = c.notes.filter(shownHere);
// 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; the rest keep theirs.
const list = visible.slice(0, c.shown || FEED_PAGE);
- c.winList = () => c.notes.filter((ev) => !hidden(ev)).slice(0, c.shown || FEED_PAGE);
+ c.winList = () => c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE);
const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id)
? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev)));
// Posts that went in above you while you were reading. A floating pill
diff --git a/src/i18n.js b/src/i18n.js
index 9c3efe3..d8ced5b 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -470,6 +470,10 @@ const STR = {
foldReported1: 'Reported by {name}',
foldReportedN: 'Reported by {n} people you follow',
foldShow: 'Show',
+ foldSensitive: 'Sensitive content',
+ foldSensitiveWhy: 'Sensitive: {why}',
+ modSensitiveToggle: 'Fold posts marked sensitive',
+ modSensitiveHelp: 'Posts their authors marked with a content warning (NIP-36) stay behind it until you tap Show, and are left out of picture and video grids. Popular never shows them, nor posts from accounts that say they post adult content.',
quoteHidden: 'Hidden post',
profMutedBanner: 'Muted. Their posts are hidden everywhere else',
modTitle: 'Muted and hidden',
diff --git a/tools/sensitive-feed-test.js b/tools/sensitive-feed-test.js
new file mode 100644
index 0000000..a050269
--- /dev/null
+++ b/tools/sensitive-feed-test.js
@@ -0,0 +1,102 @@
+// Sensitive posts: Popular (everyone's public feed) leaves out posts their
+// authors marked with a content warning (NIP-36 β€” what a Misskey artist's
+// "sensitive" media becomes over mostr), NSFW-tagged posts, and posts from
+// accounts whose own profile says they post adult work. Elsewhere a marked
+// post is folded behind its warning with a Show.
+// Run: bun tools/sensitive-feed-test.js
+import puppeteer from './puppeteer.js';
+import { cacheKeyFor } from '../src/wallet.js';
+import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
+import { buildHtml, buildVerifyWorker } 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 A = key(), ART = key(), B = key();
+const now = Math.floor(Date.now() / 1000);
+const post = (who, text, ago, tags = []) => finalizeEvent({ kind: 1, created_at: now - ago, tags, content: text }, who.sk);
+const SAFE = post(A, 'This is a perfectly ordinary post about the weather and the bitcoin price today', 100);
+const CW = post(A, 'This post was marked sensitive by its author for everyone to see', 200, [['content-warning', 'spoilers']]);
+const TAGGED = post(B, 'This post carries the nsfw hashtag and should not be in the public feed', 300, [['t', 'nsfw']]);
+const ARTIST = post(ART, 'This is a drawing I made for the autumn campaign please enjoy it', 400);
+const SAFE2 = post(B, 'Another ordinary post that everybody should see in the public feed', 500);
+const PROFILES = [
+ finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Plain Person' }) }, A.sk),
+ finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Artist', about: 'I draw things :role_nsfw: so please be 18 or older' }) }, ART.sk),
+ finalizeEvent({ kind: 0, created_at: now, tags: [], content: JSON.stringify({ name: 'Other Person' }) }, B.sk),
+];
+const html = await buildHtml({ minify: true, pwa: false });
+const worker = await buildVerifyWorker({ minify: true });
+const server = Bun.serve({ port: 0, fetch(req) {
+ const path = new URL(req.url).pathname;
+ if (path === '/api/feed') return Response.json({ version: 1, generatedAt: Date.now(), popUntil: now - 1000, notes: [SAFE, CW, TAGGED, ARTIST, SAFE2], profiles: PROFILES });
+ if (path === '/verify-worker.js') return new Response(worker, { headers: { 'Content-Type': 'text/javascript' } });
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
+} });
+const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] });
+const shim = () => {
+ const Real = window.WebSocket;
+ 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') { setTimeout(() => ws.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(['EOSE', m[1]]) })), 0); return; } if (m[0] === 'EVENT') return; } catch {}
+ return send(data);
+ };
+ return ws;
+ };
+ window.WebSocket.prototype = Real.prototype; Object.assign(window.WebSocket, Real);
+};
+const errs = [];
+try {
+ console.log('\n[Popular]');
+ let page = await browser.newPage();
+ page.on('pageerror', (e) => errs.push(String(e)));
+ await page.evaluateOnNewDocument(shim);
+ await page.goto(server.url.origin + '/feed');
+ await page.waitForFunction(() => document.body.innerText.includes('perfectly ordinary post'), { timeout: 15000 }).catch(() => {});
+ await sleep(1500);
+ let text = await page.evaluate(() => document.body.innerText);
+ check('ordinary posts are there', text.includes('perfectly ordinary post') && text.includes('Another ordinary post'));
+ check('a post marked sensitive is left out', !text.includes('marked sensitive by its author'));
+ check('...and an #nsfw one', !text.includes('carries the nsfw hashtag'));
+ check('...and one from an account whose profile says adult content', !text.includes('a drawing I made'));
+ await page.close();
+
+ console.log('\n[a followed person\'s marked post]');
+ page = await browser.newPage();
+ page.on('pageerror', (e) => errs.push(String(e)));
+ await page.evaluateOnNewDocument(shim);
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto(server.url.origin + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest'));
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(400);
+ 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);
+ 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 page.waitForFunction(() => /receive/i.test(document.body.innerText), { timeout: 20000 });
+ await sleep(3000); // the starter follows land first
+ const base = cacheKeyFor(mn + '\n');
+ 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));
+ localStorage.setItem(k + ':profiles', JSON.stringify({ [pk]: { name: 'Plain Person', t: Date.now() } }));
+ localStorage.setItem('btc-wallet-feed:' + k, 'following');
+ }, [base, A.pk, [SAFE, CW]]);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => /receive/i.test(document.body.innerText), { timeout: 20000 });
+ await page.evaluate(() => document.querySelector('.app-nav-button[aria-label="Feed"]').click());
+ await page.waitForFunction(() => document.body.innerText.includes('perfectly ordinary post'), { timeout: 15000 }).catch(() => {});
+ await sleep(800);
+ text = await page.evaluate(() => document.body.innerText);
+ check('the marked post is folded behind its warning', text.includes('Sensitive: spoilers') && !text.includes('marked sensitive by its author'), (text.match(/Sensitive[^\n]*/) || [''])[0]);
+ await page.evaluate(() => [...document.querySelectorAll('.note-folded button')].find((b) => /show/i.test(b.textContent)).click());
+ await sleep(400);
+ check('Show opens it', (await page.evaluate(() => document.body.innerText)).includes('marked sensitive by its author'));
+ check('no page errors', errs.length === 0, errs.slice(0, 2).join(' | '));
+} finally { await browser.close(); server.stop(true); }
+console.log(ok ? '\nβœ… sensitive posts stay out of Popular, folded elsewhere' : '\n❌ failed');
+process.exit(ok ? 0 : 1);