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:
Jure
2026-03-10 20:42:13 +01:00
parent f0cb9a97bb
commit ab7af96c45
9 changed files with 318 additions and 6 deletions
+31 -2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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 />}
+220
View 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>
);
}
+9 -2
View File
@@ -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;
}
+18
View File
@@ -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;
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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,