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.
 
 
 
 

669 lines
19 KiB

<script lang="ts">
import { Button, Modal, Popover, Textarea, P } from "flowbite-svelte";
import {
DotsVerticalOutline,
EyeOutline,
ClipboardCleanOutline,
TrashBinOutline,
MessageDotsOutline,
ChevronDownOutline,
ChevronUpOutline,
} from "flowbite-svelte-icons";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import {
activeInboxRelays,
activeOutboxRelays,
getNdkContext,
} from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import LazyImage from "$components/util/LazyImage.svelte";
import { communityRelays } from "$lib/consts";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
// Component props
let { event, onDelete, sectionAddress } = $props<{
event: NDKEvent;
onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option
}>();
const ndk = getNdkContext();
// Subscribe to userStore (Svelte 5 runes pattern)
let user = $derived($userStore);
// Derive metadata from event
let title = $derived(
event.tags.find((t: string[]) => t[0] === "title")?.[1] ?? "",
);
let summary = $derived(
event.tags.find((t: string[]) => t[0] === "summary")?.[1] ?? "",
);
let image = $derived(
event.tags.find((t: string[]) => t[0] === "image")?.[1] ?? null,
);
let author = $derived(
event.tags.find((t: string[]) => t[0] === "author")?.[1] ?? "",
);
let originalAuthor = $derived(
event.tags.find((t: string[]) => t[0] === "original_author")?.[1] ?? null,
);
let version = $derived(
event.tags.find((t: string[]) => t[0] === "version")?.[1] ?? "",
);
let source = $derived(
event.tags.find((t: string[]) => t[0] === "source")?.[1] ?? null,
);
let type = $derived(
event.tags.find((t: string[]) => t[0] === "type")?.[1] ?? null,
);
let language = $derived(
event.tags.find((t: string[]) => t[0] === "language")?.[1] ?? null,
);
let publisher = $derived(
event.tags.find((t: string[]) => t[0] === "publisher")?.[1] ?? null,
);
let identifier = $derived(
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
);
// UI state
let detailsModalOpen: boolean = $state(false);
let isOpen: boolean = $state(false);
// Comment modal state
let commentModalOpen: boolean = $state(false);
let commentContent: string = $state("");
let isSubmittingComment: boolean = $state(false);
let commentError: string | null = $state(null);
let commentSuccess: boolean = $state(false);
let showJsonPreview: boolean = $state(false);
// Build preview JSON for the comment event
let previewJson = $derived.by(() => {
if (!commentContent.trim() || !sectionAddress) return null;
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) return null;
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
const relayHint = $activeOutboxRelays[0] || "";
return {
kind: 1111,
pubkey: user.pubkey || "<your-pubkey>",
created_at: Math.floor(Date.now() / 1000),
tags: [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
],
content: commentContent,
id: "<calculated-on-signing>",
sig: "<calculated-on-signing>",
};
});
// Check if user can delete this event (must be the author)
let canDelete = $derived.by(() => {
const result =
user.signedIn && user.pubkey === event.pubkey && onDelete !== undefined;
console.log("[CardActions] canDelete check:", {
userSignedIn: user.signedIn,
userPubkey: user.pubkey,
eventPubkey: event.pubkey,
onDeleteProvided: onDelete !== undefined,
canDelete: result,
});
return result;
});
// Determine delete button text based on event kind
let deleteButtonText = $derived.by(() => {
if (event.kind === 30040) {
// Kind 30040 is an index/publication
return "Delete publication";
} else if (event.kind === 30041) {
// Kind 30041 is a section
return "Delete section";
} else if (event.kind === 30023) {
// Kind 30023 is a long-form article
return "Delete article";
} else {
return "Delete";
}
});
/**
* Selects the appropriate relay set based on user state and feed type
* - Uses active inbox relays from the new relay management system
* - Falls back to active inbox relays for anonymous users (which include community relays)
*/
let activeRelays = $derived(
(() => {
const relays = user.signedIn ? $activeInboxRelays : $activeInboxRelays;
console.debug("[CardActions] Selected relays:", {
eventId: event.id,
isSignedIn: user.signedIn,
relayCount: relays.length,
relayUrls: relays,
});
return relays;
})(),
);
/**
* Opens the actions popover menu
*/
function openPopover() {
isOpen = true;
}
/**
* Closes the actions popover menu and removes focus
*/
function closePopover() {
isOpen = false;
const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur();
}
/**
* Gets the appropriate identifier (nevent or naddr) for copying
* @param type - The type of identifier to get ('nevent' or 'naddr')
* @returns The encoded identifier string
*/
function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays);
return identifier;
}
/**
* Opens the event details modal
*/
function viewDetails() {
detailsModalOpen = true;
}
/**
* Navigates to the event details page
*/
function viewEventDetails() {
const nevent = getIdentifier("nevent");
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
/**
* Opens the comment modal
*/
function openCommentModal() {
if (!user.signedIn) {
commentError = "You must be signed in to comment";
setTimeout(() => {
commentError = null;
}, 3000);
return;
}
closePopover();
commentModalOpen = true;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
/**
* Parse address to get event details
*/
function parseAddress(
address: string,
): { kind: number; pubkey: string; dTag: string } | null {
const parts = address.split(":");
if (parts.length !== 3) {
console.error("[CardActions] Invalid address format:", address);
return null;
}
const [kindStr, pubkey, dTag] = parts;
const kind = parseInt(kindStr);
if (isNaN(kind)) {
console.error("[CardActions] Invalid kind in address:", kindStr);
return null;
}
return { kind, pubkey, dTag };
}
/**
* Submit comment
*/
async function submitComment() {
if (!sectionAddress || !user.pubkey) {
commentError = "Invalid state - cannot submit comment";
return;
}
const eventDetails = parseAddress(sectionAddress);
if (!eventDetails) {
commentError = "Invalid event address";
return;
}
const { kind, pubkey: authorPubkey, dTag } = eventDetails;
isSubmittingComment = true;
commentError = null;
try {
// Get relay hint
const relayHint = $activeOutboxRelays[0] || "";
// Fetch target event to get its ID
let eventId = "";
try {
const targetEvent = await ndk.fetchEvent({
kinds: [kind],
authors: [authorPubkey],
"#d": [dTag],
});
if (targetEvent) {
eventId = targetEvent.id;
}
} catch (err) {
console.warn("[CardActions] Could not fetch target event ID:", err);
}
// Create comment event (NIP-22)
const commentEvent = new NDKEventClass(ndk);
commentEvent.kind = 1111;
commentEvent.content = commentContent;
commentEvent.pubkey = user.pubkey;
commentEvent.tags = [
["A", sectionAddress, relayHint, authorPubkey],
["K", kind.toString()],
["P", authorPubkey, relayHint],
["a", sectionAddress, relayHint],
["k", kind.toString()],
["p", authorPubkey, relayHint],
];
if (eventId) {
commentEvent.tags.push(["e", eventId, relayHint]);
}
// Sign event
const plainEvent = {
kind: Number(commentEvent.kind),
pubkey: String(commentEvent.pubkey),
created_at: Number(
commentEvent.created_at ?? Math.floor(Date.now() / 1000),
),
tags: commentEvent.tags.map((tag) => tag.map(String)),
content: String(commentEvent.content),
};
if (
typeof window !== "undefined" &&
window.nostr &&
window.nostr.signEvent
) {
const signed = await window.nostr.signEvent(plainEvent);
commentEvent.sig = signed.sig;
if ("id" in signed) {
commentEvent.id = signed.id as string;
}
} else if (user.signer) {
await commentEvent.sign(user.signer);
}
// Publish to relays
const relays = [
...communityRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];
const uniqueRelays = Array.from(new Set(relays));
const signedEvent = {
...plainEvent,
id: commentEvent.id,
sig: commentEvent.sig,
};
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++;
WebSocketPool.instance.release(ws);
resolve();
} else {
WebSocketPool.instance.release(ws);
reject(new Error(message));
}
}
};
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
} catch (e) {
console.error(`[CardActions] Failed to publish to ${relayUrl}:`, e);
}
}
if (publishedCount === 0) {
throw new Error("Failed to publish to any relays");
}
commentSuccess = true;
setTimeout(() => {
commentModalOpen = false;
commentSuccess = false;
commentContent = "";
showJsonPreview = false;
}, 2000);
} catch (err) {
console.error("[CardActions] Error submitting comment:", err);
commentError =
err instanceof Error ? err.message : "Failed to post comment";
} finally {
isSubmittingComment = false;
}
}
/**
* Cancel comment
*/
function cancelComment() {
commentModalOpen = false;
commentContent = "";
commentError = null;
commentSuccess = false;
showJsonPreview = false;
}
</script>
<div
class="group bg-highlight dark:bg-primary-1000 rounded"
role="group"
onmouseenter={openPopover}
>
<!-- Main button -->
<Button
type="button"
id="dots-{event.id}"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots"
color="primary"
data-popover-target="popover-actions"
>
<DotsVerticalOutline class="h-6 w-6" />
<span class="sr-only">Open actions menu</span>
</Button>
{#if isOpen}
<Popover
id="popover-actions"
placement="bottom"
trigger="click"
class="popover-leather w-fit z-10"
onmouseleave={closePopover}
>
<div class="flex flex-row justify-between space-x-4">
<div class="flex flex-col text-nowrap">
<ul class="space-y-2">
{#if sectionAddress}
<li>
<button
class="btn-leather w-full text-left"
onclick={openCommentModal}
>
<MessageDotsOutline class="inline mr-2" /> Comment on section
</button>
</li>
{/if}
<li>
<button
class="btn-leather w-full text-left"
onclick={viewDetails}
>
<EyeOutline class="inline mr-2" /> View details
</button>
</li>
<li>
<CopyToClipboard
displayText="Copy naddr address"
copyText={getIdentifier("naddr")}
icon={ClipboardCleanOutline}
/>
</li>
<li>
<CopyToClipboard
displayText="Copy nevent address"
copyText={getIdentifier("nevent")}
icon={ClipboardCleanOutline}
/>
</li>
{#if canDelete}
<li>
<button
class="btn-leather w-full text-left text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
onclick={() => {
closePopover();
onDelete?.();
}}
>
<TrashBinOutline class="inline mr-2" />
{deleteButtonText}
</button>
</li>
{/if}
</ul>
</div>
</div>
</Popover>
{/if}
<!-- Event details -->
<Modal
class="modal-leather"
title="Publication details"
bind:open={detailsModalOpen}
autoclose
outsideclose
size="sm"
>
<div class="flex flex-row space-x-4">
{#if image}
<div
class="flex col justify-center align-middle h-32 w-24 min-w-20 max-w-24 overflow-hidden"
>
<LazyImage
src={image}
alt="Publication cover"
eventId={event.id}
className="rounded w-full h-full object-cover"
/>
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-0">{title || "Untitled"}</h1>
<h2 class="text-base font-bold">
by
{#if originalAuthor}
{@render userBadge(originalAuthor, author, ndk)}
{:else}
{author || "Unknown"}
{/if}
</h2>
{#if version}
<h4
class="text-base font-medium text-primary-700 dark:text-primary-300 mt-2"
>
Version: {version}
</h4>
{/if}
</div>
</div>
{#if summary}
<div class="flex flex-row">
<p class="text-base text-primary-900 dark:text-highlight">{summary}</p>
</div>
{/if}
<div class="flex flex-row">
<h4 class="text-base font-normal mt-2">
Index author: {@render userBadge(event.pubkey, author, ndk)}
</h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source}
<h5 class="text-sm">
Source: <a
class="underline"
href={source}
target="_blank"
rel="noopener noreferrer">{source}</a
>
</h5>
{/if}
{#if type}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5>
{/if}
<button
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
onclick={viewEventDetails}
>
View Event Details
</button>
</div>
</Modal>
<!-- Comment Modal -->
{#if sectionAddress}
<Modal
class="modal-leather"
title="Add Comment"
bind:open={commentModalOpen}
autoclose={false}
outsideclose={true}
size="md"
>
<div class="space-y-4">
{#if user.profile}
<div
class="flex items-center gap-3 pb-3 border-b border-gray-200 dark:border-gray-700"
>
{#if user.profile.picture}
<img
src={user.profile.picture}
alt={user.profile.displayName || user.profile.name || "User"}
class="w-10 h-10 rounded-full object-cover"
/>
{/if}
<span class="font-medium text-gray-900 dark:text-gray-100">
{user.profile.displayName || user.profile.name || "Anonymous"}
</span>
</div>
{/if}
<Textarea
bind:value={commentContent}
placeholder="Write your comment here..."
rows={6}
disabled={isSubmittingComment}
class="w-full"
/>
{#if commentError}
<P class="text-red-600 dark:text-red-400 text-sm">{commentError}</P>
{/if}
{#if commentSuccess}
<P class="text-green-600 dark:text-green-400 text-sm"
>Comment posted successfully!</P
>
{/if}
<!-- 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 gap-3 pt-2">
<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 gap-3">
<Button
color="alternative"
onclick={cancelComment}
disabled={isSubmittingComment}
>
Cancel
</Button>
<Button
color="primary"
onclick={submitComment}
disabled={isSubmittingComment || !commentContent.trim()}
>
{isSubmittingComment ? "Posting..." : "Post Comment"}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
</div>