From a1608d19a6bf87f65c95346f336d316d9df17b7d Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sat, 27 Dec 2025 23:18:51 +0100 Subject: [PATCH] Add Explore page and implement useExploreEvents hook for fetching notes and profiles --- src/AppRouter.tsx | 2 + src/hooks/useExploreEvents.ts | 39 +++++ src/pages/Explore.tsx | 270 ++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/hooks/useExploreEvents.ts create mode 100644 src/pages/Explore.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 64bf2b8..eb20f19 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -2,6 +2,7 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; import Index from "./pages/Index"; +import { Explore } from "./pages/Explore"; import { NIP19Page } from "./pages/NIP19Page"; import NotFound from "./pages/NotFound"; @@ -11,6 +12,7 @@ export function AppRouter() { } /> + } /> {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} diff --git a/src/hooks/useExploreEvents.ts b/src/hooks/useExploreEvents.ts new file mode 100644 index 0000000..db42ed5 --- /dev/null +++ b/src/hooks/useExploreEvents.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; + +export function useExploreEvents() { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['explore-events'], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + // Fetch both kind 1 (text notes) and kind 0 (profiles) in a single query + const events = await nostr.query( + [ + { + kinds: [1, 0], + limit: 100, + } + ], + { signal } + ); + + // Separate events by kind + const textNotes = events.filter((e) => e.kind === 1); + const profiles = events.filter((e) => e.kind === 0); + + // Sort by created_at (newest first) + textNotes.sort((a, b) => b.created_at - a.created_at); + profiles.sort((a, b) => b.created_at - a.created_at); + + return { + textNotes, + profiles, + allEvents: events, + }; + }, + refetchInterval: 30000, // Refetch every 30 seconds for fresh content + }); +} diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx new file mode 100644 index 0000000..0250d76 --- /dev/null +++ b/src/pages/Explore.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; +import { useExploreEvents } from '@/hooks/useExploreEvents'; +import { useAuthor } from '@/hooks/useAuthor'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NoteContent } from '@/components/NoteContent'; +import { genUserName } from '@/lib/genUserName'; + +function TextNoteCard({ event }: { event: NostrEvent }) { + const author = useAuthor(event.pubkey); + const metadata: NostrMetadata | undefined = author.data?.metadata; + + const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); + const username = metadata?.name || genUserName(event.pubkey); + const profileImage = metadata?.picture; + + const timestamp = new Date(event.created_at * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + + return ( + + +
+ + + {displayName[0]?.toUpperCase()} + +
+
+

{displayName}

+ {metadata?.nip05 && ( + + ✓ + + )} +
+

@{username}

+
+ +
+
+ +
+ +
+
+
+ ); +} + +function ProfileCard({ event }: { event: NostrEvent }) { + let metadata: NostrMetadata | undefined; + + try { + metadata = JSON.parse(event.content) as NostrMetadata; + } catch { + return null; + } + + const displayName = metadata?.display_name || metadata?.name || genUserName(event.pubkey); + const username = metadata?.name || genUserName(event.pubkey); + const about = metadata?.about; + const profileImage = metadata?.picture; + const banner = metadata?.banner; + const nip05 = metadata?.nip05; + const website = metadata?.website; + + return ( + + {banner && ( +
+ +
+ )} + +
+ + + {displayName[0]?.toUpperCase()} + +
+
+

{displayName}

+ {nip05 && ( + + ✓ + + )} +
+

@{username}

+ {nip05 && ( +

{nip05}

+ )} +
+
+
+ {(about || website) && ( + + {about && ( +

{about}

+ )} + {website && ( + + 🔗 {website.replace(/^https?:\/\//, '')} + + )} +
+ )} +
+ ); +} + +function LoadingSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( + + +
+ +
+ + +
+
+
+ +
+ + + +
+
+
+ ))} +
+ ); +} + +export function Explore() { + const [activeTab, setActiveTab] = useState('notes'); + const { data, isLoading, isError } = useExploreEvents(); + + return ( +
+
+ {/* Header */} +
+

+ Explore +

+

+ Discover the latest text notes and profiles from the Nostr +

+
+ + {/* Tabs */} + + + + Notes ({data?.textNotes?.length || 0}) + + + Profiles ({data?.profiles?.length || 0}) + + + + {/* Text Notes Tab */} + + {isLoading && } + + {isError && ( + + +

+ Unable to load notes. Please check your relay connections. +

+
+
+ )} + + {!isLoading && !isError && data?.textNotes.length === 0 && ( + + +

+ No text notes found. Try refreshing or check your relay connections. +

+
+
+ )} + + {!isLoading && !isError && data?.textNotes.map((event) => ( + + ))} +
+ + {/* Profiles Tab */} + +
+ {isLoading && ( + <> + {[1, 2, 3, 4].map((i) => ( + + +
+ +
+ + +
+
+
+
+ ))} + + )} + + {isError && ( +
+ + +

+ Unable to load profiles. Please check your relay connections. +

+
+
+
+ )} + + {!isLoading && !isError && data?.profiles.length === 0 && ( +
+ + +

+ No profiles found. Try refreshing or check your relay connections. +

+
+
+
+ )} + + {!isLoading && !isError && data?.profiles.map((event) => ( + + ))} +
+
+
+
+
+ ); +}