mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 04:39:12 -07:00
Add long-form article reader (Phase 1 #1, NIP-23)
- fetchArticle(naddr): fetches kind 30023 by d-tag + author pubkey - fetchAuthorArticles(pubkey): for future profile articles tab - ArticleView: cover image, title, italic summary, author row (avatar + name + date), tag pills, full markdown body (DOMPurify sanitized), zap author button in header and footer, copy nostr: link, njump.me fallback on fetch error - prose-article CSS: reader-optimised typography (15px base, 1.8 line height, h2 border, styled blockquote/code/pre/links/images) - NoteContent: naddr1 clicks for kind 30023 now open ArticleView instead of falling through to njump.me - openArticle(naddr) added to UI store with previousView tracking Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
220
src/components/article/ArticleView.tsx
Normal file
220
src/components/article/ArticleView.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { fetchArticle } from "../../lib/nostr";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { ZapModal } from "../zap/ZapModal";
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
|
||||
function renderMarkdown(md: string): string {
|
||||
const html = marked(md, { breaks: true }) as string;
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
|
||||
// ── Author row ────────────────────────────────────────────────────────────────
|
||||
|
||||
function AuthorRow({ pubkey, publishedAt }: { pubkey: string; publishedAt: number | null }) {
|
||||
const { openProfile } = useUIStore();
|
||||
const profile = useProfile(pubkey);
|
||||
const name = profile?.displayName || profile?.name || 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="" 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>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main view ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ArticleView() {
|
||||
const { pendingArticleNaddr, goBack } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
|
||||
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 naddr = pendingArticleNaddr ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!naddr) { setLoading(false); 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]);
|
||||
|
||||
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 = authorProfile?.displayName || authorProfile?.name || authorPubkey.slice(0, 12) + "…";
|
||||
|
||||
const bodyHtml = event?.content ? renderMarkdown(event.content) : "";
|
||||
|
||||
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 && loggedIn && (
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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 && (
|
||||
<article className="max-w-2xl mx-auto px-6 py-8">
|
||||
{/* Cover image */}
|
||||
{image && (
|
||||
<div className="mb-6 -mx-2">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full max-h-72 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 */}
|
||||
<AuthorRow pubkey={authorPubkey} publishedAt={publishedAt} />
|
||||
|
||||
{/* 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
|
||||
className="prose-article"
|
||||
dangerouslySetInnerHTML={{ __html: bodyHtml }}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
{loggedIn && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] px-4 py-2 bg-zap hover:bg-zap/90 text-white transition-colors"
|
||||
>
|
||||
⚡ Zap {authorName}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showZap && event && (
|
||||
<ZapModal
|
||||
target={{ type: "profile", pubkey: authorPubkey }}
|
||||
recipientName={authorName}
|
||||
onClose={() => setShowZap(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ function tryHandleUrlInternally(url: string): boolean {
|
||||
function tryOpenNostrEntity(raw: string): boolean {
|
||||
try {
|
||||
const decoded = nip19.decode(raw);
|
||||
const { openProfile } = useUIStore.getState();
|
||||
const { openProfile, openArticle } = useUIStore.getState();
|
||||
if (decoded.type === "npub") {
|
||||
openProfile(decoded.data as string);
|
||||
return true;
|
||||
@@ -143,7 +143,14 @@ function tryOpenNostrEntity(raw: string): boolean {
|
||||
openProfile((decoded.data as { pubkey: string }).pubkey);
|
||||
return true;
|
||||
}
|
||||
// note / nevent / naddr — no internal reader yet, fall through to njump.me
|
||||
if (decoded.type === "naddr") {
|
||||
const { kind } = decoded.data as { kind: number; pubkey: string; identifier: string };
|
||||
if (kind === 30023) {
|
||||
openArticle(raw);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// note / nevent / other naddr kinds — fall through to njump.me
|
||||
} catch { /* invalid entity */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user