From 4751b17de062badcee377d6258033ec684e4feda Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 09:16:56 +0200 Subject: [PATCH] add content warning info text to editor --- .../AdvancedEventLabDialog.tsx | 10 ++- src/components/PostEditor/PostContent.tsx | 23 +++++-- .../PostEditor/PostEditorAdvancedPanel.tsx | 12 ++-- .../PostEditor/PostTextarea/Preview.tsx | 43 ++++++++++++- .../PostEditor/PostTextarea/index.tsx | 62 +++++++++++++++---- src/i18n/locales/en.ts | 4 ++ src/lib/advanced-event-lab-slice.test.ts | 9 +++ src/lib/advanced-event-lab-slice.ts | 9 ++- src/lib/content-warning.test.ts | 36 +++++++++++ src/lib/content-warning.ts | 11 ++++ 10 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 src/lib/content-warning.test.ts diff --git a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx index c2510276..ff803df6 100644 --- a/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx +++ b/src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx @@ -30,6 +30,7 @@ import { serializePublishPreviewLabJson, type AdvancedEventLabSlice } from '@/lib/advanced-event-lab-slice' +import type { TContentWarningDraftOptions } from '@/lib/content-warning' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { warmTranslateLanguagesOnce, @@ -201,6 +202,8 @@ export type AdvancedEventLabDialogProps = { previewEmojiTags?: string[][] /** When true (default), JSON preview includes the Imwald `client` tag like publish. */ addClientTag?: boolean + /** Composer Advanced panel content-warning settings (merged into JSON preview). */ + contentWarning?: TContentWarningDraftOptions } function useDarkModeFlag(): boolean { @@ -234,7 +237,8 @@ export default function AdvancedEventLabDialog({ draftPersistenceKey = null, previewAuthorPubkey = null, previewEmojiTags, - addClientTag = true + addClientTag = true, + contentWarning }: AdvancedEventLabDialogProps) { const { t, i18n } = useTranslation() /** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */ @@ -316,10 +320,10 @@ export default function AdvancedEventLabDialog({ content, tags: editableRowsToLabTags(labTagRows) }, - { addClientTag } + { addClientTag, contentWarning } ) ) - }, [kindEditable, initial, labTagRows, addClientTag]) + }, [kindEditable, initial, labTagRows, addClientTag, contentWarning]) useEffect(() => { refreshLabJsonPreviewRef.current = refreshLabJsonPreview diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 11156e5d..f51ebffa 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -42,7 +42,6 @@ import { } from '@/lib/draft-event' import { contentWarningDraftOptions, - DEFAULT_CONTENT_WARNING_LABEL, normalizeContentWarningLabel } from '@/lib/content-warning' import { @@ -301,7 +300,7 @@ export default function PostContent({ const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag()) const [mentions, setMentions] = useState([]) const [isNsfw, setIsNsfw] = useState(false) - const [contentWarningLabel, setContentWarningLabel] = useState(DEFAULT_CONTENT_WARNING_LABEL) + const [contentWarningLabel, setContentWarningLabel] = useState('') const [isPoll, setIsPoll] = useState(false) const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo) const [extractedMentions, setExtractedMentions] = useState( @@ -761,9 +760,7 @@ export default function PostContent({ }) if (cachedSettings) { setIsNsfw(cachedSettings.isNsfw ?? false) - setContentWarningLabel( - normalizeContentWarningLabel(cachedSettings.contentWarningLabel ?? DEFAULT_CONTENT_WARNING_LABEL) - ) + setContentWarningLabel(cachedSettings.contentWarningLabel?.trim() ?? '') setIsPoll(cachedSettings.isPoll ?? false) setPollCreateData( cachedSettings.pollCreateData ?? { @@ -903,6 +900,11 @@ export default function PostContent({ return contextual.length ? contextual : undefined }, [isDiscussionThread, parentEvent, discussionPreviewExtraTags, rssReplyExtraPreviewTags]) + const labContentWarning = useMemo( + () => contentWarningDraftOptions(isNsfw, contentWarningLabel), + [isNsfw, contentWarningLabel] + ) + // Shared function to create draft event - used by both preview and posting const createDraftEvent = useCallback(async (cleanedText: string): Promise => { const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined @@ -2554,6 +2556,8 @@ export default function PostContent({ sourceType: 'nostr', sourceValue: '' }) + setIsNsfw(false) + setContentWarningLabel('') uploadedMediaFileMap.current.clear() composerImetaTagsRef.current = [] setUploadProgresses([]) @@ -3443,6 +3447,7 @@ export default function PostContent({ articleMetadata={articlePreviewMetadata} musicTrackMetadata={musicTrackPreviewMetadata} addClientTag={addClientTag} + contentWarning={labContentWarning} mediaImetaTags={mediaImetaTags} mediaUrl={mediaUrl} headerActions={(() => { @@ -3687,6 +3692,13 @@ export default function PostContent({ } /> + {!showMoreOptions && isNsfw ? ( +

+ {t('Post editor content warning summary', { + label: normalizeContentWarningLabel(contentWarningLabel) + })} +

+ ) : null} {isDiscussionThread && !parentEvent && (
{threadErrors.content &&

{threadErrors.content}

} @@ -4043,6 +4055,7 @@ export default function PostContent({ contextEventId={parentEvent?.id ?? null} previewAuthorPubkey={pubkey ?? null} addClientTag={addClientTag} + contentWarning={labContentWarning} draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null} bodyApiRef={advancedLabBodyApiRef} formatToolbar={ diff --git a/src/components/PostEditor/PostEditorAdvancedPanel.tsx b/src/components/PostEditor/PostEditorAdvancedPanel.tsx index 205f8c09..b545c334 100644 --- a/src/components/PostEditor/PostEditorAdvancedPanel.tsx +++ b/src/components/PostEditor/PostEditorAdvancedPanel.tsx @@ -101,7 +101,9 @@ export default function PostEditorAdvancedPanel({ } const selectValue = useMemo(() => { - if (isPresetContentWarningLabel(contentWarningLabel)) return contentWarningLabel + const trimmed = contentWarningLabel.trim() + if (!trimmed) return DEFAULT_CONTENT_WARNING_LABEL + if (isPresetContentWarningLabel(trimmed)) return trimmed return CONTENT_WARNING_CUSTOM_SELECT_VALUE }, [contentWarningLabel]) @@ -196,8 +198,10 @@ export default function PostEditorAdvancedPanel({ checked={isNsfw} onCheckedChange={(checked) => { setIsNsfw(checked) - if (checked && !contentWarningLabel.trim()) { - setContentWarningLabel(DEFAULT_CONTENT_WARNING_LABEL) + if (checked) { + setContentWarningLabel((prev) => prev.trim() || DEFAULT_CONTENT_WARNING_LABEL) + } else { + setContentWarningLabel('') } }} disabled={posting} @@ -210,7 +214,7 @@ export default function PostEditorAdvancedPanel({ value={selectValue} onValueChange={(value) => { if (value === CONTENT_WARNING_CUSTOM_SELECT_VALUE) { - if (isPresetContentWarningLabel(contentWarningLabel)) { + if (isPresetContentWarningLabel(contentWarningLabel.trim())) { setContentWarningLabel('') } return diff --git a/src/components/PostEditor/PostTextarea/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx index 9c544d3a..1bad4bf5 100644 --- a/src/components/PostEditor/PostTextarea/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -11,10 +11,12 @@ import { createFakeEvent } from '@/lib/event' import { randomString } from '@/lib/random' import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' import { cn } from '@/lib/utils' +import { mergeContentWarningTagsFromDraftOptions, type TContentWarningDraftOptions } from '@/lib/content-warning' import { TPollCreateData } from '@/types' import { kinds, nip19 } from 'nostr-tools' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { useMemo, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import ContentPreview from '../../ContentPreview' import Content from '../../Content' import Highlight from '../../Note/Highlight' @@ -34,7 +36,8 @@ export default function Preview({ articleMetadata, musicTrackMetadata, extraPreviewTags, - addClientTag = true + addClientTag = true, + contentWarning }: { content: string className?: string @@ -68,7 +71,10 @@ export default function Preview({ extraPreviewTags?: string[][] /** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */ addClientTag?: boolean + /** Composer Advanced panel content-warning settings. */ + contentWarning?: TContentWarningDraftOptions }) { + const { t } = useTranslation() const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( () => { // Clean tracking parameters from URLs in the preview @@ -223,12 +229,15 @@ export default function Preview({ if (extraPreviewTags?.length) { tags.push(...extraPreviewTags) } + if (contentWarning) { + mergeContentWarningTagsFromDraftOptions(tags, contentWarning) + } const stripped = stripImwaldAttributionTags(tags) if (addClientTag) { stripped.push(buildClientTag()) } return stripped - }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag]) + }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag, contentWarning]) const fakeEvent = useMemo(() => { // For voice comments, include the media URL in content if not already there @@ -244,6 +253,28 @@ export default function Preview({ }) }, [processedContent, allTags, kind, mediaUrl]) + const hasPreviewBody = useMemo(() => { + if (processedContent.trim()) return true + if (mediaUrl?.trim()) return true + if (articleMetadata?.title?.trim()) return true + if (articleMetadata?.summary?.trim()) return true + if (musicTrackMetadata?.title?.trim()) return true + if (musicTrackMetadata?.audioUrl?.trim()) return true + if (kind === ExtendedKind.POLL && pollCreateData?.options.some((o) => o.trim())) return true + if (kind === kinds.Highlights && highlightData?.sourceValue?.trim()) return true + if ((mediaImetaTags?.length ?? 0) > 0) return true + return false + }, [ + processedContent, + mediaUrl, + articleMetadata, + musicTrackMetadata, + kind, + pollCreateData, + highlightData, + mediaImetaTags + ]) + const selectableClass = 'select-text' const withClientBadge = (node: ReactNode) => addClientTag ? ( @@ -257,6 +288,14 @@ export default function Preview({ node ) + if (!hasPreviewBody) { + return ( + + {t('Post editor preview empty')} + + ) + } + // For polls, use ContentPreview to show poll properly if (kind === ExtendedKind.POLL) { return withClientBadge( diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx index 6674000c..5e74b739 100644 --- a/src/components/PostEditor/PostTextarea/index.tsx +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -19,6 +19,7 @@ import { Dispatch, forwardRef, SetStateAction, + useCallback, useEffect, useImperativeHandle, useMemo, @@ -34,6 +35,7 @@ import mentionSuggestion from './Mention/suggestion' import Preview from './Preview' import { HighlightData } from '../HighlightEditor' import { getKindDescription } from '@/lib/kind-description' +import type { TContentWarningDraftOptions } from '@/lib/content-warning' export type TPostTextareaHandle = { appendText: (text: string, addNewline?: boolean) => void @@ -95,6 +97,7 @@ const PostTextarea = forwardRef< } extraPreviewTags?: string[][] addClientTag?: boolean + contentWarning?: TContentWarningDraftOptions } >( ( @@ -120,7 +123,8 @@ const PostTextarea = forwardRef< articleMetadata, musicTrackMetadata, extraPreviewTags, - addClientTag = true + addClientTag = true, + contentWarning }, ref ) => { @@ -141,8 +145,23 @@ const PostTextarea = forwardRef< const onSubmitRef = useRef(onSubmit) onSubmitRef.current = onSubmit const [activeTab, setActiveTab] = useState('edit') + const activeTabRef = useRef(activeTab) + activeTabRef.current = activeTab + const [previewContent, setPreviewContent] = useState('') const editorRef = useRef(null) + const syncPreviewFromEditor = useCallback(() => { + const ed = editorRef.current + const live = ed ? parseEditorJsonToText(ed.getJSON()) : text + setPreviewContent(live) + if (ed) setText(live) + return live + }, [setText, text]) + + const previewSurfaceClass = cn( + isSmallScreen ? 'min-h-[min(36dvh,17rem)]' : 'min-h-52' + ) + const kindDescription = useMemo(() => getKindDescription(kind), [kind]) const placeholderText = useMemo( @@ -208,11 +227,17 @@ const PostTextarea = forwardRef< }, content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }), onUpdate(props) { - setText(parseEditorJsonToText(props.editor.getJSON())) + const live = parseEditorJsonToText(props.editor.getJSON()) + setText(live) + if (activeTabRef.current === 'preview') { + setPreviewContent(live) + } postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON()) }, onCreate(props) { - setText(parseEditorJsonToText(props.editor.getJSON())) + const live = parseEditorJsonToText(props.editor.getJSON()) + setText(live) + setPreviewContent(live) } }) @@ -284,6 +309,7 @@ const PostTextarea = forwardRef< // Also clear the cache postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) setText('') + setPreviewContent('') } }, syncFromPostCache: () => { @@ -293,7 +319,9 @@ const PostTextarea = forwardRef< if (next === undefined) return editor.chain().setContent(next).run() const json = editor.getJSON() - setText(parseEditorJsonToText(json)) + const live = parseEditorJsonToText(json) + setText(live) + setPreviewContent(live) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json) }, getText: () => { @@ -312,7 +340,9 @@ const PostTextarea = forwardRef< const json = plainTextToTipTapDoc(plain) editor.chain().setContent(json).run() postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) - setText(parseEditorJsonToText(editor.getJSON())) + const live = parseEditorJsonToText(editor.getJSON()) + setText(live) + setPreviewContent(live) } })) @@ -324,7 +354,12 @@ const PostTextarea = forwardRef< return ( { + if (tab === 'preview') { + syncPreviewFromEditor() + } + setActiveTab(tab) + }} className={cn( isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2 overflow-hidden' : 'space-y-2' )} @@ -371,15 +406,19 @@ const PostTextarea = forwardRef< -
-
+
+
kind {kindDescription.number}: {kindDescription.description}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7d60ccfb..3343561c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -2128,9 +2128,13 @@ export default { 'Content warning hint': 'Adds a NIP-36 content-warning tag so readers must opt in to view this note.', 'Content warning preset': 'Warning label', + 'Content warning preset placeholder': 'Choose a warning label…', 'Custom label…': 'Custom label…', 'Content warning custom placeholder': 'e.g. Violence, Trigger warning', 'Content warning label': '⚠ {{label}}', + 'Post editor content warning summary': + 'Content warning added: {{label}}. Open Advanced to change.', + 'Post editor preview empty': 'Nothing to preview yet.', 'Maximum {{max}} invitees': 'Maximum {{max}} invitees', 'Maximum {{max}} invitees allowed': 'Maximum {{max}} invitees allowed', Medium: 'Medium', diff --git a/src/lib/advanced-event-lab-slice.test.ts b/src/lib/advanced-event-lab-slice.test.ts index 036ddbac..aae375b0 100644 --- a/src/lib/advanced-event-lab-slice.test.ts +++ b/src/lib/advanced-event-lab-slice.test.ts @@ -33,4 +33,13 @@ describe('serializePublishPreviewLabJson', () => { const o = JSON.parse(json) as { tags: string[][] } expect(o.tags.some((t) => t[0] === 'client')).toBe(false) }) + + it('merges composer content-warning settings into preview tags', () => { + const json = serializePublishPreviewLabJson( + { kind: 1, content: 'hi', tags: [['content-warning', 'Spoilers']] }, + { contentWarning: { isNsfw: true, contentWarningLabel: 'Violence' } } + ) + const o = JSON.parse(json) as { tags: string[][] } + expect(o.tags.filter((t) => t[0] === 'content-warning')).toEqual([['content-warning', 'Violence']]) + }) }) diff --git a/src/lib/advanced-event-lab-slice.ts b/src/lib/advanced-event-lab-slice.ts index ca0eeb7e..2b007c34 100644 --- a/src/lib/advanced-event-lab-slice.ts +++ b/src/lib/advanced-event-lab-slice.ts @@ -1,3 +1,7 @@ +import { + mergeContentWarningTagsFromDraftOptions, + type TContentWarningDraftOptions +} from '@/lib/content-warning' import { applyImwaldAttributionTags, collectUploadImetaTagsForContentUrls, @@ -17,10 +21,13 @@ export type AdvancedEventLabSlice = { */ export function serializePublishPreviewLabJson( slice: AdvancedEventLabSlice, - options?: { addClientTag?: boolean } + options?: { addClientTag?: boolean; contentWarning?: TContentWarningDraftOptions } ): string { const tags = slice.tags.map((row) => [...row]) mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(slice.content)) + if (options?.contentWarning) { + mergeContentWarningTagsFromDraftOptions(tags, options.contentWarning) + } const draft: TDraftEvent = { kind: slice.kind, content: slice.content, diff --git a/src/lib/content-warning.test.ts b/src/lib/content-warning.test.ts new file mode 100644 index 00000000..b0aef61a --- /dev/null +++ b/src/lib/content-warning.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { + appendContentWarningTagIfNeeded, + contentWarningDraftOptions, + mergeContentWarningTagsFromDraftOptions +} from '@/lib/content-warning' + +describe('content-warning draft helpers', () => { + it('defaults to NSFW when enabled without an explicit label', () => { + expect(contentWarningDraftOptions(true, '')).toEqual({ + isNsfw: true, + contentWarningLabel: 'NSFW' + }) + }) + + it('appends the selected label when enabled', () => { + const tags: string[][] = [] + appendContentWarningTagIfNeeded(tags, contentWarningDraftOptions(true, 'Violence')) + expect(tags).toEqual([['content-warning', 'Violence']]) + }) + + it('does not append a tag when disabled', () => { + const tags: string[][] = [] + appendContentWarningTagIfNeeded(tags, contentWarningDraftOptions(false, 'NSFW')) + expect(tags).toEqual([]) + }) + + it('replaces manual lab content-warning tags with composer settings', () => { + const tags: string[][] = [['content-warning', 'Spoilers'], ['t', 'test']] + mergeContentWarningTagsFromDraftOptions(tags, contentWarningDraftOptions(true, 'Violence')) + expect(tags).toEqual([['t', 'test'], ['content-warning', 'Violence']]) + + mergeContentWarningTagsFromDraftOptions(tags, contentWarningDraftOptions(false, '')) + expect(tags).toEqual([['t', 'test']]) + }) +}) diff --git a/src/lib/content-warning.ts b/src/lib/content-warning.ts index eae6c6b3..64c66f76 100644 --- a/src/lib/content-warning.ts +++ b/src/lib/content-warning.ts @@ -56,6 +56,17 @@ export function contentWarningDraftOptions( return { isNsfw: true, contentWarningLabel: normalizeContentWarningLabel(label) } } +/** Apply composer content-warning settings over any manual lab tags. */ +export function mergeContentWarningTagsFromDraftOptions( + tags: string[][], + options: TContentWarningDraftOptions +): void { + for (let i = tags.length - 1; i >= 0; i--) { + if (tags[i][0] === 'content-warning') tags.splice(i, 1) + } + appendContentWarningTagIfNeeded(tags, options) +} + export function appendContentWarningTagIfNeeded( tags: string[][], options: TContentWarningDraftOptions