Browse Source

Add text highlighting system with position-based overlays and mock data support

master
limina1 4 months ago
parent
commit
833b82d43d
  1. 21
      src/lib/components/publications/HighlightButton.svelte
  2. 788
      src/lib/components/publications/HighlightLayer.svelte
  3. 429
      src/lib/components/publications/HighlightSelectionHandler.svelte
  4. 70
      src/lib/utils/fetch_publication_highlights.ts
  5. 224
      src/lib/utils/highlightPositioning.ts
  6. 156
      src/lib/utils/highlightUtils.ts
  7. 183
      src/lib/utils/mockHighlightData.ts
  8. 318
      tests/unit/fetchPublicationHighlights.test.ts
  9. 859
      tests/unit/highlightLayer.test.ts
  10. 875
      tests/unit/highlightSelection.test.ts

21
src/lib/components/publications/HighlightButton.svelte

@ -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>

788
src/lib/components/publications/HighlightLayer.svelte

@ -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>

429
src/lib/components/publications/HighlightSelectionHandler.svelte

@ -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>

70
src/lib/utils/fetch_publication_highlights.ts

@ -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;
}

224
src/lib/utils/highlightPositioning.ts

@ -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;
}

156
src/lib/utils/highlightUtils.ts

@ -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);
}

183
src/lib/utils/mockHighlightData.ts

@ -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;
}

318
tests/unit/fetchPublicationHighlights.test.ts

@ -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",
],
})
);
});
});

859
tests/unit/highlightLayer.test.ts

@ -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();
});
});
});

875
tests/unit/highlightSelection.test.ts

@ -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…
Cancel
Save