mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 |
58
CHANGELOG.md
58
CHANGELOG.md
@@ -7,6 +7,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.21] - 2025-10-16
|
||||
|
||||
### Added
|
||||
|
||||
- Reading position sync across devices using Nostr Kind 30078 (NIP-78)
|
||||
- Automatically saves and syncs reading position as you scroll
|
||||
- Visual reading progress indicator on article cards
|
||||
- Reading progress shown in Explore and Bookmarks sidebar
|
||||
- Auto-scroll to last reading position setting (configurable in Settings)
|
||||
- Reading position displayed as colored progress bar on cards
|
||||
- Reading progress filters for organizing articles
|
||||
- Filter by reading state: Unopened, Started (0-10%), Reading (11-94%), Completed (95-100% or marked as read)
|
||||
- Filter icons colored when active (blue for most, green for completed)
|
||||
- URL routing support for reading progress filters
|
||||
- Reading progress filters available in Archive tab and bookmarks sidebar
|
||||
- Reads and Links tabs on `/me` page
|
||||
- Reads tab shows nostr-native articles with reading progress
|
||||
- Links tab shows external URLs with reading progress
|
||||
- Both tabs populate instantly from bookmarks for fast loading
|
||||
- Lazy loading for improved performance
|
||||
- Auto-mark as read at 100% reading progress
|
||||
- Articles automatically marked as read when scrolled to end
|
||||
- Marked-as-read articles treated as 100% progress
|
||||
- Fancy checkmark animation on Mark as Read button
|
||||
- Click-to-open article navigation on highlights
|
||||
- Clicking highlights in Explore and Me pages opens the source article
|
||||
- Automatically scrolls to highlighted text position
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed Archive to Reads with expanded functionality
|
||||
- Merged 'Completed' and 'Marked as Read' filters into one unified filter
|
||||
- Simplified filter icon colors to blue (except green for completed)
|
||||
- Started reading progress state (0-10%) uses neutral text color
|
||||
- Replace spinners with skeleton placeholders during refresh in Archive/Reads/Links tabs
|
||||
- Removed unused IEventStore import in ContentPanel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position calculation now accurately reaches 100%
|
||||
- Reading position filters work correctly in bookmarks sidebar
|
||||
- Filter out reads without timestamps or 'Untitled' items
|
||||
- Show skeleton placeholders correctly during initial tab load
|
||||
- External URLs in Reads tab only shown if they have reading progress
|
||||
- Reading progress merges even when timestamp is older than bookmark
|
||||
- Resolved all linter errors and TypeScript type issues
|
||||
|
||||
### Refactored
|
||||
|
||||
- Renamed ArchiveFilters component to ReadingProgressFilters
|
||||
- Extracted shared utilities from readsFromBookmarks for DRY code
|
||||
- Use setState callback pattern for background enrichment
|
||||
- Use naddr format for article IDs to match reading positions
|
||||
- Extract article titles, images, summaries from bookmark tags using applesauce helpers
|
||||
|
||||
## [0.6.20] - 2025-10-15
|
||||
|
||||
### Added
|
||||
@@ -1641,7 +1696,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.21...HEAD
|
||||
[0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21
|
||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||
|
||||
282
api/article-og.ts
Normal file
282
api/article-og.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent, Filter } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticleSummary } = Helpers
|
||||
|
||||
// Relay configuration (from src/config/relays.ts)
|
||||
const RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net'
|
||||
]
|
||||
|
||||
type CacheEntry = {
|
||||
html: string
|
||||
expires: number
|
||||
}
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function setCacheHeaders(res: VercelResponse, maxAge: number = 86400): void {
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=604800`)
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
}
|
||||
|
||||
interface ArticleMetadata {
|
||||
title: string
|
||||
summary: string
|
||||
image: string
|
||||
author: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
async function fetchEventsFromRelays(
|
||||
relayPool: RelayPool,
|
||||
relayUrls: string[],
|
||||
filter: Filter,
|
||||
timeoutMs: number
|
||||
): Promise<NostrEvent[]> {
|
||||
const events: NostrEvent[] = []
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(), timeoutMs)
|
||||
|
||||
relayPool.req(relayUrls, filter).subscribe({
|
||||
next: (msg) => {
|
||||
if (msg.type === 'EVENT') {
|
||||
events.push(msg.event)
|
||||
}
|
||||
},
|
||||
error: () => resolve(),
|
||||
complete: () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by created_at and return most recent first
|
||||
return events.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
async function fetchArticleMetadata(naddr: string): Promise<ArticleMetadata | null> {
|
||||
const relayPool = new RelayPool()
|
||||
|
||||
try {
|
||||
// Decode naddr
|
||||
const decoded = nip19.decode(naddr)
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Connect to relays
|
||||
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||
relayUrls.forEach(url => relayPool.open(url))
|
||||
|
||||
// Fetch article and profile in parallel
|
||||
const [articleEvents, profileEvents] = await Promise.all([
|
||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier || '']
|
||||
}, 5000),
|
||||
fetchEventsFromRelays(relayPool, relayUrls, {
|
||||
kinds: [0],
|
||||
authors: [pointer.pubkey]
|
||||
}, 3000)
|
||||
])
|
||||
|
||||
if (articleEvents.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const article = articleEvents[0]
|
||||
|
||||
// Extract article metadata
|
||||
const title = getArticleTitle(article) || 'Untitled Article'
|
||||
const summary = getArticleSummary(article) || 'Read this article on Boris'
|
||||
const image = getArticleImage(article) || '/boris-social-1200.png'
|
||||
|
||||
// Extract author name from profile
|
||||
let authorName = pointer.pubkey.slice(0, 8) + '...'
|
||||
if (profileEvents.length > 0) {
|
||||
try {
|
||||
const profileData = JSON.parse(profileEvents[0].content)
|
||||
authorName = profileData.display_name || profileData.name || authorName
|
||||
} catch {
|
||||
// Use fallback
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
summary,
|
||||
image,
|
||||
author: authorName,
|
||||
published: article.created_at
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch article metadata:', err)
|
||||
return null
|
||||
} finally {
|
||||
relayPool.close()
|
||||
}
|
||||
}
|
||||
|
||||
function generateHtml(naddr: string, meta: ArticleMetadata | null): string {
|
||||
const baseUrl = 'https://read.withboris.com'
|
||||
const articleUrl = `${baseUrl}/a/${naddr}`
|
||||
|
||||
const title = meta?.title || 'Boris – Nostr Bookmarks'
|
||||
const description = meta?.summary || 'Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.'
|
||||
const image = meta?.image?.startsWith('http') ? meta.image : `${baseUrl}${meta?.image || '/boris-social-1200.png'}`
|
||||
const author = meta?.author || 'Boris'
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<meta name="description" content="${escapeHtml(description)}" />
|
||||
<link rel="canonical" href="${articleUrl}" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:url" content="${articleUrl}" />
|
||||
<meta property="og:title" content="${escapeHtml(title)}" />
|
||||
<meta property="og:description" content="${escapeHtml(description)}" />
|
||||
<meta property="og:image" content="${escapeHtml(image)}" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
${meta?.published ? `<meta property="article:published_time" content="${new Date(meta.published * 1000).toISOString()}" />` : ''}
|
||||
<meta property="article:author" content="${escapeHtml(author)}" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="${articleUrl}" />
|
||||
<meta name="twitter:title" content="${escapeHtml(title)}" />
|
||||
<meta name="twitter:description" content="${escapeHtml(description)}" />
|
||||
<meta name="twitter:image" content="${escapeHtml(image)}" />
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<p>Redirecting to <a href="/">Boris</a>...</p>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function isCrawler(userAgent: string | undefined): boolean {
|
||||
if (!userAgent) return false
|
||||
const crawlers = [
|
||||
'bot', 'crawl', 'spider', 'slurp', 'facebook', 'twitter', 'linkedin',
|
||||
'whatsapp', 'telegram', 'slack', 'discord', 'preview'
|
||||
]
|
||||
const ua = userAgent.toLowerCase()
|
||||
return crawlers.some(crawler => ua.includes(crawler))
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const naddr = (req.query.naddr as string | undefined)?.trim()
|
||||
|
||||
if (!naddr) {
|
||||
return res.status(400).json({ error: 'Missing naddr parameter' })
|
||||
}
|
||||
|
||||
const userAgent = req.headers['user-agent'] as string | undefined
|
||||
const isCrawlerRequest = isCrawler(userAgent)
|
||||
|
||||
// If it's a regular browser (not a bot), serve HTML that loads SPA
|
||||
// Use history.replaceState to set the URL before the SPA boots
|
||||
if (!isCrawlerRequest) {
|
||||
const articlePath = `/a/${naddr}`
|
||||
// Serve a minimal HTML that sets up the URL and loads the SPA
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Boris - Loading Article...</title>
|
||||
<script>
|
||||
// Set the URL to the article path before SPA loads
|
||||
if (window.location.pathname !== '${articlePath}') {
|
||||
history.replaceState(null, '', '${articlePath}');
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Redirect to index.html which will load the SPA
|
||||
// The history state is already set, so SPA will see the correct URL
|
||||
window.location.replace('/');
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
|
||||
// Check cache for bots/crawlers
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(naddr)
|
||||
if (cached && cached.expires > now) {
|
||||
setCacheHeaders(res)
|
||||
return res.status(200).send(cached.html)
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch metadata
|
||||
const meta = await fetchArticleMetadata(naddr)
|
||||
|
||||
// Generate HTML
|
||||
const html = generateHtml(naddr, meta)
|
||||
|
||||
// Cache the result
|
||||
memoryCache.set(naddr, { html, expires: now + WEEK_MS })
|
||||
|
||||
// Send response
|
||||
setCacheHeaders(res)
|
||||
return res.status(200).send(html)
|
||||
} catch (err) {
|
||||
console.error('Error generating article OG HTML:', err)
|
||||
|
||||
// Fallback to basic HTML with SPA boot
|
||||
const html = generateHtml(naddr, null)
|
||||
setCacheHeaders(res, 3600)
|
||||
return res.status(200).send(html)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
@@ -25,6 +26,7 @@
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta name="twitter:image" content="https://read.withboris.com/boris-social-1200.png" />
|
||||
|
||||
<!-- Default to system theme until settings load from Nostr -->
|
||||
<script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.21",
|
||||
"version": "0.6.22",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
BIN
public/boris-social-1200.png
Normal file
BIN
public/boris-social-1200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 819 KiB |
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/a/:naddr",
|
||||
"destination": "/api/article-og?naddr=:naddr"
|
||||
},
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
|
||||
Reference in New Issue
Block a user