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 && (