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>
186 lines
5.7 KiB
TypeScript
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}</>;
|
|
}
|