Files
lumina/components/KIND20Card.tsx
mroxso 4cb9e3acd5 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>
2025-06-07 18:59:36 +02:00

245 lines
9.4 KiB
TypeScript

import type React from "react"
import { useProfile } from "nostr-react"
import { nip19 } from "nostr-tools"
import { useState, useEffect } from "react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import ReactionButton from "@/components/ReactionButton"
import { Avatar, AvatarImage } from "@/components/ui/avatar"
import ViewNoteButton from "./ViewNoteButton"
import Link from "next/link"
import type { Event as NostrEvent } from "nostr-tools"
import ZapButton from "./ZapButton"
import Image from "next/image"
import CardOptionsDropdown from "./CardOptionsDropdown"
import { renderTextWithLinkedTags } from "@/utils/textUtils"
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[] => {
return tags
.filter(tag => tag[0] === 'imeta')
.map(tag => {
const urlItem = tag.find(item => item.startsWith('url '))
return urlItem ? urlItem.split(' ')[1] : null
})
.filter(Boolean) as string[]
}
const useImgProxy = process.env.NEXT_PUBLIC_ENABLE_IMGPROXY === "true"
interface KIND20CardProps {
pubkey: string
text: string
image: string // keeping for backward compatibility
eventId: string
tags: string[][]
event: NostrEvent
showViewNoteCardButton: boolean
}
const KIND20Card: React.FC<KIND20CardProps> = ({
pubkey,
text,
image,
eventId,
tags,
event,
showViewNoteCardButton,
}) => {
const { data: userData } = useProfile({
pubkey,
})
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);
// Use provided image as fallback if no imeta images are found
const allImages = imetaImages.length > 0 ? imetaImages : (image && image.startsWith("http") ? [image] : []);
// Filter out images with errors
const validImages = allImages.filter(img => !imageErrors[img]);
// Handle image error by first trying without proxy, then marking as error if that fails too
const handleImageError = (errorImage: string) => {
if (imagesWithoutProxy[errorImage]) {
// Already tried without proxy, mark as error
setImageErrors(prev => ({
...prev,
[errorImage]: true
}));
} else {
// Try without proxy
setImagesWithoutProxy(prev => ({
...prev,
[errorImage]: true
}));
}
}
// 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;
const onSelect = () => {
setCurrentImage(api.selectedScrollSnap());
};
api.on('select', onSelect);
// Initial selection
onSelect();
return () => {
api.off('select', onSelect);
};
}, [api]);
// If no valid images are available, don't render the card
if (validImages.length === 0) return null;
const title =
userData?.username || userData?.display_name || userData?.name || userData?.npub || nip19.npubEncode(pubkey)
text = text.replaceAll("\n", " ")
const createdAt = new Date(event.created_at * 1000)
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey
const uploadedVia = tags.find((tag) => tag[0] === "client")?.[1]
return (
<>
<div key={event.id}>
<Card className="my-4">
<CardHeader className="flex flex-row items-center space-y-0">
<CardTitle className="flex-1">
<Link href={hrefProfile} style={{ textDecoration: "none" }}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div style={{ display: "flex", alignItems: "center" }}>
<Avatar>
<AvatarImage src={profileImageSrc} />
</Avatar>
<span className="break-all" style={{ marginLeft: "10px" }}>
{title}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{title}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</Link>
</CardTitle>
<CardOptionsDropdown event={event} />
</CardHeader>
<CardContent className="p-0">
<div className="w-full">
{validImages.length > 0 && (
<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>
</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>
)}
</div>
)}
</div>
<div className="p-4">
<div className="break-word overflow-hidden">{renderTextWithLinkedTags(text, tags)}</div>
<hr className="my-4" />
<div className="space-x-4 flex justify-between items-start">
<div className="flex space-x-4">
<ReactionButton event={event} />
<ZapButton event={event} />
{showViewNoteCardButton && <ViewNoteButton event={event} />}
</div>
</div>
</div>
</CardContent>
<CardFooter>
<div className="grid grid-cols-1">
<small className="text-secondary">{createdAt.toLocaleString()}</small>
{uploadedVia && <small className="text-secondary">Uploaded via {uploadedVia}</small>}
</div>
</CardFooter>
</Card>
</div>
</>
)
}
export default KIND20Card