Add post detail page with replies and reply composer

- Create PostDetailPage with expanded post view showing author info,
  full content, images, stats (reposts, likes, zap amount), full date,
  action buttons, and inline reply composer
- Create useEvent hook for fetching single events by hex ID
- Create useReplies hook for fetching kind:1 replies to an event
- Update NIP19Page to render PostDetailPage for note1/nevent1 identifiers
- Make NoteCard clickable to navigate to post detail via nevent1 URL
- Fix NoteMoreMenu to use React Router navigate instead of window.location.href

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-16 21:24:01 -06:00
parent 83f20fe56a
commit 3fa066bd46
7 changed files with 508 additions and 6 deletions
View File
+4 -1
View File
@@ -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 && (
+3 -2
View File
@@ -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();
};
+22
View File
@@ -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<NostrEvent | null>({
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,
});
}
+25
View File
@@ -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<NostrEvent[]>({
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,
});
}
+3 -3
View File
@@ -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 <Navigate to={`/u/${identifier}`} replace />;
case 'note':
// Note view placeholder
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Note view coming soon</div>;
return <PostDetailPage eventId={decoded.data as string} />;
case 'nevent':
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Event view coming soon</div>;
return <PostDetailPage eventId={(decoded.data as { id: string }).id} />;
case 'naddr':
return <div className="min-h-screen flex items-center justify-center text-muted-foreground">Addressable event view coming soon</div>;
+451
View File
@@ -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 (
<MainLayout>
<PostDetailShell>
<PostDetailSkeleton />
</PostDetailShell>
</MainLayout>
);
}
if (isError || !event) {
return <NotFound />;
}
return (
<MainLayout>
<PostDetailShell>
<PostDetailContent event={event} />
</PostDetailShell>
</MainLayout>
);
}
function PostDetailShell({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
return (
<main className="flex-1 min-w-0 sidebar:max-w-[600px] sidebar:border-l lg:border-r border-border min-h-screen">
{/* Header */}
<div className="sticky top-10 sidebar:top-0 z-10 flex items-center gap-4 px-4 h-[53px] bg-background/80 backdrop-blur-md border-b border-border">
<button
onClick={() => navigate(-1)}
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-lg font-bold">Post Details</h1>
</div>
{children}
</main>
);
}
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 (
<div>
{/* Main post — expanded view */}
<article className="px-4 pt-4">
{/* Author row */}
<div className="flex items-center gap-3">
<Link to={`/${npub}`}>
<Avatar className="size-12">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex-1 min-w-0">
<Link to={`/${npub}`} className="font-bold text-[15px] hover:underline block truncate">
{displayName}
</Link>
{nip05 && (
<span className="text-sm text-muted-foreground truncate block">
@{nip05}
</span>
)}
</div>
{metadata?.bot && (
<span className="text-sm text-primary" title="Bot account">🤖</span>
)}
</div>
{/* Post content — larger text */}
<div className="mt-3">
<NoteContent event={event} className="text-base leading-relaxed" />
</div>
{/* Image attachments */}
{images.length > 0 && (
<div className={cn(
'mt-3 rounded-2xl overflow-hidden border border-border',
images.length > 1 && 'grid grid-cols-2 gap-0.5',
)}>
{images.slice(0, 4).map((url, i) => (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<img
src={url}
alt=""
className="w-full h-auto max-h-[500px] object-cover"
loading="lazy"
/>
</a>
))}
</div>
)}
{/* Stats row: reposts, reactions, date */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-3 text-sm text-muted-foreground">
{stats?.reposts ? (
<span>
<span className="font-semibold text-foreground">{stats.reposts}</span>{' '}
Repost{stats.reposts !== 1 ? 's' : ''}
</span>
) : null}
{stats?.reactions ? (
<span>
<span className="font-semibold text-foreground">{stats.reactions}</span>{' '}
Like{stats.reactions !== 1 ? 's' : ''}
</span>
) : null}
{stats?.zapAmount ? (
<span>
<span className="font-semibold text-foreground">{formatSats(stats.zapAmount)}</span> sats
</span>
) : null}
<span className="ml-auto shrink-0">{formatFullDate(event.created_at)}</span>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between py-2 mt-1 border-t border-b border-border -mx-4 px-4">
<div className="flex items-center justify-between w-full max-w-md">
{/* Reply */}
<button
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
>
<MessageCircle className="size-5" />
{stats?.replies ? <span className="text-xs">{stats.replies}</span> : null}
</button>
{/* Repost */}
<button
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-green-500 hover:bg-green-500/10 transition-colors"
title="Repost"
>
<Repeat2 className="size-5" />
{stats?.reposts ? <span className="text-xs">{stats.reposts}</span> : null}
</button>
{/* Like */}
<button
className={cn(
'flex items-center gap-1 p-2 rounded-full transition-colors',
liked
? 'text-pink-500'
: 'text-muted-foreground hover:text-pink-500 hover:bg-pink-500/10',
)}
title="Like"
onClick={() => setLiked(!liked)}
>
<Heart className={cn('size-5', liked && 'fill-pink-500')} />
{stats?.reactions ? <span className="text-xs">{stats.reactions}</span> : null}
</button>
{/* Zap */}
<button
className="flex items-center gap-1 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Zap"
>
<Zap className="size-5" />
{stats?.zapAmount ? <span className="text-xs">{formatSats(stats.zapAmount)}</span> : null}
</button>
{/* More */}
<button
className="p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="More"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-5" />
</button>
</div>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
</article>
{/* Reply composer */}
<ReplyComposer event={event} />
{/* Replies */}
<div>
{repliesLoading ? (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
) : replies && replies.length > 0 ? (
replies.map((reply) => (
<NoteCard key={reply.id} event={reply} />
))
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
No replies yet. Be the first to reply!
</div>
)}
</div>
</div>
);
}
/** 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<HTMLTextAreaElement>(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 (
<div className="flex gap-3 px-4 py-3 border-b border-border">
<Avatar className="size-10 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name?.[0] || '?').toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Post your reply"
className="w-full bg-transparent text-foreground placeholder:text-muted-foreground resize-none outline-none text-[15px] py-2 min-h-[44px]"
rows={1}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
handleSubmit();
}
}}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
}}
/>
<div className="flex justify-end">
<Button
onClick={handleSubmit}
disabled={!content.trim() || isPending}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPending ? (
<>
<Loader2 className="size-4 animate-spin mr-1.5" />
Replying...
</>
) : (
'Reply'
)}
</Button>
</div>
</div>
</div>
);
}
function PostDetailSkeleton() {
return (
<div className="px-4 pt-4">
{/* Author */}
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
</div>
{/* Content */}
<div className="mt-4 space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-4/5" />
<Skeleton className="h-5 w-3/5" />
</div>
{/* Stats */}
<div className="flex gap-4 mt-4">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-32 ml-auto" />
</div>
{/* Actions */}
<div className="flex gap-8 py-3 mt-2 border-t border-b border-border">
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-8" />
<Skeleton className="h-5 w-5" />
</div>
{/* Replies skeleton */}
<div className="mt-4 divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<ReplyCardSkeleton key={i} />
))}
</div>
</div>
);
}
function ReplyCardSkeleton() {
return (
<div className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-6" />
</div>
<Skeleton className="h-3 w-24" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
<div className="flex gap-12 mt-1">
<Skeleton className="h-4 w-6" />
<Skeleton className="h-4 w-6" />
<Skeleton className="h-4 w-6" />
<Skeleton className="h-4 w-6" />
</div>
</div>
</div>
</div>
);
}