mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
550ee415f0 | ||
|
|
aaaf226623 | ||
|
|
23ce0c9d4c | ||
|
|
dddf8575c4 | ||
|
|
3ab0610e1e | ||
|
|
e40f820fdc | ||
|
|
3f82bc7873 | ||
|
|
b913cc4d7f | ||
|
|
bc1aed30b4 | ||
|
|
9a801975aa | ||
|
|
f3e44edd51 | ||
|
|
0be6aa81ce | ||
|
|
c7b885cfcd | ||
|
|
11041df1fb | ||
|
|
89273e2a03 | ||
|
|
0610454e74 | ||
|
|
a02413a7cb | ||
|
|
0bc84e7c6c | ||
|
|
a1e28c6bc9 |
@@ -2,4 +2,4 @@
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Keep files below 210 lines.
|
||||
Keep files below 420 lines.
|
||||
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
18
.cursor/rules/fetching-data-with-controllers.mdc
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
description: fetching data from relays
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We fetch data from relays using controllers:
|
||||
- Start controllers immediatly; don’t await.
|
||||
- Stream via onEvent; dedupe replaceables; emit immediately.
|
||||
- Parallel local/remote queries; complete on EOSE.
|
||||
- Finalize and persist since after completion.
|
||||
- Guard with generations to cancel stale runs.
|
||||
- UI flips off loading on first streamed result.
|
||||
|
||||
We always include and prefer local relays for reads; optionally rebroadcast fetched content to local relays (depending on setting); and tolerate local‑only mode for writes (queueing for later).
|
||||
|
||||
Since we are streaming results, we should NEVER use timeouts for fetching data. We should always rely on EOSE.
|
||||
|
||||
In short: Local-first hydration, background network fetch, reactive updates, and replaceable lookups provide instant UI with eventual consistency. Use local relays as local data store for everything we fetch from remote relays.
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.10.9] - 2025-10-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- Event fetching reliability with exponential backoff in eventManager
|
||||
- Improved retry logic with incremental backoff delays
|
||||
- Better handling of concurrent event requests
|
||||
- More robust event retrieval from relay pool
|
||||
- Bookmark timestamp handling
|
||||
- Use per-item `added_at`/`created_at` timestamps when available
|
||||
- Improves accuracy of bookmark date tracking
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed all debug console logs
|
||||
- Cleaner console output in development and production
|
||||
- Improved performance by eliminating debugging statements
|
||||
|
||||
## [0.10.8] - 2025-10-21
|
||||
|
||||
### Added
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.5",
|
||||
"version": "0.10.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.10.5",
|
||||
"version": "0.10.9",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.10.9",
|
||||
"version": "0.10.10",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
@@ -43,8 +43,8 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
saveReadingPosition,
|
||||
startReadingPositionStream
|
||||
} from '../services/readingPositionService'
|
||||
import TTSControls from './TTSControls'
|
||||
|
||||
@@ -207,7 +207,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
useEffect(() => {
|
||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
||||
|
||||
// Load saved reading position when article loads
|
||||
// Load saved reading position when article loads (non-blocking, EOSE-driven)
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
return
|
||||
@@ -216,15 +216,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier
|
||||
)
|
||||
|
||||
const stop = startReadingPositionStream(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier,
|
||||
(savedPosition) => {
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
@@ -237,19 +234,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
// Article was completed, start from top
|
||||
} else {
|
||||
// Position was too early, skip restore
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
loadPosition()
|
||||
return () => stop()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
@@ -577,7 +566,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
if (selectedUrl) {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
// If it's a nostr event sentinel, open the event directly on ants.sh
|
||||
if (selectedUrl.startsWith('nostr-event:')) {
|
||||
const eventId = selectedUrl.replace('nostr-event:', '')
|
||||
window.open(`https://ants.sh/e/${eventId}`, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
@@ -927,13 +922,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleSearchExternalUrl}
|
||||
|
||||
@@ -781,9 +781,16 @@ const Debug: React.FC<DebugProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Load deduplicated results via controller
|
||||
// Load deduplicated results via controller (includes articles and external URLs)
|
||||
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||
setDeduplicatedProgressMap(new Map(progressMap))
|
||||
|
||||
// Regression guard: ensure keys include both naddr and raw URL forms when present
|
||||
try {
|
||||
const keys = Array.from(progressMap.keys())
|
||||
const sample = keys.slice(0, 5).join(', ')
|
||||
DebugBus.info('debug', `Progress keys sample: ${sample}`)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
|
||||
// Run both in parallel
|
||||
|
||||
@@ -656,7 +656,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div key={activeTab}>
|
||||
<div>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { UserSettings, saveSettings, watchSettings, startSettingsStream } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { applyTheme } from '../utils/theme'
|
||||
import { RELAYS } from '../config/relays'
|
||||
@@ -20,26 +20,24 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
||||
|
||||
// Load settings and set up subscription
|
||||
// Load settings and set up streaming subscription (non-blocking, EOSE-driven)
|
||||
useEffect(() => {
|
||||
if (!relayPool || !pubkey || !eventStore) return
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
}
|
||||
|
||||
loadAndWatch()
|
||||
// Start settings stream: seed from store, stream updates to store in background
|
||||
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
// Also watch store reactively for any further updates
|
||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||
})
|
||||
|
||||
return () => subscription.unsubscribe()
|
||||
return () => {
|
||||
subscription.unsubscribe()
|
||||
stopNetwork()
|
||||
}
|
||||
}, [relayPool, pubkey, eventStore])
|
||||
|
||||
// Apply settings to document
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Helpers, EventStore } from 'applesauce-core'
|
||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { EventPointer } from 'nostr-tools/nip19'
|
||||
import { from } from 'rxjs'
|
||||
import { mergeMap } from 'rxjs/operators'
|
||||
import { queryEvents } from './dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import {
|
||||
@@ -65,12 +60,8 @@ class BookmarkController {
|
||||
}> = new Map()
|
||||
private isLoading = false
|
||||
private hydrationGeneration = 0
|
||||
|
||||
// Event loaders for efficient batching
|
||||
private eventStore = new EventStore()
|
||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||
private externalEventStore: EventStore | null = null
|
||||
private relayPool: RelayPool | null = null
|
||||
|
||||
onRawEvent(cb: RawEventCallback): () => void {
|
||||
this.rawEventListeners.push(cb)
|
||||
@@ -119,15 +110,15 @@ class BookmarkController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate events by IDs using EventLoader (auto-batching, streaming)
|
||||
* Hydrate events by IDs using queryEvents (local-first, streaming)
|
||||
*/
|
||||
private hydrateByIds(
|
||||
private async hydrateByIds(
|
||||
ids: string[],
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.eventLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,86 +128,146 @@ class BookmarkController {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert IDs to EventPointers
|
||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||
|
||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||
// This prevents overwhelming relays with 96+ simultaneous requests
|
||||
from(pointers).pipe(
|
||||
mergeMap(pointer => this.eventLoader!(pointer), 5)
|
||||
).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
// Fetch events using local-first queryEvents
|
||||
await queryEvents(
|
||||
this.relayPool,
|
||||
{ ids: unique },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Also index by coordinate for addressable events
|
||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
}
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - EventLoader handles retries
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate addressable events by coordinates using AddressLoader (auto-batching, streaming)
|
||||
* Hydrate addressable events by coordinates using queryEvents (local-first, streaming)
|
||||
*/
|
||||
private hydrateByCoordinates(
|
||||
private async hydrateByCoordinates(
|
||||
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||
idToEvent: Map<string, NostrEvent>,
|
||||
onProgress: () => void,
|
||||
generation: number
|
||||
): void {
|
||||
if (!this.addressLoader) {
|
||||
): Promise<void> {
|
||||
if (!this.relayPool) {
|
||||
return
|
||||
}
|
||||
|
||||
if (coords.length === 0) return
|
||||
if (coords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert coordinates to AddressPointers
|
||||
const pointers = coords.map(c => ({
|
||||
kind: c.kind,
|
||||
pubkey: c.pubkey,
|
||||
identifier: c.identifier
|
||||
}))
|
||||
// Group by kind and pubkey for efficient batching
|
||||
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||
|
||||
for (const coord of coords) {
|
||||
if (!filtersByKind.has(coord.kind)) {
|
||||
filtersByKind.set(coord.kind, new Map())
|
||||
}
|
||||
const byPubkey = filtersByKind.get(coord.kind)!
|
||||
if (!byPubkey.has(coord.pubkey)) {
|
||||
byPubkey.set(coord.pubkey, [])
|
||||
}
|
||||
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
|
||||
}
|
||||
|
||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||
from(pointers).pipe(
|
||||
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
||||
).subscribe({
|
||||
next: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
// Kick off all queries in parallel (fire-and-forget)
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
for (const [kind, byPubkey] of filtersByKind) {
|
||||
for (const [pubkey, identifiers] of byPubkey) {
|
||||
// Separate empty and non-empty identifiers
|
||||
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
// Fetch events with non-empty d-tags
|
||||
if (nonEmptyIdentifiers.length > 0) {
|
||||
promises.push(
|
||||
queryEvents(
|
||||
this.relayPool,
|
||||
{ kinds: [kind], authors: [pubkey], '#d': nonEmptyIdentifiers },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
}
|
||||
).then(() => {
|
||||
// Query completed successfully
|
||||
}).catch(() => {
|
||||
// Silent error - individual query failed
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
},
|
||||
error: () => {
|
||||
// Silent error - AddressLoader handles retries
|
||||
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||
if (hasEmptyIdentifier) {
|
||||
promises.push(
|
||||
queryEvents(
|
||||
this.relayPool,
|
||||
{ kinds: [kind], authors: [pubkey] },
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if hydration was cancelled
|
||||
if (this.hydrationGeneration !== generation) return
|
||||
|
||||
// Only process events with empty d-tag
|
||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
if (dTag !== '') return
|
||||
|
||||
const coordinate = `${event.kind}:${event.pubkey}:`
|
||||
idToEvent.set(coordinate, event)
|
||||
idToEvent.set(event.id, event)
|
||||
|
||||
// Add to external event store if available
|
||||
if (this.externalEventStore) {
|
||||
this.externalEventStore.add(event)
|
||||
}
|
||||
|
||||
onProgress()
|
||||
}
|
||||
}
|
||||
).then(() => {
|
||||
// Query completed successfully
|
||||
}).catch(() => {
|
||||
// Silent error - individual query failed
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for all queries to complete
|
||||
await Promise.all(promises)
|
||||
}
|
||||
|
||||
private async buildAndEmitBookmarks(
|
||||
@@ -320,7 +371,7 @@ class BookmarkController {
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
emitBookmarks(idToEvent)
|
||||
|
||||
// Now fetch events progressively in background using batched hydrators
|
||||
// Now fetch events progressively in background using local-first queries
|
||||
|
||||
const generation = this.hydrationGeneration
|
||||
const onProgress = () => emitBookmarks(idToEvent)
|
||||
@@ -335,10 +386,14 @@ class BookmarkController {
|
||||
}
|
||||
})
|
||||
|
||||
// Kick off batched hydration (streaming, non-blocking)
|
||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
||||
// Kick off hydration (streaming, non-blocking, local-first)
|
||||
// Fire-and-forget - don't await, let it run in background
|
||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||
// Silent error - hydration will retry or show partial results
|
||||
})
|
||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation).catch(() => {
|
||||
// Silent error - hydration will retry or show partial results
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to build bookmarks:', error)
|
||||
this.bookmarksListeners.forEach(cb => cb([]))
|
||||
@@ -353,7 +408,8 @@ class BookmarkController {
|
||||
}): Promise<void> {
|
||||
const { relayPool, activeAccount, accountManager, eventStore } = options
|
||||
|
||||
// Store the external event store reference for adding hydrated events
|
||||
// Store references for hydration
|
||||
this.relayPool = relayPool
|
||||
this.externalEventStore = eventStore || null
|
||||
|
||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||
@@ -365,16 +421,6 @@ class BookmarkController {
|
||||
// Increment generation to cancel any in-flight hydration
|
||||
this.hydrationGeneration++
|
||||
|
||||
// Initialize loaders for this session
|
||||
this.eventLoader = createEventLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
this.addressLoader = createAddressLoader(relayPool, {
|
||||
eventStore: this.eventStore,
|
||||
extraRelays: RELAYS
|
||||
})
|
||||
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
|
||||
@@ -75,10 +75,17 @@ export function processReadingProgress(
|
||||
continue
|
||||
}
|
||||
} else if (dTag.startsWith('url:')) {
|
||||
// It's a URL with base64url encoding
|
||||
const encoded = dTag.replace('url:', '')
|
||||
// It's a URL. We support both raw URLs and base64url-encoded URLs.
|
||||
const value = dTag.slice(4)
|
||||
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
|
||||
try {
|
||||
itemUrl = atob(encoded.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
if (looksBase64Url) {
|
||||
// Decode base64url to raw URL
|
||||
itemUrl = atob(value.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
} else {
|
||||
// Treat as raw URL (already decoded)
|
||||
itemUrl = value
|
||||
}
|
||||
itemId = itemUrl
|
||||
itemType = 'external'
|
||||
} catch (e) {
|
||||
|
||||
@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
if (naddrOrUrl.startsWith('nostr:')) {
|
||||
return naddrOrUrl.replace('nostr:', '')
|
||||
}
|
||||
// For URLs, use base64url encoding (URL-safe)
|
||||
return btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
|
||||
return naddrOrUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,8 +135,53 @@ export async function saveReadingPosition(
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming reading position loader (non-blocking, EOSE-driven)
|
||||
* Seeds from local eventStore, streams relay updates to store in background
|
||||
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||
*/
|
||||
export function startReadingPositionStream(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
articleIdentifier: string,
|
||||
onPosition: (pos: ReadingPosition | null) => void
|
||||
): () => void {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
// 1) Seed from local replaceable immediately and watch for updates
|
||||
const storeSub = eventStore
|
||||
.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
.subscribe((event: NostrEvent | undefined) => {
|
||||
if (!event) {
|
||||
onPosition(null)
|
||||
return
|
||||
}
|
||||
const parsed = getReadingProgressContent(event)
|
||||
onPosition(parsed || null)
|
||||
})
|
||||
|
||||
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||
const networkSub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
// Caller manages lifecycle
|
||||
return () => {
|
||||
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr (kind 39802)
|
||||
* @deprecated Use startReadingPositionStream for non-blocking behavior
|
||||
* Returns current local position immediately (or null) and starts background sync
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
@@ -149,101 +191,29 @@ export async function loadReadingPosition(
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = generateDTag(articleIdentifier)
|
||||
|
||||
// Check local event store first
|
||||
let initial: ReadingPosition | null = null
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingProgressContent(localEvent)
|
||||
if (content) {
|
||||
// Fetch from relays in background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content
|
||||
}
|
||||
if (content) initial = content
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors and fetch from relays
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Fetch from relays
|
||||
const result = await fetchFromRelays(
|
||||
relayPool,
|
||||
eventStore,
|
||||
pubkey,
|
||||
READING_PROGRESS_KIND,
|
||||
dTag,
|
||||
getReadingProgressContent
|
||||
)
|
||||
|
||||
return result || null
|
||||
}
|
||||
|
||||
// Helper function to fetch from relays with timeout
|
||||
async function fetchFromRelays(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
kind: number,
|
||||
dTag: string,
|
||||
parser: (event: NostrEvent) => ReadingPosition | undefined
|
||||
): Promise<ReadingPosition | null> {
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 3000)
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [kind],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(kind, pubkey, dTag)
|
||||
)
|
||||
if (event) {
|
||||
const content = parser(event)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 3000)
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [READING_PROGRESS_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return initial
|
||||
}
|
||||
|
||||
|
||||
@@ -276,10 +276,10 @@ class ReadingProgressController {
|
||||
// Process new events
|
||||
processReadingProgress(events, readsMap)
|
||||
|
||||
// Convert back to progress map (naddr -> progress)
|
||||
// Convert back to progress map (id -> progress). Include both articles and external URLs.
|
||||
const newProgressMap = new Map<string, number>()
|
||||
for (const [id, item] of readsMap.entries()) {
|
||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
||||
if (item.readingProgress !== undefined) {
|
||||
newProgressMap.set(id, item.readingProgress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,90 +75,82 @@ export interface UserSettings {
|
||||
ttsDefaultSpeed?: number // default: 2.1
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming settings loader (non-blocking, EOSE-driven)
|
||||
* Seeds from local eventStore, streams relay updates to store in background
|
||||
* @returns Unsubscribe function to cancel both store watch and network stream
|
||||
*/
|
||||
export function startSettingsStream(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
relays: string[],
|
||||
onSettings: (settings: UserSettings | null) => void
|
||||
): () => void {
|
||||
// 1) Seed from local replaceable immediately and watch for updates
|
||||
const storeSub = eventStore
|
||||
.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
.subscribe((event: NostrEvent | undefined) => {
|
||||
if (!event) {
|
||||
onSettings(null)
|
||||
return
|
||||
}
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
onSettings(content || null)
|
||||
})
|
||||
|
||||
// 2) Stream from relays in background; pipe into store; no timeout/unsubscribe timer
|
||||
const networkSub = relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
// Caller manages lifecycle
|
||||
return () => {
|
||||
try { storeSub.unsubscribe() } catch { /* ignore */ }
|
||||
try { networkSub.unsubscribe() } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use startSettingsStream + watchSettings for non-blocking behavior.
|
||||
* Returns current local settings immediately (or null if not present) and starts background sync.
|
||||
*/
|
||||
export async function loadSettings(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): Promise<UserSettings | null> {
|
||||
|
||||
// First, check if we already have settings in the local event store
|
||||
let initial: UserSettings | null = null
|
||||
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getAppDataContent<UserSettings>(localEvent)
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content || null
|
||||
initial = content || null
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore local store errors
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (event) {
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading settings:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('❌ Settings subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Start background sync (fire-and-forget; no timeout)
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 5000)
|
||||
})
|
||||
return initial
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
|
||||
Reference in New Issue
Block a user