Browse Source

reveal comments on publications and correct threading

master^2
Silberengel 2 months ago
parent
commit
2475a9ce25
  1. 26
      src/lib/components/CommentViewer.svelte
  2. 67
      src/lib/components/publications/CommentLayer.svelte
  3. 21
      src/lib/components/publications/Publication.svelte
  4. 90
      src/lib/components/publications/PublicationSection.svelte
  5. 103
      src/lib/components/publications/SectionComments.svelte
  6. 14
      src/lib/components/util/CardActions.svelte

26
src/lib/components/CommentViewer.svelte

@ -523,6 +523,32 @@ @@ -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 () => {

67
src/lib/components/publications/CommentLayer.svelte

@ -72,19 +72,24 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -214,25 +239,44 @@
// Track the last fetched event count to know when to refetch
let lastFetchedCount = $state(0);
let fetchTimeout: ReturnType<typeof setTimeout> | 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 @@ @@ -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();
}
</script>

21
src/lib/components/publications/Publication.svelte

@ -699,15 +699,29 @@ @@ -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 @@ @@ -1476,6 +1490,7 @@
{toc}
allComments={comments}
{commentsVisible}
onCommentPosted={handleCommentPosted}
ref={(el) => onPublicationSectionMounted(el, address)}
/>
{:else}
@ -1630,5 +1645,7 @@ @@ -1630,5 +1645,7 @@
<CardActions
event={indexEvent}
bind:detailsModalOpen={detailsModalOpen}
sectionAddress={rootAddress}
onCommentPosted={handleCommentPosted}
/>
</div>

90
src/lib/components/publications/PublicationSection.svelte

@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
commentsVisible = true,
publicationTitle,
isFirstSection = false,
onCommentPosted,
}: {
address: string;
rootAddress: string;
@ -39,19 +40,84 @@ @@ -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<NDKEvent>(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<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
@ -227,7 +293,8 @@ @@ -227,7 +293,8 @@
</script>
<!-- Wrapper for positioning context -->
<div class="relative w-full overflow-x-hidden">
<!-- AI-NOTE: Removed overflow-x-hidden to allow comments panel to be visible when positioned absolutely -->
<div class="relative w-full">
<section
id={address}
bind:this={sectionRef}
@ -257,6 +324,7 @@ @@ -257,6 +324,7 @@
{event}
sectionAddress={address}
onDelete={handleDelete}
onCommentPosted={onCommentPosted}
/>
{/if}
{/await}
@ -283,9 +351,11 @@ @@ -283,9 +351,11 @@
</section>
<!-- Comments area: positioned below menu, top-center of section -->
<!-- Comments area: positioned to the right of section on desktop -->
<!-- AI-NOTE: Comments panel positioned to the right of sections on desktop (xl+ screens)
Positioned relative to viewport right edge to ensure visibility -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[calc(20%+3rem)] w-[max(16rem,min(24rem,calc(50vw-26rem-2rem)))]"
class="hidden xl:block fixed right-8 top-[calc(20%+70px)] w-80 max-h-[calc(100vh-200px)] overflow-y-auto z-30"
>
<SectionComments
sectionAddress={address}

103
src/lib/components/publications/SectionComments.svelte

@ -37,41 +37,99 @@ @@ -37,41 +37,99 @@
let user = $derived($userStore);
/**
* Parse comment threading structure
* Root comments have no 'e' tag with 'reply' marker
* Parse comment threading structure according to NIP-22
* NIP-22: Uppercase tags (A, E, I, K, P) = root scope
* Lowercase tags (a, e, i, k, p) = parent item (comment being replied to)
* Root comments have no lowercase e tag (or lowercase e tag pointing to section itself)
* Replies have lowercase e tag pointing to parent comment ID
*/
function buildThreadStructure(allComments: NDKEvent[]) {
const rootComments: NDKEvent[] = [];
const repliesByParent = new Map<string, NDKEvent[]>();
// AI-NOTE: Normalize comment IDs to lowercase for consistent matching
const allCommentIds = new Set(allComments.map(c => c.id?.toLowerCase()).filter(Boolean));
// AI-NOTE: Debug logging to track comment threading
console.debug(`[SectionComments] Building thread structure from ${allComments.length} comments (NIP-22)`);
// NIP-22: First pass - identify replies by looking for lowercase e tags
// Lowercase e tags point to the parent comment ID
for (const comment of allComments) {
// Check if this is a reply by looking for 'e' tags with 'reply' marker
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply');
// NIP-22: Look for lowercase e tag (parent item reference)
const lowercaseETags = comment.tags.filter(t => t[0] === 'e');
if (lowercaseETags.length > 0) {
// Check if any lowercase e tag points to a comment in our set
let isReply = false;
for (const eTag of lowercaseETags) {
const parentId = eTag[1]?.trim().toLowerCase();
if (!parentId) {
continue;
}
// NIP-22: If lowercase e tag points to a comment in our set, it's a reply
if (allCommentIds.has(parentId)) {
isReply = true;
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
}
repliesByParent.get(parentId)!.push(comment);
console.debug(`[SectionComments] Comment ${comment.id?.substring(0, 8)} is a reply to ${parentId.substring(0, 8)} (NIP-22 lowercase e tag)`);
break; // Found parent, no need to check other e tags
}
}
if (replyTag) {
const parentId = replyTag[1];
if (!repliesByParent.has(parentId)) {
repliesByParent.set(parentId, []);
if (!isReply) {
// Has lowercase e tag but doesn't reference any comment in our set
// This might be a root comment that references an external event, or malformed
rootComments.push(comment);
console.debug(`[SectionComments] Comment ${comment.id?.substring(0, 8)} is a root comment (lowercase e tag references external event)`);
}
repliesByParent.get(parentId)!.push(comment);
} else {
// This is a root comment (no reply tag)
// No lowercase e tags - this is a root comment
rootComments.push(comment);
console.debug(`[SectionComments] Comment ${comment.id?.substring(0, 8)} is a root comment (no lowercase e tags)`);
}
}
console.debug(`[SectionComments] Thread structure: ${rootComments.length} root comments, ${repliesByParent.size} reply groups`);
// AI-NOTE: Log reply details for debugging
for (const [parentId, replies] of repliesByParent.entries()) {
console.debug(`[SectionComments] Parent ${parentId.substring(0, 8)} has ${replies.length} replies:`, replies.map(r => r.id?.substring(0, 8)));
}
return { rootComments, repliesByParent };
}
let threadStructure = $derived(buildThreadStructure(comments));
let threadStructure = $derived.by(() => {
const structure = buildThreadStructure(comments);
// AI-NOTE: Debug logging when structure changes
if (structure.rootComments.length > 0 || comments.length > 0) {
console.debug(`[SectionComments] Thread structure updated:`, {
totalComments: comments.length,
rootComments: structure.rootComments.length,
replyGroups: structure.repliesByParent.size,
visible,
});
}
return structure;
});
/**
* Count replies for a comment thread
*/
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number {
const directReplies = repliesMap.get(commentId) || [];
// AI-NOTE: Normalize comment ID for lookup (must match how we stored it)
const normalizedCommentId = commentId?.trim().toLowerCase();
const directReplies = repliesMap.get(normalizedCommentId) || [];
let count = directReplies.length;
// AI-NOTE: Debug logging to track reply counting
if (directReplies.length > 0) {
console.debug(`[SectionComments] Found ${directReplies.length} direct replies for comment ${normalizedCommentId.substring(0, 8)}`);
}
// Recursively count nested replies
for (const reply of directReplies) {
count += countReplies(reply.id, repliesMap);
@ -166,7 +224,21 @@ @@ -166,7 +224,21 @@
* Render nested replies recursively
*/
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) {
const replies = repliesMap.get(parentId) || [];
// AI-NOTE: Normalize parent ID for lookup (must match how we stored it)
const normalizedParentId = parentId?.trim().toLowerCase();
const replies = repliesMap.get(normalizedParentId) || [];
// AI-NOTE: Debug logging to track reply rendering
if (replies.length > 0) {
console.debug(`[SectionComments] Rendering ${replies.length} replies for parent ${normalizedParentId.substring(0, 8)}`);
} else {
// AI-NOTE: Debug when no replies found - check if map has any entries for similar IDs
const allParentIds = Array.from(repliesMap.keys());
const similarIds = allParentIds.filter(id => id.substring(0, 8) === normalizedParentId.substring(0, 8));
if (similarIds.length > 0) {
console.debug(`[SectionComments] No replies found for ${normalizedParentId.substring(0, 8)}, but found similar IDs:`, similarIds.map(id => id.substring(0, 8)));
}
}
return replies;
}
@ -339,8 +411,10 @@ @@ -339,8 +411,10 @@
});
</script>
<!-- AI-NOTE: Debug info for comment display -->
{#if visible && threadStructure.rootComments.length > 0}
<div class="space-y-1">
{console.debug(`[SectionComments] RENDERING: visible=${visible}, rootComments=${threadStructure.rootComments.length}, totalComments=${comments.length}`)}
<div class="space-y-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg p-4 shadow-lg">
{#each threadStructure.rootComments as rootComment (rootComment.id)}
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)}
{@const isExpanded = expandedThreads.has(rootComment.id)}
@ -602,6 +676,7 @@ @@ -602,6 +676,7 @@
<!-- Replies -->
{#if replyCount > 0}
{console.debug(`[SectionComments] Rendering ${replyCount} replies for comment ${rootComment.id?.substring(0, 8)}`)}
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2">
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)}
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3">

14
src/lib/components/util/CardActions.svelte

@ -30,12 +30,14 @@ @@ -30,12 +30,14 @@
event,
onDelete,
sectionAddress,
detailsModalOpen = $bindable(false)
detailsModalOpen = $bindable(false),
onCommentPosted
} = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
detailsModalOpen?: boolean; // Bindable prop to control modal from outside
onCommentPosted?: () => void; // Callback when a comment is successfully posted
}>();
const ndk = getNdkContext();
@ -385,6 +387,16 @@ @@ -385,6 +387,16 @@
}
commentSuccess = true;
// AI-NOTE: Trigger callback to refresh comments after successful publish
// This allows parent components to refresh their comment displays
if (onCommentPosted) {
// Delay callback slightly to allow relay indexing
setTimeout(() => {
onCommentPosted();
}, 1000);
}
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;

Loading…
Cancel
Save