From 4cb9e3acd5f496db3a744e149b2ab6ff75ff27f4 Mon Sep 17 00:00:00 2001 From: mroxso <24775431+mroxso@users.noreply.github.com> Date: Sat, 7 Jun 2025 18:59:36 +0200 Subject: [PATCH] Feature: nsfw toggle (#130) * feat: add sensitive content toggle in TrendingImageNew component and utility function for nsfw tag check * feat: add sensitive content toggle and nsfw content check in KIND20Card and QuickViewKind20NoteCard components * fix: improve styling and text for sensitive content button and message in QuickViewKind20NoteCard component * fix: remove unused EyeOff import in TrendingImageNew component * refactor: replace inline nsfw tag check with utility function in TrendingImageNew component --------- Co-authored-by: highperfocused --- components/KIND20Card.tsx | 109 ++++++++++++++++--------- components/QuickViewKind20NoteCard.tsx | 40 ++++++++- components/TrendingImageNew.tsx | 39 ++++++++- utils/utils.ts | 8 ++ 4 files changed, 152 insertions(+), 44 deletions(-) diff --git a/components/KIND20Card.tsx b/components/KIND20Card.tsx index d516f0e..30f13da 100644 --- a/components/KIND20Card.tsx +++ b/components/KIND20Card.tsx @@ -14,7 +14,9 @@ import ZapButton from "./ZapButton" import Image from "next/image" import CardOptionsDropdown from "./CardOptionsDropdown" import { renderTextWithLinkedTags } from "@/utils/textUtils" -import { getProxiedImageUrl } from "@/utils/utils" +import { getProxiedImageUrl, hasNsfwContent } from "@/utils/utils" +import { Button } from "@/components/ui/button" +import { Eye } from "lucide-react" // Function to extract all images from a kind 20 event's imeta tags const extractImagesFromEvent = (tags: string[][]): string[] => { @@ -54,8 +56,12 @@ const KIND20Card: React.FC = ({ const [currentImage, setCurrentImage] = useState(0); const [imageErrors, setImageErrors] = useState>({}); const [imagesWithoutProxy, setImagesWithoutProxy] = useState>({}); + const [showSensitiveContent, setShowSensitiveContent] = useState(false); const [api, setApi] = useState(null); + // Check if the event has nsfw content + const isNsfwContent = hasNsfwContent(tags); + // Extract all images from imeta tags const imetaImages = extractImagesFromEvent(tags); @@ -82,6 +88,13 @@ const KIND20Card: React.FC = ({ } } + // Toggle sensitive content visibility + const toggleSensitiveContent = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowSensitiveContent(true); + }; + // Update current image index when carousel slides useEffect(() => { if (!api) return; @@ -142,47 +155,67 @@ const KIND20Card: React.FC = ({
{validImages.length > 0 && ( - - - {validImages.map((imageUrl, index) => { - const shouldUseProxy = useImgProxy && !imagesWithoutProxy[imageUrl]; - const image = shouldUseProxy ? getProxiedImageUrl(imageUrl, 1200, 0) : imageUrl; - return ( - -
-
- {text} handleImageError(imageUrl)} - loading="lazy" - style={{ - maxHeight: "80vh", - margin: "auto" - }} - /> +
+ + + {validImages.map((imageUrl, index) => { + const shouldUseProxy = useImgProxy && !imagesWithoutProxy[imageUrl]; + const image = shouldUseProxy ? getProxiedImageUrl(imageUrl, 1200, 0) : imageUrl; + return ( + +
+
+ {text} handleImageError(imageUrl)} + loading="lazy" + style={{ + maxHeight: "80vh", + margin: "auto" + }} + /> +
+
+ ); + })} +
+ {validImages.length > 1 && !isNsfwContent && ( + <> + + +
+
+ {`${currentImage + 1} / ${validImages.length}`}
- - ); - })} - - {validImages.length > 1 && ( - <> - - -
-
- {`${currentImage + 1} / ${validImages.length}`}
-
- + + )} + + + {isNsfwContent && !showSensitiveContent && ( +
+ +

+ This image may contain sensitive content +

+
)} - +
)}
diff --git a/components/QuickViewKind20NoteCard.tsx b/components/QuickViewKind20NoteCard.tsx index edb4fb2..ce9eb6f 100644 --- a/components/QuickViewKind20NoteCard.tsx +++ b/components/QuickViewKind20NoteCard.tsx @@ -9,7 +9,9 @@ import { } from "@/components/ui/card" import Link from 'next/link'; import Image from 'next/image'; -import { extractDimensions, getProxiedImageUrl } from '@/utils/utils'; +import { Button } from '@/components/ui/button'; +import { Eye } from 'lucide-react'; +import { extractDimensions, getProxiedImageUrl, hasNsfwContent } from '@/utils/utils'; interface QuickViewKind20NoteCardProps { pubkey: string; @@ -27,6 +29,10 @@ const QuickViewKind20NoteCard: React.FC = ({ pubke }); const [imageError, setImageError] = useState(false); const [tryWithoutProxy, setTryWithoutProxy] = useState(false); + const [showSensitiveContent, setShowSensitiveContent] = useState(false); + + // Check if the event has nsfw content + const isNsfwContent = hasNsfwContent(tags); if (!image || !image.startsWith("http")) return null; if (imageError && tryWithoutProxy) return null; @@ -40,6 +46,13 @@ const QuickViewKind20NoteCard: React.FC = ({ pubke const { width, height } = extractDimensions(event); + // Toggle sensitive content visibility + const toggleSensitiveContent = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowSensitiveContent(true); + }; + const card = ( @@ -48,7 +61,7 @@ const QuickViewKind20NoteCard: React.FC = ({ pubke {text} { if (tryWithoutProxy) { @@ -59,6 +72,23 @@ const QuickViewKind20NoteCard: React.FC = ({ pubke }} style={{ objectPosition: 'center' }} /> + {isNsfwContent && !showSensitiveContent && ( +
+ +

+ Sensitive Content +

+
+ )}
@@ -68,7 +98,11 @@ const QuickViewKind20NoteCard: React.FC = ({ pubke return ( <> {linkToNote ? ( - + e.preventDefault() : undefined} + > {card} ) : ( diff --git a/components/TrendingImageNew.tsx b/components/TrendingImageNew.tsx index 1b6eae3..5e4f70e 100644 --- a/components/TrendingImageNew.tsx +++ b/components/TrendingImageNew.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useProfile } from "nostr-react"; import { nip19 } from "nostr-tools"; import { @@ -10,6 +10,9 @@ import { import Link from 'next/link'; import { Avatar } from './ui/avatar'; import { AvatarImage } from '@radix-ui/react-avatar'; +import { Button } from '@/components/ui/button'; +import { Eye } from 'lucide-react'; +import { hasNsfwContent } from '@/utils/utils'; interface TrendingImageNewProps { event: { @@ -26,6 +29,12 @@ const TrendingImageNew: React.FC = ({ event }) => { pubkey: event.pubkey, }); + // Check if the event has nsfw or sexy tags + const hasNsfwTag = hasNsfwContent(event.tags); + + // State to control image blur + const [showSensitiveContent, setShowSensitiveContent] = useState(false); + const npubShortened = (() => { let encoded = nip19.npubEncode(event.pubkey); let parts = encoded.split('npub'); @@ -43,6 +52,13 @@ const TrendingImageNew: React.FC = ({ event }) => { const hrefNote = `/note/${nip19.noteEncode(event.id)}`; const profileImageSrc = userData?.picture || "https://robohash.org/" + event.pubkey; + // Toggle sensitive content visibility + const toggleSensitiveContent = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setShowSensitiveContent(!showSensitiveContent); + }; + return ( @@ -62,14 +78,31 @@ const TrendingImageNew: React.FC = ({ event }) => {
{imageUrl && (
- + e.preventDefault() : undefined}> {text} + {hasNsfwTag && !showSensitiveContent && ( +
+ +

+ This image may contain sensitive content +

+
+ )}
)} diff --git a/utils/utils.ts b/utils/utils.ts index 1caabed..c406b09 100644 --- a/utils/utils.ts +++ b/utils/utils.ts @@ -2,6 +2,14 @@ import { Event as NostrEvent, finalizeEvent} from "nostr-tools"; import { hexToBytes } from "@noble/hashes/utils" import { signEventWithBunker } from "./bunkerUtils"; +// Check if the event has nsfw or sexy tags +export function hasNsfwContent(tags: string[][]): boolean { + return tags.some(tag => + (tag[0] === 't' && (tag[1]?.toLowerCase() === 'nsfw' || tag[1]?.toLowerCase() === 'sexy')) || + (tag[0] === 'content-warning') + ); +} + export function getImageUrl(tags: string[][]): string { const imetaTag = tags.find(tag => tag[0] === 'imeta'); if (imetaTag) {