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:
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user