aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/features/messages.js49
-rw-r--r--src/i18n.js4
2 files changed, 49 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',