mirror of
https://github.com/hoornet/vega.git
synced 2026-06-18 02:39:43 -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:
Generated
+31
-2
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "wrystr",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wrystr",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"dompurify": "^3.3.2",
|
||||
"marked": "^17.0.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -1912,6 +1914,15 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1963,6 +1974,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
@@ -2210,6 +2227,18 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"dompurify": "^3.3.2",
|
||||
"marked": "^17.0.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingsView } from "./components/shared/SettingsView";
|
||||
import { ProfileView } from "./components/profile/ProfileView";
|
||||
import { ThreadView } from "./components/thread/ThreadView";
|
||||
import { ArticleEditor } from "./components/article/ArticleEditor";
|
||||
import { ArticleView } from "./components/article/ArticleView";
|
||||
import { OnboardingFlow } from "./components/onboarding/OnboardingFlow";
|
||||
import { AboutView } from "./components/shared/AboutView";
|
||||
import { ZapHistoryView } from "./components/zap/ZapHistoryView";
|
||||
@@ -34,6 +35,7 @@ function App() {
|
||||
{currentView === "profile" && <ProfileView />}
|
||||
{currentView === "thread" && <ThreadView />}
|
||||
{currentView === "article-editor" && <ArticleEditor />}
|
||||
{currentView === "article" && <ArticleView />}
|
||||
{currentView === "about" && <AboutView />}
|
||||
{currentView === "zaps" && <ZapHistoryView />}
|
||||
{currentView === "dm" && <DMView />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -66,6 +66,24 @@ body {
|
||||
.article-preview strong { color: var(--color-text); font-weight: 600; }
|
||||
.article-preview img { max-width: 100%; border-radius: 2px; margin: 0.5em 0; }
|
||||
|
||||
/* Article reader — slightly larger type than the editor preview */
|
||||
.prose-article { -webkit-user-select: text; user-select: text; color: var(--color-text); font-size: 15px; line-height: 1.8; }
|
||||
.prose-article h1 { font-size: 1.7em; font-weight: bold; margin: 1.4em 0 0.5em; }
|
||||
.prose-article h2 { font-size: 1.35em; font-weight: bold; margin: 1.4em 0 0.5em; border-bottom: 1px solid var(--color-border); padding-bottom: 0.3em; }
|
||||
.prose-article h3 { font-size: 1.15em; font-weight: bold; margin: 1.2em 0 0.4em; }
|
||||
.prose-article p { margin: 0.9em 0; }
|
||||
.prose-article ul { list-style: disc; padding-left: 1.6em; margin: 0.9em 0; }
|
||||
.prose-article ol { list-style: decimal; padding-left: 1.6em; margin: 0.9em 0; }
|
||||
.prose-article li { margin: 0.35em 0; }
|
||||
.prose-article blockquote { border-left: 3px solid var(--color-accent); padding-left: 1.1em; color: var(--color-text-muted); margin: 1.2em 0; font-style: italic; }
|
||||
.prose-article code { font-family: var(--font-mono); background: var(--color-bg-raised); padding: 0.15em 0.4em; font-size: 0.88em; border-radius: 2px; }
|
||||
.prose-article pre { background: var(--color-bg-raised); border: 1px solid var(--color-border); padding: 1.1em; overflow-x: auto; margin: 1.2em 0; border-radius: 2px; }
|
||||
.prose-article pre code { background: none; padding: 0; }
|
||||
.prose-article a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose-article hr { border: none; border-top: 1px solid var(--color-border); margin: 2em 0; }
|
||||
.prose-article strong { color: var(--color-text); font-weight: 600; }
|
||||
.prose-article img { max-width: 100%; border-radius: 2px; margin: 1em 0; }
|
||||
|
||||
/* Scrollbar — thin, minimal */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -365,6 +365,36 @@ export async function decryptDM(event: NDKEvent, myPubkey: string): Promise<stri
|
||||
return instance.signer.decrypt(otherUser, event.content, "nip04");
|
||||
}
|
||||
|
||||
export async function fetchArticle(naddr: string): Promise<NDKEvent | null> {
|
||||
const instance = getNDK();
|
||||
try {
|
||||
const decoded = nip19.decode(naddr);
|
||||
if (decoded.type !== "naddr") return null;
|
||||
const { identifier, pubkey, kind } = decoded.data;
|
||||
const filter: NDKFilter = {
|
||||
kinds: [kind as NDKKind],
|
||||
authors: [pubkey],
|
||||
"#d": [identifier],
|
||||
limit: 1,
|
||||
};
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
return Array.from(events)[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAuthorArticles(pubkey: string, limit = 20): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Article], authors: [pubkey], limit };
|
||||
const events = await instance.fetchEvents(filter, {
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
});
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function fetchZapsReceived(pubkey: string, limit = 50): Promise<NDKEvent[]> {
|
||||
const instance = getNDK();
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit };
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchReactionCount, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers } from "./client";
|
||||
|
||||
+5
-1
@@ -2,7 +2,7 @@ import { create } from "zustand";
|
||||
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "about" | "zaps" | "dm";
|
||||
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "about" | "zaps" | "dm";
|
||||
|
||||
interface UIState {
|
||||
currentView: View;
|
||||
@@ -12,11 +12,13 @@ interface UIState {
|
||||
previousView: View;
|
||||
pendingSearch: string | null;
|
||||
pendingDMPubkey: string | null;
|
||||
pendingArticleNaddr: string | null;
|
||||
setView: (view: View) => void;
|
||||
openProfile: (pubkey: string) => void;
|
||||
openThread: (note: NDKEvent, from: View) => void;
|
||||
openSearch: (query: string) => void;
|
||||
openDM: (pubkey: string) => void;
|
||||
openArticle: (naddr: string) => void;
|
||||
goBack: () => void;
|
||||
toggleSidebar: () => void;
|
||||
}
|
||||
@@ -31,11 +33,13 @@ export const useUIStore = create<UIState>((set, _get) => ({
|
||||
previousView: "feed",
|
||||
pendingSearch: null,
|
||||
pendingDMPubkey: null,
|
||||
pendingArticleNaddr: null,
|
||||
setView: (currentView) => set({ currentView }),
|
||||
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
|
||||
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
|
||||
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
|
||||
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
|
||||
openArticle: (naddr) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, previousView: s.currentView as View })),
|
||||
goBack: () => set((s) => ({
|
||||
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
|
||||
selectedNote: null,
|
||||
|
||||
Reference in New Issue
Block a user