Compare commits

..

19 Commits

Author SHA1 Message Date
Gigi
550ee415f0 chore: bump version to 0.10.10 2025-10-22 11:37:08 +02:00
Gigi
aaaf226623 Merge pull request #24 from dergigi/controllers-and-fetching
Replace timeouts with streaming controllers and fix bookmark hydration
2025-10-22 11:36:35 +02:00
Gigi
23ce0c9d4c chore: remove debug logging from bookmark controller 2025-10-22 11:33:41 +02:00
Gigi
dddf8575c4 fix: resolve TypeScript type errors in bookmark hydration promises
Add .then() handlers to convert Promise<NostrEvent[]> to Promise<void>
for compatibility with Promise<void>[] array type.
2025-10-22 11:30:53 +02:00
Gigi
3ab0610e1e fix: prevent cascading hydration loops in bookmark controller
Run all coordinate queries in parallel with Promise.all instead of
sequential awaits. This prevents each query from triggering a rebuild
that causes another hydration cycle, which was creating infinite loops.

The issue was that awaiting each query sequentially would:
1. Fetch articles for author A
2. Call onProgress, rebuild bookmarks
3. Trigger new hydration because coordinates changed
4. Repeat indefinitely

Now all queries start at once and stream results as they arrive,
matching the original loader behavior.
2025-10-22 11:27:12 +02:00
Gigi
e40f820fdc fix: handle empty d-tags separately in bookmark hydration
Separate fetching of articles with empty vs non-empty d-tags to work
around relay filter handling issues. For empty d-tags, fetch all events
of that kind/author and filter client-side.
2025-10-22 11:25:30 +02:00
Gigi
3f82bc7873 debug: add logging for bookmark coordinate hydration 2025-10-22 11:23:26 +02:00
Gigi
b913cc4d7f fix: hide 'Open Original' button for nostr-native events
Only external URLs (/r/ paths) have original sources.
Nostr-native events don't need this option in the three-dot menu.
2025-10-22 11:21:08 +02:00
Gigi
bc1aed30b4 fix: open nostr events directly on ants.sh instead of as search query
When clicking search in the three-dot menu for a nostr event,
now opens https://ants.sh/e/<eventId> directly instead of
https://ants.sh/?q=nostr-event:<eventId>
2025-10-22 11:20:12 +02:00
Gigi
9a801975aa fix(bookmarks): replace applesauce loaders with local-first queryEvents
Replace EventLoader and AddressLoader with queryEvents for bookmark
hydration to properly prioritize local relays. The applesauce loaders
were not using local-first fetching strategy, causing bookmarked events
to not be hydrated from local relay cache.

- Remove createEventLoader and createAddressLoader usage
- Replace with queryEvents which handles local-first fetching
- Properly streams events from local relays before remote relays
- Follows the controller pattern used by other services (writings, etc)

This fixes the issue where bookmarks would only show event IDs instead
of full content, while blog posts (kind:30023) worked correctly.
2025-10-22 11:16:21 +02:00
Gigi
f3e44edd51 fix: remove unnecessary key prop causing lag on tab switching in Explore 2025-10-22 11:09:05 +02:00
Gigi
0be6aa81ce fix: add comments to empty catch blocks to satisfy linter 2025-10-22 09:00:01 +02:00
Gigi
c7b885cfcd refactor(reader): use startReadingPositionStream in ContentPanel 2025-10-22 08:55:50 +02:00
Gigi
11041df1fb refactor(reading-position): add startReadingPositionStream and remove timeouts 2025-10-22 08:55:18 +02:00
Gigi
89273e2a03 refactor(settings): use startSettingsStream in useSettings hook 2025-10-22 08:54:45 +02:00
Gigi
0610454e74 feat(settings): add startSettingsStream and remove timeout-based blocking 2025-10-22 08:54:17 +02:00
Gigi
a02413a7cb fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard 2025-10-22 02:02:39 +02:00
Gigi
0bc84e7c6c chore: update package-lock.json for v0.10.9 2025-10-22 01:41:46 +02:00
Gigi
a1e28c6bc9 docs: update CHANGELOG for v0.10.9 2025-10-22 01:41:34 +02:00
14 changed files with 359 additions and 305 deletions

View File

@@ -2,4 +2,4 @@
alwaysApply: true
---
Keep files below 210 lines.
Keep files below 420 lines.

View File

@@ -0,0 +1,18 @@
---
description: fetching data from relays
alwaysApply: false
---
We fetch data from relays using controllers:
- Start controllers immediatly; dont 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 localonly 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.

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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}

View File

@@ -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

View File

@@ -656,7 +656,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
</div>
</div>
<div key={activeTab}>
<div>
{renderTabContent()}
</div>
</div>

View File

@@ -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

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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(