feat: add native support for rendering Nostr long-form articles (NIP-23)

- Install react-router-dom for routing support
- Create Article component to decode naddr and fetch/render articles
- Add /a/:naddr route to App.tsx for article viewing
- Use applesauce relay pool patterns for event fetching
- Render articles with markdown using ReactMarkdown
- Support article metadata (title, image, published date, summary)
This commit is contained in:
Gigi
2025-10-05 08:08:34 +01:00
parent 8faa2e2de0
commit 9b0c59b1ae
6 changed files with 309 additions and 16 deletions

6
dist/index.html vendored
View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Markr - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-sYF0VIKc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BNyWhz1u.css">
<title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-zNlVawf9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css">
</head>
<body>
<div id="root"></div>

55
node_modules/.package-lock.json generated vendored
View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -2299,6 +2299,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4830,6 +4839,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5070,6 +5117,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

58
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "boris",
"version": "0.1.4",
"version": "0.1.6",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
@@ -23,6 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},
@@ -2291,6 +2292,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4822,6 +4832,44 @@
"node": ">=0.10.0"
}
},
"node_modules/react-router": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.9.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
"license": "MIT",
"dependencies": {
"react-router": "7.9.3"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/reading-time-estimator": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
@@ -5062,6 +5110,12 @@
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -25,6 +25,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.3",
"reading-time-estimator": "^1.14.0",
"remark-gfm": "^4.0.1"
},

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
import { EventStore } from 'applesauce-core'
import { AccountManager } from 'applesauce-accounts'
@@ -6,6 +7,7 @@ import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders'
import Login from './components/Login'
import Bookmarks from './components/Bookmarks'
import Article from './components/Article'
function App() {
const [eventStore, setEventStore] = useState<EventStore | null>(null)
@@ -62,16 +64,23 @@ function App() {
return (
<EventStoreProvider eventStore={eventStore}>
<AccountsProvider manager={accountManager}>
<div className="app">
{!isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} />
) : (
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
/>
)}
</div>
<BrowserRouter>
<div className="app">
<Routes>
<Route path="/a/:naddr" element={<Article relayPool={relayPool} />} />
<Route path="/" element={
!isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} />
) : (
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
/>
)
} />
</Routes>
</div>
</BrowserRouter>
</AccountsProvider>
</EventStoreProvider>
)

176
src/components/Article.tsx Normal file
View File

@@ -0,0 +1,176 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { remarkNostrMentions } from 'applesauce-content/markdown'
import {
getArticleTitle,
getArticleImage,
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
import { npubEncode } from 'nostr-tools/nip19'
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
interface ArticleProps {
relayPool: RelayPool
}
const Article: React.FC<ArticleProps> = ({ relayPool }) => {
const { naddr } = useParams<{ naddr: string }>()
const [article, setArticle] = useState<NostrEvent | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!naddr) return
const fetchArticle = async () => {
setLoading(true)
setError(null)
try {
// Decode the naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.primal.net'
]
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
if (events.length > 0) {
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
setArticle(events[0])
} else {
setError('Article not found')
}
} catch (err) {
console.error('Failed to fetch article:', err)
setError(err instanceof Error ? err.message : 'Failed to load article')
} finally {
setLoading(false)
}
}
fetchArticle()
}, [naddr, relayPool])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-xl">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<div className="text-xl text-red-500">Error: {error}</div>
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
)
}
if (!article) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<div className="text-xl">Article not found</div>
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
)
}
const title = getArticleTitle(article)
const image = getArticleImage(article)
const published = getArticlePublished(article)
const summary = getArticleSummary(article)
return (
<div className="min-h-screen bg-base-100">
<div className="container mx-auto max-w-4xl px-4 py-8">
<Link to="/" className="btn btn-ghost mb-6">
Back to Home
</Link>
{image && (
<div className="w-full mb-6 rounded-lg overflow-hidden">
<img
src={image}
alt={title}
className="w-full h-auto max-h-[400px] object-cover"
/>
</div>
)}
<article>
<h1 className="text-4xl font-bold mb-4">{title}</h1>
<div className="text-sm opacity-70 mb-2">
By {npubEncode(article.pubkey).slice(0, 12)}...
</div>
{published && (
<div className="text-sm opacity-60 mb-6">
Published: {new Date(published * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
)}
{summary && (
<div className="text-lg opacity-80 italic mb-8 border-l-4 border-primary pl-4">
{summary}
</div>
)}
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
>
{article.content}
</ReactMarkdown>
</div>
</article>
</div>
</div>
)
}
export default Article