mirror of
https://github.com/lumina-rocks/lumina.git
synced 2026-06-04 09:41:32 +02:00
Feature: Display multiple Images in Kind20 Cards (#113)
Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
92
.github/prompts/nostr-nip68.prompt.md
vendored
Normal file
92
.github/prompts/nostr-nip68.prompt.md
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
NIP-68
|
||||
======
|
||||
|
||||
Picture-first feeds
|
||||
-------------------
|
||||
|
||||
`draft` `optional`
|
||||
|
||||
This NIP defines event kind `20` for picture-first clients. Images must be self-contained. They are hosted externally and referenced using `imeta` tags.
|
||||
|
||||
The idea is for this type of event to cater to Nostr clients resembling platforms like Instagram, Flickr, Snapchat, or 9GAG, where the picture itself takes center stage in the user experience.
|
||||
|
||||
## Picture Events
|
||||
|
||||
Picture events contain a `title` tag and description in the `.content`.
|
||||
|
||||
They may contain multiple images to be displayed as a single post.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"id": <32-bytes lowercase hex-encoded SHA-256 of the the serialized event data>,
|
||||
"pubkey": <32-bytes lowercase hex-encoded public key of the event creator>,
|
||||
"created_at": <Unix timestamp in seconds>,
|
||||
"kind": 20,
|
||||
"content": "<description of post>",
|
||||
"tags": [
|
||||
["title", "<short title of post>"],
|
||||
|
||||
// Picture Data
|
||||
[
|
||||
"imeta",
|
||||
"url https://nostr.build/i/my-image.jpg",
|
||||
"m image/jpeg",
|
||||
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$",
|
||||
"dim 3024x4032",
|
||||
"alt A scenic photo overlooking the coast of Costa Rica",
|
||||
"x <sha256 hash as specified in NIP 94>",
|
||||
"fallback https://nostrcheck.me/alt1.jpg",
|
||||
"fallback https://void.cat/alt1.jpg"
|
||||
],
|
||||
[
|
||||
"imeta",
|
||||
"url https://nostr.build/i/my-image2.jpg",
|
||||
"m image/jpeg",
|
||||
"blurhash eVF$^OI:${M{o#*0-nNFxakD-?xVM}WEWB%iNKxvR-oetmo#R-aen$",
|
||||
"dim 3024x4032",
|
||||
"alt Another scenic photo overlooking the coast of Costa Rica",
|
||||
"x <sha256 hash as specified in NIP 94>",
|
||||
"fallback https://nostrcheck.me/alt2.jpg",
|
||||
"fallback https://void.cat/alt2.jpg",
|
||||
|
||||
"annotate-user <32-bytes hex of a pubkey>:<posX>:<posY>" // Tag users in specific locations in the picture
|
||||
],
|
||||
|
||||
["content-warning", "<reason>"], // if NSFW
|
||||
|
||||
// Tagged users
|
||||
["p", "<32-bytes hex of a pubkey>", "<optional recommended relay URL>"],
|
||||
["p", "<32-bytes hex of a pubkey>", "<optional recommended relay URL>"],
|
||||
|
||||
// Specify the media type for filters to allow clients to filter by supported kinds
|
||||
["m", "image/jpeg"],
|
||||
|
||||
// Hashes of each image to make them queryable
|
||||
["x", "<sha256>"]
|
||||
|
||||
// Hashtags
|
||||
["t", "<tag>"],
|
||||
["t", "<tag>"],
|
||||
|
||||
// location
|
||||
["location", "<location>"], // city name, state, country
|
||||
["g", "<geohash>"],
|
||||
|
||||
// When text is written in the image, add the tag to represent the language
|
||||
["L", "ISO-639-1"],
|
||||
["l", "en", "ISO-639-1"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `imeta` tag `annotate-user` places a user link in the specific position in the image.
|
||||
|
||||
Only the following media types are accepted:
|
||||
- `image/apng`: Animated Portable Network Graphics (APNG)
|
||||
- `image/avif`: AV1 Image File Format (AVIF)
|
||||
- `image/gif`: Graphics Interchange Format (GIF)
|
||||
- `image/jpeg`: Joint Photographic Expert Group image (JPEG)
|
||||
- `image/png`: Portable Network Graphics (PNG)
|
||||
- `image/webp`: Web Picture format (WEBP)
|
||||
|
||||
Picture events might be used with [NIP-71](71.md)'s kind `22` to display short vertical videos in the same feed.
|
||||
@@ -1,7 +1,7 @@
|
||||
import type React from "react"
|
||||
import { useProfile } from "nostr-react"
|
||||
import { nip19 } from "nostr-tools"
|
||||
import { useState } from "react"
|
||||
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"
|
||||
@@ -16,10 +16,21 @@ import ZapButton from "./ZapButton"
|
||||
import Image from "next/image"
|
||||
import { renderTextWithLinkedTags } from "@/utils/textUtils"
|
||||
|
||||
// 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[]
|
||||
}
|
||||
|
||||
interface KIND20CardProps {
|
||||
pubkey: string
|
||||
text: string
|
||||
image: string
|
||||
image: string // keeping for backward compatibility
|
||||
eventId: string
|
||||
tags: string[][]
|
||||
event: NostrEvent
|
||||
@@ -38,9 +49,21 @@ const KIND20Card: React.FC<KIND20CardProps> = ({
|
||||
const { data: userData } = useProfile({
|
||||
pubkey,
|
||||
})
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
if (!image || !image.startsWith("http") || imageError) return null;
|
||||
const [currentImage, setCurrentImage] = useState(0);
|
||||
const [imageErrors, setImageErrors] = useState<Record<string, boolean>>({});
|
||||
const [api, setApi] = useState<any>(null);
|
||||
|
||||
// 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]);
|
||||
|
||||
// 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)
|
||||
@@ -49,6 +72,32 @@ const KIND20Card: React.FC<KIND20CardProps> = ({
|
||||
const hrefProfile = `/profile/${nip19.npubEncode(pubkey)}`
|
||||
const profileImageSrc = userData?.picture || "https://robohash.org/" + pubkey
|
||||
const uploadedVia = tags.find((tag) => tag[0] === "client")?.[1]
|
||||
|
||||
// Handle image error by marking that specific image as having an error
|
||||
const handleImageError = (errorImage: string) => {
|
||||
setImageErrors(prev => ({
|
||||
...prev,
|
||||
[errorImage]: 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]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -79,21 +128,45 @@ const KIND20Card: React.FC<KIND20CardProps> = ({
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="w-full">
|
||||
<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={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
style={{
|
||||
maxHeight: "80vh",
|
||||
margin: "auto"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{validImages.length > 0 && (
|
||||
<Carousel
|
||||
className="w-full"
|
||||
setApi={setApi}
|
||||
>
|
||||
<CarouselContent>
|
||||
{validImages.map((imageUrl, index) => (
|
||||
<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={imageUrl}
|
||||
alt={text}
|
||||
className="rounded-lg w-full h-auto object-contain"
|
||||
onError={() => handleImageError(imageUrl)}
|
||||
loading="lazy"
|
||||
style={{
|
||||
maxHeight: "80vh",
|
||||
margin: "auto"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="break-word overflow-hidden">{renderTextWithLinkedTags(text, tags)}</div>
|
||||
|
||||
Reference in New Issue
Block a user