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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user