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 (
-
-
+
+
+
+ {
+ 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