From 833b82d43d863221fad79f521bdcb897d37982aa Mon Sep 17 00:00:00 2001 From: limina1 Date: Thu, 6 Nov 2025 10:46:31 -0500 Subject: [PATCH 01/13] Add text highlighting system with position-based overlays and mock data support --- .../publications/HighlightButton.svelte | 21 + .../publications/HighlightLayer.svelte | 788 ++++++++++++++++ .../HighlightSelectionHandler.svelte | 429 +++++++++ src/lib/utils/fetch_publication_highlights.ts | 70 ++ src/lib/utils/highlightPositioning.ts | 224 +++++ src/lib/utils/highlightUtils.ts | 156 ++++ src/lib/utils/mockHighlightData.ts | 183 ++++ tests/unit/fetchPublicationHighlights.test.ts | 318 +++++++ tests/unit/highlightLayer.test.ts | 859 +++++++++++++++++ tests/unit/highlightSelection.test.ts | 875 ++++++++++++++++++ 10 files changed, 3923 insertions(+) create mode 100644 src/lib/components/publications/HighlightButton.svelte create mode 100644 src/lib/components/publications/HighlightLayer.svelte create mode 100644 src/lib/components/publications/HighlightSelectionHandler.svelte create mode 100644 src/lib/utils/fetch_publication_highlights.ts create mode 100644 src/lib/utils/highlightPositioning.ts create mode 100644 src/lib/utils/highlightUtils.ts create mode 100644 src/lib/utils/mockHighlightData.ts create mode 100644 tests/unit/fetchPublicationHighlights.test.ts create mode 100644 tests/unit/highlightLayer.test.ts create mode 100644 tests/unit/highlightSelection.test.ts diff --git a/src/lib/components/publications/HighlightButton.svelte b/src/lib/components/publications/HighlightButton.svelte new file mode 100644 index 0000000..90c45c1 --- /dev/null +++ b/src/lib/components/publications/HighlightButton.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte new file mode 100644 index 0000000..c6f273e --- /dev/null +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -0,0 +1,788 @@ + + +{#if loading && visible} +
+

Loading highlights...

+
+{/if} + +{#if visible && highlights.length > 0} +
+

+ Highlights +

+
+ {#each Array.from(groupedHighlights.entries()) as [pubkey, authorHighlights]} + {@const isExpanded = expandedAuthors.has(pubkey)} + {@const profile = authorProfiles.get(pubkey)} + {@const displayName = getAuthorDisplayName(profile, pubkey)} + {@const color = colorMap.get(pubkey) || "hsla(60, 70%, 60%, 0.3)"} + {@const sortedHighlights = sortHighlightsByTime(authorHighlights)} + +
+ + + + + {#if isExpanded} +
+ {#each sortedHighlights as highlight} + {@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} + {@const showCopied = copyFeedback === highlight.id} + +
+ + +
+ {/each} +
+ {/if} +
+ {/each} +
+
+{/if} + + diff --git a/src/lib/components/publications/HighlightSelectionHandler.svelte b/src/lib/components/publications/HighlightSelectionHandler.svelte new file mode 100644 index 0000000..1a17986 --- /dev/null +++ b/src/lib/components/publications/HighlightSelectionHandler.svelte @@ -0,0 +1,429 @@ + + +{#if showConfirmModal} + +
+
+

Selected Text:

+
+

"{selectedText}"

+
+
+ +
+ + `, + })), + P: vi.fn().mockImplementation(() => ({ + $$render: () => `

`, + })), +})); + +// Mock flowbite-svelte-icons +vi.mock("flowbite-svelte-icons", () => ({ + FontHighlightOutline: vi.fn().mockImplementation(() => ({ + $$render: () => ``, + })), +})); + +describe("HighlightButton Component Logic", () => { + let isActive: boolean; + + beforeEach(() => { + isActive = false; + }); + + describe("Initial State", () => { + it("should initialize with inactive state", () => { + expect(isActive).toBe(false); + }); + + it("should have correct inactive label", () => { + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Add Highlight"); + }); + + it("should have correct inactive title", () => { + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Enter highlight mode"); + }); + + it("should have correct inactive color", () => { + const color = isActive ? "primary" : "light"; + expect(color).toBe("light"); + }); + + it("should not have ring styling when inactive", () => { + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe(""); + }); + }); + + describe("Toggle Functionality", () => { + it("should toggle to active state when clicked", () => { + // Simulate toggle + isActive = !isActive; + expect(isActive).toBe(true); + }); + + it("should toggle back to inactive state on second click", () => { + // Simulate two toggles + isActive = !isActive; + isActive = !isActive; + expect(isActive).toBe(false); + }); + + it("should show correct label when active", () => { + isActive = true; + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Exit Highlight Mode"); + }); + + it("should show correct title when active", () => { + isActive = true; + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Exit highlight mode"); + }); + }); + + describe("Active State Styling", () => { + it("should apply primary color when active", () => { + isActive = true; + const color = isActive ? "primary" : "light"; + expect(color).toBe("primary"); + }); + + it("should apply ring styling when active", () => { + isActive = true; + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe("ring-2 ring-primary-500"); + }); + }); +}); + +describe("HighlightSelectionHandler Component Logic", () => { + let mockNDK: NDKEvent; + let mockUserStore: any; + let mockSelection: Selection; + let mockPublicationEvent: NDKEvent; + let isActive: boolean; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + isActive = false; + + // Mock document and DOM elements + const mockElement = { + createElement: vi.fn((tag: string) => ({ + tagName: tag.toUpperCase(), + textContent: "", + className: "", + closest: vi.fn(), + parentElement: null, + })), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + body: { + classList: { + add: vi.fn(), + remove: vi.fn(), + }, + }, + }; + + global.document = mockElement as any; + + // Mock NDK event + mockPublicationEvent = { + id: "test-event-id", + pubkey: "test-pubkey", + kind: 30023, + tagAddress: vi.fn().mockReturnValue("30023:test-pubkey:test-d-tag"), + tags: [], + content: "", + } as unknown as NDKEvent; + + // Mock user store + mockUserStore = { + signedIn: true, + signer: { + sign: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Mock window.getSelection + const mockParagraph = { + textContent: "This is the full paragraph context", + closest: vi.fn(), + }; + + mockSelection = { + toString: vi.fn().mockReturnValue("Selected text from publication"), + isCollapsed: false, + removeAllRanges: vi.fn(), + anchorNode: { + parentElement: mockParagraph, + }, + } as unknown as Selection; + + global.window = { + getSelection: vi.fn().mockReturnValue(mockSelection), + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Selection Detection", () => { + it("should ignore mouseup events when isActive is false", () => { + isActive = false; + const shouldProcess = isActive; + expect(shouldProcess).toBe(false); + }); + + it("should process mouseup events when isActive is true", () => { + isActive = true; + const shouldProcess = isActive; + expect(shouldProcess).toBe(true); + }); + + it("should ignore collapsed selections", () => { + const selection = { isCollapsed: true } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(true); + }); + + it("should process non-collapsed selections", () => { + const selection = { isCollapsed: false } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(false); + }); + + it("should ignore selections with less than 3 characters", () => { + const text = "ab"; + const isValid = text.length >= 3; + expect(isValid).toBe(false); + }); + + it("should accept selections with 3 or more characters", () => { + const text = "abc"; + const isValid = text.length >= 3; + expect(isValid).toBe(true); + }); + + it("should ignore empty selections after trim", () => { + const text = " "; + const trimmed = text.trim(); + const isValid = trimmed.length >= 3; + expect(isValid).toBe(false); + }); + }); + + describe("User Authentication", () => { + it("should reject selection when user not signed in", () => { + const userStore = { signedIn: false }; + expect(userStore.signedIn).toBe(false); + }); + + it("should process selection when user signed in", () => { + const userStore = { signedIn: true }; + expect(userStore.signedIn).toBe(true); + }); + + it("should check for signer before creating highlight", () => { + const userStore = { + signedIn: true, + signer: { sign: vi.fn() }, + }; + expect(userStore.signer).toBeDefined(); + }); + + it("should reject creation without signer", () => { + const userStore = { + signedIn: true, + signer: null, + }; + expect(userStore.signer).toBeNull(); + }); + }); + + describe("Publication Context Detection", () => { + it("should detect selection within publication-leather class", () => { + const mockElement = { + className: "publication-leather", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? mockElement : null; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeTruthy(); + }); + + it("should reject selection outside publication-leather class", () => { + const mockElement = { + className: "other-section", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? null : mockElement; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeNull(); + }); + }); + + describe("Context Extraction", () => { + it("should extract context from parent paragraph", () => { + const paragraph = { + textContent: "This is the full paragraph context with selected text inside.", + }; + + const context = paragraph.textContent?.trim() || ""; + expect(context).toBe("This is the full paragraph context with selected text inside."); + }); + + it("should extract context from parent section", () => { + const section = { + textContent: "Full section context including selected text.", + }; + + const context = section.textContent?.trim() || ""; + expect(context).toBe("Full section context including selected text."); + }); + + it("should extract context from parent div", () => { + const div = { + textContent: "Full div context including selected text.", + }; + + const context = div.textContent?.trim() || ""; + expect(context).toBe("Full div context including selected text."); + }); + + it("should handle missing context gracefully", () => { + const context = ""; + expect(context).toBe(""); + }); + }); + + describe("NIP-84 Event Creation - Addressable Events", () => { + it("should use 'a' tag for addressable events", () => { + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + }); + + it("should create event with correct kind 9802", () => { + const event = { + kind: 9802, + content: "", + tags: [], + }; + + expect(event.kind).toBe(9802); + }); + + it("should include selected text as content", () => { + const selectedText = "This is the selected highlight text"; + const event = { + kind: 9802, + content: selectedText, + tags: [], + }; + + expect(event.content).toBe(selectedText); + }); + + it("should include context tag", () => { + const context = "This is the surrounding context"; + const tags: string[][] = []; + + if (context) { + tags.push(["context", context]); + } + + expect(tags).toContainEqual(["context", context]); + }); + + it("should include author p-tag with role", () => { + const pubkey = "author-pubkey-hex"; + const tags: string[][] = []; + + if (pubkey) { + tags.push(["p", pubkey, "", "author"]); + } + + expect(tags).toContainEqual(["p", pubkey, "", "author"]); + }); + + it("should include comment tag when comment provided", () => { + const comment = "This is my insightful comment"; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).toContainEqual(["comment", comment]); + }); + + it("should not include comment tag when comment is empty", () => { + const comment = ""; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).not.toContainEqual(["comment", ""]); + expect(tags.length).toBe(0); + }); + + it("should not include comment tag when comment is only whitespace", () => { + const comment = " "; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags.length).toBe(0); + }); + }); + + describe("NIP-84 Event Creation - Regular Events", () => { + it("should use 'e' tag for regular events", () => { + const eventId = "regular-event-id"; + const eventAddress = null; // No address means regular event + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["e", eventId, ""]); + }); + + it("should prefer addressable event over regular event", () => { + const eventId = "regular-event-id"; + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + expect(tags).not.toContainEqual(["e", eventId, ""]); + }); + }); + + describe("Complete Event Structure", () => { + it("should create complete highlight event with all required tags", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(3); + expect(event.tags[0]).toEqual(["a", eventAddress, ""]); + expect(event.tags[1]).toEqual(["context", context]); + expect(event.tags[2]).toEqual(["p", pubkey, "", "author"]); + }); + + it("should create complete quote highlight with comment", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + const comment = "My thoughtful comment"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ["comment", comment], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(4); + expect(event.tags[3]).toEqual(["comment", comment]); + }); + + it("should handle event without context", () => { + const selectedText = "Highlighted text"; + const context = ""; + const pubkey = "author-pubkey"; + const eventId = "event-id"; + + const tags: string[][] = []; + tags.push(["e", eventId, ""]); + if (context) { + tags.push(["context", context]); + } + tags.push(["p", pubkey, "", "author"]); + + expect(tags).toHaveLength(2); + expect(tags).not.toContainEqual(["context", ""]); + }); + }); + + describe("Event Signing and Publishing", () => { + it("should sign event before publishing", async () => { + const mockSigner = { + sign: vi.fn().mockResolvedValue(undefined), + }; + + const mockEvent = { + kind: 9802, + content: "test", + tags: [], + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign(mockSigner); + expect(mockEvent.sign).toHaveBeenCalledWith(mockSigner); + }); + + it("should publish event after signing", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign({}); + await mockEvent.publish(); + + expect(mockEvent.publish).toHaveBeenCalled(); + }); + + it("should handle signing errors", async () => { + const mockEvent = { + sign: vi.fn().mockRejectedValue(new Error("Signing failed")), + }; + + await expect(mockEvent.sign({})).rejects.toThrow("Signing failed"); + }); + + it("should handle publishing errors", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockRejectedValue(new Error("Publishing failed")), + }; + + await mockEvent.sign({}); + await expect(mockEvent.publish()).rejects.toThrow("Publishing failed"); + }); + }); + + describe("Selection Cleanup", () => { + it("should clear selection after successful highlight creation", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset selectedText after creation", () => { + let selectedText = "Some text"; + selectedText = ""; + expect(selectedText).toBe(""); + }); + + it("should reset comment after creation", () => { + let comment = "Some comment"; + comment = ""; + expect(comment).toBe(""); + }); + + it("should reset context after creation", () => { + let context = "Some context"; + context = ""; + expect(context).toBe(""); + }); + + it("should close modal after creation", () => { + let showModal = true; + showModal = false; + expect(showModal).toBe(false); + }); + }); + + describe("Cancel Functionality", () => { + it("should clear selection when cancelled", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // Simulate cancel + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset all state when cancelled", () => { + let selectedText = "text"; + let comment = "comment"; + let context = "context"; + let showModal = true; + + // Simulate cancel + selectedText = ""; + comment = ""; + context = ""; + showModal = false; + + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(context).toBe(""); + expect(showModal).toBe(false); + }); + }); + + describe("Feedback Messages", () => { + it("should show success message after creation", () => { + const message = "Highlight created successfully!"; + const type = "success"; + + expect(message).toBe("Highlight created successfully!"); + expect(type).toBe("success"); + }); + + it("should show error message on failure", () => { + const message = "Failed to create highlight. Please try again."; + const type = "error"; + + expect(message).toBe("Failed to create highlight. Please try again."); + expect(type).toBe("error"); + }); + + it("should show error when not signed in", () => { + const message = "Please sign in to create highlights"; + const type = "error"; + + expect(message).toBe("Please sign in to create highlights"); + expect(type).toBe("error"); + }); + + it("should auto-hide feedback after delay", () => { + let showFeedback = true; + + // Simulate timeout + setTimeout(() => { + showFeedback = false; + }, 3000); + + // Initially shown + expect(showFeedback).toBe(true); + }); + }); + + describe("Event Listeners", () => { + it("should add mouseup listener on mount", () => { + const mockAddEventListener = vi.fn(); + document.addEventListener = mockAddEventListener; + + document.addEventListener("mouseup", () => {}); + expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function)); + }); + + it("should remove mouseup listener on unmount", () => { + const mockRemoveEventListener = vi.fn(); + document.removeEventListener = mockRemoveEventListener; + + const handler = () => {}; + document.removeEventListener("mouseup", handler); + expect(mockRemoveEventListener).toHaveBeenCalledWith("mouseup", handler); + }); + }); + + describe("Highlight Mode Body Class", () => { + it("should add highlight-mode-active class when active", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate active mode + document.body.classList.add("highlight-mode-active"); + expect(mockClassList.add).toHaveBeenCalledWith("highlight-mode-active"); + }); + + it("should remove highlight-mode-active class when inactive", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate inactive mode + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); + }); + + it("should clean up class on unmount", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate cleanup + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); + }); + }); + + describe("Modal Display", () => { + it("should show modal when text is selected", () => { + let showModal = false; + + // Simulate successful selection + showModal = true; + expect(showModal).toBe(true); + }); + + it("should display selected text in modal", () => { + const selectedText = "This is the selected text"; + const displayText = `"${selectedText}"`; + + expect(displayText).toBe('"This is the selected text"'); + }); + + it("should provide textarea for optional comment", () => { + let comment = ""; + const placeholder = "Share your thoughts about this highlight..."; + + expect(placeholder).toBe("Share your thoughts about this highlight..."); + expect(comment).toBe(""); + }); + + it("should disable buttons while submitting", () => { + const isSubmitting = true; + const disabled = isSubmitting; + + expect(disabled).toBe(true); + }); + + it("should show 'Creating...' text while submitting", () => { + const isSubmitting = true; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Creating..."); + }); + + it("should show normal text when not submitting", () => { + const isSubmitting = false; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Create Highlight"); + }); + }); + + describe("Callback Execution", () => { + it("should call onHighlightCreated callback after creation", () => { + const mockCallback = vi.fn(); + + // Simulate successful creation + mockCallback(); + + expect(mockCallback).toHaveBeenCalled(); + }); + + it("should not call callback if creation fails", () => { + const mockCallback = vi.fn(); + + // Simulate failed creation - callback not called + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("should handle missing callback gracefully", () => { + const callback = undefined; + + // Should not throw error + expect(() => { + if (callback) { + callback(); + } + }).not.toThrow(); + }); + }); + + describe("Integration Scenarios", () => { + it("should handle complete highlight workflow", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text for highlighting", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + expect(selectedText).toBe("Selected text for highlighting"); + expect(showModal).toBe(true); + }); + + it("should handle complete quote highlight workflow with comment", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + let comment = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + // User adds comment + comment = "This is insightful"; + + // Create event with comment + const tags: string[][] = []; + if (comment.trim()) { + tags.push(["comment", comment]); + } + + expect(selectedText).toBe("Selected text"); + expect(comment).toBe("This is insightful"); + expect(tags).toContainEqual(["comment", "This is insightful"]); + }); + + it("should reject workflow when user not signed in", () => { + let isActive = true; + let showModal = false; + const userSignedIn = false; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User tries to select text + if (isActive && userSignedIn && !selection.isCollapsed) { + showModal = true; + } + + expect(showModal).toBe(false); + }); + + it("should handle workflow cancellation", () => { + // Setup initial state + let showModal = true; + let selectedText = "Some text"; + let comment = "Some comment"; + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // User cancels + showModal = false; + selectedText = ""; + comment = ""; + mockSelection.removeAllRanges(); + + expect(showModal).toBe(false); + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + }); +}); From 5ec17ad1b1363ddae2255c8c6853cf77adb9fbc3 Mon Sep 17 00:00:00 2001 From: limina1 Date: Thu, 6 Nov 2025 10:46:37 -0500 Subject: [PATCH 02/13] Add article and section commenting system with NIP-22 support --- .../publications/CommentButton.svelte | 520 ++++++++++ .../publications/CommentLayer.svelte | 282 ++++++ .../publications/CommentPanel.svelte | 280 ++++++ .../publications/SectionComments.svelte | 323 +++++++ src/lib/utils/mockCommentData.ts | 177 ++++ src/lib/utils/nostrUtils.ts | 31 + tests/unit/commentButton.test.ts | 911 ++++++++++++++++++ 7 files changed, 2524 insertions(+) create mode 100644 src/lib/components/publications/CommentButton.svelte create mode 100644 src/lib/components/publications/CommentLayer.svelte create mode 100644 src/lib/components/publications/CommentPanel.svelte create mode 100644 src/lib/components/publications/SectionComments.svelte create mode 100644 src/lib/utils/mockCommentData.ts create mode 100644 tests/unit/commentButton.test.ts diff --git a/src/lib/components/publications/CommentButton.svelte b/src/lib/components/publications/CommentButton.svelte new file mode 100644 index 0000000..fb6ee25 --- /dev/null +++ b/src/lib/components/publications/CommentButton.svelte @@ -0,0 +1,520 @@ + + + +
+ + + + {#if showCommentUI} +
+
+

Add Comment

+ {#if $userStore.profile} + + {/if} +
+ + {:else} @@ -335,10 +337,9 @@ {#if isEditing} - toggleEditing(rootId, false)} - /> + {#snippet right()} + toggleEditing(rootId, false)} /> + {/snippet}
-
+
- {#if !content.trim()} -
- Start typing to see the preview... -
- {:else} -
- - {#if contentType === "article" && publicationResult?.metadata.title} - {@const documentHeader = content.split(/\n==\s+/)[0]} -
-
- {@html asciidoctor.convert(documentHeader, { - standalone: false, - attributes: { - showtitle: true, - sectids: false, - }, - })} + {#if !content.trim()} +
+ Start typing to see the preview... +
+ {:else} +
+ + {#if contentType === "article" && publicationResult?.metadata.title} + {@const documentHeader = content.split(/\n==\s+/)[0]} +
+
+ {@html asciidoctor.convert(documentHeader, { + standalone: false, + attributes: { + showtitle: true, + sectids: false, + }, + })} +
-
- {/if} - - {#each parsedSections as section, index} -
- {#if section.isIndex} - -
- -
- Index Event (30040) -
+ {/if} - -

- {section.title} -

- - - {#if section.tags && section.tags.length > 0} - {@const tTags = section.tags.filter((tag) => tag[0] === 't')} - {@const wTags = section.tags.filter((tag) => tag[0] === 'w')} - - {#if tTags.length > 0 || wTags.length > 0} -
- - {#if tTags.length > 0} -
- {#each tTags as tag} - - #{tag[1]} - - {/each} -
- {/if} + {#each parsedSections as section, index} +
+ {#if section.isIndex} + +
+ +
+ Index Event (30040) +
- - {#if wTags.length > 0} -
- {#each wTags as tag} - - 🔗 {tag[2] || tag[1]} - - {/each} + +

+ {section.title} +

+ + + {#if section.tags && section.tags.length > 0} + {@const tTags = section.tags.filter( + (tag: any) => tag[0] === "t", + )} + {@const wTags = section.tags.filter( + (tag: any) => tag[0] === "w", + )} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + 🔗 {tag[2] || tag[1]} + + {/each} +
+ {/if}
{/if} -
{/if} - {/if} -
- {:else} - -
- -
- Content Event (30041)
+ {:else} + +
+ +
+ Content Event (30041) +
- -
- {@html asciidoctor.convert( - `${"=".repeat(section.level)} ${section.title}`, - { - standalone: false, - attributes: { - showtitle: false, - sectids: false, + +
+ {@html asciidoctor.convert( + `${"=".repeat(section.level)} ${section.title}`, + { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, }, - }, - )} -
- - - {#if section.tags && section.tags.length > 0} - {@const tTags = section.tags.filter((tag) => tag[0] === 't')} - {@const wTags = section.tags.filter((tag) => tag[0] === 'w')} - - {#if tTags.length > 0 || wTags.length > 0} -
- - {#if tTags.length > 0} -
- {#each tTags as tag} - - #{tag[1]} - - {/each} -
- {/if} + )} +
- - {#if wTags.length > 0} -
- {#each wTags as tag} - - 🔗 {tag[2] || tag[1]} - - {/each} + + {#if section.tags && section.tags.length > 0} + {@const tTags = section.tags.filter( + (tag: any) => tag[0] === "t", + )} + {@const wTags = section.tags.filter( + (tag: any) => tag[0] === "w", + )} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + 🔗 {tag[2] || tag[1]} + + {/each} +
+ {/if}
{/if} -
{/if} - {/if} - - {#if section.content} -
- {@html (() => { - // Extract wiki links and replace with placeholders BEFORE Asciidoctor - const wikiLinks = extractWikiLinks(section.content); - let contentWithPlaceholders = section.content; - const placeholders = new Map(); - - wikiLinks.forEach((link, index) => { - // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text - const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; - const placeholder = `pass:[${innerPlaceholder}]`; - placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) - contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder); - }); - - // Check if content contains nested headers - const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n===='); - - let rendered; - if (hasNestedHeaders) { - // For proper nested header parsing, we need full document context - // Create a complete AsciiDoc document structure - // Important: Ensure proper level sequence for nested headers - const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`; - - rendered = asciidoctor.convert(fullDoc, { - standalone: false, - attributes: { - showtitle: false, - sectids: false, - }, + + {#if section.content} +
+ {@html (() => { + // Extract wiki links and replace with placeholders BEFORE Asciidoctor + const wikiLinks = extractWikiLinks( + section.content, + ); + let contentWithPlaceholders = section.content; + const placeholders = new Map(); + + wikiLinks.forEach((link, index) => { + // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text + const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; + const placeholder = `pass:[${innerPlaceholder}]`; + placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) + contentWithPlaceholders = + contentWithPlaceholders.replace( + link.fullMatch, + placeholder, + ); }); - // Extract just the content we want (remove the temporary structure) - // Find the section we care about - const sectionStart = rendered.indexOf(``, sectionStart); - if (nextSectionStart !== -1) { - // Get everything after our section header - const afterHeader = rendered.substring(nextSectionStart + ``.length); - // Find where the section ends (at the closing div) - const sectionEnd = afterHeader.lastIndexOf('
'); - if (sectionEnd !== -1) { - rendered = afterHeader.substring(0, sectionEnd); + // Check if content contains nested headers + const hasNestedHeaders = + contentWithPlaceholders.includes("\n===") || + contentWithPlaceholders.includes("\n===="); + + let rendered: string | Document; + if (hasNestedHeaders) { + // For proper nested header parsing, we need full document context + // Create a complete AsciiDoc document structure + // Important: Ensure proper level sequence for nested headers + const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`; + + rendered = asciidoctor.convert(fullDoc, { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, + }); + + // Extract just the content we want (remove the temporary structure) + // Find the section we care about + const sectionStart = rendered + .toString() + .indexOf(``, + sectionStart, + ); + if (nextSectionStart !== -1) { + // Get everything after our section header + const afterHeader = rendered + .toString() + .substring( + nextSectionStart + + ``.length, + ); + // Find where the section ends (at the closing div) + const sectionEnd = + afterHeader.lastIndexOf("
"); + if (sectionEnd !== -1) { + rendered = afterHeader.substring( + 0, + sectionEnd, + ); + } } } + } else { + // Simple content without nested headers + rendered = asciidoctor.convert( + contentWithPlaceholders, + { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, + }, + ); } - } else { - // Simple content without nested headers - rendered = asciidoctor.convert(contentWithPlaceholders, { - standalone: false, - attributes: { - showtitle: false, - sectids: false, - }, + + // Replace placeholders with actual wiki link HTML + // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them) + placeholders.forEach((link, placeholder) => { + const className = + link.type === "auto" + ? "wiki-link wiki-link-auto" + : link.type === "w" + ? "wiki-link wiki-link-ref" + : "wiki-link wiki-link-def"; + + const title = + link.type === "w" + ? "Wiki reference (mentions this concept)" + : link.type === "d" + ? "Wiki definition (defines this concept)" + : "Wiki link (searches both references and definitions)"; + + const html = `${link.displayText}`; + + // Use global replace to handle all occurrences + const regex = new RegExp( + placeholder.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ), + "g", + ); + rendered = rendered + .toString() + .replace(regex, html); }); - } - - // Replace placeholders with actual wiki link HTML - // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them) - placeholders.forEach((link, placeholder) => { - const className = - link.type === 'auto' - ? 'wiki-link wiki-link-auto' - : link.type === 'w' - ? 'wiki-link wiki-link-ref' - : 'wiki-link wiki-link-def'; - - const title = - link.type === 'w' - ? 'Wiki reference (mentions this concept)' - : link.type === 'd' - ? 'Wiki definition (defines this concept)' - : 'Wiki link (searches both references and definitions)'; - - const html = `${link.displayText}`; - - // Use global replace to handle all occurrences - const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); - rendered = rendered.replace(regex, html); - }); - - return rendered; - })()} -
- {/if} -
- {/if} - - {#if index < parsedSections.length - 1} -
-
-
+ return rendered; + })()} +
+ {/if}
-
- - Event Boundary - + {/if} + + + {#if index < parsedSections.length - 1} +
+
+
+
+
+ + Event Boundary + +
-
- {/if} -
- {/each} -
+ {/if} +
+ {/each} +
-
- Event Count: - {#if generatedEvents} - {@const indexEvents = generatedEvents.contentEvents.filter( - (e: any) => e.kind === 30040, - )} - {@const contentOnlyEvents = - generatedEvents.contentEvents.filter( - (e: any) => e.kind === 30041, +
+ Event Count: + {#if generatedEvents} + {@const indexEvents = generatedEvents.contentEvents.filter( + (e: any) => e.kind === 30040, )} - {@const totalIndexEvents = - indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} - {@const totalEvents = - totalIndexEvents + contentOnlyEvents.length} - {totalEvents} event{totalEvents !== 1 ? "s" : ""} - ({totalIndexEvents} index{totalIndexEvents !== 1 - ? " events" - : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== - 1 - ? " events" - : ""}) - {:else} - 0 events - {/if} -
- {/if} + {@const contentOnlyEvents = + generatedEvents.contentEvents.filter( + (e: any) => e.kind === 30041, + )} + {@const totalIndexEvents = + indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} + {@const totalEvents = + totalIndexEvents + contentOnlyEvents.length} + {totalEvents} event{totalEvents !== 1 ? "s" : ""} + ({totalIndexEvents} index{totalIndexEvents !== 1 + ? " events" + : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== + 1 + ? " events" + : ""}) + {:else} + 0 events + {/if} +
+ {/if}
@@ -1497,23 +1555,42 @@ Understanding the nature of knowledge...

  • - [[term]] - - Auto link (queries both w and d tags) + [[term]] + - Auto link (queries both w and d tags)
  • - [[w:term]] - - Reference/mention (backward link) + [[w:term]] + - Reference/mention (backward link)
  • - [[d:term]] - - Definition link (forward link) + [[d:term]] + - Definition link (forward link)
  • - Custom text: [[term|display text]] + Custom text: + [[term|display text]]

- Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically. + Example: "The concept of [[Knowledge Graphs]] enables..." + creates a w-tag automatically.

@@ -1591,7 +1668,7 @@ Understanding the nature of knowledge...
- {#snippet renderEventNode(node, depth = 0)} + {#snippet renderEventNode(node: any, depth = 0)}
{node.eventKind === 30040 ? "📁" : "📄"} [{node.eventKind}] {node.title || "Untitled"} diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte index c6f273e..48b00f6 100644 --- a/src/lib/components/publications/HighlightLayer.svelte +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -1,5 +1,9 @@ {#if loading && visible} -
-

Loading highlights...

+
+

+ Loading highlights... +

{/if} {#if visible && highlights.length > 0} -
+

Highlights

@@ -707,19 +839,28 @@ class="w-3 h-3 rounded flex-shrink-0" style="background-color: {color};" >
- + {displayName} ({authorHighlights.length}) - + @@ -727,14 +868,18 @@ {#if isExpanded}
{#each sortedHighlights as highlight} - {@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} + {@const truncated = useMockHighlights + ? "test data" + : truncateHighlight(highlight.content)} {@const showCopied = copyFeedback === highlight.id}
@@ -744,12 +889,30 @@ title="Copy naddr" > {#if showCopied} - - + + {:else} - - + + {/if} @@ -776,8 +939,9 @@ animation: flash 1.5s ease-in-out; } - @keyframes :global(flash) { - 0%, 100% { + @keyframes -global-flash { + 0%, + 100% { filter: brightness(1); } 50% { diff --git a/src/lib/components/publications/HighlightSelectionHandler.svelte b/src/lib/components/publications/HighlightSelectionHandler.svelte index 1a17986..3e2efd6 100644 --- a/src/lib/components/publications/HighlightSelectionHandler.svelte +++ b/src/lib/components/publications/HighlightSelectionHandler.svelte @@ -73,7 +73,7 @@ tags: tags, content: selectedText, id: "", - sig: "" + sig: "", }; }); @@ -110,7 +110,7 @@ address: sectionAddress, eventId: sectionEventId, allDataAttrs: publicationSection.dataset, - sectionId: publicationSection.id + sectionId: publicationSection.id, }); currentSelection = selection; @@ -151,13 +151,14 @@ event.pubkey = $userStore.pubkey; // Set pubkey from user store // Use the specific section's address/ID if available, otherwise fall back to publication event - const useAddress = selectedSectionAddress || publicationEvent.tagAddress(); + const useAddress = + selectedSectionAddress || publicationEvent.tagAddress(); const useEventId = selectedSectionEventId || publicationEvent.id; console.log("[HighlightSelectionHandler] Creating highlight with:", { address: useAddress, eventId: useEventId, - fallbackUsed: !selectedSectionAddress + fallbackUsed: !selectedSectionAddress, }); const tags: string[][] = []; @@ -202,7 +203,11 @@ content: String(event.content), }; - if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { + if ( + typeof window !== "undefined" && + window.nostr && + window.nostr.signEvent + ) { const signed = await window.nostr.signEvent(plainEvent); event.sig = signed.sig; if ("id" in signed) { @@ -222,7 +227,10 @@ // Remove duplicates const uniqueRelays = Array.from(new Set(relays)); - console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays); + console.log( + "[HighlightSelectionHandler] Publishing to relays:", + uniqueRelays, + ); const signedEvent = { ...plainEvent, @@ -248,11 +256,15 @@ clearTimeout(timeout); if (ok) { publishedCount++; - console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`); + console.log( + `[HighlightSelectionHandler] Published to ${relayUrl}`, + ); WebSocketPool.instance.release(ws); resolve(); } else { - console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`); + console.warn( + `[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`, + ); WebSocketPool.instance.release(ws); reject(new Error(message)); } @@ -263,7 +275,10 @@ ws.send(JSON.stringify(["EVENT", signedEvent])); }); } catch (e) { - console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e); + console.error( + `[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, + e, + ); } } @@ -271,7 +286,10 @@ throw new Error("Failed to publish to any relays"); } - showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success"); + showFeedbackMessage( + `Highlight created and published to ${publishedCount} relay(s)!`, + "success", + ); // Clear the selection if (currentSelection) { @@ -294,7 +312,10 @@ } } catch (error) { console.error("Failed to create highlight:", error); - showFeedbackMessage("Failed to create highlight. Please try again.", "error"); + showFeedbackMessage( + "Failed to create highlight. Please try again.", + "error", + ); } finally { isSubmitting = false; } @@ -349,11 +370,18 @@ {#if showConfirmModal} - +

Selected Text:

-
+

"{selectedText}"

@@ -366,16 +394,21 @@ id="comment" bind:value={comment} placeholder="Share your thoughts about this highlight..." - rows="3" + rows={3} class="w-full" />
{#if showJsonPreview && previewJson} -
+

Event JSON Preview:

-
{JSON.stringify(previewJson, null, 2)}
+
{JSON.stringify(previewJson, null, 2)}
{/if} @@ -383,7 +416,7 @@
- -
@@ -409,7 +450,9 @@ {#if showFeedback}
diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 422abf6..fdfc7b1 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -7,7 +7,8 @@ SidebarGroup, SidebarWrapper, Heading, - CloseButton, uiHelpers + CloseButton, + uiHelpers, } from "flowbite-svelte"; import { getContext, onDestroy, onMount } from "svelte"; import { @@ -37,13 +38,14 @@ import { Textarea, P } from "flowbite-svelte"; import { userStore } from "$lib/stores/userStore"; - let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ - rootAddress: string; - publicationType: string; - indexEvent: NDKEvent; - publicationTree: SveltePublicationTree; - toc: TocType; - }>(); + let { rootAddress, publicationType, indexEvent, publicationTree, toc } = + $props<{ + rootAddress: string; + publicationType: string; + indexEvent: NDKEvent; + publicationTree: SveltePublicationTree; + toc: TocType; + }>(); const ndk = getNdkContext(); @@ -64,23 +66,25 @@ // Toggle between mock and real data for testing (DEBUG MODE) // Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables - let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true"); - let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true"); + let useMockComments = $state( + import.meta.env.VITE_USE_MOCK_COMMENTS === "true", + ); + let useMockHighlights = $state( + import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true", + ); // Log initial state for debugging - console.log('[Publication] Mock data initialized:', { - useMockComments, - useMockHighlights, + console.log("[Publication] Mock data initialized:", { envVars: { VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS, VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS, - } + }, }); // Derive all event IDs and addresses for highlight fetching let allEventIds = $derived.by(() => { const ids = [indexEvent.id]; - leaves.forEach(leaf => { + leaves.forEach((leaf) => { if (leaf?.id) ids.push(leaf.id); }); return ids; @@ -88,7 +92,7 @@ let allEventAddresses = $derived.by(() => { const addresses = [rootAddress]; - leaves.forEach(leaf => { + leaves.forEach((leaf) => { if (leaf) { const addr = leaf.tagAddress(); if (addr) addresses.push(addr); @@ -99,11 +103,11 @@ // Filter comments for the root publication (kind 30040) let articleComments = $derived( - comments.filter(comment => { + comments.filter((comment) => { // Check if comment targets the root publication via #a tag - const aTag = comment.tags.find(t => t[0] === 'a'); + const aTag = comment.tags.find((t) => t[0] === "a"); return aTag && aTag[1] === rootAddress; - }) + }), ); // #region Loading @@ -124,9 +128,11 @@ console.warn("[Publication] publicationTree is not available"); return; } - - console.log(`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); - + + console.log( + `[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, + ); + isLoading = true; try { @@ -159,7 +165,9 @@ console.error("[Publication] Error loading more content:", error); } finally { isLoading = false; - console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); + console.log( + `[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, + ); } } @@ -196,12 +204,12 @@ lastElementRef = null; loadedAddresses = new Set(); hasInitialized = false; - + // Reset the publication tree iterator to prevent duplicate events - if (typeof publicationTree.resetIterator === 'function') { + if (typeof publicationTree.resetIterator === "function") { publicationTree.resetIterator(); } - + // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading // This prevents race conditions where loadMore is called before the iterator is fully reset setTimeout(() => { @@ -298,7 +306,9 @@ const kind = parseInt(kindStr); // Create comment event (kind 1111) - const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk); + const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent( + ndk, + ); commentEvent.kind = 1111; commentEvent.content = articleCommentContent; @@ -330,10 +340,10 @@ articleCommentSuccess = false; handleCommentPosted(); }, 1500); - } catch (err) { console.error("[Publication] Error posting article comment:", err); - articleCommentError = err instanceof Error ? err.message : "Failed to post comment"; + articleCommentError = + err instanceof Error ? err.message : "Failed to post comment"; } finally { isSubmittingArticleComment = false; } @@ -344,30 +354,36 @@ */ async function handleDeletePublication() { const confirmed = confirm( - "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays." + "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays.", ); if (!confirmed) return; try { - await deleteEvent({ - eventAddress: indexEvent.tagAddress(), - eventKind: indexEvent.kind, - reason: "User deleted publication", - onSuccess: (deletionEventId) => { - console.log("[Publication] Deletion event published:", deletionEventId); - publicationDeleted = true; - - // Redirect after 2 seconds - setTimeout(() => { - goto("/publications"); - }, 2000); + await deleteEvent( + { + eventAddress: indexEvent.tagAddress(), + eventKind: indexEvent.kind, + reason: "User deleted publication", + onSuccess: (deletionEventId) => { + console.log( + "[Publication] Deletion event published:", + deletionEventId, + ); + publicationDeleted = true; + + // Redirect after 2 seconds + setTimeout(() => { + goto("/publications"); + }, 2000); + }, + onError: (error) => { + console.error("[Publication] Failed to delete publication:", error); + alert(`Failed to delete publication: ${error}`); + }, }, - onError: (error) => { - console.error("[Publication] Failed to delete publication:", error); - alert(`Failed to delete publication: ${error}`); - }, - }); + ndk, + ); } catch (error) { console.error("[Publication] Error deleting publication:", error); alert(`Error: ${error}`); @@ -422,14 +438,19 @@ observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - if (entry.isIntersecting && !isLoading && !isDone && publicationTree) { + if ( + entry.isIntersecting && + !isLoading && + !isDone && + publicationTree + ) { loadMore(1); } }); }, { threshold: 0.5 }, ); - + // AI-NOTE: Removed duplicate loadMore call // Initial content loading is handled by the $effect that watches publicationTree // This prevents duplicate loading when both onMount and $effect trigger @@ -450,14 +471,11 @@ -
+
- - +
-
+
{#if publicationType !== "blog" && !isLeaf} {#if $publicationColumnVisibility.toc} - + publicationTree.setBookmark(address)} + onSectionFocused={(address: string) => + publicationTree.setBookmark(address)} onLoadMore={() => { - if (!isLoading && !isDone && publicationTree) { - loadMore(4); - } - }} + if (!isLoading && !isDone && publicationTree) { + loadMore(4); + } + }} /> - {/if} {/if} -
{#if $publicationColumnVisibility.main} -
+
@@ -521,7 +549,10 @@
-
+
{#if publicationDeleted} @@ -542,7 +573,9 @@
-