From 0bea3a6ae6beb561ebedf77fa98effecf37169c4 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 4 Nov 2025 17:20:02 -0500 Subject: [PATCH 01/11] packagelock --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6f3024f..0e8f511 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "flowbite-typography": "^1.0.5", "playwright": "^1.50.1", "postcss": "^8.5.6", + "postcss-import": "^16.1.1", "postcss-load-config": "6.x", "prettier": "^3.6.2", "prettier-plugin-svelte": "^3.4.0", @@ -6452,6 +6453,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/plantuml-encoder": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/plantuml-encoder/-/plantuml-encoder-1.4.0.tgz", @@ -6543,6 +6554,24 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-16.1.1.tgz", + "integrity": "sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -6976,6 +7005,16 @@ "node": ">=6" } }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", From 833b82d43d863221fad79f521bdcb897d37982aa Mon Sep 17 00:00:00 2001 From: limina1 Date: Thu, 6 Nov 2025 10:46:31 -0500 Subject: [PATCH 02/11] 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 03/11] 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} +
+ +