import { type ReactNode, useMemo } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { Image, Film, Music, ExternalLink, Blocks } from 'lucide-react'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { getAvatarShape } from '@/lib/avatarShape'; import { Skeleton } from '@/components/ui/skeleton'; import { EmojifiedText } from '@/components/CustomEmoji'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { useEvent } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; import { useAppContext } from '@/hooks/useAppContext'; import { IMAGE_URL_REGEX, IMETA_MEDIA_URL_REGEX, extractVideoUrls, extractAudioUrls } from '@/lib/mediaUrls'; /** Bech32 charset used by NIP-19 identifiers. */ const B32 = '023456789acdefghjklmnpqrstuvwxyz'; /** Regex that matches nostr:npub1… and nostr:nprofile1… inside text. */ const MENTION_REGEX = new RegExp(`nostr:(npub1|nprofile1)[${B32}]+`, 'g'); /** A parsed segment of embedded-note text. */ type EmbedSegment = | { type: 'text'; value: string } | { type: 'mention'; pubkey: string; npub: string }; /** Split text into plain strings and mention segments. */ function parseEmbedSegments(text: string): EmbedSegment[] { const segments: EmbedSegment[] = []; let last = 0; let m: RegExpExecArray | null; MENTION_REGEX.lastIndex = 0; while ((m = MENTION_REGEX.exec(text)) !== null) { if (m.index > last) { segments.push({ type: 'text', value: text.slice(last, m.index) }); } try { const bech32 = m[0].slice('nostr:'.length); const decoded = nip19.decode(bech32); const pubkey = decoded.type === 'npub' ? (decoded.data as string) : (decoded.data as { pubkey: string }).pubkey; const npub = nip19.npubEncode(pubkey); segments.push({ type: 'mention', pubkey, npub }); } catch { // If decode fails, keep as plain text segments.push({ type: 'text', value: m[0] }); } last = m.index + m[0].length; } if (last < text.length) { segments.push({ type: 'text', value: text.slice(last) }); } return segments; } 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; } /** Maximum characters of note content to show in the embedded preview. */ const MAX_CONTENT_LENGTH = 280; /** 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 null; } return ; } /** The actual card once the event has been fetched. */ function EmbeddedNoteCard({ event, className, disableHoverCards, }: { event: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] }; className?: string; disableHoverCards?: boolean; }) { const { config } = useAppContext(); const navigate = useNavigate(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); const displayName = metadata?.name || genUserName(event.pubkey); const profileUrl = useProfileUrl(event.pubkey, metadata); const neventId = useMemo( () => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey], ); // Truncate long content, stripping media URLs and nested nostr event references const truncatedContent = useMemo(() => { const cleaned = event.content // Strip media URLs (same extensions as NoteContent's MEDIA_URL_REGEX) .replace(new RegExp(IMETA_MEDIA_URL_REGEX.source, 'gi'), '') // Strip embedded event references (nevent / note) so they don't nest .replace(/nostr:(nevent1|note1)[023456789acdefghjklmnpqrstuvwxyz]+/g, '') // Collapse leftover whitespace .replace(/\n{3,}/g, '\n\n') .trim(); if (cleaned.length <= MAX_CONTENT_LENGTH) return cleaned; return cleaned.slice(0, MAX_CONTENT_LENGTH).trimEnd() + '…'; }, [event.content]); // Extract first image for a small thumbnail const firstImage = useMemo(() => { return event.content.match(IMAGE_URL_REGEX)?.[0] ?? null; }, [event.content]); // Detect stripped attachments to show indicator chips const attachments = useMemo(() => { 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_REGEX.test(u)).length; return { imgs, vids, auds, apps, links }; }, [event.content]); // 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; } return (
{ e.stopPropagation(); navigate(`/${neventId}`); }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); navigate(`/${neventId}`); } }} > {/* Optional image thumbnail — skip when content-warning is blurred */} {firstImage && !(hasCW && config.contentWarningPolicy === 'blur') && (
{ (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; }} />
)} {/* Note content */}
{/* Author row */}
{author.isLoading ? ( <> ) : ( <> e.stopPropagation()} > {displayName[0]?.toUpperCase()} e.stopPropagation()} > {author.data?.event ? ( {displayName} ) : displayName} )} · {timeAgo(event.created_at)}
{/* Content warning notice or text preview */} {hasCW && config.contentWarningPolicy === 'blur' ? (

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

) : truncatedContent ? ( ) : null} {/* Attachment indicators for stripped media/links */} {!hasCW && (attachments.imgs > (firstImage ? 1 : 0) || attachments.vids > 0 || attachments.auds > 0 || attachments.apps > 0 || attachments.links > 0) && (
{attachments.imgs > (firstImage ? 1 : 0) && ( {attachments.imgs > 1 ? `${attachments.imgs} images` : '1 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'} )}
)}
); } /** Renders embedded-note text with @mentions resolved inline. */ function EmbedContentPreview({ text, disableHoverCards }: { text: string; disableHoverCards?: boolean }) { const segments = useMemo(() => parseEmbedSegments(text), [text]); return (

{segments.map((seg, i) => { if (seg.type === 'text') { return {seg.value}; } return ; })}

); } /** Inline @mention inside an embedded note preview. */ function EmbedMention({ pubkey, disableHoverCards }: { pubkey: string; npub: string; disableHoverCards?: boolean }) { const author = useAuthor(pubkey); const hasRealName = !!author.data?.metadata?.name; const displayName = author.data?.metadata?.name ?? genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, author.data?.metadata); return ( e.stopPropagation()} > @{author.data?.event ? ( {displayName} ) : displayName} ); } /** Conditionally wraps children in a ProfileHoverCard. When disabled, renders children directly. */ function MaybeProfileHoverCard({ pubkey, disabled, children }: { pubkey: string; disabled?: boolean; children: ReactNode }) { if (disabled) { return <>{children}; } return ( {children} ); } function EmbeddedNoteSkeleton({ className }: { className?: string }) { return (
); }