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 { @@ -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 = { @@ -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({ @@ -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({ @@ -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

23
src/components/PostEditor/PostContent.tsx

@ -42,7 +42,6 @@ import { @@ -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({ @@ -301,7 +300,7 @@ export default function PostContent({
const [addClientTag, setAddClientTag] = useState(() => storage.getAddClientTag())
const [mentions, setMentions] = useState<string[]>([])
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<string[]>(
@ -761,9 +760,7 @@ export default function PostContent({ @@ -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({ @@ -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<any> => {
const uploadImetaTagsOpt = mediaImetaTags.length > 0 ? mediaImetaTags : undefined
@ -2554,6 +2556,8 @@ export default function PostContent({ @@ -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({ @@ -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({ @@ -3687,6 +3692,13 @@ export default function PostContent({
}
/>
</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 && (
<div className="flex min-w-0 flex-col gap-1">
{threadErrors.content && <p className="text-sm text-destructive">{threadErrors.content}</p>}
@ -4043,6 +4055,7 @@ export default function PostContent({ @@ -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={

12
src/components/PostEditor/PostEditorAdvancedPanel.tsx

@ -101,7 +101,9 @@ export default function PostEditorAdvancedPanel({ @@ -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({ @@ -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({ @@ -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

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

@ -11,10 +11,12 @@ import { createFakeEvent } from '@/lib/event' @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -257,6 +288,14 @@ export default function Preview({
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
if (kind === ExtendedKind.POLL) {
return withClientBadge(

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

@ -19,6 +19,7 @@ import { @@ -19,6 +19,7 @@ import {
Dispatch,
forwardRef,
SetStateAction,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
@ -34,6 +35,7 @@ import mentionSuggestion from './Mention/suggestion' @@ -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< @@ -95,6 +97,7 @@ const PostTextarea = forwardRef<
}
extraPreviewTags?: string[][]
addClientTag?: boolean
contentWarning?: TContentWarningDraftOptions
}
>(
(
@ -120,7 +123,8 @@ const PostTextarea = forwardRef< @@ -120,7 +123,8 @@ const PostTextarea = forwardRef<
articleMetadata,
musicTrackMetadata,
extraPreviewTags,
addClientTag = true
addClientTag = true,
contentWarning
},
ref
) => {
@ -141,8 +145,23 @@ const PostTextarea = forwardRef< @@ -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<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 placeholderText = useMemo(
@ -208,11 +227,17 @@ const PostTextarea = forwardRef< @@ -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< @@ -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< @@ -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< @@ -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< @@ -324,7 +354,12 @@ const PostTextarea = forwardRef<
return (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
onValueChange={(tab) => {
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< @@ -371,15 +406,19 @@ const PostTextarea = forwardRef<
</TabsContent>
<TabsContent
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="text-xs text-muted-foreground">
<div className={cn('space-y-2', isSmallScreen && 'flex min-h-0 flex-1 flex-col')}>
<div className="text-xs text-muted-foreground shrink-0">
kind {kindDescription.number}: {kindDescription.description}
</div>
<Preview
content={text}
className={className}
content={previewContent}
className={previewSurfaceClass}
kind={kind}
highlightData={highlightData}
pollCreateData={pollCreateData}
@ -389,6 +428,7 @@ const PostTextarea = forwardRef< @@ -389,6 +428,7 @@ const PostTextarea = forwardRef<
musicTrackMetadata={musicTrackMetadata}
extraPreviewTags={extraPreviewTags}
addClientTag={addClientTag}
contentWarning={contentWarning}
/>
</div>
</TabsContent>

4
src/i18n/locales/en.ts

@ -2128,9 +2128,13 @@ export default { @@ -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',

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

@ -33,4 +33,13 @@ describe('serializePublishPreviewLabJson', () => { @@ -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']])
})
})

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

@ -1,3 +1,7 @@ @@ -1,3 +1,7 @@
import {
mergeContentWarningTagsFromDraftOptions,
type TContentWarningDraftOptions
} from '@/lib/content-warning'
import {
applyImwaldAttributionTags,
collectUploadImetaTagsForContentUrls,
@ -17,10 +21,13 @@ export type AdvancedEventLabSlice = { @@ -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,

36
src/lib/content-warning.test.ts

@ -0,0 +1,36 @@ @@ -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( @@ -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

Loading…
Cancel
Save