diff --git a/&1 b/&1 new file mode 100644 index 00000000..e69de29b diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 1e3f2812..8999c2f7 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { MessageCircle, Repeat2, Heart, Zap, MoreHorizontal } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { NoteContent } from '@/components/NoteContent'; @@ -32,11 +32,13 @@ function extractImages(content: string): string[] { } export function NoteCard({ event, className }: NoteCardProps) { + const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const displayName = metadata?.name || genUserName(event.pubkey); const nip05 = metadata?.nip05; const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); + const neventId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey]); const images = useMemo(() => extractImages(event.content), [event.content]); const { data: stats } = useEventStats(event.id); const [liked, setLiked] = useState(false); @@ -52,6 +54,7 @@ export function NoteCard({ event, className }: NoteCardProps) { 'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer', className, )} + onClick={() => navigate(`/${neventId}`)} > {/* Reply context */} {isReply && replyTo && ( diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index 2beb4c8a..e20dd9a7 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -1,4 +1,5 @@ import { nip19 } from 'nostr-tools'; +import { useNavigate } from 'react-router-dom'; import { ArrowUpDown, Bookmark, @@ -55,6 +56,7 @@ function MenuItem({ icon, label, onClick, destructive }: MenuItemProps) { } export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { + const navigate = useNavigate(); const { isBookmarked, toggleBookmark } = useBookmarks(); const bookmarked = isBookmarked(event.id); const author = useAuthor(event.pubkey); @@ -83,8 +85,7 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) { }; const handleShowDetails = () => { - const url = `/${neventId}`; - window.location.href = url; + navigate(`/${neventId}`); close(); }; diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts new file mode 100644 index 00000000..ac668ffc --- /dev/null +++ b/src/hooks/useEvent.ts @@ -0,0 +1,22 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** Fetches a single Nostr event by its hex ID. */ +export function useEvent(eventId: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['event', eventId ?? ''], + queryFn: async ({ signal }) => { + if (!eventId) return null; + const events = await nostr.query( + [{ ids: [eventId], limit: 1 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + return events[0] ?? null; + }, + enabled: !!eventId, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/src/hooks/useReplies.ts b/src/hooks/useReplies.ts new file mode 100644 index 00000000..b6aa4b8d --- /dev/null +++ b/src/hooks/useReplies.ts @@ -0,0 +1,25 @@ +import { useNostr } from '@nostrify/react'; +import { useQuery } from '@tanstack/react-query'; +import type { NostrEvent } from '@nostrify/nostrify'; + +/** Fetches kind:1 reply events that reference the given event ID. */ +export function useReplies(eventId: string | undefined) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['replies', eventId ?? ''], + queryFn: async ({ signal }) => { + if (!eventId) return []; + + const events = await nostr.query( + [{ kinds: [1], '#e': [eventId], limit: 100 }], + { signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]) }, + ); + + // Sort oldest first for threaded conversation view + return events.sort((a, b) => a.created_at - b.created_at); + }, + enabled: !!eventId, + staleTime: 30 * 1000, + }); +} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 3ec54d98..39acee0d 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -1,6 +1,7 @@ import { nip19 } from 'nostr-tools'; import { useParams, Navigate } from 'react-router-dom'; import NotFound from './NotFound'; +import { PostDetailPage } from './PostDetailPage'; export function NIP19Page() { const { nip19: identifier } = useParams<{ nip19: string }>(); @@ -25,11 +26,10 @@ export function NIP19Page() { return ; case 'note': - // Note view placeholder - return
Note view coming soon
; + return ; case 'nevent': - return
Event view coming soon
; + return ; case 'naddr': return
Addressable event view coming soon
; diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx new file mode 100644 index 00000000..9586fb5a --- /dev/null +++ b/src/pages/PostDetailPage.tsx @@ -0,0 +1,451 @@ +import { useMemo, useState, useRef } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { ArrowLeft, MessageCircle, Repeat2, Heart, Zap, MoreHorizontal, Loader2 } from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import type { NostrEvent } from '@nostrify/nostrify'; +import { useSeoMeta } from '@unhead/react'; + +import { MainLayout } from '@/components/MainLayout'; +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NoteContent } from '@/components/NoteContent'; +import { NoteCard } from '@/components/NoteCard'; +import { NoteMoreMenu } from '@/components/NoteMoreMenu'; +import { useEvent } from '@/hooks/useEvent'; +import { useReplies } from '@/hooks/useReplies'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useEventStats } from '@/hooks/useTrending'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/hooks/useToast'; +import { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; +import NotFound from './NotFound'; + +interface PostDetailPageProps { + eventId: string; +} + +/** Formats a sats amount into a compact human-readable string. */ +function formatSats(sats: number): string { + if (sats >= 1_000_000) return `${(sats / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (sats >= 1_000) return `${(sats / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + return sats.toString(); +} + +/** Extracts image URLs from note content. */ +function extractImages(content: string): string[] { + const urlRegex = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?/gi; + return content.match(urlRegex) || []; +} + +/** Formats a timestamp into a full date string like "Feb 16, 2026, 2:53 PM". */ +function formatFullDate(timestamp: number): string { + const date = new Date(timestamp * 1000); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +export function PostDetailPage({ eventId }: PostDetailPageProps) { + const { data: event, isLoading, isError } = useEvent(eventId); + + useSeoMeta({ + title: event ? 'Post Details - Mew' : 'Loading... - Mew', + }); + + if (isLoading) { + return ( + + + + + + ); + } + + if (isError || !event) { + return ; + } + + return ( + + + + + + ); +} + +function PostDetailShell({ children }: { children: React.ReactNode }) { + const navigate = useNavigate(); + + return ( +
+ {/* Header */} +
+ +

Post Details

+
+ + {children} +
+ ); +} + +function PostDetailContent({ event }: { event: NostrEvent }) { + const author = useAuthor(event.pubkey); + const metadata = author.data?.metadata; + const displayName = metadata?.name || genUserName(event.pubkey); + const nip05 = metadata?.nip05; + const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); + const images = useMemo(() => extractImages(event.content), [event.content]); + const { data: stats } = useEventStats(event.id); + const { data: replies, isLoading: repliesLoading } = useReplies(event.id); + const [liked, setLiked] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); + + return ( +
+ {/* Main post — expanded view */} +
+ {/* Author row */} +
+ + + + + {displayName[0].toUpperCase()} + + + + +
+ + {displayName} + + {nip05 && ( + + @{nip05} + + )} +
+ + {metadata?.bot && ( + 🤖 + )} +
+ + {/* Post content — larger text */} +
+ +
+ + {/* Image attachments */} + {images.length > 0 && ( +
1 && 'grid grid-cols-2 gap-0.5', + )}> + {images.slice(0, 4).map((url, i) => ( + + + + ))} +
+ )} + + {/* Stats row: reposts, reactions, date */} +
+ {stats?.reposts ? ( + + {stats.reposts}{' '} + Repost{stats.reposts !== 1 ? 's' : ''} + + ) : null} + {stats?.reactions ? ( + + {stats.reactions}{' '} + Like{stats.reactions !== 1 ? 's' : ''} + + ) : null} + {stats?.zapAmount ? ( + + {formatSats(stats.zapAmount)} sats + + ) : null} + {formatFullDate(event.created_at)} +
+ + {/* Action buttons */} +
+
+ {/* Reply */} + + + {/* Repost */} + + + {/* Like */} + + + {/* Zap */} + + + {/* More */} + +
+
+ + +
+ + {/* Reply composer */} + + + {/* Replies */} +
+ {repliesLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : replies && replies.length > 0 ? ( + replies.map((reply) => ( + + )) + ) : ( +
+ No replies yet. Be the first to reply! +
+ )} +
+
+ ); +} + +/** Inline reply composer for the post detail page. */ +function ReplyComposer({ event }: { event: NostrEvent }) { + const { user, metadata } = useCurrentUser(); + const { mutateAsync: createEvent, isPending } = useNostrPublish(); + const queryClient = useQueryClient(); + const { toast } = useToast(); + const [content, setContent] = useState(''); + const textareaRef = useRef(null); + + if (!user) return null; + + const handleSubmit = async () => { + if (!content.trim() || isPending) return; + + try { + const tags: string[][] = [ + ['e', event.id, '', 'root'], + ['p', event.pubkey], + ]; + + // Extract hashtags + const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || []; + for (const t of hashtags) { + tags.push(['t', t.toLowerCase()]); + } + + await createEvent({ + kind: 1, + content: content.trim(), + tags, + created_at: Math.floor(Date.now() / 1000), + }); + + setContent(''); + queryClient.invalidateQueries({ queryKey: ['replies', event.id] }); + queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] }); + toast({ title: 'Reply posted!' }); + } catch { + toast({ title: 'Error', description: 'Failed to post reply.', variant: 'destructive' }); + } + }; + + return ( +
+ + + + {(metadata?.name?.[0] || '?').toUpperCase()} + + + +
+