import { useEffect, useState, useRef, useCallback } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { renderMarkdown } from "../../lib/markdown";
import { useAutoResize } from "../../hooks/useAutoResize";
import { useUIStore } from "../../stores/ui";
import { useCanSign } from "../../stores/user";
import { useBookmarkStore } from "../../stores/bookmark";
import { fetchArticle, publishReaction, publishRepost, publishNote } from "../../lib/nostr";
import { nip19 } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { profileName } from "../../lib/utils";
import { ZapModal } from "../zap/ZapModal";
import { ImageLightbox } from "../shared/ImageLightbox";
// ── Types ────────────────────────────────────────────────────────────────────
interface TocHeading {
id: string;
text: string;
level: number;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function getTag(event: NDKEvent, name: string): string {
return event.tags.find((t) => t[0] === name)?.[1] ?? "";
}
function getTags(event: NDKEvent, name: string): string[] {
return event.tags.filter((t) => t[0] === name).map((t) => t[1]).filter(Boolean);
}
// ── Author row ────────────────────────────────────────────────────────────────
function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publishedAt: number | null; readingTime?: number }) {
const { openProfile } = useUIStore();
const profile = useProfile(pubkey);
const name = profileName(profile, pubkey.slice(0, 12) + "…");
const date = publishedAt
? new Date(publishedAt * 1000).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })
: null;
return (
{date && {date}}
{readingTime && · {readingTime} min read}
);
}
// ── Main view ─────────────────────────────────────────────────────────────────
export function ArticleView() {
const { pendingArticleNaddr, pendingArticleEvent, goBack } = useUIStore();
const canSign = useCanSign();
const [event, setEvent] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showZap, setShowZap] = useState(false);
const [reacted, setReacted] = useState(false);
const [reposted, setReposted] = useState(false);
const [showComment, setShowComment] = useState(false);
const [commentText, setCommentText] = useState("");
const autoResize = useAutoResize(3, 10);
const { isBookmarked, addBookmark, removeBookmark, isArticleBookmarked, addArticleBookmark, removeArticleBookmark } = useBookmarkStore();
const naddr = pendingArticleNaddr ?? "";
const { markArticleRead } = useBookmarkStore();
useEffect(() => {
if (!naddr) { setLoading(false); return; }
// Use cached event if available (from ArticleCard click), skip relay fetch
if (pendingArticleEvent) {
setEvent(pendingArticleEvent);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
setEvent(null);
fetchArticle(naddr)
.then((e) => {
if (!e) setError("Article not found — it may not be available on your current relays.");
else setEvent(e);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [naddr]);
// Auto-mark article as read when opened
useEffect(() => {
if (!event) return;
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
if (dTag) {
markArticleRead(`30023:${event.pubkey}:${dTag}`);
}
}, [event]);
const title = event ? getTag(event, "title") : "";
const summary = event ? getTag(event, "summary") : "";
const image = event ? getTag(event, "image") : "";
const publishedAt = event ? (parseInt(getTag(event, "published_at")) || event.created_at || null) : null;
const articleTags = event ? getTags(event, "t") : [];
const authorPubkey = event?.pubkey ?? "";
const authorProfile = useProfile(authorPubkey);
const authorName = profileName(authorProfile, authorPubkey.slice(0, 12) + "…");
const bodyHtml = event?.content ? renderMarkdown(event.content) : "";
const wordCount = event?.content?.trim().split(/\s+/).length ?? 0;
const readingTime = Math.max(1, Math.ceil(wordCount / 230));
const dTag = event?.tags?.find((t) => t[0] === "d")?.[1];
const articleAddr = event && dTag ? `30023:${event.pubkey}:${dTag}` : null;
const bookmarked = articleAddr ? isArticleBookmarked(articleAddr) : (event?.id ? isBookmarked(event.id) : false);
// Reading progress bar + TOC
const scrollRef = useRef(null);
const contentRef = useRef(null);
const [progress, setProgress] = useState(0);
const [headings, setHeadings] = useState([]);
const [activeId, setActiveId] = useState("");
const [lightbox, setLightbox] = useState<{ images: string[]; index: number } | null>(null);
const handleContentClick = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (!(target instanceof HTMLImageElement)) return;
const root = contentRef.current;
if (!root) return;
const imgs = Array.from(root.querySelectorAll("img"));
const index = imgs.indexOf(target);
if (index < 0) return;
setLightbox({ images: imgs.map((img) => img.src), index });
}, []);
// Extract headings from rendered content and assign IDs
useEffect(() => {
const el = contentRef.current;
if (!el) { setHeadings([]); return; }
const nodes = el.querySelectorAll("h2, h3");
const items: TocHeading[] = [];
nodes.forEach((node, i) => {
const id = `toc-${i}`;
node.id = id;
items.push({ id, text: node.textContent || "", level: parseInt(node.tagName[1]) });
});
setHeadings(items);
if (items.length > 0) setActiveId(items[0].id);
}, [bodyHtml]);
const handleScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
const max = scrollHeight - clientHeight;
setProgress(max > 0 ? (scrollTop / max) * 100 : 0);
// Track active heading — find the last heading above the top ~80px of scroll area
const scrollRect = el.getBoundingClientRect();
let active = "";
for (const { id } of headings) {
const heading = document.getElementById(id);
if (!heading) continue;
const top = heading.getBoundingClientRect().top - scrollRect.top;
if (top <= 80) active = id;
}
if (active) setActiveId(active);
}, [headings]);
useEffect(() => {
const el = scrollRef.current;
if (!el || !event) return;
el.addEventListener("scroll", handleScroll, { passive: true });
return () => el.removeEventListener("scroll", handleScroll);
}, [event, handleScroll]);
const scrollToHeading = useCallback((id: string) => {
const el = document.getElementById(id);
const scrollEl = scrollRef.current;
if (!el || !scrollEl) return;
const scrollRect = scrollEl.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const offset = elRect.top - scrollRect.top + scrollEl.scrollTop - 16;
scrollEl.scrollTo({ top: offset, behavior: "smooth" });
}, []);
const handleReaction = async () => {
if (!event?.id || reacted) return;
setReacted(true);
try {
await publishReaction(event.id, event.pubkey);
} catch {
setReacted(false);
}
};
const handleBookmark = () => {
if (!event) return;
if (articleAddr) {
if (bookmarked) removeArticleBookmark(articleAddr);
else addArticleBookmark(articleAddr);
} else if (event.id) {
if (bookmarked) removeBookmark(event.id);
else addBookmark(event.id);
}
};
const handleRepost = async () => {
if (!event || reposted) return;
setReposted(true);
try {
await publishRepost(event);
} catch {
setReposted(false);
}
};
const handleComment = async () => {
if (!event || !commentText.trim()) return;
const dTag = event.tags.find((t) => t[0] === "d")?.[1] ?? "";
const naddrStr = nip19.naddrEncode({ identifier: dTag, pubkey: event.pubkey, kind: 30023 });
const text = `${commentText.trim()}\n\nnostr:${naddrStr}`;
try {
await publishNote(text);
setCommentText("");
setShowComment(false);
} catch { /* ignore */ }
};
return (
{/* Header */}
{event && canSign && (
)}
{event && canSign && (
)}
{event && canSign && (
)}
{event && canSign && (
)}
{naddr && (
)}
{/* Comment box */}
{showComment && event && (
)}
{/* Body */}
{/* Reading progress bar */}
{event && (
)}
{loading && (
Loading article…
)}
{error && (
)}
{event && (
{/* Cover image */}
{image && (

{ (e.target as HTMLImageElement).style.display = "none"; }}
/>
)}
{/* Title */}
{title || "Untitled"}
{/* Summary */}
{summary && (
{summary}
)}
{/* Author + date + reading time */}
{/* Tags */}
{articleTags.length > 0 && (
{articleTags.map((tag) => (
#{tag}
))}
)}
{/* Content */}
{lightbox && (
setLightbox(null)}
onNavigate={(i) => setLightbox({ ...lightbox, index: i })}
/>
)}
{/* Footer */}
{canSign && (
)}
{canSign && (
)}
{canSign && (
)}
{canSign && (
)}
{canSign && (
)}
{/* Table of Contents — right margin on wide screens */}
{headings.length >= 2 && (
)}
)}
{showZap && event && (
setShowZap(false)}
/>
)}
);
}