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