Files
BelgianBitcoinEmbassy/frontend/app/blog/[slug]/BlogPostClient.tsx
T
bbeandCursor 2ef68222bf 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>
2026-06-29 03:57:32 +02:00

456 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { ArrowLeft, Heart, Send } from "lucide-react";
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,
resolveEventFromRelays,
type NostrProfile,
} from "@/lib/nostr";
import { Navbar } from "@/components/public/Navbar";
import { Footer } from "@/components/public/Footer";
import { markdownComponents } from "./markdownComponents";
import { remarkNostr } from "./remarkNostr";
interface Post {
id: string;
slug: string;
title: string;
content: string;
excerpt?: string;
authorName?: string;
authorPubkey?: string;
publishedAt?: string;
createdAt?: string;
nostrEventId?: string;
naddr?: string;
categories?: { category: { id: string; name: string; slug: string } }[];
}
interface NostrReply {
id: string;
pubkey: string;
content: string;
created_at: number;
}
// 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];
return (
<div className="animate-pulse max-w-3xl mx-auto">
<div className="flex gap-2 mb-6">
<div className="h-5 w-20 bg-surface-container-high rounded-full" />
<div className="h-5 w-16 bg-surface-container-high rounded-full" />
</div>
<div className="h-12 w-3/4 bg-surface-container-high rounded mb-4" />
<div className="h-12 w-1/2 bg-surface-container-high rounded mb-8" />
<div className="h-5 w-48 bg-surface-container-high rounded mb-16" />
<div className="space-y-4">
{widths.map((w, i) => (
<div
key={i}
className="h-4 bg-surface-container-high rounded"
style={{ width: `${w}%` }}
/>
))}
</div>
</div>
);
}
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);
const [error, setError] = useState<string | null>(null);
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [comment, setComment] = useState("");
const [replies, setReplies] = useState<NostrReply[]>([]);
const [hasNostr, setHasNostr] = useState(false);
const [submitting, setSubmitting] = useState(false);
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());
}, []);
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) => 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");
});
});
return () => {
cancelled = true;
};
}, [slug, retryKey]);
useEffect(() => {
if (!slug) return;
api.getPostReactions(slug)
.then((data) => setLikeCount(data.count))
.catch(() => {});
api.getPostReplies(slug)
.then((data) => setReplies(data.replies || []))
.catch(() => {});
}, [slug]);
const handleLike = useCallback(async () => {
if (liked || !post?.nostrEventId || !hasNostr) return;
try {
const pubkey = await getPublicKey();
const reactionEvent = {
kind: 7,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId], ["p", post.authorPubkey || ""]],
content: "+",
pubkey,
};
const signedReaction = await signEvent(reactionEvent);
await publishEvent(signedReaction);
setLiked(true);
setLikeCount((c) => c + 1);
} catch {
// User rejected or extension unavailable
}
}, [liked, post, hasNostr]);
const handleComment = useCallback(async () => {
if (!comment.trim() || !post?.nostrEventId || !hasNostr) return;
setSubmitting(true);
try {
const pubkey = await getPublicKey();
const replyEvent = {
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [["e", post.nostrEventId, "", "reply"], ["p", post.authorPubkey || ""]],
content: comment.trim(),
pubkey,
};
const signed = await signEvent(replyEvent);
await publishEvent(signed);
setReplies((prev) => [
...prev,
{
id: signed.id || Date.now().toString(),
pubkey,
content: comment.trim(),
created_at: Math.floor(Date.now() / 1000),
},
]);
setComment("");
} catch {
// User rejected or extension unavailable
} finally {
setSubmitting(false);
}
}, [comment, post, hasNostr]);
const categories = post?.categories?.map((c) => c.category) || [];
return (
<>
<Navbar />
<div className="min-h-screen">
<div className="max-w-3xl mx-auto px-8 pt-12 pb-24">
<Link
href="/blog"
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-12 text-sm font-medium"
>
<ArrowLeft size={16} />
Back to Blog
</Link>
{loading && <ArticleSkeleton />}
{resolvingFromRelays && !post && <RelayLoading />}
{error && (
<div className="bg-error-container/20 text-error rounded-xl p-6">
<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>
)}
{!loading && !error && post && (
<>
<header className="mb-16">
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
{categories.map((cat) => (
<span
key={cat.id}
className="px-3 py-1 text-xs font-bold uppercase tracking-widest text-primary bg-primary/10 rounded-full"
>
{cat.name}
</span>
))}
</div>
)}
<h1 className="text-4xl md:text-5xl font-black tracking-tight leading-tight mb-6">
{post.title}
</h1>
<div className="flex items-center gap-3 text-sm text-on-surface-variant/60">
{(authorProfile || post.authorName || post.authorPubkey) && (
<div className="flex items-center gap-2.5">
{authorProfile?.picture && (
<img
src={authorProfile.picture}
alt={authorProfile.name || post.authorName || "Author"}
className="w-8 h-8 rounded-full object-cover bg-zinc-800 shrink-0"
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
<span className="font-medium text-on-surface-variant">
{authorProfile?.name || post.authorName || shortenPubkey(post.authorPubkey!)}
</span>
</div>
)}
{(post.publishedAt || post.createdAt) && (
<>
{(authorProfile || post.authorName || post.authorPubkey) && (
<span className="text-on-surface-variant/30">·</span>
)}
<span>
{formatDate(post.publishedAt || post.createdAt!)}
</span>
</>
)}
</div>
</header>
<article className="mb-16">
{loadingContent ? (
<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, 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>
)}
</article>
<section className="bg-surface-container-low rounded-xl p-8 mb-16">
<div className="flex items-center gap-6 mb-8">
<button
onClick={handleLike}
disabled={!hasNostr}
title={hasNostr ? "Like this post" : "Install a Nostr extension to interact"}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
liked
? "bg-primary/20 text-primary"
: hasNostr
? "bg-surface-container-high text-on-surface hover:bg-surface-bright"
: "bg-surface-container-high text-on-surface/40 cursor-not-allowed"
}`}
>
<Heart size={18} fill={liked ? "currentColor" : "none"} />
<span className="font-semibold">{likeCount}</span>
</button>
{!hasNostr && (
<span className="text-on-surface-variant/50 text-xs">
Install a Nostr extension to like and comment
</span>
)}
</div>
<h3 className="text-lg font-bold mb-6">
Comments {replies.length > 0 && `(${replies.length})`}
</h3>
{hasNostr && (
<div className="flex gap-3 mb-8">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Share your thoughts..."
rows={3}
className="flex-1 bg-surface-container-highest text-on-surface rounded-lg p-4 resize-none placeholder:text-on-surface-variant/40 focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
<button
onClick={handleComment}
disabled={!comment.trim() || submitting}
className="self-end px-4 py-3 bg-primary text-on-primary rounded-lg font-semibold hover:scale-105 transition-transform disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send size={18} />
</button>
</div>
)}
{replies.length > 0 ? (
<div className="space-y-6">
{replies.map((r) => (
<div
key={r.id}
className="bg-surface-container-high rounded-lg p-4"
>
<div className="flex items-center gap-2.5 mb-2">
<span className="font-semibold text-xs font-mono text-on-surface-variant/70">
{shortenPubkey(r.pubkey)}
</span>
<span className="text-on-surface-variant/30">·</span>
<span className="text-xs text-on-surface-variant/50">
{formatDate(new Date(r.created_at * 1000))}
</span>
</div>
<p className="text-on-surface-variant text-sm leading-relaxed">
{r.content}
</p>
</div>
))}
</div>
) : (
<p className="text-on-surface-variant/50 text-sm">
No comments yet. Be the first to share your thoughts.
</p>
)}
</section>
</>
)}
</div>
</div>
<Footer />
</>
);
}