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. 86
      src/lib/modules/comments/CommentThread.svelte
  6. 359
      src/lib/modules/feed/FeedPage.svelte
  7. 34
      src/lib/modules/feed/FeedPost.svelte
  8. 64
      src/lib/modules/profiles/ProfilePage.svelte
  9. 78
      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. 10
      src/lib/services/user-data.ts

2
package.json

@ -1,6 +1,6 @@ @@ -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.",

6
public/healthz.json

@ -1,8 +1,8 @@ @@ -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
}

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

@ -120,7 +120,7 @@ @@ -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 }
);

2
src/lib/components/write/CreateEventForm.svelte

@ -700,7 +700,7 @@ @@ -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

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

@ -29,7 +29,20 @@ @@ -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<Set<Promise<any>>>(new Set());
// Cleanup on unmount
$effect(() => {
return () => {
isMounted = false;
activeFetchPromises.clear();
};
});
onMount(async () => {
isMounted = true;
await nostrClient.initialize();
});
@ -58,11 +71,16 @@ @@ -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) => {
if (isMounted) { // Only log if still mounted
console.error('Error initializing nostrClient in CommentThread:', error);
}
// Still try to load comments even if initialization fails
if (isMounted) {
return loadComments();
}
}).finally(() => {
loadingPromise = null;
});
@ -163,6 +181,7 @@ @@ -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 @@ @@ -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 @@ @@ -258,6 +279,7 @@
}
async function loadComments() {
if (!isMounted) return;
if (!threadId) {
loading = false;
return;
@ -278,11 +300,18 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -392,13 +428,21 @@
onUpdate: handleReplyUpdate,
priority: 'high'
}
).catch(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 @@ @@ -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 @@ @@ -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 @@ @@ -583,6 +632,7 @@
}
async function loadCommentsFresh() {
if (!isMounted) return;
if (!threadId) {
loading = false;
return;

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

@ -35,6 +35,15 @@ @@ -35,6 +35,15 @@
// Batch-loaded reactions: eventId -> reactions[]
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
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
@ -58,11 +67,17 @@ @@ -58,11 +67,17 @@
let subscriptionId: string | null = $state(null);
let refreshInterval: ReturnType<typeof setInterval> | 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<Set<Promise<any>>>(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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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,7 +470,12 @@ @@ -440,7 +470,12 @@
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)`);
@ -506,8 +541,33 @@ @@ -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<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 {
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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<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) {
@ -638,6 +725,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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<string, NostrEvent[]>();
@ -826,6 +922,196 @@ @@ -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<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>
<div class="feed-page">
@ -881,7 +1167,14 @@ @@ -881,7 +1167,14 @@
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<HighlightCard highlight={event} onOpenEvent={openDrawer} />
{: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}
{/each}
</div>

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

@ -26,9 +26,10 @@ @@ -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<NostrEvent | null>(null);
let loadingParent = $state(false);
@ -115,19 +116,38 @@ @@ -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()) {
// 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
});

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

@ -49,6 +49,19 @@ @@ -49,6 +49,19 @@
// Pins state
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) {
drawerEvent = event;
drawerOpen = true;
@ -99,19 +112,25 @@ @@ -99,19 +112,25 @@
});
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);
@ -122,8 +141,8 @@ @@ -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 @@ @@ -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 interactionEvents = await Promise.race([
nostrClient.fetchEvents(
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([
fetchPromise2,
new Promise<NostrEvent[]>((resolve) => setTimeout(() => resolve([]), 5000)) // 5s timeout
]);
activeFetchPromises.delete(fetchPromise2);
if (!isMounted) return;
// Deduplicate and filter to only include actual interactions
const seenIds = new Set<string>();
@ -361,6 +391,7 @@ @@ -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 @@ @@ -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;

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

@ -35,14 +35,33 @@ @@ -35,14 +35,33 @@
let prevShowOlder = $state<boolean | 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
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(() => {
// Skip if we haven't set initial values yet (onMount hasn't run)
@ -62,7 +81,7 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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<string, NostrEvent>();
@ -135,12 +160,19 @@ @@ -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<string, Set<string>>();
@ -177,11 +209,14 @@ @@ -177,11 +209,14 @@
}
}
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 @@ @@ -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 @@ @@ -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,9 +257,16 @@ @@ -215,9 +257,16 @@
onUpdate: handleReactionUpdate
}
);
activeFetchPromises.add(reactionsFetchPromise2);
reactionsWithUpperE = await reactionsFetchPromise2;
activeFetchPromises.delete(reactionsFetchPromise2);
if (!isMounted) return; // Don't process if unmounted
} catch (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 @@ @@ -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<string, NostrEvent[]>();
@ -248,7 +304,7 @@ @@ -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, []);

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

@ -179,7 +179,7 @@ export async function loadAllEmojiPacks(): Promise<void> { @@ -179,7 +179,7 @@ export async function loadAllEmojiPacks(): Promise<void> {
// 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 }
);

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

@ -68,7 +68,7 @@ class NostrClient { @@ -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<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
/**

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

@ -76,7 +76,12 @@ export async function fetchProfile( @@ -76,7 +76,12 @@ export async function fetchProfile(
// Try cache first
const cached = await getProfile(pubkey);
if (cached) {
// Return cached immediately, then background-refresh
// 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
// Only background refresh if cache is old
if (cacheAge > RECENT_CACHE_THRESHOLD) {
const relayList = relays || [
...config.defaultRelays,
...config.profileRelays
@ -97,6 +102,7 @@ export async function fetchProfile( @@ -97,6 +102,7 @@ export async function fetchProfile(
}).catch(() => {
// Silently fail - background refresh errors shouldn't break the app
});
}
return parseProfile(cached.event);
}
@ -146,7 +152,7 @@ export async function fetchProfiles( @@ -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,

Loading…
Cancel
Save