mirror of
https://github.com/dergigi/boris.git
synced 2026-02-19 05:54:49 +01:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
550ee415f0 | ||
|
|
aaaf226623 | ||
|
|
23ce0c9d4c | ||
|
|
dddf8575c4 | ||
|
|
3ab0610e1e | ||
|
|
e40f820fdc | ||
|
|
3f82bc7873 | ||
|
|
b913cc4d7f | ||
|
|
bc1aed30b4 | ||
|
|
9a801975aa | ||
|
|
f3e44edd51 | ||
|
|
0be6aa81ce | ||
|
|
c7b885cfcd | ||
|
|
11041df1fb | ||
|
|
89273e2a03 | ||
|
|
0610454e74 | ||
|
|
a02413a7cb | ||
|
|
0bc84e7c6c | ||
|
|
a1e28c6bc9 | ||
|
|
a1a7f0e4a4 | ||
|
|
cde8e30ab2 | ||
|
|
aa7e532950 | ||
|
|
c9208cfff2 | ||
|
|
2fb4132342 | ||
|
|
81180c8ba8 | ||
|
|
1c48adf44e |
@@ -2,4 +2,4 @@
|
|||||||
alwaysApply: true
|
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.
|
||||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
|
- 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
|
## [0.10.7] - 2025-10-21
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.5",
|
"version": "0.10.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.5",
|
"version": "0.10.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "boris",
|
"name": "boris",
|
||||||
"version": "0.10.7",
|
"version": "0.10.10",
|
||||||
"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",
|
||||||
|
|||||||
@@ -578,8 +578,6 @@ function App() {
|
|||||||
|
|
||||||
// Handle user relay list and blocked relays when account changes
|
// Handle user relay list and blocked relays when account changes
|
||||||
const userRelaysSub = accounts.active$.subscribe((account) => {
|
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) {
|
if (account) {
|
||||||
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
// User logged in - start with hardcoded relays immediately, then stream user relay list updates
|
||||||
const pubkey = account.pubkey
|
const pubkey = account.pubkey
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ import { EventFactory } from 'applesauce-factory'
|
|||||||
import { Hooks } from 'applesauce-react'
|
import { Hooks } from 'applesauce-react'
|
||||||
import {
|
import {
|
||||||
generateArticleIdentifier,
|
generateArticleIdentifier,
|
||||||
loadReadingPosition,
|
saveReadingPosition,
|
||||||
saveReadingPosition
|
startReadingPositionStream
|
||||||
} from '../services/readingPositionService'
|
} from '../services/readingPositionService'
|
||||||
import TTSControls from './TTSControls'
|
import TTSControls from './TTSControls'
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
}, [isTextContent, settings?.syncReadingPosition, activeAccount, relayPool, eventStore, articleIdentifier, progressPercentage])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||||
return
|
return
|
||||||
@@ -216,15 +216,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPosition = async () => {
|
const stop = startReadingPositionStream(
|
||||||
try {
|
relayPool,
|
||||||
const savedPosition = await loadReadingPosition(
|
eventStore,
|
||||||
relayPool,
|
activeAccount.pubkey,
|
||||||
eventStore,
|
articleIdentifier,
|
||||||
activeAccount.pubkey,
|
(savedPosition) => {
|
||||||
articleIdentifier
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||||
// Wait for content to be fully rendered before scrolling
|
// Wait for content to be fully rendered before scrolling
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -237,19 +234,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
}, 500) // Give content time to render
|
}, 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])
|
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||||
|
|
||||||
// Save position before unmounting or changing article
|
// Save position before unmounting or changing article
|
||||||
@@ -577,7 +566,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
|
|
||||||
const handleSearchExternalUrl = () => {
|
const handleSearchExternalUrl = () => {
|
||||||
if (selectedUrl) {
|
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)
|
setShowExternalMenu(false)
|
||||||
}
|
}
|
||||||
@@ -927,13 +922,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
<FontAwesomeIcon icon={faCopy} />
|
<FontAwesomeIcon icon={faCopy} />
|
||||||
<span>Copy URL</span>
|
<span>Copy URL</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
{/* Only show "Open Original" for actual external URLs, not nostr events */}
|
||||||
className="article-menu-item"
|
{!selectedUrl?.startsWith('nostr-event:') && (
|
||||||
onClick={handleOpenExternalUrl}
|
<button
|
||||||
>
|
className="article-menu-item"
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
onClick={handleOpenExternalUrl}
|
||||||
<span>Open Original</span>
|
>
|
||||||
</button>
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
|
<span>Open Original</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="article-menu-item"
|
className="article-menu-item"
|
||||||
onClick={handleSearchExternalUrl}
|
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) => {
|
const unsubProgress = readingProgressController.onProgress((progressMap) => {
|
||||||
setDeduplicatedProgressMap(new Map(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
|
// Run both in parallel
|
||||||
|
|||||||
@@ -656,7 +656,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div key={activeTab}>
|
<div>
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function RouteDebug() {
|
|||||||
// Unexpected during deep-link refresh tests
|
// Unexpected during deep-link refresh tests
|
||||||
console.warn('[RouteDebug] unexpected root redirect', info)
|
console.warn('[RouteDebug] unexpected root redirect', info)
|
||||||
} else {
|
} else {
|
||||||
console.debug('[RouteDebug]', info)
|
// silent
|
||||||
}
|
}
|
||||||
}, [location, matchArticle])
|
}, [location, matchArticle])
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
|||||||
const lang = detect(text)
|
const lang = detect(text)
|
||||||
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
if (typeof lang === 'string' && lang.length >= 2) langOverride = lang.slice(0, 2)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.debug('[tts][detect] failed', err)
|
// ignore detection errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!langOverride && resolvedSystemLang) {
|
if (!langOverride && resolvedSystemLang) {
|
||||||
@@ -78,7 +78,6 @@ const TTSControls: React.FC<Props> = ({ text, defaultLang, className, settings }
|
|||||||
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
const currentIndex = SPEED_OPTIONS.indexOf(rate)
|
||||||
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
const nextIndex = (currentIndex + 1) % SPEED_OPTIONS.length
|
||||||
const next = SPEED_OPTIONS[nextIndex]
|
const next = SPEED_OPTIONS[nextIndex]
|
||||||
console.debug('[tts][ui] cycle speed', { from: rate, to: next, speaking, paused })
|
|
||||||
setRate(next)
|
setRate(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IEventStore } from 'applesauce-core'
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { EventFactory } from 'applesauce-factory'
|
import { EventFactory } from 'applesauce-factory'
|
||||||
import { AccountManager } from 'applesauce-accounts'
|
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 { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||||
import { applyTheme } from '../utils/theme'
|
import { applyTheme } from '../utils/theme'
|
||||||
import { RELAYS } from '../config/relays'
|
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 [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
const [toastType, setToastType] = useState<'success' | 'error'>('success')
|
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(() => {
|
useEffect(() => {
|
||||||
if (!relayPool || !pubkey || !eventStore) return
|
if (!relayPool || !pubkey || !eventStore) return
|
||||||
|
|
||||||
const loadAndWatch = async () => {
|
// Start settings stream: seed from store, stream updates to store in background
|
||||||
try {
|
const stopNetwork = startSettingsStream(relayPool, eventStore, pubkey, RELAYS, (loadedSettings) => {
|
||||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
})
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load settings:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAndWatch()
|
|
||||||
|
|
||||||
|
// Also watch store reactively for any further updates
|
||||||
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
const subscription = watchSettings(eventStore, pubkey, (loadedSettings) => {
|
||||||
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
if (loadedSettings) setSettings({ renderVideoLinksAsEmbeds: true, hideBotArticlesByName: true, ...loadedSettings })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => subscription.unsubscribe()
|
return () => {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
stopNetwork()
|
||||||
|
}
|
||||||
}, [relayPool, pubkey, eventStore])
|
}, [relayPool, pubkey, eventStore])
|
||||||
|
|
||||||
// Apply settings to document
|
// Apply settings to document
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
// Update rate when defaultRate option changes
|
// Update rate when defaultRate option changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (options.defaultRate !== undefined) {
|
if (options.defaultRate !== undefined) {
|
||||||
console.debug('[tts] defaultRate changed ->', options.defaultRate)
|
|
||||||
setRate(options.defaultRate)
|
setRate(options.defaultRate)
|
||||||
}
|
}
|
||||||
}, [options.defaultRate])
|
}, [options.defaultRate])
|
||||||
@@ -73,7 +72,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
if (!voice && v.length) {
|
if (!voice && v.length) {
|
||||||
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
const byLang = v.find(x => x.lang?.toLowerCase().startsWith(defaultLang.toLowerCase()))
|
||||||
setVoice(byLang || v[0] || null)
|
setVoice(byLang || v[0] || null)
|
||||||
console.debug('[tts] voices loaded', { total: v.length, picked: (byLang || v[0] || null)?.lang })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
@@ -107,44 +105,37 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
u.onstart = () => {
|
u.onstart = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onstart')
|
|
||||||
setSpeaking(true)
|
setSpeaking(true)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
u.onpause = () => {
|
u.onpause = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onpause')
|
|
||||||
setPaused(true)
|
setPaused(true)
|
||||||
}
|
}
|
||||||
u.onresume = () => {
|
u.onresume = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onresume')
|
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
u.onend = () => {
|
u.onend = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onend')
|
|
||||||
// Continue with next chunk if available
|
// Continue with next chunk if available
|
||||||
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
const hasMore = chunkIndexRef.current < (chunksRef.current.length - 1)
|
||||||
if (hasMore) {
|
if (hasMore) {
|
||||||
chunkIndexRef.current += 1
|
chunkIndexRef.current++
|
||||||
globalOffsetRef.current += self.text.length
|
charIndexRef.current += self.text.length
|
||||||
const next = chunksRef.current[chunkIndexRef.current] || ''
|
const nextChunk = chunksRef.current[chunkIndexRef.current]
|
||||||
const nextUtterance = createUtterance(next, langRef.current)
|
const nextUtterance = createUtterance(nextChunk, langRef.current)
|
||||||
utteranceRef.current = nextUtterance
|
utteranceRef.current = nextUtterance
|
||||||
synth!.speak(nextUtterance)
|
synth!.speak(nextUtterance)
|
||||||
return
|
} else {
|
||||||
|
setSpeaking(false)
|
||||||
|
setPaused(false)
|
||||||
}
|
}
|
||||||
setSpeaking(false)
|
|
||||||
setPaused(false)
|
|
||||||
utteranceRef.current = null
|
|
||||||
}
|
}
|
||||||
u.onerror = () => {
|
u.onerror = () => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
console.debug('[tts] onerror')
|
|
||||||
setSpeaking(false)
|
setSpeaking(false)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
utteranceRef.current = null
|
|
||||||
}
|
}
|
||||||
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
u.onboundary = (ev: SpeechSynthesisEvent) => {
|
||||||
if (utteranceRef.current !== self) return
|
if (utteranceRef.current !== self) return
|
||||||
@@ -197,7 +188,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
console.debug('[tts] stop')
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
setSpeaking(false)
|
setSpeaking(false)
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
@@ -211,18 +201,16 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
|
|
||||||
const speak = useCallback((text: string, langOverride?: string) => {
|
const speak = useCallback((text: string, langOverride?: string) => {
|
||||||
if (!supported || !text?.trim()) return
|
if (!supported || !text?.trim()) return
|
||||||
console.debug('[tts] speak', { len: text.length, rate })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
spokenTextRef.current = text
|
spokenTextRef.current = text
|
||||||
charIndexRef.current = 0
|
charIndexRef.current = 0
|
||||||
langRef.current = langOverride
|
langRef.current = langOverride
|
||||||
startSpeakingChunks(text)
|
startSpeakingChunks(text)
|
||||||
}, [supported, synth, startSpeakingChunks, rate])
|
}, [supported, synth, startSpeakingChunks])
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (synth!.speaking && !synth!.paused) {
|
if (synth!.speaking && !synth!.paused) {
|
||||||
console.debug('[tts] pause')
|
|
||||||
synth!.pause()
|
synth!.pause()
|
||||||
setPaused(true)
|
setPaused(true)
|
||||||
}
|
}
|
||||||
@@ -231,7 +219,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (synth!.speaking && synth!.paused) {
|
if (synth!.speaking && synth!.paused) {
|
||||||
console.debug('[tts] resume')
|
|
||||||
synth!.resume()
|
synth!.resume()
|
||||||
setPaused(false)
|
setPaused(false)
|
||||||
}
|
}
|
||||||
@@ -242,14 +229,11 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
if (!supported) return
|
if (!supported) return
|
||||||
if (!utteranceRef.current) 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) {
|
if (synth!.speaking && !synth!.paused) {
|
||||||
const fullText = spokenTextRef.current
|
const fullText = spokenTextRef.current
|
||||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length))
|
||||||
const remainingText = fullText.slice(startIndex)
|
const remainingText = fullText.slice(startIndex)
|
||||||
|
|
||||||
console.debug('[tts] restart at new rate', { startIndex, remainingLen: remainingText.length })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
// restart chunked from current global index
|
// restart chunked from current global index
|
||||||
spokenTextRef.current = remainingText
|
spokenTextRef.current = remainingText
|
||||||
@@ -273,7 +257,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
const fullText = spokenTextRef.current
|
const fullText = spokenTextRef.current
|
||||||
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
const startIndex = Math.max(0, Math.min(charIndexRef.current, fullText.length - 1))
|
||||||
const remainingText = fullText.slice(startIndex)
|
const remainingText = fullText.slice(startIndex)
|
||||||
console.debug('[tts] updateRate -> restart', { newRate, startIndex, remainingLen: remainingText.length })
|
|
||||||
synth!.cancel()
|
synth!.cancel()
|
||||||
const u = createUtterance(remainingText)
|
const u = createUtterance(remainingText)
|
||||||
// ensure the new rate is applied immediately on the new utterance
|
// ensure the new rate is applied immediately on the new utterance
|
||||||
@@ -281,7 +264,6 @@ export function useTextToSpeech(options: UseTTSOptions = {}): UseTTS {
|
|||||||
utteranceRef.current = u
|
utteranceRef.current = u
|
||||||
synth!.speak(u)
|
synth!.speak(u)
|
||||||
} else if (utteranceRef.current) {
|
} else if (utteranceRef.current) {
|
||||||
console.debug('[tts] updateRate -> set on utterance', { newRate })
|
|
||||||
utteranceRef.current.rate = newRate
|
utteranceRef.current.rate = newRate
|
||||||
}
|
}
|
||||||
}, [supported, synth, createUtterance])
|
}, [supported, synth, createUtterance])
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { RelayPool } from 'applesauce-relay'
|
import { RelayPool } from 'applesauce-relay'
|
||||||
import { Helpers, EventStore } from 'applesauce-core'
|
import { Helpers, EventStore } from 'applesauce-core'
|
||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
|
||||||
import { NostrEvent } from 'nostr-tools'
|
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 { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
|
||||||
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
import { collectBookmarksFromEvents } from './bookmarkProcessing'
|
||||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||||
import {
|
import {
|
||||||
@@ -65,12 +60,8 @@ class BookmarkController {
|
|||||||
}> = new Map()
|
}> = new Map()
|
||||||
private isLoading = false
|
private isLoading = false
|
||||||
private hydrationGeneration = 0
|
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 externalEventStore: EventStore | null = null
|
||||||
|
private relayPool: RelayPool | null = null
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
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[],
|
ids: string[],
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.eventLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,86 +128,146 @@ class BookmarkController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert IDs to EventPointers
|
// Fetch events using local-first queryEvents
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
await queryEvents(
|
||||||
|
this.relayPool,
|
||||||
|
{ ids: unique },
|
||||||
|
{
|
||||||
|
onEvent: (event) => {
|
||||||
|
// Check if hydration was cancelled
|
||||||
|
if (this.hydrationGeneration !== generation) return
|
||||||
|
|
||||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
idToEvent.set(event.id, event)
|
||||||
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
// Also index by coordinate for addressable events
|
// Add to external event store if available
|
||||||
if (event.kind && event.kind >= 30000 && event.kind < 40000) {
|
if (this.externalEventStore) {
|
||||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
this.externalEventStore.add(event)
|
||||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
}
|
||||||
idToEvent.set(coordinate, 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 }>,
|
coords: Array<{ kind: number; pubkey: string; identifier: string }>,
|
||||||
idToEvent: Map<string, NostrEvent>,
|
idToEvent: Map<string, NostrEvent>,
|
||||||
onProgress: () => void,
|
onProgress: () => void,
|
||||||
generation: number
|
generation: number
|
||||||
): void {
|
): Promise<void> {
|
||||||
if (!this.addressLoader) {
|
if (!this.relayPool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coords.length === 0) return
|
if (coords.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert coordinates to AddressPointers
|
// Group by kind and pubkey for efficient batching
|
||||||
const pointers = coords.map(c => ({
|
const filtersByKind = new Map<number, Map<string, string[]>>()
|
||||||
kind: c.kind,
|
|
||||||
pubkey: c.pubkey,
|
|
||||||
identifier: c.identifier
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
for (const coord of coords) {
|
||||||
from(pointers).pipe(
|
if (!filtersByKind.has(coord.kind)) {
|
||||||
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
filtersByKind.set(coord.kind, new Map())
|
||||||
).subscribe({
|
}
|
||||||
next: (event) => {
|
const byPubkey = filtersByKind.get(coord.kind)!
|
||||||
// Check if hydration was cancelled
|
if (!byPubkey.has(coord.pubkey)) {
|
||||||
if (this.hydrationGeneration !== generation) return
|
byPubkey.set(coord.pubkey, [])
|
||||||
|
}
|
||||||
|
byPubkey.get(coord.pubkey)!.push(coord.identifier || '')
|
||||||
|
}
|
||||||
|
|
||||||
const dTag = event.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
// Kick off all queries in parallel (fire-and-forget)
|
||||||
const coordinate = `${event.kind}:${event.pubkey}:${dTag}`
|
const promises: Promise<void>[] = []
|
||||||
idToEvent.set(coordinate, event)
|
|
||||||
idToEvent.set(event.id, event)
|
|
||||||
|
|
||||||
// Add to external event store if available
|
for (const [kind, byPubkey] of filtersByKind) {
|
||||||
if (this.externalEventStore) {
|
for (const [pubkey, identifiers] of byPubkey) {
|
||||||
this.externalEventStore.add(event)
|
// Separate empty and non-empty identifiers
|
||||||
|
const nonEmptyIdentifiers = identifiers.filter(id => id && id.length > 0)
|
||||||
|
const hasEmptyIdentifier = identifiers.some(id => !id || id.length === 0)
|
||||||
|
|
||||||
|
// 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()
|
// Fetch events with empty d-tag separately (without '#d' filter)
|
||||||
},
|
if (hasEmptyIdentifier) {
|
||||||
error: () => {
|
promises.push(
|
||||||
// Silent error - AddressLoader handles retries
|
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(
|
private async buildAndEmitBookmarks(
|
||||||
@@ -279,8 +330,6 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
|
|
||||||
|
|
||||||
// Helper to build and emit bookmarks
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
||||||
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
|
||||||
@@ -293,10 +342,8 @@ class BookmarkController {
|
|||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
tags: b.tags || [],
|
||||||
// Prefer hydrated content; fallback to any cached event content in external store
|
content: b.content || this.externalEventStore?.getEvent(b.id)?.content || '', // Fallback to eventStore content
|
||||||
content: b.content && b.content.length > 0
|
created_at: b.created_at || this.externalEventStore?.getEvent(b.id)?.created_at || b.created_at
|
||||||
? b.content
|
|
||||||
: (this.externalEventStore?.getEvent(b.id)?.content || '')
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedBookmarks = enriched
|
const sortedBookmarks = enriched
|
||||||
@@ -324,7 +371,7 @@ class BookmarkController {
|
|||||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||||
emitBookmarks(idToEvent)
|
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 generation = this.hydrationGeneration
|
||||||
const onProgress = () => emitBookmarks(idToEvent)
|
const onProgress = () => emitBookmarks(idToEvent)
|
||||||
@@ -339,10 +386,14 @@ class BookmarkController {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Kick off batched hydration (streaming, non-blocking)
|
// Kick off hydration (streaming, non-blocking, local-first)
|
||||||
// EventLoader and AddressLoader handle batching and streaming automatically
|
// Fire-and-forget - don't await, let it run in background
|
||||||
this.hydrateByIds(noteIds, idToEvent, onProgress, generation)
|
this.hydrateByIds(noteIds, idToEvent, onProgress, generation).catch(() => {
|
||||||
this.hydrateByCoordinates(coordObjs, idToEvent, onProgress, generation)
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to build bookmarks:', error)
|
console.error('Failed to build bookmarks:', error)
|
||||||
this.bookmarksListeners.forEach(cb => cb([]))
|
this.bookmarksListeners.forEach(cb => cb([]))
|
||||||
@@ -357,7 +408,8 @@ class BookmarkController {
|
|||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { relayPool, activeAccount, accountManager, eventStore } = options
|
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
|
this.externalEventStore = eventStore || null
|
||||||
|
|
||||||
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
@@ -369,16 +421,6 @@ class BookmarkController {
|
|||||||
// Increment generation to cancel any in-flight hydration
|
// Increment generation to cancel any in-flight hydration
|
||||||
this.hydrationGeneration++
|
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)
|
this.setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,12 +21,16 @@ export interface AddressPointer {
|
|||||||
pubkey: string
|
pubkey: string
|
||||||
identifier: string
|
identifier: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EventPointer {
|
export interface EventPointer {
|
||||||
id: string
|
id: string
|
||||||
relays?: string[]
|
relays?: string[]
|
||||||
author?: string
|
author?: string
|
||||||
|
added_at?: number
|
||||||
|
created_at?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplesauceBookmarks {
|
export interface ApplesauceBookmarks {
|
||||||
@@ -77,14 +81,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: note.created_at || parentCreatedAt || 0,
|
||||||
pubkey: note.author || activeAccount.pubkey,
|
pubkey: note.author || activeAccount.pubkey,
|
||||||
kind: 1, // Short note kind
|
kind: 1, // Short note kind
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
added_at: note.added_at || parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -97,14 +101,14 @@ export const processApplesauceBookmarks = (
|
|||||||
allItems.push({
|
allItems.push({
|
||||||
id: coordinate,
|
id: coordinate,
|
||||||
content: '',
|
content: '',
|
||||||
created_at: parentCreatedAt || 0,
|
created_at: article.created_at || parentCreatedAt || 0,
|
||||||
pubkey: article.pubkey,
|
pubkey: article.pubkey,
|
||||||
kind: article.kind, // Usually 30023 for long-form articles
|
kind: article.kind, // Usually 30023 for long-form articles
|
||||||
tags: [],
|
tags: [],
|
||||||
parsedContent: undefined,
|
parsedContent: undefined,
|
||||||
type: 'event' as const,
|
type: 'event' as const,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
added_at: parentCreatedAt || 0
|
added_at: article.added_at || parentCreatedAt || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class EventManager {
|
|||||||
|
|
||||||
// Safety timeout for event fetches (ms)
|
// Safety timeout for event fetches (ms)
|
||||||
private fetchTimeoutMs = 12000
|
private fetchTimeoutMs = 12000
|
||||||
|
// Retry policy
|
||||||
|
private maxAttempts = 4
|
||||||
|
private baseBackoffMs = 700
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the event manager with event store and relay pool
|
* Initialize the event manager with event store and relay pool
|
||||||
@@ -70,7 +73,7 @@ class EventManager {
|
|||||||
|
|
||||||
// Start a new fetch request
|
// Start a new fetch request
|
||||||
this.pendingRequests.set(eventId, [{ resolve, reject }])
|
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))
|
requests.forEach(req => req.reject(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fetchFromRelayWithRetry(eventId: string, attempt: number): void {
|
||||||
* Actually fetch the event from relay
|
|
||||||
*/
|
|
||||||
private fetchFromRelay(eventId: string): void {
|
|
||||||
// If no loader yet, schedule retry
|
// If no loader yet, schedule retry
|
||||||
if (!this.relayPool || !this.eventLoader) {
|
if (!this.relayPool || !this.eventLoader) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.eventLoader && this.pendingRequests.has(eventId)) {
|
if (this.pendingRequests.has(eventId)) {
|
||||||
this.fetchFromRelay(eventId)
|
this.fetchFromRelayWithRetry(eventId, attempt)
|
||||||
}
|
}
|
||||||
}, 500)
|
}, this.baseBackoffMs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,14 +111,23 @@ class EventManager {
|
|||||||
error: (err: unknown) => {
|
error: (err: unknown) => {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
const error = err instanceof Error ? err : new Error(String(err))
|
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()
|
subscription.unsubscribe()
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
// Completed without next - consider not found
|
// Completed without next - consider not found, but retry a few times
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
clearTimeout(timeoutId)
|
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()
|
subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
@@ -127,8 +136,13 @@ class EventManager {
|
|||||||
// Safety timeout
|
// Safety timeout
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!delivered) {
|
if (!delivered) {
|
||||||
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
if (attempt < this.maxAttempts && this.pendingRequests.has(eventId)) {
|
||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
|
this.fetchFromRelayWithRetry(eventId, attempt + 1)
|
||||||
|
} else {
|
||||||
|
subscription.unsubscribe()
|
||||||
|
this.rejectPending(eventId, new Error('Timed out fetching event'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, this.fetchTimeoutMs)
|
}, this.fetchTimeoutMs)
|
||||||
}
|
}
|
||||||
@@ -139,7 +153,7 @@ class EventManager {
|
|||||||
private retryAllPending(): void {
|
private retryAllPending(): void {
|
||||||
const pendingIds = Array.from(this.pendingRequests.keys())
|
const pendingIds = Array.from(this.pendingRequests.keys())
|
||||||
pendingIds.forEach(eventId => {
|
pendingIds.forEach(eventId => {
|
||||||
this.fetchFromRelay(eventId)
|
this.fetchFromRelayWithRetry(eventId, 1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,10 +75,17 @@ export function processReadingProgress(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
} else if (dTag.startsWith('url:')) {
|
} else if (dTag.startsWith('url:')) {
|
||||||
// It's a URL with base64url encoding
|
// It's a URL. We support both raw URLs and base64url-encoded URLs.
|
||||||
const encoded = dTag.replace('url:', '')
|
const value = dTag.slice(4)
|
||||||
|
const looksBase64Url = /^[A-Za-z0-9_-]+$/.test(value) && (value.includes('-') || value.includes('_'))
|
||||||
try {
|
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
|
itemId = itemUrl
|
||||||
itemType = 'external'
|
itemType = 'external'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -98,11 +98,8 @@ export function generateArticleIdentifier(naddrOrUrl: string): string {
|
|||||||
if (naddrOrUrl.startsWith('nostr:')) {
|
if (naddrOrUrl.startsWith('nostr:')) {
|
||||||
return naddrOrUrl.replace('nostr:', '')
|
return naddrOrUrl.replace('nostr:', '')
|
||||||
}
|
}
|
||||||
// For URLs, use base64url encoding (URL-safe)
|
// For URLs, return the raw URL. Downstream tag generation will encode as needed.
|
||||||
return btoa(naddrOrUrl)
|
return naddrOrUrl
|
||||||
.replace(/\+/g, '-')
|
|
||||||
.replace(/\//g, '_')
|
|
||||||
.replace(/=+$/, '')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,8 +135,53 @@ export async function saveReadingPosition(
|
|||||||
await publishEvent(relayPool, eventStore, signed)
|
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)
|
* 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(
|
export async function loadReadingPosition(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
@@ -149,101 +191,29 @@ export async function loadReadingPosition(
|
|||||||
): Promise<ReadingPosition | null> {
|
): Promise<ReadingPosition | null> {
|
||||||
const dTag = generateDTag(articleIdentifier)
|
const dTag = generateDTag(articleIdentifier)
|
||||||
|
|
||||||
// Check local event store first
|
let initial: ReadingPosition | null = null
|
||||||
try {
|
try {
|
||||||
const localEvent = await firstValueFrom(
|
const localEvent = await firstValueFrom(
|
||||||
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
eventStore.replaceable(READING_PROGRESS_KIND, pubkey, dTag)
|
||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getReadingProgressContent(localEvent)
|
const content = getReadingProgressContent(localEvent)
|
||||||
if (content) {
|
if (content) initial = 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
// Ignore errors and fetch from relays
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch from relays
|
// Start background sync (fire-and-forget; no timeout)
|
||||||
const result = await fetchFromRelays(
|
relayPool
|
||||||
relayPool,
|
.subscription(RELAYS, {
|
||||||
eventStore,
|
kinds: [READING_PROGRESS_KIND],
|
||||||
pubkey,
|
authors: [pubkey],
|
||||||
READING_PROGRESS_KIND,
|
'#d': [dTag]
|
||||||
dTag,
|
})
|
||||||
getReadingProgressContent
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
)
|
.subscribe()
|
||||||
|
|
||||||
return result || null
|
return initial
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,10 +276,10 @@ class ReadingProgressController {
|
|||||||
// Process new events
|
// Process new events
|
||||||
processReadingProgress(events, readsMap)
|
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>()
|
const newProgressMap = new Map<string, number>()
|
||||||
for (const [id, item] of readsMap.entries()) {
|
for (const [id, item] of readsMap.entries()) {
|
||||||
if (item.readingProgress !== undefined && item.type === 'article') {
|
if (item.readingProgress !== undefined) {
|
||||||
newProgressMap.set(id, item.readingProgress)
|
newProgressMap.set(id, item.readingProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,90 +75,82 @@ export interface UserSettings {
|
|||||||
ttsDefaultSpeed?: number // default: 2.1
|
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(
|
export async function loadSettings(
|
||||||
relayPool: RelayPool,
|
relayPool: RelayPool,
|
||||||
eventStore: IEventStore,
|
eventStore: IEventStore,
|
||||||
pubkey: string,
|
pubkey: string,
|
||||||
relays: string[]
|
relays: string[]
|
||||||
): Promise<UserSettings | null> {
|
): Promise<UserSettings | null> {
|
||||||
|
let initial: UserSettings | null = null
|
||||||
|
|
||||||
// First, check if we already have settings in the local event store
|
|
||||||
try {
|
try {
|
||||||
const localEvent = await firstValueFrom(
|
const localEvent = await firstValueFrom(
|
||||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||||
)
|
)
|
||||||
if (localEvent) {
|
if (localEvent) {
|
||||||
const content = getAppDataContent<UserSettings>(localEvent)
|
const content = getAppDataContent<UserSettings>(localEvent)
|
||||||
|
initial = content || null
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
} catch (_err) {
|
} catch {
|
||||||
// Ignore local store errors
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local store, fetch from relays
|
// Start background sync (fire-and-forget; no timeout)
|
||||||
return new Promise((resolve) => {
|
relayPool
|
||||||
let hasResolved = false
|
.subscription(relays, {
|
||||||
const timeout = setTimeout(() => {
|
kinds: [APP_DATA_KIND],
|
||||||
if (!hasResolved) {
|
authors: [pubkey],
|
||||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
'#d': [SETTINGS_IDENTIFIER]
|
||||||
hasResolved = true
|
})
|
||||||
resolve(null)
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
}
|
.subscribe()
|
||||||
}, 5000)
|
|
||||||
|
|
||||||
const sub = relayPool
|
return initial
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
sub.unsubscribe()
|
|
||||||
}, 5000)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
|
|||||||
Reference in New Issue
Block a user