clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

472 lines
14 KiB

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