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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"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}</>;
|
||||
}
|
||||
Reference in New Issue
Block a user