diff options
Diffstat (limited to 'src/features')
| -rw-r--r-- | src/features/messages.js | 49 |
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 |
