From 1a652c1dbed57749ce6a1688562fde4bf84ce66d Mon Sep 17 00:00:00 2001 From: highperfocused Date: Sun, 5 Oct 2025 15:34:14 +0200 Subject: [PATCH] Add NIP-19 routing and implement Profile, Note, and Event pages --- src/AppRouter.tsx | 6 +- src/hooks/useAuthorBlogPosts.ts | 62 +++++++ src/pages/BlogPostPage.tsx | 10 +- src/pages/EventPage.tsx | 213 ++++++++++++++++++++++++ src/pages/NIP19Page.tsx | 33 ++-- src/pages/NotePage.tsx | 178 ++++++++++++++++++++ src/pages/ProfilePage.tsx | 277 ++++++++++++++++++++++++++++++++ 7 files changed, 762 insertions(+), 17 deletions(-) create mode 100644 src/hooks/useAuthorBlogPosts.ts create mode 100644 src/pages/EventPage.tsx create mode 100644 src/pages/NotePage.tsx create mode 100644 src/pages/ProfilePage.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 2604a73..236580b 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -3,9 +3,9 @@ import { ScrollToTop } from "./components/ScrollToTop"; import { BlogLayout } from "./components/BlogLayout"; import BlogHomePage from "./pages/BlogHomePage"; -import BlogPostPage from "./pages/BlogPostPage"; import CreatePostPage from "./pages/CreatePostPage"; import EditPostPage from "./pages/EditPostPage"; +import { NIP19Page } from "./pages/NIP19Page"; import NotFound from "./pages/NotFound"; export function AppRouter() { @@ -17,8 +17,8 @@ export function AppRouter() { } /> } /> } /> - {/* NIP-19 route for naddr1 blog posts */} - } /> + {/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */} + } /> {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} } /> diff --git a/src/hooks/useAuthorBlogPosts.ts b/src/hooks/useAuthorBlogPosts.ts new file mode 100644 index 0000000..74dd558 --- /dev/null +++ b/src/hooks/useAuthorBlogPosts.ts @@ -0,0 +1,62 @@ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +interface BlogPost extends NostrEvent { + kind: 30023; +} + +/** + * Validates that a Nostr event is a valid NIP-23 blog post + */ +function validateBlogPost(event: NostrEvent): event is BlogPost { + // Must be kind 30023 + if (event.kind !== 30023) return false; + + // Must have required tags + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const title = event.tags.find(([name]) => name === 'title')?.[1]; + + // d and title are required for addressable events + if (!d || !title) return false; + + return true; +} + +/** + * Hook to fetch blog posts from a specific author + */ +export function useAuthorBlogPosts(pubkey: string) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['author-blog-posts', pubkey], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + + const events = await nostr.query( + [{ + kinds: [30023], + authors: [pubkey], + limit: 50, + }], + { signal } + ); + + // Filter and validate events + const validPosts = events.filter(validateBlogPost); + + // Sort by published_at (newest first), fallback to created_at + return validPosts.sort((a, b) => { + const aPublished = a.tags.find(([name]) => name === 'published_at')?.[1]; + const bPublished = b.tags.find(([name]) => name === 'published_at')?.[1]; + + const aTime = aPublished ? parseInt(aPublished) : a.created_at; + const bTime = bPublished ? parseInt(bPublished) : b.created_at; + + return bTime - aTime; + }); + }, + enabled: !!pubkey, + }); +} diff --git a/src/pages/BlogPostPage.tsx b/src/pages/BlogPostPage.tsx index aa43ba5..ec2bf5a 100644 --- a/src/pages/BlogPostPage.tsx +++ b/src/pages/BlogPostPage.tsx @@ -17,7 +17,7 @@ import { genUserName } from '@/lib/genUserName'; import NotFound from '@/pages/NotFound'; export default function BlogPostPage() { - const { naddr } = useParams<{ naddr: string }>(); + const { nip19: naddr } = useParams<{ nip19: string }>(); const navigate = useNavigate(); const { user } = useCurrentUser(); @@ -25,6 +25,7 @@ export default function BlogPostPage() { let pubkey = ''; let identifier = ''; let kind = 0; + let isValidNaddr = false; try { if (naddr?.startsWith('naddr1')) { @@ -33,6 +34,7 @@ export default function BlogPostPage() { pubkey = decoded.data.pubkey; identifier = decoded.data.identifier; kind = decoded.data.kind; + isValidNaddr = true; } } } catch (error) { @@ -51,7 +53,7 @@ export default function BlogPostPage() { const isPostAuthor = user?.pubkey === post?.pubkey; const hasReacted = reactions?.likes.some(like => like.pubkey === user?.pubkey); - if (!naddr || kind !== 30023) { + if (!isValidNaddr || !naddr || kind !== 30023) { return ; } @@ -120,7 +122,7 @@ export default function BlogPostPage() { {/* Author info and metadata */}
-
+ {displayName[0]?.toUpperCase()} @@ -138,7 +140,7 @@ export default function BlogPostPage() {
- + {isPostAuthor && ( + + {/* Event card */} + + +
+ + + Kind {event.kind} + + +
+ + + + + {displayName[0]?.toUpperCase()} + +
+
{displayName}
+
+ {event.pubkey.slice(0, 8)}...{event.pubkey.slice(-8)} +
+
+ +
+ + + {/* Event content */} + {event.content && ( +
+

Content

+
+
+                    {event.content}
+                  
+
+
+ )} + + {/* Event tags */} + {event.tags.length > 0 && ( +
+

Tags

+
+ {event.tags.map((tag, index) => ( +
+ {tag.map((value, i) => ( + + {value} + {i < tag.length - 1 && ,} + + ))} +
+ ))} +
+
+ )} + + {/* Event metadata */} +
+

Metadata

+
+
+ ID:{' '} + {event.id} +
+
+ Pubkey:{' '} + {event.pubkey} +
+
+ Signature:{' '} + {event.sig} +
+
+
+ + {/* Raw JSON */} +
+ + View Raw JSON + +
+
+                  {JSON.stringify(event, null, 2)}
+                
+
+
+
+
+ + + ); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 5aa4439..3204c55 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -1,5 +1,9 @@ import { nip19 } from 'nostr-tools'; import { useParams } from 'react-router-dom'; +import ProfilePage from './ProfilePage'; +import BlogPostPage from './BlogPostPage'; +import { NotePage } from './NotePage'; +import { EventPage } from './EventPage'; import NotFound from './NotFound'; export function NIP19Page() { @@ -16,25 +20,34 @@ export function NIP19Page() { return ; } - const { type } = decoded; + const { type, data } = decoded; switch (type) { case 'npub': case 'nprofile': - // AI agent should implement profile view here - return
Profile placeholder
; + // Render profile page - ProfilePage will handle validation + return ; case 'note': - // AI agent should implement note view here - return
Note placeholder
; + // Render kind:1 text note + return ; - case 'nevent': - // AI agent should implement event view here - return
Event placeholder
; + case 'nevent': { + // Render any event with optional relay hints and author + const eventData = data as { id: string; relays?: string[]; author?: string; kind?: number }; + return ( + + ); + } case 'naddr': - // AI agent should implement addressable event view here - return
Addressable event placeholder
; + // Render addressable event (blog post) - BlogPostPage will handle validation + return ; default: return ; diff --git a/src/pages/NotePage.tsx b/src/pages/NotePage.tsx new file mode 100644 index 0000000..995d4cb --- /dev/null +++ b/src/pages/NotePage.tsx @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query'; +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { useNostr } from '@nostrify/react'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useReactions, useReact } from '@/hooks/useReactions'; +import { NoteContent } from '@/components/NoteContent'; +import { ZapButton } from '@/components/ZapButton'; +import { CommentsSection } from '@/components/comments/CommentsSection'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { Heart, MessageCircle, ArrowLeft } from 'lucide-react'; +import { genUserName } from '@/lib/genUserName'; +import type { NostrEvent } from '@nostrify/nostrify'; +import NotFound from './NotFound'; + +interface NotePageProps { + eventId: string; +} + +export function NotePage({ eventId }: NotePageProps) { + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + + // Fetch the note event + const { data: note, isLoading } = useQuery({ + queryKey: ['note', eventId], + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(3000)]); + const events = await nostr.query( + [{ ids: [eventId], kinds: [1], limit: 1 }], + { signal } + ); + return events[0] as NostrEvent | undefined; + }, + }); + + const author = useAuthor(note?.pubkey || ''); + const { data: reactions } = useReactions(eventId, note?.pubkey || ''); + const { mutate: react } = useReact(); + + const metadata = author.data?.metadata; + const displayName = metadata?.display_name || metadata?.name || genUserName(note?.pubkey || ''); + const profileImage = metadata?.picture; + + const hasReacted = reactions?.likes.some(like => like.pubkey === user?.pubkey); + + const handleReact = () => { + if (!user || !note) return; + if (hasReacted) return; + react({ eventId: note.id, eventAuthor: note.pubkey }); + }; + + if (isLoading) { + return ( +
+
+ + + +
+ +
+ + +
+
+
+ + + + + +
+
+
+ ); + } + + if (!note) { + return ; + } + + const date = new Date(note.created_at * 1000); + + return ( +
+
+ {/* Back button */} + + + {/* Note card */} + + + + + + {displayName[0]?.toUpperCase()} + +
+
{displayName}
+ +
+ +
+ + + {/* Note content */} +
+ +
+ + + + {/* Interaction buttons */} +
+ + + + + {note && ( + + )} +
+
+
+ + {/* Comments section */} +
+ +
+
+
+ ); +} diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..112a767 --- /dev/null +++ b/src/pages/ProfilePage.tsx @@ -0,0 +1,277 @@ +import { useParams, Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Calendar, MapPin, Link2, Mail } from 'lucide-react'; +import { genUserName } from '@/lib/genUserName'; +import { RelaySelector } from '@/components/RelaySelector'; +import NotFound from '@/pages/NotFound'; + +export default function ProfilePage() { + const { nip19: npub } = useParams<{ nip19: string }>(); + + // Decode npub/nprofile to get pubkey + let pubkey = ''; + let isValidProfile = false; + + try { + if (npub?.startsWith('npub1')) { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + pubkey = decoded.data; + isValidProfile = true; + } + } else if (npub?.startsWith('nprofile1')) { + const decoded = nip19.decode(npub); + if (decoded.type === 'nprofile') { + pubkey = decoded.data.pubkey; + isValidProfile = true; + } + } + } catch (error) { + console.error('Failed to decode npub:', error); + } + + const author = useAuthor(pubkey); + const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey); + + const metadata = author.data?.metadata; + const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); + const userName = metadata?.name || genUserName(pubkey); + const profileImage = metadata?.picture; + const banner = metadata?.banner; + const about = metadata?.about; + const website = metadata?.website; + const nip05 = metadata?.nip05; + + // If not a valid profile identifier, show 404 + if (!isValidProfile || !pubkey) { + return ; + } + + // Loading state + if (author.isLoading) { + return ( +
+
+ {/* Banner skeleton */} + + + {/* Profile info skeleton */} + + +
+ +
+
+ + +
+ + +
+
+
+
+ + {/* Posts skeleton */} +
+ +
+ {[1, 2, 3].map((i) => ( + + + + + + + + ))} +
+
+
+
+ ); + } + + return ( +
+
+ {/* Banner */} +
+ {banner && ( + Profile banner + )} +
+ + {/* Profile Info */} + + +
+ {/* Avatar */} + + + + {displayName[0]?.toUpperCase()} + + + + {/* User Info */} +
+
+

{displayName}

+ {metadata?.name && metadata.name !== displayName && ( +

@{userName}

+ )} + {nip05 && ( +
+ + {nip05} +
+ )} +
+ + {about && ( +

+ {about} +

+ )} + + {website && ( + + )} +
+
+
+
+ + {/* Blog Posts Section */} +
+
+

Blog Posts

+ {posts && posts.length > 0 && ( + + {posts.length} {posts.length === 1 ? 'post' : 'posts'} + + )} +
+ + {postsLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + + + + + + + ))} +
+ ) : posts && posts.length > 0 ? ( +
+ {posts.map((post) => { + const title = post.tags.find(([name]) => name === 'title')?.[1] || 'Untitled'; + const summary = post.tags.find(([name]) => name === 'summary')?.[1]; + const image = post.tags.find(([name]) => name === 'image')?.[1]; + const publishedAt = post.tags.find(([name]) => name === 'published_at')?.[1]; + const tags = post.tags.filter(([name]) => name === 't').map(([, value]) => value); + const identifier = post.tags.find(([name]) => name === 'd')?.[1]; + + const naddr = nip19.naddrEncode({ + kind: post.kind, + pubkey: post.pubkey, + identifier: identifier || '', + }); + + const date = publishedAt + ? new Date(parseInt(publishedAt) * 1000) + : new Date(post.created_at * 1000); + + return ( + + + {image && ( +
+ {title} +
+ )} + +

+ {title} +

+ {summary && ( +

+ {summary} +

+ )} +
+ + +
+ {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tags.length > 3 && ( + + +{tags.length - 3} + + )} +
+ )} +
+
+ + ); + })} +
+ ) : ( + + +
+

+ No blog posts found from this author. Try another relay? +

+ +
+
+
+ )} +
+
+
+ ); +}