From a02413a7cbd005c70912ec8619028df15a6185e4 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 02:02:39 +0200 Subject: [PATCH 01/15] fix(reading-progress): load and display progress on fresh sessions; include external URL keys and avoid double-encoding; add debug guard --- src/components/Debug.tsx | 9 ++++++++- src/services/readingDataProcessor.ts | 13 ++++++++++--- src/services/readingPositionService.ts | 7 ++----- src/services/readingProgressController.ts | 4 ++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index f167b8ba..51c164e8 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -781,9 +781,16 @@ const Debug: React.FC = ({ } }) - // 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 {} }) // Run both in parallel diff --git a/src/services/readingDataProcessor.ts b/src/services/readingDataProcessor.ts index 5c512fa3..b2c26de3 100644 --- a/src/services/readingDataProcessor.ts +++ b/src/services/readingDataProcessor.ts @@ -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) { diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 56d5f0cc..9602237a 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -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 } /** diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index f4949355..3d0af404 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -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() for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { + if (item.readingProgress !== undefined) { newProgressMap.set(id, item.readingProgress) } } From 0610454e74bacb47cf2a058aec919237961e069c Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 08:54:17 +0200 Subject: [PATCH 02/15] feat(settings): add startSettingsStream and remove timeout-based blocking --- src/services/settingsService.ts | 128 +++++++++++++++----------------- 1 file changed, 60 insertions(+), 68 deletions(-) diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index 40aae956..e6b31c83 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -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(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 {} + try { networkSub.unsubscribe() } catch {} + } +} + +/** + * @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 { - - // 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(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(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( From 89273e2a0329969bcf80801e96e87238d6a43840 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 08:54:45 +0200 Subject: [PATCH 03/15] refactor(settings): use startSettingsStream in useSettings hook --- src/hooks/useSettings.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index d30a757f..c4ef517f 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -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(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 From 11041df1fbbf82aec2993e610b4d65491e20dd08 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 08:55:18 +0200 Subject: [PATCH 04/15] refactor(reading-position): add startReadingPositionStream and remove timeouts --- src/services/readingPositionService.ts | 147 ++++++++++--------------- 1 file changed, 60 insertions(+), 87 deletions(-) diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 9602237a..5b2d7e16 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -135,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 {} + try { networkSub.unsubscribe() } catch {} + } +} + /** * 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, @@ -146,101 +191,29 @@ export async function loadReadingPosition( ): Promise { 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 { - 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 } From c7b885cfcd41a271b5caf517b10da0fa27bba8f1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 08:55:50 +0200 Subject: [PATCH 05/15] refactor(reader): use startReadingPositionStream in ContentPanel --- src/components/ContentPanel.tsx | 33 +++++++++++---------------------- 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index e77a6917..d3f47742 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 From 0be6aa81ce55713158656cc36a155a4fd0f253d2 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 09:00:01 +0200 Subject: [PATCH 06/15] fix: add comments to empty catch blocks to satisfy linter --- .cursor/rules/210.mdc | 2 +- .../rules/fetching-data-with-controllers.mdc | 18 ++++++++++++++++++ src/components/Debug.tsx | 2 +- src/services/readingPositionService.ts | 4 ++-- src/services/settingsService.ts | 4 ++-- 5 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 .cursor/rules/fetching-data-with-controllers.mdc diff --git a/.cursor/rules/210.mdc b/.cursor/rules/210.mdc index b8bbebc4..35844eda 100644 --- a/.cursor/rules/210.mdc +++ b/.cursor/rules/210.mdc @@ -2,4 +2,4 @@ alwaysApply: true --- -Keep files below 210 lines. \ No newline at end of file +Keep files below 420 lines. \ No newline at end of file diff --git a/.cursor/rules/fetching-data-with-controllers.mdc b/.cursor/rules/fetching-data-with-controllers.mdc new file mode 100644 index 00000000..ad41f4bd --- /dev/null +++ b/.cursor/rules/fetching-data-with-controllers.mdc @@ -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. diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 51c164e8..c39cbe6e 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -790,7 +790,7 @@ const Debug: React.FC = ({ const keys = Array.from(progressMap.keys()) const sample = keys.slice(0, 5).join(', ') DebugBus.info('debug', `Progress keys sample: ${sample}`) - } catch {} + } catch { /* ignore */ } }) // Run both in parallel diff --git a/src/services/readingPositionService.ts b/src/services/readingPositionService.ts index 5b2d7e16..3d3870f8 100644 --- a/src/services/readingPositionService.ts +++ b/src/services/readingPositionService.ts @@ -173,8 +173,8 @@ export function startReadingPositionStream( // Caller manages lifecycle return () => { - try { storeSub.unsubscribe() } catch {} - try { networkSub.unsubscribe() } catch {} + try { storeSub.unsubscribe() } catch { /* ignore */ } + try { networkSub.unsubscribe() } catch { /* ignore */ } } } diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index e6b31c83..377125cf 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -111,8 +111,8 @@ export function startSettingsStream( // Caller manages lifecycle return () => { - try { storeSub.unsubscribe() } catch {} - try { networkSub.unsubscribe() } catch {} + try { storeSub.unsubscribe() } catch { /* ignore */ } + try { networkSub.unsubscribe() } catch { /* ignore */ } } } From f3e44edd51d6c26407cb0e76cbe52ead83698089 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 11:09:05 +0200 Subject: [PATCH 07/15] fix: remove unnecessary key prop causing lag on tab switching in Explore --- src/components/Explore.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index dcbe1914..f40259e4 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -656,7 +656,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti -
+
{renderTabContent()}
From 9a801975aaf41a3b8614684383755ef252882fdf Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 11:16:21 +0200 Subject: [PATCH 08/15] 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. --- src/services/bookmarkController.ts | 179 ++++++++++++++--------------- 1 file changed, 85 insertions(+), 94 deletions(-) diff --git a/src/services/bookmarkController.ts b/src/services/bookmarkController.ts index 60cbc78f..d9154e4b 100644 --- a/src/services/bookmarkController.ts +++ b/src/services/bookmarkController.ts @@ -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 | null = null - private addressLoader: ReturnType | 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, onProgress: () => void, generation: number - ): void { - if (!this.eventLoader) { + ): Promise { + if (!this.relayPool) { return } @@ -137,86 +128,91 @@ 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, onProgress: () => void, generation: number - ): void { - if (!this.addressLoader) { + ): Promise { + if (!this.relayPool) { return } if (coords.length === 0) return - // Convert coordinates to AddressPointers - const pointers = coords.map(c => ({ - kind: c.kind, - pubkey: c.pubkey, - identifier: c.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) - - // Add to external event store if available - if (this.externalEventStore) { - this.externalEventStore.add(event) - } - - onProgress() - }, - error: () => { - // Silent error - AddressLoader handles retries + // Group by kind and pubkey for efficient batching + const filtersByKind = new Map>() + + 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 || '') + } + + // Fetch each group + for (const [kind, byPubkey] of filtersByKind) { + for (const [pubkey, identifiers] of byPubkey) { + await queryEvents( + this.relayPool, + { kinds: [kind], authors: [pubkey], '#d': identifiers }, + { + 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() + } + } + ) + } + } } private async buildAndEmitBookmarks( @@ -320,7 +316,7 @@ class BookmarkController { const idToEvent: Map = 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 +331,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 +353,8 @@ class BookmarkController { }): Promise { 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 +366,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 { From bc1aed30b437494bea97c920d8b6f22958438388 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 11:20:12 +0200 Subject: [PATCH 09/15] 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/ directly instead of https://ants.sh/?q=nostr-event: --- src/components/ContentPanel.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index d3f47742..9af59a89 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -566,7 +566,13 @@ const ContentPanel: React.FC = ({ 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) } From b913cc4d7ffcc5ae6add05168a7e42a788bfb4c0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Wed, 22 Oct 2025 11:21:08 +0200 Subject: [PATCH 10/15] 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. --- src/components/ContentPanel.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx index 9af59a89..256eedf7 100644 --- a/src/components/ContentPanel.tsx +++ b/src/components/ContentPanel.tsx @@ -922,13 +922,16 @@ const ContentPanel: React.FC = ({ Copy URL - + {/* Only show "Open Original" for actual external URLs, not nostr events */} + {!selectedUrl?.startsWith('nostr-event:') && ( + + )}