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 (
);
}