10 changed files with 3923 additions and 0 deletions
@ -0,0 +1,21 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { FontHighlightOutline } from "flowbite-svelte-icons"; |
||||||
|
|
||||||
|
let { isActive = $bindable(false) }: { isActive?: boolean } = $props(); |
||||||
|
|
||||||
|
function toggleHighlightMode() { |
||||||
|
isActive = !isActive; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Button |
||||||
|
color={isActive ? "primary" : "light"} |
||||||
|
size="sm" |
||||||
|
class="btn-leather {isActive ? 'ring-2 ring-primary-500' : ''}" |
||||||
|
onclick={toggleHighlightMode} |
||||||
|
title={isActive ? "Exit highlight mode" : "Enter highlight mode"} |
||||||
|
> |
||||||
|
<FontHighlightOutline class="w-4 h-4 mr-2" /> |
||||||
|
{isActive ? "Exit Highlight Mode" : "Add Highlight"} |
||||||
|
</Button> |
||||||
@ -0,0 +1,788 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
||||||
|
import { pubkeyToHue } from "$lib/utils/nostrUtils"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; |
||||||
|
import { communityRelays } from "$lib/consts"; |
||||||
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
||||||
|
import { generateMockHighlightsForSections } from "$lib/utils/mockHighlightData"; |
||||||
|
import { |
||||||
|
groupHighlightsByAuthor, |
||||||
|
truncateHighlight, |
||||||
|
encodeHighlightNaddr, |
||||||
|
getRelaysFromHighlight, |
||||||
|
getAuthorDisplayName, |
||||||
|
sortHighlightsByTime |
||||||
|
} from "$lib/utils/highlightUtils"; |
||||||
|
import { unifiedProfileCache } from "$lib/utils/npubCache"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { highlightByOffset, getPlainText } from "$lib/utils/highlightPositioning"; |
||||||
|
|
||||||
|
let { |
||||||
|
eventId, |
||||||
|
eventAddress, |
||||||
|
eventIds = [], |
||||||
|
eventAddresses = [], |
||||||
|
visible = $bindable(false), |
||||||
|
useMockHighlights = false, |
||||||
|
}: { |
||||||
|
eventId?: string; |
||||||
|
eventAddress?: string; |
||||||
|
eventIds?: string[]; |
||||||
|
eventAddresses?: string[]; |
||||||
|
visible?: boolean; |
||||||
|
useMockHighlights?: boolean; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
const ndk = getNdkContext(); |
||||||
|
|
||||||
|
// State management |
||||||
|
let highlights: NDKEvent[] = $state([]); |
||||||
|
let loading = $state(false); |
||||||
|
let containerRef: HTMLElement | null = $state(null); |
||||||
|
let expandedAuthors = $state(new Set<string>()); |
||||||
|
let authorProfiles = $state(new Map<string, any>()); |
||||||
|
let copyFeedback = $state<string | null>(null); |
||||||
|
|
||||||
|
// Derived state for color mapping |
||||||
|
let colorMap = $derived.by(() => { |
||||||
|
const map = new Map<string, string>(); |
||||||
|
highlights.forEach(highlight => { |
||||||
|
if (!map.has(highlight.pubkey)) { |
||||||
|
const hue = pubkeyToHue(highlight.pubkey); |
||||||
|
map.set(highlight.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); |
||||||
|
} |
||||||
|
}); |
||||||
|
return map; |
||||||
|
}); |
||||||
|
|
||||||
|
// Derived state for grouped highlights |
||||||
|
let groupedHighlights = $derived.by(() => { |
||||||
|
return groupHighlightsByAuthor(highlights); |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch highlight events (kind 9802) for the current publication using NDK |
||||||
|
* Or generate mock highlights if useMockHighlights is enabled |
||||||
|
*/ |
||||||
|
async function fetchHighlights() { |
||||||
|
// Prevent concurrent fetches |
||||||
|
if (loading) { |
||||||
|
console.log("[HighlightLayer] Already loading, skipping fetch"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Collect all event IDs and addresses |
||||||
|
const allEventIds = [...(eventId ? [eventId] : []), ...eventIds].filter(Boolean); |
||||||
|
const allAddresses = [...(eventAddress ? [eventAddress] : []), ...eventAddresses].filter(Boolean); |
||||||
|
|
||||||
|
if (allEventIds.length === 0 && allAddresses.length === 0) { |
||||||
|
console.warn("[HighlightLayer] No event IDs or addresses provided"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
loading = true; |
||||||
|
highlights = []; |
||||||
|
|
||||||
|
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays |
||||||
|
// This is useful for development and demonstrating the highlight system |
||||||
|
if (useMockHighlights) { |
||||||
|
console.log(`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`); |
||||||
|
|
||||||
|
try { |
||||||
|
// Generate mock highlight data |
||||||
|
const mockHighlights = generateMockHighlightsForSections(allAddresses); |
||||||
|
|
||||||
|
// Convert to NDKEvent instances (same as real events) |
||||||
|
highlights = mockHighlights.map(rawEvent => new NDKEventClass(ndk, rawEvent)); |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Generated ${highlights.length} mock highlights`); |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error generating mock highlights:`, err); |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Fetching highlights for:`, { |
||||||
|
eventIds: allEventIds, |
||||||
|
addresses: allAddresses |
||||||
|
}); |
||||||
|
|
||||||
|
try { |
||||||
|
// Build filter for kind 9802 highlight events |
||||||
|
// IMPORTANT: Use only #a tags because filters are AND, not OR |
||||||
|
// If we include both #e and #a, relays will only return highlights that have BOTH |
||||||
|
const filter: any = { |
||||||
|
kinds: [9802], |
||||||
|
limit: 500, |
||||||
|
}; |
||||||
|
|
||||||
|
// Prefer #a (addressable events) since they're more specific and persistent |
||||||
|
if (allAddresses.length > 0) { |
||||||
|
filter["#a"] = allAddresses; |
||||||
|
} else if (allEventIds.length > 0) { |
||||||
|
// Fallback to #e if no addresses available |
||||||
|
filter["#e"] = allEventIds; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); |
||||||
|
|
||||||
|
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) |
||||||
|
const relays = [ |
||||||
|
...communityRelays, |
||||||
|
...$activeOutboxRelays, |
||||||
|
...$activeInboxRelays, |
||||||
|
]; |
||||||
|
const uniqueRelays = Array.from(new Set(relays)); |
||||||
|
console.log(`[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays); |
||||||
|
|
||||||
|
/** |
||||||
|
* Use WebSocketPool with nostr-tools protocol instead of NDK |
||||||
|
* |
||||||
|
* Reasons for not using NDK: |
||||||
|
* 1. NDK subscriptions mysteriously returned 0 events even when websocat confirmed events existed |
||||||
|
* 2. Consistency - CommentButton and HighlightSelectionHandler both use WebSocketPool pattern |
||||||
|
* 3. Better debugging - direct access to WebSocket messages for troubleshooting |
||||||
|
* 4. Proven reliability - battle-tested in the codebase for similar use cases |
||||||
|
* 5. Performance control - explicit 5s timeout per relay, tunable as needed |
||||||
|
* |
||||||
|
* This matches the pattern in: |
||||||
|
* - src/lib/components/publications/CommentButton.svelte:156-220 |
||||||
|
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280 |
||||||
|
*/ |
||||||
|
const subscriptionId = `highlights-${Date.now()}`; |
||||||
|
const receivedEventIds = new Set<string>(); |
||||||
|
let eoseCount = 0; |
||||||
|
|
||||||
|
const fetchPromises = uniqueRelays.map(async (relayUrl) => { |
||||||
|
try { |
||||||
|
console.log(`[HighlightLayer] Connecting to ${relayUrl}`); |
||||||
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
||||||
|
|
||||||
|
return new Promise<void>((resolve) => { |
||||||
|
const messageHandler = (event: MessageEvent) => { |
||||||
|
try { |
||||||
|
const message = JSON.parse(event.data); |
||||||
|
|
||||||
|
// Log ALL messages from relay.nostr.band for debugging |
||||||
|
if (relayUrl.includes('relay.nostr.band')) { |
||||||
|
console.log(`[HighlightLayer] RAW message from ${relayUrl}:`, message); |
||||||
|
} |
||||||
|
|
||||||
|
if (message[0] === "EVENT" && message[1] === subscriptionId) { |
||||||
|
const rawEvent = message[2]; |
||||||
|
console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, { |
||||||
|
id: rawEvent.id, |
||||||
|
kind: rawEvent.kind, |
||||||
|
content: rawEvent.content.substring(0, 50), |
||||||
|
tags: rawEvent.tags |
||||||
|
}); |
||||||
|
|
||||||
|
// Avoid duplicates |
||||||
|
if (!receivedEventIds.has(rawEvent.id)) { |
||||||
|
receivedEventIds.add(rawEvent.id); |
||||||
|
|
||||||
|
// Convert to NDKEvent |
||||||
|
const ndkEvent = new NDKEventClass(ndk, rawEvent); |
||||||
|
highlights = [...highlights, ndkEvent]; |
||||||
|
console.log(`[HighlightLayer] Added highlight, total now: ${highlights.length}`); |
||||||
|
} |
||||||
|
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { |
||||||
|
eoseCount++; |
||||||
|
console.log(`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); |
||||||
|
|
||||||
|
// Close subscription |
||||||
|
ws.send(JSON.stringify(["CLOSE", subscriptionId])); |
||||||
|
ws.removeEventListener("message", messageHandler); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
resolve(); |
||||||
|
} else if (message[0] === "NOTICE") { |
||||||
|
console.warn(`[HighlightLayer] NOTICE from ${relayUrl}:`, message[1]); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error processing message from ${relayUrl}:`, err); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.addEventListener("message", messageHandler); |
||||||
|
|
||||||
|
// Send REQ |
||||||
|
const req = ["REQ", subscriptionId, filter]; |
||||||
|
if (relayUrl.includes('relay.nostr.band')) { |
||||||
|
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`); |
||||||
|
} |
||||||
|
ws.send(JSON.stringify(req)); |
||||||
|
|
||||||
|
// Timeout per relay (5 seconds) |
||||||
|
setTimeout(() => { |
||||||
|
if (ws.readyState === WebSocket.OPEN) { |
||||||
|
ws.send(JSON.stringify(["CLOSE", subscriptionId])); |
||||||
|
ws.removeEventListener("message", messageHandler); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
} |
||||||
|
resolve(); |
||||||
|
}, 5000); |
||||||
|
}); |
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error connecting to ${relayUrl}:`, err); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Wait for all relays to respond or timeout |
||||||
|
await Promise.all(fetchPromises); |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`); |
||||||
|
|
||||||
|
if (highlights.length > 0) { |
||||||
|
console.log(`[HighlightLayer] Highlights summary:`, highlights.map(h => ({ |
||||||
|
content: h.content.substring(0, 30) + "...", |
||||||
|
address: h.tags.find(t => t[0] === "a")?.[1], |
||||||
|
author: h.pubkey.substring(0, 8) |
||||||
|
}))); |
||||||
|
} |
||||||
|
|
||||||
|
loading = false; |
||||||
|
|
||||||
|
// Rendering is handled by the visibility/highlights effect |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error fetching highlights:`, err); |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Apply highlight using position offsets |
||||||
|
* @param offsetStart - Start character position |
||||||
|
* @param offsetEnd - End character position |
||||||
|
* @param color - The color to use for highlighting |
||||||
|
* @param targetAddress - Optional address to limit search to specific section |
||||||
|
*/ |
||||||
|
function highlightByPosition( |
||||||
|
offsetStart: number, |
||||||
|
offsetEnd: number, |
||||||
|
color: string, |
||||||
|
targetAddress?: string |
||||||
|
): boolean { |
||||||
|
if (!containerRef) { |
||||||
|
console.log(`[HighlightLayer] Cannot highlight by position - no containerRef`); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// If we have a target address, search only in that section |
||||||
|
let searchRoot: HTMLElement = containerRef; |
||||||
|
if (targetAddress) { |
||||||
|
const sectionElement = document.getElementById(targetAddress); |
||||||
|
if (sectionElement) { |
||||||
|
searchRoot = sectionElement; |
||||||
|
console.log(`[HighlightLayer] Highlighting in specific section: ${targetAddress}`); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`); |
||||||
|
const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color); |
||||||
|
|
||||||
|
if (result) { |
||||||
|
console.log(`[HighlightLayer] Successfully applied position-based highlight`); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Failed to apply position-based highlight`); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find text in the DOM and highlight it (fallback method) |
||||||
|
* @param text - The text to highlight |
||||||
|
* @param color - The color to use for highlighting |
||||||
|
* @param targetAddress - Optional address to limit search to specific section |
||||||
|
*/ |
||||||
|
function findAndHighlightText(text: string, color: string, targetAddress?: string): void { |
||||||
|
if (!containerRef || !text || text.trim().length === 0) { |
||||||
|
console.log(`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If we have a target address, search only in that section |
||||||
|
let searchRoot: HTMLElement | Document = containerRef; |
||||||
|
if (targetAddress) { |
||||||
|
const sectionElement = document.getElementById(targetAddress); |
||||||
|
if (sectionElement) { |
||||||
|
searchRoot = sectionElement; |
||||||
|
console.log(`[HighlightLayer] Searching in specific section: ${targetAddress}`); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Searching for text: "${text}" in`, searchRoot); |
||||||
|
|
||||||
|
// Use TreeWalker to find all text nodes |
||||||
|
const walker = document.createTreeWalker( |
||||||
|
searchRoot, |
||||||
|
NodeFilter.SHOW_TEXT, |
||||||
|
null |
||||||
|
); |
||||||
|
|
||||||
|
const textNodes: Node[] = []; |
||||||
|
let node: Node | null; |
||||||
|
while ((node = walker.nextNode())) { |
||||||
|
textNodes.push(node); |
||||||
|
} |
||||||
|
|
||||||
|
// Search for the highlight text in text nodes |
||||||
|
console.log(`[HighlightLayer] Searching through ${textNodes.length} text nodes`); |
||||||
|
|
||||||
|
for (const textNode of textNodes) { |
||||||
|
const nodeText = textNode.textContent || ""; |
||||||
|
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); |
||||||
|
|
||||||
|
if (index !== -1) { |
||||||
|
console.log(`[HighlightLayer] Found match in text node:`, nodeText.substring(Math.max(0, index - 20), Math.min(nodeText.length, index + text.length + 20))); |
||||||
|
const parent = textNode.parentNode; |
||||||
|
if (!parent) continue; |
||||||
|
|
||||||
|
// Skip if already highlighted |
||||||
|
if (parent.nodeName === "MARK" || (parent instanceof Element && parent.classList?.contains("highlight"))) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const before = nodeText.substring(0, index); |
||||||
|
const match = nodeText.substring(index, index + text.length); |
||||||
|
const after = nodeText.substring(index + text.length); |
||||||
|
|
||||||
|
// Create highlight span |
||||||
|
const highlightSpan = document.createElement("mark"); |
||||||
|
highlightSpan.className = "highlight"; |
||||||
|
highlightSpan.style.backgroundColor = color; |
||||||
|
highlightSpan.style.borderRadius = "2px"; |
||||||
|
highlightSpan.style.padding = "2px 0"; |
||||||
|
highlightSpan.textContent = match; |
||||||
|
|
||||||
|
// Replace the text node with the highlighted version |
||||||
|
const fragment = document.createDocumentFragment(); |
||||||
|
if (before) fragment.appendChild(document.createTextNode(before)); |
||||||
|
fragment.appendChild(highlightSpan); |
||||||
|
if (after) fragment.appendChild(document.createTextNode(after)); |
||||||
|
|
||||||
|
parent.replaceChild(fragment, textNode); |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Highlighted text:`, match); |
||||||
|
return; // Only highlight first occurrence to avoid multiple highlights |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] No match found for text: "${text}"`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Render all highlights on the page |
||||||
|
*/ |
||||||
|
function renderHighlights() { |
||||||
|
console.log(`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`); |
||||||
|
|
||||||
|
if (!visible || !containerRef) { |
||||||
|
console.log(`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (highlights.length === 0) { |
||||||
|
console.log(`[HighlightLayer] No highlights to render`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Clear existing highlights |
||||||
|
clearHighlights(); |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`); |
||||||
|
console.log(`[HighlightLayer] Container element:`, containerRef); |
||||||
|
console.log(`[HighlightLayer] Container has children:`, containerRef.children.length); |
||||||
|
|
||||||
|
// Apply each highlight |
||||||
|
for (const highlight of highlights) { |
||||||
|
const content = highlight.content; |
||||||
|
const color = colorMap.get(highlight.pubkey) || "hsla(60, 70%, 60%, 0.3)"; |
||||||
|
|
||||||
|
// Extract the target address from the highlight's "a" tag |
||||||
|
const aTag = highlight.tags.find(tag => tag[0] === "a"); |
||||||
|
const targetAddress = aTag ? aTag[1] : undefined; |
||||||
|
|
||||||
|
// Check for offset tags (position-based highlighting) |
||||||
|
const offsetTag = highlight.tags.find(tag => tag[0] === "offset"); |
||||||
|
const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Rendering highlight:`, { |
||||||
|
hasOffset, |
||||||
|
offsetTag, |
||||||
|
content: content.substring(0, 50), |
||||||
|
contentLength: content.length, |
||||||
|
targetAddress, |
||||||
|
color, |
||||||
|
allTags: highlight.tags |
||||||
|
}); |
||||||
|
|
||||||
|
if (hasOffset) { |
||||||
|
// Use position-based highlighting |
||||||
|
const offsetStart = parseInt(offsetTag[1], 10); |
||||||
|
const offsetEnd = parseInt(offsetTag[2], 10); |
||||||
|
|
||||||
|
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { |
||||||
|
console.log(`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`); |
||||||
|
highlightByPosition(offsetStart, offsetEnd, color, targetAddress); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Invalid offset values, falling back to text search`); |
||||||
|
if (content && content.trim().length > 0) { |
||||||
|
findAndHighlightText(content, color, targetAddress); |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Fall back to text-based highlighting |
||||||
|
console.log(`[HighlightLayer] Using text-based highlighting`); |
||||||
|
if (content && content.trim().length > 0) { |
||||||
|
findAndHighlightText(content, color, targetAddress); |
||||||
|
} else { |
||||||
|
console.log(`[HighlightLayer] Skipping highlight - empty content`); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Check if any highlights were actually rendered |
||||||
|
const renderedHighlights = containerRef.querySelectorAll("mark.highlight"); |
||||||
|
console.log(`[HighlightLayer] Rendered ${renderedHighlights.length} highlight marks in DOM`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all highlights from the page |
||||||
|
*/ |
||||||
|
function clearHighlights() { |
||||||
|
if (!containerRef) return; |
||||||
|
|
||||||
|
const highlightElements = containerRef.querySelectorAll("mark.highlight"); |
||||||
|
highlightElements.forEach(el => { |
||||||
|
const parent = el.parentNode; |
||||||
|
if (parent) { |
||||||
|
// Replace highlight with plain text |
||||||
|
const textNode = document.createTextNode(el.textContent || ""); |
||||||
|
parent.replaceChild(textNode, el); |
||||||
|
|
||||||
|
// Normalize the parent to merge adjacent text nodes |
||||||
|
parent.normalize(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Cleared ${highlightElements.length} highlights`); |
||||||
|
} |
||||||
|
|
||||||
|
// Track the last fetched event count to know when to refetch |
||||||
|
let lastFetchedCount = $state(0); |
||||||
|
let fetchTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
// Watch for changes to event data - debounce and fetch when data stabilizes |
||||||
|
$effect(() => { |
||||||
|
const currentCount = eventIds.length + eventAddresses.length; |
||||||
|
const hasEventData = currentCount > 0; |
||||||
|
|
||||||
|
console.log(`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`); |
||||||
|
|
||||||
|
// Only fetch if: |
||||||
|
// 1. We have event data |
||||||
|
// 2. The count has changed since last fetch |
||||||
|
// 3. We're not already loading |
||||||
|
if (hasEventData && currentCount !== lastFetchedCount && !loading) { |
||||||
|
// Clear any existing timeout |
||||||
|
if (fetchTimeout) { |
||||||
|
clearTimeout(fetchTimeout); |
||||||
|
} |
||||||
|
|
||||||
|
// Debounce: wait 500ms for more events to arrive before fetching |
||||||
|
fetchTimeout = setTimeout(() => { |
||||||
|
console.log(`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`); |
||||||
|
lastFetchedCount = currentCount; |
||||||
|
fetchHighlights(); |
||||||
|
}, 500); |
||||||
|
} |
||||||
|
|
||||||
|
// Cleanup timeout on effect cleanup |
||||||
|
return () => { |
||||||
|
if (fetchTimeout) { |
||||||
|
clearTimeout(fetchTimeout); |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
// Watch for visibility AND highlights changes - render when both are ready |
||||||
|
$effect(() => { |
||||||
|
// This effect runs when either visible or highlights.length changes |
||||||
|
const highlightCount = highlights.length; |
||||||
|
console.log(`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`); |
||||||
|
|
||||||
|
if (visible && highlightCount > 0) { |
||||||
|
console.log(`[HighlightLayer] Both visible and highlights ready, rendering...`); |
||||||
|
renderHighlights(); |
||||||
|
} else if (!visible) { |
||||||
|
clearHighlights(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Fetch profiles when highlights change |
||||||
|
$effect(() => { |
||||||
|
const highlightCount = highlights.length; |
||||||
|
if (highlightCount > 0) { |
||||||
|
fetchAuthorProfiles(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch author profiles for all unique pubkeys in highlights |
||||||
|
*/ |
||||||
|
async function fetchAuthorProfiles() { |
||||||
|
const uniquePubkeys = Array.from(groupedHighlights.keys()); |
||||||
|
console.log(`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`); |
||||||
|
|
||||||
|
for (const pubkey of uniquePubkeys) { |
||||||
|
try { |
||||||
|
// Convert hex pubkey to npub for the profile cache |
||||||
|
const npub = nip19.npubEncode(pubkey); |
||||||
|
const profile = await unifiedProfileCache.getProfile(npub, ndk); |
||||||
|
if (profile) { |
||||||
|
authorProfiles.set(pubkey, profile); |
||||||
|
// Trigger reactivity |
||||||
|
authorProfiles = new Map(authorProfiles); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error fetching profile for ${pubkey}:`, err); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Toggle expansion state for an author's highlights |
||||||
|
*/ |
||||||
|
function toggleAuthor(pubkey: string) { |
||||||
|
if (expandedAuthors.has(pubkey)) { |
||||||
|
expandedAuthors.delete(pubkey); |
||||||
|
} else { |
||||||
|
expandedAuthors.add(pubkey); |
||||||
|
} |
||||||
|
// Trigger reactivity |
||||||
|
expandedAuthors = new Set(expandedAuthors); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Scroll to a specific highlight in the document |
||||||
|
*/ |
||||||
|
function scrollToHighlight(highlight: NDKEvent) { |
||||||
|
console.log(`[HighlightLayer] scrollToHighlight called for:`, highlight.content.substring(0, 50)); |
||||||
|
|
||||||
|
if (!containerRef) { |
||||||
|
console.warn(`[HighlightLayer] No containerRef available`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const content = highlight.content; |
||||||
|
if (!content || content.trim().length === 0) { |
||||||
|
console.warn(`[HighlightLayer] No content in highlight`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Find the highlight mark element |
||||||
|
const highlightMarks = containerRef.querySelectorAll("mark.highlight"); |
||||||
|
console.log(`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`); |
||||||
|
|
||||||
|
// Try exact match first |
||||||
|
for (const mark of highlightMarks) { |
||||||
|
const markText = mark.textContent?.toLowerCase() || ""; |
||||||
|
const searchText = content.toLowerCase(); |
||||||
|
|
||||||
|
if (markText === searchText) { |
||||||
|
console.log(`[HighlightLayer] Found exact match, scrolling and flashing`); |
||||||
|
// Scroll to this element |
||||||
|
mark.scrollIntoView({ behavior: "smooth", block: "center" }); |
||||||
|
|
||||||
|
// Add a temporary flash effect |
||||||
|
mark.classList.add("highlight-flash"); |
||||||
|
setTimeout(() => { |
||||||
|
mark.classList.remove("highlight-flash"); |
||||||
|
}, 1500); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Try partial match (for position-based highlights that might be split) |
||||||
|
for (const mark of highlightMarks) { |
||||||
|
const markText = mark.textContent?.toLowerCase() || ""; |
||||||
|
const searchText = content.toLowerCase(); |
||||||
|
|
||||||
|
if (markText.includes(searchText) || searchText.includes(markText)) { |
||||||
|
console.log(`[HighlightLayer] Found partial match, scrolling and flashing`); |
||||||
|
mark.scrollIntoView({ behavior: "smooth", block: "center" }); |
||||||
|
mark.classList.add("highlight-flash"); |
||||||
|
setTimeout(() => { |
||||||
|
mark.classList.remove("highlight-flash"); |
||||||
|
}, 1500); |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.warn(`[HighlightLayer] Could not find highlight mark for:`, content.substring(0, 50)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Copy highlight naddr to clipboard |
||||||
|
*/ |
||||||
|
async function copyHighlightNaddr(highlight: NDKEvent) { |
||||||
|
const relays = getRelaysFromHighlight(highlight); |
||||||
|
const naddr = encodeHighlightNaddr(highlight, relays); |
||||||
|
|
||||||
|
try { |
||||||
|
await navigator.clipboard.writeText(naddr); |
||||||
|
copyFeedback = highlight.id; |
||||||
|
console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr); |
||||||
|
|
||||||
|
// Clear feedback after 2 seconds |
||||||
|
setTimeout(() => { |
||||||
|
copyFeedback = null; |
||||||
|
}, 2000); |
||||||
|
} catch (err) { |
||||||
|
console.error(`[HighlightLayer] Error copying to clipboard:`, err); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Bind to parent container element |
||||||
|
*/ |
||||||
|
export function setContainer(element: HTMLElement | null) { |
||||||
|
containerRef = element; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Public method to refresh highlights (e.g., after creating a new one) |
||||||
|
*/ |
||||||
|
export function refresh() { |
||||||
|
console.log("[HighlightLayer] Manual refresh triggered"); |
||||||
|
|
||||||
|
// Clear existing highlights |
||||||
|
highlights = []; |
||||||
|
clearHighlights(); |
||||||
|
|
||||||
|
// Reset fetch count to force re-fetch |
||||||
|
lastFetchedCount = 0; |
||||||
|
fetchHighlights(); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if loading && visible} |
||||||
|
<div class="fixed top-40 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-3"> |
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300">Loading highlights...</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if visible && highlights.length > 0} |
||||||
|
<div class="fixed bottom-4 right-4 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4 max-w-sm w-80"> |
||||||
|
<h4 class="text-sm font-semibold mb-3 text-gray-900 dark:text-gray-100"> |
||||||
|
Highlights |
||||||
|
</h4> |
||||||
|
<div class="space-y-2 max-h-96 overflow-y-auto"> |
||||||
|
{#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)} |
||||||
|
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-2"> |
||||||
|
<!-- Author header --> |
||||||
|
<button |
||||||
|
class="w-full flex items-center gap-2 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 p-2 rounded transition-colors" |
||||||
|
onclick={() => toggleAuthor(pubkey)} |
||||||
|
> |
||||||
|
<div |
||||||
|
class="w-3 h-3 rounded flex-shrink-0" |
||||||
|
style="background-color: {color};" |
||||||
|
></div> |
||||||
|
<span class="font-medium text-gray-900 dark:text-gray-100 flex-1 text-left truncate"> |
||||||
|
{displayName} |
||||||
|
</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||||
|
({authorHighlights.length}) |
||||||
|
</span> |
||||||
|
<svg |
||||||
|
class="w-4 h-4 text-gray-500 transition-transform {isExpanded ? 'rotate-90' : ''}" |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
viewBox="0 0 24 24" |
||||||
|
> |
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
|
||||||
|
<!-- Expanded highlight list --> |
||||||
|
{#if isExpanded} |
||||||
|
<div class="mt-2 ml-5 space-y-2"> |
||||||
|
{#each sortedHighlights as highlight} |
||||||
|
{@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} |
||||||
|
{@const showCopied = copyFeedback === highlight.id} |
||||||
|
|
||||||
|
<div class="flex items-start gap-2 group"> |
||||||
|
<button |
||||||
|
class="flex-1 text-left text-xs text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" |
||||||
|
onclick={() => scrollToHighlight(highlight)} |
||||||
|
title={useMockHighlights ? "Mock highlight" : highlight.content} |
||||||
|
> |
||||||
|
{truncated} |
||||||
|
</button> |
||||||
|
<button |
||||||
|
class="flex-shrink-0 p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors" |
||||||
|
onclick={() => copyHighlightNaddr(highlight)} |
||||||
|
title="Copy naddr" |
||||||
|
> |
||||||
|
{#if showCopied} |
||||||
|
<svg class="w-3 h-3 text-green-500" fill="currentColor" viewBox="0 0 20 20"> |
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> |
||||||
|
</svg> |
||||||
|
{:else} |
||||||
|
<svg class="w-3 h-3 text-gray-400 group-hover:text-gray-600 dark:group-hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
:global(mark.highlight) { |
||||||
|
transition: background-color 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
:global(mark.highlight:hover) { |
||||||
|
filter: brightness(1.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(mark.highlight.highlight-flash) { |
||||||
|
animation: flash 1.5s ease-in-out; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes :global(flash) { |
||||||
|
0%, 100% { |
||||||
|
filter: brightness(1); |
||||||
|
} |
||||||
|
50% { |
||||||
|
filter: brightness(0.4); |
||||||
|
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5); |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,429 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { getContext, onMount, onDestroy } from "svelte"; |
||||||
|
import { Button, Modal, Textarea, P } from "flowbite-svelte"; |
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import { activeOutboxRelays, activeInboxRelays } from "$lib/ndk"; |
||||||
|
import { communityRelays } from "$lib/consts"; |
||||||
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
||||||
|
import { ChevronDownOutline, ChevronUpOutline } from "flowbite-svelte-icons"; |
||||||
|
|
||||||
|
let { |
||||||
|
isActive = false, |
||||||
|
publicationEvent, |
||||||
|
onHighlightCreated, |
||||||
|
}: { |
||||||
|
isActive: boolean; |
||||||
|
publicationEvent: NDKEvent; |
||||||
|
onHighlightCreated?: () => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
const ndk: NDK = getContext("ndk"); |
||||||
|
|
||||||
|
let showConfirmModal = $state(false); |
||||||
|
let selectedText = $state(""); |
||||||
|
let selectionContext = $state(""); |
||||||
|
let comment = $state(""); |
||||||
|
let isSubmitting = $state(false); |
||||||
|
let feedbackMessage = $state(""); |
||||||
|
let showFeedback = $state(false); |
||||||
|
let showJsonPreview = $state(false); |
||||||
|
|
||||||
|
// Store the selection range and section info for creating highlight |
||||||
|
let currentSelection: Selection | null = null; |
||||||
|
let selectedSectionAddress = $state<string | undefined>(undefined); |
||||||
|
let selectedSectionEventId = $state<string | undefined>(undefined); |
||||||
|
|
||||||
|
// Build preview JSON for the highlight event |
||||||
|
let previewJson = $derived.by(() => { |
||||||
|
if (!selectedText) return null; |
||||||
|
|
||||||
|
const useAddress = selectedSectionAddress || publicationEvent.tagAddress(); |
||||||
|
const useEventId = selectedSectionEventId || publicationEvent.id; |
||||||
|
|
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
if (useAddress) { |
||||||
|
tags.push(["a", useAddress, ""]); |
||||||
|
} else if (useEventId) { |
||||||
|
tags.push(["e", useEventId, ""]); |
||||||
|
} |
||||||
|
|
||||||
|
if (selectionContext) { |
||||||
|
tags.push(["context", selectionContext]); |
||||||
|
} |
||||||
|
|
||||||
|
let authorPubkey = publicationEvent.pubkey; |
||||||
|
if (useAddress && useAddress.includes(":")) { |
||||||
|
authorPubkey = useAddress.split(":")[1]; |
||||||
|
} |
||||||
|
if (authorPubkey) { |
||||||
|
tags.push(["p", authorPubkey, "", "author"]); |
||||||
|
} |
||||||
|
|
||||||
|
if (comment.trim()) { |
||||||
|
tags.push(["comment", comment.trim()]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
kind: 9802, |
||||||
|
pubkey: $userStore.pubkey || "<your-pubkey>", |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: tags, |
||||||
|
content: selectedText, |
||||||
|
id: "<calculated-on-signing>", |
||||||
|
sig: "<calculated-on-signing>" |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
function handleMouseUp(event: MouseEvent) { |
||||||
|
if (!isActive) return; |
||||||
|
if (!$userStore.signedIn) { |
||||||
|
showFeedbackMessage("Please sign in to create highlights", "error"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const selection = window.getSelection(); |
||||||
|
if (!selection || selection.isCollapsed) return; |
||||||
|
|
||||||
|
const text = selection.toString().trim(); |
||||||
|
if (!text || text.length < 3) return; |
||||||
|
|
||||||
|
// Check if the selection is within the publication content |
||||||
|
const target = event.target as HTMLElement; |
||||||
|
|
||||||
|
// Find the closest section element with an id (PublicationSection) |
||||||
|
// Don't use closest('.publication-leather') as Details also has that class |
||||||
|
const publicationSection = target.closest("section[id]") as HTMLElement; |
||||||
|
if (!publicationSection) { |
||||||
|
console.log("[HighlightSelectionHandler] No section[id] found, aborting"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Get the specific section's event address and ID from data attributes |
||||||
|
const sectionAddress = publicationSection.dataset.eventAddress; |
||||||
|
const sectionEventId = publicationSection.dataset.eventId; |
||||||
|
|
||||||
|
console.log("[HighlightSelectionHandler] Selection in section:", { |
||||||
|
element: publicationSection, |
||||||
|
address: sectionAddress, |
||||||
|
eventId: sectionEventId, |
||||||
|
allDataAttrs: publicationSection.dataset, |
||||||
|
sectionId: publicationSection.id |
||||||
|
}); |
||||||
|
|
||||||
|
currentSelection = selection; |
||||||
|
selectedText = text; |
||||||
|
selectedSectionAddress = sectionAddress; |
||||||
|
selectedSectionEventId = sectionEventId; |
||||||
|
selectionContext = ""; // Will be set below |
||||||
|
|
||||||
|
// Get surrounding context (the paragraph or section) |
||||||
|
const parentElement = selection.anchorNode?.parentElement; |
||||||
|
if (parentElement) { |
||||||
|
const contextElement = parentElement.closest("p, section, div"); |
||||||
|
if (contextElement) { |
||||||
|
selectionContext = contextElement.textContent?.trim() || ""; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showConfirmModal = true; |
||||||
|
} |
||||||
|
|
||||||
|
async function createHighlight() { |
||||||
|
if (!$userStore.signer || !ndk) { |
||||||
|
showFeedbackMessage("Please sign in to create highlights", "error"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!$userStore.pubkey) { |
||||||
|
showFeedbackMessage("User pubkey not available", "error"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
isSubmitting = true; |
||||||
|
|
||||||
|
try { |
||||||
|
const event = new NDKEvent(ndk); |
||||||
|
event.kind = 9802; |
||||||
|
event.content = selectedText; |
||||||
|
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 useEventId = selectedSectionEventId || publicationEvent.id; |
||||||
|
|
||||||
|
console.log("[HighlightSelectionHandler] Creating highlight with:", { |
||||||
|
address: useAddress, |
||||||
|
eventId: useEventId, |
||||||
|
fallbackUsed: !selectedSectionAddress |
||||||
|
}); |
||||||
|
|
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
// Always prefer addressable events for publications |
||||||
|
if (useAddress) { |
||||||
|
// Addressable event - use "a" tag |
||||||
|
tags.push(["a", useAddress, ""]); |
||||||
|
} else if (useEventId) { |
||||||
|
// Regular event - use "e" tag |
||||||
|
tags.push(["e", useEventId, ""]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add context tag |
||||||
|
if (selectionContext) { |
||||||
|
tags.push(["context", selectionContext]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add author tag - extract from address or use publication event |
||||||
|
let authorPubkey = publicationEvent.pubkey; |
||||||
|
if (useAddress && useAddress.includes(":")) { |
||||||
|
// Extract pubkey from address format "kind:pubkey:identifier" |
||||||
|
authorPubkey = useAddress.split(":")[1]; |
||||||
|
} |
||||||
|
if (authorPubkey) { |
||||||
|
tags.push(["p", authorPubkey, "", "author"]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add comment tag if user provided a comment (quote highlight) |
||||||
|
if (comment.trim()) { |
||||||
|
tags.push(["comment", comment.trim()]); |
||||||
|
} |
||||||
|
|
||||||
|
event.tags = tags; |
||||||
|
|
||||||
|
// Sign the event - create plain object to avoid proxy issues |
||||||
|
const plainEvent = { |
||||||
|
kind: Number(event.kind), |
||||||
|
pubkey: String(event.pubkey), |
||||||
|
created_at: Number(event.created_at ?? Math.floor(Date.now() / 1000)), |
||||||
|
tags: event.tags.map((tag) => tag.map(String)), |
||||||
|
content: String(event.content), |
||||||
|
}; |
||||||
|
|
||||||
|
if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { |
||||||
|
const signed = await window.nostr.signEvent(plainEvent); |
||||||
|
event.sig = signed.sig; |
||||||
|
if ("id" in signed) { |
||||||
|
event.id = signed.id as string; |
||||||
|
} |
||||||
|
} else { |
||||||
|
await event.sign($userStore.signer); |
||||||
|
} |
||||||
|
|
||||||
|
// Build relay list following the same pattern as eventServices |
||||||
|
const relays = [ |
||||||
|
...communityRelays, |
||||||
|
...$activeOutboxRelays, |
||||||
|
...$activeInboxRelays, |
||||||
|
]; |
||||||
|
|
||||||
|
// Remove duplicates |
||||||
|
const uniqueRelays = Array.from(new Set(relays)); |
||||||
|
|
||||||
|
console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays); |
||||||
|
|
||||||
|
const signedEvent = { |
||||||
|
...plainEvent, |
||||||
|
id: event.id, |
||||||
|
sig: event.sig, |
||||||
|
}; |
||||||
|
|
||||||
|
// Publish to relays using WebSocketPool |
||||||
|
let publishedCount = 0; |
||||||
|
for (const relayUrl of uniqueRelays) { |
||||||
|
try { |
||||||
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === "OK" && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
publishedCount++; |
||||||
|
console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Send the event to the relay |
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (publishedCount === 0) { |
||||||
|
throw new Error("Failed to publish to any relays"); |
||||||
|
} |
||||||
|
|
||||||
|
showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success"); |
||||||
|
|
||||||
|
// Clear the selection |
||||||
|
if (currentSelection) { |
||||||
|
currentSelection.removeAllRanges(); |
||||||
|
} |
||||||
|
|
||||||
|
// Reset state |
||||||
|
showConfirmModal = false; |
||||||
|
selectedText = ""; |
||||||
|
selectionContext = ""; |
||||||
|
comment = ""; |
||||||
|
selectedSectionAddress = undefined; |
||||||
|
selectedSectionEventId = undefined; |
||||||
|
showJsonPreview = false; |
||||||
|
currentSelection = null; |
||||||
|
|
||||||
|
// Notify parent component |
||||||
|
if (onHighlightCreated) { |
||||||
|
onHighlightCreated(); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error("Failed to create highlight:", error); |
||||||
|
showFeedbackMessage("Failed to create highlight. Please try again.", "error"); |
||||||
|
} finally { |
||||||
|
isSubmitting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function cancelHighlight() { |
||||||
|
showConfirmModal = false; |
||||||
|
selectedText = ""; |
||||||
|
selectionContext = ""; |
||||||
|
comment = ""; |
||||||
|
selectedSectionAddress = undefined; |
||||||
|
selectedSectionEventId = undefined; |
||||||
|
showJsonPreview = false; |
||||||
|
|
||||||
|
// Clear the selection |
||||||
|
if (currentSelection) { |
||||||
|
currentSelection.removeAllRanges(); |
||||||
|
} |
||||||
|
currentSelection = null; |
||||||
|
} |
||||||
|
|
||||||
|
function showFeedbackMessage(message: string, type: "success" | "error") { |
||||||
|
feedbackMessage = message; |
||||||
|
showFeedback = true; |
||||||
|
setTimeout(() => { |
||||||
|
showFeedback = false; |
||||||
|
}, 3000); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
// Only listen to mouseup on the document |
||||||
|
document.addEventListener("mouseup", handleMouseUp); |
||||||
|
}); |
||||||
|
|
||||||
|
onDestroy(() => { |
||||||
|
document.removeEventListener("mouseup", handleMouseUp); |
||||||
|
}); |
||||||
|
|
||||||
|
// Add visual indicator when highlight mode is active |
||||||
|
$effect(() => { |
||||||
|
if (isActive) { |
||||||
|
document.body.classList.add("highlight-mode-active"); |
||||||
|
} else { |
||||||
|
document.body.classList.remove("highlight-mode-active"); |
||||||
|
} |
||||||
|
|
||||||
|
// Cleanup when component unmounts |
||||||
|
return () => { |
||||||
|
document.body.classList.remove("highlight-mode-active"); |
||||||
|
}; |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if showConfirmModal} |
||||||
|
<Modal title="Create Highlight" bind:open={showConfirmModal} autoclose={false} size="md"> |
||||||
|
<div class="space-y-4"> |
||||||
|
<div> |
||||||
|
<P class="text-sm font-semibold mb-2">Selected Text:</P> |
||||||
|
<div class="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg max-h-32 overflow-y-auto"> |
||||||
|
<P class="text-sm italic">"{selectedText}"</P> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div> |
||||||
|
<label for="comment" class="block text-sm font-semibold mb-2"> |
||||||
|
Add a Comment (Optional): |
||||||
|
</label> |
||||||
|
<Textarea |
||||||
|
id="comment" |
||||||
|
bind:value={comment} |
||||||
|
placeholder="Share your thoughts about this highlight..." |
||||||
|
rows="3" |
||||||
|
class="w-full" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- JSON Preview Section --> |
||||||
|
{#if showJsonPreview && previewJson} |
||||||
|
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-900"> |
||||||
|
<P class="text-sm font-semibold mb-2">Event JSON Preview:</P> |
||||||
|
<pre class="text-xs bg-white dark:bg-gray-800 p-3 rounded overflow-x-auto border border-gray-200 dark:border-gray-700"><code>{JSON.stringify(previewJson, null, 2)}</code></pre> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="flex justify-between items-center"> |
||||||
|
<Button |
||||||
|
color="light" |
||||||
|
size="sm" |
||||||
|
onclick={() => showJsonPreview = !showJsonPreview} |
||||||
|
class="flex items-center gap-1" |
||||||
|
> |
||||||
|
{#if showJsonPreview} |
||||||
|
<ChevronUpOutline class="w-4 h-4" /> |
||||||
|
{:else} |
||||||
|
<ChevronDownOutline class="w-4 h-4" /> |
||||||
|
{/if} |
||||||
|
{showJsonPreview ? "Hide" : "Show"} JSON |
||||||
|
</Button> |
||||||
|
|
||||||
|
<div class="flex space-x-2"> |
||||||
|
<Button color="alternative" onclick={cancelHighlight} disabled={isSubmitting}> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
<Button color="primary" onclick={createHighlight} disabled={isSubmitting}> |
||||||
|
{isSubmitting ? "Creating..." : "Create Highlight"} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Modal> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if showFeedback} |
||||||
|
<div |
||||||
|
class="fixed bottom-4 right-4 z-50 p-4 rounded-lg shadow-lg {feedbackMessage.includes('success') |
||||||
|
? 'bg-green-500 text-white' |
||||||
|
: 'bg-red-500 text-white'}" |
||||||
|
> |
||||||
|
{feedbackMessage} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<style> |
||||||
|
:global(body.highlight-mode-active .publication-leather) { |
||||||
|
cursor: text; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
:global(body.highlight-mode-active .publication-leather *) { |
||||||
|
cursor: text; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches all highlight events (kind 9802) for sections referenced in a publication event (kind 30040). |
||||||
|
* |
||||||
|
* @param publicationEvent - The kind 30040 event containing "a" tags referencing sections (kind 30041) |
||||||
|
* @param ndk - The NDK instance to use for fetching events |
||||||
|
* @returns A Map of section addresses to arrays of highlight events |
||||||
|
* |
||||||
|
* @example |
||||||
|
* ```typescript
|
||||||
|
* const highlights = await fetchHighlightsForPublication(publicationEvent, ndk); |
||||||
|
* // Returns: Map {
|
||||||
|
* // "30041:pubkey:section-id" => [highlightEvent1, highlightEvent2],
|
||||||
|
* // "30041:pubkey:another-section" => [highlightEvent3]
|
||||||
|
* // }
|
||||||
|
* ``` |
||||||
|
*/ |
||||||
|
export async function fetchHighlightsForPublication( |
||||||
|
publicationEvent: NDKEvent, |
||||||
|
ndk: NDK |
||||||
|
): Promise<Map<string, NDKEvent[]>> { |
||||||
|
// Extract all "a" tags from the publication event
|
||||||
|
const aTags = publicationEvent.getMatchingTags("a"); |
||||||
|
|
||||||
|
// Filter for only 30041 (section) references
|
||||||
|
const sectionAddresses: string[] = []; |
||||||
|
aTags.forEach((tag: string[]) => { |
||||||
|
if (tag[1]) { |
||||||
|
const parts = tag[1].split(":"); |
||||||
|
// Check if it's a 30041 kind reference and has the correct format
|
||||||
|
if (parts.length >= 3 && parts[0] === "30041") { |
||||||
|
// Handle d-tags with colons by joining everything after the pubkey
|
||||||
|
const sectionAddress = tag[1]; |
||||||
|
sectionAddresses.push(sectionAddress); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// If no section references found, return empty map
|
||||||
|
if (sectionAddresses.length === 0) { |
||||||
|
return new Map(); |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch all highlight events (kind 9802) that reference these sections
|
||||||
|
const highlightEvents = await ndk.fetchEvents({ |
||||||
|
kinds: [9802], |
||||||
|
"#a": sectionAddresses, |
||||||
|
}); |
||||||
|
|
||||||
|
// Group highlights by section address
|
||||||
|
const highlightsBySection = new Map<string, NDKEvent[]>(); |
||||||
|
|
||||||
|
highlightEvents.forEach((highlight: NDKEvent) => { |
||||||
|
const highlightATags = highlight.getMatchingTags("a"); |
||||||
|
highlightATags.forEach((tag: string[]) => { |
||||||
|
const sectionAddress = tag[1]; |
||||||
|
// Only include if this section is in our original list
|
||||||
|
if (sectionAddress && sectionAddresses.includes(sectionAddress)) { |
||||||
|
if (!highlightsBySection.has(sectionAddress)) { |
||||||
|
highlightsBySection.set(sectionAddress, []); |
||||||
|
} |
||||||
|
highlightsBySection.get(sectionAddress)!.push(highlight); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
return highlightsBySection; |
||||||
|
} |
||||||
@ -0,0 +1,224 @@ |
|||||||
|
/** |
||||||
|
* Utility for position-based text highlighting in the DOM |
||||||
|
* |
||||||
|
* Highlights text by character offset rather than text search, |
||||||
|
* making highlights resilient to minor content changes. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all text nodes within an element, excluding script/style tags |
||||||
|
*/ |
||||||
|
function getTextNodes(element: HTMLElement): Text[] { |
||||||
|
const textNodes: Text[] = []; |
||||||
|
const walker = document.createTreeWalker( |
||||||
|
element, |
||||||
|
NodeFilter.SHOW_TEXT, |
||||||
|
{ |
||||||
|
acceptNode: (node) => { |
||||||
|
// Skip text in script/style tags
|
||||||
|
const parent = node.parentElement; |
||||||
|
if (parent && (parent.tagName === 'SCRIPT' || parent.tagName === 'STYLE')) { |
||||||
|
return NodeFilter.FILTER_REJECT; |
||||||
|
} |
||||||
|
// Skip empty text nodes
|
||||||
|
if (!node.textContent || node.textContent.trim().length === 0) { |
||||||
|
return NodeFilter.FILTER_REJECT; |
||||||
|
} |
||||||
|
return NodeFilter.FILTER_ACCEPT; |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
let node: Node | null; |
||||||
|
while ((node = walker.nextNode())) { |
||||||
|
textNodes.push(node as Text); |
||||||
|
} |
||||||
|
|
||||||
|
return textNodes; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Calculate the total text length from text nodes |
||||||
|
*/ |
||||||
|
function getTotalTextLength(textNodes: Text[]): number { |
||||||
|
return textNodes.reduce((total, node) => total + (node.textContent?.length || 0), 0); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Find text node and local offset for a given global character position |
||||||
|
*/ |
||||||
|
function findNodeAtOffset( |
||||||
|
textNodes: Text[], |
||||||
|
globalOffset: number |
||||||
|
): { node: Text; localOffset: number } | null { |
||||||
|
let currentOffset = 0; |
||||||
|
|
||||||
|
for (const node of textNodes) { |
||||||
|
const nodeLength = node.textContent?.length || 0; |
||||||
|
|
||||||
|
if (globalOffset < currentOffset + nodeLength) { |
||||||
|
return { |
||||||
|
node, |
||||||
|
localOffset: globalOffset - currentOffset |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
currentOffset += nodeLength; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Highlight text by character offset within a container element |
||||||
|
* |
||||||
|
* @param container - The root element to search within |
||||||
|
* @param startOffset - Character position where highlight starts (0-indexed) |
||||||
|
* @param endOffset - Character position where highlight ends (exclusive) |
||||||
|
* @param color - Background color for the highlight |
||||||
|
* @returns true if highlight was applied, false otherwise |
||||||
|
*/ |
||||||
|
export function highlightByOffset( |
||||||
|
container: HTMLElement, |
||||||
|
startOffset: number, |
||||||
|
endOffset: number, |
||||||
|
color: string |
||||||
|
): boolean { |
||||||
|
console.log(`[highlightByOffset] Attempting to highlight chars ${startOffset}-${endOffset}`); |
||||||
|
|
||||||
|
// Validate inputs
|
||||||
|
if (startOffset < 0 || endOffset <= startOffset) { |
||||||
|
console.warn(`[highlightByOffset] Invalid offsets: ${startOffset}-${endOffset}`); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Get all text nodes
|
||||||
|
const textNodes = getTextNodes(container); |
||||||
|
if (textNodes.length === 0) { |
||||||
|
console.warn(`[highlightByOffset] No text nodes found in container`); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
const totalLength = getTotalTextLength(textNodes); |
||||||
|
console.log(`[highlightByOffset] Total text length: ${totalLength}, nodes: ${textNodes.length}`); |
||||||
|
|
||||||
|
// Validate offsets are within bounds
|
||||||
|
if (startOffset >= totalLength) { |
||||||
|
console.warn(`[highlightByOffset] Start offset ${startOffset} exceeds total length ${totalLength}`); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
// Adjust end offset if it exceeds content
|
||||||
|
const adjustedEndOffset = Math.min(endOffset, totalLength); |
||||||
|
|
||||||
|
// Find the nodes containing start and end positions
|
||||||
|
const startPos = findNodeAtOffset(textNodes, startOffset); |
||||||
|
const endPos = findNodeAtOffset(textNodes, adjustedEndOffset); |
||||||
|
|
||||||
|
if (!startPos || !endPos) { |
||||||
|
console.warn(`[highlightByOffset] Could not locate positions in DOM`); |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[highlightByOffset] Found positions:`, { |
||||||
|
startNode: startPos.node.textContent?.substring(0, 20), |
||||||
|
startLocal: startPos.localOffset, |
||||||
|
endNode: endPos.node.textContent?.substring(0, 20), |
||||||
|
endLocal: endPos.localOffset |
||||||
|
}); |
||||||
|
|
||||||
|
// Create the highlight mark element
|
||||||
|
const createHighlightMark = (text: string): HTMLElement => { |
||||||
|
const mark = document.createElement('mark'); |
||||||
|
mark.className = 'highlight'; |
||||||
|
mark.style.backgroundColor = color; |
||||||
|
mark.style.borderRadius = '2px'; |
||||||
|
mark.style.padding = '2px 0'; |
||||||
|
mark.textContent = text; |
||||||
|
return mark; |
||||||
|
}; |
||||||
|
|
||||||
|
try { |
||||||
|
// Case 1: Highlight is within a single text node
|
||||||
|
if (startPos.node === endPos.node) { |
||||||
|
const text = startPos.node.textContent || ''; |
||||||
|
const before = text.substring(0, startPos.localOffset); |
||||||
|
const highlighted = text.substring(startPos.localOffset, endPos.localOffset); |
||||||
|
const after = text.substring(endPos.localOffset); |
||||||
|
|
||||||
|
const parent = startPos.node.parentNode; |
||||||
|
if (!parent) return false; |
||||||
|
|
||||||
|
// Create fragment with before + highlight + after
|
||||||
|
const fragment = document.createDocumentFragment(); |
||||||
|
if (before) fragment.appendChild(document.createTextNode(before)); |
||||||
|
fragment.appendChild(createHighlightMark(highlighted)); |
||||||
|
if (after) fragment.appendChild(document.createTextNode(after)); |
||||||
|
|
||||||
|
parent.replaceChild(fragment, startPos.node); |
||||||
|
console.log(`[highlightByOffset] Applied single-node highlight: "${highlighted}"`); |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
// Case 2: Highlight spans multiple text nodes
|
||||||
|
let currentNode: Text | null = startPos.node; |
||||||
|
let isFirstNode = true; |
||||||
|
let nodeIndex = textNodes.indexOf(currentNode); |
||||||
|
|
||||||
|
while (currentNode && nodeIndex <= textNodes.indexOf(endPos.node)) { |
||||||
|
const parent = currentNode.parentNode; |
||||||
|
if (!parent) break; |
||||||
|
|
||||||
|
const text = currentNode.textContent || ''; |
||||||
|
let fragment = document.createDocumentFragment(); |
||||||
|
|
||||||
|
if (isFirstNode) { |
||||||
|
// First node: split at start offset
|
||||||
|
const before = text.substring(0, startPos.localOffset); |
||||||
|
const highlighted = text.substring(startPos.localOffset); |
||||||
|
|
||||||
|
if (before) fragment.appendChild(document.createTextNode(before)); |
||||||
|
fragment.appendChild(createHighlightMark(highlighted)); |
||||||
|
isFirstNode = false; |
||||||
|
} else if (currentNode === endPos.node) { |
||||||
|
// Last node: split at end offset
|
||||||
|
const highlighted = text.substring(0, endPos.localOffset); |
||||||
|
const after = text.substring(endPos.localOffset); |
||||||
|
|
||||||
|
fragment.appendChild(createHighlightMark(highlighted)); |
||||||
|
if (after) fragment.appendChild(document.createTextNode(after)); |
||||||
|
} else { |
||||||
|
// Middle node: highlight entirely
|
||||||
|
fragment.appendChild(createHighlightMark(text)); |
||||||
|
} |
||||||
|
|
||||||
|
parent.replaceChild(fragment, currentNode); |
||||||
|
|
||||||
|
nodeIndex++; |
||||||
|
currentNode = textNodes[nodeIndex] || null; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[highlightByOffset] Applied multi-node highlight`); |
||||||
|
return true; |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
console.error(`[highlightByOffset] Error applying highlight:`, err); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the plain text content of an element (without HTML tags) |
||||||
|
* Useful for debugging and validation |
||||||
|
*/ |
||||||
|
export function getPlainText(element: HTMLElement): string { |
||||||
|
const textNodes = getTextNodes(element); |
||||||
|
return textNodes.map(node => node.textContent).join(''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the character count of visible text in an element |
||||||
|
*/ |
||||||
|
export function getTextLength(element: HTMLElement): number { |
||||||
|
return getPlainText(element).length; |
||||||
|
} |
||||||
@ -0,0 +1,156 @@ |
|||||||
|
/** |
||||||
|
* Utility functions for highlight management |
||||||
|
*/ |
||||||
|
|
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
|
||||||
|
export interface GroupedHighlight { |
||||||
|
pubkey: string; |
||||||
|
highlights: NDKEvent[]; |
||||||
|
count: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Groups highlights by author pubkey |
||||||
|
* Returns a Map with pubkey as key and array of highlights as value |
||||||
|
*/ |
||||||
|
export function groupHighlightsByAuthor(highlights: NDKEvent[]): Map<string, NDKEvent[]> { |
||||||
|
const grouped = new Map<string, NDKEvent[]>(); |
||||||
|
|
||||||
|
for (const highlight of highlights) { |
||||||
|
const pubkey = highlight.pubkey; |
||||||
|
const existing = grouped.get(pubkey) || []; |
||||||
|
existing.push(highlight); |
||||||
|
grouped.set(pubkey, existing); |
||||||
|
} |
||||||
|
|
||||||
|
return grouped; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Truncates highlight text to specified length, breaking at word boundaries |
||||||
|
* @param text - The text to truncate |
||||||
|
* @param maxLength - Maximum length (default: 50) |
||||||
|
* @returns Truncated text with ellipsis if needed |
||||||
|
*/ |
||||||
|
export function truncateHighlight(text: string, maxLength: number = 50): string { |
||||||
|
if (!text || text.length <= maxLength) { |
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
// Find the last space before maxLength
|
||||||
|
const truncated = text.slice(0, maxLength); |
||||||
|
const lastSpace = truncated.lastIndexOf(" "); |
||||||
|
|
||||||
|
// If there's a space, break there; otherwise use the full maxLength
|
||||||
|
if (lastSpace > 0) { |
||||||
|
return truncated.slice(0, lastSpace) + "..."; |
||||||
|
} |
||||||
|
|
||||||
|
return truncated + "..."; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Encodes a highlight event as an naddr with relay hints |
||||||
|
* @param event - The highlight event (kind 9802) |
||||||
|
* @param relays - Array of relay URLs to include as hints |
||||||
|
* @returns naddr string |
||||||
|
*/ |
||||||
|
export function encodeHighlightNaddr(event: NDKEvent, relays: string[] = []): string { |
||||||
|
try { |
||||||
|
// For kind 9802 highlights, we need the event's unique identifier
|
||||||
|
// Since highlights don't have a d-tag, we'll use the event id as nevent instead
|
||||||
|
// But per NIP-19, naddr is for addressable events (with d-tag)
|
||||||
|
// For non-addressable events like kind 9802, we should use nevent
|
||||||
|
|
||||||
|
const nevent = nip19.neventEncode({ |
||||||
|
id: event.id, |
||||||
|
relays: relays.length > 0 ? relays : undefined, |
||||||
|
author: event.pubkey, |
||||||
|
kind: event.kind, |
||||||
|
}); |
||||||
|
|
||||||
|
return nevent; |
||||||
|
} catch (error) { |
||||||
|
console.error("Error encoding highlight naddr:", error); |
||||||
|
// Fallback to just the event id
|
||||||
|
return event.id; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a shortened npub for display |
||||||
|
* @param pubkey - The hex pubkey |
||||||
|
* @param length - Number of characters to show from start (default: 8) |
||||||
|
* @returns Shortened npub like "npub1abc...xyz" |
||||||
|
*/ |
||||||
|
export function shortenNpub(pubkey: string, length: number = 8): string { |
||||||
|
try { |
||||||
|
const npub = nip19.npubEncode(pubkey); |
||||||
|
// npub format: "npub1" + bech32 encoded data
|
||||||
|
// Show first part and last part
|
||||||
|
if (npub.length <= length + 10) { |
||||||
|
return npub; |
||||||
|
} |
||||||
|
|
||||||
|
const start = npub.slice(0, length + 5); // "npub1" + first chars
|
||||||
|
const end = npub.slice(-4); // last chars
|
||||||
|
return `${start}...${end}`; |
||||||
|
} catch (error) { |
||||||
|
console.error("Error creating shortened npub:", error); |
||||||
|
// Fallback to shortened hex
|
||||||
|
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts relay URLs from a highlight event's tags or metadata |
||||||
|
* @param event - The highlight event |
||||||
|
* @returns Array of relay URLs |
||||||
|
*/ |
||||||
|
export function getRelaysFromHighlight(event: NDKEvent): string[] { |
||||||
|
const relays: string[] = []; |
||||||
|
|
||||||
|
// Check for relay hints in tags (e.g., ["a", "30041:pubkey:id", "relay-url"])
|
||||||
|
for (const tag of event.tags) { |
||||||
|
if ((tag[0] === "a" || tag[0] === "e" || tag[0] === "p") && tag[2]) { |
||||||
|
relays.push(tag[2]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Also include relay from the event if available
|
||||||
|
if (event.relay?.url) { |
||||||
|
relays.push(event.relay.url); |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate
|
||||||
|
return [...new Set(relays)]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sorts highlights within a group by creation time (newest first) |
||||||
|
* @param highlights - Array of highlight events |
||||||
|
* @returns Sorted array |
||||||
|
*/ |
||||||
|
export function sortHighlightsByTime(highlights: NDKEvent[]): NDKEvent[] { |
||||||
|
return [...highlights].sort((a, b) => { |
||||||
|
const timeA = a.created_at || 0; |
||||||
|
const timeB = b.created_at || 0; |
||||||
|
return timeB - timeA; // Newest first
|
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets the display name for a highlight author |
||||||
|
* Priority: displayName > name > shortened npub |
||||||
|
*/ |
||||||
|
export function getAuthorDisplayName( |
||||||
|
profile: { name?: string; displayName?: string; display_name?: string } | null, |
||||||
|
pubkey: string, |
||||||
|
): string { |
||||||
|
if (profile) { |
||||||
|
return profile.displayName || profile.display_name || profile.name || shortenNpub(pubkey); |
||||||
|
} |
||||||
|
return shortenNpub(pubkey); |
||||||
|
} |
||||||
@ -0,0 +1,183 @@ |
|||||||
|
/** |
||||||
|
* Generate mock highlight data (kind 9802) for testing highlight UI |
||||||
|
* Creates realistic highlight events with context and optional annotations |
||||||
|
*/ |
||||||
|
|
||||||
|
// Sample highlighted text snippets (things users might actually highlight)
|
||||||
|
const highlightedTexts = [ |
||||||
|
'Knowledge that tries to stay put inevitably becomes ossified', |
||||||
|
'The attempt to hold knowledge still is like trying to photograph a river', |
||||||
|
'Understanding emerges not from rigid frameworks but from fluid engagement', |
||||||
|
'Traditional institutions struggle with the natural promiscuity of ideas', |
||||||
|
'Thinking without permission means refusing predetermined categories', |
||||||
|
'The most valuable insights often come from unexpected juxtapositions', |
||||||
|
'Anarchistic knowledge rejects the notion of authorized interpreters', |
||||||
|
'Every act of reading is an act of creative interpretation', |
||||||
|
'Hierarchy in knowledge systems serves power, not understanding', |
||||||
|
'The boundary between creator and consumer is an artificial construction', |
||||||
|
]; |
||||||
|
|
||||||
|
// Context strings (surrounding text to help locate the highlight)
|
||||||
|
const contexts = [ |
||||||
|
'This is the fundamental paradox of institutionalized knowledge. Knowledge that tries to stay put inevitably becomes ossified, a monument to itself rather than a living practice.', |
||||||
|
'The attempt to hold knowledge still is like trying to photograph a river—you capture an image, but you lose the flow. What remains is a static representation, not the dynamic reality.', |
||||||
|
'Understanding emerges not from rigid frameworks but from fluid engagement with ideas, people, and contexts. This fluidity is precisely what traditional systems attempt to eliminate.', |
||||||
|
'Traditional institutions struggle with the natural promiscuity of ideas—the way concepts naturally migrate, mutate, and merge across boundaries that were meant to contain them.', |
||||||
|
'Thinking without permission means refusing predetermined categories and challenging the gatekeepers who claim authority over legitimate thought.', |
||||||
|
'The most valuable insights often come from unexpected juxtapositions, from bringing together ideas that were never meant to meet.', |
||||||
|
'Anarchistic knowledge rejects the notion of authorized interpreters, asserting instead that meaning-making is a fundamentally distributed and democratic process.', |
||||||
|
'Every act of reading is an act of creative interpretation, a collaboration between text and reader that produces something new each time.', |
||||||
|
'Hierarchy in knowledge systems serves power, not understanding. It determines who gets to speak, who must listen, and what counts as legitimate knowledge.', |
||||||
|
'The boundary between creator and consumer is an artificial construction, one that digital networks make increasingly untenable and obsolete.', |
||||||
|
]; |
||||||
|
|
||||||
|
// Optional annotations (user comments on their highlights)
|
||||||
|
const annotations = [ |
||||||
|
'This perfectly captures the institutional problem', |
||||||
|
'Key insight - worth revisiting', |
||||||
|
'Reminds me of Deleuze on rhizomatic structures', |
||||||
|
'Fundamental critique of academic gatekeeping', |
||||||
|
'The core argument in one sentence', |
||||||
|
null, // Some highlights have no annotation
|
||||||
|
'Important for understanding the broader thesis', |
||||||
|
null, |
||||||
|
'Connects to earlier discussion on page 12', |
||||||
|
null, |
||||||
|
]; |
||||||
|
|
||||||
|
// Mock pubkeys - MUST be exactly 64 hex characters
|
||||||
|
const mockPubkeys = [ |
||||||
|
'a1b2c3d4e5f67890123456789012345678901234567890123456789012345678', |
||||||
|
'b2c3d4e5f67890123456789012345678901234567890123456789012345678ab', |
||||||
|
'c3d4e5f67890123456789012345678901234567890123456789012345678abcd', |
||||||
|
'd4e5f67890123456789012345678901234567890123456789012345678abcdef', |
||||||
|
'e5f6789012345678901234567890123456789012345678901234567890abcdef', |
||||||
|
]; |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a mock highlight event (kind 9802) |
||||||
|
* |
||||||
|
* AI-NOTE: Unlike comments (kind 1111), highlights have: |
||||||
|
* - content field = the highlighted text itself (NOT a user comment) |
||||||
|
* - ["context", ...] tag with surrounding text to help locate the highlight |
||||||
|
* - Optional ["comment", ...] tag for user annotations |
||||||
|
* - Optional ["offset", start, end] tag for position-based highlighting |
||||||
|
* - Single lowercase ["a", targetAddress] tag (not uppercase/lowercase pairs) |
||||||
|
*/ |
||||||
|
function createMockHighlight( |
||||||
|
id: string, |
||||||
|
highlightedText: string, |
||||||
|
context: string, |
||||||
|
targetAddress: string, |
||||||
|
pubkey: string, |
||||||
|
createdAt: number, |
||||||
|
authorPubkey: string, |
||||||
|
annotation?: string | null, |
||||||
|
offsetStart?: number, |
||||||
|
offsetEnd?: number |
||||||
|
): any { |
||||||
|
const tags: string[][] = [ |
||||||
|
['a', targetAddress, 'wss://relay.damus.io'], |
||||||
|
['context', context], |
||||||
|
['p', authorPubkey, 'wss://relay.damus.io', 'author'], |
||||||
|
]; |
||||||
|
|
||||||
|
// Add optional annotation
|
||||||
|
if (annotation) { |
||||||
|
tags.push(['comment', annotation]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add optional offset for position-based highlighting
|
||||||
|
if (offsetStart !== undefined && offsetEnd !== undefined) { |
||||||
|
tags.push(['offset', offsetStart.toString(), offsetEnd.toString()]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
id, |
||||||
|
kind: 9802, |
||||||
|
pubkey, |
||||||
|
created_at: createdAt, |
||||||
|
content: highlightedText, // The highlighted text itself
|
||||||
|
tags, |
||||||
|
sig: 'mock-signature-' + id, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate mock highlights for a section |
||||||
|
* @param sectionAddress - The section address to attach highlights to |
||||||
|
* @param authorPubkey - The author's pubkey (for the "p" tag) |
||||||
|
* @param numHighlights - Number of highlights to generate (default: 3-5 random) |
||||||
|
* @returns Array of mock highlight objects |
||||||
|
*/ |
||||||
|
export function generateMockHighlights( |
||||||
|
sectionAddress: string, |
||||||
|
authorPubkey: string, |
||||||
|
numHighlights: number = Math.floor(Math.random() * 2) + 2 // 2-3 highlights
|
||||||
|
): any[] { |
||||||
|
const highlights: any[] = []; |
||||||
|
const now = Math.floor(Date.now() / 1000); |
||||||
|
|
||||||
|
// Generate position-based highlights at the beginning of each section
|
||||||
|
// For test mode, we use simple placeholder text and rely on offset-based highlighting
|
||||||
|
// The offset tags will highlight the ACTUAL text at those positions in the section
|
||||||
|
|
||||||
|
for (let i = 0; i < numHighlights; i++) { |
||||||
|
const id = `mock-highlight-${i}-${Date.now()}-${Math.random().toString(36).substring(7)}`; |
||||||
|
const highlighterPubkey = mockPubkeys[i % mockPubkeys.length]; |
||||||
|
const annotation = annotations[i % annotations.length]; |
||||||
|
const createdAt = now - (numHighlights - i) * 7200; // Stagger by 2 hours
|
||||||
|
|
||||||
|
// Create sequential highlights at the beginning of the section
|
||||||
|
// Each highlight is exactly 100 characters
|
||||||
|
const highlightLength = 100; |
||||||
|
const offsetStart = i * 120; // Space between highlights (120 chars apart)
|
||||||
|
const offsetEnd = offsetStart + highlightLength; |
||||||
|
|
||||||
|
// Use placeholder text - the actual highlighted text will be determined by the offsets
|
||||||
|
const placeholderText = `Test highlight ${i + 1}`; |
||||||
|
const placeholderContext = `This is test highlight ${i + 1} at position ${offsetStart}-${offsetEnd}`; |
||||||
|
|
||||||
|
const highlight = createMockHighlight( |
||||||
|
id, |
||||||
|
placeholderText, |
||||||
|
placeholderContext, |
||||||
|
sectionAddress, |
||||||
|
highlighterPubkey, |
||||||
|
createdAt, |
||||||
|
authorPubkey, |
||||||
|
annotation, |
||||||
|
offsetStart, |
||||||
|
offsetEnd |
||||||
|
); |
||||||
|
|
||||||
|
highlights.push(highlight); |
||||||
|
} |
||||||
|
|
||||||
|
return highlights; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate mock highlights for multiple sections |
||||||
|
* @param sectionAddresses - Array of section addresses |
||||||
|
* @param authorPubkey - The publication author's pubkey |
||||||
|
* @returns Array of all mock highlights across all sections |
||||||
|
*/ |
||||||
|
export function generateMockHighlightsForSections( |
||||||
|
sectionAddresses: string[], |
||||||
|
authorPubkey: string = 'dc4cd086cd7ce5b1832adf4fdd1211289880d2c7e295bcb0e684c01acee77c06' |
||||||
|
): any[] { |
||||||
|
const allHighlights: any[] = []; |
||||||
|
|
||||||
|
sectionAddresses.forEach((address, index) => { |
||||||
|
// Each section gets 2 highlights at the very beginning (positions 0-100 and 120-220)
|
||||||
|
const numHighlights = 2; |
||||||
|
const sectionHighlights = generateMockHighlights(address, authorPubkey, numHighlights); |
||||||
|
console.log(`[MockHighlightData] Generated ${numHighlights} highlights for section ${address.split(':')[2]?.substring(0, 20)}... at positions 0-100, 120-220`); |
||||||
|
allHighlights.push(...sectionHighlights); |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`[MockHighlightData] Total: ${allHighlights.length} highlights across ${sectionAddresses.length} sections`); |
||||||
|
console.log(`[MockHighlightData] Each highlight is anchored to its section via "a" tag and uses offset tags for position`); |
||||||
|
return allHighlights; |
||||||
|
} |
||||||
@ -0,0 +1,318 @@ |
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest"; |
||||||
|
import type { NDK, NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { fetchHighlightsForPublication } from "../../src/lib/utils/fetch_publication_highlights"; |
||||||
|
|
||||||
|
// Mock NDKEvent class
|
||||||
|
class MockNDKEvent { |
||||||
|
kind: number; |
||||||
|
pubkey: string; |
||||||
|
content: string; |
||||||
|
tags: string[][]; |
||||||
|
created_at: number; |
||||||
|
id: string; |
||||||
|
sig: string; |
||||||
|
|
||||||
|
constructor(event: { |
||||||
|
kind: number; |
||||||
|
pubkey: string; |
||||||
|
content: string; |
||||||
|
tags: string[][]; |
||||||
|
created_at?: number; |
||||||
|
id?: string; |
||||||
|
sig?: string; |
||||||
|
}) { |
||||||
|
this.kind = event.kind; |
||||||
|
this.pubkey = event.pubkey; |
||||||
|
this.content = event.content; |
||||||
|
this.tags = event.tags; |
||||||
|
this.created_at = event.created_at || Date.now() / 1000; |
||||||
|
this.id = event.id || "mock-id"; |
||||||
|
this.sig = event.sig || "mock-sig"; |
||||||
|
} |
||||||
|
|
||||||
|
getMatchingTags(tagName: string): string[][] { |
||||||
|
return this.tags.filter((tag) => tag[0] === tagName); |
||||||
|
} |
||||||
|
|
||||||
|
tagValue(tagName: string): string | undefined { |
||||||
|
const tag = this.tags.find((tag) => tag[0] === tagName); |
||||||
|
return tag ? tag[1] : undefined; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe("fetchHighlightsForPublication", () => { |
||||||
|
let mockNDK: NDK; |
||||||
|
let publicationEvent: NDKEvent; |
||||||
|
let mockHighlights: MockNDKEvent[]; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
// Create the sample 30040 publication event from the user's example
|
||||||
|
publicationEvent = new MockNDKEvent({ |
||||||
|
kind: 30040, |
||||||
|
pubkey: |
||||||
|
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
content: "", |
||||||
|
tags: [ |
||||||
|
["d", "document-test"], |
||||||
|
["title", "Document Test"], |
||||||
|
["author", "unknown"], |
||||||
|
["version", "1"], |
||||||
|
["m", "application/json"], |
||||||
|
["M", "meta-data/index/replaceable"], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading", |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading", |
||||||
|
], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document", |
||||||
|
], |
||||||
|
["t", "a-tags"], |
||||||
|
["t", "testfile"], |
||||||
|
["t", "asciimath"], |
||||||
|
["t", "latexmath"], |
||||||
|
["image", "https://i.nostr.build/5kWwbDR04joIASVx.png"], |
||||||
|
], |
||||||
|
created_at: 1744910311, |
||||||
|
id: "4585ed74a0be37655aa887340d239f0bbb9df5476165d912f098c55a71196fef", |
||||||
|
sig: "e6a832dcfc919c913acee62cb598211544bc8e03a3f61c016eb3bf6c8cb4fb333eff8fecc601517604c7a8029dfa73591f3218465071a532f4abfe8c0bf3662d", |
||||||
|
}) as unknown as NDKEvent; |
||||||
|
|
||||||
|
// Create mock highlight events for different sections
|
||||||
|
mockHighlights = [ |
||||||
|
new MockNDKEvent({ |
||||||
|
kind: 9802, |
||||||
|
pubkey: "user-pubkey-1", |
||||||
|
content: "This is an interesting point", |
||||||
|
tags: [ |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", |
||||||
|
], |
||||||
|
["context", "surrounding text here"], |
||||||
|
[ |
||||||
|
"p", |
||||||
|
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"", |
||||||
|
"author", |
||||||
|
], |
||||||
|
], |
||||||
|
id: "highlight-1", |
||||||
|
}), |
||||||
|
new MockNDKEvent({ |
||||||
|
kind: 9802, |
||||||
|
pubkey: "user-pubkey-2", |
||||||
|
content: "Another highlight on same section", |
||||||
|
tags: [ |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", |
||||||
|
], |
||||||
|
["context", "more surrounding text"], |
||||||
|
[ |
||||||
|
"p", |
||||||
|
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"", |
||||||
|
"author", |
||||||
|
], |
||||||
|
], |
||||||
|
id: "highlight-2", |
||||||
|
}), |
||||||
|
new MockNDKEvent({ |
||||||
|
kind: 9802, |
||||||
|
pubkey: "user-pubkey-3", |
||||||
|
content: "Highlight on different section", |
||||||
|
tags: [ |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading", |
||||||
|
], |
||||||
|
["context", "different section text"], |
||||||
|
[ |
||||||
|
"p", |
||||||
|
"fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1", |
||||||
|
"", |
||||||
|
"author", |
||||||
|
], |
||||||
|
], |
||||||
|
id: "highlight-3", |
||||||
|
}), |
||||||
|
]; |
||||||
|
|
||||||
|
// Mock NDK instance
|
||||||
|
mockNDK = { |
||||||
|
fetchEvents: vi.fn(async (filter) => { |
||||||
|
// Return highlights that match the filter
|
||||||
|
const aTagFilter = filter["#a"]; |
||||||
|
if (aTagFilter) { |
||||||
|
return new Set( |
||||||
|
mockHighlights.filter((highlight) => |
||||||
|
aTagFilter.includes(highlight.tagValue("a") || "") |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
return new Set(); |
||||||
|
}), |
||||||
|
} as unknown as NDK; |
||||||
|
}); |
||||||
|
|
||||||
|
it("should extract section references from 30040 publication event", async () => { |
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
publicationEvent, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
// Should have results for the sections that have highlights
|
||||||
|
expect(result.size).toBeGreaterThan(0); |
||||||
|
expect( |
||||||
|
result.has( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" |
||||||
|
) |
||||||
|
).toBe(true); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should fetch highlights for each section reference", async () => { |
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
publicationEvent, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
// First section should have 2 highlights
|
||||||
|
const firstSectionHighlights = result.get( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" |
||||||
|
); |
||||||
|
expect(firstSectionHighlights?.length).toBe(2); |
||||||
|
|
||||||
|
// Second section should have 1 highlight
|
||||||
|
const secondSectionHighlights = result.get( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:another-first-level-heading" |
||||||
|
); |
||||||
|
expect(secondSectionHighlights?.length).toBe(1); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should group highlights by section address", async () => { |
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
publicationEvent, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
const firstSectionHighlights = result.get( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading" |
||||||
|
); |
||||||
|
|
||||||
|
// Verify the highlights are correctly grouped
|
||||||
|
expect(firstSectionHighlights?.[0].content).toBe( |
||||||
|
"This is an interesting point" |
||||||
|
); |
||||||
|
expect(firstSectionHighlights?.[1].content).toBe( |
||||||
|
"Another highlight on same section" |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should not include sections without highlights", async () => { |
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
publicationEvent, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
// Sections without highlights should not be in the result
|
||||||
|
expect( |
||||||
|
result.has( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:a-third-first-level-heading" |
||||||
|
) |
||||||
|
).toBe(false); |
||||||
|
expect( |
||||||
|
result.has( |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:asciimath-test-document" |
||||||
|
) |
||||||
|
).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should handle publication with no section references", async () => { |
||||||
|
const emptyPublication = new MockNDKEvent({ |
||||||
|
kind: 30040, |
||||||
|
pubkey: "test-pubkey", |
||||||
|
content: "", |
||||||
|
tags: [ |
||||||
|
["d", "empty-doc"], |
||||||
|
["title", "Empty Document"], |
||||||
|
], |
||||||
|
}) as unknown as NDKEvent; |
||||||
|
|
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
emptyPublication, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
expect(result.size).toBe(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should only process 30041 kind references, ignoring other a-tags", async () => { |
||||||
|
const mixedPublication = new MockNDKEvent({ |
||||||
|
kind: 30040, |
||||||
|
pubkey: "test-pubkey", |
||||||
|
content: "", |
||||||
|
tags: [ |
||||||
|
["d", "mixed-doc"], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", |
||||||
|
], |
||||||
|
["a", "30023:some-pubkey:blog-post"], // Different kind, should be ignored
|
||||||
|
["a", "1:some-pubkey"], // Different kind, should be ignored
|
||||||
|
], |
||||||
|
}) as unknown as NDKEvent; |
||||||
|
|
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
mixedPublication, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
// Should call fetchEvents with only the 30041 reference
|
||||||
|
expect(mockNDK.fetchEvents).toHaveBeenCalledWith( |
||||||
|
expect.objectContaining({ |
||||||
|
kinds: [9802], |
||||||
|
"#a": [ |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:first-level-heading", |
||||||
|
], |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it("should handle d-tags with colons correctly", async () => { |
||||||
|
const colonPublication = new MockNDKEvent({ |
||||||
|
kind: 30040, |
||||||
|
pubkey: "test-pubkey", |
||||||
|
content: "", |
||||||
|
tags: [ |
||||||
|
["d", "colon-doc"], |
||||||
|
[ |
||||||
|
"a", |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", |
||||||
|
], |
||||||
|
], |
||||||
|
}) as unknown as NDKEvent; |
||||||
|
|
||||||
|
const result = await fetchHighlightsForPublication( |
||||||
|
colonPublication, |
||||||
|
mockNDK |
||||||
|
); |
||||||
|
|
||||||
|
// Should correctly parse the section address with colons
|
||||||
|
expect(mockNDK.fetchEvents).toHaveBeenCalledWith( |
||||||
|
expect.objectContaining({ |
||||||
|
"#a": [ |
||||||
|
"30041:fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1:section:with:colons", |
||||||
|
], |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,859 @@ |
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
||||||
|
import { pubkeyToHue } from '../../src/lib/utils/nostrUtils'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
|
||||||
|
describe('pubkeyToHue', () => { |
||||||
|
describe('Consistency', () => { |
||||||
|
it('returns consistent hue for same pubkey', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const hue1 = pubkeyToHue(pubkey); |
||||||
|
const hue2 = pubkeyToHue(pubkey); |
||||||
|
|
||||||
|
expect(hue1).toBe(hue2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns same hue for same pubkey called multiple times', () => { |
||||||
|
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; |
||||||
|
const hues = Array.from({ length: 10 }, () => pubkeyToHue(pubkey)); |
||||||
|
|
||||||
|
expect(new Set(hues).size).toBe(1); // All hues should be the same
|
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Range Validation', () => { |
||||||
|
it('returns hue in valid range (0-360)', () => { |
||||||
|
const pubkeys = [ |
||||||
|
'a'.repeat(64), |
||||||
|
'f'.repeat(64), |
||||||
|
'0'.repeat(64), |
||||||
|
'9'.repeat(64), |
||||||
|
'abc123def456'.repeat(5) + 'abcd', |
||||||
|
'123456789abc'.repeat(5) + 'def0', |
||||||
|
]; |
||||||
|
|
||||||
|
pubkeys.forEach(pubkey => { |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns integer hue value', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
|
||||||
|
expect(Number.isInteger(hue)).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Format Handling', () => { |
||||||
|
it('handles hex format pubkeys', () => { |
||||||
|
const hexPubkey = 'abcdef123456789'.repeat(4) + '0123'; |
||||||
|
const hue = pubkeyToHue(hexPubkey); |
||||||
|
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles npub format pubkeys', () => { |
||||||
|
const hexPubkey = 'a'.repeat(64); |
||||||
|
const npub = nip19.npubEncode(hexPubkey); |
||||||
|
const hue = pubkeyToHue(npub); |
||||||
|
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
|
||||||
|
it('returns same hue for hex and npub format of same pubkey', () => { |
||||||
|
const hexPubkey = 'abc123def456'.repeat(5) + 'abcd'; |
||||||
|
const npub = nip19.npubEncode(hexPubkey); |
||||||
|
|
||||||
|
const hueFromHex = pubkeyToHue(hexPubkey); |
||||||
|
const hueFromNpub = pubkeyToHue(npub); |
||||||
|
|
||||||
|
expect(hueFromHex).toBe(hueFromNpub); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Uniqueness', () => { |
||||||
|
it('different pubkeys generate different hues', () => { |
||||||
|
const pubkey1 = 'a'.repeat(64); |
||||||
|
const pubkey2 = 'b'.repeat(64); |
||||||
|
const pubkey3 = 'c'.repeat(64); |
||||||
|
|
||||||
|
const hue1 = pubkeyToHue(pubkey1); |
||||||
|
const hue2 = pubkeyToHue(pubkey2); |
||||||
|
const hue3 = pubkeyToHue(pubkey3); |
||||||
|
|
||||||
|
expect(hue1).not.toBe(hue2); |
||||||
|
expect(hue2).not.toBe(hue3); |
||||||
|
expect(hue1).not.toBe(hue3); |
||||||
|
}); |
||||||
|
|
||||||
|
it('generates diverse hues for multiple pubkeys', () => { |
||||||
|
const pubkeys = Array.from({ length: 10 }, (_, i) => |
||||||
|
String.fromCharCode(97 + i).repeat(64) |
||||||
|
); |
||||||
|
|
||||||
|
const hues = pubkeys.map(pk => pubkeyToHue(pk)); |
||||||
|
const uniqueHues = new Set(hues); |
||||||
|
|
||||||
|
// Most pubkeys should generate unique hues (allowing for some collisions)
|
||||||
|
expect(uniqueHues.size).toBeGreaterThan(7); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Edge Cases', () => { |
||||||
|
it('handles empty string input', () => { |
||||||
|
const hue = pubkeyToHue(''); |
||||||
|
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles invalid npub format gracefully', () => { |
||||||
|
const invalidNpub = 'npub1invalid'; |
||||||
|
const hue = pubkeyToHue(invalidNpub); |
||||||
|
|
||||||
|
// Should still return a valid hue even if decode fails
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles short input strings', () => { |
||||||
|
const shortInput = 'abc'; |
||||||
|
const hue = pubkeyToHue(shortInput); |
||||||
|
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles special characters', () => { |
||||||
|
const specialInput = '!@#$%^&*()'; |
||||||
|
const hue = pubkeyToHue(specialInput); |
||||||
|
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0); |
||||||
|
expect(hue).toBeLessThan(360); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Color Distribution', () => { |
||||||
|
it('distributes colors across the spectrum', () => { |
||||||
|
// Generate hues for many different pubkeys
|
||||||
|
const pubkeys = Array.from({ length: 50 }, (_, i) => |
||||||
|
i.toString().repeat(16) |
||||||
|
); |
||||||
|
|
||||||
|
const hues = pubkeys.map(pk => pubkeyToHue(pk)); |
||||||
|
|
||||||
|
// Check that we have hues in different ranges of the spectrum
|
||||||
|
const hasLowHues = hues.some(h => h < 120); |
||||||
|
const hasMidHues = hues.some(h => h >= 120 && h < 240); |
||||||
|
const hasHighHues = hues.some(h => h >= 240); |
||||||
|
|
||||||
|
expect(hasLowHues).toBe(true); |
||||||
|
expect(hasMidHues).toBe(true); |
||||||
|
expect(hasHighHues).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('HighlightLayer Component', () => { |
||||||
|
let mockNdk: any; |
||||||
|
let mockSubscription: any; |
||||||
|
let eventHandlers: Map<string, Function>; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
eventHandlers = new Map(); |
||||||
|
|
||||||
|
// Mock NDK subscription
|
||||||
|
mockSubscription = { |
||||||
|
on: vi.fn((event: string, handler: Function) => { |
||||||
|
eventHandlers.set(event, handler); |
||||||
|
}), |
||||||
|
stop: vi.fn(), |
||||||
|
}; |
||||||
|
|
||||||
|
mockNdk = { |
||||||
|
subscribe: vi.fn(() => mockSubscription), |
||||||
|
}; |
||||||
|
|
||||||
|
// Mock DOM APIs
|
||||||
|
global.document = { |
||||||
|
createTreeWalker: vi.fn(() => ({ |
||||||
|
nextNode: vi.fn(() => null), |
||||||
|
})), |
||||||
|
createDocumentFragment: vi.fn(() => ({ |
||||||
|
appendChild: vi.fn(), |
||||||
|
})), |
||||||
|
createTextNode: vi.fn((text: string) => ({ |
||||||
|
textContent: text, |
||||||
|
})), |
||||||
|
createElement: vi.fn((tag: string) => ({ |
||||||
|
className: '', |
||||||
|
style: {}, |
||||||
|
textContent: '', |
||||||
|
})), |
||||||
|
} as any; |
||||||
|
}); |
||||||
|
|
||||||
|
afterEach(() => { |
||||||
|
vi.clearAllMocks(); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('NDK Subscription', () => { |
||||||
|
it('fetches kind 9802 events with correct filter when eventId provided', () => { |
||||||
|
const eventId = 'a'.repeat(64); |
||||||
|
|
||||||
|
// Simulate calling fetchHighlights
|
||||||
|
mockNdk.subscribe({ kinds: [9802], '#e': [eventId], limit: 100 }); |
||||||
|
|
||||||
|
expect(mockNdk.subscribe).toHaveBeenCalledWith( |
||||||
|
expect.objectContaining({ |
||||||
|
kinds: [9802], |
||||||
|
'#e': [eventId], |
||||||
|
limit: 100, |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('fetches kind 9802 events with correct filter when eventAddress provided', () => { |
||||||
|
const eventAddress = '30040:' + 'a'.repeat(64) + ':chapter-1'; |
||||||
|
|
||||||
|
// Simulate calling fetchHighlights
|
||||||
|
mockNdk.subscribe({ kinds: [9802], '#a': [eventAddress], limit: 100 }); |
||||||
|
|
||||||
|
expect(mockNdk.subscribe).toHaveBeenCalledWith( |
||||||
|
expect.objectContaining({ |
||||||
|
kinds: [9802], |
||||||
|
'#a': [eventAddress], |
||||||
|
limit: 100, |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('fetches with both eventId and eventAddress filters when both provided', () => { |
||||||
|
const eventId = 'a'.repeat(64); |
||||||
|
const eventAddress = '30040:' + 'b'.repeat(64) + ':chapter-1'; |
||||||
|
|
||||||
|
// Simulate calling fetchHighlights
|
||||||
|
mockNdk.subscribe({ |
||||||
|
kinds: [9802], |
||||||
|
'#e': [eventId], |
||||||
|
'#a': [eventAddress], |
||||||
|
limit: 100, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(mockNdk.subscribe).toHaveBeenCalledWith( |
||||||
|
expect.objectContaining({ |
||||||
|
kinds: [9802], |
||||||
|
'#e': [eventId], |
||||||
|
'#a': [eventAddress], |
||||||
|
limit: 100, |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('cleans up subscription on unmount', () => { |
||||||
|
mockNdk.subscribe({ kinds: [9802], limit: 100 }); |
||||||
|
|
||||||
|
// Simulate unmount by calling stop
|
||||||
|
mockSubscription.stop(); |
||||||
|
|
||||||
|
expect(mockSubscription.stop).toHaveBeenCalled(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Color Mapping', () => { |
||||||
|
it('maps highlights to colors correctly', () => { |
||||||
|
const pubkey1 = 'a'.repeat(64); |
||||||
|
const pubkey2 = 'b'.repeat(64); |
||||||
|
|
||||||
|
const hue1 = pubkeyToHue(pubkey1); |
||||||
|
const hue2 = pubkeyToHue(pubkey2); |
||||||
|
|
||||||
|
const expectedColor1 = `hsla(${hue1}, 70%, 60%, 0.3)`; |
||||||
|
const expectedColor2 = `hsla(${hue2}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(expectedColor1).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); |
||||||
|
expect(expectedColor2).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); |
||||||
|
expect(expectedColor1).not.toBe(expectedColor2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('uses consistent color for same pubkey', () => { |
||||||
|
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
|
||||||
|
const color1 = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
const color2 = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(color1).toBe(color2); |
||||||
|
}); |
||||||
|
|
||||||
|
it('generates semi-transparent colors with 0.3 opacity', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(color).toContain('0.3'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('uses HSL color format with correct values', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
// Verify format: hsla(hue, 70%, 60%, 0.3)
|
||||||
|
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Highlight Events', () => { |
||||||
|
it('handles no highlights gracefully', () => { |
||||||
|
const highlights: any[] = []; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(0); |
||||||
|
// Component should render without errors
|
||||||
|
}); |
||||||
|
|
||||||
|
it('handles single highlight from one user', () => { |
||||||
|
const mockHighlight = { |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: 'highlighted text', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const highlights = [mockHighlight]; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(1); |
||||||
|
expect(highlights[0].pubkey).toBe('a'.repeat(64)); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles multiple highlights from same user', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const mockHighlights = [ |
||||||
|
{ |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: pubkey, |
||||||
|
content: 'first highlight', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'highlight2', |
||||||
|
kind: 9802, |
||||||
|
pubkey: pubkey, |
||||||
|
content: 'second highlight', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
expect(mockHighlights.length).toBe(2); |
||||||
|
expect(mockHighlights[0].pubkey).toBe(mockHighlights[1].pubkey); |
||||||
|
|
||||||
|
// Should use same color for both
|
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles multiple highlights from different users', () => { |
||||||
|
const pubkey1 = 'a'.repeat(64); |
||||||
|
const pubkey2 = 'b'.repeat(64); |
||||||
|
const pubkey3 = 'c'.repeat(64); |
||||||
|
|
||||||
|
const mockHighlights = [ |
||||||
|
{ |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: pubkey1, |
||||||
|
content: 'highlight from user 1', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'highlight2', |
||||||
|
kind: 9802, |
||||||
|
pubkey: pubkey2, |
||||||
|
content: 'highlight from user 2', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'highlight3', |
||||||
|
kind: 9802, |
||||||
|
pubkey: pubkey3, |
||||||
|
content: 'highlight from user 3', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
expect(mockHighlights.length).toBe(3); |
||||||
|
|
||||||
|
// Each should have different color
|
||||||
|
const hue1 = pubkeyToHue(pubkey1); |
||||||
|
const hue2 = pubkeyToHue(pubkey2); |
||||||
|
const hue3 = pubkeyToHue(pubkey3); |
||||||
|
|
||||||
|
expect(hue1).not.toBe(hue2); |
||||||
|
expect(hue2).not.toBe(hue3); |
||||||
|
expect(hue1).not.toBe(hue3); |
||||||
|
}); |
||||||
|
|
||||||
|
it('prevents duplicate highlights', () => { |
||||||
|
const mockHighlight = { |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: 'highlighted text', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const highlights = [mockHighlight]; |
||||||
|
|
||||||
|
// Try to add duplicate
|
||||||
|
const isDuplicate = highlights.some(h => h.id === mockHighlight.id); |
||||||
|
|
||||||
|
expect(isDuplicate).toBe(true); |
||||||
|
// Should not add duplicate
|
||||||
|
}); |
||||||
|
|
||||||
|
it('handles empty content gracefully', () => { |
||||||
|
const mockHighlight = { |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: '', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}; |
||||||
|
|
||||||
|
// Should not crash
|
||||||
|
expect(mockHighlight.content).toBe(''); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles whitespace-only content', () => { |
||||||
|
const mockHighlight = { |
||||||
|
id: 'highlight1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: ' \n\t ', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}; |
||||||
|
|
||||||
|
const trimmed = mockHighlight.content.trim(); |
||||||
|
expect(trimmed.length).toBe(0); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Highlighter Legend', () => { |
||||||
|
it('displays legend with correct color for single highlighter', () => { |
||||||
|
const pubkey = 'abc123def456'.repeat(5) + 'abcd'; |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
const legend = { |
||||||
|
pubkey: pubkey, |
||||||
|
color: color, |
||||||
|
shortPubkey: `${pubkey.slice(0, 8)}...`, |
||||||
|
}; |
||||||
|
|
||||||
|
expect(legend.color).toBe(color); |
||||||
|
expect(legend.shortPubkey).toBe(`${pubkey.slice(0, 8)}...`); |
||||||
|
}); |
||||||
|
|
||||||
|
it('displays legend with colors for multiple highlighters', () => { |
||||||
|
const pubkeys = [ |
||||||
|
'a'.repeat(64), |
||||||
|
'b'.repeat(64), |
||||||
|
'c'.repeat(64), |
||||||
|
]; |
||||||
|
|
||||||
|
const legendEntries = pubkeys.map(pubkey => ({ |
||||||
|
pubkey, |
||||||
|
color: `hsla(${pubkeyToHue(pubkey)}, 70%, 60%, 0.3)`, |
||||||
|
shortPubkey: `${pubkey.slice(0, 8)}...`, |
||||||
|
})); |
||||||
|
|
||||||
|
expect(legendEntries.length).toBe(3); |
||||||
|
|
||||||
|
// Each should have unique color
|
||||||
|
const colors = legendEntries.map(e => e.color); |
||||||
|
const uniqueColors = new Set(colors); |
||||||
|
expect(uniqueColors.size).toBe(3); |
||||||
|
}); |
||||||
|
|
||||||
|
it('shows truncated pubkey in legend', () => { |
||||||
|
const pubkey = 'abcdefghijklmnop'.repeat(4); |
||||||
|
const shortPubkey = `${pubkey.slice(0, 8)}...`; |
||||||
|
|
||||||
|
expect(shortPubkey).toBe('abcdefgh...'); |
||||||
|
expect(shortPubkey.length).toBeLessThan(pubkey.length); |
||||||
|
}); |
||||||
|
|
||||||
|
it('displays highlight count', () => { |
||||||
|
const highlights = [ |
||||||
|
{ id: '1', pubkey: 'a'.repeat(64), content: 'text1' }, |
||||||
|
{ id: '2', pubkey: 'b'.repeat(64), content: 'text2' }, |
||||||
|
{ id: '3', pubkey: 'a'.repeat(64), content: 'text3' }, |
||||||
|
]; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(3); |
||||||
|
|
||||||
|
// Count unique highlighters
|
||||||
|
const uniqueHighlighters = new Set(highlights.map(h => h.pubkey)); |
||||||
|
expect(uniqueHighlighters.size).toBe(2); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Text Matching', () => { |
||||||
|
it('matches text case-insensitively', () => { |
||||||
|
const searchText = 'Hello World'; |
||||||
|
const contentText = 'hello world'; |
||||||
|
|
||||||
|
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); |
||||||
|
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles special characters in search text', () => { |
||||||
|
const searchText = 'text with "quotes" and symbols!'; |
||||||
|
const contentText = 'This is text with "quotes" and symbols! in it.'; |
||||||
|
|
||||||
|
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); |
||||||
|
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles Unicode characters', () => { |
||||||
|
const searchText = 'café résumé'; |
||||||
|
const contentText = 'The café résumé was excellent.'; |
||||||
|
|
||||||
|
const index = contentText.toLowerCase().indexOf(searchText.toLowerCase()); |
||||||
|
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles multi-line text', () => { |
||||||
|
const searchText = 'line one\nline two'; |
||||||
|
const contentText = 'This is line one\nline two in the document.'; |
||||||
|
|
||||||
|
const index = contentText.indexOf(searchText); |
||||||
|
|
||||||
|
expect(index).toBeGreaterThanOrEqual(0); |
||||||
|
}); |
||||||
|
|
||||||
|
it('does not match partial words when searching for whole words', () => { |
||||||
|
const searchText = 'cat'; |
||||||
|
const contentText = 'The category is important.'; |
||||||
|
|
||||||
|
// Simple word boundary check
|
||||||
|
const wordBoundaryMatch = new RegExp(`\\b${searchText}\\b`, 'i').test(contentText); |
||||||
|
|
||||||
|
expect(wordBoundaryMatch).toBe(false); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Subscription Lifecycle', () => { |
||||||
|
it('registers EOSE event handler', () => { |
||||||
|
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); |
||||||
|
|
||||||
|
// Verify that 'on' method is available for registering handlers
|
||||||
|
expect(subscription.on).toBeDefined(); |
||||||
|
|
||||||
|
// Register EOSE handler
|
||||||
|
subscription.on('eose', () => { |
||||||
|
subscription.stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
// Verify on was called
|
||||||
|
expect(subscription.on).toHaveBeenCalledWith('eose', expect.any(Function)); |
||||||
|
}); |
||||||
|
|
||||||
|
it('registers error event handler', () => { |
||||||
|
const subscription = mockNdk.subscribe({ kinds: [9802], limit: 100 }); |
||||||
|
|
||||||
|
// Verify that 'on' method is available for registering handlers
|
||||||
|
expect(subscription.on).toBeDefined(); |
||||||
|
|
||||||
|
// Register error handler
|
||||||
|
subscription.on('error', () => { |
||||||
|
subscription.stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
// Verify on was called
|
||||||
|
expect(subscription.on).toHaveBeenCalledWith('error', expect.any(Function)); |
||||||
|
}); |
||||||
|
|
||||||
|
it('stops subscription on timeout', async () => { |
||||||
|
vi.useFakeTimers(); |
||||||
|
|
||||||
|
mockNdk.subscribe({ kinds: [9802], limit: 100 }); |
||||||
|
|
||||||
|
// Fast-forward time by 10 seconds
|
||||||
|
vi.advanceTimersByTime(10000); |
||||||
|
|
||||||
|
// Subscription should be stopped after timeout
|
||||||
|
// Note: This would be tested in the actual component
|
||||||
|
|
||||||
|
vi.useRealTimers(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles multiple subscription cleanup calls safely', () => { |
||||||
|
mockNdk.subscribe({ kinds: [9802], limit: 100 }); |
||||||
|
|
||||||
|
// Call stop multiple times
|
||||||
|
mockSubscription.stop(); |
||||||
|
mockSubscription.stop(); |
||||||
|
mockSubscription.stop(); |
||||||
|
|
||||||
|
expect(mockSubscription.stop).toHaveBeenCalledTimes(3); |
||||||
|
// Should not throw errors
|
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Performance', () => { |
||||||
|
it('handles large number of highlights efficiently', () => { |
||||||
|
const startTime = Date.now(); |
||||||
|
|
||||||
|
const highlights = Array.from({ length: 1000 }, (_, i) => ({ |
||||||
|
id: `highlight${i}`, |
||||||
|
kind: 9802, |
||||||
|
pubkey: (i % 10).toString().repeat(64), |
||||||
|
content: `highlighted text ${i}`, |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
})); |
||||||
|
|
||||||
|
// Generate colors for all highlights
|
||||||
|
const colorMap = new Map<string, string>(); |
||||||
|
highlights.forEach(h => { |
||||||
|
if (!colorMap.has(h.pubkey)) { |
||||||
|
const hue = pubkeyToHue(h.pubkey); |
||||||
|
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
const endTime = Date.now(); |
||||||
|
const duration = endTime - startTime; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(1000); |
||||||
|
expect(colorMap.size).toBe(10); |
||||||
|
expect(duration).toBeLessThan(1000); // Should complete in less than 1 second
|
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Integration Tests', () => { |
||||||
|
describe('Toggle Functionality', () => { |
||||||
|
it('toggle button shows highlights when clicked', () => { |
||||||
|
let highlightsVisible = false; |
||||||
|
|
||||||
|
// Simulate toggle
|
||||||
|
highlightsVisible = !highlightsVisible; |
||||||
|
|
||||||
|
expect(highlightsVisible).toBe(true); |
||||||
|
}); |
||||||
|
|
||||||
|
it('toggle button hides highlights when clicked again', () => { |
||||||
|
let highlightsVisible = true; |
||||||
|
|
||||||
|
// Simulate toggle
|
||||||
|
highlightsVisible = !highlightsVisible; |
||||||
|
|
||||||
|
expect(highlightsVisible).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('toggle state persists between interactions', () => { |
||||||
|
let highlightsVisible = false; |
||||||
|
|
||||||
|
highlightsVisible = !highlightsVisible; |
||||||
|
expect(highlightsVisible).toBe(true); |
||||||
|
|
||||||
|
highlightsVisible = !highlightsVisible; |
||||||
|
expect(highlightsVisible).toBe(false); |
||||||
|
|
||||||
|
highlightsVisible = !highlightsVisible; |
||||||
|
expect(highlightsVisible).toBe(true); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Color Format Validation', () => { |
||||||
|
it('generates semi-transparent colors with 0.3 opacity', () => { |
||||||
|
const pubkeys = [ |
||||||
|
'a'.repeat(64), |
||||||
|
'b'.repeat(64), |
||||||
|
'c'.repeat(64), |
||||||
|
]; |
||||||
|
|
||||||
|
pubkeys.forEach(pubkey => { |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(color).toContain('0.3'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('uses HSL color format with correct saturation and lightness', () => { |
||||||
|
const pubkey = 'a'.repeat(64); |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
expect(color).toContain('70%'); |
||||||
|
expect(color).toContain('60%'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('generates valid CSS color strings', () => { |
||||||
|
const pubkeys = Array.from({ length: 20 }, (_, i) => |
||||||
|
String.fromCharCode(97 + i).repeat(64) |
||||||
|
); |
||||||
|
|
||||||
|
pubkeys.forEach(pubkey => { |
||||||
|
const hue = pubkeyToHue(pubkey); |
||||||
|
const color = `hsla(${hue}, 70%, 60%, 0.3)`; |
||||||
|
|
||||||
|
// Validate CSS color format
|
||||||
|
expect(color).toMatch(/^hsla\(\d+, 70%, 60%, 0\.3\)$/); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('End-to-End Flow', () => { |
||||||
|
it('complete highlight workflow', () => { |
||||||
|
// 1. Start with no highlights visible
|
||||||
|
let highlightsVisible = false; |
||||||
|
let highlights: any[] = []; |
||||||
|
|
||||||
|
expect(highlightsVisible).toBe(false); |
||||||
|
expect(highlights.length).toBe(0); |
||||||
|
|
||||||
|
// 2. Fetch highlights
|
||||||
|
const mockHighlights = [ |
||||||
|
{ |
||||||
|
id: 'h1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: 'first highlight', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: 'h2', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'b'.repeat(64), |
||||||
|
content: 'second highlight', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
highlights = mockHighlights; |
||||||
|
expect(highlights.length).toBe(2); |
||||||
|
|
||||||
|
// 3. Generate color map
|
||||||
|
const colorMap = new Map<string, string>(); |
||||||
|
highlights.forEach(h => { |
||||||
|
if (!colorMap.has(h.pubkey)) { |
||||||
|
const hue = pubkeyToHue(h.pubkey); |
||||||
|
colorMap.set(h.pubkey, `hsla(${hue}, 70%, 60%, 0.3)`); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
expect(colorMap.size).toBe(2); |
||||||
|
|
||||||
|
// 4. Toggle visibility
|
||||||
|
highlightsVisible = true; |
||||||
|
expect(highlightsVisible).toBe(true); |
||||||
|
|
||||||
|
// 5. Verify colors are different
|
||||||
|
const colors = Array.from(colorMap.values()); |
||||||
|
expect(colors[0]).not.toBe(colors[1]); |
||||||
|
|
||||||
|
// 6. Toggle off
|
||||||
|
highlightsVisible = false; |
||||||
|
expect(highlightsVisible).toBe(false); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles event updates correctly', () => { |
||||||
|
let eventId = 'event1'; |
||||||
|
let highlights: any[] = []; |
||||||
|
|
||||||
|
// Initial load
|
||||||
|
highlights = [ |
||||||
|
{ |
||||||
|
id: 'h1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'a'.repeat(64), |
||||||
|
content: 'highlight 1', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(1); |
||||||
|
|
||||||
|
// Event changes
|
||||||
|
eventId = 'event2'; |
||||||
|
highlights = []; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(0); |
||||||
|
|
||||||
|
// New highlights loaded
|
||||||
|
highlights = [ |
||||||
|
{ |
||||||
|
id: 'h2', |
||||||
|
kind: 9802, |
||||||
|
pubkey: 'b'.repeat(64), |
||||||
|
content: 'highlight 2', |
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
expect(highlights.length).toBe(1); |
||||||
|
expect(highlights[0].id).toBe('h2'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('Error Handling', () => { |
||||||
|
it('handles missing event ID and address gracefully', () => { |
||||||
|
const eventId = undefined; |
||||||
|
const eventAddress = undefined; |
||||||
|
|
||||||
|
// Should not attempt to fetch
|
||||||
|
expect(eventId).toBeUndefined(); |
||||||
|
expect(eventAddress).toBeUndefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles subscription errors gracefully', () => { |
||||||
|
const error = new Error('Subscription failed'); |
||||||
|
|
||||||
|
// Should log error but not crash
|
||||||
|
expect(error.message).toBe('Subscription failed'); |
||||||
|
}); |
||||||
|
|
||||||
|
it('handles malformed highlight events', () => { |
||||||
|
const malformedHighlight = { |
||||||
|
id: 'h1', |
||||||
|
kind: 9802, |
||||||
|
pubkey: '', // Empty pubkey
|
||||||
|
content: undefined, // Missing content
|
||||||
|
created_at: Date.now(), |
||||||
|
tags: [], |
||||||
|
}; |
||||||
|
|
||||||
|
// Should handle gracefully
|
||||||
|
expect(malformedHighlight.pubkey).toBe(''); |
||||||
|
expect(malformedHighlight.content).toBeUndefined(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,875 @@ |
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; |
||||||
|
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
// Mock flowbite-svelte components
|
||||||
|
vi.mock("flowbite-svelte", () => ({ |
||||||
|
Button: vi.fn().mockImplementation((props) => ({ |
||||||
|
$$render: () => `<button data-testid="button">${props.children || ""}</button>`, |
||||||
|
})), |
||||||
|
Modal: vi.fn().mockImplementation(() => ({ |
||||||
|
$$render: () => `<div data-testid="modal"></div>`, |
||||||
|
})), |
||||||
|
Textarea: vi.fn().mockImplementation(() => ({ |
||||||
|
$$render: () => `<textarea data-testid="textarea"></textarea>`, |
||||||
|
})), |
||||||
|
P: vi.fn().mockImplementation(() => ({ |
||||||
|
$$render: () => `<p data-testid="p"></p>`, |
||||||
|
})), |
||||||
|
})); |
||||||
|
|
||||||
|
// Mock flowbite-svelte-icons
|
||||||
|
vi.mock("flowbite-svelte-icons", () => ({ |
||||||
|
FontHighlightOutline: vi.fn().mockImplementation(() => ({ |
||||||
|
$$render: () => `<svg data-testid="highlight-icon"></svg>`, |
||||||
|
})), |
||||||
|
})); |
||||||
|
|
||||||
|
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(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
Loading…
Reference in new issue