Browse Source

feat: add nsfw toggle to post editor

imwald
codytseng 9 months ago
parent
commit
5df33837ab
  1. 15
      src/components/Content/index.tsx
  2. 12
      src/components/ImageCarousel/index.tsx
  3. 12
      src/components/ImageGallery/index.tsx
  4. 23
      src/components/Note/NsfwNote.tsx
  5. 24
      src/components/Note/index.tsx
  6. 21
      src/components/NsfwOverlay/index.tsx
  7. 9
      src/components/PictureContent/index.tsx
  8. 9
      src/components/PostEditor/PostContent.tsx
  9. 33
      src/components/PostEditor/PostOptions.tsx
  10. 14
      src/components/VideoPlayer/index.tsx
  11. 10
      src/lib/draft-event.ts

15
src/components/Content/index.tsx

@ -11,7 +11,7 @@ import { @@ -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 } @@ -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} />

12
src/components/ImageCarousel/index.tsx

@ -6,15 +6,8 @@ import { useEffect, useState } from 'react' @@ -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({ @@ -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({ @@ -78,7 +71,6 @@ export function ImageCarousel({
}}
styles={{ toolbar: { paddingTop: '2.25rem' } }}
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

12
src/components/ImageGallery/index.tsx

@ -7,18 +7,15 @@ import { createPortal } from 'react-dom' @@ -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({ @@ -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({ @@ -112,7 +103,6 @@ export default function ImageGallery({
</div>,
document.body
)}
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

23
src/components/Note/NsfwNote.tsx

@ -0,0 +1,23 @@ @@ -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>
)
}

24
src/components/Note/index.tsx

@ -3,13 +3,14 @@ import { @@ -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' @@ -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({ @@ -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({ @@ -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>
)

21
src/components/NsfwOverlay/index.tsx

@ -1,21 +0,0 @@ @@ -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)
}}
/>
)
)
}

9
src/components/PictureContent/index.tsx

@ -1,13 +1,13 @@ @@ -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 { @@ -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 @@ -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') {

9
src/components/PostEditor/PostContent.tsx

@ -37,6 +37,7 @@ export default function PostContent({ @@ -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({ @@ -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({ @@ -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

33
src/components/PostEditor/PostOptions.tsx

@ -7,11 +7,15 @@ import { useTranslation } from 'react-i18next' @@ -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({ @@ -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>
)

14
src/components/VideoPlayer/index.tsx

@ -2,17 +2,8 @@ import { cn, isInViewport } from '@/lib/utils' @@ -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({ @@ -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({ @@ -61,7 +52,6 @@ export default function VideoPlayer({
}}
muted
/>
{isNsfw && <NsfwOverlay className="rounded-lg" />}
</div>
)
}

10
src/lib/draft-event.ts

@ -68,6 +68,7 @@ export async function createShortTextNoteDraftEvent( @@ -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( @@ -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( @@ -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( @@ -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(['-'])
}

Loading…
Cancel
Save