Add reply compose modal with embedded original post preview

- Created ReplyComposeModal component that shows the original post embedded
  in a compact card above the compose area, matching the reference design
- Updated ComposeBox to accept replyTo, forceExpanded, and hideAvatar props
- ComposeBox now generates proper NIP-10 reply tags (root/reply markers,
  p tags for thread participants)
- Wired reply buttons in NoteCard and PostDetailPage to open the modal
- Reply success invalidates both feed and replies query caches

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-16 21:35:26 -06:00
parent 23502ccb5a
commit 7cdbf4a6e6
4 changed files with 181 additions and 11 deletions
+48 -10
View File
@@ -12,6 +12,7 @@ import { useUploadFile } from '@/hooks/useUploadFile';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
import type { NostrEvent } from '@nostrify/nostrify';
const MAX_CHARS = 5000;
@@ -19,6 +20,12 @@ interface ComposeBoxProps {
onSuccess?: () => void;
placeholder?: string;
compact?: boolean;
/** Event being replied to adds NIP-10 reply tags when set. */
replyTo?: NostrEvent;
/** If true, the compose area is always expanded (e.g. inside a modal). */
forceExpanded?: boolean;
/** If true, hides the avatar (useful inside modals with their own layout). */
hideAvatar?: boolean;
}
/** Circular progress ring for character count. */
@@ -58,7 +65,7 @@ function CharRing({ count, max }: { count: number; max: number }) {
);
}
export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", compact = false }: ComposeBoxProps) {
export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", compact = false, replyTo, forceExpanded = false, hideAvatar = false }: ComposeBoxProps) {
const { user, metadata } = useCurrentUser();
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
@@ -118,6 +125,32 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
const hashtags = content.match(/#\w+/g)?.map((t) => t.slice(1)) || [];
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
// NIP-10 reply tags
if (replyTo) {
// Determine root of the thread
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
if (rootTag) {
// replyTo is itself a reply preserve the root and mark replyTo as reply
tags.push(['e', rootTag[1], rootTag[2] || '', 'root', rootTag[4] || '']);
tags.push(['e', replyTo.id, '', 'reply', replyTo.pubkey]);
} else {
// replyTo is a top-level note it becomes the root
tags.push(['e', replyTo.id, '', 'root', replyTo.pubkey]);
}
// Add p tags: original author + all existing p tags from the parent
const pPubkeys = new Set<string>();
pPubkeys.add(replyTo.pubkey);
for (const tag of replyTo.tags) {
if (tag[0] === 'p' && tag[1]) pPubkeys.add(tag[1]);
}
// Don't include ourselves
if (user.pubkey) pPubkeys.delete(user.pubkey);
for (const pk of pPubkeys) {
tags.push(['p', pk]);
}
}
// NIP-36: content warning
if (cwEnabled) {
tags.push(['content-warning', cwText || '']);
@@ -139,23 +172,28 @@ export function ComposeBox({ onSuccess, placeholder = "What's on your mind?", co
setCwText('');
setExpanded(false);
queryClient.invalidateQueries({ queryKey: ['feed'] });
toast({ title: 'Posted!', description: 'Your note has been published.' });
if (replyTo) {
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
}
toast({ title: 'Posted!', description: replyTo ? 'Your reply has been published.' : 'Your note has been published.' });
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish note.', variant: 'destructive' });
}
};
const isExpanded = expanded || content.length > 0 || !compact;
const isExpanded = forceExpanded || expanded || content.length > 0 || !compact;
return (
<div className="flex gap-3 px-4 py-3 border-b border-border">
<Avatar className="size-11 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{user ? (metadata?.name?.[0] || '?').toUpperCase() : '?'}
</AvatarFallback>
</Avatar>
<div className={cn("flex gap-3 px-4 py-3", !forceExpanded && "border-b border-border")}>
{!hideAvatar && (
<Avatar className="size-11 shrink-0 mt-0.5">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{user ? (metadata?.name?.[0] || '?').toUpperCase() : '?'}
</AvatarFallback>
</Avatar>
)}
<div className="flex-1 min-w-0">
{/* Textarea */}
+9 -1
View File
@@ -11,6 +11,7 @@ import { nip19 } from 'nostr-tools';
import { useMemo, useState } from 'react';
import type { NostrEvent } from '@nostrify/nostrify';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
interface NoteCardProps {
event: NostrEvent;
@@ -43,6 +44,7 @@ export function NoteCard({ event, className }: NoteCardProps) {
const { data: stats } = useEventStats(event.id);
const [liked, setLiked] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
// Check if content is a reply
const isReply = event.tags.some(([name]) => name === 'e');
@@ -133,7 +135,10 @@ export function NoteCard({ event, className }: NoteCardProps) {
<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"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setReplyOpen(true);
}}
>
<MessageCircle className="size-[18px]" />
{stats?.replies ? <span className="text-xs">{stats.replies}</span> : null}
@@ -192,6 +197,9 @@ export function NoteCard({ event, className }: NoteCardProps) {
{/* More menu dialog */}
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
{/* Reply compose modal */}
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</div>
</div>
</article>
+120
View File
@@ -0,0 +1,120 @@
import { useMemo } from 'react';
import { X } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NoteContent } from '@/components/NoteContent';
import { ComposeBox } from '@/components/ComposeBox';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
interface ReplyComposeModalProps {
event: NostrEvent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** 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) || [];
}
export function ReplyComposeModal({ event, open, onOpenChange }: ReplyComposeModalProps) {
if (!event) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -ml-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
<DialogTitle className="text-base font-semibold">Reply to post</DialogTitle>
<div className="w-8" />
</div>
{/* Embedded original post */}
<EmbeddedPost event={event} onDismiss={() => {}} />
{/* Compose area */}
<ComposeBox
replyTo={event}
onSuccess={() => onOpenChange(false)}
placeholder="What's on your mind?"
forceExpanded
hideAvatar
/>
</DialogContent>
</Dialog>
);
}
/** Compact embedded preview of the post being replied to. */
function EmbeddedPost({ event }: { event: NostrEvent; onDismiss?: () => void }) {
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]);
return (
<div className="mx-4 mb-2 rounded-xl border border-border bg-secondary/30 overflow-hidden">
<div className="px-3 py-2.5">
{/* Author row */}
<div className="flex items-center gap-2 mb-1.5">
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="size-8">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex items-center gap-1 min-w-0 text-sm">
<span className="font-bold truncate">{displayName}</span>
{nip05 && (
<span className="text-muted-foreground truncate">@{nip05}</span>
)}
{metadata?.bot && (
<span className="text-xs text-primary" title="Bot account">🤖</span>
)}
<span className="text-muted-foreground shrink-0">·</span>
<span className="text-muted-foreground shrink-0">{timeAgo(event.created_at)}</span>
</div>
</div>
{/* Content preview clamp to a few lines */}
<div className="text-sm line-clamp-4">
<NoteContent event={event} className="text-sm leading-relaxed" />
</div>
{/* Show first image thumbnail if any */}
{images.length > 0 && (
<div className="mt-2 rounded-lg overflow-hidden border border-border max-w-[120px]">
<img
src={images[0]}
alt=""
className="w-full h-auto max-h-[80px] object-cover"
loading="lazy"
/>
</div>
)}
</div>
</div>
);
}
+4
View File
@@ -11,6 +11,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { NoteCard } from '@/components/NoteCard';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { useEvent } from '@/hooks/useEvent';
import { useReplies } from '@/hooks/useReplies';
import { useAuthor } from '@/hooks/useAuthor';
@@ -112,6 +113,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const { data: replies, isLoading: repliesLoading } = useReplies(event.id);
const [liked, setLiked] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const hasStats = !!(stats?.reposts || stats?.reactions || stats?.zapAmount);
@@ -215,6 +217,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<button
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title="Reply"
onClick={() => setReplyOpen(true)}
>
<MessageCircle className="size-[18px]" />
{stats?.replies ? <span className="text-xs">{stats.replies}</span> : null}
@@ -264,6 +267,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
</article>
{/* Replies */}