From 54837d5ad259a6bafd28eab8f3ff7b58b39543a7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 08:28:03 +0200 Subject: [PATCH] bug-fixes --- src/components/Note/NsfwNote.tsx | 16 +++- src/components/Note/index.tsx | 3 +- src/components/PostEditor/PostContent.tsx | 55 +++++++---- .../PostEditor/PostEditorAdvancedPanel.tsx | 95 ++++++++++++++++--- src/i18n/locales/en.ts | 10 +- src/lib/content-warning.ts | 66 +++++++++++++ src/lib/discussion-thread-composer.ts | 13 +-- src/lib/draft-event.ts | 81 +++++++--------- src/services/post-editor-cache.service.ts | 1 + 9 files changed, 249 insertions(+), 91 deletions(-) create mode 100644 src/lib/content-warning.ts diff --git a/src/components/Note/NsfwNote.tsx b/src/components/Note/NsfwNote.tsx index a0cb0391..38a85439 100644 --- a/src/components/Note/NsfwNote.tsx +++ b/src/components/Note/NsfwNote.tsx @@ -1,13 +1,25 @@ import { Button } from '@/components/ui/button' +import { DEFAULT_CONTENT_WARNING_LABEL } from '@/lib/content-warning' import { Eye } from 'lucide-react' import { useTranslation } from 'react-i18next' -export default function NsfwNote({ show }: { show: () => void }) { +export default function NsfwNote({ + show, + label +}: { + show: () => void + label?: string | null +}) { const { t } = useTranslation() + const normalized = label?.trim() || DEFAULT_CONTENT_WARNING_LABEL + const heading = + normalized.toLowerCase() === DEFAULT_CONTENT_WARNING_LABEL.toLowerCase() + ? t('🔞 NSFW 🔞') + : t('Content warning label', { label: normalized }) return (
-
{t('🔞 NSFW 🔞')}
+
{heading}
-
- - +
+
+ + { + setIsNsfw(checked) + if (checked && !contentWarningLabel.trim()) { + setContentWarningLabel(DEFAULT_CONTENT_WARNING_LABEL) + } + }} + disabled={posting} + /> +
+

{t('Content warning hint')}

+ {isNsfw ? ( +
+ + {selectValue === CONTENT_WARNING_CUSTOM_SELECT_VALUE ? ( + setContentWarningLabel(e.target.value)} + onBlur={() => + setContentWarningLabel((prev) => normalizeContentWarningLabel(prev)) + } + placeholder={t('Content warning custom placeholder')} + disabled={posting} + maxLength={80} + /> + ) : null} +
+ ) : null}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 91e50624..94b50492 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -300,7 +300,8 @@ export default { Connections: 'Connections', Calls: 'Calls', Advanced: 'Advanced', - 'Post editor advanced hint': 'Relay targets, mention recipients, client tag, NSFW, and proof of work.', + 'Post editor advanced hint': + 'Relay targets, mention recipients, client tag, content warning, and proof of work.', 'Open Advanced to adjust mention recipients': 'Open Advanced to adjust who receives this message.', 'Add recipients using nostr: mentions (e.g., nostr:npub1...) or open Advanced': 'Add nostr:npub… or nostr:nevent… mentions in the text, or open Advanced to pick recipients.', @@ -2122,6 +2123,13 @@ export default { 'Long-form Article': 'Long-form Article', 'Mailbox relays saved': 'Mailbox relays saved', 'Mark as NSFW': 'Mark as NSFW', + 'Content warning': 'Content warning', + 'Content warning hint': + 'Adds a NIP-36 content-warning tag so readers must opt in to view this note.', + 'Content warning preset': 'Warning label', + 'Custom label…': 'Custom label…', + 'Content warning custom placeholder': 'e.g. Violence, Trigger warning', + 'Content warning label': '⚠ {{label}}', 'Maximum {{max}} invitees': 'Maximum {{max}} invitees', 'Maximum {{max}} invitees allowed': 'Maximum {{max}} invitees allowed', Medium: 'Medium', diff --git a/src/lib/content-warning.ts b/src/lib/content-warning.ts new file mode 100644 index 00000000..eae6c6b3 --- /dev/null +++ b/src/lib/content-warning.ts @@ -0,0 +1,66 @@ +import type { Event } from 'nostr-tools' + +/** Default NIP-36 `content-warning` tag value (legacy clients also use `t` = nsfw). */ +export const DEFAULT_CONTENT_WARNING_LABEL = 'NSFW' + +/** Built-in labels for the post editor (stored verbatim in the `content-warning` tag). */ +export const CONTENT_WARNING_PRESETS = [ + DEFAULT_CONTENT_WARNING_LABEL, + 'Violence', + 'Trigger warning', + 'Sensitive content', + 'Spoilers' +] as const + +export type TContentWarningPreset = (typeof CONTENT_WARNING_PRESETS)[number] + +export const CONTENT_WARNING_CUSTOM_SELECT_VALUE = '__custom__' + +export function normalizeContentWarningLabel(raw: string | undefined | null): string { + const trimmed = (raw ?? '').trim() + if (!trimmed) return DEFAULT_CONTENT_WARNING_LABEL + return trimmed.slice(0, 80) +} + +export function buildContentWarningTag(label?: string | null): string[] { + return ['content-warning', normalizeContentWarningLabel(label)] +} + +/** Read NIP-36 label from an event (`content-warning` tag, else legacy `t` = nsfw). */ +export function getContentWarningLabel(event: Event): string | null { + const row = event.tags.find(([name]) => name === 'content-warning') + if (row) { + const value = row[1]?.trim() + return value || DEFAULT_CONTENT_WARNING_LABEL + } + if (event.tags.some(([name, value]) => name === 't' && value?.toLowerCase() === 'nsfw')) { + return DEFAULT_CONTENT_WARNING_LABEL + } + return null +} + +export function isPresetContentWarningLabel(label: string): label is TContentWarningPreset { + return (CONTENT_WARNING_PRESETS as readonly string[]).includes(label) +} + +export type TContentWarningDraftOptions = { + isNsfw?: boolean + contentWarningLabel?: string +} + +export function contentWarningDraftOptions( + enabled: boolean, + label: string +): TContentWarningDraftOptions { + if (!enabled) return { isNsfw: false } + return { isNsfw: true, contentWarningLabel: normalizeContentWarningLabel(label) } +} + +export function appendContentWarningTagIfNeeded( + tags: string[][], + options: TContentWarningDraftOptions +): void { + if (options.isNsfw) { + tags.push(buildContentWarningTag(options.contentWarningLabel)) + } +} diff --git a/src/lib/discussion-thread-composer.ts b/src/lib/discussion-thread-composer.ts index 7f0852a0..f22accab 100644 --- a/src/lib/discussion-thread-composer.ts +++ b/src/lib/discussion-thread-composer.ts @@ -1,4 +1,5 @@ import { ExtendedKind } from '@/constants' +import { appendContentWarningTagIfNeeded } from '@/lib/content-warning' import { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' import { DISCUSSION_TOPICS } from '@/pages/primary/DiscussionsPage/discussionTopics' import { Event } from 'nostr-tools' @@ -48,10 +49,6 @@ function generateImetaTagsFromUrls(imageUrls: string[]): string[][] { return imageUrls.map((url) => ['imeta', 'url', url]) } -function buildDiscussionNsfwTag(): string[] { - return ['content-warning', ''] -} - /** Match preset/dynamic list by id or exact label (case-insensitive); otherwise normalize as a new topic slug. */ export function resolveTopicFromInput(raw: string, topics: TopicListEntry[]): string { const trimmed = raw.trim() @@ -142,8 +139,10 @@ export function collectDiscussionThreadTags(params: { author: string subject: string isNsfw: boolean + contentWarningLabel?: string }): string[][] { - const { processedContent, topicForTags, title, dynamicTopics, isReadingGroup, author, subject, isNsfw } = params + const { processedContent, topicForTags, title, dynamicTopics, isReadingGroup, author, subject, isNsfw, contentWarningLabel } = + params const images = extractImagesFromContent(processedContent) const hashtags = extractHashtagsFromContent(processedContent) const tags: string[][] = [['title', title.trim()]] @@ -221,9 +220,7 @@ export function collectDiscussionThreadTags(params: { tags.push(...generateImetaTagsFromUrls(images)) } - if (isNsfw) { - tags.push(buildDiscussionNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, { isNsfw, contentWarningLabel }) return tags } diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 6296a4cd..e2312c67 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -3,6 +3,7 @@ import client from '@/services/client.service' import { eventService } from '@/services/client.service' import customEmojiService from '@/services/custom-emoji.service' import mediaUpload from '@/services/media-upload.service' +import { appendContentWarningTagIfNeeded } from '@/lib/content-warning' import { prefixNostrAddresses } from '@/lib/nostr-address' import { normalizeHashtag, normalizeTopic } from '@/lib/discussion-topics' import logger from '@/lib/logger' @@ -227,6 +228,7 @@ export async function createShortTextNoteDraftEvent( parentEvent?: Event addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number /** NIP-94 imeta rows from uploads (audio/video/images as plain URLs in content). */ @@ -266,9 +268,7 @@ export async function createShortTextNoteDraftEvent( // p tags tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -305,6 +305,7 @@ export async function createCommentDraftEvent( options: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] @@ -379,9 +380,7 @@ export async function createCommentDraftEvent( ) } - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -404,6 +403,7 @@ export async function createPublicMessageReplyDraftEvent( options: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] // Allow media imeta tags for audio/video @@ -449,9 +449,7 @@ export async function createPublicMessageReplyDraftEvent( ...Array.from(recipients).map((pubkey) => buildPTag(pubkey)) ) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -479,6 +477,7 @@ export async function createPublicMessageDraftEvent( options: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] // Allow media imeta tags for audio/video @@ -504,9 +503,7 @@ export async function createPublicMessageDraftEvent( ...recipients.map((pubkey) => buildPTag(pubkey)) ) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -1115,12 +1112,14 @@ export async function createPollDraftEvent( { isMultipleChoice, relays, options, endsAt }: TPollCreateData, { isNsfw, + contentWarningLabel, addExpirationTag, expirationMonths, mediaImetaTags }: { addClientTag?: boolean // accepted for API compat; client tag is added in publish() isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] @@ -1166,9 +1165,7 @@ export async function createPollDraftEvent( }) } - if (isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, { isNsfw, contentWarningLabel }) if (addExpirationTag && expirationMonths) { tags.push(buildExpirationTag(expirationMonths)) @@ -1606,10 +1603,6 @@ export function applyImwaldAttributionTags( return draft } -function buildNsfwTag() { - return ['content-warning', 'NSFW'] -} - function buildExpirationTag(months: number): string[] { const expirationTime = dayjs().add(months, 'month').unix() return ['expiration', expirationTime.toString()] @@ -1642,6 +1635,7 @@ export async function createHighlightDraftEvent( options?: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] @@ -1761,9 +1755,7 @@ export async function createHighlightDraftEvent( } // Add optional tags - if (options?.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options ?? {}) if (options?.addExpirationTag && options?.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -1789,6 +1781,7 @@ export async function createVoiceDraftEvent( options: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number /** Extra NIP-94 rows from uploads (merged after content-derived imeta, deduped by URL). */ @@ -1809,9 +1802,7 @@ export async function createVoiceDraftEvent( tags.push(...imetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -1834,6 +1825,7 @@ export async function createVoiceCommentDraftEvent( options: { addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number /** NIP-94 rows from file upload (merged before `imetaTags`; deduped by URL). */ @@ -1907,9 +1899,7 @@ export async function createVoiceCommentDraftEvent( ) } - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -1931,6 +1921,7 @@ export async function createPictureDraftEvent( title?: string addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] @@ -1949,9 +1940,7 @@ export async function createPictureDraftEvent( mergeUploadImetaTagsInto(tags, options.mediaImetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -1986,6 +1975,7 @@ export async function createVideoDraftEvent( title?: string addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number mediaImetaTags?: string[][] @@ -2004,9 +1994,7 @@ export async function createVideoDraftEvent( mergeUploadImetaTagsInto(tags, options.mediaImetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -2036,6 +2024,7 @@ export async function createMusicTrackDraftEvent( genres?: string[] addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string } ): Promise { const { content: transformedEmojisContent, emojiTags } = transformCustomEmojisInContent(content) @@ -2078,9 +2067,7 @@ export async function createMusicTrackDraftEvent( tags.push(...emojiTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) return setDraftEventCache({ kind: ExtendedKind.MUSIC_TRACK, @@ -2103,6 +2090,7 @@ export async function createLongFormArticleDraftEvent( topics?: string[] addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number } = {} @@ -2143,9 +2131,7 @@ export async function createLongFormArticleDraftEvent( tags.push(...generateImetaTags(images)) } - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -2179,6 +2165,7 @@ export async function createWikiArticleDraftEvent( topics?: string[] addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number } @@ -2208,9 +2195,7 @@ export async function createWikiArticleDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -2236,6 +2221,7 @@ export async function createNostrSpecificationDraftEvent( topics?: string[] addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number } @@ -2267,9 +2253,7 @@ export async function createNostrSpecificationDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) @@ -2294,6 +2278,7 @@ export async function createPublicationContentDraftEvent( topics?: string[] addClientTag?: boolean isNsfw?: boolean + contentWarningLabel?: string addExpirationTag?: boolean expirationMonths?: number } @@ -2323,9 +2308,7 @@ export async function createPublicationContentDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.isNsfw) { - tags.push(buildNsfwTag()) - } + appendContentWarningTagIfNeeded(tags, options) if (options.addExpirationTag && options.expirationMonths) { tags.push(buildExpirationTag(options.expirationMonths)) diff --git a/src/services/post-editor-cache.service.ts b/src/services/post-editor-cache.service.ts index 36b48ca3..43c9e1cb 100644 --- a/src/services/post-editor-cache.service.ts +++ b/src/services/post-editor-cache.service.ts @@ -10,6 +10,7 @@ const PERSIST_DEBOUNCE_MS = 5_000 type TPostSettings = { isNsfw?: boolean + contentWarningLabel?: string isPoll?: boolean pollCreateData?: TPollCreateData addClientTag?: boolean