mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
84 Commits
sync-readi
...
v0.6.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69a956247 | ||
|
|
82a8dcf6eb | ||
|
|
8e19e22289 | ||
|
|
e167b57810 | ||
|
|
ba3b82e6b5 | ||
|
|
b5edfbb2c9 | ||
|
|
48048f877a | ||
|
|
bd1afc54c3 | ||
|
|
a2c4bed0f5 | ||
|
|
9bad49fe5f | ||
|
|
2aa6536496 | ||
|
|
bd6d8a0342 | ||
|
|
dc8e86bc57 | ||
|
|
32b843908e | ||
|
|
5a71480459 | ||
|
|
17455aa47b | ||
|
|
4cc32c27de | ||
|
|
99bfe209a5 | ||
|
|
0a28bfbd50 | ||
|
|
ba9fb109f6 | ||
|
|
ec9d2fcb49 | ||
|
|
f841043e03 | ||
|
|
94dc95e1f0 | ||
|
|
32a5145d8f | ||
|
|
a856e8ca26 | ||
|
|
d54306cf92 | ||
|
|
9fdb96b64e | ||
|
|
c50aa3a243 | ||
|
|
adef1a922c | ||
|
|
99df4d6761 | ||
|
|
5f6a414953 | ||
|
|
ed17a68986 | ||
|
|
bedf3daed1 | ||
|
|
2c913cf7e8 | ||
|
|
aff5bff03b | ||
|
|
e90f902f0b | ||
|
|
d763aa5f15 | ||
|
|
9d6b1f6f84 | ||
|
|
9eb2f35dbf | ||
|
|
5f33ad3ba0 | ||
|
|
3db4855532 | ||
|
|
3305be1da5 | ||
|
|
fe55e87496 | ||
|
|
f78f1a3460 | ||
|
|
e73d89739b | ||
|
|
7e2b4b46c9 | ||
|
|
fddf79e0c6 | ||
|
|
cf2098a723 | ||
|
|
5568437663 | ||
|
|
7bfd7fdf6c | ||
|
|
e6876d141f | ||
|
|
5bb81b3c22 | ||
|
|
1e8e58fa05 | ||
|
|
f44e36e4bf | ||
|
|
11c7564f8c | ||
|
|
a064376bd8 | ||
|
|
292e8e9bda | ||
|
|
951a3699ca | ||
|
|
860ec70b1c | ||
|
|
2b69c72939 | ||
|
|
b98d774cbf | ||
|
|
8972571a18 | ||
|
|
ab5d5dca58 | ||
|
|
e383356af1 | ||
|
|
165d10c49b | ||
|
|
e0869c436b | ||
|
|
95432fc276 | ||
|
|
1982d25fa8 | ||
|
|
2fc64b6028 | ||
|
|
6e8686a49d | ||
|
|
fd5ce80a06 | ||
|
|
ac4185e2cc | ||
|
|
9217077283 | ||
|
|
b7c14b5c7c | ||
|
|
9b3cc41770 | ||
|
|
4c4bd2214c | ||
|
|
93c31650f4 | ||
|
|
7f0d99fc29 | ||
|
|
eb6dbe1644 | ||
|
|
474da25f77 | ||
|
|
02eaa1c8f8 | ||
|
|
8800791723 | ||
|
|
6758b9678b | ||
|
|
85649ae283 |
83
CHANGELOG.md
83
CHANGELOG.md
@@ -7,6 +7,86 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.22] - 2025-10-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Dynamic OpenGraph and Twitter Card meta tags for article deep-links
|
||||||
|
- Social media platforms display article title, author, cover image, and summary when sharing `/a/{naddr}` links
|
||||||
|
- Serverless endpoint fetches article metadata from Nostr relays (kind:30023) and author profiles (kind:0)
|
||||||
|
- User-agent detection serves appropriate content to crawlers vs browsers
|
||||||
|
- Falls back to default social preview image when articles have no cover image
|
||||||
|
- Social preview image for homepage and article links
|
||||||
|
- Added `boris-social-1200.png` as default OpenGraph image (1200x630)
|
||||||
|
- Homepage now includes social preview image in meta tags
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Article deep-links now properly preserve URL when loading in browser
|
||||||
|
- Uses `history.replaceState()` to maintain correct article path
|
||||||
|
- Browser navigation works correctly on refresh and new tab opens
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Vercel rewrite configuration for article routes
|
||||||
|
- Routes `/a/:naddr` to serverless OG endpoint for dynamic meta tags
|
||||||
|
- Regular SPA routing preserved for browser navigation
|
||||||
|
|
||||||
|
## [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
|
## [0.6.20] - 2025-10-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -1641,7 +1721,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Optimize relay usage following applesauce-relay best practices
|
- Optimize relay usage following applesauce-relay best practices
|
||||||
- Use applesauce-react event models for better profile handling
|
- 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.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.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
|
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||||
|
|||||||
304
api/article-og.ts
Normal file
304
api/article-og.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// `request` emits NostrEvent objects directly
|
||||||
|
relayPool.request(relayUrls, filter).subscribe({
|
||||||
|
next: (event) => {
|
||||||
|
events.push(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
|
||||||
|
|
||||||
|
// Determine relay URLs
|
||||||
|
const relayUrls = pointer.relays && pointer.relays.length > 0 ? pointer.relays : RELAYS
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
// No explicit close needed; pool manages connections internally
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
const debugEnabled = req.query.debug === '1' || req.headers['x-boris-debug'] === '1'
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] request', JSON.stringify({
|
||||||
|
naddr,
|
||||||
|
ua: userAgent || null,
|
||||||
|
isCrawlerRequest,
|
||||||
|
path: req.url || null
|
||||||
|
}))
|
||||||
|
res.setHeader('X-Boris-Debug', '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
${debugEnabled ? `<script>console.debug('article-og', { mode: 'browser', naddr: '${naddr}', path: location.pathname, referrer: document.referrer });</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')
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'browser', naddr }))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: true }))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot', naddr, cache: false }))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
if (debugEnabled) {
|
||||||
|
console.log('[article-og] response', JSON.stringify({ mode: 'bot-fallback', naddr }))
|
||||||
|
}
|
||||||
|
return res.status(200).send(html)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<meta property="og:url" content="https://read.withboris.com/" />
|
<meta property="og:url" content="https://read.withboris.com/" />
|
||||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
<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: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" />
|
<meta property="og:site_name" content="Boris" />
|
||||||
|
|
||||||
<!-- Twitter Card -->
|
<!-- Twitter Card -->
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
<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: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 -->
|
<!-- Default to system theme until settings load from Nostr -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.6.20",
|
"version": "0.6.23",
|
||||||
"description": "A minimal nostr client for bookmark management",
|
"description": "A minimal nostr client for bookmark management",
|
||||||
"homepage": "https://read.withboris.com/",
|
"homepage": "https://read.withboris.com/",
|
||||||
"type": "module",
|
"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 |
22
src/App.tsx
22
src/App.tsx
@@ -9,6 +9,7 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import Bookmarks from './components/Bookmarks'
|
import Bookmarks from './components/Bookmarks'
|
||||||
|
import RouteDebug from './components/RouteDebug'
|
||||||
import Toast from './components/Toast'
|
import Toast from './components/Toast'
|
||||||
import { useToast } from './hooks/useToast'
|
import { useToast } from './hooks/useToast'
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||||
@@ -112,7 +113,25 @@ function AppRoutes({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/me/archive"
|
path="/me/reads"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/reads/:filter"
|
||||||
|
element={
|
||||||
|
<Bookmarks
|
||||||
|
relayPool={relayPool}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/me/links"
|
||||||
element={
|
element={
|
||||||
<Bookmarks
|
<Bookmarks
|
||||||
relayPool={relayPool}
|
relayPool={relayPool}
|
||||||
@@ -285,6 +304,7 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||||
|
<RouteDebug />
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
{toastMessage && (
|
{toastMessage && (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
import { faBookOpen, faBookmark, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
|
|
||||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||||
@@ -22,17 +21,24 @@ const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilte
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bookmark-filters">
|
<div className="bookmark-filters">
|
||||||
{filters.map(filter => (
|
{filters.map(filter => {
|
||||||
<button
|
const isActive = selectedFilter === filter.type
|
||||||
key={filter.type}
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
onClick={() => onFilterChange(filter.type)}
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
|
||||||
title={filter.label}
|
return (
|
||||||
aria-label={`Filter by ${filter.label}`}
|
<button
|
||||||
>
|
key={filter.type}
|
||||||
<FontAwesomeIcon icon={filter.icon} />
|
onClick={() => onFilterChange(filter.type)}
|
||||||
</button>
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
))}
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingP
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate progress percentage and determine color
|
// Calculate progress percentage and determine color (matching readingProgressUtils.ts logic)
|
||||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface LargeViewProps {
|
|||||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||||
articleSummary?: string
|
articleSummary?: string
|
||||||
contentTypeIcon: IconDefinition
|
contentTypeIcon: IconDefinition
|
||||||
|
readingProgress?: number // 0-1 reading progress (optional)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LargeView: React.FC<LargeViewProps> = ({
|
export const LargeView: React.FC<LargeViewProps> = ({
|
||||||
@@ -38,11 +39,22 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
getAuthorDisplayName,
|
getAuthorDisplayName,
|
||||||
handleReadNow,
|
handleReadNow,
|
||||||
articleSummary,
|
articleSummary,
|
||||||
contentTypeIcon
|
contentTypeIcon,
|
||||||
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
const cachedImage = useImageCache(previewImage || undefined)
|
const cachedImage = useImageCache(previewImage || undefined)
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
|
|
||||||
|
// Calculate progress display (matching readingProgressUtils.ts logic)
|
||||||
|
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||||
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
|
|
||||||
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
|
progressColor = '#10b981' // Green (completed)
|
||||||
|
} else if (readingProgress && readingProgress > 0 && readingProgress <= 0.10) {
|
||||||
|
progressColor = 'var(--color-text)' // Neutral text color (started)
|
||||||
|
}
|
||||||
|
|
||||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -92,6 +104,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reading progress indicator for articles - shown only if there's progress */}
|
||||||
|
{isArticle && readingProgress !== undefined && readingProgress > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '3px',
|
||||||
|
width: '100%',
|
||||||
|
background: 'var(--color-border)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
marginTop: '0.75rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
background: progressColor,
|
||||||
|
transition: 'width 0.3s ease, background 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="large-footer">
|
<div className="large-footer">
|
||||||
<span className="bookmark-type-large">
|
<span className="bookmark-type-large">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
|||||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||||
location.pathname === '/me/highlights' ? 'highlights' :
|
location.pathname === '/me/highlights' ? 'highlights' :
|
||||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||||
location.pathname === '/me/archive' ? 'archive' :
|
location.pathname.startsWith('/me/reads') ? 'reads' :
|
||||||
|
location.pathname === '/me/links' ? 'links' :
|
||||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||||
|
|
||||||
// Extract tab from profile routes
|
// Extract tab from profile routes
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { faSpinner, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } from '@fortawesome/free-solid-svg-icons'
|
import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons'
|
||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { HighlightItem } from './HighlightItem'
|
import { HighlightItem } from './HighlightItem'
|
||||||
import { fetchHighlights } from '../services/highlightService'
|
import { fetchHighlights } from '../services/highlightService'
|
||||||
import { fetchBookmarks } from '../services/bookmarkService'
|
import { fetchBookmarks } from '../services/bookmarkService'
|
||||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
import { fetchAllReads, ReadItem } from '../services/readsService'
|
||||||
|
import { fetchLinks } from '../services/linksService'
|
||||||
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
@@ -19,15 +20,18 @@ import BlogPostCard from './BlogPostCard'
|
|||||||
import { BookmarkItem } from './BookmarkItem'
|
import { BookmarkItem } from './BookmarkItem'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
import { ViewMode } from './Bookmarks'
|
import { ViewMode } from './Bookmarks'
|
||||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
import { getCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||||
import { faBooks } from '../icons/customIcons'
|
import { faBooks } from '../icons/customIcons'
|
||||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||||
import RefreshIndicator from './RefreshIndicator'
|
import RefreshIndicator from './RefreshIndicator'
|
||||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||||
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProgressFilters'
|
||||||
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||||
|
import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks'
|
||||||
|
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
interface MeProps {
|
interface MeProps {
|
||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
@@ -35,12 +39,15 @@ interface MeProps {
|
|||||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||||
|
|
||||||
|
// Valid reading progress filters
|
||||||
|
const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started', 'reading', 'completed']
|
||||||
|
|
||||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||||
const activeAccount = Hooks.useActiveAccount()
|
const activeAccount = Hooks.useActiveAccount()
|
||||||
const eventStore = Hooks.useEventStore()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { filter: urlFilter } = useParams<{ filter?: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||||
|
|
||||||
// Use provided pubkey or fall back to active account
|
// Use provided pubkey or fall back to active account
|
||||||
@@ -48,14 +55,22 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
const [reads, setReads] = useState<ReadItem[]>([])
|
||||||
|
const [, setReadsMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
|
const [links, setLinks] = useState<ReadItem[]>([])
|
||||||
|
const [, setLinksMap] = useState<Map<string, ReadItem>>(new Map())
|
||||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||||
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
|
||||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
// Initialize reading progress filter from URL param
|
||||||
|
const initialFilter = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
|
: 'all'
|
||||||
|
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(initialFilter)
|
||||||
|
|
||||||
// Update local state when prop changes
|
// Update local state when prop changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -64,131 +79,246 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
}
|
}
|
||||||
}, [propActiveTab])
|
}, [propActiveTab])
|
||||||
|
|
||||||
|
// Sync filter state with URL changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const filterFromUrl = urlFilter && VALID_FILTERS.includes(urlFilter as ReadingProgressFilterType)
|
||||||
if (!viewingPubkey) {
|
? (urlFilter as ReadingProgressFilterType)
|
||||||
setLoading(false)
|
: 'all'
|
||||||
return
|
setReadingProgressFilter(filterFromUrl)
|
||||||
}
|
}, [urlFilter])
|
||||||
|
|
||||||
|
// Handler to change reading progress filter and update URL
|
||||||
|
const handleReadingProgressFilterChange = (filter: ReadingProgressFilterType) => {
|
||||||
|
setReadingProgressFilter(filter)
|
||||||
|
if (activeTab === 'reads') {
|
||||||
|
if (filter === 'all') {
|
||||||
|
navigate('/me/reads', { replace: true })
|
||||||
|
} else {
|
||||||
|
navigate(`/me/reads/${filter}`, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab-specific loading functions
|
||||||
|
const loadHighlightsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
// Only show loading skeleton if tab hasn't been loaded yet
|
||||||
|
const hasBeenLoaded = loadedTabs.has('highlights')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userHighlights = await fetchHighlights(relayPool, viewingPubkey)
|
||||||
|
setHighlights(userHighlights)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('highlights'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load highlights:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWritingsTab = async () => {
|
||||||
|
if (!viewingPubkey) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('writings')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||||
|
setWritings(userWritings)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('writings'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load writings:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadReadingListTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('reading-list')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
// Seed from cache if available to avoid empty flash (own profile only)
|
})
|
||||||
if (isOwnProfile) {
|
|
||||||
const cached = getCachedMeData(viewingPubkey)
|
|
||||||
if (cached) {
|
|
||||||
setHighlights(cached.highlights)
|
|
||||||
setBookmarks(cached.bookmarks)
|
|
||||||
setReadArticles(cached.readArticles)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch highlights and writings (public data)
|
|
||||||
const [userHighlights, userWritings] = await Promise.all([
|
|
||||||
fetchHighlights(relayPool, viewingPubkey),
|
|
||||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
|
||||||
])
|
|
||||||
|
|
||||||
setHighlights(userHighlights)
|
|
||||||
setWritings(userWritings)
|
|
||||||
|
|
||||||
// Only fetch private data for own profile
|
|
||||||
if (isOwnProfile && activeAccount) {
|
|
||||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
|
||||||
setReadArticles(userReadArticles)
|
|
||||||
|
|
||||||
// Fetch bookmarks using callback pattern
|
|
||||||
let fetchedBookmarks: Bookmark[] = []
|
|
||||||
try {
|
|
||||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
|
||||||
fetchedBookmarks = newBookmarks
|
|
||||||
setBookmarks(newBookmarks)
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('Failed to load bookmarks:', err)
|
|
||||||
setBookmarks([])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update cache with all fetched data
|
|
||||||
setCachedMeData(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
|
||||||
} else {
|
|
||||||
setBookmarks([])
|
|
||||||
setReadArticles([])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err)
|
console.warn('Failed to load bookmarks:', err)
|
||||||
// No blocking error - user can pull-to-refresh
|
setBookmarks([])
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('reading-list'))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reading list:', err)
|
||||||
|
} finally {
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadData()
|
const loadReadsTab = async () => {
|
||||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
// Load reading positions for read articles (only for own profile)
|
const hasBeenLoaded = loadedTabs.has('reads')
|
||||||
useEffect(() => {
|
|
||||||
const loadPositions = async () => {
|
try {
|
||||||
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
console.log('🔍 [Archive] Skipping position load:', {
|
|
||||||
isOwnProfile,
|
// Ensure bookmarks are loaded
|
||||||
hasAccount: !!activeAccount,
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
hasRelayPool: !!relayPool,
|
if (bookmarks.length === 0) {
|
||||||
hasEventStore: !!eventStore,
|
try {
|
||||||
articlesCount: readArticles.length
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
})
|
fetchedBookmarks = newBookmarks
|
||||||
return
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
// Derive reads from bookmarks immediately
|
||||||
|
const initialReads = deriveReadsFromBookmarks(fetchedBookmarks)
|
||||||
|
const initialMap = new Map(initialReads.map(item => [item.id, item]))
|
||||||
|
setReadsMap(initialMap)
|
||||||
|
setReads(initialReads)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('reads'))
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
const positions = new Map<string, number>()
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
|
// Only update items that are already in our map
|
||||||
|
fetchAllReads(relayPool, viewingPubkey, fetchedBookmarks, (item) => {
|
||||||
|
console.log('📈 [Reads] Enrichment item received:', {
|
||||||
|
id: item.id.slice(0, 20) + '...',
|
||||||
|
progress: item.readingProgress,
|
||||||
|
hasProgress: item.readingProgress !== undefined && item.readingProgress > 0
|
||||||
|
})
|
||||||
|
|
||||||
// Load positions for all read articles
|
setReadsMap(prevMap => {
|
||||||
await Promise.all(
|
// Only update if item exists in our current map
|
||||||
readArticles.map(async (post) => {
|
if (!prevMap.has(item.id)) {
|
||||||
try {
|
console.log('⚠️ [Reads] Item not in map, skipping:', item.id.slice(0, 20) + '...')
|
||||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
return prevMap
|
||||||
const naddr = nip19.naddrEncode({
|
|
||||||
kind: 30023,
|
|
||||||
pubkey: post.author,
|
|
||||||
identifier: dTag
|
|
||||||
})
|
|
||||||
const articleUrl = `nostr:${naddr}`
|
|
||||||
const identifier = generateArticleIdentifier(articleUrl)
|
|
||||||
|
|
||||||
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
|
||||||
|
|
||||||
const savedPosition = await loadReadingPosition(
|
|
||||||
relayPool,
|
|
||||||
eventStore,
|
|
||||||
activeAccount.pubkey,
|
|
||||||
identifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0) {
|
|
||||||
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
|
||||||
positions.set(post.event.id, savedPosition.position)
|
|
||||||
} else {
|
|
||||||
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
const newMap = new Map(prevMap)
|
||||||
setReadingPositions(positions)
|
const merged = mergeReadItem(newMap, item)
|
||||||
|
if (merged) {
|
||||||
|
console.log('✅ [Reads] Merged progress:', item.id.slice(0, 20) + '...', item.readingProgress)
|
||||||
|
// Update reads array after map is updated
|
||||||
|
setReads(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
|
})
|
||||||
|
}).catch(err => console.warn('Failed to enrich reads:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load reads:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLinksTab = async () => {
|
||||||
|
if (!viewingPubkey || !isOwnProfile || !activeAccount) return
|
||||||
|
|
||||||
|
const hasBeenLoaded = loadedTabs.has('links')
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!hasBeenLoaded) setLoading(true)
|
||||||
|
|
||||||
|
// Ensure bookmarks are loaded
|
||||||
|
let fetchedBookmarks: Bookmark[] = bookmarks
|
||||||
|
if (bookmarks.length === 0) {
|
||||||
|
try {
|
||||||
|
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||||
|
fetchedBookmarks = newBookmarks
|
||||||
|
setBookmarks(newBookmarks)
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load bookmarks:', err)
|
||||||
|
fetchedBookmarks = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive links from bookmarks immediately
|
||||||
|
const initialLinks = deriveLinksFromBookmarks(fetchedBookmarks)
|
||||||
|
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||||
|
setLinksMap(initialMap)
|
||||||
|
setLinks(initialLinks)
|
||||||
|
setLoadedTabs(prev => new Set(prev).add('links'))
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
|
||||||
|
// Background enrichment: merge reading progress and mark-as-read
|
||||||
|
// Only update items that are already in our map
|
||||||
|
fetchLinks(relayPool, viewingPubkey, (item) => {
|
||||||
|
setLinksMap(prevMap => {
|
||||||
|
// Only update if item exists in our current map
|
||||||
|
if (!prevMap.has(item.id)) return prevMap
|
||||||
|
|
||||||
|
const newMap = new Map(prevMap)
|
||||||
|
if (mergeReadItem(newMap, item)) {
|
||||||
|
// Update links array after map is updated
|
||||||
|
setLinks(Array.from(newMap.values()))
|
||||||
|
return newMap
|
||||||
|
}
|
||||||
|
return prevMap
|
||||||
|
})
|
||||||
|
}).catch(err => console.warn('Failed to enrich links:', err))
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load links:', err)
|
||||||
|
if (!hasBeenLoaded) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load active tab data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!viewingPubkey || !activeTab) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loadPositions()
|
// Load cached data immediately if available
|
||||||
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
if (isOwnProfile) {
|
||||||
|
const cached = getCachedMeData(viewingPubkey)
|
||||||
|
if (cached) {
|
||||||
|
setHighlights(cached.highlights)
|
||||||
|
setBookmarks(cached.bookmarks)
|
||||||
|
setReads(cached.reads || [])
|
||||||
|
setLinks(cached.links || [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pull-to-refresh
|
// Load data for active tab (refresh in background if already loaded)
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'highlights':
|
||||||
|
loadHighlightsTab()
|
||||||
|
break
|
||||||
|
case 'writings':
|
||||||
|
loadWritingsTab()
|
||||||
|
break
|
||||||
|
case 'reading-list':
|
||||||
|
loadReadingListTab()
|
||||||
|
break
|
||||||
|
case 'reads':
|
||||||
|
loadReadsTab()
|
||||||
|
break
|
||||||
|
case 'links':
|
||||||
|
loadLinksTab()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTab, viewingPubkey, refreshTrigger])
|
||||||
|
|
||||||
|
|
||||||
|
// Pull-to-refresh - reload active tab without clearing state
|
||||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
|
// Just trigger refresh - loaders will merge new data
|
||||||
setRefreshTrigger(prev => prev + 1)
|
setRefreshTrigger(prev => prev + 1)
|
||||||
},
|
},
|
||||||
maximumPullLength: 240,
|
maximumPullLength: 240,
|
||||||
@@ -217,6 +347,49 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
return `/a/${naddr}`
|
return `/a/${naddr}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getReadItemUrl = (item: ReadItem) => {
|
||||||
|
if (item.type === 'article') {
|
||||||
|
// ID is already in naddr format
|
||||||
|
return `/a/${item.id}`
|
||||||
|
} else if (item.url) {
|
||||||
|
return `/r/${encodeURIComponent(item.url)}`
|
||||||
|
}
|
||||||
|
return '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertReadItemToBlogPostPreview = (item: ReadItem): BlogPostPreview => {
|
||||||
|
if (item.event) {
|
||||||
|
return {
|
||||||
|
event: item.event,
|
||||||
|
title: item.title || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || item.event.pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a mock event for external URLs
|
||||||
|
const mockEvent = {
|
||||||
|
id: item.id,
|
||||||
|
pubkey: item.author || '',
|
||||||
|
created_at: item.readingTimestamp || Math.floor(Date.now() / 1000),
|
||||||
|
kind: 1,
|
||||||
|
tags: [] as string[][],
|
||||||
|
content: item.title || item.url || 'Untitled',
|
||||||
|
sig: ''
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: mockEvent as unknown as import('nostr-tools').NostrEvent,
|
||||||
|
title: item.title || item.url || 'Untitled',
|
||||||
|
summary: item.summary,
|
||||||
|
image: item.image,
|
||||||
|
published: item.published,
|
||||||
|
author: item.author || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||||
if (bookmark && bookmark.kind === 30023) {
|
if (bookmark && bookmark.kind === 30023) {
|
||||||
// For kind:30023 articles, navigate to the article route
|
// For kind:30023 articles, navigate to the article route
|
||||||
@@ -245,29 +418,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
|
|
||||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||||
|
|
||||||
// Apply archive filter
|
// Apply reading progress filter
|
||||||
const filteredReadArticles = readArticles.filter(post => {
|
const filteredReads = filterByReadingProgress(reads, readingProgressFilter)
|
||||||
const position = readingPositions.get(post.event.id)
|
const filteredLinks = filterByReadingProgress(links, readingProgressFilter)
|
||||||
|
|
||||||
switch (archiveFilter) {
|
|
||||||
case 'to-read':
|
|
||||||
// No position or 0% progress
|
|
||||||
return !position || position === 0
|
|
||||||
case 'reading':
|
|
||||||
// Has some progress but not completed (0 < position < 1)
|
|
||||||
return position !== undefined && position > 0 && position < 0.95
|
|
||||||
case 'completed':
|
|
||||||
// 95% or more read (we consider 95%+ as completed)
|
|
||||||
return position !== undefined && position >= 0.95
|
|
||||||
case 'marked':
|
|
||||||
// Manually marked as read (in archive but no reading position data)
|
|
||||||
// These are articles that were marked via the emoji reaction
|
|
||||||
return !position || position === 0
|
|
||||||
case 'all':
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||||
@@ -276,7 +429,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Show content progressively - no blocking error screens
|
// Show content progressively - no blocking error screens
|
||||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0
|
||||||
const showSkeletons = loading && !hasData
|
const showSkeletons = loading && !hasData
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
@@ -291,9 +444,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return highlights.length === 0 ? (
|
return highlights.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No highlights yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="highlights-list me-highlights-list">
|
<div className="highlights-list me-highlights-list">
|
||||||
@@ -320,9 +473,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return allIndividualBookmarks.length === 0 ? (
|
return allIndividualBookmarks.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No bookmarks yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bookmarks-list">
|
<div className="bookmarks-list">
|
||||||
@@ -386,8 +539,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'archive':
|
case 'reads':
|
||||||
if (showSkeletons) {
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('reads')) {
|
||||||
return (
|
return (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
@@ -396,32 +550,84 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return readArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
// Show empty state if loaded but no reads
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
if (reads.length === 0 && loadedTabs.has('reads')) {
|
||||||
</div>
|
return (
|
||||||
) : (
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No articles read yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show reads with filters
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
{readArticles.length > 0 && (
|
<ReadingProgressFilters
|
||||||
<ArchiveFilters
|
selectedFilter={readingProgressFilter}
|
||||||
selectedFilter={archiveFilter}
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
onFilterChange={setArchiveFilter}
|
/>
|
||||||
/>
|
{filteredReads.length === 0 ? (
|
||||||
)}
|
|
||||||
{filteredReadArticles.length === 0 ? (
|
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
No articles match this filter.
|
No articles match this filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
{filteredReadArticles.map((post) => (
|
{filteredReads.map((item) => (
|
||||||
<BlogPostCard
|
<BlogPostCard
|
||||||
key={post.event.id}
|
key={item.id}
|
||||||
post={post}
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
href={getPostUrl(post)}
|
href={getReadItemUrl(item)}
|
||||||
readingProgress={readingPositions.get(post.event.id)}
|
readingProgress={item.readingProgress}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'links':
|
||||||
|
// Show loading skeletons only while initially loading
|
||||||
|
if (loading && !loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<BlogPostSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show empty state if loaded but no links
|
||||||
|
if (links.length === 0 && loadedTabs.has('links')) {
|
||||||
|
return (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links with reading progress yet.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show links with filters
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReadingProgressFilters
|
||||||
|
selectedFilter={readingProgressFilter}
|
||||||
|
onFilterChange={handleReadingProgressFilterChange}
|
||||||
|
/>
|
||||||
|
{filteredLinks.length === 0 ? (
|
||||||
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
|
No links match this filter.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="explore-grid">
|
||||||
|
{filteredLinks.map((item) => (
|
||||||
|
<BlogPostCard
|
||||||
|
key={item.id}
|
||||||
|
post={convertReadItemToBlogPostPreview(item)}
|
||||||
|
href={getReadItemUrl(item)}
|
||||||
|
readingProgress={item.readingProgress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -437,9 +643,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return writings.length === 0 ? (
|
return writings.length === 0 && !loading ? (
|
||||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
No articles written yet.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="explore-grid">
|
<div className="explore-grid">
|
||||||
@@ -487,12 +693,20 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
|||||||
<span className="tab-label">Bookmarks</span>
|
<span className="tab-label">Bookmarks</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
className={`me-tab ${activeTab === 'reads' ? 'active' : ''}`}
|
||||||
data-tab="archive"
|
data-tab="reads"
|
||||||
onClick={() => navigate('/me/archive')}
|
onClick={() => navigate('/me/reads')}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBooks} />
|
<FontAwesomeIcon icon={faBooks} />
|
||||||
<span className="tab-label">Archive</span>
|
<span className="tab-label">Reads</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`me-tab ${activeTab === 'links' ? 'active' : ''}`}
|
||||||
|
data-tab="links"
|
||||||
|
onClick={() => navigate('/me/links')}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faLink} />
|
||||||
|
<span className="tab-label">Links</span>
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
47
src/components/ReadingProgressFilters.tsx
Normal file
47
src/components/ReadingProgressFilters.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
|
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||||
|
import { faEnvelope, faEnvelopeOpen } from '@fortawesome/free-regular-svg-icons'
|
||||||
|
|
||||||
|
export type ReadingProgressFilterType = 'all' | 'unopened' | 'started' | 'reading' | 'completed'
|
||||||
|
|
||||||
|
interface ReadingProgressFiltersProps {
|
||||||
|
selectedFilter: ReadingProgressFilterType
|
||||||
|
onFilterChange: (filter: ReadingProgressFilterType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReadingProgressFilters: React.FC<ReadingProgressFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||||
|
const filters = [
|
||||||
|
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||||
|
{ type: 'unopened' as const, icon: faEnvelope, label: 'Unopened' },
|
||||||
|
{ type: 'started' as const, icon: faEnvelopeOpen, label: 'Started' },
|
||||||
|
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||||
|
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' }
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bookmark-filters">
|
||||||
|
{filters.map(filter => {
|
||||||
|
const isActive = selectedFilter === filter.type
|
||||||
|
// Only "completed" gets green color, everything else uses default blue
|
||||||
|
const activeStyle = isActive && filter.type === 'completed' ? { color: '#10b981' } : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filter.type}
|
||||||
|
onClick={() => onFilterChange(filter.type)}
|
||||||
|
className={`filter-btn ${isActive ? 'active' : ''}`}
|
||||||
|
title={filter.label}
|
||||||
|
aria-label={`Filter by ${filter.label}`}
|
||||||
|
style={activeStyle}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={filter.icon} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReadingProgressFilters
|
||||||
|
|
||||||
@@ -19,6 +19,21 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
}) => {
|
}) => {
|
||||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||||
|
|
||||||
|
// Determine reading state based on progress (matching readingProgressUtils.ts logic)
|
||||||
|
const progressDecimal = clampedProgress / 100
|
||||||
|
const isStarted = progressDecimal > 0 && progressDecimal <= 0.10
|
||||||
|
|
||||||
|
// Determine bar color based on state
|
||||||
|
let barColorClass = ''
|
||||||
|
let barColorStyle: string | undefined = 'var(--color-primary)' // Default blue
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
barColorClass = 'bg-green-500'
|
||||||
|
barColorStyle = undefined
|
||||||
|
} else if (isStarted) {
|
||||||
|
barColorStyle = 'var(--color-text)' // Neutral text color (matches card titles)
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||||
const leftOffset = isSidebarCollapsed
|
const leftOffset = isSidebarCollapsed
|
||||||
? 'var(--sidebar-collapsed-width)'
|
? 'var(--sidebar-collapsed-width)'
|
||||||
@@ -42,14 +57,10 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
style={{ backgroundColor: 'var(--color-border)' }}
|
style={{ backgroundColor: 'var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
className={`h-full rounded-full transition-all duration-300 relative ${barColorClass}`}
|
||||||
isComplete
|
|
||||||
? 'bg-green-500'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
style={{
|
style={{
|
||||||
width: `${clampedProgress}%`,
|
width: `${clampedProgress}%`,
|
||||||
backgroundColor: isComplete ? undefined : 'var(--color-primary)'
|
backgroundColor: barColorStyle
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||||
@@ -60,7 +71,9 @@ export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> =
|
|||||||
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||||
isComplete ? 'text-green-500' : ''
|
isComplete ? 'text-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ color: isComplete ? undefined : 'var(--color-text-muted)' }}
|
style={{
|
||||||
|
color: isComplete ? undefined : isStarted ? 'var(--color-text)' : 'var(--color-text-muted)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
30
src/components/RouteDebug.tsx
Normal file
30
src/components/RouteDebug.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation, useMatch } from 'react-router-dom'
|
||||||
|
|
||||||
|
export default function RouteDebug() {
|
||||||
|
const location = useLocation()
|
||||||
|
const matchArticle = useMatch('/a/:naddr')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('debug') !== '1') return
|
||||||
|
|
||||||
|
const info: Record<string, unknown> = {
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: location.search || null,
|
||||||
|
matchedArticleRoute: Boolean(matchArticle),
|
||||||
|
referrer: document.referrer || null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
// Unexpected during deep-link refresh tests
|
||||||
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
|
} else {
|
||||||
|
console.debug('[RouteDebug]', info)
|
||||||
|
}
|
||||||
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -167,6 +167,21 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
|||||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs opacity-60 mt-4 px-4 pb-3 select-text">
|
||||||
|
<span>Version {typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'dev'}</span>
|
||||||
|
{typeof __GIT_COMMIT__ !== 'undefined' && __GIT_COMMIT__ ? (
|
||||||
|
<span>
|
||||||
|
{' '}·
|
||||||
|
{typeof __GIT_COMMIT_URL__ !== 'undefined' && __GIT_COMMIT_URL__ ? (
|
||||||
|
<a href={__GIT_COMMIT_URL__} target="_blank" rel="noopener noreferrer">
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<code>{__GIT_COMMIT__.slice(0, 7)}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
15
src/config/kinds.ts
Normal file
15
src/config/kinds.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// Nostr event kinds used throughout the application
|
||||||
|
export const KINDS = {
|
||||||
|
Highlights: 9802, // NIP-?? user highlights
|
||||||
|
BlogPost: 30023, // NIP-23 long-form article
|
||||||
|
AppData: 30078, // NIP-78 application data (reading positions)
|
||||||
|
List: 30001, // NIP-51 list (addressable)
|
||||||
|
ListReplaceable: 30003, // NIP-51 replaceable list
|
||||||
|
ListSimple: 10003, // NIP-51 simple list
|
||||||
|
WebBookmark: 39701, // NIP-B0 web bookmark
|
||||||
|
ReactionToEvent: 7, // emoji reaction to event (used for mark-as-read)
|
||||||
|
ReactionToUrl: 17 // emoji reaction to URL (used for mark-as-read)
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KindValue = typeof KINDS[keyof typeof KINDS]
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
|||||||
import { UserSettings } from './settingsService'
|
import { UserSettings } from './settingsService'
|
||||||
import { rebroadcastEvents } from './rebroadcastService'
|
import { rebroadcastEvents } from './rebroadcastService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export const fetchBookmarks = async (
|
|||||||
|
|
||||||
const rawEvents = await queryEvents(
|
const rawEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] },
|
{ kinds: [KINDS.ListSimple, KINDS.ListReplaceable, KINDS.List, KINDS.WebBookmark], authors: [activeAccount.pubkey] },
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||||
@@ -71,7 +72,7 @@ export const fetchBookmarks = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Check specifically for Primal's "reads" list
|
// Check specifically for Primal's "reads" list
|
||||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
const primalReads = rawEvents.find(e => e.kind === KINDS.ListSimple && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||||
if (primalReads) {
|
if (primalReads) {
|
||||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
|
||||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export const fetchBlogPostsFromAuthors = async (
|
|||||||
|
|
||||||
await queryEvents(
|
await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], authors: pubkeys, limit: 100 },
|
{ kinds: [KINDS.BlogPost], authors: pubkeys, limit: 100 },
|
||||||
{
|
{
|
||||||
relayUrls,
|
relayUrls,
|
||||||
onEvent: (event: NostrEvent) => {
|
onEvent: (event: NostrEvent) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
|||||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||||
import { UserSettings } from '../settingsService'
|
import { UserSettings } from '../settingsService'
|
||||||
import { rebroadcastEvents } from '../rebroadcastService'
|
import { rebroadcastEvents } from '../rebroadcastService'
|
||||||
|
import { KINDS } from '../../config/kinds'
|
||||||
|
|
||||||
export const fetchHighlights = async (
|
export const fetchHighlights = async (
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -21,7 +22,7 @@ export const fetchHighlights = async (
|
|||||||
const seenIds = new Set<string>()
|
const seenIds = new Set<string>()
|
||||||
const local$ = localRelays.length > 0
|
const local$ = localRelays.length > 0
|
||||||
? relayPool
|
? relayPool
|
||||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
.req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
tap((event: NostrEvent) => {
|
tap((event: NostrEvent) => {
|
||||||
@@ -36,7 +37,7 @@ export const fetchHighlights = async (
|
|||||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||||
const remote$ = remoteRelays.length > 0
|
const remote$ = remoteRelays.length > 0
|
||||||
? relayPool
|
? relayPool
|
||||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
.req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] })
|
||||||
.pipe(
|
.pipe(
|
||||||
onlyEvents(),
|
onlyEvents(),
|
||||||
tap((event: NostrEvent) => {
|
tap((event: NostrEvent) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RelayPool } from 'applesauce-relay'
|
|||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { Helpers } from 'applesauce-core'
|
import { Helpers } from 'applesauce-core'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
@@ -29,8 +30,8 @@ export async function fetchReadArticles(
|
|||||||
try {
|
try {
|
||||||
// Fetch kind:7 and kind:17 reactions in parallel
|
// Fetch kind:7 and kind:17 reactions in parallel
|
||||||
const [kind7Events, kind17Events] = await Promise.all([
|
const [kind7Events, kind17Events] = await Promise.all([
|
||||||
queryEvents(relayPool, { kinds: [7], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToEvent], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
queryEvents(relayPool, { kinds: [17], authors: [userPubkey] }, { relayUrls: RELAYS })
|
queryEvents(relayPool, { kinds: [KINDS.ReactionToUrl], authors: [userPubkey] }, { relayUrls: RELAYS })
|
||||||
])
|
])
|
||||||
|
|
||||||
const readArticles: ReadArticle[] = []
|
const readArticles: ReadArticle[] = []
|
||||||
@@ -102,7 +103,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
// Filter to only nostr-native articles (kind 30023)
|
// Filter to only nostr-native articles (kind 30023)
|
||||||
const nostrArticles = readArticles.filter(
|
const nostrArticles = readArticles.filter(
|
||||||
article => article.eventKind === 30023 && article.eventId
|
article => article.eventKind === KINDS.BlogPost && article.eventId
|
||||||
)
|
)
|
||||||
|
|
||||||
if (nostrArticles.length === 0) {
|
if (nostrArticles.length === 0) {
|
||||||
@@ -114,7 +115,7 @@ export async function fetchReadArticlesWithData(
|
|||||||
|
|
||||||
const articleEvents = await queryEvents(
|
const articleEvents = await queryEvents(
|
||||||
relayPool,
|
relayPool,
|
||||||
{ kinds: [30023], ids: eventIds },
|
{ kinds: [KINDS.BlogPost], ids: eventIds },
|
||||||
{ relayUrls: RELAYS }
|
{ relayUrls: RELAYS }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
90
src/services/linksService.ts
Normal file
90
src/services/linksService.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches external URL links with reading progress from:
|
||||||
|
* - URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchLinks(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('🔗 [Links] Fetching external links for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(linksMap, item)) {
|
||||||
|
onItem(linksMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
linksMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Links] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit external items
|
||||||
|
processReadingPositions(readingPositionEvents, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit external items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, linksMap)
|
||||||
|
if (onItem) {
|
||||||
|
linksMap.forEach(item => {
|
||||||
|
if (item.type === 'external') {
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
if (hasProgress) emitItem(item)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for external URLs only with reading progress
|
||||||
|
const links = Array.from(linksMap.values())
|
||||||
|
.filter(item => {
|
||||||
|
// Only external URLs
|
||||||
|
if (item.type !== 'external') return false
|
||||||
|
|
||||||
|
// Only include if there's reading progress or marked as read
|
||||||
|
const hasProgress = (item.readingProgress && item.readingProgress > 0) || item.markedAsRead
|
||||||
|
return hasProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply common validation and sorting
|
||||||
|
const validLinks = filterValidItems(links)
|
||||||
|
const sortedLinks = sortByReadingActivity(validLinks)
|
||||||
|
|
||||||
|
console.log('✅ [Links] Processed', sortedLinks.length, 'total links')
|
||||||
|
return sortedLinks
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch links:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Highlight } from '../types/highlights'
|
import { Highlight } from '../types/highlights'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import { BlogPostPreview } from './exploreService'
|
import { BlogPostPreview } from './exploreService'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
|
||||||
export interface MeCache {
|
export interface MeCache {
|
||||||
highlights: Highlight[]
|
highlights: Highlight[]
|
||||||
bookmarks: Bookmark[]
|
bookmarks: Bookmark[]
|
||||||
readArticles: BlogPostPreview[]
|
readArticles: BlogPostPreview[]
|
||||||
|
reads?: ReadItem[]
|
||||||
|
links?: ReadItem[]
|
||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
147
src/services/readingDataProcessor.ts
Normal file
147
src/services/readingDataProcessor.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { ReadItem } from './readsService'
|
||||||
|
import { fallbackTitleFromUrl } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||||
|
|
||||||
|
interface ReadArticle {
|
||||||
|
id: string
|
||||||
|
url?: string
|
||||||
|
eventId?: string
|
||||||
|
eventKind?: number
|
||||||
|
markedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes reading position events into ReadItems
|
||||||
|
*/
|
||||||
|
export function processReadingPositions(
|
||||||
|
events: NostrEvent[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (!dTag || !dTag.startsWith(READING_POSITION_PREFIX)) continue
|
||||||
|
|
||||||
|
const identifier = dTag.replace(READING_POSITION_PREFIX, '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const positionData = JSON.parse(event.content)
|
||||||
|
const position = positionData.position
|
||||||
|
const timestamp = positionData.timestamp
|
||||||
|
|
||||||
|
let itemId: string
|
||||||
|
let itemUrl: string | undefined
|
||||||
|
let itemType: 'article' | 'external' = 'external'
|
||||||
|
|
||||||
|
// Check if it's a nostr article (naddr format)
|
||||||
|
if (identifier.startsWith('naddr1')) {
|
||||||
|
itemId = identifier
|
||||||
|
itemType = 'article'
|
||||||
|
} else {
|
||||||
|
// It's a base64url-encoded URL
|
||||||
|
try {
|
||||||
|
itemUrl = atob(identifier.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
itemId = itemUrl
|
||||||
|
itemType = 'external'
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode URL identifier:', identifier)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add or update the item
|
||||||
|
const existing = readsMap.get(itemId)
|
||||||
|
if (!existing || !existing.readingTimestamp || timestamp > existing.readingTimestamp) {
|
||||||
|
readsMap.set(itemId, {
|
||||||
|
...existing,
|
||||||
|
id: itemId,
|
||||||
|
source: 'reading-progress',
|
||||||
|
type: itemType,
|
||||||
|
url: itemUrl,
|
||||||
|
readingProgress: position,
|
||||||
|
readingTimestamp: timestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse reading position:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes marked-as-read articles into ReadItems
|
||||||
|
*/
|
||||||
|
export function processMarkedAsRead(
|
||||||
|
articles: ReadArticle[],
|
||||||
|
readsMap: Map<string, ReadItem>
|
||||||
|
): void {
|
||||||
|
for (const article of articles) {
|
||||||
|
const existing = readsMap.get(article.id)
|
||||||
|
|
||||||
|
if (article.eventId && article.eventKind === 30023) {
|
||||||
|
// Nostr article
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'article',
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
} else if (article.url) {
|
||||||
|
// External URL
|
||||||
|
readsMap.set(article.id, {
|
||||||
|
...existing,
|
||||||
|
id: article.id,
|
||||||
|
source: 'marked-as-read',
|
||||||
|
type: 'external',
|
||||||
|
url: article.url,
|
||||||
|
markedAsRead: true,
|
||||||
|
markedAt: article.markedAt,
|
||||||
|
readingTimestamp: existing?.readingTimestamp || article.markedAt
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts ReadItems by most recent reading activity
|
||||||
|
*/
|
||||||
|
export function sortByReadingActivity(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||||
|
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters out items without timestamps and enriches external items with fallback titles
|
||||||
|
*/
|
||||||
|
export function filterValidItems(items: ReadItem[]): ReadItem[] {
|
||||||
|
return items
|
||||||
|
.filter(item => {
|
||||||
|
// Only include items that have a timestamp
|
||||||
|
const hasTimestamp = (item.readingTimestamp && item.readingTimestamp > 0) ||
|
||||||
|
(item.markedAt && item.markedAt > 0)
|
||||||
|
if (!hasTimestamp) return false
|
||||||
|
|
||||||
|
// For Nostr articles, we need the event to be valid
|
||||||
|
if (item.type === 'article' && !item.event) return false
|
||||||
|
|
||||||
|
// For external URLs, we need at least a URL
|
||||||
|
if (item.type === 'external' && !item.url) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.map(item => {
|
||||||
|
// Add fallback title for external URLs without titles
|
||||||
|
if (item.type === 'external' && !item.title && item.url) {
|
||||||
|
return { ...item, title: fallbackTitleFromUrl(item.url) }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
197
src/services/readsService.ts
Normal file
197
src/services/readsService.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { RelayPool } from 'applesauce-relay'
|
||||||
|
import { NostrEvent } from 'nostr-tools'
|
||||||
|
import { Helpers } from 'applesauce-core'
|
||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { fetchReadArticles } from './libraryService'
|
||||||
|
import { queryEvents } from './dataFetch'
|
||||||
|
import { RELAYS } from '../config/relays'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { classifyBookmarkType } from '../utils/bookmarkTypeClassifier'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
import { processReadingPositions, processMarkedAsRead, filterValidItems, sortByReadingActivity } from './readingDataProcessor'
|
||||||
|
import { mergeReadItem } from '../utils/readItemMerge'
|
||||||
|
|
||||||
|
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||||
|
|
||||||
|
export interface ReadItem {
|
||||||
|
id: string // event ID or URL or coordinate
|
||||||
|
source: 'bookmark' | 'reading-progress' | 'marked-as-read'
|
||||||
|
type: 'article' | 'external' // article=kind:30023, external=URL
|
||||||
|
|
||||||
|
// Article data
|
||||||
|
event?: NostrEvent
|
||||||
|
url?: string
|
||||||
|
title?: string
|
||||||
|
summary?: string
|
||||||
|
image?: string
|
||||||
|
published?: number
|
||||||
|
author?: string
|
||||||
|
|
||||||
|
// Reading metadata
|
||||||
|
readingProgress?: number // 0-1
|
||||||
|
readingTimestamp?: number // Unix timestamp of last reading activity
|
||||||
|
markedAsRead?: boolean
|
||||||
|
markedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all reads from multiple sources:
|
||||||
|
* - Bookmarked articles (kind:30023) and article/website URLs
|
||||||
|
* - Articles/URLs with reading progress (kind:30078)
|
||||||
|
* - Manually marked as read articles/URLs (kind:7, kind:17)
|
||||||
|
*/
|
||||||
|
export async function fetchAllReads(
|
||||||
|
relayPool: RelayPool,
|
||||||
|
userPubkey: string,
|
||||||
|
bookmarks: Bookmark[],
|
||||||
|
onItem?: (item: ReadItem) => void
|
||||||
|
): Promise<ReadItem[]> {
|
||||||
|
console.log('📚 [Reads] Fetching all reads for user:', userPubkey.slice(0, 8))
|
||||||
|
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
// Helper to emit items as they're added/updated
|
||||||
|
const emitItem = (item: ReadItem) => {
|
||||||
|
if (onItem && mergeReadItem(readsMap, item)) {
|
||||||
|
onItem(readsMap.get(item.id)!)
|
||||||
|
} else if (!onItem) {
|
||||||
|
readsMap.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch all data sources in parallel
|
||||||
|
const [readingPositionEvents, markedAsReadArticles] = await Promise.all([
|
||||||
|
queryEvents(relayPool, { kinds: [KINDS.AppData], authors: [userPubkey] }, { relayUrls: RELAYS }),
|
||||||
|
fetchReadArticles(relayPool, userPubkey)
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log('📊 [Reads] Data fetched:', {
|
||||||
|
readingPositions: readingPositionEvents.length,
|
||||||
|
markedAsRead: markedAsReadArticles.length,
|
||||||
|
bookmarks: bookmarks.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Process reading positions and emit items
|
||||||
|
processReadingPositions(readingPositionEvents, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process marked-as-read and emit items
|
||||||
|
processMarkedAsRead(markedAsReadArticles, readsMap)
|
||||||
|
if (onItem) {
|
||||||
|
readsMap.forEach(item => {
|
||||||
|
if (item.type === 'article') onItem(item)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Process bookmarked articles and article/website URLs
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles
|
||||||
|
if (bookmarkType === 'article') {
|
||||||
|
// Kind:30023 nostr article
|
||||||
|
const coordinate = bookmark.id // Already in coordinate format
|
||||||
|
const existing = readsMap.get(coordinate)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: coordinate,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
readsMap.set(coordinate, item)
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fetch full event data for nostr articles
|
||||||
|
const articleCoordinates = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article' && !item.event)
|
||||||
|
.map(item => item.id)
|
||||||
|
|
||||||
|
if (articleCoordinates.length > 0) {
|
||||||
|
console.log('📖 [Reads] Fetching article events for', articleCoordinates.length, 'articles')
|
||||||
|
|
||||||
|
// Parse coordinates and fetch events
|
||||||
|
const articlesToFetch: Array<{ pubkey: string; identifier: string }> = []
|
||||||
|
|
||||||
|
for (const coord of articleCoordinates) {
|
||||||
|
try {
|
||||||
|
// Try to decode as naddr
|
||||||
|
if (coord.startsWith('naddr1')) {
|
||||||
|
const decoded = nip19.decode(coord)
|
||||||
|
if (decoded.type === 'naddr' && decoded.data.kind === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: decoded.data.pubkey,
|
||||||
|
identifier: decoded.data.identifier || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try coordinate format (kind:pubkey:identifier)
|
||||||
|
const parts = coord.split(':')
|
||||||
|
if (parts.length === 3 && parseInt(parts[0]) === KINDS.BlogPost) {
|
||||||
|
articlesToFetch.push({
|
||||||
|
pubkey: parts[1],
|
||||||
|
identifier: parts[2]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to decode article coordinate:', coord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (articlesToFetch.length > 0) {
|
||||||
|
const authors = Array.from(new Set(articlesToFetch.map(a => a.pubkey)))
|
||||||
|
const identifiers = Array.from(new Set(articlesToFetch.map(a => a.identifier)))
|
||||||
|
|
||||||
|
const events = await queryEvents(
|
||||||
|
relayPool,
|
||||||
|
{ kinds: [KINDS.BlogPost], authors, '#d': identifiers },
|
||||||
|
{ relayUrls: RELAYS }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Merge event data into ReadItems and emit
|
||||||
|
for (const event of events) {
|
||||||
|
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||||
|
const coordinate = `${KINDS.BlogPost}:${event.pubkey}:${dTag}`
|
||||||
|
|
||||||
|
const item = readsMap.get(coordinate) || readsMap.get(event.id)
|
||||||
|
if (item) {
|
||||||
|
item.event = event
|
||||||
|
item.title = getArticleTitle(event) || 'Untitled'
|
||||||
|
item.summary = getArticleSummary(event)
|
||||||
|
item.image = getArticleImage(event)
|
||||||
|
item.published = getArticlePublished(event)
|
||||||
|
item.author = event.pubkey
|
||||||
|
if (onItem) emitItem(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Filter for Nostr articles only and apply common validation/sorting
|
||||||
|
const articles = Array.from(readsMap.values())
|
||||||
|
.filter(item => item.type === 'article')
|
||||||
|
|
||||||
|
const validArticles = filterValidItems(articles)
|
||||||
|
const sortedReads = sortByReadingActivity(validArticles)
|
||||||
|
|
||||||
|
console.log('✅ [Reads] Processed', sortedReads.length, 'total reads')
|
||||||
|
return sortedReads
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch all reads:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/utils/linksFromBookmarks.ts
Normal file
69
src/utils/linksFromBookmarks.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { fallbackTitleFromUrl } from './readItemMerge'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for external URLs:
|
||||||
|
* - Web bookmarks (kind:39701)
|
||||||
|
* - Any bookmark with http(s) URLs in content or urlReferences
|
||||||
|
*/
|
||||||
|
export function deriveLinksFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const linksMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const urls: string[] = []
|
||||||
|
|
||||||
|
// Web bookmarks (kind:39701) - extract from 'd' tag
|
||||||
|
if (bookmark.kind === KINDS.WebBookmark) {
|
||||||
|
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1]
|
||||||
|
if (dTag) {
|
||||||
|
const url = dTag.startsWith('http') ? dTag : `https://${dTag}`
|
||||||
|
urls.push(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract URLs from content if not already captured
|
||||||
|
if (bookmark.content) {
|
||||||
|
const urlRegex = /(https?:\/\/[^\s]+)/g
|
||||||
|
const matches = bookmark.content.match(urlRegex)
|
||||||
|
if (matches) {
|
||||||
|
urls.push(...matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (for web bookmarks and other types)
|
||||||
|
const title = bookmark.tags.find(t => t[0] === 'title')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
|
||||||
|
// Create ReadItem for each unique URL
|
||||||
|
for (const url of [...new Set(urls)]) {
|
||||||
|
if (!linksMap.has(url)) {
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: url,
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'external',
|
||||||
|
url,
|
||||||
|
title: title || fallbackTitleFromUrl(url),
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
linksMap.set(url, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(linksMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
83
src/utils/readItemMerge.ts
Normal file
83
src/utils/readItemMerge.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a ReadItem into a state map, returning whether the state changed.
|
||||||
|
* Uses most recent reading activity to determine precedence.
|
||||||
|
*/
|
||||||
|
export function mergeReadItem(
|
||||||
|
stateMap: Map<string, ReadItem>,
|
||||||
|
incoming: ReadItem
|
||||||
|
): boolean {
|
||||||
|
const existing = stateMap.get(incoming.id)
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
stateMap.set(incoming.id, incoming)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always merge if incoming has reading progress data
|
||||||
|
const hasNewProgress = incoming.readingProgress !== undefined &&
|
||||||
|
(existing.readingProgress === undefined || existing.readingProgress !== incoming.readingProgress)
|
||||||
|
|
||||||
|
const hasNewMarkedAsRead = incoming.markedAsRead !== undefined && existing.markedAsRead === undefined
|
||||||
|
|
||||||
|
// Merge by taking the most recent reading activity
|
||||||
|
const existingTime = existing.readingTimestamp || existing.markedAt || 0
|
||||||
|
const incomingTime = incoming.readingTimestamp || incoming.markedAt || 0
|
||||||
|
|
||||||
|
if (incomingTime > existingTime || hasNewProgress || hasNewMarkedAsRead) {
|
||||||
|
// Keep existing data, but update with newer reading metadata
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
// Preserve event data if incoming doesn't have it
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author,
|
||||||
|
// Always take reading progress if available
|
||||||
|
readingProgress: incoming.readingProgress !== undefined ? incoming.readingProgress : existing.readingProgress,
|
||||||
|
readingTimestamp: incomingTime > existingTime ? incoming.readingTimestamp : existing.readingTimestamp
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timestamps are equal but incoming has additional data, merge it
|
||||||
|
if (incomingTime === existingTime && (!existing.event && incoming.event || !existing.title && incoming.title)) {
|
||||||
|
stateMap.set(incoming.id, {
|
||||||
|
...existing,
|
||||||
|
...incoming,
|
||||||
|
event: incoming.event || existing.event,
|
||||||
|
title: incoming.title || existing.title,
|
||||||
|
summary: incoming.summary || existing.summary,
|
||||||
|
image: incoming.image || existing.image,
|
||||||
|
published: incoming.published || existing.published,
|
||||||
|
author: incoming.author || existing.author
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a readable title from a URL when no title is available.
|
||||||
|
* Removes protocol, www, and shows domain + path.
|
||||||
|
*/
|
||||||
|
export function fallbackTitleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
let title = parsed.hostname.replace(/^www\./, '')
|
||||||
|
if (parsed.pathname && parsed.pathname !== '/') {
|
||||||
|
const path = parsed.pathname.slice(0, 40)
|
||||||
|
title += path.length < parsed.pathname.length ? path + '...' : path
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, just return the URL truncated
|
||||||
|
return url.length > 50 ? url.slice(0, 47) + '...' : url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
src/utils/readingProgressUtils.ts
Normal file
30
src/utils/readingProgressUtils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { ReadingProgressFilterType } from '../components/ReadingProgressFilters'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters ReadItems by reading progress
|
||||||
|
*/
|
||||||
|
export function filterByReadingProgress(
|
||||||
|
items: ReadItem[],
|
||||||
|
filter: ReadingProgressFilterType
|
||||||
|
): ReadItem[] {
|
||||||
|
return items.filter((item) => {
|
||||||
|
const progress = item.readingProgress || 0
|
||||||
|
const isMarked = item.markedAsRead || false
|
||||||
|
|
||||||
|
switch (filter) {
|
||||||
|
case 'unopened':
|
||||||
|
return progress === 0 && !isMarked
|
||||||
|
case 'started':
|
||||||
|
return progress > 0 && progress <= 0.10 && !isMarked
|
||||||
|
case 'reading':
|
||||||
|
return progress > 0.10 && progress <= 0.94 && !isMarked
|
||||||
|
case 'completed':
|
||||||
|
return progress >= 0.95 || isMarked
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
71
src/utils/readsFromBookmarks.ts
Normal file
71
src/utils/readsFromBookmarks.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Bookmark } from '../types/bookmarks'
|
||||||
|
import { ReadItem } from '../services/readsService'
|
||||||
|
import { classifyBookmarkType } from './bookmarkTypeClassifier'
|
||||||
|
import { KINDS } from '../config/kinds'
|
||||||
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives ReadItems from bookmarks for Nostr articles (kind:30023).
|
||||||
|
* Returns items with type='article', using hydrated data when available.
|
||||||
|
* Note: After hydration, article titles are in bookmark.content, metadata in tags.
|
||||||
|
*/
|
||||||
|
export function deriveReadsFromBookmarks(bookmarks: Bookmark[]): ReadItem[] {
|
||||||
|
const readsMap = new Map<string, ReadItem>()
|
||||||
|
|
||||||
|
const allBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||||
|
|
||||||
|
for (const bookmark of allBookmarks) {
|
||||||
|
const bookmarkType = classifyBookmarkType(bookmark)
|
||||||
|
|
||||||
|
// Only include articles (kind:30023)
|
||||||
|
if (bookmarkType === 'article' && bookmark.kind === KINDS.BlogPost) {
|
||||||
|
const coordinate = bookmark.id // coordinate format: kind:pubkey:identifier
|
||||||
|
|
||||||
|
// Extract identifier from coordinate
|
||||||
|
const parts = coordinate.split(':')
|
||||||
|
const identifier = parts[2] || ''
|
||||||
|
|
||||||
|
// Convert to naddr format (reading positions use naddr as ID)
|
||||||
|
let naddr: string
|
||||||
|
try {
|
||||||
|
naddr = nip19.naddrEncode({
|
||||||
|
kind: KINDS.BlogPost,
|
||||||
|
pubkey: bookmark.pubkey,
|
||||||
|
identifier
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to encode naddr for bookmark:', coordinate)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from tags (same as BookmarkItem does)
|
||||||
|
const title = bookmark.content || 'Untitled'
|
||||||
|
const image = bookmark.tags.find(t => t[0] === 'image')?.[1]
|
||||||
|
const summary = bookmark.tags.find(t => t[0] === 'summary')?.[1]
|
||||||
|
const published = bookmark.tags.find(t => t[0] === 'published_at')?.[1]
|
||||||
|
|
||||||
|
const item: ReadItem = {
|
||||||
|
id: naddr, // Use naddr format to match reading positions
|
||||||
|
source: 'bookmark',
|
||||||
|
type: 'article',
|
||||||
|
readingProgress: 0,
|
||||||
|
readingTimestamp: bookmark.added_at || bookmark.created_at,
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
image,
|
||||||
|
published: published ? parseInt(published) : undefined,
|
||||||
|
author: bookmark.pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
readsMap.set(naddr, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent bookmark activity
|
||||||
|
return Array.from(readsMap.values()).sort((a, b) => {
|
||||||
|
const timeA = a.readingTimestamp || 0
|
||||||
|
const timeB = b.readingTimestamp || 0
|
||||||
|
return timeB - timeA
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
11
vercel.json
11
vercel.json
@@ -1,5 +1,16 @@
|
|||||||
{
|
{
|
||||||
"rewrites": [
|
"rewrites": [
|
||||||
|
{
|
||||||
|
"source": "/a/:naddr",
|
||||||
|
"has": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"key": "user-agent",
|
||||||
|
"value": ".*(bot|crawl|spider|slurp|facebook|twitter|linkedin|whatsapp|telegram|slack|discord|preview).*"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"destination": "/api/article-og?naddr=:naddr"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/(.*)",
|
"source": "/(.*)",
|
||||||
"destination": "/index.html"
|
"destination": "/index.html"
|
||||||
|
|||||||
@@ -1,8 +1,68 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
|
function getGitMetadata() {
|
||||||
|
const envSha = process.env.VERCEL_GIT_COMMIT_SHA || ''
|
||||||
|
const envRef = process.env.VERCEL_GIT_COMMIT_REF || ''
|
||||||
|
let commit = envSha
|
||||||
|
let branch = envRef
|
||||||
|
try {
|
||||||
|
if (!commit) commit = execSync('git rev-parse HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
if (!branch) branch = execSync('git rev-parse --abbrev-ref HEAD', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
} catch {}
|
||||||
|
return { commit, branch }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPackageVersion() {
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url)).toString())
|
||||||
|
return pkg.version as string
|
||||||
|
} catch {
|
||||||
|
return '0.0.0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { commit, branch } = getGitMetadata()
|
||||||
|
const version = getPackageVersion()
|
||||||
|
const buildTime = new Date().toISOString()
|
||||||
|
|
||||||
|
function getCommitUrl(commit: string): string {
|
||||||
|
if (!commit) return ''
|
||||||
|
const provider = process.env.VERCEL_GIT_PROVIDER || ''
|
||||||
|
const owner = process.env.VERCEL_GIT_REPO_OWNER || ''
|
||||||
|
const slug = process.env.VERCEL_GIT_REPO_SLUG || ''
|
||||||
|
if (provider.toLowerCase() === 'github' && owner && slug) {
|
||||||
|
return `https://github.com/${owner}/${slug}/commit/${commit}`
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const remote = execSync('git config --get remote.origin.url', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
||||||
|
if (remote.includes('github.com')) {
|
||||||
|
// git@github.com:owner/repo.git or https://github.com/owner/repo.git
|
||||||
|
const https = remote.startsWith('git@')
|
||||||
|
? `https://github.com/${remote.split(':')[1]}`
|
||||||
|
: remote
|
||||||
|
const cleaned = https.replace(/\.git$/, '')
|
||||||
|
return `${cleaned}/commit/${commit}`
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitUrl = getCommitUrl(commit)
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(version),
|
||||||
|
__GIT_COMMIT__: JSON.stringify(commit),
|
||||||
|
__GIT_BRANCH__: JSON.stringify(branch),
|
||||||
|
__BUILD_TIME__: JSON.stringify(buildTime),
|
||||||
|
__GIT_COMMIT_URL__: JSON.stringify(commitUrl)
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
|
|||||||
Reference in New Issue
Block a user