Browse Source

fix build. reduce RAM usage

master
Silberengel 1 month ago
parent
commit
c45eea1aa2
  1. 2
      package.json
  2. 6
      public/healthz.json
  3. 2
      src/lib/components/content/PollCard.svelte
  4. 2
      src/lib/components/write/CreateEventForm.svelte
  5. 92
      src/lib/modules/comments/CommentThread.svelte
  6. 361
      src/lib/modules/feed/FeedPage.svelte
  7. 36
      src/lib/modules/feed/FeedPost.svelte
  8. 76
      src/lib/modules/profiles/ProfilePage.svelte
  9. 84
      src/lib/modules/threads/ThreadList.svelte
  10. 2
      src/lib/services/nostr/nip30-emoji.ts
  11. 2
      src/lib/services/nostr/nostr-client.ts
  12. 48
      src/lib/services/user-data.ts

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "aitherboard", "name": "aitherboard",
"version": "0.1.0", "version": "0.1.1",
"type": "module", "type": "module",
"author": "silberengel@gitcitadel.com", "author": "silberengel@gitcitadel.com",
"description": "A decentralized messageboard built on the Nostr protocol.", "description": "A decentralized messageboard built on the Nostr protocol.",

6
public/healthz.json

@ -1,8 +1,8 @@
{ {
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.1",
"buildTime": "2026-02-05T07:24:19.725Z", "buildTime": "2026-02-05T09:08:05.545Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770276259725 "timestamp": 1770282485545
} }

2
src/lib/components/content/PollCard.svelte

@ -120,7 +120,7 @@
const until = endsAt ? Math.min(endsAt, now) : now; const until = endsAt ? Math.min(endsAt, now) : now;
const responseEvents = await nostrClient.fetchEvents( 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, relays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true }
); );

2
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.')) { if (confirm('Are you sure you want to clear the form? This will delete all unsaved content.')) {
try { try {
// Mark form as cleared to prevent initial props from re-applying // Mark form as cleared to prevent initial props from re-applying

92
src/lib/modules/comments/CommentThread.svelte

@ -29,7 +29,20 @@
const isKind1 = $derived(event?.kind === KIND.SHORT_TEXT_NOTE); const isKind1 = $derived(event?.kind === KIND.SHORT_TEXT_NOTE);
const rootKind = $derived(event?.kind || null); const rootKind = $derived(event?.kind || null);
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
// Cleanup on unmount
$effect(() => {
return () => {
isMounted = false;
activeFetchPromises.clear();
};
});
onMount(async () => { onMount(async () => {
isMounted = true;
await nostrClient.initialize(); await nostrClient.initialize();
}); });
@ -58,11 +71,16 @@
// Load comments - filters will adapt based on whether event is available // Load comments - filters will adapt based on whether event is available
// Ensure nostrClient is initialized first // Ensure nostrClient is initialized first
loadingPromise = nostrClient.initialize().then(() => { loadingPromise = nostrClient.initialize().then(() => {
if (!isMounted) return; // Don't load if unmounted
return loadComments(); return loadComments();
}).catch((error) => { }).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 // Still try to load comments even if initialization fails
return loadComments(); if (isMounted) {
return loadComments();
}
}).finally(() => { }).finally(() => {
loadingPromise = null; loadingPromise = null;
}); });
@ -163,6 +181,7 @@
} }
function handleReplyUpdate(updated: NostrEvent[]) { function handleReplyUpdate(updated: NostrEvent[]) {
if (!isMounted) return; // Don't update if unmounted
// Prevent recursive calls // Prevent recursive calls
if (isProcessingUpdate) { if (isProcessingUpdate) {
return; return;
@ -217,6 +236,8 @@
} }
// Update state immediately if we have new replies // Update state immediately if we have new replies
if (!isMounted) return; // Don't update if unmounted
if (hasNewReplies) { if (hasNewReplies) {
const allComments = Array.from(commentsMap.values()); const allComments = Array.from(commentsMap.values());
const allKind1Replies = Array.from(kind1RepliesMap.values()); const allKind1Replies = Array.from(kind1RepliesMap.values());
@ -258,6 +279,7 @@
} }
async function loadComments() { async function loadComments() {
if (!isMounted) return;
if (!threadId) { if (!threadId) {
loading = false; loading = false;
return; return;
@ -278,11 +300,18 @@
// then fetches fresh data in background. Only show loading if no cache. // then fetches fresh data in background. Only show loading if no cache.
try { try {
// Quick cache check - if we have cache, don't show loading // Quick cache check - if we have cache, don't show loading
const quickCacheCheck = await nostrClient.fetchEvents( if (!isMounted) return;
const fetchPromise1 = nostrClient.fetchEvents(
replyFilters, replyFilters,
allRelays, allRelays,
{ useCache: true, cacheResults: false, timeout: 50 } { useCache: true, cacheResults: false, timeout: 50 }
); );
activeFetchPromises.add(fetchPromise1);
const quickCacheCheck = await fetchPromise1;
activeFetchPromises.delete(fetchPromise1);
if (!isMounted) return;
if (quickCacheCheck.length === 0) { if (quickCacheCheck.length === 0) {
loading = true; // Only show loading if no cache 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 // 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 // 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.) // Use high priority to ensure comments load before background fetches (reactions, profiles, etc.)
const allReplies = await nostrClient.fetchEvents( const fetchPromise2 = nostrClient.fetchEvents(
replyFilters, replyFilters,
allRelays, allRelays,
{ {
@ -302,6 +331,11 @@
priority: 'high' 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) // Process initial results (from relays or cache)
// Note: onUpdate may have already updated the state and cleared loading // Note: onUpdate may have already updated the state and cleared loading
@ -376,14 +410,16 @@
// Use a single subscription that covers all reply IDs // Use a single subscription that covers all reply IDs
const nestedFilters: any[] = [ const nestedFilters: any[] = [
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 200 }, { kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 200 }, { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 200 }, { kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 200 }, { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 },
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 200 } { kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: 100 }
]; ];
nostrClient.fetchEvents( if (!isMounted) return; // Don't subscribe if unmounted
const subscriptionPromise = nostrClient.fetchEvents(
nestedFilters, nestedFilters,
allRelays, allRelays,
{ {
@ -392,13 +428,21 @@
onUpdate: handleReplyUpdate, onUpdate: handleReplyUpdate,
priority: 'high' 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; nestedSubscriptionActive = false;
}).finally(() => {
activeFetchPromises.delete(subscriptionPromise);
}); });
} }
async function fetchNestedReplies() { async function fetchNestedReplies() {
if (!isMounted) return; // Don't fetch if unmounted
// Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache // Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache
const allRelays = relayManager.getProfileReadRelays(); const allRelays = relayManager.getProfileReadRelays();
let hasNewReplies = true; let hasNewReplies = true;
@ -406,7 +450,7 @@
const maxIterations = 3; // Reduced from 10 to prevent excessive fetching const maxIterations = 3; // Reduced from 10 to prevent excessive fetching
const maxReplyIdsPerIteration = 100; // Limit number of reply IDs to check per iteration const maxReplyIdsPerIteration = 100; // Limit number of reply IDs to check per iteration
while (hasNewReplies && iterations < maxIterations) { while (isMounted && hasNewReplies && iterations < maxIterations) {
iterations++; iterations++;
hasNewReplies = false; hasNewReplies = false;
const allReplyIds = Array.from(new Set([ const allReplyIds = Array.from(new Set([
@ -422,23 +466,28 @@
if (limitedReplyIds.length > 0) { if (limitedReplyIds.length > 0) {
const nestedFilters: any[] = [ const nestedFilters: any[] = [
// Fetch nested kind 1111 comments - check both e/E and a/A tags // 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: 100 },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 200 }, { kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: 200 }, { kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: 100 },
{ kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: 200 }, { kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: 100 },
// Fetch nested kind 1 replies // 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 // Fetch nested yak backs
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 200 }, { kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: 100 },
// Fetch nested zap receipts // 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, nestedFilters,
allRelays, allRelays,
{ useCache: true, cacheResults: true, timeout: 5000 } { 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 // Add new replies by type
for (const reply of nestedReplies) { for (const reply of nestedReplies) {
@ -583,6 +632,7 @@
} }
async function loadCommentsFresh() { async function loadCommentsFresh() {
if (!isMounted) return;
if (!threadId) { if (!threadId) {
loading = false; loading = false;
return; return;

361
src/lib/modules/feed/FeedPage.svelte

@ -35,6 +35,15 @@
// Batch-loaded reactions: eventId -> reactions[] // Batch-loaded reactions: eventId -> reactions[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
// Batch-loaded parent events: eventId -> parentEvent
let parentEventsMap = $state<Map<string, NostrEvent>>(new Map());
// Batch-loaded quoted events: eventId -> quotedEvent
let quotedEventsMap = $state<Map<string, NostrEvent>>(new Map());
// Batch-loaded zap counts: eventId -> count
let zapCountsMap = $state<Map<string, number>>(new Map());
// Drawer state for viewing parent/quoted events // Drawer state for viewing parent/quoted events
let drawerOpen = $state(false); let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null); let drawerEvent = $state<NostrEvent | null>(null);
@ -58,11 +67,17 @@
let subscriptionId: string | null = $state(null); let subscriptionId: string | null = $state(null);
let refreshInterval: ReturnType<typeof setInterval> | null = null; let refreshInterval: ReturnType<typeof setInterval> | null = null;
let subscriptionSetup = $state(false); // Track if subscription is already set up let subscriptionSetup = $state(false); // Track if subscription is already set up
let isMounted = $state(true); // Track if component is still mounted
let activeFetchPromises = $state<Set<Promise<any>>>(new Set()); // Track active fetch promises
onMount(async () => { onMount(async () => {
isMounted = true;
await nostrClient.initialize(); await nostrClient.initialize();
if (!isMounted) return; // Check if unmounted during init
await loadUserLists(); await loadUserLists();
if (!isMounted) return;
await loadFeed(); await loadFeed();
if (!isMounted) return;
// Set up persistent subscription for new events (only once) // Set up persistent subscription for new events (only once)
if (!subscriptionSetup) { if (!subscriptionSetup) {
setupSubscription(); setupSubscription();
@ -214,14 +229,38 @@
// Cleanup subscription on unmount // Cleanup subscription on unmount
$effect(() => { $effect(() => {
return () => { 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) { if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId); nostrClient.unsubscribe(subscriptionId);
subscriptionId = null; subscriptionId = null;
} }
// Clear refresh interval
if (refreshInterval) { if (refreshInterval) {
clearInterval(refreshInterval); clearInterval(refreshInterval);
refreshInterval = null; refreshInterval = null;
} }
// Clear update timeout
if (updateTimeout) {
clearTimeout(updateTimeout);
updateTimeout = null;
}
// Disconnect intersection observer
if (observer) {
observer.disconnect();
observer = null;
}
subscriptionSetup = false; subscriptionSetup = false;
}; };
}); });
@ -241,25 +280,7 @@
}; };
}); });
// Cleanup on unmount // This cleanup is now handled in the main cleanup effect above
$effect(() => {
return () => {
if (observer) {
observer.disconnect();
}
if (updateTimeout) {
clearTimeout(updateTimeout);
}
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
};
});
// Set up persistent subscription for real-time updates // Set up persistent subscription for real-time updates
function setupSubscription() { function setupSubscription() {
@ -314,6 +335,13 @@
// Refresh every 30 seconds // Refresh every 30 seconds
refreshInterval = setInterval(async () => { refreshInterval = setInterval(async () => {
if (!isMounted) {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
return; // Don't refresh if component is unmounted
}
try { try {
// Use single relay if provided, otherwise use normal relay list // Use single relay if provided, otherwise use normal relay list
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
@ -386,6 +414,7 @@
}); });
async function loadFeed() { async function loadFeed() {
if (!isMounted) return; // Don't load if component is unmounted
loading = true; loading = true;
relayError = null; // Clear any previous errors relayError = null; // Clear any previous errors
try { try {
@ -430,6 +459,7 @@
useCache: true, // Fill from cache if relay query returns nothing useCache: true, // Fill from cache if relay query returns nothing
cacheResults: true, // Cache the results cacheResults: true, // Cache the results
timeout: 3000, // 3-second timeout timeout: 3000, // 3-second timeout
priority: 'high' as const, // High priority for posts/highlights - display first
onUpdate: (updatedEvents: NostrEvent[]) => { onUpdate: (updatedEvents: NostrEvent[]) => {
// Update incrementally as events arrive // Update incrementally as events arrive
handleUpdate(updatedEvents); handleUpdate(updatedEvents);
@ -440,8 +470,13 @@
console.log(`[FeedPage] Single-relay mode: fetching from ${singleRelay} with useCache=false, cacheResults=false`); 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)`); 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 // Separate events by kind - we'll handle all showInFeed kinds
@ -506,8 +541,33 @@
if (sortedPosts.length > 0 || sortedHighlights.length > 0) { if (sortedPosts.length > 0 || sortedHighlights.length > 0) {
const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)]; const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)];
oldestTimestamp = Math.min(...allTimestamps); 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<void>((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 { } else {
console.log('[FeedPage] No events found. Relays:', relays); 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 // 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() { async function loadMore() {
if (loadingMore || !hasMore) return; if (!isMounted || loadingMore || !hasMore) return; // Don't load if unmounted
loadingMore = true; loadingMore = true;
try { try {
@ -543,7 +603,7 @@
// In single-relay mode: never use cache, only fetch directly from relay // In single-relay mode: never use cache, only fetch directly from relay
// In normal mode: use relay-first with cache fallback // In normal mode: use relay-first with cache fallback
const events = await nostrClient.fetchEvents( const fetchPromise = nostrClient.fetchEvents(
filters, filters,
relays, relays,
singleRelay ? { singleRelay ? {
@ -558,6 +618,11 @@
timeout: 3000 // 3-second timeout 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) { if (events.length === 0) {
hasMore = false; hasMore = false;
@ -585,8 +650,30 @@
if (uniqueNewPosts.length > 0) { if (uniqueNewPosts.length > 0) {
const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at); const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at);
allPosts = [...allPosts, ...sorted]; allPosts = [...allPosts, ...sorted];
// Batch load reactions for new posts // Load secondary data with low priority after posts are displayed
await loadReactionsForPosts(sorted); const secondaryDataPromise = new Promise<void>((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) { if (uniqueNewHighlights.length > 0) {
@ -638,6 +725,7 @@
// Debounced update handler to prevent rapid re-renders and loops // Debounced update handler to prevent rapid re-renders and loops
function handleUpdate(updated: NostrEvent[]) { function handleUpdate(updated: NostrEvent[]) {
if (!isMounted) return; // Don't update if component is unmounted
if (!updated || updated.length === 0) return; if (!updated || updated.length === 0) return;
// Deduplicate incoming updates before adding to pending // Deduplicate incoming updates before adding to pending
@ -669,6 +757,7 @@
// Batch updates every 500ms to prevent rapid re-renders // Batch updates every 500ms to prevent rapid re-renders
updateTimeout = setTimeout(() => { updateTimeout = setTimeout(() => {
if (!isMounted) return; // Don't update if component is unmounted
if (pendingUpdates.length === 0) { if (pendingUpdates.length === 0) {
return; return;
} }
@ -768,7 +857,7 @@
// Batch load reactions for multiple posts at once // Batch load reactions for multiple posts at once
async function loadReactionsForPosts(postsToLoad: NostrEvent[]) { async function loadReactionsForPosts(postsToLoad: NostrEvent[]) {
if (postsToLoad.length === 0) return; if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted
try { try {
const reactionRelays = relayManager.getProfileReadRelays(); const reactionRelays = relayManager.getProfileReadRelays();
@ -779,24 +868,31 @@
// Batch fetch all reactions for all posts in one query // Batch fetch all reactions for all posts in one query
// In single-relay mode: never use cache // 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: 100 },
{ kinds: [KIND.REACTION], '#E': eventIds, limit: 1000 } { kinds: [KIND.REACTION], '#E': eventIds, limit: 100 }
], ],
relaysForReactions, relaysForReactions,
singleRelay ? { singleRelay ? {
relayFirst: true, relayFirst: true,
useCache: false, // Never use cache in single-relay mode useCache: false, // Never use cache in single-relay mode
cacheResults: false, // Don't 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, relayFirst: true,
useCache: true, useCache: true,
cacheResults: 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 // Group reactions by event ID
const newReactionsMap = new Map<string, NostrEvent[]>(); const newReactionsMap = new Map<string, NostrEvent[]>();
@ -826,6 +922,196 @@
console.error('[FeedPage] Error batch loading reactions:', error); 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<string>();
const quotedEventIds = new Set<string>();
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<string, NostrEvent>();
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<string, number>();
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<string>();
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);
}
}
</script> </script>
<div class="feed-page"> <div class="feed-page">
@ -881,7 +1167,14 @@
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE} {#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<HighlightCard highlight={event} onOpenEvent={openDrawer} /> <HighlightCard highlight={event} onOpenEvent={openDrawer} />
{:else} {:else}
<FeedPost post={event} onOpenEvent={openDrawer} reactions={reactionsMap.get(event.id)} /> <FeedPost
post={event}
onOpenEvent={openDrawer}
reactions={reactionsMap.get(event.id)}
parentEvent={parentEventsMap.get(event.id)}
quotedEvent={quotedEventsMap.get(event.id)}
zapCount={zapCountsMap.get(event.id) || 0}
/>
{/if} {/if}
{/each} {/each}
</div> </div>

36
src/lib/modules/feed/FeedPost.svelte

@ -26,9 +26,10 @@
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
previewMode?: boolean; // If true, show only title and first 150 chars of content previewMode?: boolean; // If true, show only title and first 150 chars of content
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance) 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<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
@ -115,19 +116,38 @@
return; 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()) { 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 () => { onMount(async () => {
// If parent not provided and this is a reply, try to load it // Only load zap count if not provided (fallback for edge cases)
if (!providedParentEvent && !loadedParentEvent && isReply()) { // In most cases, FeedPage will have pre-loaded the zap count
await loadParentEvent(); 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 // Votes are now calculated as derived values, no need to load separately
}); });

76
src/lib/modules/profiles/ProfilePage.svelte

@ -49,6 +49,19 @@
// Pins state // Pins state
let pins = $state<NostrEvent[]>([]); let pins = $state<NostrEvent[]>([]);
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
// Cleanup on unmount
$effect(() => {
return () => {
isMounted = false;
activeFetchPromises.clear();
loading = false;
};
});
function openDrawer(event: NostrEvent) { function openDrawer(event: NostrEvent) {
drawerEvent = event; drawerEvent = event;
drawerOpen = true; drawerOpen = true;
@ -99,20 +112,26 @@
}); });
async function loadPins(pubkey: string) { async function loadPins(pubkey: string) {
if (!isMounted) return;
try { try {
const pinnedIds = await getPinnedEvents(); const pinnedIds = await getPinnedEvents();
if (pinnedIds.size === 0) { if (!isMounted || pinnedIds.size === 0) {
pins = []; if (isMounted) pins = [];
return; return;
} }
const profileRelays = relayManager.getProfileReadRelays(); const profileRelays = relayManager.getProfileReadRelays();
const pinnedEvents = await nostrClient.fetchEvents( const fetchPromise = nostrClient.fetchEvents(
[{ ids: Array.from(pinnedIds), limit: 100 }], [{ ids: Array.from(pinnedIds), limit: 100 }],
profileRelays, profileRelays,
{ useCache: true, cacheResults: true, timeout: 5000 } { useCache: true, cacheResults: true, timeout: 5000 }
); );
activeFetchPromises.add(fetchPromise);
const pinnedEvents = await fetchPromise;
activeFetchPromises.delete(fetchPromise);
if (!isMounted) return;
// Sort by created_at descending // Sort by created_at descending
pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at); pins = pinnedEvents.sort((a, b) => b.created_at - a.created_at);
} catch (error) { } catch (error) {
@ -122,8 +141,8 @@
} }
async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) { async function loadInteractionsWithMe(profilePubkey: string, currentUserPubkey: string) {
if (!currentUserPubkey || currentUserPubkey === profilePubkey) { if (!isMounted || !currentUserPubkey || currentUserPubkey === profilePubkey) {
interactionsWithMe = []; if (isMounted) interactionsWithMe = [];
return; return;
} }
@ -131,31 +150,42 @@
const interactionRelays = relayManager.getFeedResponseReadRelays(); const interactionRelays = relayManager.getFeedResponseReadRelays();
// Fetch current user's posts from cache first (fast) // 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 }], [{ kinds: [KIND.SHORT_TEXT_NOTE], authors: [currentUserPubkey], limit: 50 }],
interactionRelays, interactionRelays,
{ useCache: true, cacheResults: true, timeout: 2000 } // Short timeout for cache { 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)); const currentUserPostIds = new Set(currentUserPosts.map(p => p.id));
// Only fetch interactions if we have some posts to check against // Only fetch interactions if we have some posts to check against
if (currentUserPostIds.size === 0) { if (currentUserPostIds.size === 0) {
interactionsWithMe = []; if (isMounted) interactionsWithMe = [];
return; return;
} }
// Fetch interactions with timeout to prevent blocking // 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([ const interactionEvents = await Promise.race([
nostrClient.fetchEvents( fetchPromise2,
[
{ 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 }
),
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout
]); ]);
activeFetchPromises.delete(fetchPromise2);
if (!isMounted) return;
// Deduplicate and filter to only include actual interactions // Deduplicate and filter to only include actual interactions
const seenIds = new Set<string>(); const seenIds = new Set<string>();
@ -361,6 +391,7 @@
} }
async function loadProfile() { async function loadProfile() {
if (!isMounted) return;
const param = $page.params.pubkey; const param = $page.params.pubkey;
if (!param) { if (!param) {
console.warn('No pubkey parameter provided to ProfilePage'); console.warn('No pubkey parameter provided to ProfilePage');
@ -381,10 +412,15 @@
loading = true; loading = true;
try { try {
// Step 1: Load profile and status first (fast from cache) - display immediately // Step 1: Load profile and status first (fast from cache) - display immediately
const [profileData, status] = await Promise.all([ const profilePromise = fetchProfile(pubkey);
fetchProfile(pubkey), const statusPromise = fetchUserStatus(pubkey);
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; profile = profileData;
userStatus = status; userStatus = status;

84
src/lib/modules/threads/ThreadList.svelte

@ -34,14 +34,33 @@
let prevSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null); let prevSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null);
let prevShowOlder = $state<boolean | null>(null); let prevShowOlder = $state<boolean | null>(null);
let prevSelectedTopic = $state<string | null | undefined | null>(null); let prevSelectedTopic = $state<string | null | undefined | null>(null);
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
// Initial load on mount // Initial load on mount
onMount(() => { onMount(() => {
isMounted = true;
prevSortBy = sortBy; prevSortBy = sortBy;
prevShowOlder = showOlder; prevShowOlder = showOlder;
prevSelectedTopic = selectedTopic; prevSelectedTopic = selectedTopic;
loadAllData(); 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) // Only reload when sortBy, showOlder, or selectedTopic changes (after initial values are set)
$effect(() => { $effect(() => {
@ -62,7 +81,7 @@
}); });
async function loadAllData() { async function loadAllData() {
if (isLoading) return; // Prevent concurrent loads if (!isMounted || isLoading) return; // Don't load if unmounted or already loading
loading = true; loading = true;
isLoading = true; isLoading = true;
try { try {
@ -78,7 +97,7 @@
const zapRelays = relayManager.getZapReceiptReadRelays(); const zapRelays = relayManager.getZapReceiptReadRelays();
// Query relays first with 3-second timeout, then fill from cache if needed // 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 }], [{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays, threadRelays,
{ {
@ -87,6 +106,7 @@
cacheResults: true, // Cache the results cacheResults: true, // Cache the results
timeout: 3000, // 3-second timeout timeout: 3000, // 3-second timeout
onUpdate: async (updatedEvents) => { onUpdate: async (updatedEvents) => {
if (!isMounted) return; // Don't update if unmounted
// Update incrementally as events arrive // Update incrementally as events arrive
const newThreadsMap = new Map(threadsMap); const newThreadsMap = new Map(threadsMap);
let hasNewEvents = false; let hasNewEvents = false;
@ -101,12 +121,17 @@
hasNewEvents = true; hasNewEvents = true;
} }
} }
if (hasNewEvents) { if (hasNewEvents && isMounted) {
threadsMap = newThreadsMap; // Trigger reactivity 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 // Build threads map from results
const newThreadsMap = new Map<string, NostrEvent>(); const newThreadsMap = new Map<string, NostrEvent>();
@ -135,12 +160,19 @@
if (allReactions.length === 0) return; if (allReactions.length === 0) return;
if (!isMounted) return; // Don't process if unmounted
// Fetch deletion events for current reactions // 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))) }], [{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }],
reactionRelays, reactionRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 } { 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 // Build deleted reaction IDs map
const deletedReactionIdsByPubkey = new Map<string, Set<string>>(); const deletedReactionIdsByPubkey = new Map<string, Set<string>>();
@ -177,11 +209,14 @@
} }
} }
reactionsMap = updatedReactionsMap; if (isMounted) {
// Updated reactions map reactionsMap = updatedReactionsMap;
// Updated reactions map
}
}; };
const handleReactionUpdate = async (updated: NostrEvent[]) => { const handleReactionUpdate = async (updated: NostrEvent[]) => {
if (!isMounted) return; // Don't update if unmounted
for (const r of updated) { for (const r of updated) {
allReactionsMap.set(r.id, r); allReactionsMap.set(r.id, r);
} }
@ -189,8 +224,10 @@
await processReactionUpdates(); await processReactionUpdates();
}; };
const reactionsWithLowerE = await nostrClient.fetchEvents( if (!isMounted) return; // Don't process if unmounted
[{ kinds: [KIND.REACTION], '#e': threadIds }],
const reactionsFetchPromise1 = nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': threadIds, limit: 100 }],
reactionRelays, reactionRelays,
{ {
relayFirst: true, relayFirst: true,
@ -200,12 +237,17 @@
onUpdate: handleReactionUpdate 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 // Try uppercase filter, but some relays reject it - that's okay
let reactionsWithUpperE: NostrEvent[] = []; let reactionsWithUpperE: NostrEvent[] = [];
try { try {
reactionsWithUpperE = await nostrClient.fetchEvents( const reactionsFetchPromise2 = nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': threadIds }], [{ kinds: [KIND.REACTION], '#E': threadIds, limit: 100 }],
reactionRelays, reactionRelays,
{ {
relayFirst: true, relayFirst: true,
@ -215,8 +257,15 @@
onUpdate: handleReactionUpdate onUpdate: handleReactionUpdate
} }
); );
activeFetchPromises.add(reactionsFetchPromise2);
reactionsWithUpperE = await reactionsFetchPromise2;
activeFetchPromises.delete(reactionsFetchPromise2);
if (!isMounted) return; // Don't process if unmounted
} catch (error) { } 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 // Reactions fetched
@ -232,11 +281,18 @@
const allReactions = Array.from(allReactionsMap.values()); const allReactions = Array.from(allReactionsMap.values());
// Fetch all zap receipts in parallel (relay-first for first-time users) // Fetch all zap receipts in parallel (relay-first for first-time users)
const allZapReceipts = await nostrClient.fetchEvents( if (!isMounted) return; // Don't process if unmounted
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds }],
const zapFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: 100 }],
zapRelays, zapRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 } { 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 // Build maps
let newReactionsMap = new Map<string, NostrEvent[]>(); let newReactionsMap = new Map<string, NostrEvent[]>();
@ -248,7 +304,7 @@
// Group zap receipts by thread ID // Group zap receipts by thread ID
for (const zapReceipt of allZapReceipts) { 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 (threadId && newThreadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) { if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []); newZapReceiptsMap.set(threadId, []);

2
src/lib/services/nostr/nip30-emoji.ts

@ -179,7 +179,7 @@ export async function loadAllEmojiPacks(): Promise<void> {
// Fetch all emoji sets (10030) and emoji packs (30030) // Fetch all emoji sets (10030) and emoji packs (30030)
// Use a high limit to get all available packs - increase limit to get more // Use a high limit to get all available packs - increase limit to get more
const events = await nostrClient.fetchEvents( 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, relays,
{ useCache: true, cacheResults: true, timeout: 15000 } { useCache: true, cacheResults: true, timeout: 15000 }
); );

2
src/lib/services/nostr/nostr-client.ts

@ -68,7 +68,7 @@ class NostrClient {
// Cache empty results to prevent repeated fetches of non-existent data // Cache empty results to prevent repeated fetches of non-existent data
// Also track pending fetches to prevent concurrent duplicate fetches // Also track pending fetches to prevent concurrent duplicate fetches
private emptyResultCache: Map<string, { cachedAt: number; pending?: boolean }> = new Map(); private emptyResultCache: Map<string, { cachedAt: number; pending?: boolean }> = 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 private readonly PENDING_FETCH_TTL = 5000; // 5 seconds - how long to wait for a pending fetch
/** /**

48
src/lib/services/user-data.ts

@ -76,27 +76,33 @@ export async function fetchProfile(
// Try cache first // Try cache first
const cached = await getProfile(pubkey); const cached = await getProfile(pubkey);
if (cached) { if (cached) {
// Return cached immediately, then background-refresh // Check if profile was recently cached (within last 5 minutes) - skip background refresh if so
const relayList = relays || [ const cacheAge = Date.now() - cached.cached_at;
...config.defaultRelays, const RECENT_CACHE_THRESHOLD = 300000; // 5 minutes
...config.profileRelays
];
// Background refresh - don't await, just fire and forget // Only background refresh if cache is old
// Use low priority - profiles are background data, comments should load first if (cacheAge > RECENT_CACHE_THRESHOLD) {
nostrClient.fetchEvents( const relayList = relays || [
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }], ...config.defaultRelays,
relayList, ...config.profileRelays
{ useCache: false, cacheResults: true, priority: 'low' } // Don't use cache, but cache results ];
).then((events) => {
if (events.length > 0) { // Background refresh - don't await, just fire and forget
cacheProfile(events[0]).catch(() => { // Use low priority - profiles are background data, comments should load first
// Silently fail - caching errors shouldn't break the app nostrClient.fetchEvents(
}); [{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
} relayList,
}).catch(() => { { useCache: false, cacheResults: true, priority: 'low' } // Don't use cache, but cache results
// Silently fail - background refresh errors shouldn't break the app ).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); return parseProfile(cached.event);
} }
@ -146,7 +152,7 @@ export async function fetchProfiles(
...config.profileRelays ...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( const events = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: missing, limit: 1 }], [{ kinds: [KIND.METADATA], authors: missing, limit: 1 }],
relayList, relayList,

Loading…
Cancel
Save