Files
BelgianBitcoinEmbassy/frontend/app/blog/[slug]/NostrEmbeds.tsx
T
bbeandCursor 2ef68222bf feat: resolve live Nostr references in blog posts and add embeds
Support naddr/nevent/note slugs for unindexed posts, cache naddr lookups,
render Nostr embeds in markdown, and add a consistency check for events mirrors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 03:57:32 +02:00

186 lines
5.7 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { nip19 } from "nostr-tools";
import {
loadNostrProfile,
resolveEventFromRelays,
shortenPubkey,
type NostrProfile,
} from "@/lib/nostr";
function njump(bech32: string): string {
return `https://njump.me/${bech32}`;
}
// Inline @mention chip: avatar + display name, resolved live from the author's
// kind:0 profile. Falls back to a shortened pubkey until (or unless) it loads.
export function NostrMention({ bech32 }: { bech32: string }) {
const pubkey = useMemo(() => {
try {
const d = nip19.decode(bech32);
if (d.type === "npub") return d.data as string;
if (d.type === "nprofile") return (d.data as { pubkey: string }).pubkey;
} catch {}
return null;
}, [bech32]);
const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
if (!pubkey) return;
let cancelled = false;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
// Show the username; fall back to a shortened npub only when no profile.
const npub = useMemo(() => {
if (bech32.startsWith("npub")) return bech32;
return pubkey ? nip19.npubEncode(pubkey) : bech32;
}, [bech32, pubkey]);
const name = profile?.name || profile?.displayName || shortenPubkey(npub);
return (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 align-middle text-primary hover:underline font-medium no-underline"
>
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-5 h-5 rounded-full object-cover bg-surface-container-high inline-block"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span>@{name}</span>
</a>
);
}
// Compact author line used inside an embedded note.
function NoteAuthor({ pubkey }: { pubkey: string }) {
const [profile, setProfile] = useState<NostrProfile | null>(null);
useEffect(() => {
let cancelled = false;
loadNostrProfile(pubkey)
.then((p) => !cancelled && setProfile(p))
.catch(() => {});
return () => {
cancelled = true;
};
}, [pubkey]);
const fallback = useMemo(() => {
try {
return shortenPubkey(nip19.npubEncode(pubkey));
} catch {
return shortenPubkey(pubkey);
}
}, [pubkey]);
const name = profile?.name || profile?.displayName || fallback;
return (
<span className="flex items-center gap-2">
{profile?.picture && (
<img
src={profile.picture}
alt=""
className="w-6 h-6 rounded-full object-cover bg-surface-container-high"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
)}
<span className="font-semibold text-on-surface text-sm">{name}</span>
</span>
);
}
// Embedded referenced note (note/nevent/naddr). Renders as a bordered card.
// Uses span/block elements (not <div>) so it stays valid when the reference
// sits inside a markdown paragraph.
export function NostrNote({ bech32 }: { bech32: string }) {
const [event, setEvent] = useState<any>(null);
const [state, setState] = useState<"loading" | "done" | "error">("loading");
useEffect(() => {
let cancelled = false;
setState("loading");
resolveEventFromRelays(bech32)
.then((ev) => {
if (cancelled) return;
if (ev) {
setEvent(ev);
setState("done");
} else {
setState("error");
}
})
.catch(() => !cancelled && setState("error"));
return () => {
cancelled = true;
};
}, [bech32]);
return (
<span className="block my-4 rounded-xl border border-surface-container-high bg-surface-container-low p-4">
{state === "loading" && (
<span className="flex items-center gap-2 text-on-surface-variant text-sm">
<span className="inline-block w-4 h-4 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
Loading note
</span>
)}
{state === "error" && (
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm break-all"
>
View referenced note on njump.me
</a>
)}
{state === "done" && event && (
<span className="block">
<span className="flex items-center justify-between mb-3">
<NoteAuthor pubkey={event.pubkey} />
<a
href={njump(bech32)}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-on-surface-variant/60 hover:text-primary"
>
View note
</a>
</span>
<span className="block whitespace-pre-wrap break-words text-on-surface-variant text-sm leading-relaxed">
{(event.content || "").slice(0, 1000)}
{(event.content || "").length > 1000 ? "…" : ""}
</span>
</span>
)}
</span>
);
}
// Dispatches a `nostr:<bech32>` reference to the right embed by entity type.
export function NostrEntity({ bech32 }: { bech32: string }) {
let type: string;
try {
type = nip19.decode(bech32).type;
} catch {
return <>nostr:{bech32}</>;
}
if (type === "npub" || type === "nprofile") return <NostrMention bech32={bech32} />;
if (type === "note" || type === "nevent" || type === "naddr") return <NostrNote bech32={bech32} />;
return <>nostr:{bech32}</>;
}