From 0919677f24f9a8aef70a64cc1ff454d5c06b7fc9 Mon Sep 17 00:00:00 2001 From: limina1 Date: Fri, 7 Nov 2025 11:19:01 -0500 Subject: [PATCH] Add comprehensive comment system with actions and threaded replies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full-featured comment system for publications with support for article-level and section-level comments, complete action menus, and threaded reply functionality. Key features: - Article-level comments display next to publication header with responsive layout (desktop: right sidebar, mobile: below header) - Three-dot action menu on all comment levels (root, replies, nested replies) with View details, View JSON, Copy nevent, and Delete options - Reply functionality for all comment levels with NIP-22 compliant tagging - View details navigates to event page (/events?id={nevent}) - View JSON opens modal displaying full event data - Discussion button now available for blog-type publications - Replace placeholder in Discussion sidebar with actual comment rendering Technical implementation: - Responsive comment layout matching section comment pattern - State management for reply UI, deletion, and modals - Proper NIP-22 threading with root/parent tag structure - Integration with deletion service for comment removal - Navigation to event details page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../publications/Publication.svelte | 75 +- .../publications/SectionComments.svelte | 674 +++++++++++++++++- src/lib/components/util/ArticleNav.svelte | 2 +- 3 files changed, 724 insertions(+), 27 deletions(-) diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 7aed69d..422abf6 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -33,6 +33,7 @@ import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte"; import CommentLayer from "./CommentLayer.svelte"; import CommentButton from "./CommentButton.svelte"; + import SectionComments from "./SectionComments.svelte"; import { Textarea, P } from "flowbite-svelte"; import { userStore } from "$lib/stores/userStore"; @@ -96,6 +97,14 @@ return addresses; }); + // Filter comments for the root publication (kind 30040) + let articleComments = $derived( + comments.filter(comment => { + // Check if comment targets the root publication via #a tag + const aTag = comment.tags.find(t => t[0] === 'a'); + return aTag && aTag[1] === rootAddress; + }) + ); // #region Loading let leaves = $state>([]); @@ -505,18 +514,42 @@ {#if $publicationColumnVisibility.main}
-
-
-
+ +
+ +
+
+
+
- {#if publicationDeleted} - - - Publication deleted. Redirecting to publications page... - - {/if} + {#if publicationDeleted} + + + Publication deleted. Redirecting to publications page... + + {/if} +
+ + +
+ +
+ + + +
@@ -698,16 +731,16 @@ /> {/if}
- -
- Unknown - 1.1.1970 -
-
- This is a very intelligent comment placeholder that applies to - all the content equally well. -
-
+ + {#if articleComments.length === 0} +

+ No comments yet. Be the first to comment! +

+ {/if}
diff --git a/src/lib/components/publications/SectionComments.svelte b/src/lib/components/publications/SectionComments.svelte index b1e6eb3..d89484b 100644 --- a/src/lib/components/publications/SectionComments.svelte +++ b/src/lib/components/publications/SectionComments.svelte @@ -3,8 +3,12 @@ import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; import { getNdkContext } from "$lib/ndk"; import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; - import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons"; + import { ChevronDownOutline, ChevronRightOutline, DotsVerticalOutline, TrashBinOutline, ClipboardCleanOutline, EyeOutline } from "flowbite-svelte-icons"; import { nip19 } from "nostr-tools"; + import { Button, Popover, Modal, Textarea, P } from "flowbite-svelte"; + import { deleteEvent, canDeleteEvent } from "$lib/services/deletion"; + import { userStore } from "$lib/stores/userStore"; + import { goto } from "$app/navigation"; let { sectionAddress, @@ -21,6 +25,16 @@ // State management let profiles = $state(new Map()); let expandedThreads = $state(new Set()); + let jsonModalOpen = $state(null); + let deletingComments = $state(new Set()); + let replyingTo = $state(null); + let replyContent = $state(""); + let isSubmittingReply = $state(false); + let replyError = $state(null); + let replySuccess = $state(null); + + // Subscribe to userStore + let user = $derived($userStore); /** * Parse comment threading structure @@ -173,6 +187,147 @@ } } + /** + * Navigate to event details page + */ + function viewEventDetails(comment: NDKEvent) { + const nevent = nip19.neventEncode({ + id: comment.id, + author: comment.pubkey, + kind: comment.kind, + }); + goto(`/events?id=${encodeURIComponent(nevent)}`); + } + + /** + * Check if user can delete a comment + */ + function canDelete(comment: NDKEvent): boolean { + return canDeleteEvent(comment, ndk); + } + + /** + * Submit a reply to a comment + */ + async function submitReply(parentComment: NDKEvent) { + if (!replyContent.trim()) { + replyError = "Reply cannot be empty"; + return; + } + + if (!user.signedIn || !user.signer) { + replyError = "You must be signed in to reply"; + return; + } + + isSubmittingReply = true; + replyError = null; + replySuccess = null; + + try { + const { NDKEvent: NDKEventClass } = await import("@nostr-dev-kit/ndk"); + const { activeOutboxRelays } = await import("$lib/ndk"); + + // Get relay hint + const relays = activeOutboxRelays; + let relayHint = ""; + relays.subscribe((r) => { relayHint = r[0] || ""; })(); + + // Create reply event (kind 1111) + const replyEvent = new NDKEventClass(ndk); + replyEvent.kind = 1111; + replyEvent.content = replyContent; + + // Parse section address to get root event details + const rootParts = sectionAddress.split(":"); + if (rootParts.length !== 3) { + throw new Error("Invalid section address format"); + } + const [rootKindStr, rootAuthorPubkey, rootDTag] = rootParts; + const rootKind = parseInt(rootKindStr); + + // NIP-22 reply tags structure: + // - Root tags (A, K, P) point to the section/article + // - Parent tags (a, k, p) point to the parent comment + // - Add 'e' tag with 'reply' marker for the parent comment + replyEvent.tags = [ + // Root scope - uppercase tags (point to section) + ["A", sectionAddress, relayHint, rootAuthorPubkey], + ["K", rootKind.toString()], + ["P", rootAuthorPubkey, relayHint], + + // Parent scope - lowercase tags (point to parent comment) + ["a", `1111:${parentComment.pubkey}:`, relayHint], + ["k", "1111"], + ["p", parentComment.pubkey, relayHint], + + // Reply marker + ["e", parentComment.id, relayHint, "reply"], + ]; + + console.log("[SectionComments] Creating reply with tags:", replyEvent.tags); + + // Sign and publish + await replyEvent.sign(); + await replyEvent.publish(); + + console.log("[SectionComments] Reply published:", replyEvent.id); + + replySuccess = parentComment.id; + replyContent = ""; + + // Close reply UI after a delay + setTimeout(() => { + replyingTo = null; + replySuccess = null; + }, 2000); + + } catch (err) { + console.error("[SectionComments] Error submitting reply:", err); + replyError = err instanceof Error ? err.message : "Failed to submit reply"; + } finally { + isSubmittingReply = false; + } + } + + /** + * Delete a comment + */ + async function handleDeleteComment(comment: NDKEvent) { + if (!canDelete(comment)) return; + + if (!confirm('Are you sure you want to delete this comment?')) { + return; + } + + const newDeleting = new Set(deletingComments); + newDeleting.add(comment.id); + deletingComments = newDeleting; + + try { + const result = await deleteEvent({ + eventId: comment.id, + eventKind: comment.kind, + reason: 'User deleted comment', + }, ndk); + + if (result.success) { + console.log('[SectionComments] Comment deleted successfully'); + // Note: The comment will still show in the UI until the page is refreshed + // or the parent component refetches comments + } else { + alert(`Failed to delete comment: ${result.error}`); + } + } catch (err) { + console.error('[SectionComments] Error deleting comment:', err); + alert('Failed to delete comment'); + } finally { + const newDeleting = new Set(deletingComments); + newDeleting.delete(comment.id); + deletingComments = newDeleting; + } + } + /** * Pre-fetch profiles for all comment authors */ @@ -220,8 +375,91 @@ {replyCount} {replyCount === 1 ? 'reply' : 'replies'} {/if} + • +
+ + +
+ + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • + {#if canDelete(rootComment)} +
  • + +
  • + {/if} +
+
+
{:else} @@ -253,6 +491,72 @@ {replyCount} {replyCount === 1 ? 'reply' : 'replies'} {/if} + + +
+ + +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • + {#if canDelete(rootComment)} +
  • + +
  • + {/if} +
+
+
@@ -261,12 +565,69 @@ {@render basicMarkup(rootComment.content)} + +
+ +
+ + + {#if replyingTo === rootComment.id} +
+