"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, shortenNpub, 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"; import { NostrAuthor } from "./NostrEmbeds"; interface Post { id: string; slug: string; title: string; content: string; excerpt?: string; image?: 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"), image: tag("image"), 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 (
{widths.map((w, i) => (
))}
); } function RelayLoading() { return (
Fetching from Nostr relays…
); } export default function BlogPostClient({ slug }: { slug: string }) { const [post, setPost] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [liked, setLiked] = useState(false); const [likeCount, setLikeCount] = useState(0); const [comment, setComment] = useState(""); const [replies, setReplies] = useState([]); const [hasNostr, setHasNostr] = useState(false); const [submitting, setSubmitting] = useState(false); const [authorProfile, setAuthorProfile] = useState(null); const [liveContent, setLiveContent] = useState(null); const [liveImage, setLiveImage] = useState(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); setLiveImage(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); // Pull the longform header image (`image` tag) for display. const img = event.tags?.find((t: string[]) => t[0] === "image")?.[1]; if (img) setLiveImage(img); } 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) || []; const headerImage = post?.image || liveImage; return ( <>
Back to Blog {loading && } {resolvingFromRelays && !post && } {error && (

Failed to load post: {error}

)} {!loading && !error && post && ( <>
{categories.length > 0 && (
{categories.map((cat) => ( {cat.name} ))}
)}

{post.title}

{(authorProfile || post.authorName || post.authorPubkey) && (
{authorProfile?.picture && ( {authorProfile.name { (e.target as HTMLImageElement).style.display = "none"; }} /> )} {authorProfile?.name || post.authorName || shortenNpub(post.authorPubkey!)}
)} {(post.publishedAt || post.createdAt) && ( <> {(authorProfile || post.authorName || post.authorPubkey) && ( · )} {formatDate(post.publishedAt || post.createdAt!)} )}
{headerImage && ( {post.title} { (e.target as HTMLImageElement).style.display = "none"; }} /> )}
{loadingContent ? ( ) : contentError && !(post.content || liveContent) ? (

Couldn't fetch this article from the Nostr relays. It may be temporarily unavailable.

) : ( url.startsWith("nostr:") ? url : defaultUrlTransform(url) } > {post.content || liveContent || ""} )}
{!hasNostr && ( Install a Nostr extension to like and comment )}

Comments {replies.length > 0 && `(${replies.length})`}

{hasNostr && (