From a9cf35019cf3fc833e67bd67658eab3a90c98fd3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 7 Feb 2026 08:05:43 +0100 Subject: [PATCH] more performance fixes --- public/healthz.json | 4 +- src/lib/modules/comments/CommentThread.svelte | 18 +-- .../modules/discussions/DiscussionList.svelte | 140 ++++++++---------- src/lib/modules/profiles/ProfilePage.svelte | 49 ++++-- src/lib/services/cache/event-cache.ts | 27 +++- src/routes/highlights/+page.svelte | 73 ++++++++- src/routes/lists/+page.svelte | 62 +++++--- src/routes/repos/+page.svelte | 39 ++++- src/routes/topics/[name]/+page.svelte | 38 ++--- 9 files changed, 285 insertions(+), 165 deletions(-) diff --git a/public/healthz.json b/public/healthz.json index 0fa660d..9aeecf7 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.2.0", - "buildTime": "2026-02-07T06:54:31.135Z", + "buildTime": "2026-02-07T07:05:23.594Z", "gitCommit": "unknown", - "timestamp": 1770447271135 + "timestamp": 1770447923594 } \ No newline at end of file diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 2da6f7a..eb5e114 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -300,20 +300,18 @@ } // Load from cache first (fast - instant display) + // Optimized: Batch all kinds into single cache query try { const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js'); - const cachedComments = await getRecentCachedEvents([KIND.COMMENT], 60 * 60 * 1000, config.feedLimit); // 1 hour cache - const cachedKind1 = await getRecentCachedEvents([KIND.SHORT_TEXT_NOTE], 60 * 60 * 1000, config.feedLimit); - const cachedYakBacks = await getRecentCachedEvents([KIND.VOICE_REPLY], 60 * 60 * 1000, config.feedLimit); - const cachedZaps = await getRecentCachedEvents([KIND.ZAP_RECEIPT], 60 * 60 * 1000, config.feedLimit); + // Batch all kinds into one call (optimized in getRecentCachedEvents to use single transaction) + const allCached = await getRecentCachedEvents( + [KIND.COMMENT, KIND.SHORT_TEXT_NOTE, KIND.VOICE_REPLY, KIND.ZAP_RECEIPT], + 60 * 60 * 1000, + config.feedLimit * 4 // Get more since we're filtering by thread + ); // Filter cached events to only those that reference this thread - const cachedReplies = [ - ...cachedComments.filter(r => referencesRoot(r)), - ...cachedKind1.filter(r => referencesRoot(r)), - ...cachedYakBacks.filter(r => referencesRoot(r)), - ...cachedZaps.filter(r => referencesRoot(r)) - ]; + const cachedReplies = allCached.filter(r => referencesRoot(r)); if (cachedReplies.length > 0 && isMounted) { // Process cached replies immediately diff --git a/src/lib/modules/discussions/DiscussionList.svelte b/src/lib/modules/discussions/DiscussionList.svelte index 8a9fc5a..d7f5075 100644 --- a/src/lib/modules/discussions/DiscussionList.svelte +++ b/src/lib/modules/discussions/DiscussionList.svelte @@ -297,51 +297,29 @@ if (!isMounted) return; - // Fetch reactions with lowercase e - const reactionsFetchPromise1 = nostrClient.fetchEvents( - [{ kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit }], + // Optimized: Fetch reactions with both #e and #E in single call (most relays support both) + // If a relay rejects #E, it will just return empty results for that filter + const reactionsFetchPromise = nostrClient.fetchEvents( + [ + { kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit }, + { kinds: [KIND.REACTION], '#E': threadIds, limit: config.feedLimit } + ], reactionRelays, { - useCache: 'relay-first', + useCache: 'cache-first', // Changed from relay-first for better performance cacheResults: true, timeout: config.standardTimeout, onUpdate: handleReactionUpdate } ); - activeFetchPromises.add(reactionsFetchPromise1); - const reactionsWithLowerE = await reactionsFetchPromise1; - activeFetchPromises.delete(reactionsFetchPromise1); + activeFetchPromises.add(reactionsFetchPromise); + const allReactions = await reactionsFetchPromise; + activeFetchPromises.delete(reactionsFetchPromise); if (!isMounted) return; - // Try uppercase filter - let reactionsWithUpperE: NostrEvent[] = []; - try { - const reactionsFetchPromise2 = nostrClient.fetchEvents( - [{ kinds: [KIND.REACTION], '#E': threadIds, limit: config.feedLimit }], - reactionRelays, - { - useCache: 'relay-first', - cacheResults: true, - timeout: config.standardTimeout, - onUpdate: handleReactionUpdate - } - ); - activeFetchPromises.add(reactionsFetchPromise2); - reactionsWithUpperE = await reactionsFetchPromise2; - activeFetchPromises.delete(reactionsFetchPromise2); - if (!isMounted) return; - } catch (error) { - if (isMounted) { - console.log('[DiscussionList] Upper case #E filter rejected by relay (this is normal):', error); - } - } - - // Combine reactions - for (const r of reactionsWithLowerE) { - allReactionsMap.set(r.id, r); - } - for (const r of reactionsWithUpperE) { + // Add all reactions to map (deduplication handled by Map) + for (const r of allReactions) { allReactionsMap.set(r.id, r); } @@ -352,65 +330,71 @@ updateVoteCountsMap(); voteCountsReady = true; - // Fetch zap receipts (for sorting) + // Optimized: Fetch zaps and comments in parallel (they're independent) if (!isMounted) return; const zapFetchPromise = nostrClient.fetchEvents( [{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: config.feedLimit }], zapRelays, - { useCache: 'relay-first', cacheResults: true, timeout: config.standardTimeout } + { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } ); - activeFetchPromises.add(zapFetchPromise); - const allZapReceipts = await zapFetchPromise; - activeFetchPromises.delete(zapFetchPromise); - - if (!isMounted) return; - - // Group zap receipts by thread ID (for sorting) - const newZapReceiptsMap = new Map(); - for (const zapReceipt of allZapReceipts) { - const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1]; - if (threadId && threadsMap.has(threadId)) { - if (!newZapReceiptsMap.has(threadId)) { - newZapReceiptsMap.set(threadId, []); - } - newZapReceiptsMap.get(threadId)!.push(zapReceipt); - } - } - zapReceiptsMap = newZapReceiptsMap; - - // Batch-load comment counts for all threads - if (!isMounted) return; const commentsFetchPromise = nostrClient.fetchEvents( [{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: config.feedLimit }], commentRelays, - { useCache: 'relay-first', cacheResults: true, timeout: config.standardTimeout, priority: 'low' } + { useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout, priority: 'low' } ); + + // Track both promises for cleanup + activeFetchPromises.add(zapFetchPromise); activeFetchPromises.add(commentsFetchPromise); - const allComments = await commentsFetchPromise; - activeFetchPromises.delete(commentsFetchPromise); - if (!isMounted) return; + try { + const [allZapReceipts, allComments] = await Promise.all([ + zapFetchPromise, + commentsFetchPromise + ]); - // Count comments per thread - const newCommentsMap = new Map(); - for (const comment of allComments) { - const threadId = comment.tags.find((t: string[]) => { - const tagName = t[0]; - return (tagName === 'e' || tagName === 'E') && t[1]; - })?.[1]; + if (!isMounted) return; - if (threadId && threadsMap.has(threadId)) { - newCommentsMap.set(threadId, (newCommentsMap.get(threadId) || 0) + 1); + // Group zap receipts by thread ID (for sorting) + const newZapReceiptsMap = new Map(); + for (const zapReceipt of allZapReceipts) { + const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1]; + if (threadId && threadsMap.has(threadId)) { + if (!newZapReceiptsMap.has(threadId)) { + newZapReceiptsMap.set(threadId, []); + } + newZapReceiptsMap.get(threadId)!.push(zapReceipt); + } } - } - - // Set count to 0 for threads with no comments - for (const threadId of threadIds) { - if (!newCommentsMap.has(threadId)) { - newCommentsMap.set(threadId, 0); + zapReceiptsMap = newZapReceiptsMap; + + if (!isMounted) return; + + // Count comments per thread + const newCommentsMap = new Map(); + for (const comment of allComments) { + const threadId = comment.tags.find((t: string[]) => { + const tagName = t[0]; + return (tagName === 'e' || tagName === 'E') && t[1]; + })?.[1]; + + if (threadId && threadsMap.has(threadId)) { + newCommentsMap.set(threadId, (newCommentsMap.get(threadId) || 0) + 1); + } + } + + // Set count to 0 for threads with no comments + for (const threadId of threadIds) { + if (!newCommentsMap.has(threadId)) { + newCommentsMap.set(threadId, 0); + } } + commentsMap = newCommentsMap; + } finally { + // Clean up both promises + activeFetchPromises.delete(zapFetchPromise); + activeFetchPromises.delete(commentsFetchPromise); } - commentsMap = newCommentsMap; } } catch (error) { console.error('Error loading thread data:', error); diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 1d6b9c8..223fe21 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -330,14 +330,18 @@ } // Stream fresh data from relays (progressive enhancement) - // Fetch the actual bookmarked events in batches + // Optimized: Fetch all bookmarked events in parallel batches const batchSize = 100; - const allBookmarkedEvents: NostrEvent[] = []; const bookmarkedIdsArray = Array.from(bookmarkedIds); + const batches: string[][] = []; for (let i = 0; i < bookmarkedIdsArray.length; i += batchSize) { - const batch = bookmarkedIdsArray.slice(i, i + batchSize); - const fetchPromise = nostrClient.fetchEvents( + batches.push(bookmarkedIdsArray.slice(i, i + batchSize)); + } + + // Fetch all batches in parallel + const batchPromises = batches.map(batch => + nostrClient.fetchEvents( [{ ids: batch, limit: batch.length }], profileRelays, { @@ -355,19 +359,34 @@ loadingBookmarks = false; } } - ); - activeFetchPromises.add(fetchPromise); - const batchEvents = await fetchPromise; - activeFetchPromises.delete(fetchPromise); + ) + ); + + // Track all batch promises for cleanup + for (const promise of batchPromises) { + activeFetchPromises.add(promise); + } + + try { + // Wait for all batches to complete + const allBatchResults = await Promise.all(batchPromises); if (!isMounted) return; - allBookmarkedEvents.push(...batchEvents); + + // Merge final results with existing bookmarks (onUpdate may have already updated some) + // This ensures we don't lose any intermediate updates from streaming + const bookmarkMap = new Map(bookmarks.map(b => [b.id, b])); + const allBookmarkedEvents = allBatchResults.flat(); + for (const bookmark of allBookmarkedEvents) { + bookmarkMap.set(bookmark.id, bookmark); + } + bookmarks = Array.from(bookmarkMap.values()).sort((a, b) => b.created_at - a.created_at); + } finally { + // Clean up all batch promises + for (const promise of batchPromises) { + activeFetchPromises.delete(promise); + } } - - if (!isMounted) return; - - // Sort by created_at descending - bookmarks = allBookmarkedEvents.sort((a, b) => b.created_at - a.created_at); } catch (error) { console.error('Error loading bookmarks:', error); if (isMounted) { @@ -1275,4 +1294,4 @@ max-width: 100%; } } - + \ No newline at end of file diff --git a/src/lib/services/cache/event-cache.ts b/src/lib/services/cache/event-cache.ts index 7d869c4..b9de826 100644 --- a/src/lib/services/cache/event-cache.ts +++ b/src/lib/services/cache/event-cache.ts @@ -162,14 +162,25 @@ export async function getRecentCachedEvents(kinds: number[], maxAge: number = 15 const results: CachedEvent[] = []; const seen = new Set(); - // Get events for each kind - for (const kind of kinds) { - const tx = db.transaction('events', 'readonly'); - const index = tx.store.index('kind'); - const events = await index.getAll(kind); - await tx.done; - - // Filter by cache age and deduplicate + // Optimized: Use single transaction for all kinds + const tx = db.transaction('events', 'readonly'); + const kindIndex = tx.store.index('kind'); + + // Get events for all kinds in parallel within single transaction + const kindPromises = kinds.map(async (kind) => { + try { + return await kindIndex.getAll(kind); + } catch (error) { + console.debug(`Error getting events for kind ${kind}:`, error); + return []; + } + }); + + const allKindResults = await Promise.all(kindPromises); + await tx.done; + + // Flatten and filter by cache age and deduplicate + for (const events of allKindResults) { for (const event of events) { if (event.cached_at >= cutoffTime && !seen.has(event.id)) { seen.add(event.id); diff --git a/src/routes/highlights/+page.svelte b/src/routes/highlights/+page.svelte index 8b1df3f..9af0648 100644 --- a/src/routes/highlights/+page.svelte +++ b/src/routes/highlights/+page.svelte @@ -67,10 +67,57 @@ // Computed: total pages let totalPages = $derived.by(() => Math.ceil(filteredItems.length / itemsPerPage)); + // Process highlight events and add them to allItems (for quick display from cache/streaming) + async function processHighlightEvents(highlightEvents: NostrEvent[]) { + if (!highlightEvents || highlightEvents.length === 0) return; + + // Track existing highlight IDs to avoid duplicates + const existingIds = new Set(allItems.map(item => item.event.id)); + + // Add new highlights that aren't already in allItems + const newItems: HighlightItem[] = []; + for (const highlight of highlightEvents) { + if (!existingIds.has(highlight.id)) { + newItems.push({ + event: highlight, + authorPubkey: highlight.pubkey + }); + existingIds.add(highlight.id); + } + } + + // Merge with existing items, sort by created_at (newest first), and limit + if (newItems.length > 0) { + allItems = [...allItems, ...newItems] + .sort((a, b) => b.event.created_at - a.event.created_at) + .slice(0, maxTotalItems); + } + } + async function loadHighlights() { - loading = true; + // Load from cache first (fast - instant display) + try { + const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js'); + const cachedHighlights = await getRecentCachedEvents([KIND.HIGHLIGHTED_ARTICLE], 60 * 60 * 1000, 100); // 1 hour cache + + if (cachedHighlights.length > 0) { + // Process cached highlights immediately + await processHighlightEvents(cachedHighlights); + loading = false; // Show cached content immediately + error = null; + currentPage = 1; + } else { + loading = true; // Only show loading if no cache + } + } catch (error) { + console.debug('Error loading cached highlights:', error); + loading = true; // Show loading if cache check fails + } + error = null; - allItems = []; + if (allItems.length === 0) { + allItems = []; + } currentPage = 1; try { @@ -81,15 +128,27 @@ // Fetch highlight events (kind 9802) - limit 100 const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; + // Stream fresh data from relays (progressive enhancement) const highlightEvents = await nostrClient.fetchEvents( [highlightFilter], allRelaysForHighlights, { - useCache: true, + useCache: 'cache-first', // Already shown cache above, now stream updates cacheResults: true, - timeout: config.standardTimeout + timeout: config.standardTimeout, + onUpdate: async (newHighlights) => { + // Process new highlights as they stream in + if (newHighlights && newHighlights.length > 0) { + await processHighlightEvents(newHighlights); + } + } } ); + + // Process final results (merge with any streaming updates) + if (highlightEvents && highlightEvents.length > 0) { + await processHighlightEvents(highlightEvents); + } console.log(`[Highlights] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`); @@ -193,7 +252,7 @@ aTagFilters, allRelaysForHighlights, { - useCache: true, + useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout } @@ -281,7 +340,7 @@ filters, relays, { - useCache: true, + useCache: 'cache-first', cacheResults: true, timeout: config.mediumTimeout } @@ -353,7 +412,7 @@ [{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], profileRelays, { - useCache: true, + useCache: 'cache-first', cacheResults: true, priority: 'low', timeout: config.standardTimeout diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index 9025f96..ac505c0 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -58,19 +58,19 @@ try { const relays = getAllRelays(); - // Fetch kind 3 (contacts) - replaceable, one per user - const contactsEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }], - relays, - { useCache: true, cacheResults: true } - ); - - // Fetch kind 30000 (follow_set) - parameterized replaceable, multiple per user with d-tags - const followSetEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.FOLLOW_SET], authors: [currentPubkey] }], - relays, - { useCache: true, cacheResults: true } - ); + // Optimized: Fetch both kinds in parallel in a single call + const [contactsEvents, followSetEvents] = await Promise.all([ + nostrClient.fetchEvents( + [{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }], + relays, + { useCache: 'cache-first', cacheResults: true } + ), + nostrClient.fetchEvents( + [{ kinds: [KIND.FOLLOW_SET], authors: [currentPubkey] }], + relays, + { useCache: 'cache-first', cacheResults: true } + ) + ]); const allLists: ListInfo[] = []; @@ -137,7 +137,28 @@ return; } - loadingEvents = true; + // Load from cache first (fast - instant display) + try { + const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js'); + const feedKinds = getFeedKinds(); + const cachedEvents = await getRecentCachedEvents(feedKinds, 60 * 60 * 1000, 100); // 1 hour cache + + // Filter cached events to only those from list pubkeys + const listPubkeySet = new Set(list.pubkeys); + const filteredCached = cachedEvents.filter(e => listPubkeySet.has(e.pubkey)); + + if (filteredCached.length > 0) { + events = filteredCached.sort((a, b) => b.created_at - a.created_at); + loadingEvents = false; // Show cached content immediately + } else { + loadingEvents = true; // Only show loading if no cache + } + } catch (error) { + console.debug('Error loading cached list events:', error); + loadingEvents = true; // Show loading if cache check fails + } + + // Stream fresh data from relays (progressive enhancement) try { const relays = getAllRelays(); const feedKinds = getFeedKinds(); // Get all kinds with showInFeed: true @@ -147,7 +168,11 @@ // Function to merge new events into the view const mergeEvents = (newEvents: NostrEvent[]) => { - for (const event of newEvents) { + // Filter to only events from list pubkeys + const listPubkeySet = new Set(list.pubkeys); + const filtered = newEvents.filter(e => listPubkeySet.has(e.pubkey)); + + for (const event of filtered) { eventsMap.set(event.id, event); } // Convert map to array, sort by created_at descending (newest first) @@ -156,8 +181,7 @@ }; // Fetch events from all pubkeys in the list, with showInFeed kinds - // useCache: true will return cached events immediately, then fetch from relays in background - // onUpdate will be called as new events arrive from relays + // Already shown cache above, now stream updates from relays const fetchedEvents = await nostrClient.fetchEvents( [{ kinds: feedKinds, @@ -166,13 +190,13 @@ }], relays, { - useCache: true, + useCache: 'cache-first', // Already shown cache above, now stream updates cacheResults: true, onUpdate: mergeEvents // Update view as new events arrive from relays } ); - // Initial merge of cached events + // Final merge of any remaining events mergeEvents(fetchedEvents); } catch (error) { console.error('Error loading list events:', error); diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 4a0e686..c16e7d7 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -40,8 +40,8 @@ async function loadCachedRepos() { try { - // Load cached repos (within 15 minutes) - const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 15 * 60 * 1000, 100); + // Load cached repos (within 1 hour - optimized for slow connections) + const cachedRepos = await getRecentCachedEvents([KIND.REPO_ANNOUNCEMENT], 60 * 60 * 1000, 100); if (cachedRepos.length > 0) { // For parameterized replaceable events, get the newest version of each (by pubkey + d tag) @@ -79,13 +79,40 @@ // Fetch repo announcement events const allRepos: NostrEvent[] = []; + // Stream fresh data from relays (progressive enhancement) const events = await nostrClient.fetchEvents( [{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }], relays, - { useCache: true, cacheResults: true } + { + useCache: 'cache-first', // Already shown cache above, now stream updates + cacheResults: true, + onUpdate: (newRepos) => { + // Merge with existing repos as they stream in + const reposByKey = new Map(); + + // Add existing repos first + for (const repo of repos) { + const dTag = repo.tags.find(t => t[0] === 'd')?.[1] || ''; + const key = `${repo.pubkey}:${dTag}`; + reposByKey.set(key, repo); + } + + // Add/update with new repos + for (const event of newRepos) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''; + const key = `${event.pubkey}:${dTag}`; + const existing = reposByKey.get(key); + if (!existing || event.created_at > existing.created_at) { + reposByKey.set(key, event); + } + } + + repos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at); + } + } ); - // Merge with existing cached repos + // Final merge of any remaining events const reposByKey = new Map(); // Add existing cached repos first @@ -105,10 +132,8 @@ } } - allRepos.push(...Array.from(reposByKey.values())); - // Sort by created_at descending - repos = allRepos.sort((a, b) => b.created_at - a.created_at); + repos = Array.from(reposByKey.values()).sort((a, b) => b.created_at - a.created_at); } catch (error) { console.error('Error loading repos:', error); repos = []; diff --git a/src/routes/topics/[name]/+page.svelte b/src/routes/topics/[name]/+page.svelte index 66a5c8a..48f863b 100644 --- a/src/routes/topics/[name]/+page.svelte +++ b/src/routes/topics/[name]/+page.svelte @@ -62,8 +62,8 @@ if (!topicName || loadingEvents) return; try { - // Load cached events for this topic (within 15 minutes) - const cachedEvents = await getRecentCachedEvents([KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], 15 * 60 * 1000, 100); + // Load cached events for this topic (within 1 hour - optimized for slow connections) + const cachedEvents = await getRecentCachedEvents([KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], 60 * 60 * 1000, 100); if (cachedEvents.length > 0) { // Filter events that match the topic @@ -118,30 +118,30 @@ // Only fetch events with matching t-tag (most efficient - uses relay filtering) // This avoids fetching all events and filtering client-side, saving bandwidth + // Stream events as they arrive (progressive enhancement) const tTagEvents = await nostrClient.fetchEvents( [{ kinds: [KIND.SHORT_TEXT_NOTE, KIND.DISCUSSION_THREAD], '#t': [topicName], limit: config.feedLimit }], relays, - { useCache: true, cacheResults: true, caller: `topics/[name]/+page.svelte (t-tag)` } + { + useCache: 'cache-first', // Already shown cache above, now stream updates + cacheResults: true, + caller: `topics/[name]/+page.svelte (t-tag)`, + onUpdate: (newEvents) => { + // Merge with existing events as they stream in + const eventMap = new Map(events.map(e => [e.id, e])); + for (const event of newEvents) { + eventMap.set(event.id, event); + } + events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at); + } + } ); - // Use t-tag events as the primary source - // Content-based hashtag search is too inefficient (would require fetching all events) - // Users should use t-tags for proper topic organization - const allEvents: NostrEvent[] = [...tTagEvents]; - - // Merge with existing cached events and deduplicate - const eventMap = new Map(); - - // Add existing cached events first - for (const event of events) { + // Final merge of any remaining events + const eventMap = new Map(events.map(e => [e.id, e])); + for (const event of tTagEvents) { eventMap.set(event.id, event); } - - // Add new events - for (const event of allEvents) { - eventMap.set(event.id, event); - } - events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at); } catch (error) { console.error('Error loading topic events:', error);