diff --git a/src/components/ComposeBox.tsx b/src/components/ComposeBox.tsx index 95fbea19..2fac4b12 100644 --- a/src/components/ComposeBox.tsx +++ b/src/components/ComposeBox.tsx @@ -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(); + 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 ( -
- - - - {user ? (metadata?.name?.[0] || '?').toUpperCase() : '?'} - - +
+ {!hideAvatar && ( + + + + {user ? (metadata?.name?.[0] || '?').toUpperCase() : '?'} + + + )}
{/* Textarea */} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 8999c2f7..13a0a2a9 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -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) {
diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx new file mode 100644 index 00000000..3d7459dc --- /dev/null +++ b/src/components/ReplyComposeModal.tsx @@ -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 ( + + + {/* Header */} +
+ + Reply to post +
+
+ + {/* Embedded original post */} + {}} /> + + {/* Compose area */} + onOpenChange(false)} + placeholder="What's on your mind?" + forceExpanded + hideAvatar + /> + +
+ ); +} + +/** 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 ( +
+
+ {/* Author row */} +
+ + + + + {displayName[0].toUpperCase()} + + + + +
+ {displayName} + {nip05 && ( + @{nip05} + )} + {metadata?.bot && ( + ๐Ÿค– + )} + ยท + {timeAgo(event.created_at)} +
+
+ + {/* Content preview โ€“ clamp to a few lines */} +
+ +
+ + {/* Show first image thumbnail if any */} + {images.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 0b766e5c..95d8a178 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -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 }) {
+ {/* Replies */}