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,