import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; import { Image, Film, Music, ExternalLink, Blocks, MessageSquareOff, Zap } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { EmbeddedCardShell } from '@/components/EmbeddedCardShell'; import { VanishCardCompact } from '@/components/VanishEventContent'; import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent'; import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent'; import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr'; import { EmojifiedText } from '@/components/CustomEmoji'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { NoteContent } from '@/components/NoteContent'; import { useEvent } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { isProfileBadgesKind } from '@/lib/badgeUtils'; import { extractZapAmount, extractZapSender, extractZapMessage } from '@/hooks/useEventInteractions'; import { genUserName } from '@/lib/genUserName'; import { formatNumber } from '@/lib/formatNumber'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; import { getKindLabel, getKindIcon } from '@/lib/extraKinds'; /** NIP-62 Request to Vanish. */ const VANISH_KIND = 62; /** Max-height (px) for the content area before it gets clipped. */ const EMBED_MAX_HEIGHT = 260; interface EmbeddedNoteProps { /** Hex event ID to fetch and display. */ eventId: string; /** Optional relay hints from the nevent1 identifier. */ relays?: string[]; /** Optional author pubkey hint from the nevent1 identifier. */ authorHint?: string; className?: string; /** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */ disableHoverCards?: boolean; } /** Inline embedded note card – similar to a link preview but for Nostr events. */ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHoverCards }: EmbeddedNoteProps) { const { data: event, isLoading, isError } = useEvent(eventId, relays, authorHint); if (isLoading) { return ; } if (isError || !event) { return ; } // NIP-62 vanish events get their own dramatic inline card if (event.kind === VANISH_KIND) { return ; } // Kind 4 encrypted DMs get a compact card instead of rendering ciphertext if (event.kind === 4) { return ; } // Kind 8211 encrypted letters get a compact card if (event.kind === 8211) { return ; } // Profile badges (kind 10008/30008) get a compact badge row preview if (isProfileBadgesKind(event.kind)) { return ; } // Kind 9735 zap receipts get a compact zap card instead of rendering raw JSON if (event.kind === 9735) { return ; } return ; } /** Compact inline card for kind 9735 zap receipts. */ function EmbeddedZapCard({ event, className, disableHoverCards }: { event: NostrEvent; className?: string; disableHoverCards?: boolean }) { const navigate = useNavigate(); const neventId = useMemo( () => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey], ); const senderPubkey = useMemo(() => extractZapSender(event), [event]); const amountSats = useMemo(() => Math.floor(extractZapAmount(event) / 1000), [event]); const message = useMemo(() => extractZapMessage(event), [event]); const sender = useAuthor(senderPubkey || undefined); const senderMeta = sender.data?.metadata; const senderName = senderMeta?.name || (senderPubkey ? genUserName(senderPubkey) : 'Someone'); const senderProfileUrl = useProfileUrl(senderPubkey, senderMeta); return (
{ e.stopPropagation(); navigate(`/${neventId}`); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); navigate(`/${neventId}`); } }} >
{/* Zap icon */}
{/* Sender avatar */} {senderPubkey && ( e.stopPropagation()}> {senderName[0]?.toUpperCase()} )} {/* Text */}
{senderPubkey ? ( e.stopPropagation()} > {sender.data?.event ? ( {senderName} ) : senderName} ) : ( Someone )} zapped {amountSats > 0 && ( {formatNumber(amountSats)} {amountSats === 1 ? 'sat' : 'sats'} )} · {timeAgo(event.created_at)}
{message && (

“{message}”

)}
); } /** The actual card once the event has been fetched. */ function EmbeddedNoteCard({ event, className, disableHoverCards, }: { event: NostrEvent; className?: string; disableHoverCards?: boolean; }) { const { config } = useAppContext(); const neventId = useMemo( () => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey], ); const [contentOverflows, setContentOverflows] = useState(false); const [contentExpanded, setContentExpanded] = useState(false); const isPhoto = event.kind === 20; // Attachment counts for indicator chips const attachments = useMemo(() => { if (isPhoto) { const photoCount = event.tags.filter(([n]) => n === 'imeta').length; return { imgs: 0, vids: 0, auds: 0, apps: 0, links: 0, photos: photoCount }; } const imgs = (event.content.match(new RegExp(IMAGE_URL_REGEX.source, 'gi')) || []).length; const vids = extractVideoUrls(event.content).length; const auds = extractAudioUrls(event.content).length; const apps = (event.content.match(/https?:\/\/[^\s]+\.xdc(\?[^\s]*)?/gi) || []).length; const allUrls = event.content.match(/https?:\/\/[^\s]+/g) || []; const links = allUrls.filter((u) => !IMETA_MEDIA_URL_TEST_REGEX.test(u)).length; return { imgs, vids, auds, apps, links, photos: 0 }; }, [event.content, event.tags, isPhoto]); // Kind label for non-text-note kinds const kindMeta = useMemo(() => { const label = getKindLabel(event.kind); if (!label) return undefined; return { label, Icon: getKindIcon(event.kind) }; }, [event.kind]); // Tag-based fallback metadata for events with empty content (articles, custom kinds, etc.) const hasContent = event.content.trim().length > 0; const tagMeta = useMemo(() => { if (hasContent) return undefined; const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; const title = getTag('title') || getTag('name') || getTag('d'); const description = getTag('summary') || getTag('description'); if (!title && !description) return undefined; return { title, description }; }, [hasContent, event.tags]); // NIP-36 content-warning check const cwTag = event.tags.find(([name]) => name === 'content-warning'); const hasCW = !!cwTag; // If policy is "hide", don't render the embedded note at all if (hasCW && config.contentWarningPolicy === 'hide') { return null; } const hasChips = !hasCW && ( attachments.photos > 0 || attachments.imgs > 0 || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0 || kindMeta ); const hasFooter = hasChips || contentOverflows; return ( {/* Content — rendered identically to a normal NoteCard, just height-capped */} {hasCW && config.contentWarningPolicy === 'blur' ? (

Content warning{cwTag?.[1] ? <>{' '}“{cwTag[1]}” : ''}

) : tagMeta ? ( <> {tagMeta.title && (

{tagMeta.title}

)} {tagMeta.description && (

{tagMeta.description}

)} ) : ( )} {/* Attachment / kind indicator chips + Read more toggle */} {hasFooter && (
{kindMeta && ( {kindMeta.Icon && } {kindMeta.label} )} {attachments.photos > 0 && ( {attachments.photos > 1 ? `${attachments.photos} photos` : 'Photo'} )} {attachments.imgs > 0 && ( {attachments.imgs > 1 ? `${attachments.imgs} images` : 'Image'} )} {attachments.vids > 0 && ( {attachments.vids > 1 ? `${attachments.vids} videos` : 'Video'} )} {attachments.auds > 0 && ( {attachments.auds > 1 ? `${attachments.auds} audio files` : 'Audio'} )} {attachments.apps > 0 && ( App )} {attachments.links > 0 && ( {attachments.links > 1 ? `${attachments.links} links` : 'Link'} )} {contentOverflows && ( )}
)}
); } /** Truncated content area with overflow detection. Toggle is rendered externally. */ function EmbedTruncatedContent({ event, expanded, onOverflowChange }: { event: NostrEvent; expanded: boolean; onOverflowChange: (overflows: boolean) => void; }) { const contentRef = useRef(null); const [overflows, setOverflows] = useState(false); const measure = useCallback(() => { const el = contentRef.current; if (!el) return; const doesOverflow = el.scrollHeight > EMBED_MAX_HEIGHT; setOverflows(doesOverflow); onOverflowChange(doesOverflow); }, [onOverflowChange]); useEffect(() => { measure(); window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, [measure]); // Re-measure after images load useEffect(() => { const el = contentRef.current; if (!el) return; const imgs = el.querySelectorAll('img'); if (imgs.length === 0) return; imgs.forEach((img) => img.addEventListener('load', measure, { once: true })); return () => imgs.forEach((img) => img.removeEventListener('load', measure)); }, [measure]); return (
{!expanded && overflows && (
)}
); } /** Clickable wrapper around VanishCardCompact for embedded/quoted vanish events. */ function EmbeddedVanishCardWrapper({ event, className, }: { event: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] }; className?: string; }) { const navigate = useNavigate(); const neventId = useMemo( () => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey], ); return (
{ e.stopPropagation(); navigate(`/${neventId}`); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); navigate(`/${neventId}`); } }} >
); } /** Tombstone shown when a quoted note could not be loaded. */ function EmbeddedNoteTombstone({ eventId, relays, authorHint, className }: { eventId: string; relays?: string[]; authorHint?: string; className?: string }) { const navigate = useNavigate(); const neventId = useMemo( () => nip19.neventEncode({ id: eventId, ...(authorHint ? { author: authorHint } : {}), ...(relays?.length ? { relays } : {}), }), [eventId, authorHint, relays], ); return (
{ e.stopPropagation(); navigate(`/${neventId}`); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); navigate(`/${neventId}`); } }} >
This post could not be loaded
); } /** Conditionally wraps children in a ProfileHoverCard. */ function MaybeHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) { if (disabled) return <>{children}; return ( {children} ); } function EmbeddedNoteSkeleton({ className }: { className?: string }) { return (
); }