From 2475a9ce25cb5b2a409a4a7e66c06d648b6ad272 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 16 Jan 2026 20:41:44 +0100 Subject: [PATCH] reveal comments on publications and correct threading --- src/lib/components/CommentViewer.svelte | 26 +++++ .../publications/CommentLayer.svelte | 67 ++++++++++- .../publications/Publication.svelte | 21 +++- .../publications/PublicationSection.svelte | 90 +++++++++++++-- .../publications/SectionComments.svelte | 105 +++++++++++++++--- src/lib/components/util/CardActions.svelte | 14 ++- 6 files changed, 289 insertions(+), 34 deletions(-) diff --git a/src/lib/components/CommentViewer.svelte b/src/lib/components/CommentViewer.svelte index a1b3836..83b3b20 100644 --- a/src/lib/components/CommentViewer.svelte +++ b/src/lib/components/CommentViewer.svelte @@ -523,6 +523,32 @@ } } + /** + * Public method to refresh comments (e.g., after creating a new one) + */ + export function refresh() { + console.log(`[CommentViewer] Refreshing comments for event:`, event?.id); + + // Clean up previous subscription + if (activeSub) { + activeSub.stop(); + activeSub = null; + } + + // Reset state + comments = []; + profiles = new Map(); + nestedReplyIds = new Set(); + isFetchingNestedReplies = false; + retryCount = 0; + isFetching = false; + + // Refetch comments + if (event?.id && !isFetching) { + fetchComments(); + } + } + // Cleanup on unmount onMount(() => { return () => { diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte index 26f574a..163ac76 100644 --- a/src/lib/components/publications/CommentLayer.svelte +++ b/src/lib/components/publications/CommentLayer.svelte @@ -72,19 +72,24 @@ try { // Build filter for kind 1111 comment events - // IMPORTANT: Use only #a tags because filters are AND, not OR - // If we include both #e and #a, relays will only return comments that have BOTH + // NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication) + // Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to) + // IMPORTANT: Use uppercase #A filter to match NIP-22 root scope tags + // If we include both #e and #A, relays will only return comments that have BOTH const filter: any = { kinds: [1111], limit: 500, }; - // Prefer #a (addressable events) since they're more specific and persistent + // NIP-22: Use uppercase #A filter to match root scope (section addresses) + // This will fetch both direct comments and replies (replies also have uppercase A tag) if (allAddresses.length > 0) { - filter["#a"] = allAddresses; + filter["#A"] = allAddresses; + console.debug(`[CommentLayer] Fetching comments for addresses (NIP-22 #A filter):`, allAddresses); } else if (allEventIds.length > 0) { // Fallback to #e if no addresses available filter["#e"] = allEventIds; + console.debug(`[CommentLayer] Fetching comments for event IDs:`, allEventIds); } // Build explicit relay set (same pattern as HighlightLayer) @@ -168,6 +173,16 @@ // Convert to NDKEvent const ndkEvent = new NDKEventClass(ndk, rawEvent); + + // AI-NOTE: Debug logging to track comment reception + const aTags = ndkEvent.tags.filter((t: string[]) => t[0] === "a"); + console.debug(`[CommentLayer] Received comment event:`, { + id: rawEvent.id?.substring(0, 8), + kind: rawEvent.kind, + aTags: aTags.map((t: string[]) => t[1]), + content: rawEvent.content?.substring(0, 50), + }); + comments = [...comments, ndkEvent]; } } else if (message[0] === "EOSE" && message[1] === subscriptionId) { @@ -202,6 +217,16 @@ // Wait for all relays to respond or timeout await Promise.allSettled(fetchPromises); + // AI-NOTE: Debug logging to track comment fetching + console.debug(`[CommentLayer] Fetched ${comments.length} comments for addresses:`, allAddresses); + if (comments.length > 0) { + console.debug(`[CommentLayer] Comment addresses:`, comments.map(c => { + // NIP-22: Look for uppercase A tag (root scope) + const rootATag = c.tags.find((t: string[]) => t[0] === "A"); + return rootATag ? rootATag[1] : "no-A-tag"; + })); + } + // Ensure loading is cleared even if checkAllResponses didn't fire loading = false; @@ -214,25 +239,44 @@ // Track the last fetched event count to know when to refetch let lastFetchedCount = $state(0); let fetchTimeout: ReturnType | null = null; + let lastAddressesString = $state(""); // Watch for changes to event data - debounce and fetch when data stabilizes $effect(() => { const currentCount = eventIds.length + eventAddresses.length; const hasEventData = currentCount > 0; + + // AI-NOTE: Debug logging to track effect execution + console.debug(`[CommentLayer] Effect running:`, { + eventIdsCount: eventIds.length, + eventAddressesCount: eventAddresses.length, + hasEventData, + addresses: eventAddresses, + }); + + // AI-NOTE: Also track the actual addresses string to detect when addresses change + // even if the count stays the same (e.g., when commentsVisible toggles) + const currentAddressesString = JSON.stringify(eventAddresses.sort()); // Only fetch if: // 1. We have event data - // 2. The count has changed since last fetch + // 2. (The count has changed OR the addresses have changed) since last fetch // 3. We're not already loading - if (hasEventData && currentCount !== lastFetchedCount && !loading) { + const addressesChanged = currentAddressesString !== lastAddressesString; + const countChanged = currentCount !== lastFetchedCount; + + if (hasEventData && (countChanged || addressesChanged) && !loading) { // Clear any existing timeout if (fetchTimeout) { clearTimeout(fetchTimeout); } + console.debug(`[CommentLayer] Effect triggered: count=${currentCount}, addresses changed=${addressesChanged}, addresses:`, eventAddresses); + // Debounce: wait 500ms for more events to arrive before fetching fetchTimeout = setTimeout(() => { lastFetchedCount = currentCount; + lastAddressesString = currentAddressesString; fetchComments(); }, 500); } @@ -249,11 +293,22 @@ * Public method to refresh comments (e.g., after creating a new one) */ export function refresh() { + console.debug(`[CommentLayer] refresh() called, current comments: ${comments.length}`); + // Clear existing comments comments = []; // Reset fetch count to force re-fetch lastFetchedCount = 0; + + // Collect current addresses to log what we're fetching + const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean); + const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean); + console.debug(`[CommentLayer] Refreshing comments for:`, { + eventIds: allEventIds, + addresses: allAddresses, + }); + fetchComments(); } diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 092af49..cab9d41 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -699,15 +699,29 @@ function toggleComments() { commentsVisible = !commentsVisible; + + // AI-NOTE: When toggling comments on, ensure CommentLayer fetches comments + // The effect in CommentLayer should handle this, but we can also trigger a refresh + if (commentsVisible && commentLayerRef) { + console.debug("[Publication] Comments toggled on, triggering refresh"); + // Small delay to ensure addresses are available + setTimeout(() => { + if (commentLayerRef && commentsVisible) { + commentLayerRef.refresh(); + } + }, 100); + } } function handleCommentPosted() { - // Refresh the comment layer after a short delay to allow relay indexing + // AI-NOTE: Refresh the comment layer after a delay to allow relay indexing + // Increased delay to 3 seconds to give relays more time to index the new comment setTimeout(() => { if (commentLayerRef) { + console.debug("[Publication] Refreshing CommentLayer after comment posted"); commentLayerRef.refresh(); } - }, 500); + }, 3000); } async function submitArticleComment() { @@ -1476,6 +1490,7 @@ {toc} allComments={comments} {commentsVisible} + onCommentPosted={handleCommentPosted} ref={(el) => onPublicationSectionMounted(el, address)} /> {:else} @@ -1630,5 +1645,7 @@ diff --git a/src/lib/components/publications/PublicationSection.svelte b/src/lib/components/publications/PublicationSection.svelte index fadabe7..7fc2775 100644 --- a/src/lib/components/publications/PublicationSection.svelte +++ b/src/lib/components/publications/PublicationSection.svelte @@ -28,6 +28,7 @@ commentsVisible = true, publicationTitle, isFirstSection = false, + onCommentPosted, }: { address: string; rootAddress: string; @@ -39,19 +40,84 @@ commentsVisible?: boolean; publicationTitle?: string; isFirstSection?: boolean; + onCommentPosted?: () => void; } = $props(); const asciidoctor: Asciidoctor = getContext("asciidoctor"); const ndk: NDK = getContext("ndk"); // Filter comments for this section - let sectionComments = $derived( - allComments.filter((comment) => { - // Check if comment targets this section via #a tag - const aTag = comment.tags.find((t) => t[0] === "a"); - return aTag && aTag[1] === address; - }), - ); + // AI-NOTE: NIP-22: Uppercase tags (A, E, I, K, P) point to root scope (section/publication) + // Lowercase tags (a, e, i, k, p) point to parent item (comment being replied to) + // All comments scoped to this section will have uppercase A tag matching section address + let sectionComments = $derived.by(() => { + // Step 1: Find all comments scoped to this section (have uppercase A tag matching section address) + const directComments = allComments.filter((comment) => { + // NIP-22: Look for uppercase A tag (root scope) + const rootATag = comment.tags.find((t) => t[0] === "A"); + const matches = rootATag && rootATag[1] === address; + + // AI-NOTE: Debug logging to help diagnose comment filtering issues + if (rootATag) { + console.debug("[PublicationSection] Comment filtering:", { + sectionAddress: address, + commentRootATag: rootATag[1], + matches, + commentId: comment.id?.substring(0, 8), + }); + } + + return matches; + }); + + // Step 2: Build a set of comment IDs that match this section (for efficient lookup) + const matchingCommentIds = new Set( + directComments.map(c => c.id?.toLowerCase()).filter(Boolean) + ); + + // Step 3: Recursively find all replies to matching comments + // NIP-22: Replies have lowercase e tag pointing to parent comment ID + // They also have uppercase A tag matching section address (same root scope) + const allMatchingComments = new Set(directComments); + let foundNewReplies = true; + + // Keep iterating until we find no new replies (handles nested replies) + while (foundNewReplies) { + foundNewReplies = false; + + for (const comment of allComments) { + // Skip if already included + if (allMatchingComments.has(comment)) { + continue; + } + + // NIP-22: Check if this comment is scoped to this section (uppercase A tag) + const rootATag = comment.tags.find((t) => t[0] === "A"); + if (!rootATag || rootATag[1] !== address) { + // Not scoped to this section, skip + continue; + } + + // NIP-22: Check if this is a reply (has lowercase e tag pointing to a matching comment) + const lowercaseETags = comment.tags.filter(t => t[0] === "e"); + for (const eTag of lowercaseETags) { + const parentId = eTag[1]?.toLowerCase(); + if (parentId && matchingCommentIds.has(parentId)) { + // This is a reply to a matching comment - include it + allMatchingComments.add(comment); + matchingCommentIds.add(comment.id?.toLowerCase() || ""); + foundNewReplies = true; + console.debug(`[PublicationSection] Found reply ${comment.id?.substring(0, 8)} to matching comment ${parentId.substring(0, 8)} (NIP-22)`); + break; // Found a match, no need to check other e tags + } + } + } + } + + const filtered = Array.from(allMatchingComments); + console.debug(`[PublicationSection] Filtered ${filtered.length} comments (${directComments.length} direct, ${filtered.length - directComments.length} replies) for section ${address} from ${allComments.length} total comments`); + return filtered; + }); let leafEvent: Promise = $derived.by( async () => await publicationTree.getEvent(address), @@ -227,7 +293,8 @@ -
+ +
{/if} {/await} @@ -283,9 +351,11 @@
- + +