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 <highperfocused@pm.me>
This commit is contained in:
mroxso
2025-06-07 18:59:36 +02:00
committed by GitHub
parent 12b238c91f
commit 4cb9e3acd5
4 changed files with 152 additions and 44 deletions

View File

@@ -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<KIND20CardProps> = ({
const [currentImage, setCurrentImage] = useState(0);
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
const [imagesWithoutProxy, setImagesWithoutProxy] = useState<Record<string, boolean>>({});
const [showSensitiveContent, setShowSensitiveContent] = useState(false);
const [api, setApi] = useState<any>(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<KIND20CardProps> = ({
}
}
// 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<KIND20CardProps> = ({
<CardContent className="p-0">
<div className="w-full">
{validImages.length > 0 && (
<Carousel
className="w-full"
setApi={setApi}
>
<CarouselContent>
{validImages.map((imageUrl, index) => {
const shouldUseProxy = useImgProxy && !imagesWithoutProxy[imageUrl];
const image = shouldUseProxy ? getProxiedImageUrl(imageUrl, 1200, 0) : imageUrl;
return (
<CarouselItem key={`${imageUrl}-${index}`}>
<div className="w-full flex justify-center">
<div className="relative w-full h-auto min-h-[300px] max-h-[80vh] flex justify-center">
<img
src={image}
alt={text}
className="rounded-lg w-full h-auto object-contain"
onError={() => handleImageError(imageUrl)}
loading="lazy"
style={{
maxHeight: "80vh",
margin: "auto"
}}
/>
<div className="relative">
<Carousel
className="w-full"
setApi={setApi}
>
<CarouselContent>
{validImages.map((imageUrl, index) => {
const shouldUseProxy = useImgProxy && !imagesWithoutProxy[imageUrl];
const image = shouldUseProxy ? getProxiedImageUrl(imageUrl, 1200, 0) : imageUrl;
return (
<CarouselItem key={`${imageUrl}-${index}`}>
<div className="w-full flex justify-center">
<div className="relative w-full h-auto min-h-[300px] max-h-[80vh] flex justify-center">
<img
src={image}
alt={text}
className={`rounded-lg w-full h-auto object-contain ${isNsfwContent && !showSensitiveContent ? 'blur-xl' : ''}`}
onError={() => handleImageError(imageUrl)}
loading="lazy"
style={{
maxHeight: "80vh",
margin: "auto"
}}
/>
</div>
</div>
</CarouselItem>
);
})}
</CarouselContent>
{validImages.length > 1 && !isNsfwContent && (
<>
<CarouselPrevious className="left-2" />
<CarouselNext className="right-2" />
<div className="absolute bottom-4 left-0 right-0 flex justify-center">
<div className="bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm">
{`${currentImage + 1} / ${validImages.length}`}
</div>
</CarouselItem>
);
})}
</CarouselContent>
{validImages.length > 1 && (
<>
<CarouselPrevious className="left-2" />
<CarouselNext className="right-2" />
<div className="absolute bottom-4 left-0 right-0 flex justify-center">
<div className="bg-black bg-opacity-50 text-white px-3 py-1 rounded-full text-sm">
{`${currentImage + 1} / ${validImages.length}`}
</div>
</div>
</>
</>
)}
</Carousel>
{isNsfwContent && !showSensitiveContent && (
<div
className="absolute inset-0 flex flex-col items-center justify-center z-10"
onClick={toggleSensitiveContent}
>
<Button
variant="secondary"
className="bg-black bg-opacity-50 hover:bg-opacity-70 text-white px-4 py-2 rounded-md"
onClick={toggleSensitiveContent}
>
<Eye className="h-4 w-4 mr-2" /> Show Sensitive Content
</Button>
<p className="mt-2 text-white text-sm bg-black bg-opacity-50 p-2 rounded">
This image may contain sensitive content
</p>
</div>
)}
</Carousel>
</div>
)}
</div>
<div className="p-4">

View File

@@ -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<QuickViewKind20NoteCardProps> = ({ 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<QuickViewKind20NoteCardProps> = ({ pubke
const { width, height } = extractDimensions(event);
// Toggle sensitive content visibility
const toggleSensitiveContent = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setShowSensitiveContent(true);
};
const card = (
<Card className="aspect-square overflow-hidden">
<SmallCardContent className="h-full p-0">
@@ -48,7 +61,7 @@ const QuickViewKind20NoteCard: React.FC<QuickViewKind20NoteCardProps> = ({ pubke
<img
src={image || "/placeholder.svg"}
alt={text}
className='w-full h-full rounded lg:rounded-lg object-cover'
className={`w-full h-full rounded lg:rounded-lg object-cover ${isNsfwContent && !showSensitiveContent ? 'blur-xl' : ''}`}
loading="lazy"
onError={() => {
if (tryWithoutProxy) {
@@ -59,6 +72,23 @@ const QuickViewKind20NoteCard: React.FC<QuickViewKind20NoteCardProps> = ({ pubke
}}
style={{ objectPosition: 'center' }}
/>
{isNsfwContent && !showSensitiveContent && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
onClick={toggleSensitiveContent}
>
<Button
variant="secondary"
className="bg-black bg-opacity-50 hover:bg-opacity-70 text-white px-2 py-1 sm:px-4 sm:py-2 rounded-md text-xs sm:text-sm"
onClick={toggleSensitiveContent}
>
<Eye className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" /> Show
</Button>
<p className="mt-1 sm:mt-2 text-white text-xs sm:text-sm bg-black bg-opacity-50 p-1 sm:p-2 rounded max-w-[80%] text-center">
Sensitive Content
</p>
</div>
)}
</div>
</div>
</SmallCardContent>
@@ -68,7 +98,11 @@ const QuickViewKind20NoteCard: React.FC<QuickViewKind20NoteCardProps> = ({ pubke
return (
<>
{linkToNote ? (
<Link href={`/note/${encodedNoteId}`} className="block w-full aspect-square">
<Link
href={`/note/${encodedNoteId}`}
className="block w-full aspect-square"
onClick={isNsfwContent && !showSensitiveContent ? (e) => e.preventDefault() : undefined}
>
{card}
</Link>
) : (

View File

@@ -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<TrendingImageNewProps> = ({ 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<TrendingImageNewProps> = ({ 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 (
<Card>
<CardHeader>
@@ -62,14 +78,31 @@ const TrendingImageNew: React.FC<TrendingImageNewProps> = ({ event }) => {
<div className='d-flex justify-content-center align-items-center'>
{imageUrl && (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<Link href={hrefNote}>
<Link href={hrefNote} onClick={hasNsfwTag && !showSensitiveContent ? (e) => e.preventDefault() : undefined}>
<img
src={imageUrl}
className='rounded lg:rounded-lg w-full h-full object-cover'
className={`rounded lg:rounded-lg w-full h-full object-cover ${hasNsfwTag && !showSensitiveContent ? 'blur-xl' : ''}`}
style={{ margin: 'auto' }}
alt={text}
loading="lazy"
/>
{hasNsfwTag && !showSensitiveContent && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
onClick={toggleSensitiveContent}
>
<Button
variant="secondary"
className="bg-black bg-opacity-50 hover:bg-opacity-70 text-white px-4 py-2 rounded-md"
onClick={toggleSensitiveContent}
>
<Eye className="h-4 w-4 mr-2" /> Show Sensitive Content
</Button>
<p className="mt-2 text-white text-sm bg-black bg-opacity-50 p-2 rounded">
This image may contain sensitive content
</p>
</div>
)}
</Link>
</div>
)}

View File

@@ -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) {