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,
parseContent
} from '@/lib/content-parser'
import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event'
import { extractEmojiInfosFromTags } from '@/lib/event'
import { extractImageInfoFromTag } from '@/lib/tag'
import { cn } from '@/lib/utils'
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)
imageIndex = end
return (
<ImageGallery
className="mt-2"
key={index}
images={allImages}
isNsfw={isNsfwEvent(event)}
start={start}
end={end}
/>
<ImageGallery className="mt-2" key={index} images={allImages} start={start} end={end} />
)
}
if (node.type === 'video') {
return (
<VideoPlayer className="mt-2" key={index} src={node.data} isNsfw={isNsfwEvent(event)} />
)
return <VideoPlayer className="mt-2" key={index} src={node.data} />
}
if (node.type === 'url') {
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 Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export function ImageCarousel({
images,
isNsfw = false
}: {
images: TImageInfo[]
isNsfw?: boolean
}) {
export function ImageCarousel({ images }: { images: TImageInfo[] }) {
const [api, setApi] = useState<CarouselApi>()
const [currentIndex, setCurrentIndex] = useState(0)
const [lightboxIndex, setLightboxIndex] = useState(-1)
@@ -42,7 +35,7 @@ export function ImageCarousel({
}
return (
<div className="relative space-y-2">
<div className="space-y-2">
<Carousel className="w-full" setApi={setApi}>
<CarouselContent className="xl:px-4">
{images.map((image, index) => (
@@ -78,7 +71,6 @@ export function ImageCarousel({
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View File

@@ -7,18 +7,15 @@ import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import Image from '../Image'
import NsfwOverlay from '../NsfwOverlay'
export default function ImageGallery({
className,
images,
isNsfw = false,
start = 0,
end = images.length
}: {
className?: string
images: TImageInfo[]
isNsfw?: boolean
start?: number
end?: number
}) {
@@ -83,13 +80,7 @@ export default function ImageGallery({
}
return (
<div
className={cn(
'relative',
displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full',
className
)}
>
<div className={cn(displayImages.length === 1 ? 'w-fit max-w-full' : 'w-full', className)}>
{imageContent}
{index >= 0 &&
createPortal(
@@ -112,7 +103,6 @@ export default function ImageGallery({
</div>,
document.body
)}
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</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,
getParentEventId,
getUsingClient,
isNsfwEvent,
isPictureEvent,
isSupportedKind
} from '@/lib/event'
import { toNote } from '@/lib/link'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useMemo, useState } from 'react'
import Content from '../Content'
import { FormattedTimestamp } from '../FormattedTimestamp'
import ImageGallery from '../ImageGallery'
@@ -21,6 +22,7 @@ import UserAvatar from '../UserAvatar'
import Username from '../Username'
import Highlight from './Highlight'
import IValue from './IValue'
import NsfwNote from './NsfwNote'
import { UnknownNote } from './UnknownNote'
export default function Note({
@@ -45,6 +47,18 @@ export default function Note({
[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 (
<div className={className}>
@@ -90,13 +104,7 @@ export default function Note({
/>
)}
<IValue event={event} className="mt-2" />
{event.kind === kinds.Highlights ? (
<Highlight className="mt-2" event={event} />
) : isSupportedKind(event.kind) ? (
<Content className="mt-2" event={event} />
) : (
<UnknownNote className="mt-2" event={event} />
)}
{content}
{imageInfos.length > 0 && <ImageGallery images={imageInfos} />}
</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 {
EmbeddedEmojiParser,
EmbeddedLNInvoiceParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedNormalUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import { extractEmojiInfosFromTags, extractImageInfosFromEventTags, isNsfwEvent } from '@/lib/event'
import { extractEmojiInfosFromTags, extractImageInfosFromEventTags } from '@/lib/event'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { memo, useMemo } from 'react'
@@ -18,12 +18,11 @@ import {
EmbeddedNormalUrl,
EmbeddedWebsocketUrl
} from '../Embedded'
import { ImageCarousel } from '../ImageCarousel'
import Emoji from '../Emoji'
import { ImageCarousel } from '../ImageCarousel'
const PictureContent = memo(({ event, className }: { event: Event; className?: string }) => {
const images = useMemo(() => extractImageInfosFromEventTags(event), [event])
const isNsfw = isNsfwEvent(event)
const nodes = parseContent(event.content, [
EmbeddedNormalUrlParser,
@@ -38,7 +37,7 @@ const PictureContent = memo(({ event, className }: { event: Event; className?: s
return (
<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">
{nodes.map((node, index) => {
if (node.type === 'text') {

View File

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

View File

@@ -7,11 +7,15 @@ import { useTranslation } from 'react-i18next'
export default function PostOptions({
show,
addClientTag,
setAddClientTag
setAddClientTag,
isNsfw,
setIsNsfw
}: {
show: boolean
addClientTag: boolean
setAddClientTag: Dispatch<SetStateAction<boolean>>
isNsfw: boolean
setIsNsfw: Dispatch<SetStateAction<boolean>>
}) {
const { t } = useTranslation()
@@ -26,14 +30,29 @@ export default function PostOptions({
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
}
const onNsfwChange = (checked: boolean) => {
setIsNsfw(checked)
}
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch id="add-client-tag" checked={addClientTag} onCheckedChange={onAddClientTagChange} />
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
<Switch
id="add-client-tag"
checked={addClientTag}
onCheckedChange={onAddClientTagChange}
/>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')}
</div>
</div>
<div className="text-muted-foreground text-xs">
{t('Show others this was sent via Jumble')}
<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 videoManager from '@/services/video-manager.service'
import { useEffect, useRef } from 'react'
import NsfwOverlay from '../NsfwOverlay'
export default function VideoPlayer({
src,
className,
isNsfw = false
}: {
src: string
className?: string
isNsfw?: boolean
}) {
export default function VideoPlayer({ src, className }: { src: string; className?: string }) {
const { autoplay } = useAutoplay()
const videoRef = useRef<HTMLVideoElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
@@ -48,7 +39,7 @@ export default function VideoPlayer({
}, [autoplay])
return (
<div ref={containerRef} className="relative">
<div ref={containerRef}>
<video
ref={videoRef}
controls
@@ -61,7 +52,6 @@ export default function VideoPlayer({
}}
muted
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

View File

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