Browse Source

add content warning info text to editor

imwald
Silberengel 1 week ago
parent
commit
4751b17de0
  1. 10
      src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx
  2. 23
      src/components/PostEditor/PostContent.tsx
  3. 12
      src/components/PostEditor/PostEditorAdvancedPanel.tsx
  4. 43
      src/components/PostEditor/PostTextarea/Preview.tsx
  5. 62
      src/components/PostEditor/PostTextarea/index.tsx
  6. 4
      src/i18n/locales/en.ts
  7. 9
      src/lib/advanced-event-lab-slice.test.ts
  8. 9
      src/lib/advanced-event-lab-slice.ts
  9. 36
      src/lib/content-warning.test.ts
  10. 11
      src/lib/content-warning.ts

10
src/components/AdvancedEventLab/AdvancedEventLabDialog.tsx

@ -30,6 +30,7 @@ import {
serializePublishPreviewLabJson, serializePublishPreviewLabJson,
type AdvancedEventLabSlice type AdvancedEventLabSlice
} from '@/lib/advanced-event-lab-slice' } from '@/lib/advanced-event-lab-slice'
import type { TContentWarningDraftOptions } from '@/lib/content-warning'
import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect' import { translateAdvancedLabMarkup } from '@/lib/advanced-lab-markup-protect'
import { import {
warmTranslateLanguagesOnce, warmTranslateLanguagesOnce,
@ -201,6 +202,8 @@ export type AdvancedEventLabDialogProps = {
previewEmojiTags?: string[][] previewEmojiTags?: string[][]
/** When true (default), JSON preview includes the Imwald `client` tag like publish. */ /** When true (default), JSON preview includes the Imwald `client` tag like publish. */
addClientTag?: boolean addClientTag?: boolean
/** Composer Advanced panel content-warning settings (merged into JSON preview). */
contentWarning?: TContentWarningDraftOptions
} }
function useDarkModeFlag(): boolean { function useDarkModeFlag(): boolean {
@ -234,7 +237,8 @@ export default function AdvancedEventLabDialog({
draftPersistenceKey = null, draftPersistenceKey = null,
previewAuthorPubkey = null, previewAuthorPubkey = null,
previewEmojiTags, previewEmojiTags,
addClientTag = true addClientTag = true,
contentWarning
}: AdvancedEventLabDialogProps) { }: AdvancedEventLabDialogProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
/** `useTranslation().t` can change identity every render; never list it as a layout-effect dep (editor remount loop). */ /** `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, content,
tags: editableRowsToLabTags(labTagRows) tags: editableRowsToLabTags(labTagRows)
}, },
{ addClientTag } { addClientTag, contentWarning }
) )
) )
}, [kindEditable, initial, labTagRows, addClientTag]) }, [kindEditable, initial, labTagRows, addClientTag, contentWarning])
useEffect(() => { useEffect(() => {
refreshLabJsonPreviewRef.current = refreshLabJsonPreview refreshLabJsonPreviewRef.current = refreshLabJsonPreview

23
src/components/PostEditor/PostContent.tsx

@ -42,7 +42,6 @@ import {
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { import {
contentWarningDraftOptions, contentWarningDraftOptions,
DEFAULT_CONTENT_WARNING_LABEL,
normalizeContentWarningLabel normalizeContentWarningLabel
} from '@/lib/content-warning' } from '@/lib/content-warning'
import { import {
@ -301,7 +300,7 @@ export default function PostContent({
const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag()) const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag())
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false) const [isNsfw, setIsNsfw] = useState(false)
const [contentWarningLabel, setContentWarningLabel] = useState(DEFAULT_CONTENT_WARNING_LABEL) const [contentWarningLabel, setContentWarningLabel] = useState('')
const [isPoll, setIsPoll] = useState(false) const [isPoll, setIsPoll] = useState(false)
const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo) const [isPublicMessage, setIsPublicMessage] = useState(!!initialPublicMessageTo)
const [extractedMentions, setExtractedMentions] = useState<string[]>( const [extractedMentions, setExtractedMentions] = useState<string[]>(
@ -761,9 +760,7 @@ export default function PostContent({
}) })
if (cachedSettings) { if (cachedSettings) {
setIsNsfw(cachedSettings.isNsfw ?? false) setIsNsfw(cachedSettings.isNsfw ?? false)
setContentWarningLabel( setContentWarningLabel(cachedSettings.contentWarningLabel?.trim() ?? '')
normalizeContentWarningLabel(cachedSettings.contentWarningLabel ?? DEFAULT_CONTENT_WARNING_LABEL)
)
setIsPoll(cachedSettings.isPoll ?? false) setIsPoll(cachedSettings.isPoll ?? false)
setPollCreateData( setPollCreateData(
cachedSettings.pollCreateData ?? { cachedSettings.pollCreateData ?? {
@ -903,6 +900,11 @@ export default function PostContent({
return contextual.length ? contextual : undefined return contextual.length ? contextual : undefined
}, [isDiscussionThread, parentEvent, discussionPreviewExtraTags, rssReplyExtraPreviewTags]) }, [isDiscussionThread, parentEvent, discussionPreviewExtraTags, rssReplyExtraPreviewTags])
const labContentWarning = useMemo(
() => contentWarningDraftOptions(isNsfw, contentWarningLabel),
[isNsfw, contentWarningLabel]
)
// Shared function to create draft event - used by both preview and posting // Shared function to create draft event - used by both preview and posting
const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => { const createDraftEvent = useCallback(async (cleanedText: string): Promise<any> => {
const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined
@ -2554,6 +2556,8 @@ export default function PostContent({
sourceType: 'nostr', sourceType: 'nostr',
sourceValue: '' sourceValue: ''
}) })
setIsNsfw(false)
setContentWarningLabel('')
uploadedMediaFileMap.current.clear() uploadedMediaFileMap.current.clear()
composerImetaTagsRef.current = [] composerImetaTagsRef.current = []
setUploadProgresses([]) setUploadProgresses([])
@ -3443,6 +3447,7 @@ export default function PostContent({
articleMetadata={articlePreviewMetadata} articleMetadata={articlePreviewMetadata}
musicTrackMetadata={musicTrackPreviewMetadata} musicTrackMetadata={musicTrackPreviewMetadata}
addClientTag={addClientTag} addClientTag={addClientTag}
contentWarning={labContentWarning}
mediaImetaTags={mediaImetaTags} mediaImetaTags={mediaImetaTags}
mediaUrl={mediaUrl} mediaUrl={mediaUrl}
headerActions={(() => { headerActions={(() => {
@ -3687,6 +3692,13 @@ export default function PostContent({
} }
/> />
</div> </div>
{!showMoreOptions && isNsfw ? (
<p className="text-xs text-muted-foreground" role="status">
{t('Post editor content warning summary', {
label: normalizeContentWarningLabel(contentWarningLabel)
})}
</p>
) : null}
{isDiscussionThread && !parentEvent && ( {isDiscussionThread && !parentEvent && (
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
{threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>} {threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>}
@ -4043,6 +4055,7 @@ export default function PostContent({
contextEventId={parentEvent?.id ?? null} contextEventId={parentEvent?.id ?? null}
previewAuthorPubkey={pubkey ?? null} previewAuthorPubkey={pubkey ?? null}
addClientTag={addClientTag} addClientTag={addClientTag}
contentWarning={labContentWarning}
draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null} draftPersistenceKey={advancedLabOpen ? advancedLabPersistenceKey : null}
bodyApiRef={advancedLabBodyApiRef} bodyApiRef={advancedLabBodyApiRef}
formatToolbar={ formatToolbar={

12
src/components/PostEditor/PostEditorAdvancedPanel.tsx

@ -101,7 +101,9 @@ export default function PostEditorAdvancedPanel({
} }
const selectValue = useMemo(() => { 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 return CONTENT_WARNING_CUSTOM_SELECT_VALUE
}, [contentWarningLabel]) }, [contentWarningLabel])
@ -196,8 +198,10 @@ export default function PostEditorAdvancedPanel({
checked={isNsfw} checked={isNsfw}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setIsNsfw(checked) setIsNsfw(checked)
if (checked && !contentWarningLabel.trim()) { if (checked) {
setContentWarningLabel(DEFAULT_CONTENT_WARNING_LABEL) setContentWarningLabel((prev) => prev.trim() || DEFAULT_CONTENT_WARNING_LABEL)
} else {
setContentWarningLabel('')
} }
}} }}
disabled={posting} disabled={posting}
@ -210,7 +214,7 @@ export default function PostEditorAdvancedPanel({
value={selectValue} value={selectValue}
onValueChange={(value) => { onValueChange={(value) => {
if (value === CONTENT_WARNING_CUSTOM_SELECT_VALUE) { if (value === CONTENT_WARNING_CUSTOM_SELECT_VALUE) {
if (isPresetContentWarningLabel(contentWarningLabel)) { if (isPresetContentWarningLabel(contentWarningLabel.trim())) {
setContentWarningLabel('') setContentWarningLabel('')
} }
return return

43
src/components/PostEditor/PostTextarea/Preview.tsx

@ -11,10 +11,12 @@ import { createFakeEvent } from '@/lib/event'
import { randomString } from '@/lib/random' import { randomString } from '@/lib/random'
import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url' import { cleanUrl, rewritePlainTextHttpUrls } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { mergeContentWarningTagsFromDraftOptions, type TContentWarningDraftOptions } from '@/lib/content-warning'
import { TPollCreateData } from '@/types' import { TPollCreateData } from '@/types'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content' import { replaceStandardEmojiShortcodesInContent } from '@/lib/emoji-content'
import { useMemo, type ReactNode } from 'react' import { useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import ContentPreview from '../../ContentPreview' import ContentPreview from '../../ContentPreview'
import Content from '../../Content' import Content from '../../Content'
import Highlight from '../../Note/Highlight' import Highlight from '../../Note/Highlight'
@ -34,7 +36,8 @@ export default function Preview({
articleMetadata, articleMetadata,
musicTrackMetadata, musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true addClientTag = true,
contentWarning
}: { }: {
content: string content: string
className?: string className?: string
@ -68,7 +71,10 @@ export default function Preview({
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
/** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */ /** When true (default), preview matches publish: Imwald `client` + attribution `alt` tags and badge. */
addClientTag?: boolean addClientTag?: boolean
/** Composer Advanced panel content-warning settings. */
contentWarning?: TContentWarningDraftOptions
}) { }) {
const { t } = useTranslation()
const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo( const { content: processedContent, emojiTags, highlightTags, pollTags } = useMemo(
() => { () => {
// Clean tracking parameters from URLs in the preview // Clean tracking parameters from URLs in the preview
@ -223,12 +229,15 @@ export default function Preview({
if (extraPreviewTags?.length) { if (extraPreviewTags?.length) {
tags.push(...extraPreviewTags) tags.push(...extraPreviewTags)
} }
if (contentWarning) {
mergeContentWarningTagsFromDraftOptions(tags, contentWarning)
}
const stripped = stripImwaldAttributionTags(tags) const stripped = stripImwaldAttributionTags(tags)
if (addClientTag) { if (addClientTag) {
stripped.push(buildClientTag()) stripped.push(buildClientTag())
} }
return stripped return stripped
}, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag]) }, [emojiTags, highlightTags, pollTags, mediaImetaTags, articleMetadata, musicTrackMetadata, kind, extraPreviewTags, addClientTag, contentWarning])
const fakeEvent = useMemo(() => { const fakeEvent = useMemo(() => {
// For voice comments, include the media URL in content if not already there // 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]) }, [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 selectableClass = 'select-text'
const withClientBadge = (node: ReactNode) => const withClientBadge = (node: ReactNode) =>
addClientTag ? ( addClientTag ? (
@ -257,6 +288,14 @@ export default function Preview({
node node
) )
if (!hasPreviewBody) {
return (
<Card className={cn('p-3 text-sm text-muted-foreground', className, selectableClass)}>
{t('Post editor preview empty')}
</Card>
)
}
// For polls, use ContentPreview to show poll properly // For polls, use ContentPreview to show poll properly
if (kind === ExtendedKind.POLL) { if (kind === ExtendedKind.POLL) {
return withClientBadge( return withClientBadge(

62
src/components/PostEditor/PostTextarea/index.tsx

@ -19,6 +19,7 @@ import {
Dispatch, Dispatch,
forwardRef, forwardRef,
SetStateAction, SetStateAction,
useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
@ -34,6 +35,7 @@ import mentionSuggestion from './Mention/suggestion'
import Preview from './Preview' import Preview from './Preview'
import { HighlightData } from '../HighlightEditor' import { HighlightData } from '../HighlightEditor'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import type { TContentWarningDraftOptions } from '@/lib/content-warning'
export type TPostTextareaHandle = { export type TPostTextareaHandle = {
appendText: (text: string, addNewline?: boolean) => void appendText: (text: string, addNewline?: boolean) => void
@ -95,6 +97,7 @@ const PostTextarea = forwardRef<
} }
extraPreviewTags?: string[][] extraPreviewTags?: string[][]
addClientTag?: boolean addClientTag?: boolean
contentWarning?: TContentWarningDraftOptions
} }
>( >(
( (
@ -120,7 +123,8 @@ const PostTextarea = forwardRef<
articleMetadata, articleMetadata,
musicTrackMetadata, musicTrackMetadata,
extraPreviewTags, extraPreviewTags,
addClientTag = true addClientTag = true,
contentWarning
}, },
ref ref
) => { ) => {
@ -141,8 +145,23 @@ const PostTextarea = forwardRef<
const onSubmitRef = useRef(onSubmit) const onSubmitRef = useRef(onSubmit)
onSubmitRef.current = onSubmit onSubmitRef.current = onSubmit
const [activeTab, setActiveTab] = useState('edit') const [activeTab, setActiveTab] = useState('edit')
const activeTabRef = useRef(activeTab)
activeTabRef.current = activeTab
const [previewContent, setPreviewContent] = useState('')
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(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 kindDescription = useMemo(() => getKindDescription(kind), [kind])
const placeholderText = useMemo( const placeholderText = useMemo(
@ -208,11 +227,17 @@ const PostTextarea = forwardRef<
}, },
content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }), content: postEditorCache.getPostContentCache({ kind, defaultContent, parentEvent }),
onUpdate(props) { 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()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, props.editor.getJSON())
}, },
onCreate(props) { 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 // Also clear the cache
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON())
setText('') setText('')
setPreviewContent('')
} }
}, },
syncFromPostCache: () => { syncFromPostCache: () => {
@ -293,7 +319,9 @@ const PostTextarea = forwardRef<
if (next === undefined) return if (next === undefined) return
editor.chain().setContent(next).run() editor.chain().setContent(next).run()
const json = editor.getJSON() const json = editor.getJSON()
setText(parseEditorJsonToText(json)) const live = parseEditorJsonToText(json)
setText(live)
setPreviewContent(live)
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json) postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, json)
}, },
getText: () => { getText: () => {
@ -312,7 +340,9 @@ const PostTextarea = forwardRef<
const json = plainTextToTipTapDoc(plain) const json = plainTextToTipTapDoc(plain)
editor.chain().setContent(json).run() editor.chain().setContent(json).run()
postEditorCache.setPostContentCache({ kind, defaultContent, parentEvent }, editor.getJSON()) 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 ( return (
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={setActiveTab} onValueChange={(tab) => {
if (tab === 'preview') {
syncPreviewFromEditor()
}
setActiveTab(tab)
}}
className={cn( className={cn(
isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2 overflow-hidden' : 'space-y-2' isSmallScreen ? 'flex min-h-0 flex-1 flex-col gap-2 overflow-hidden' : 'space-y-2'
)} )}
@ -371,15 +406,19 @@ const PostTextarea = forwardRef<
</TabsContent> </TabsContent>
<TabsContent <TabsContent
value="preview" value="preview"
className="mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0" forceMount
className={cn(
'mt-0 data-[state=inactive]:hidden focus-visible:ring-0 focus-visible:ring-offset-0',
isSmallScreen && 'flex min-h-0 flex-1 flex-col overflow-hidden'
)}
> >
<div className="space-y-2"> <div className={cn('space-y-2', isSmallScreen && 'flex min-h-0 flex-1 flex-col')}>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground shrink-0">
kind {kindDescription.number}: {kindDescription.description} kind {kindDescription.number}: {kindDescription.description}
</div> </div>
<Preview <Preview
content={text} content={previewContent}
className={className} className={previewSurfaceClass}
kind={kind} kind={kind}
highlightData={highlightData} highlightData={highlightData}
pollCreateData={pollCreateData} pollCreateData={pollCreateData}
@ -389,6 +428,7 @@ const PostTextarea = forwardRef<
musicTrackMetadata={musicTrackMetadata} musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags} extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag} addClientTag={addClientTag}
contentWarning={contentWarning}
/> />
</div> </div>
</TabsContent> </TabsContent>

4
src/i18n/locales/en.ts

@ -2128,9 +2128,13 @@ export default {
'Content warning hint': 'Content warning hint':
'Adds a NIP-36 content-warning tag so readers must opt in to view this note.', 'Adds a NIP-36 content-warning tag so readers must opt in to view this note.',
'Content warning preset': 'Warning label', 'Content warning preset': 'Warning label',
'Content warning preset placeholder': 'Choose a warning label…',
'Custom label…': 'Custom label…', 'Custom label…': 'Custom label…',
'Content warning custom placeholder': 'e.g. Violence, Trigger warning', 'Content warning custom placeholder': 'e.g. Violence, Trigger warning',
'Content warning label': '⚠ {{label}}', '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': 'Maximum {{max}} invitees',
'Maximum {{max}} invitees allowed': 'Maximum {{max}} invitees allowed', 'Maximum {{max}} invitees allowed': 'Maximum {{max}} invitees allowed',
Medium: 'Medium', Medium: 'Medium',

9
src/lib/advanced-event-lab-slice.test.ts

@ -33,4 +33,13 @@ describe('serializePublishPreviewLabJson', () => {
const o = JSON.parse(json) as { tags: string[][] } const o = JSON.parse(json) as { tags: string[][] }
expect(o.tags.some((t) => t[0] === 'client')).toBe(false) 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']])
})
}) })

9
src/lib/advanced-event-lab-slice.ts

@ -1,3 +1,7 @@
import {
mergeContentWarningTagsFromDraftOptions,
type TContentWarningDraftOptions
} from '@/lib/content-warning'
import { import {
applyImwaldAttributionTags, applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls, collectUploadImetaTagsForContentUrls,
@ -17,10 +21,13 @@ export type AdvancedEventLabSlice = {
*/ */
export function serializePublishPreviewLabJson( export function serializePublishPreviewLabJson(
slice: AdvancedEventLabSlice, slice: AdvancedEventLabSlice,
options?: { addClientTag?: boolean } options?: { addClientTag?: boolean; contentWarning?: TContentWarningDraftOptions }
): string { ): string {
const tags = slice.tags.map((row) => [...row]) const tags = slice.tags.map((row) => [...row])
mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(slice.content)) mergeUploadImetaTagsInto(tags, collectUploadImetaTagsForContentUrls(slice.content))
if (options?.contentWarning) {
mergeContentWarningTagsFromDraftOptions(tags, options.contentWarning)
}
const draft: TDraftEvent = { const draft: TDraftEvent = {
kind: slice.kind, kind: slice.kind,
content: slice.content, content: slice.content,

36
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']])
})
})

11
src/lib/content-warning.ts

@ -56,6 +56,17 @@ export function contentWarningDraftOptions(
return { isNsfw: true, contentWarningLabel: normalizeContentWarningLabel(label) } 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( export function appendContentWarningTagIfNeeded(
tags: string[][], tags: string[][],
options: TContentWarningDraftOptions options: TContentWarningDraftOptions

Loading…
Cancel
Save