diff --git a/src/components/feed/PictureCard.tsx b/src/components/feed/PictureCard.tsx new file mode 100644 index 0000000..fb50d0f --- /dev/null +++ b/src/components/feed/PictureCard.tsx @@ -0,0 +1,171 @@ +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { AspectRatio } from '@/components/ui/aspect-ratio'; +import { Badge } from '@/components/ui/badge'; +import { useState } from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface PictureCardProps { + event: NostrEvent; +} + +interface ImageMeta { + url: string; + alt?: string; + blurhash?: string; + dim?: string; + m?: string; +} + +function parseImetaTags(event: NostrEvent): ImageMeta[] { + const images: ImageMeta[] = []; + + for (const tag of event.tags) { + if (tag[0] === 'imeta') { + const imageMeta: ImageMeta = { url: '' }; + + for (let i = 1; i < tag.length; i++) { + const part = tag[i]; + if (part.startsWith('url ')) { + imageMeta.url = part.substring(4); + } else if (part.startsWith('alt ')) { + imageMeta.alt = part.substring(4); + } else if (part.startsWith('blurhash ')) { + imageMeta.blurhash = part.substring(9); + } else if (part.startsWith('dim ')) { + imageMeta.dim = part.substring(4); + } else if (part.startsWith('m ')) { + imageMeta.m = part.substring(2); + } + } + + if (imageMeta.url) { + images.push(imageMeta); + } + } + } + + return images; +} + +export function PictureCard({ event }: PictureCardProps) { + const author = useAuthor(event.pubkey); + const metadata: NostrMetadata | undefined = author.data?.metadata; + const [currentImageIndex, setCurrentImageIndex] = useState(0); + + const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); + const profileImage = metadata?.picture; + const title = event.tags.find(([name]) => name === 'title')?.[1] || ''; + const images = parseImetaTags(event); + const hashtags = event.tags.filter(([name]) => name === 't').map(([, tag]) => tag); + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % images.length); + }; + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length); + }; + + if (images.length === 0) { + return null; + } + + return ( + + + + + + {displayName[0]?.toUpperCase()} + + + {displayName} + + {new Date(event.created_at * 1000).toLocaleDateString()} + + + + + + + {/* Image carousel */} + + + + + + {/* Navigation buttons for multiple images */} + {images.length > 1 && ( + <> + + + + + + + + {/* Image indicators */} + + {images.map((_, index) => ( + + ))} + + > + )} + + + {/* Content section */} + + {title && ( + {title} + )} + + {event.content && ( + + {event.content} + + )} + + {hashtags.length > 0 && ( + + {hashtags.map((tag) => ( + + #{tag} + + ))} + + )} + + + + ); +} diff --git a/src/hooks/usePictureFeed.ts b/src/hooks/usePictureFeed.ts new file mode 100644 index 0000000..57065a9 --- /dev/null +++ b/src/hooks/usePictureFeed.ts @@ -0,0 +1,31 @@ +import { useNostr } from '@nostrify/react'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** + * Hook for fetching kind 20 (NIP-68) picture events with infinite scroll + */ +export function usePictureFeed() { + const { nostr } = useNostr(); + + return useInfiniteQuery({ + queryKey: ['picture-feed'], + queryFn: async ({ pageParam, signal }) => { + const filter = pageParam + ? { kinds: [20], limit: 20, until: pageParam as number } + : { kinds: [20], limit: 20 }; + + const events = await nostr.query([filter], { + signal: AbortSignal.any([signal, AbortSignal.timeout(1500)]) + }); + + return events as NostrEvent[]; + }, + getNextPageParam: (lastPage) => { + if (lastPage.length === 0) return undefined; + // Subtract 1 since 'until' is inclusive + return lastPage[lastPage.length - 1].created_at - 1; + }, + initialPageParam: undefined as number | undefined, + }); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 2b9f491..f525fc3 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,23 +1,110 @@ import { useSeoMeta } from '@unhead/react'; import { Layout } from '@/components/Layout'; +import { usePictureFeed } from '@/hooks/usePictureFeed'; +import { PictureCard } from '@/components/feed/PictureCard'; +import { useInView } from 'react-intersection-observer'; +import { useEffect, useMemo } from 'react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Card, CardContent } from '@/components/ui/card'; const Index = () => { useSeoMeta({ - title: 'Welcome to Your Blank App', - description: 'A modern Nostr client application built with React, TailwindCSS, and Nostrify.', + title: 'Picture Feed - LUMINA', + description: 'Discover amazing pictures shared on Nostr.', }); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = usePictureFeed(); + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Remove duplicate events by ID + const pictures = useMemo(() => { + const seen = new Set(); + return data?.pages.flat().filter(event => { + if (!event.id || seen.has(event.id)) return false; + seen.add(event.id); + return true; + }) || []; + }, [data?.pages]); + return ( - - - - Welcome to Your Blank App - - - Start building your amazing project here! + + + Picture Feed + + Discover amazing pictures shared on Nostr + + + {isLoading ? ( + // Loading skeletons + Array.from({ length: 3 }).map((_, i) => ( + + + + + + + + + + + + + + + + )) + ) : pictures.length === 0 ? ( + // Empty state + + + + + No pictures found yet. Try checking your relay connections or wait a moment for content to load. + + + + + ) : ( + // Picture feed + <> + {pictures.map((picture) => ( + + ))} + + {/* Infinite scroll trigger */} + {hasNextPage && ( + + {isFetchingNextPage && ( + + + + + + + + + + + + + + + + )} + + )} + > + )} + );
+ {event.content} +
- Start building your amazing project here! +
+ Discover amazing pictures shared on Nostr
+ No pictures found yet. Try checking your relay connections or wait a moment for content to load. +