feat: add nsfw toggle to post editor

This commit is contained in:
codytseng
2025-06-27 22:55:21 +08:00
parent 544d65972a
commit 5df33837ab
11 changed files with 94 additions and 88 deletions

View File

@@ -11,7 +11,7 @@ import {
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event' import { extractEmojiInfosFromTags } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag' import { extractImageInfoFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import mediaUpload from '@/services/media-upload.service' import mediaUpload from '@/services/media-upload.service'
@@ -88,20 +88,11 @@ const Content = memo(({ event, className }: { event: Event; className?: string }
const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1) const end = imageIndex + (Array.isArray(node.data) ? node.data.length : 1)
imageIndex = end imageIndex = end
return ( return (
<ImageGallery <ImageGallery className="mt-2" key={index} images={allImages} start={start} end={end} />
className="mt-2"
key={index}
images={allImages}
isNsfw={isNsfwEvent(event)}
start={start}
end={end}
/>
) )
} }
if (node.type === 'video') { if (node.type === 'video') {
return ( return <VideoPlayer className="mt-2" key={index} src={node.data} />
<VideoPlayer className="mt-2" key={index} src={node.data} isNsfw={isNsfwEvent(event)} />
)
} }
if (node.type === 'url') { if (node.type === 'url') {
return <EmbeddedNormalUrl url={node.data} key={index} /> return <EmbeddedNormalUrl url={node.data} key={index} />

View File

@@ -6,15 +6,8 @@ import { useEffect, useState } from 'react'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export function ImageCarousel({ export function ImageCarousel({ images }: { images: TImageInfo[] }) {
images,
isNsfw = false
}: {
images: TImageInfo[]
isNsfw?: boolean
}) {
const [api, setApi] = useState<CarouselApi>() const [api, setApi] = useState<CarouselApi>()
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [lightboxIndex, setLightboxIndex] = useState(-1) const [lightboxIndex, setLightboxIndex] = useState(-1)
@@ -42,7 +35,7 @@ export function ImageCarousel({
} }
return ( return (
<div className="relative space-y-2"> <div className="space-y-2">
<Carousel className="w-full" setApi={setApi}> <Carousel className="w-full" setApi={setApi}>
<CarouselContent className="xl:px-4"> <CarouselContent className="xl:px-4">
{images.map((image, index) => ( {images.map((image, index) => (
@@ -78,7 +71,6 @@ export function ImageCarousel({
}} }}
styles={{ toolbar: { paddingTop: '2.25rem' } }} styles={{ toolbar: { paddingTop: '2.25rem' } }}
/> />
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div> </div>
) )
} }

View File

@@ -7,18 +7,15 @@ import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom' import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image' import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export default function ImageGallery({ export default function ImageGallery({
className, className,
images, images,
isNsfw = false,
start = 0, start = 0,
end = images.length end = images.length
}: { }: {
className?: string className?: string
images: TImageInfo[] images: TImageInfo[]
isNsfw?: boolean
start?: number start?: number
end?: number end?: number
}) { }) {
@@ -83,13 +80,7 @@ export default function ImageGallery({
} }
return ( return (
<div <div className={cn(displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full', className)}>
className={cn(
'relative',
displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full',
className
)}
>
{imageContent} {imageContent}
{index >= 0 && {index >= 0 &&
createPortal( createPortal(
@@ -112,7 +103,6 @@ export default function ImageGallery({
</div>, </div>,
document.body document.body
)} )}
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div> </div>
) )
} }

View File

@@ -0,0 +1,23 @@
import { Button } from '@/components/ui/button'
import { Eye } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function NsfwNote({ show }: { show: () => void }) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium my-4">
<div>{t('🔞 NSFW 🔞')}</div>
<Button
onClick={(e) => {
e.stopPropagation()
show()
}}
variant="outline"
>
<Eye />
{t('Temporarily display this note')}
</Button>
</div>
)
}

View File

@@ -3,13 +3,14 @@ import {
extractImageInfosFromEventTags, extractImageInfosFromEventTags,
getParentEventId, getParentEventId,
getUsingClient, getUsingClient,
isNsfwEvent,
isPictureEvent, isPictureEvent,
isSupportedKind isSupportedKind
} from '@/lib/event' } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo, useState } from 'react'
import Content from '../Content' import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp' import { FormattedTimestamp } from '../FormattedTimestamp'
import ImageGallery from '../ImageGallery' import ImageGallery from '../ImageGallery'
@@ -21,6 +22,7 @@ import UserAvatar from '../UserAvatar'
import Username from '../Username' import Username from '../Username'
import Highlight from './Highlight' import Highlight from './Highlight'
import IValue from './IValue' import IValue from './IValue'
import NsfwNote from './NsfwNote'
import { UnknownNote } from './UnknownNote' import { UnknownNote } from './UnknownNote'
export default function Note({ export default function Note({
@@ -45,6 +47,18 @@ export default function Note({
[event] [event]
) )
const usingClient = useMemo(() => getUsingClient(event), [event]) const usingClient = useMemo(() => getUsingClient(event), [event])
const [showNsfw, setShowNsfw] = useState(false)
let content: React.ReactNode
if (!isSupportedKind(event.kind)) {
content = <UnknownNote className="mt-2" event={event} />
} else if (isNsfwEvent(event) && !showNsfw) {
content = <NsfwNote show={() => setShowNsfw(true)} />
} else if (event.kind === kinds.Highlights) {
content = <Highlight className="mt-2" event={event} />
} else {
content = <Content className="mt-2" event={event} />
}
return ( return (
<div className={className}> <div className={className}>
@@ -90,13 +104,7 @@ export default function Note({
/> />
)} )}
<IValue event={event} className="mt-2" /> <IValue event={event} className="mt-2" />
{event.kind === kinds.Highlights ? ( {content}
<Highlight className="mt-2" event={event} />
) : isSupportedKind(event.kind) ? (
<Content className="mt-2" event={event} />
) : (
<UnknownNote className="mt-2" event={event} />
)}
{imageInfos.length > 0 && <ImageGallery images={imageInfos} />} {imageInfos.length > 0 && <ImageGallery images={imageInfos} />}
</div> </div>
) )

View File

@@ -1,21 +0,0 @@
import { cn } from '@/lib/utils'
import { useState } from 'react'
export default function NsfwOverlay({ className }: { className?: string }) {
const [isHidden, setIsHidden] = useState(true)
return (
isHidden && (
<div
className={cn(
'absolute top-0 left-0 backdrop-blur-3xl w-full h-full cursor-pointer',
className
)}
onClick={(e) => {
e.stopPropagation()
setIsHidden(false)
}}
/>
)
)
}

View File

@@ -1,13 +1,13 @@
import { import {
EmbeddedEmojiParser, EmbeddedEmojiParser,
EmbeddedLNInvoiceParser,
EmbeddedHashtagParser, EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser, EmbeddedMentionParser,
EmbeddedNormalUrlParser, EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser, EmbeddedWebsocketUrlParser,
parseContent parseContent
} from '@/lib/content-parser' } from '@/lib/content-parser'
import { extractEmojiInfosFromTags, extractImageInfosFromEventTags, isNsfwEvent } from '@/lib/event' import { extractEmojiInfosFromTags, extractImageInfosFromEventTags } from '@/lib/event'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { memo, useMemo } from 'react' import { memo, useMemo } from 'react'
@@ -18,12 +18,11 @@ import {
EmbeddedNormalUrl, EmbeddedNormalUrl,
EmbeddedWebsocketUrl EmbeddedWebsocketUrl
} from '../Embedded' } from '../Embedded'
import { ImageCarousel } from '../ImageCarousel'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import { ImageCarousel } from '../ImageCarousel'
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => { const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
const images = useMemo(() => extractImageInfosFromEventTags(event), [event]) const images = useMemo(() => extractImageInfosFromEventTags(event), [event])
const isNsfw = isNsfwEvent(event)
const nodes = parseContent(event.content, [ const nodes = parseContent(event.content, [
EmbeddedNormalUrlParser, EmbeddedNormalUrlParser,
@@ -38,7 +37,7 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
return ( return (
<div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}> <div className={cn('text-wrap break-words whitespace-pre-wrap space-y-2', className)}>
<ImageCarousel images={images} isNsfw={isNsfw} /> <ImageCarousel images={images} />
<div className="px-4"> <div className="px-4">
{nodes.map((node, index) => { {nodes.map((node, index) => {
if (node.type === 'text') { if (node.type === 'text') {

View File

@@ -37,6 +37,7 @@ export default function PostContent({
const [addClientTag, setAddClientTag] = useState(false) const [addClientTag, setAddClientTag] = useState(false)
const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined) const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState<string[] | undefined>(undefined)
const [mentions, setMentions] = useState<string[]>([]) const [mentions, setMentions] = useState<string[]>([])
const [isNsfw, setIsNsfw] = useState(false)
const canPost = !!text && !posting && !uploadingFiles const canPost = !!text && !posting && !uploadingFiles
const post = async (e?: React.MouseEvent) => { const post = async (e?: React.MouseEvent) => {
@@ -50,12 +51,14 @@ export default function PostContent({
parentEvent && parentEvent.kind !== kinds.ShortTextNote parentEvent && parentEvent.kind !== kinds.ShortTextNote
? await createCommentDraftEvent(text, parentEvent, mentions, { ? await createCommentDraftEvent(text, parentEvent, mentions, {
addClientTag, addClientTag,
protectedEvent: !!specifiedRelayUrls protectedEvent: !!specifiedRelayUrls,
isNsfw
}) })
: await createShortTextNoteDraftEvent(text, mentions, { : await createShortTextNoteDraftEvent(text, mentions, {
parentEvent, parentEvent,
addClientTag, addClientTag,
protectedEvent: !!specifiedRelayUrls protectedEvent: !!specifiedRelayUrls,
isNsfw
}) })
await publish(draftEvent, { specifiedRelayUrls }) await publish(draftEvent, { specifiedRelayUrls })
postContentCache.clearPostCache({ defaultContent, parentEvent }) postContentCache.clearPostCache({ defaultContent, parentEvent })
@@ -159,6 +162,8 @@ export default function PostContent({
show={showMoreOptions} show={showMoreOptions}
addClientTag={addClientTag} addClientTag={addClientTag}
setAddClientTag={setAddClientTag} setAddClientTag={setAddClientTag}
isNsfw={isNsfw}
setIsNsfw={setIsNsfw}
/> />
<div className="flex gap-2 items-center justify-around sm:hidden"> <div className="flex gap-2 items-center justify-around sm:hidden">
<Button <Button

View File

@@ -7,11 +7,15 @@ import { useTranslation } from 'react-i18next'
export default function PostOptions({ export default function PostOptions({
show, show,
addClientTag, addClientTag,
setAddClientTag setAddClientTag,
isNsfw,
setIsNsfw
}: { }: {
show: boolean show: boolean
addClientTag: boolean addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>> setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@@ -26,15 +30,30 @@ export default function PostOptions({
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString()) window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
} }
const onNsfwChange = (checked: boolean) => {
setIsNsfw(checked)
}
return ( return (
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label> <Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch id="add-client-tag" checked={addClientTag} onCheckedChange={onAddClientTagChange} /> <Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
/>
</div> </div>
<div className="text-muted-foreground text-xs"> <div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')} {t('Show others this was sent via Jumble')}
</div> </div>
</div> </div>
<div className="flex items-center space-x-2">
<Label htmlFor="add-nsfw-tag">{t('NSFW')}</Label>
<Switch id="add-nsfw-tag" checked={isNsfw} onCheckedChange={onNsfwChange} />
</div>
</div>
) )
} }

View File

@@ -2,17 +2,8 @@ import { cn, isInViewport } from '@/lib/utils'
import { useAutoplay } from '@/providers/AutoplayProvider' import { useAutoplay } from '@/providers/AutoplayProvider'
import videoManager from '@/services/video-manager.service' import videoManager from '@/services/video-manager.service'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import NsfwOverlay from '../NsfwOverlay'
export default function VideoPlayer({ export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
src,
className,
isNsfw = false
}: {
src: string
className?: string
isNsfw?: boolean
}) {
const { autoplay } = useAutoplay() const { autoplay } = useAutoplay()
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@@ -48,7 +39,7 @@ export default function VideoPlayer({
}, [autoplay]) }, [autoplay])
return ( return (
<div ref={containerRef} className="relative"> <div ref={containerRef}>
<video <video
ref={videoRef} ref={videoRef}
controls controls
@@ -61,7 +52,6 @@ export default function VideoPlayer({
}} }}
muted muted
/> />
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div> </div>
) )
} }

View File

@@ -68,6 +68,7 @@ export async function createShortTextNoteDraftEvent(
parentEvent?: Event parentEvent?: Event
addClientTag?: boolean addClientTag?: boolean
protectedEvent?: boolean protectedEvent?: boolean
isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { quoteEventIds, rootETag, parentETag } = await extractRelatedEventIds( const { quoteEventIds, rootETag, parentETag } = await extractRelatedEventIds(
@@ -103,6 +104,10 @@ export async function createShortTextNoteDraftEvent(
tags.push(['client', 'jumble']) tags.push(['client', 'jumble'])
} }
if (options.isNsfw) {
tags.push(['content-warning', 'NSFW'])
}
if (options.protectedEvent) { if (options.protectedEvent) {
tags.push(['-']) tags.push(['-'])
} }
@@ -182,6 +187,7 @@ export async function createCommentDraftEvent(
options: { options: {
addClientTag?: boolean addClientTag?: boolean
protectedEvent?: boolean protectedEvent?: boolean
isNsfw?: boolean
} = {} } = {}
): Promise<TDraftEvent> { ): Promise<TDraftEvent> {
const { const {
@@ -241,6 +247,10 @@ export async function createCommentDraftEvent(
tags.push(['client', 'jumble']) tags.push(['client', 'jumble'])
} }
if (options.isNsfw) {
tags.push(['content-warning', 'NSFW'])
}
if (options.protectedEvent) { if (options.protectedEvent) {
tags.push(['-']) tags.push(['-'])
} }