diff --git a/package-lock.json b/package-lock.json
index 563be69..e42e939 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index a521d9a..d781fc8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.tsx b/src/App.tsx
index 276cd73..1757adf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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" && }
{currentView === "thread" && }
{currentView === "article-editor" && }
+ {currentView === "article" && }
{currentView === "about" && }
{currentView === "zaps" && }
{currentView === "dm" && }
diff --git a/src/components/article/ArticleView.tsx b/src/components/article/ArticleView.tsx
new file mode 100644
index 0000000..7fc971f
--- /dev/null
+++ b/src/components/article/ArticleView.tsx
@@ -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 (
+
+
+
+
+ {date && {date}}
+
+
+ );
+}
+
+// ── Main view ─────────────────────────────────────────────────────────────────
+
+export function ArticleView() {
+ const { pendingArticleNaddr, goBack } = useUIStore();
+ const { loggedIn } = useUserStore();
+
+ const [event, setEvent] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+ {/* Header */}
+
+
+
+ {event && loggedIn && (
+
+ )}
+ {naddr && (
+
+ )}
+
+
+
+ {/* Body */}
+
+ {loading && (
+
Loading article…
+ )}
+
+ {error && (
+
+ )}
+
+ {event && (
+
+ {/* Cover image */}
+ {image && (
+
+

{ (e.target as HTMLImageElement).style.display = "none"; }}
+ />
+
+ )}
+
+ {/* Title */}
+
+ {title || "Untitled"}
+
+
+ {/* Summary */}
+ {summary && (
+
+ {summary}
+
+ )}
+
+ {/* Author + date */}
+
+
+ {/* Tags */}
+ {articleTags.length > 0 && (
+
+ {articleTags.map((tag) => (
+
+ #{tag}
+
+ ))}
+
+ )}
+
+ {/* Content */}
+
+
+ {/* Footer */}
+
+
+ {loggedIn && (
+
+ )}
+
+
+ )}
+
+
+ {showZap && event && (
+
setShowZap(false)}
+ />
+ )}
+
+ );
+}
diff --git a/src/components/feed/NoteContent.tsx b/src/components/feed/NoteContent.tsx
index a33acc9..1b57fb5 100644
--- a/src/components/feed/NoteContent.tsx
+++ b/src/components/feed/NoteContent.tsx
@@ -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;
}
diff --git a/src/index.css b/src/index.css
index 5f55f1d..e5539f7 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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;
diff --git a/src/lib/nostr/client.ts b/src/lib/nostr/client.ts
index 5c4351a..b678379 100644
--- a/src/lib/nostr/client.ts
+++ b/src/lib/nostr/client.ts
@@ -365,6 +365,36 @@ export async function decryptDM(event: NDKEvent, myPubkey: string): Promise {
+ 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 {
+ 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 {
const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit };
diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts
index ea38f5b..cd6559f 100644
--- a/src/lib/nostr/index.ts
+++ b/src/lib/nostr/index.ts
@@ -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";
diff --git a/src/stores/ui.ts b/src/stores/ui.ts
index 7efc309..881dcb8 100644
--- a/src/stores/ui.ts
+++ b/src/stores/ui.ts
@@ -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((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,