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:
bbe
2026-06-29 03:57:32 +02:00
co-authored by Cursor
parent 6023991f5c
commit 2ef68222bf
23 changed files with 1066 additions and 264 deletions
+139 -117
View File
@@ -3,14 +3,26 @@
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
import ReactMarkdown from "react-markdown";
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
import remarkGfm from "remark-gfm";
import { api } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { hasNostrExtension, getPublicKey, signEvent, publishEvent, shortenPubkey, fetchNostrProfile, fetchLongformFromRelays, fetchEventFromRelays, type NostrProfile } from "@/lib/nostr";
import {
hasNostrExtension,
getPublicKey,
signEvent,
publishEvent,
shortenPubkey,
fetchNostrProfile,
fetchLongformFromRelays,
fetchEventFromRelays,
resolveEventFromRelays,
type NostrProfile,
} from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import type { Components } from "react-markdown";
import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr";
interface Post {
id: string;
@@ -34,84 +46,28 @@ interface NostrReply {
created_at: number;
}
const markdownComponents: Components = {
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-on-surface mb-4 mt-10">{children}</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-on-surface mb-4 mt-8">{children}</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-bold text-on-surface mb-3 mt-6">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-on-surface mb-2 mt-4">{children}</h4>
),
p: ({ children }) => (
<p className="text-on-surface-variant leading-relaxed mb-6">{children}</p>
),
a: ({ href, children }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
ul: ({ children }) => (
<ul className="list-disc ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ul>
),
ol: ({ children }) => (
<ol className="list-decimal ml-6 mb-6 space-y-2 text-on-surface-variant">{children}</ol>
),
li: ({ children }) => (
<li className="leading-relaxed">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary/30 pl-4 italic text-on-surface-variant mb-6">
{children}
</blockquote>
),
code: ({ className, children }) => {
const isBlock = className?.includes("language-");
if (isBlock) {
return (
<code className={`${className} block`}>
{children}
</code>
);
}
return (
<code className="bg-surface-container-high px-2 py-1 rounded text-sm text-primary">
{children}
</code>
);
},
pre: ({ children }) => (
<pre className="bg-surface-container-highest p-4 rounded-lg overflow-x-auto mb-6 text-sm">
{children}
</pre>
),
img: ({ src, alt }) => (
<img src={src} alt={alt || ""} className="rounded-lg max-w-full mb-6" />
),
hr: () => <hr className="border-surface-container-high my-8" />,
table: ({ children }) => (
<div className="overflow-x-auto mb-6">
<table className="w-full text-left text-on-surface-variant">{children}</table>
</div>
),
th: ({ children }) => (
<th className="px-4 py-2 font-semibold text-on-surface bg-surface-container-high">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-2">{children}</td>
),
};
// Builds a renderable Post from a raw long-form Nostr event, used when a slug is
// a NIP-19 reference (naddr/nevent/note) that was never indexed by the backend.
function postFromEvent(event: any, slug: string): Post {
const tag = (name: string): string | undefined =>
event.tags?.find((t: string[]) => t[0] === name)?.[1];
const publishedAtSec = Number(tag("published_at")) || event.created_at;
return {
id: event.id,
slug,
title: tag("title") || "Untitled",
content: event.content || "",
excerpt: tag("summary"),
authorPubkey: event.pubkey,
publishedAt: new Date(publishedAtSec * 1000).toISOString(),
nostrEventId: event.id,
naddr: slug.startsWith("naddr") ? slug : undefined,
categories: (event.tags || [])
.filter((t: string[]) => t[0] === "t" && t[1])
.map((t: string[]) => ({ category: { id: t[1], name: t[1], slug: t[1] } })),
};
}
function ArticleSkeleton() {
const widths = [85, 92, 78, 95, 88, 72, 90, 83];
@@ -137,6 +93,18 @@ function ArticleSkeleton() {
);
}
function RelayLoading() {
return (
<div className="flex items-center justify-center gap-3 py-16 text-on-surface-variant">
<span
className="inline-block w-5 h-5 rounded-full border-2 border-primary/30 border-t-primary animate-spin"
aria-hidden
/>
<span className="text-sm font-medium">Fetching from Nostr relays</span>
</div>
);
}
export default function BlogPostClient({ slug }: { slug: string }) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
@@ -150,6 +118,11 @@ export default function BlogPostClient({ slug }: { slug: string }) {
const [authorProfile, setAuthorProfile] = useState<NostrProfile | null>(null);
const [liveContent, setLiveContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(false);
const [resolvingFromRelays, setResolvingFromRelays] = useState(false);
const [contentError, setContentError] = useState(false);
const [retryKey, setRetryKey] = useState(0);
const retry = useCallback(() => setRetryKey((k) => k + 1), []);
useEffect(() => {
setHasNostr(hasNostrExtension());
@@ -157,39 +130,70 @@ export default function BlogPostClient({ slug }: { slug: string }) {
useEffect(() => {
if (!slug) return;
let cancelled = false;
setLoading(true);
setError(null);
setLiveContent(null);
setPost(null);
setAuthorProfile(null);
setContentError(false);
setResolvingFromRelays(false);
// Loads the author's profile, then hydrates the article body from relays
// when only metadata is present (indexed posts store an empty body).
const hydrate = (data: Post) => {
if (cancelled) return;
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => !cancelled && setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
setContentError(false);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (cancelled) return;
if (event?.content) setLiveContent(event.content);
else setContentError(true); // not found / timed out
})
.catch(() => !cancelled && setContentError(true))
.finally(() => !cancelled && setLoadingContent(false));
}
};
api
.getPost(slug)
.then((data) => {
setPost(data);
setLoading(false);
if (data?.authorPubkey) {
fetchNostrProfile(data.authorPubkey)
.then((profile) => setAuthorProfile(profile))
.catch(() => {});
}
if (!data?.content && (data?.naddr || data?.nostrEventId)) {
setLoadingContent(true);
const fetchPromise = data.naddr
? fetchLongformFromRelays(data.naddr)
: fetchEventFromRelays(data.nostrEventId!);
fetchPromise
.then((event) => {
if (event?.content) {
setLiveContent(event.content);
}
})
.catch(() => {})
.finally(() => setLoadingContent(false));
}
})
.catch((err) => {
setError(err.message);
.then((data) => hydrate(data))
.catch(() => {
// Not indexed: treat the slug itself as a NIP-19 reference and resolve
// the long-form note live from relays.
if (cancelled) return;
setLoading(false);
setResolvingFromRelays(true);
resolveEventFromRelays(slug)
.then((event) => {
if (cancelled) return;
setResolvingFromRelays(false);
if (event) hydrate(postFromEvent(event, slug));
else setError("Post not found");
})
.catch((err) => {
if (cancelled) return;
setResolvingFromRelays(false);
setError(err?.message || "Post not found");
});
});
}, [slug]);
return () => {
cancelled = true;
};
}, [slug, retryKey]);
useEffect(() => {
if (!slug) return;
@@ -271,9 +275,17 @@ export default function BlogPostClient({ slug }: { slug: string }) {
{loading && <ArticleSkeleton />}
{resolvingFromRelays && !post && <RelayLoading />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
Failed to load post: {error}
<p className="mb-4">Failed to load post: {error}</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
)}
@@ -328,19 +340,29 @@ export default function BlogPostClient({ slug }: { slug: string }) {
<article className="mb-16">
{loadingContent ? (
<div className="animate-pulse space-y-4">
{[85, 92, 78, 95, 88, 72, 90, 83].map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
<RelayLoading />
) : contentError && !(post.content || liveContent) ? (
<div className="text-center py-12">
<p className="text-on-surface-variant mb-4">
Couldn&apos;t fetch this article from the Nostr relays. It may be
temporarily unavailable.
</p>
<button
onClick={retry}
className="px-4 py-2 rounded-lg bg-surface-container-high text-on-surface hover:bg-surface-bright transition-colors text-sm font-semibold"
>
Try again
</button>
</div>
) : (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
remarkPlugins={[remarkGfm, remarkNostr]}
components={markdownComponents}
// Preserve nostr: links (react-markdown strips unknown
// schemes by default), so mentions/notes resolve correctly.
urlTransform={(url) =>
url.startsWith("nostr:") ? url : defaultUrlTransform(url)
}
>
{post.content || liveContent || ""}
</ReactMarkdown>