Files
BelgianBitcoinEmbassy/frontend/app/blog/[slug]/NostrEmbeds.tsx
T
bbeandCursor 495289232b feat: blog header images, npub display, and meetups load more
Show longform image tags on posts, use shortened npubs for author names,
and paginate the homepage meetups section with responsive load more.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 04:36:10 +02:00

178 lines
5.5 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import { nip19 } from "nostr-tools";
import {
loadNostrProfile,
resolveEventFromRelays,
shortenNpub,
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 name =
profile?.name || profile?.displayName || shortenNpub(pubkey || bech32);
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>
);
}
// Reusable author line (avatar + username) resolved from the author's kind:0
// profile. Falls back to a shortened npub — never the raw hex pubkey. Shared by
// embedded notes and the blog comment list.
export function NostrAuthor({ 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 name = profile?.name || profile?.displayName || shortenNpub(pubkey);
return (
<span className="inline-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 shrink-0"
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">
<NostrAuthor 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}</>;
}