aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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 /src/features
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
Diffstat (limited to 'src/features')
-rw-r--r--src/features/messages.js49
1 files changed, 45 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