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>
178 lines
5.5 KiB
TypeScript
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}</>;
|
|
}
|