diff --git a/src/lib/components/content/EmojiPicker.svelte b/src/lib/components/content/EmojiPicker.svelte index d29689e..0ea5c8c 100644 --- a/src/lib/components/content/EmojiPicker.svelte +++ b/src/lib/components/content/EmojiPicker.svelte @@ -61,7 +61,7 @@ try { await loadAllEmojiPacks(); const allEmojis = getAllCustomEmojis(); - console.debug(`[EmojiPicker] Loaded ${allEmojis.length} custom emojis`); + // Silently load emojis - no need to log customEmojis = allEmojis; } catch (error) { console.error('Error loading custom emojis:', error); diff --git a/src/lib/modules/comments/CommentThread.svelte b/src/lib/modules/comments/CommentThread.svelte index 4d27ffb..6b73b38 100644 --- a/src/lib/modules/comments/CommentThread.svelte +++ b/src/lib/modules/comments/CommentThread.svelte @@ -168,66 +168,93 @@ return; } - // Batch updates to prevent flickering - requestAnimationFrame(() => { - isProcessingUpdate = true; + // Process immediately - don't batch with requestAnimationFrame for faster UI updates + isProcessingUpdate = true; + + try { + let hasNewReplies = false; + const commentsMap = new Map(comments.map(c => [c.id, c])); + const kind1RepliesMap = new Map(kind1Replies.map(r => [r.id, r])); + const yakBacksMap = new Map(yakBacks.map(y => [y.id, y])); + const zapReceiptsMap = new Map(zapReceipts.map(z => [z.id, z])); - try { - let hasNewReplies = false; - const commentsMap = new Map(comments.map(c => [c.id, c])); - const kind1RepliesMap = new Map(kind1Replies.map(r => [r.id, r])); - const yakBacksMap = new Map(yakBacks.map(y => [y.id, y])); - const zapReceiptsMap = new Map(zapReceipts.map(z => [z.id, z])); + for (const reply of updated) { + // Skip if we already have this reply + if (commentsMap.has(reply.id) || kind1RepliesMap.has(reply.id) || + yakBacksMap.has(reply.id) || zapReceiptsMap.has(reply.id)) { + continue; + } - for (const reply of updated) { - // Skip if we already have this reply - if (commentsMap.has(reply.id) || kind1RepliesMap.has(reply.id) || - yakBacksMap.has(reply.id) || zapReceiptsMap.has(reply.id)) { - continue; - } - - // Check if this reply references the root OR is a reply to any existing comment/reply - const parentId = getParentEventId(reply); - const isReplyToRoot = referencesRoot(reply); - const isReplyToExisting = parentId && ( - parentId === threadId || - commentsMap.has(parentId) || - kind1RepliesMap.has(parentId) || - yakBacksMap.has(parentId) || - zapReceiptsMap.has(parentId) - ); - - if (!isReplyToRoot && !isReplyToExisting) { - continue; - } - - // Add the reply to the appropriate map - if (reply.kind === KIND.COMMENT) { - commentsMap.set(reply.id, reply); - hasNewReplies = true; - } else if (reply.kind === KIND.SHORT_TEXT_NOTE) { - kind1RepliesMap.set(reply.id, reply); - hasNewReplies = true; - } else if (reply.kind === KIND.VOICE_REPLY) { - yakBacksMap.set(reply.id, reply); - hasNewReplies = true; - } else if (reply.kind === KIND.ZAP_RECEIPT) { - zapReceiptsMap.set(reply.id, reply); - hasNewReplies = true; - } + // Check if this reply references the root OR is a reply to any existing comment/reply + const parentId = getParentEventId(reply); + const isReplyToRoot = referencesRoot(reply); + const isReplyToExisting = parentId && ( + parentId === threadId || + commentsMap.has(parentId) || + kind1RepliesMap.has(parentId) || + yakBacksMap.has(parentId) || + zapReceiptsMap.has(parentId) + ); + + if (!isReplyToRoot && !isReplyToExisting) { + continue; } - // Only update state if we have new replies - if (hasNewReplies) { - comments = Array.from(commentsMap.values()); - kind1Replies = Array.from(kind1RepliesMap.values()); - yakBacks = Array.from(yakBacksMap.values()); - zapReceipts = Array.from(zapReceiptsMap.values()); + // Add the reply to the appropriate map + if (reply.kind === KIND.COMMENT) { + commentsMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === KIND.SHORT_TEXT_NOTE) { + kind1RepliesMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === KIND.VOICE_REPLY) { + yakBacksMap.set(reply.id, reply); + hasNewReplies = true; + } else if (reply.kind === KIND.ZAP_RECEIPT) { + zapReceiptsMap.set(reply.id, reply); + hasNewReplies = true; } - } finally { - isProcessingUpdate = false; } - }); + + // Update state immediately if we have new replies + if (hasNewReplies) { + const allComments = Array.from(commentsMap.values()); + const allKind1Replies = Array.from(kind1RepliesMap.values()); + const allYakBacks = Array.from(yakBacksMap.values()); + const allZapReceipts = Array.from(zapReceiptsMap.values()); + + // Limit array sizes to prevent memory bloat (keep most recent 500 of each type) + const MAX_COMMENTS = 500; + const MAX_REPLIES = 500; + + // Sort by created_at descending and take most recent + comments = allComments + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_COMMENTS); + kind1Replies = allKind1Replies + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_REPLIES); + yakBacks = allYakBacks + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_REPLIES); + zapReceipts = allZapReceipts + .sort((a, b) => b.created_at - a.created_at) + .slice(0, MAX_REPLIES); + + // Clear loading flag as soon as we get the first results + // This allows comments to render immediately instead of waiting for all fetches + if (loading) { + loading = false; + } + } else if (updated.length > 0 && loading) { + // If we got events but they were all filtered out, still clear loading + // This prevents the UI from being stuck in loading state + // The events might be nested replies that will be processed later + loading = false; + } + } finally { + isProcessingUpdate = false; + } } async function loadComments() { @@ -238,13 +265,13 @@ const allRelays = relayManager.getProfileReadRelays(); const replyFilters: any[] = [ - { kinds: [KIND.COMMENT], '#e': [threadId], limit: 500 }, - { kinds: [KIND.COMMENT], '#E': [threadId], limit: 500 }, - { kinds: [KIND.COMMENT], '#a': [threadId], limit: 500 }, - { kinds: [KIND.COMMENT], '#A': [threadId], limit: 500 }, - { kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 500 }, - { kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 500 }, - { kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 500 } + { kinds: [KIND.COMMENT], '#e': [threadId], limit: 100 }, + { kinds: [KIND.COMMENT], '#E': [threadId], limit: 100 }, + { kinds: [KIND.COMMENT], '#a': [threadId], limit: 100 }, + { kinds: [KIND.COMMENT], '#A': [threadId], limit: 100 }, + { kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 100 }, + { kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 100 }, + { kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 100 } ]; // fetchEvents with useCache:true returns cached data immediately if available, @@ -261,7 +288,9 @@ loading = true; // Only show loading if no cache } - // Now fetch with full options - returns cached immediately, fetches fresh in background + // 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( replyFilters, allRelays, @@ -269,20 +298,43 @@ useCache: true, cacheResults: true, timeout: 10000, - onUpdate: handleReplyUpdate + onUpdate: handleReplyUpdate, + priority: 'high' } ); - // Filter to only replies that reference the root + // Process initial results (from relays or cache) + // Note: onUpdate may have already updated the state and cleared loading + // But if onUpdate didn't process them (e.g., filtered out), we need to process them here const rootReplies = allReplies.filter(reply => referencesRoot(reply)); - // Separate by type - comments = rootReplies.filter(e => e.kind === KIND.COMMENT); - kind1Replies = rootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); - yakBacks = rootReplies.filter(e => e.kind === KIND.VOICE_REPLY); - zapReceipts = rootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT); + // Only update if we have new replies not already processed by onUpdate + const existingIds = new Set([ + ...comments.map(c => c.id), + ...kind1Replies.map(r => r.id), + ...yakBacks.map(y => y.id), + ...zapReceipts.map(z => z.id) + ]); + + const newRootReplies = rootReplies.filter(r => !existingIds.has(r.id)); - loading = false; // Hide loading now that we have data (cached or fresh) + if (newRootReplies.length > 0) { + // Merge with existing (onUpdate may have already added some) + const allComments = [...comments, ...newRootReplies.filter(e => e.kind === KIND.COMMENT)]; + const allKind1Replies = [...kind1Replies, ...newRootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE)]; + const allYakBacks = [...yakBacks, ...newRootReplies.filter(e => e.kind === KIND.VOICE_REPLY)]; + const allZapReceipts = [...zapReceipts, ...newRootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT)]; + + // Deduplicate + comments = Array.from(new Map(allComments.map(c => [c.id, c])).values()); + kind1Replies = Array.from(new Map(allKind1Replies.map(r => [r.id, r])).values()); + yakBacks = Array.from(new Map(allYakBacks.map(y => [y.id, y])).values()); + zapReceipts = Array.from(new Map(allZapReceipts.map(z => [z.id, z])).values()); + } + + // ALWAYS clear loading flag after fetch completes, even if no events matched + // This prevents the UI from being stuck in loading state + loading = false; // Recursively fetch all nested replies (non-blocking - let it run in background) fetchNestedReplies().then(() => { @@ -337,7 +389,8 @@ { useCache: true, cacheResults: true, - onUpdate: handleReplyUpdate + onUpdate: handleReplyUpdate, + priority: 'high' } ).catch(error => { console.error('Error subscribing to nested replies:', error); @@ -544,27 +597,28 @@ // Always fetch kind 1111 comments - check both e and E tags, and a and A tags replyFilters.push( - { kinds: [KIND.COMMENT], '#e': [threadId], limit: 500 }, // Lowercase e tag - { kinds: [KIND.COMMENT], '#E': [threadId], limit: 500 }, // Uppercase E tag (NIP-22) - { kinds: [KIND.COMMENT], '#a': [threadId], limit: 500 }, // Lowercase a tag (some clients use wrong tags) - { kinds: [KIND.COMMENT], '#A': [threadId], limit: 500 } // Uppercase A tag (NIP-22 for addressable events) + { kinds: [KIND.COMMENT], '#e': [threadId], limit: 100 }, // Lowercase e tag + { kinds: [KIND.COMMENT], '#E': [threadId], limit: 100 }, // Uppercase E tag (NIP-22) + { kinds: [KIND.COMMENT], '#a': [threadId], limit: 100 }, // Lowercase a tag (some clients use wrong tags) + { kinds: [KIND.COMMENT], '#A': [threadId], limit: 100 } // Uppercase A tag (NIP-22 for addressable events) ); // For kind 1 events, fetch kind 1 replies // Also fetch kind 1 replies for any event (some apps use kind 1 for everything) - replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 500 }); + replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: 100 }); // Fetch yak backs (kind 1244) - voice replies - replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 500 }); + replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: 100 }); // Fetch zap receipts (kind 9735) - replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 500 }); + replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: 100 }); // Don't use cache when reloading after publishing - we want fresh data + // Use high priority to ensure comments load before background fetches const allReplies = await nostrClient.fetchEvents( replyFilters, allRelays, - { useCache: false, cacheResults: true, timeout: 10000 } + { useCache: false, cacheResults: true, timeout: 10000, priority: 'high' } ); // Filter to only replies that reference the root diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index c1db073..b74dfa5 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -14,6 +14,7 @@ import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { stripMarkdown } from '../../services/text-utils.js'; import { isBookmarked } from '../../services/user-actions.js'; + import { sessionManager } from '../../services/auth/session-manager.js'; interface Props { post: NostrEvent; @@ -37,12 +38,18 @@ let zapCount = $state(0); // Check if this event is bookmarked (async, so we use state) + // Only check if user is logged in let bookmarked = $state(false); + const isLoggedIn = $derived(sessionManager.isLoggedIn()); $effect(() => { - isBookmarked(post.id).then(b => { - bookmarked = b; - }); + if (isLoggedIn) { + isBookmarked(post.id).then(b => { + bookmarked = b; + }); + } else { + bookmarked = false; + } }); // Calculate votes as derived values to avoid infinite loops @@ -465,7 +472,9 @@ {/if} {/if}