From c45eea1aa2f00ebeb85060b0ed36e72ed2b8333b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 5 Feb 2026 10:09:13 +0100 Subject: [PATCH] fix build. reduce RAM usage --- package.json | 2 +- public/healthz.json | 6 +- src/lib/components/content/PollCard.svelte | 2 +- .../components/write/CreateEventForm.svelte | 2 +- src/lib/modules/comments/CommentThread.svelte | 92 ++++- src/lib/modules/feed/FeedPage.svelte | 361 ++++++++++++++++-- src/lib/modules/feed/FeedPost.svelte | 36 +- src/lib/modules/profiles/ProfilePage.svelte | 76 +++- src/lib/modules/threads/ThreadList.svelte | 84 +++- src/lib/services/nostr/nip30-emoji.ts | 2 +- src/lib/services/nostr/nostr-client.ts | 2 +- src/lib/services/user-data.ts | 48 ++- 12 files changed, 587 insertions(+), 126 deletions(-) diff --git a/package.json b/package.json index 5377ae2..3512f33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aitherboard", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "author": "silberengel@gitcitadel.com", "description": "A decentralized messageboard built on the Nostr protocol.", diff --git a/public/healthz.json b/public/healthz.json index f87be17..58fb2b7 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -1,8 +1,8 @@ { "status": "ok", "service": "aitherboard", - "version": "0.1.0", - "buildTime": "2026-02-05T07:24:19.725Z", + "version": "0.1.1", + "buildTime": "2026-02-05T09:08:05.545Z", "gitCommit": "unknown", - "timestamp": 1770276259725 + "timestamp": 1770282485545 } \ No newline at end of file diff --git a/src/lib/components/content/PollCard.svelte b/src/lib/components/content/PollCard.svelte index 6bed20f..f667b52 100644 --- a/src/lib/components/content/PollCard.svelte +++ b/src/lib/components/content/PollCard.svelte @@ -120,7 +120,7 @@ const until = endsAt ? Math.min(endsAt, now) : now; const responseEvents = await nostrClient.fetchEvents( - [{ kinds: [KIND.POLL_RESPONSE], '#e': [pollEvent.id], until, limit: 1000 }], + [{ kinds: [KIND.POLL_RESPONSE], '#e': [pollEvent.id], until, limit: 100 }], relays, { useCache: true, cacheResults: true } ); diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 9de8725..34e0a86 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -700,7 +700,7 @@ } } - function clearForm() { + async function clearForm() { if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) { try { // Mark form as cleared to prevent initial props from re-applying diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 6b73b38..84753c2 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -29,7 +29,20 @@ const isKind1 = $derived(event?.kind === KIND.SHORT_TEXT_NOTE); const rootKind = $derived(event?.kind || null); + // Cleanup tracking + let isMounted = $state(true); + let activeFetchPromises = $state>>(new Set()); + + // Cleanup on unmount + $effect(() => { + return () => { + isMounted = false; + activeFetchPromises.clear(); + }; + }); + onMount(async () => { + isMounted = true; await nostrClient.initialize(); }); @@ -58,11 +71,16 @@ // Load comments - filters will adapt based on whether event is available // Ensure nostrClient is initialized first loadingPromise = nostrClient.initialize().then(() => { + if (!isMounted) return; // Don't load if unmounted return loadComments(); }).catch((error) => { - console.error('Error initializing nostrClient in CommentThread:', error); + if (isMounted) { // Only log if still mounted + console.error('Error initializing nostrClient in CommentThread:', error); + } // Still try to load comments even if initialization fails - return loadComments(); + if (isMounted) { + return loadComments(); + } }).finally(() => { loadingPromise = null; }); @@ -163,6 +181,7 @@ } function handleReplyUpdate(updated: NostrEvent[]) { + if (!isMounted) return; // Don't update if unmounted // Prevent recursive calls if (isProcessingUpdate) { return; @@ -217,6 +236,8 @@ } // Update state immediately if we have new replies + if (!isMounted) return; // Don't update if unmounted + if (hasNewReplies) { const allComments = Array.from(commentsMap.values()); const allKind1Replies = Array.from(kind1RepliesMap.values()); @@ -258,6 +279,7 @@ } async function loadComments() { + if (!isMounted) return; if (!threadId) { loading = false; return; @@ -278,11 +300,18 @@ // then fetches fresh data in background. Only show loading if no cache. try { // Quick cache check - if we have cache, don't show loading - const quickCacheCheck = await nostrClient.fetchEvents( + if (!isMounted) return; + + const fetchPromise1 = nostrClient.fetchEvents( replyFilters, allRelays, { useCache: true, cacheResults: false, timeout: 50 } ); + activeFetchPromises.add(fetchPromise1); + const quickCacheCheck = await fetchPromise1; + activeFetchPromises.delete(fetchPromise1); + + if (!isMounted) return; if (quickCacheCheck.length === 0) { loading = true; // Only show loading if no cache @@ -291,7 +320,7 @@ // Now fetch with full options - returns relay results immediately, then enhances with cache // onUpdate callback will be called as events arrive from relays, allowing immediate rendering // Use high priority to ensure comments load before background fetches (reactions, profiles, etc.) - const allReplies = await nostrClient.fetchEvents( + const fetchPromise2 = nostrClient.fetchEvents( replyFilters, allRelays, { @@ -302,6 +331,11 @@ priority: 'high' } ); + activeFetchPromises.add(fetchPromise2); + const allReplies = await fetchPromise2; + activeFetchPromises.delete(fetchPromise2); + + if (!isMounted) return; // Don't process if unmounted // Process initial results (from relays or cache) // Note: onUpdate may have already updated the state and cleared loading @@ -376,14 +410,16 @@ // Use a single subscription that covers all reply IDs const nestedFilters: any[] = [ - { kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 200 }, - { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 200 }, - { kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 200 }, - { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 200 }, - { kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 200 } + { kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 100 }, + { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 }, + { kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 100 }, + { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 }, + { kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 100 } ]; - nostrClient.fetchEvents( + if (!isMounted) return; // Don't subscribe if unmounted + + const subscriptionPromise = nostrClient.fetchEvents( nestedFilters, allRelays, { @@ -392,13 +428,21 @@ onUpdate: handleReplyUpdate, priority: 'high' } - ).catch(error => { - console.error('Error subscribing to nested replies:', error); + ); + activeFetchPromises.add(subscriptionPromise); + subscriptionPromise.catch(error => { + if (isMounted) { // Only log if still mounted + console.error('Error subscribing to nested replies:', error); + } nestedSubscriptionActive = false; + }).finally(() => { + activeFetchPromises.delete(subscriptionPromise); }); } async function fetchNestedReplies() { + if (!isMounted) return; // Don't fetch if unmounted + // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache const allRelays = relayManager.getProfileReadRelays(); let hasNewReplies = true; @@ -406,7 +450,7 @@ const maxIterations = 3; // Reduced from 10 to prevent excessive fetching const maxReplyIdsPerIteration = 100; // Limit number of reply IDs to check per iteration - while (hasNewReplies && iterations < maxIterations) { + while (isMounted && hasNewReplies && iterations < maxIterations) { iterations++; hasNewReplies = false; const allReplyIds = Array.from(new Set([ @@ -422,23 +466,28 @@ if (limitedReplyIds.length > 0) { const nestedFilters: any[] = [ // Fetch nested kind 1111 comments - check both e/E and a/A tags - { kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 200 }, - { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 200 }, - { kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: 200 }, - { kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: 200 }, + { kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 100 }, + { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 }, + { kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: 100 }, + { kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: 100 }, // Fetch nested kind 1 replies - { kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 200 }, + { kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 100 }, // Fetch nested yak backs - { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 200 }, + { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 }, // Fetch nested zap receipts - { kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 200 } + { kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 100 } ]; - const nestedReplies = await nostrClient.fetchEvents( + const fetchPromise = nostrClient.fetchEvents( nestedFilters, allRelays, { useCache: true, cacheResults: true, timeout: 5000 } ); + activeFetchPromises.add(fetchPromise); + const nestedReplies = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) break; // Exit loop if unmounted // Add new replies by type for (const reply of nestedReplies) { @@ -583,6 +632,7 @@ } async function loadCommentsFresh() { + if (!isMounted) return; if (!threadId) { loading = false; return; diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index b320806..db0b98b 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -35,6 +35,15 @@ // Batch-loaded reactions: eventId -> reactions[] let reactionsMap = $state>(new Map()); + // Batch-loaded parent events: eventId -> parentEvent + let parentEventsMap = $state>(new Map()); + + // Batch-loaded quoted events: eventId -> quotedEvent + let quotedEventsMap = $state>(new Map()); + + // Batch-loaded zap counts: eventId -> count + let zapCountsMap = $state>(new Map()); + // Drawer state for viewing parent/quoted events let drawerOpen = $state(false); let drawerEvent = $state(null); @@ -58,11 +67,17 @@ let subscriptionId: string | null = $state(null); let refreshInterval: ReturnType | null = null; let subscriptionSetup = $state(false); // Track if subscription is already set up + let isMounted = $state(true); // Track if component is still mounted + let activeFetchPromises = $state>>(new Set()); // Track active fetch promises onMount(async () => { + isMounted = true; await nostrClient.initialize(); + if (!isMounted) return; // Check if unmounted during init await loadUserLists(); + if (!isMounted) return; await loadFeed(); + if (!isMounted) return; // Set up persistent subscription for new events (only once) if (!subscriptionSetup) { setupSubscription(); @@ -214,14 +229,38 @@ // Cleanup subscription on unmount $effect(() => { return () => { + isMounted = false; // Mark as unmounted to halt all operations + + // Cancel all active fetch promises + activeFetchPromises.forEach(promise => { + // Promises can't be cancelled directly, but we'll ignore their results + }); + activeFetchPromises.clear(); + + // Unsubscribe from real-time updates if (subscriptionId) { nostrClient.unsubscribe(subscriptionId); subscriptionId = null; } + + // Clear refresh interval if (refreshInterval) { clearInterval(refreshInterval); refreshInterval = null; } + + // Clear update timeout + if (updateTimeout) { + clearTimeout(updateTimeout); + updateTimeout = null; + } + + // Disconnect intersection observer + if (observer) { + observer.disconnect(); + observer = null; + } + subscriptionSetup = false; }; }); @@ -241,25 +280,7 @@ }; }); - // Cleanup on unmount - $effect(() => { - return () => { - if (observer) { - observer.disconnect(); - } - if (updateTimeout) { - clearTimeout(updateTimeout); - } - if (subscriptionId) { - nostrClient.unsubscribe(subscriptionId); - subscriptionId = null; - } - if (refreshInterval) { - clearInterval(refreshInterval); - refreshInterval = null; - } - }; - }); + // This cleanup is now handled in the main cleanup effect above // Set up persistent subscription for real-time updates function setupSubscription() { @@ -314,6 +335,13 @@ // Refresh every 30 seconds refreshInterval = setInterval(async () => { + if (!isMounted) { + if (refreshInterval) { + clearInterval(refreshInterval); + refreshInterval = null; + } + return; // Don't refresh if component is unmounted + } try { // Use single relay if provided, otherwise use normal relay list const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); @@ -386,6 +414,7 @@ }); async function loadFeed() { + if (!isMounted) return; // Don't load if component is unmounted loading = true; relayError = null; // Clear any previous errors try { @@ -430,6 +459,7 @@ useCache: true, // Fill from cache if relay query returns nothing cacheResults: true, // Cache the results timeout: 3000, // 3-second timeout + priority: 'high' as const, // High priority for posts/highlights - display first onUpdate: (updatedEvents: NostrEvent[]) => { // Update incrementally as events arrive handleUpdate(updatedEvents); @@ -440,8 +470,13 @@ console.log(`[FeedPage] Single-relay mode: fetching from ${singleRelay} with useCache=false, cacheResults=false`); } - const events = await nostrClient.fetchEvents(filters, relays, fetchOptions); - + const fetchPromise = nostrClient.fetchEvents(filters, relays, fetchOptions); + activeFetchPromises.add(fetchPromise); + const events = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted + console.log(`[FeedPage] Loaded ${events.length} events from ${singleRelay ? `single relay ${singleRelay}` : 'relays'} (relay-first mode)`); // Separate events by kind - we'll handle all showInFeed kinds @@ -506,8 +541,33 @@ if (sortedPosts.length > 0 || sortedHighlights.length > 0) { const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)]; oldestTimestamp = Math.min(...allTimestamps); - // Batch load reactions for all posts - await loadReactionsForPosts(sortedPosts); + + // Load secondary data (reactions, profiles, etc.) with low priority after posts are displayed + // Use setTimeout to defer loading so posts/highlights render first + const secondaryDataPromise = new Promise((resolve) => { + setTimeout(() => { + if (!isMounted) { + resolve(); + return; // Don't load if component is unmounted + } + // Load in parallel but with low priority - don't await, let it load in background + const promise = Promise.all([ + loadReactionsForPosts(sortedPosts), + loadParentAndQuotedEvents(sortedPosts), + loadZapCountsForPosts(sortedPosts), + loadProfilesForPosts(sortedPosts) + ]).catch(error => { + if (isMounted) { // Only log if still mounted + console.error('[FeedPage] Error loading secondary data:', error); + } + }).finally(() => { + activeFetchPromises.delete(promise); + resolve(); + }); + activeFetchPromises.add(promise); + }, 100); // Small delay to ensure posts render first + }); + activeFetchPromises.add(secondaryDataPromise); } else { console.log('[FeedPage] No events found. Relays:', relays); // In single-relay mode, if we got 0 events, it might mean the relay doesn't have any @@ -525,7 +585,7 @@ } async function loadMore() { - if (loadingMore || !hasMore) return; + if (!isMounted || loadingMore || !hasMore) return; // Don't load if unmounted loadingMore = true; try { @@ -543,7 +603,7 @@ // In single-relay mode: never use cache, only fetch directly from relay // In normal mode: use relay-first with cache fallback - const events = await nostrClient.fetchEvents( + const fetchPromise = nostrClient.fetchEvents( filters, relays, singleRelay ? { @@ -558,6 +618,11 @@ timeout: 3000 // 3-second timeout } ); + activeFetchPromises.add(fetchPromise); + const events = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted if (events.length === 0) { hasMore = false; @@ -585,8 +650,30 @@ if (uniqueNewPosts.length > 0) { const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at); allPosts = [...allPosts, ...sorted]; - // Batch load reactions for new posts - await loadReactionsForPosts(sorted); + // Load secondary data with low priority after posts are displayed + const secondaryDataPromise = new Promise((resolve) => { + setTimeout(() => { + if (!isMounted) { + resolve(); + return; // Don't load if component is unmounted + } + const promise = Promise.all([ + loadReactionsForPosts(sorted), + loadParentAndQuotedEvents(sorted), + loadZapCountsForPosts(sorted), + loadProfilesForPosts(sorted) + ]).catch(error => { + if (isMounted) { // Only log if still mounted + console.error('[FeedPage] Error loading secondary data for new posts:', error); + } + }).finally(() => { + activeFetchPromises.delete(promise); + resolve(); + }); + activeFetchPromises.add(promise); + }, 100); + }); + activeFetchPromises.add(secondaryDataPromise); } if (uniqueNewHighlights.length > 0) { @@ -638,6 +725,7 @@ // Debounced update handler to prevent rapid re-renders and loops function handleUpdate(updated: NostrEvent[]) { + if (!isMounted) return; // Don't update if component is unmounted if (!updated || updated.length === 0) return; // Deduplicate incoming updates before adding to pending @@ -669,6 +757,7 @@ // Batch updates every 500ms to prevent rapid re-renders updateTimeout = setTimeout(() => { + if (!isMounted) return; // Don't update if component is unmounted if (pendingUpdates.length === 0) { return; } @@ -768,7 +857,7 @@ // Batch load reactions for multiple posts at once async function loadReactionsForPosts(postsToLoad: NostrEvent[]) { - if (postsToLoad.length === 0) return; + if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted try { const reactionRelays = relayManager.getProfileReadRelays(); @@ -779,24 +868,31 @@ // Batch fetch all reactions for all posts in one query // In single-relay mode: never use cache - const allReactions = await nostrClient.fetchEvents( + const fetchPromise = nostrClient.fetchEvents( [ - { kinds: [KIND.REACTION], '#e': eventIds, limit: 1000 }, - { kinds: [KIND.REACTION], '#E': eventIds, limit: 1000 } + { kinds: [KIND.REACTION], '#e': eventIds, limit: 100 }, + { kinds: [KIND.REACTION], '#E': eventIds, limit: 100 } ], relaysForReactions, singleRelay ? { relayFirst: true, useCache: false, // Never use cache in single-relay mode cacheResults: false, // Don't cache in single-relay mode - timeout: 3000 + timeout: 3000, + priority: 'low' // Low priority - secondary data } : { relayFirst: true, useCache: true, cacheResults: true, - timeout: 3000 + timeout: 3000, + priority: 'low' // Low priority - secondary data } ); + activeFetchPromises.add(fetchPromise); + const allReactions = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted // Group reactions by event ID const newReactionsMap = new Map(); @@ -826,6 +922,196 @@ console.error('[FeedPage] Error batch loading reactions:', error); } } + + // Batch load parent and quoted events for all posts + async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) { + if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted + + try { + const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); + + // Collect all parent and quoted event IDs + const parentEventIds = new Set(); + const quotedEventIds = new Set(); + + for (const post of postsToLoad) { + // Check for parent event (reply) + const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); + if (replyTag && replyTag[1]) { + parentEventIds.add(replyTag[1]); + } else { + // Fallback: find any 'e' tag that's not the root + const rootId = post.tags.find((t) => t[0] === 'root')?.[1]; + const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); + if (eTag && eTag[1]) { + parentEventIds.add(eTag[1]); + } + } + + // Check for quoted event + const quotedTag = post.tags.find((t) => t[0] === 'q'); + if (quotedTag && quotedTag[1]) { + quotedEventIds.add(quotedTag[1]); + } + } + + // Batch fetch all parent and quoted events in one query + const allEventIds = [...parentEventIds, ...quotedEventIds]; + if (allEventIds.length === 0) return; + + const fetchPromise = nostrClient.fetchEvents( + [{ kinds: [KIND.SHORT_TEXT_NOTE], ids: allEventIds }], + relays, + singleRelay ? { + relayFirst: true, + useCache: false, + cacheResults: false, + timeout: 3000, + priority: 'low' // Low priority - secondary data + } : { + relayFirst: true, + useCache: true, + cacheResults: true, + timeout: 3000, + priority: 'low' // Low priority - secondary data + } + ); + activeFetchPromises.add(fetchPromise); + const events = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted + + // Map events by ID + const eventsById = new Map(); + for (const event of events) { + eventsById.set(event.id, event); + } + + // Store parent events + for (const post of postsToLoad) { + const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); + let parentId: string | undefined; + if (replyTag && replyTag[1]) { + parentId = replyTag[1]; + } else { + const rootId = post.tags.find((t) => t[0] === 'root')?.[1]; + const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); + parentId = eTag?.[1]; + } + if (parentId && eventsById.has(parentId)) { + parentEventsMap.set(post.id, eventsById.get(parentId)!); + } + } + + // Store quoted events + for (const post of postsToLoad) { + const quotedTag = post.tags.find((t) => t[0] === 'q'); + if (quotedTag && quotedTag[1] && eventsById.has(quotedTag[1])) { + quotedEventsMap.set(post.id, eventsById.get(quotedTag[1])!); + } + } + } catch (error) { + console.error('[FeedPage] Error batch loading parent/quoted events:', error); + } + } + + // Batch load zap counts for all posts + async function loadZapCountsForPosts(postsToLoad: NostrEvent[]) { + if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted + + try { + const config = nostrClient.getConfig(); + const threshold = config.zapThreshold; + const zapRelays = singleRelay ? [singleRelay] : relayManager.getZapReceiptReadRelays(); + const eventIds = postsToLoad.map(p => p.id); + + // Batch fetch all zap receipts in one query + const fetchPromise = nostrClient.fetchEvents( + [{ kinds: [KIND.ZAP_RECEIPT], '#e': eventIds, limit: 100 }], + zapRelays, + singleRelay ? { + relayFirst: true, + useCache: false, + cacheResults: false, + timeout: 3000, + priority: 'low' // Low priority - secondary data + } : { + relayFirst: true, + useCache: true, + cacheResults: true, + timeout: 3000, + priority: 'low' // Low priority - secondary data + } + ); + activeFetchPromises.add(fetchPromise); + const receipts = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted + + // Count zaps per event (filtered by threshold) + const countsByEventId = new Map(); + for (const receipt of receipts) { + const eTags = receipt.tags.filter(t => t[0] === 'e' && t[1]); + for (const tag of eTags) { + const eventId = tag[1]; + if (eventIds.includes(eventId)) { + const amountTag = receipt.tags.find((t) => t[0] === 'amount'); + if (amountTag && amountTag[1]) { + const amount = parseInt(amountTag[1], 10); + if (!isNaN(amount) && amount >= threshold) { + countsByEventId.set(eventId, (countsByEventId.get(eventId) || 0) + 1); + } + } + } + } + } + + // Store zap counts + for (const [eventId, count] of countsByEventId.entries()) { + zapCountsMap.set(eventId, count); + } + } catch (error) { + console.error('[FeedPage] Error batch loading zap counts:', error); + } + } + + // Batch load profiles for all posts + async function loadProfilesForPosts(postsToLoad: NostrEvent[]) { + if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted + + try { + // Collect all unique pubkeys + const pubkeys = new Set(); + for (const post of postsToLoad) { + pubkeys.add(post.pubkey); + // Also collect pubkeys from parent/quoted events if we have them + const parentEvent = parentEventsMap.get(post.id); + if (parentEvent) { + pubkeys.add(parentEvent.pubkey); + } + const quotedEvent = quotedEventsMap.get(post.id); + if (quotedEvent) { + pubkeys.add(quotedEvent.pubkey); + } + } + + // Batch fetch all profiles using fetchProfiles (which handles caching) + const { fetchProfiles } = await import('../../services/user-data.js'); + const relayList = singleRelay ? [singleRelay] : undefined; + const fetchPromise = fetchProfiles(Array.from(pubkeys), relayList); + activeFetchPromises.add(fetchPromise); + await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if component is unmounted + + // Profiles are now cached and ProfileBadge will use the cache + } catch (error) { + console.error('[FeedPage] Error batch loading profiles:', error); + } + }
@@ -881,7 +1167,14 @@ {#if event.kind === KIND.HIGHLIGHTED_ARTICLE} {:else} - + {/if} {/each}
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index b74dfa5..4d76294 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -26,9 +26,10 @@ onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer previewMode?: boolean; // If true, show only title and first 150 chars of content reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance) + zapCount?: number; // Optional pre-loaded zap count (for performance) } - let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false, reactions }: Props = $props(); + let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false, reactions, zapCount: providedZapCount }: Props = $props(); let loadedParentEvent = $state(null); let loadingParent = $state(false); @@ -115,19 +116,38 @@ return; } - // If no provided parent and this is a reply, try to load it + // Only load if not provided and this is a reply (fallback for edge cases) + // In most cases, FeedPage will have pre-loaded the parent if (!loadedParentEvent && isReply()) { - loadParentEvent(); + // Delay loading to give FeedPage time to batch load + setTimeout(() => { + if (!providedParentEvent && !loadedParentEvent && isReply()) { + loadParentEvent(); + } + }, 1000); + } + }); + + // Sync provided zap count - initialize and update when prop changes + $effect(() => { + if (providedZapCount !== undefined) { + zapCount = providedZapCount; + } else { + zapCount = 0; } }); onMount(async () => { - // If parent not provided and this is a reply, try to load it - if (!providedParentEvent && !loadedParentEvent && isReply()) { - await loadParentEvent(); + // Only load zap count if not provided (fallback for edge cases) + // In most cases, FeedPage will have pre-loaded the zap count + if (providedZapCount === undefined) { + // Delay loading to give FeedPage time to batch load + setTimeout(() => { + if (providedZapCount === undefined) { + loadZapCount(); + } + }, 1000); } - // Load zap receipt count - await loadZapCount(); // Votes are now calculated as derived values, no need to load separately }); diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index 506515c..aae1bf2 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -49,6 +49,19 @@ // Pins state let pins = $state([]); + // Cleanup tracking + let isMounted = $state(true); + let activeFetchPromises = $state>>(new Set()); + + // Cleanup on unmount + $effect(() => { + return () => { + isMounted = false; + activeFetchPromises.clear(); + loading = false; + }; + }); + function openDrawer(event: NostrEvent) { drawerEvent = event; drawerOpen = true; @@ -99,20 +112,26 @@ }); async function loadPins(pubkey: string) { + if (!isMounted) return; try { const pinnedIds = await getPinnedEvents(); - if (pinnedIds.size === 0) { - pins = []; + if (!isMounted || pinnedIds.size === 0) { + if (isMounted) pins = []; return; } const profileRelays = relayManager.getProfileReadRelays(); - const pinnedEvents = await nostrClient.fetchEvents( + const fetchPromise = nostrClient.fetchEvents( [{ ids: Array.from(pinnedIds), limit: 100 }], profileRelays, { useCache: true, cacheResults: true, timeout: 5000 } ); - + activeFetchPromises.add(fetchPromise); + const pinnedEvents = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; + // Sort by created_at descending pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at); } catch (error) { @@ -122,8 +141,8 @@ } async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { - if (!currentUserPubkey || currentUserPubkey === profilePubkey) { - interactionsWithMe = []; + if (!isMounted || !currentUserPubkey || currentUserPubkey === profilePubkey) { + if (isMounted) interactionsWithMe = []; return; } @@ -131,31 +150,42 @@ const interactionRelays = relayManager.getFeedResponseReadRelays(); // Fetch current user's posts from cache first (fast) - const currentUserPosts = await nostrClient.fetchEvents( + const fetchPromise1 = nostrClient.fetchEvents( [{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: 50 }], interactionRelays, { useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache ); + activeFetchPromises.add(fetchPromise1); + const currentUserPosts = await fetchPromise1; + activeFetchPromises.delete(fetchPromise1); + + if (!isMounted) return; + const currentUserPostIds = new Set(currentUserPosts.map(p => p.id)); // Only fetch interactions if we have some posts to check against if (currentUserPostIds.size === 0) { - interactionsWithMe = []; + if (isMounted) interactionsWithMe = []; return; } // Fetch interactions with timeout to prevent blocking + const fetchPromise2 = nostrClient.fetchEvents( + [ + { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries + { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } + ], + interactionRelays, + { useCache: true, cacheResults: true, timeout: 5000 } + ); + activeFetchPromises.add(fetchPromise2); const interactionEvents = await Promise.race([ - nostrClient.fetchEvents( - [ - { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#e': Array.from(currentUserPostIds).slice(0, 20), limit: 20 }, // Limit IDs to avoid huge queries - { kinds: [KIND.SHORT_TEXT_NOTE], authors: [profilePubkey], '#p': [currentUserPubkey], limit: 20 } - ], - interactionRelays, - { useCache: true, cacheResults: true, timeout: 5000 } - ), + fetchPromise2, new Promise((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout ]); + activeFetchPromises.delete(fetchPromise2); + + if (!isMounted) return; // Deduplicate and filter to only include actual interactions const seenIds = new Set(); @@ -361,6 +391,7 @@ } async function loadProfile() { + if (!isMounted) return; const param = $page.params.pubkey; if (!param) { console.warn('No pubkey parameter provided to ProfilePage'); @@ -381,10 +412,15 @@ loading = true; try { // Step 1: Load profile and status first (fast from cache) - display immediately - const [profileData, status] = await Promise.all([ - fetchProfile(pubkey), - fetchUserStatus(pubkey) - ]); + const profilePromise = fetchProfile(pubkey); + const statusPromise = fetchUserStatus(pubkey); + activeFetchPromises.add(profilePromise); + activeFetchPromises.add(statusPromise); + const [profileData, status] = await Promise.all([profilePromise, statusPromise]); + activeFetchPromises.delete(profilePromise); + activeFetchPromises.delete(statusPromise); + + if (!isMounted) return; profile = profileData; userStatus = status; diff --git a/src/lib/modules/threads/ThreadList.svelte b/src/lib/modules/threads/ThreadList.svelte index eea83b3..d623453 100644 --- a/src/lib/modules/threads/ThreadList.svelte +++ b/src/lib/modules/threads/ThreadList.svelte @@ -34,14 +34,33 @@ let prevSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null); let prevShowOlder = $state(null); let prevSelectedTopic = $state(null); + + // Cleanup tracking + let isMounted = $state(true); + let activeFetchPromises = $state>>(new Set()); // Initial load on mount onMount(() => { + isMounted = true; prevSortBy = sortBy; prevShowOlder = showOlder; prevSelectedTopic = selectedTopic; loadAllData(); }); + + // Cleanup on unmount + $effect(() => { + return () => { + isMounted = false; + + // Cancel all active fetch promises + activeFetchPromises.clear(); + + // Clear any pending operations + isLoading = false; + loading = false; + }; + }); // Only reload when sortBy, showOlder, or selectedTopic changes (after initial values are set) $effect(() => { @@ -62,7 +81,7 @@ }); async function loadAllData() { - if (isLoading) return; // Prevent concurrent loads + if (!isMounted || isLoading) return; // Don't load if unmounted or already loading loading = true; isLoading = true; try { @@ -78,7 +97,7 @@ const zapRelays = relayManager.getZapReceiptReadRelays(); // Query relays first with 3-second timeout, then fill from cache if needed - const relayThreads = await nostrClient.fetchEvents( + const fetchPromise = nostrClient.fetchEvents( [{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }], threadRelays, { @@ -87,6 +106,7 @@ cacheResults: true, // Cache the results timeout: 3000, // 3-second timeout onUpdate: async (updatedEvents) => { + if (!isMounted) return; // Don't update if unmounted // Update incrementally as events arrive const newThreadsMap = new Map(threadsMap); let hasNewEvents = false; @@ -101,12 +121,17 @@ hasNewEvents = true; } } - if (hasNewEvents) { + if (hasNewEvents && isMounted) { threadsMap = newThreadsMap; // Trigger reactivity } } } ); + activeFetchPromises.add(fetchPromise); + const relayThreads = await fetchPromise; + activeFetchPromises.delete(fetchPromise); + + if (!isMounted) return; // Don't process if unmounted // Build threads map from results const newThreadsMap = new Map(); @@ -135,12 +160,19 @@ if (allReactions.length === 0) return; + if (!isMounted) return; // Don't process if unmounted + // Fetch deletion events for current reactions - const deletionEvents = await nostrClient.fetchEvents( + const deletionFetchPromise = nostrClient.fetchEvents( [{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }], reactionRelays, { relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 } ); + activeFetchPromises.add(deletionFetchPromise); + const deletionEvents = await deletionFetchPromise; + activeFetchPromises.delete(deletionFetchPromise); + + if (!isMounted) return; // Don't process if unmounted // Build deleted reaction IDs map const deletedReactionIdsByPubkey = new Map>(); @@ -177,11 +209,14 @@ } } - reactionsMap = updatedReactionsMap; - // Updated reactions map + if (isMounted) { + reactionsMap = updatedReactionsMap; + // Updated reactions map + } }; const handleReactionUpdate = async (updated: NostrEvent[]) => { + if (!isMounted) return; // Don't update if unmounted for (const r of updated) { allReactionsMap.set(r.id, r); } @@ -189,8 +224,10 @@ await processReactionUpdates(); }; - const reactionsWithLowerE = await nostrClient.fetchEvents( - [{ kinds: [KIND.REACTION], '#e': threadIds }], + if (!isMounted) return; // Don't process if unmounted + + const reactionsFetchPromise1 = nostrClient.fetchEvents( + [{ kinds: [KIND.REACTION], '#e': threadIds, limit: 100 }], reactionRelays, { relayFirst: true, @@ -200,12 +237,17 @@ onUpdate: handleReactionUpdate } ); + activeFetchPromises.add(reactionsFetchPromise1); + const reactionsWithLowerE = await reactionsFetchPromise1; + activeFetchPromises.delete(reactionsFetchPromise1); + + if (!isMounted) return; // Don't process if unmounted // Try uppercase filter, but some relays reject it - that's okay let reactionsWithUpperE: NostrEvent[] = []; try { - reactionsWithUpperE = await nostrClient.fetchEvents( - [{ kinds: [KIND.REACTION], '#E': threadIds }], + const reactionsFetchPromise2 = nostrClient.fetchEvents( + [{ kinds: [KIND.REACTION], '#E': threadIds, limit: 100 }], reactionRelays, { relayFirst: true, @@ -215,8 +257,15 @@ onUpdate: handleReactionUpdate } ); + activeFetchPromises.add(reactionsFetchPromise2); + reactionsWithUpperE = await reactionsFetchPromise2; + activeFetchPromises.delete(reactionsFetchPromise2); + + if (!isMounted) return; // Don't process if unmounted } catch (error) { - console.log('[ThreadList] Upper case #E filter rejected by relay (this is normal):', error); + if (isMounted) { // Only log if still mounted + console.log('[ThreadList] Upper case #E filter rejected by relay (this is normal):', error); + } } // Reactions fetched @@ -232,11 +281,18 @@ const allReactions = Array.from(allReactionsMap.values()); // Fetch all zap receipts in parallel (relay-first for first-time users) - const allZapReceipts = await nostrClient.fetchEvents( - [{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds }], + if (!isMounted) return; // Don't process if unmounted + + const zapFetchPromise = nostrClient.fetchEvents( + [{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: 100 }], zapRelays, { relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 } ); + activeFetchPromises.add(zapFetchPromise); + const allZapReceipts = await zapFetchPromise; + activeFetchPromises.delete(zapFetchPromise); + + if (!isMounted) return; // Don't process if unmounted // Build maps let newReactionsMap = new Map(); @@ -248,7 +304,7 @@ // Group zap receipts by thread ID for (const zapReceipt of allZapReceipts) { - const threadId = zapReceipt.tags.find(t => t[0] === 'e')?.[1]; + const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1]; if (threadId && newThreadsMap.has(threadId)) { if (!newZapReceiptsMap.has(threadId)) { newZapReceiptsMap.set(threadId, []); diff --git a/src/lib/services/nostr/nip30-emoji.ts b/src/lib/services/nostr/nip30-emoji.ts index ebfdb58..47e5935 100644 --- a/src/lib/services/nostr/nip30-emoji.ts +++ b/src/lib/services/nostr/nip30-emoji.ts @@ -179,7 +179,7 @@ export async function loadAllEmojiPacks(): Promise { // Fetch all emoji sets (10030) and emoji packs (30030) // Use a high limit to get all available packs - increase limit to get more const events = await nostrClient.fetchEvents( - [{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], limit: 1000 }], // Increased limit to get more emoji packs/sets + [{ kinds: [KIND.EMOJI_SET, KIND.EMOJI_PACK], limit: 100 }], // Limit to 100 to reduce memory usage relays, { useCache: true, cacheResults: true, timeout: 15000 } ); diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index d03feac..6f229b1 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -68,7 +68,7 @@ class NostrClient { // Cache empty results to prevent repeated fetches of non-existent data // Also track pending fetches to prevent concurrent duplicate fetches private emptyResultCache: Map = new Map(); - private readonly EMPTY_RESULT_CACHE_TTL = 30000; // 30 seconds - cache empty results briefly + private readonly EMPTY_RESULT_CACHE_TTL = 300000; // 5 minutes - cache empty results longer to prevent repeated fetches private readonly PENDING_FETCH_TTL = 5000; // 5 seconds - how long to wait for a pending fetch /** diff --git a/src/lib/services/user-data.ts b/src/lib/services/user-data.ts index 16db175..bd5d4c2 100644 --- a/src/lib/services/user-data.ts +++ b/src/lib/services/user-data.ts @@ -76,27 +76,33 @@ export async function fetchProfile( // Try cache first const cached = await getProfile(pubkey); if (cached) { - // Return cached immediately, then background-refresh - const relayList = relays || [ - ...config.defaultRelays, - ...config.profileRelays - ]; + // Check if profile was recently cached (within last 5 minutes) - skip background refresh if so + const cacheAge = Date.now() - cached.cached_at; + const RECENT_CACHE_THRESHOLD = 300000; // 5 minutes - // Background refresh - don't await, just fire and forget - // Use low priority - profiles are background data, comments should load first - nostrClient.fetchEvents( - [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], - relayList, - { useCache: false, cacheResults: true, priority: 'low' } // Don't use cache, but cache results - ).then((events) => { - if (events.length > 0) { - cacheProfile(events[0]).catch(() => { - // Silently fail - caching errors shouldn't break the app - }); - } - }).catch(() => { - // Silently fail - background refresh errors shouldn't break the app - }); + // Only background refresh if cache is old + if (cacheAge > RECENT_CACHE_THRESHOLD) { + const relayList = relays || [ + ...config.defaultRelays, + ...config.profileRelays + ]; + + // Background refresh - don't await, just fire and forget + // Use low priority - profiles are background data, comments should load first + nostrClient.fetchEvents( + [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], + relayList, + { useCache: false, cacheResults: true, priority: 'low' } // Don't use cache, but cache results + ).then((events) => { + if (events.length > 0) { + cacheProfile(events[0]).catch(() => { + // Silently fail - caching errors shouldn't break the app + }); + } + }).catch(() => { + // Silently fail - background refresh errors shouldn't break the app + }); + } return parseProfile(cached.event); } @@ -146,7 +152,7 @@ export async function fetchProfiles( ...config.profileRelays ]; - // Use low priority - profiles are background data, comments should load first + // Use low priority - profiles are background data, posts/highlights should load first const events = await nostrClient.fetchEvents( [{ kinds: [KIND.METADATA], authors: missing, limit: 1 }], relayList,