Compare commits

..

5 Commits

Author SHA1 Message Date
Gigi
a1a7f0e4a4 chore: bump version to 0.10.9 2025-10-22 01:41:14 +02:00
Gigi
cde8e30ab2 fix(events): improve /e/ reliability with retry + backoff in eventManager
- Add multi-attempt fetch with backoff
- Retry on not-found, errors, and timeouts before failing
- Keep deduplication and cache-first behavior
2025-10-22 01:40:26 +02:00
Gigi
aa7e532950 fix(bookmarks): use per-item added_at/created_at when available
- Read / from applesauce pointers for notes/articles
- Fallback to eventStore event  during enrichment
- Keeps sorting by  then  consistent
2025-10-22 01:35:06 +02:00
Gigi
c9208cfff2 chore: remove all debug console logs
- Remove console.log from bookmark hydration
- Remove console.log from relay initialization
- Remove all console.debug calls from TTS hook and controls
- Remove debug logging from RouteDebug component
- Fix useCallback dependency warning in speak function
2025-10-22 01:26:42 +02:00
Gigi
2fb4132342 docs: update CHANGELOG for v0.10.8 2025-10-22 01:25:41 +02:00
9 changed files with 79 additions and 56 deletions

View File

@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.8] - 2025-10-21
### Added
- Individual event rendering via `/e/:eventId` path
- Display `kind:1` notes and other events with article-like presentation
- Publication date displayed in top-right corner like articles
- Author attribution with "Note by @author" titles
- Direct event loading with intelligent caching from eventStore
- Centralized event fetching via new `eventManager` singleton
- Request deduplication for concurrent fetches
- Automatic retry logic when relay pool becomes available
- Non-blocking background fetching with 12-second timeout
- Seamless integration with eventStore for instant cached event display
### Fixed
- Bookmark hydration efficiency
- Only request content for bookmarks missing data (not all bookmarks)
- Use eventStore fallback for instant display of cached profiles
- Prevents over-fetching and improves initial load performance
- Search button behavior for notes
- Opens `kind:1` notes directly via `/e/{eventId}` instead of search portal
- Articles continue to use search portal with proper naddr encoding
- Removes unwanted `nostr-event:` prefix from URLs
- Author profile resolution
- Fetch author profiles from eventStore cache first before relay requests
- Instant title updates if profile already loaded
- Graceful fallback to short pubkey display if profile unavailable
## [0.10.7] - 2025-10-21
### Fixed

View File

@@ -1,6 +1,6 @@
{
"name": "boris",
"version": "0.10.8",
"version": "0.10.9",
"description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/",
"type": "module",

View File

@@ -578,8 +578,6 @@ function App() {
// Handle user relay list and blocked relays when account changes
const userRelaysSub = accounts.active$.subscribe((account) => {
console.log('[relay-init] userRelaysSub fired, account:', account ? 'logged in' : 'logged out')
console.log('[relay-init] Pool has', Array.from(pool.relays.keys()).length, 'relays before applying changes')
if (account) {
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
const pubkey = account.pubkey

View File

@@ -20,7 +20,7 @@ export default function RouteDebug() {
// Unexpected during deep-link refresh tests
console.warn('[RouteDebug] unexpected root redirect', info)
} else {
console.debug('[RouteDebug]', info)
// silent
}
}, [location, matchArticle])

View File

@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const lang = detect(text)
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
} catch (err) {
console.debug('[tts][detect] failed', err)
// ignore detection errors
}
}
if (!langOverride && resolvedSystemLang) {
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
const currentIndex = SPEED_OPTIONS.indexOf(rate)
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
const next = SPEED_OPTIONS[nextIndex]
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
setRate(next)
}

View File

@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
// Update rate when defaultRate option changes
useEffect(() => {
if (options.defaultRate !== undefined) {
console.debug('[tts] defaultRate changed ->', options.defaultRate)
setRate(options.defaultRate)
}
}, [options.defaultRate])
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!voice && v.length) {
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
setVoice(byLang || v[0] || null)
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
}
}
load()
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
u.onstart = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onstart')
setSpeaking(true)
setPaused(false)
}
u.onpause = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onpause')
setPaused(true)
}
u.onresume = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onresume')
setPaused(false)
}
u.onend = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onend')
// Continue with next chunk if available
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
if (hasMore) {
chunkIndexRef.current += 1
globalOffsetRef.current += self.text.length
const next = chunksRef.current[chunkIndexRef.current] || ''
const nextUtterance = createUtterance(next, langRef.current)
chunkIndexRef.current++
charIndexRef.current += self.text.length
const nextChunk = chunksRef.current[chunkIndexRef.current]
const nextUtterance = createUtterance(nextChunk, langRef.current)
utteranceRef.current = nextUtterance
synth!.speak(nextUtterance)
return
} else {
setSpeaking(false)
setPaused(false)
}
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
}
u.onerror = () => {
if (utteranceRef.current !== self) return
console.debug('[tts] onerror')
setSpeaking(false)
setPaused(false)
utteranceRef.current = null
}
u.onboundary = (ev: SpeechSynthesisEvent) => {
if (utteranceRef.current !== self) return
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const stop = useCallback(() => {
if (!supported) return
console.debug('[tts] stop')
synth!.cancel()
setSpeaking(false)
setPaused(false)
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const speak = useCallback((text: string, langOverride?: string) => {
if (!supported || !text?.trim()) return
console.debug('[tts] speak', { len: text.length, rate })
synth!.cancel()
spokenTextRef.current = text
charIndexRef.current = 0
langRef.current = langOverride
startSpeakingChunks(text)
}, [supported, synth, startSpeakingChunks, rate])
}, [supported, synth, startSpeakingChunks])
const pause = useCallback(() => {
if (!supported) return
if (synth!.speaking && !synth!.paused) {
console.debug('[tts] pause')
synth!.pause()
setPaused(true)
}
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const resume = useCallback(() => {
if (!supported) return
if (synth!.speaking && synth!.paused) {
console.debug('[tts] resume')
synth!.resume()
setPaused(false)
}
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
if (!supported) return
if (!utteranceRef.current) return
console.debug('[tts] rate change', { rate, speaking: synth!.speaking, paused: synth!.paused, charIndex: charIndexRef.current })
if (synth!.speaking && !synth!.paused) {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
const remainingText = fullText.slice(startIndex)
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
synth!.cancel()
// restart chunked from current global index
spokenTextRef.current = remainingText
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
const fullText = spokenTextRef.current
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
const remainingText = fullText.slice(startIndex)
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
synth!.cancel()
const u = createUtterance(remainingText)
// ensure the new rate is applied immediately on the new utterance
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
utteranceRef.current = u
synth!.speak(u)
} else if (utteranceRef.current) {
console.debug('[tts] updateRate -> set on utterance', { newRate })
utteranceRef.current.rate = newRate
}
}, [supported, synth, createUtterance])

View File

@@ -279,8 +279,6 @@ class BookmarkController {
}
})
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
@@ -293,10 +291,8 @@ class BookmarkController {
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
// Prefer hydrated content; fallback to any cached event content in external store
content: b.content && b.content.length > 0
? b.content
: (this.externalEventStore?.getEvent(b.id)?.content || '')
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
created_at: b.created_at || this.externalEventStore?.getEvent(b.id)?.created_at || b.created_at
}))
const sortedBookmarks = enriched

View File

@@ -21,12 +21,16 @@ export interface AddressPointer {
pubkey: string
identifier: string
relays?: string[]
added_at?: number
created_at?: number
}
export interface EventPointer {
id: string
relays?: string[]
author?: string
added_at?: number
created_at?: number
}
export interface ApplesauceBookmarks {
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: note.id,
content: '',
created_at: parentCreatedAt || 0,
created_at: note.created_at || parentCreatedAt || 0,
pubkey: note.author || activeAccount.pubkey,
kind: 1, // Short note kind
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
added_at: note.added_at || parentCreatedAt || 0
})
})
}
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
allItems.push({
id: coordinate,
content: '',
created_at: parentCreatedAt || 0,
created_at: article.created_at || parentCreatedAt || 0,
pubkey: article.pubkey,
kind: article.kind, // Usually 30023 for long-form articles
tags: [],
parsedContent: undefined,
type: 'event' as const,
isPrivate,
added_at: parentCreatedAt || 0
added_at: article.added_at || parentCreatedAt || 0
})
})
}

View File

@@ -22,6 +22,9 @@ class EventManager {
// Safety timeout for event fetches (ms)
private fetchTimeoutMs = 12000
// Retry policy
private maxAttempts = 4
private baseBackoffMs = 700
/**
* Initialize the event manager with event store and relay pool
@@ -70,7 +73,7 @@ class EventManager {
// Start a new fetch request
this.pendingRequests.set(eventId, [{ resolve, reject }])
this.fetchFromRelay(eventId)
this.fetchFromRelayWithRetry(eventId, 1)
})
}
@@ -86,17 +89,14 @@ class EventManager {
requests.forEach(req => req.reject(error))
}
/**
* Actually fetch the event from relay
*/
private fetchFromRelay(eventId: string): void {
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
// If no loader yet, schedule retry
if (!this.relayPool || !this.eventLoader) {
setTimeout(() => {
if (this.eventLoader && this.pendingRequests.has(eventId)) {
this.fetchFromRelay(eventId)
if (this.pendingRequests.has(eventId)) {
this.fetchFromRelayWithRetry(eventId, attempt)
}
}, 500)
}, this.baseBackoffMs)
return
}
@@ -111,14 +111,23 @@ class EventManager {
error: (err: unknown) => {
clearTimeout(timeoutId)
const error = err instanceof Error ? err : new Error(String(err))
this.rejectPending(eventId, error)
// Retry on error until attempts exhausted
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, error)
}
subscription.unsubscribe()
},
complete: () => {
// Completed without next - consider not found
// Completed without next - consider not found, but retry a few times
if (!delivered) {
clearTimeout(timeoutId)
this.rejectPending(eventId, new Error('Event not found'))
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
setTimeout(() => this.fetchFromRelayWithRetry(eventId, attempt + 1), this.baseBackoffMs * attempt)
} else {
this.rejectPending(eventId, new Error('Event not found'))
}
}
subscription.unsubscribe()
}
@@ -127,8 +136,13 @@ class EventManager {
// Safety timeout
const timeoutId = setTimeout(() => {
if (!delivered) {
this.rejectPending(eventId, new Error('Timed out fetching event'))
subscription.unsubscribe()
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
subscription.unsubscribe()
this.fetchFromRelayWithRetry(eventId, attempt + 1)
} else {
subscription.unsubscribe()
this.rejectPending(eventId, new Error('Timed out fetching event'))
}
}
}, this.fetchTimeoutMs)
}
@@ -139,7 +153,7 @@ class EventManager {
private retryAllPending(): void {
const pendingIds = Array.from(this.pendingRequests.keys())
pendingIds.forEach(eventId => {
this.fetchFromRelay(eventId)
this.fetchFromRelayWithRetry(eventId, 1)
})
}
}