10 changed files with 3923 additions and 0 deletions
@ -0,0 +1,21 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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