mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 14:11:55 -07:00
94f610c2d3
WebKitGTK collapses body <img> elements to 0 height during re-decode, shrinking scrollHeight and clamping scrollTop — articles with body images locked partway down. Give every .prose-article img a fixed 16:9 aspect-ratio + object-fit: contain so the layout box survives the collapse and scrollHeight stays constant. Body images now click-to-open the existing ImageLightbox (with cursor: zoom-in affordance), and the lightbox image itself now closes on click instead of swallowing it.
528 lines
21 KiB
TypeScript
528 lines
21 KiB
TypeScript
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 (
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<button className="shrink-0" onClick={() => openProfile(pubkey)}>
|
|
{profile?.picture ? (
|
|
<img src={profile.picture} alt={`${name}'s avatar`} className="w-9 h-9 rounded-sm object-cover hover:opacity-80 transition-opacity"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
|
) : (
|
|
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-sm">
|
|
{name.charAt(0).toUpperCase()}
|
|
</div>
|
|
)}
|
|
</button>
|
|
<div>
|
|
<button
|
|
onClick={() => openProfile(pubkey)}
|
|
className="text-text text-[13px] font-medium hover:text-accent transition-colors block"
|
|
>
|
|
{name}
|
|
</button>
|
|
{date && <span className="text-text-dim text-[11px]">{date}</span>}
|
|
{readingTime && <span className="text-text-dim text-[11px]"> · {readingTime} min read</span>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main view ─────────────────────────────────────────────────────────────────
|
|
|
|
export function ArticleView() {
|
|
const { pendingArticleNaddr, pendingArticleEvent, goBack } = useUIStore();
|
|
const canSign = useCanSign();
|
|
|
|
const [event, setEvent] = useState<NDKEvent | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null);
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
const [progress, setProgress] = useState(0);
|
|
const [headings, setHeadings] = useState<TocHeading[]>([]);
|
|
const [activeId, setActiveId] = useState("");
|
|
const [lightbox, setLightbox] = useState<{ images: string[]; index: number } | null>(null);
|
|
|
|
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
const target = e.target as HTMLElement;
|
|
if (!(target instanceof HTMLImageElement)) return;
|
|
const root = contentRef.current;
|
|
if (!root) return;
|
|
const imgs = Array.from(root.querySelectorAll<HTMLImageElement>("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 (
|
|
<div className="h-full flex flex-col">
|
|
{/* Header */}
|
|
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
|
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
|
← Back
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{event && canSign && (
|
|
<button
|
|
onClick={handleBookmark}
|
|
className={`text-[11px] px-3 py-1 border transition-colors ${
|
|
bookmarked
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
title={bookmarked ? "Remove bookmark" : "Bookmark article"}
|
|
>
|
|
{bookmarked ? "▪ Saved" : "▫ Save"}
|
|
</button>
|
|
)}
|
|
{event && canSign && (
|
|
<button
|
|
onClick={() => setShowZap(true)}
|
|
className="text-[11px] px-3 py-1 border border-border text-zap hover:border-zap/40 hover:bg-zap/5 transition-colors"
|
|
>
|
|
⚡ zap {authorName}
|
|
</button>
|
|
)}
|
|
{event && canSign && (
|
|
<button
|
|
onClick={handleRepost}
|
|
disabled={reposted}
|
|
className={`text-[11px] px-3 py-1 border transition-colors ${
|
|
reposted
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
>
|
|
{reposted ? "Reposted" : "Repost"}
|
|
</button>
|
|
)}
|
|
{event && canSign && (
|
|
<button
|
|
onClick={() => setShowComment(!showComment)}
|
|
className={`text-[11px] px-3 py-1 border transition-colors ${
|
|
showComment
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
>
|
|
Comment
|
|
</button>
|
|
)}
|
|
{naddr && (
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(`nostr:${naddr}`)}
|
|
className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
|
title="Copy nostr: link"
|
|
>
|
|
Copy link
|
|
</button>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{/* Comment box */}
|
|
{showComment && event && (
|
|
<div className="border-b border-border px-4 py-3 shrink-0">
|
|
<textarea
|
|
value={commentText}
|
|
onChange={(e) => { setCommentText(e.target.value); autoResize(e); }}
|
|
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleComment(); } }}
|
|
placeholder="Write a comment…"
|
|
rows={3}
|
|
className="w-full bg-bg-raised border border-border rounded-sm px-3 py-2 text-[12px] text-text placeholder:text-text-dim resize-none focus:outline-none focus:border-accent leading-relaxed"
|
|
autoFocus
|
|
/>
|
|
<div className="flex justify-between items-center mt-2">
|
|
<span className="text-text-dim text-[10px]">Shift+Enter for new line</span>
|
|
<button
|
|
onClick={handleComment}
|
|
disabled={!commentText.trim()}
|
|
className="px-3 py-1.5 bg-accent/10 text-accent text-[12px] rounded-sm hover:bg-accent/20 transition-colors disabled:opacity-40"
|
|
>
|
|
Post
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Body */}
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto">
|
|
{/* Reading progress bar */}
|
|
{event && (
|
|
<div className="sticky top-0 z-10 h-0.5 bg-transparent">
|
|
<div
|
|
className="h-full bg-accent transition-[width] duration-150 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{loading && (
|
|
<div className="px-8 py-16 text-text-dim text-[12px] text-center">Loading article…</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="px-8 py-16 text-center space-y-3">
|
|
<p className="text-danger text-[12px]">{error}</p>
|
|
<a
|
|
href={`https://njump.me/${naddr}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-accent text-[11px] hover:text-accent-hover transition-colors"
|
|
>
|
|
Try opening on njump.me ↗
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
{event && (
|
|
<div className="flex">
|
|
<article className="flex-1 max-w-2xl mx-auto px-6 py-8">
|
|
{/* Cover image */}
|
|
{image && (
|
|
<div className="mb-6 -mx-2">
|
|
<img
|
|
src={image}
|
|
alt={`Cover image for ${title || "article"}`}
|
|
className="w-full aspect-video object-cover rounded-sm"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<h1 className="text-text text-2xl font-bold leading-tight mb-3 tracking-tight">
|
|
{title || "Untitled"}
|
|
</h1>
|
|
|
|
{/* Summary */}
|
|
{summary && (
|
|
<p className="text-text-muted text-[14px] leading-relaxed mb-4 italic border-l-2 border-border pl-3">
|
|
{summary}
|
|
</p>
|
|
)}
|
|
|
|
{/* Author + date + reading time */}
|
|
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} readingTime={readingTime} />
|
|
|
|
{/* Tags */}
|
|
{articleTags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 mb-6">
|
|
{articleTags.map((tag) => (
|
|
<span key={tag} className="px-2 py-0.5 text-[10px] border border-border text-text-dim">
|
|
#{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div
|
|
ref={contentRef}
|
|
className="prose-article"
|
|
onClick={handleContentClick}
|
|
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
|
/>
|
|
{lightbox && (
|
|
<ImageLightbox
|
|
images={lightbox.images}
|
|
index={lightbox.index}
|
|
onClose={() => setLightbox(null)}
|
|
onNavigate={(i) => setLightbox({ ...lightbox, index: i })}
|
|
/>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="mt-10 pt-6 border-t border-border flex items-center justify-between">
|
|
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
|
|
← Back
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{canSign && (
|
|
<button
|
|
onClick={handleReaction}
|
|
disabled={reacted}
|
|
className={`text-[11px] px-3 py-1.5 border transition-colors disabled:cursor-not-allowed ${
|
|
reacted
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
>
|
|
{reacted ? "♥ Liked" : "♡ Like"}
|
|
</button>
|
|
)}
|
|
{canSign && (
|
|
<button
|
|
onClick={handleBookmark}
|
|
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
|
bookmarked
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
>
|
|
{bookmarked ? "▪ Saved" : "▫ Save"}
|
|
</button>
|
|
)}
|
|
{canSign && (
|
|
<button
|
|
onClick={handleRepost}
|
|
disabled={reposted}
|
|
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
|
reposted
|
|
? "border-accent/40 text-accent"
|
|
: "border-border text-text-muted hover:text-accent hover:border-accent/40"
|
|
}`}
|
|
>
|
|
{reposted ? "Reposted" : "Repost"}
|
|
</button>
|
|
)}
|
|
{canSign && (
|
|
<button
|
|
onClick={() => { setShowComment(true); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }}
|
|
className="text-[11px] px-3 py-1.5 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
|
>
|
|
Comment
|
|
</button>
|
|
)}
|
|
{canSign && (
|
|
<button
|
|
onClick={() => setShowZap(true)}
|
|
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-zap-text transition-colors"
|
|
>
|
|
⚡ Zap {authorName}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
{/* Table of Contents — right margin on wide screens */}
|
|
{headings.length >= 2 && (
|
|
<nav className="hidden xl:block w-44 shrink-0 py-8 pr-4">
|
|
<div className="sticky top-4 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
|
<p className="text-text-dim text-[10px] uppercase tracking-wider mb-3">Contents</p>
|
|
{headings.map(({ id, text, level }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => scrollToHeading(id)}
|
|
className={`block text-left w-full py-1 text-[11px] leading-snug transition-colors truncate ${
|
|
level === 3 ? "pl-3" : ""
|
|
} ${
|
|
activeId === id
|
|
? "text-accent"
|
|
: "text-text-dim hover:text-text-muted"
|
|
}`}
|
|
title={text}
|
|
>
|
|
{text}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</nav>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{showZap && event && (
|
|
<ZapModal
|
|
target={{ type: "profile", pubkey: authorPubkey }}
|
|
recipientName={authorName}
|
|
onClose={() => setShowZap(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|