Files
eranos/src/components/ReplyComposeModal.tsx
T

173 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } 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 { VerifiedNip05Text } from '@/components/Nip05Badge';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
interface ReplyComposeModalProps {
/** The event being replied to, or a URL for commenting on external content. When `null`, the modal acts as a "New post" composer. */
event?: NostrEvent | URL | null;
/** The event being quoted (for quote posts). */
quotedEvent?: NostrEvent | null;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a post is successfully published. */
onSuccess?: () => void;
/** Pre-filled content for the compose box. */
initialContent?: string;
}
/** 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, quotedEvent, open, onOpenChange, onSuccess, initialContent }: ReplyComposeModalProps) {
const isUrl = event instanceof URL;
const isReply = !!event;
const isQuote = !!quotedEvent;
const [previewMode, setPreviewMode] = useState(false);
const [hasPreviewableContent, setHasPreviewableContent] = useState(false);
const title = isUrl ? 'New comment' : isReply ? 'Reply to post' : isQuote ? 'Quote post' : 'New post';
const placeholder = isUrl ? 'Write a comment...' : isReply ? "What's on your mind?" : isQuote ? 'Add a comment...' : "What's happening?";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[520px] rounded-2xl p-0 gap-0 border-border overflow-visible [&>button]:hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 h-12">
<DialogTitle className="text-base font-semibold">
{title}
</DialogTitle>
<div className="flex items-center gap-2">
{/* Preview toggle */}
{hasPreviewableContent && (
<div className="inline-flex items-center gap-0.5 p-1 bg-muted/50 rounded-lg">
<button
onClick={() => setPreviewMode(false)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
!previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Edit
</button>
<button
onClick={() => setPreviewMode(true)}
className={cn(
"px-3.5 py-1.5 text-xs font-medium rounded-md transition-all",
previewMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
</button>
</div>
)}
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
</div>
{/* Embedded original post (reply only, not for URL roots or quotes) */}
{event && !isUrl && !isQuote && <EmbeddedPost event={event} />}
{/* Compose area */}
<ComposeBox
replyTo={isQuote ? undefined : (event ?? undefined)}
quotedEvent={quotedEvent ?? undefined}
onSuccess={() => { onOpenChange(false); onSuccess?.(); }}
placeholder={placeholder}
forceExpanded
hideAvatar
previewMode={previewMode}
onHasPreviewableContentChange={setHasPreviewableContent}
initialContent={initialContent}
/>
</DialogContent>
</Dialog>
);
}
/** Compact embedded preview of the post being replied to. */
function EmbeddedPost({ 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]);
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 && (
<VerifiedNip05Text nip05={nip05} pubkey={event.pubkey} className="text-muted-foreground truncate" />
)}
{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 overflow-hidden">
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
</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>
);
}