7 changed files with 2524 additions and 0 deletions
@ -0,0 +1,520 @@
@@ -0,0 +1,520 @@
|
||||
<script lang="ts"> |
||||
import { Button, Textarea, P } from "flowbite-svelte"; |
||||
import { getContext } from "svelte"; |
||||
import type NDK from "@nostr-dev-kit/ndk"; |
||||
import { NDKEvent } 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 { |
||||
address, |
||||
onCommentPosted, |
||||
inline = false, |
||||
}: { |
||||
address: string; |
||||
onCommentPosted?: () => void; |
||||
inline?: boolean; |
||||
} = $props(); |
||||
|
||||
const ndk: NDK = getContext("ndk"); |
||||
|
||||
// State management |
||||
let showCommentUI = $state(false); |
||||
let commentContent = $state(""); |
||||
let isSubmitting = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let success = $state(false); |
||||
let showJsonPreview = $state(false); |
||||
|
||||
// Build preview JSON for the comment event |
||||
let previewJson = $derived.by(() => { |
||||
if (!commentContent.trim()) return null; |
||||
|
||||
const eventDetails = parseAddress(address); |
||||
if (!eventDetails) return null; |
||||
|
||||
const { kind, pubkey: authorPubkey, dTag } = eventDetails; |
||||
const relayHint = $activeOutboxRelays[0] || ""; |
||||
|
||||
return { |
||||
kind: 1111, |
||||
pubkey: $userStore.pubkey || "<your-pubkey>", |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: [ |
||||
["A", address, relayHint, authorPubkey], |
||||
["K", kind.toString()], |
||||
["P", authorPubkey, relayHint], |
||||
["a", address, relayHint], |
||||
["k", kind.toString()], |
||||
["p", authorPubkey, relayHint], |
||||
], |
||||
content: commentContent, |
||||
id: "<calculated-on-signing>", |
||||
sig: "<calculated-on-signing>" |
||||
}; |
||||
}); |
||||
|
||||
// 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("[CommentButton] Invalid address format:", address); |
||||
return null; |
||||
} |
||||
|
||||
const [kindStr, pubkey, dTag] = parts; |
||||
const kind = parseInt(kindStr); |
||||
|
||||
if (isNaN(kind)) { |
||||
console.error("[CommentButton] Invalid kind in address:", kindStr); |
||||
return null; |
||||
} |
||||
|
||||
return { kind, pubkey, dTag }; |
||||
} |
||||
|
||||
// Create NIP-22 comment event |
||||
async function createCommentEvent(content: string): Promise<NDKEvent | null> { |
||||
const eventDetails = parseAddress(address); |
||||
if (!eventDetails) { |
||||
error = "Invalid event address"; |
||||
return null; |
||||
} |
||||
|
||||
const { kind, pubkey: authorPubkey, dTag } = eventDetails; |
||||
|
||||
// Get relay hint (use first available outbox relay) |
||||
const relayHint = $activeOutboxRelays[0] || ""; |
||||
|
||||
// Get the actual event to include its ID in tags |
||||
let eventId = ""; |
||||
try { |
||||
const targetEvent = await ndk.fetchEvent({ |
||||
kinds: [kind], |
||||
authors: [authorPubkey], |
||||
"#d": [dTag], |
||||
}); |
||||
|
||||
if (targetEvent) { |
||||
eventId = targetEvent.id; |
||||
} |
||||
} catch (err) { |
||||
console.warn("[CommentButton] Could not fetch target event ID:", err); |
||||
} |
||||
|
||||
// Create the comment event following NIP-22 structure |
||||
const commentEvent = new NDKEvent(ndk); |
||||
commentEvent.kind = 1111; |
||||
commentEvent.content = content; |
||||
commentEvent.pubkey = $userStore.pubkey || ""; // Set pubkey from user store |
||||
|
||||
// NIP-22 tags structure for top-level comments |
||||
commentEvent.tags = [ |
||||
// Root scope - uppercase tags |
||||
["A", address, relayHint, authorPubkey], |
||||
["K", kind.toString()], |
||||
["P", authorPubkey, relayHint], |
||||
|
||||
// Parent scope (same as root for top-level) - lowercase tags |
||||
["a", address, relayHint], |
||||
["k", kind.toString()], |
||||
["p", authorPubkey, relayHint], |
||||
]; |
||||
|
||||
// Include e tag if we have the event ID |
||||
if (eventId) { |
||||
commentEvent.tags.push(["e", eventId, relayHint]); |
||||
} |
||||
|
||||
console.log("[CommentButton] Created NIP-22 comment event:", { |
||||
kind: commentEvent.kind, |
||||
tags: commentEvent.tags, |
||||
content: commentEvent.content, |
||||
}); |
||||
|
||||
return commentEvent; |
||||
} |
||||
|
||||
// Submit comment |
||||
async function submitComment() { |
||||
if (!commentContent.trim()) { |
||||
error = "Comment cannot be empty"; |
||||
return; |
||||
} |
||||
|
||||
if (!$userStore.signedIn || !$userStore.signer) { |
||||
error = "You must be signed in to comment"; |
||||
return; |
||||
} |
||||
|
||||
isSubmitting = true; |
||||
error = null; |
||||
success = false; |
||||
|
||||
try { |
||||
const commentEvent = await createCommentEvent(commentContent); |
||||
if (!commentEvent) { |
||||
throw new Error("Failed to create comment event"); |
||||
} |
||||
|
||||
// Sign the event - create plain object to avoid proxy issues |
||||
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 { |
||||
await commentEvent.sign($userStore.signer); |
||||
} |
||||
|
||||
console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent()); |
||||
|
||||
// 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("[CommentButton] Publishing to relays:", uniqueRelays); |
||||
|
||||
const signedEvent = { |
||||
...plainEvent, |
||||
id: commentEvent.id, |
||||
sig: commentEvent.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(`[CommentButton] Published to ${relayUrl}`); |
||||
WebSocketPool.instance.release(ws); |
||||
resolve(); |
||||
} else { |
||||
console.warn(`[CommentButton] ${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(`[CommentButton] Failed to publish to ${relayUrl}:`, e); |
||||
} |
||||
} |
||||
|
||||
if (publishedCount === 0) { |
||||
throw new Error("Failed to publish to any relays"); |
||||
} |
||||
|
||||
console.log(`[CommentButton] Published to ${publishedCount} relay(s)`); |
||||
|
||||
// Success! |
||||
success = true; |
||||
commentContent = ""; |
||||
showJsonPreview = false; |
||||
|
||||
// Close UI after a delay |
||||
setTimeout(() => { |
||||
showCommentUI = false; |
||||
success = false; |
||||
|
||||
// Trigger refresh of CommentViewer if callback provided |
||||
if (onCommentPosted) { |
||||
onCommentPosted(); |
||||
} |
||||
}, 2000); |
||||
|
||||
} catch (err) { |
||||
console.error("[CommentButton] Error submitting comment:", err); |
||||
error = err instanceof Error ? err.message : "Failed to post comment"; |
||||
} finally { |
||||
isSubmitting = false; |
||||
} |
||||
} |
||||
|
||||
// Cancel comment |
||||
function cancelComment() { |
||||
showCommentUI = false; |
||||
commentContent = ""; |
||||
error = null; |
||||
success = false; |
||||
showJsonPreview = false; |
||||
} |
||||
|
||||
// Toggle comment UI |
||||
function toggleCommentUI() { |
||||
if (!$userStore.signedIn) { |
||||
error = "You must be signed in to comment"; |
||||
setTimeout(() => { |
||||
error = null; |
||||
}, 3000); |
||||
return; |
||||
} |
||||
|
||||
showCommentUI = !showCommentUI; |
||||
error = null; |
||||
success = false; |
||||
showJsonPreview = false; |
||||
} |
||||
</script> |
||||
|
||||
<!-- Hamburger Comment Button --> |
||||
<div class="comment-button-container" class:inline={inline}> |
||||
<button |
||||
class="single-line-button" |
||||
onclick={toggleCommentUI} |
||||
title="Add comment" |
||||
aria-label="Add comment" |
||||
> |
||||
<span class="line"></span> |
||||
<span class="line"></span> |
||||
<span class="line"></span> |
||||
</button> |
||||
|
||||
<!-- Comment Creation UI --> |
||||
{#if showCommentUI} |
||||
<div class="comment-ui"> |
||||
<div class="comment-header"> |
||||
<h4>Add Comment</h4> |
||||
{#if $userStore.profile} |
||||
<div class="user-info"> |
||||
{#if $userStore.profile.picture} |
||||
<img src={$userStore.profile.picture} alt={$userStore.profile.displayName || $userStore.profile.name || "User"} class="user-avatar" /> |
||||
{/if} |
||||
<span class="user-name">{$userStore.profile.displayName || $userStore.profile.name || "Anonymous"}</span> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<Textarea |
||||
bind:value={commentContent} |
||||
placeholder="Write your comment here..." |
||||
rows={4} |
||||
disabled={isSubmitting} |
||||
class="comment-textarea" |
||||
/> |
||||
|
||||
{#if error} |
||||
<P class="error-message text-red-600 dark:text-red-400 text-sm mt-2">{error}</P> |
||||
{/if} |
||||
|
||||
{#if success} |
||||
<P class="success-message text-green-600 dark:text-green-400 text-sm mt-2">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 mt-3"> |
||||
<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="comment-actions-wrapper"> |
||||
<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="comment-actions"> |
||||
<Button |
||||
size="sm" |
||||
color="alternative" |
||||
onclick={cancelComment} |
||||
disabled={isSubmitting} |
||||
> |
||||
Cancel |
||||
</Button> |
||||
<Button |
||||
size="sm" |
||||
onclick={submitComment} |
||||
disabled={isSubmitting || !commentContent.trim()} |
||||
> |
||||
{isSubmitting ? "Posting..." : "Post Comment"} |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.comment-button-container { |
||||
position: absolute; |
||||
top: 0; |
||||
right: 0; |
||||
left: 0; |
||||
height: 0; |
||||
pointer-events: none; |
||||
} |
||||
|
||||
.comment-button-container.inline { |
||||
position: relative; |
||||
height: auto; |
||||
pointer-events: auto; |
||||
} |
||||
|
||||
.single-line-button { |
||||
position: absolute; |
||||
top: 4px; |
||||
right: 8px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
width: 24px; |
||||
height: 18px; |
||||
padding: 4px; |
||||
background: transparent; |
||||
border: none; |
||||
cursor: pointer; |
||||
opacity: 0; |
||||
transition: opacity 0.2s ease-in-out; |
||||
z-index: 10; |
||||
pointer-events: auto; |
||||
} |
||||
|
||||
.comment-button-container.inline .single-line-button { |
||||
position: relative; |
||||
top: 0; |
||||
right: 0; |
||||
opacity: 1; |
||||
} |
||||
|
||||
.single-line-button:hover .line { |
||||
border-width: 3px; |
||||
} |
||||
|
||||
.line { |
||||
display: block; |
||||
width: 100%; |
||||
height: 0; |
||||
border: none; |
||||
border-top: 2px dashed #6b7280; |
||||
transition: all 0.2s ease-in-out; |
||||
} |
||||
|
||||
.comment-ui { |
||||
position: absolute; |
||||
top: 35px; |
||||
right: 8px; |
||||
min-width: 400px; |
||||
max-width: 600px; |
||||
background: white; |
||||
border: 1px solid #e5e7eb; |
||||
border-radius: 8px; |
||||
padding: 16px; |
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); |
||||
z-index: 20; |
||||
pointer-events: auto; |
||||
} |
||||
|
||||
:global(.dark) .comment-ui { |
||||
background: #1f2937; |
||||
border-color: #374151; |
||||
} |
||||
|
||||
.comment-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-bottom: 12px; |
||||
} |
||||
|
||||
.comment-header h4 { |
||||
font-size: 16px; |
||||
font-weight: 600; |
||||
margin: 0; |
||||
color: #111827; |
||||
} |
||||
|
||||
:global(.dark) .comment-header h4 { |
||||
color: #f9fafb; |
||||
} |
||||
|
||||
.user-info { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
} |
||||
|
||||
.user-avatar { |
||||
width: 24px; |
||||
height: 24px; |
||||
border-radius: 50%; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.user-name { |
||||
font-size: 14px; |
||||
color: #6b7280; |
||||
} |
||||
|
||||
:global(.dark) .user-name { |
||||
color: #9ca3af; |
||||
} |
||||
|
||||
.comment-actions-wrapper { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
margin-top: 12px; |
||||
} |
||||
|
||||
.comment-actions { |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
gap: 8px; |
||||
} |
||||
|
||||
/* Make the comment UI responsive */ |
||||
@media (max-width: 640px) { |
||||
.comment-ui { |
||||
min-width: 280px; |
||||
max-width: calc(100vw - 32px); |
||||
right: -8px; |
||||
} |
||||
} |
||||
</style> |
||||
@ -0,0 +1,282 @@
@@ -0,0 +1,282 @@
|
||||
<script lang="ts"> |
||||
import { getNdkContext, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
||||
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 { generateMockCommentsForSections } from "$lib/utils/mockCommentData"; |
||||
|
||||
let { |
||||
eventId, |
||||
eventAddress, |
||||
eventIds = [], |
||||
eventAddresses = [], |
||||
comments = $bindable([]), |
||||
useMockComments = false, |
||||
}: { |
||||
eventId?: string; |
||||
eventAddress?: string; |
||||
eventIds?: string[]; |
||||
eventAddresses?: string[]; |
||||
comments?: NDKEvent[]; |
||||
useMockComments?: boolean; |
||||
} = $props(); |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
// State management |
||||
let loading = $state(false); |
||||
|
||||
/** |
||||
* Fetch comment events (kind 1111) for the current publication using WebSocketPool |
||||
* |
||||
* This follows the exact pattern from HighlightLayer.svelte to ensure reliability. |
||||
* Uses WebSocketPool with nostr-tools protocol instead of NDK subscriptions. |
||||
*/ |
||||
async function fetchComments() { |
||||
// Prevent concurrent fetches |
||||
if (loading) { |
||||
console.log("[CommentLayer] 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("[CommentLayer] No event IDs or addresses provided"); |
||||
return; |
||||
} |
||||
|
||||
loading = true; |
||||
comments = []; |
||||
|
||||
// AI-NOTE: Mock mode allows testing comment UI without publishing to relays |
||||
// This is useful for development and demonstrating the comment system |
||||
if (useMockComments) { |
||||
console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`); |
||||
|
||||
try { |
||||
// Generate mock comment data |
||||
const mockComments = generateMockCommentsForSections(allAddresses); |
||||
|
||||
// Convert to NDKEvent instances (same as real events) |
||||
comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent)); |
||||
|
||||
console.log(`[CommentLayer] Generated ${comments.length} mock comments`); |
||||
loading = false; |
||||
return; |
||||
} catch (err) { |
||||
console.error(`[CommentLayer] Error generating mock comments:`, err); |
||||
loading = false; |
||||
return; |
||||
} |
||||
} |
||||
|
||||
console.log(`[CommentLayer] Fetching comments for:`, { |
||||
eventIds: allEventIds, |
||||
addresses: allAddresses |
||||
}); |
||||
|
||||
try { |
||||
// Build filter for kind 1111 comment events |
||||
// IMPORTANT: Use only #a tags because filters are AND, not OR |
||||
// If we include both #e and #a, relays will only return comments that have BOTH |
||||
const filter: any = { |
||||
kinds: [1111], |
||||
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(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); |
||||
|
||||
// Build explicit relay set (same pattern as HighlightLayer) |
||||
const relays = [ |
||||
...communityRelays, |
||||
...$activeOutboxRelays, |
||||
...$activeInboxRelays, |
||||
]; |
||||
const uniqueRelays = Array.from(new Set(relays)); |
||||
console.log(`[CommentLayer] 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 - HighlightLayer, CommentButton, and HighlightSelectionHandler use WebSocketPool |
||||
* 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/HighlightLayer.svelte:111-212 |
||||
* - src/lib/components/publications/CommentButton.svelte:156-220 |
||||
* - src/lib/components/publications/HighlightSelectionHandler.svelte:217-280 |
||||
*/ |
||||
const subscriptionId = `comments-${Date.now()}`; |
||||
const receivedEventIds = new Set<string>(); |
||||
let eoseCount = 0; |
||||
|
||||
const fetchPromises = uniqueRelays.map(async (relayUrl) => { |
||||
try { |
||||
console.log(`[CommentLayer] 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(`[CommentLayer] RAW message from ${relayUrl}:`, message); |
||||
} |
||||
|
||||
if (message[0] === "EVENT" && message[1] === subscriptionId) { |
||||
const rawEvent = message[2]; |
||||
console.log(`[CommentLayer] 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); |
||||
comments = [...comments, ndkEvent]; |
||||
console.log(`[CommentLayer] Added comment, total now: ${comments.length}`); |
||||
} |
||||
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { |
||||
eoseCount++; |
||||
console.log(`[CommentLayer] 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(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]); |
||||
} |
||||
} catch (err) { |
||||
console.error(`[CommentLayer] 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(`[CommentLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); |
||||
} else { |
||||
console.log(`[CommentLayer] 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(`[CommentLayer] Error connecting to ${relayUrl}:`, err); |
||||
} |
||||
}); |
||||
|
||||
// Wait for all relays to respond or timeout |
||||
await Promise.all(fetchPromises); |
||||
|
||||
console.log(`[CommentLayer] Fetched ${comments.length} comments`); |
||||
|
||||
if (comments.length > 0) { |
||||
console.log(`[CommentLayer] Comments summary:`, comments.map(c => ({ |
||||
content: c.content.substring(0, 30) + "...", |
||||
address: c.tags.find(t => t[0] === "a")?.[1], |
||||
author: c.pubkey.substring(0, 8) |
||||
}))); |
||||
} |
||||
|
||||
loading = false; |
||||
|
||||
} catch (err) { |
||||
console.error(`[CommentLayer] Error fetching comments:`, err); |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
// 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(`[CommentLayer] 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(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`); |
||||
lastFetchedCount = currentCount; |
||||
fetchComments(); |
||||
}, 500); |
||||
} |
||||
|
||||
// Cleanup timeout on effect cleanup |
||||
return () => { |
||||
if (fetchTimeout) { |
||||
clearTimeout(fetchTimeout); |
||||
} |
||||
}; |
||||
}); |
||||
|
||||
/** |
||||
* Public method to refresh comments (e.g., after creating a new one) |
||||
*/ |
||||
export function refresh() { |
||||
console.log("[CommentLayer] Manual refresh triggered"); |
||||
|
||||
// Clear existing comments |
||||
comments = []; |
||||
|
||||
// Reset fetch count to force re-fetch |
||||
lastFetchedCount = 0; |
||||
fetchComments(); |
||||
} |
||||
</script> |
||||
|
||||
{#if loading} |
||||
<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 comments...</p> |
||||
</div> |
||||
{/if} |
||||
@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
|
||||
<script lang="ts"> |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; |
||||
import { getNdkContext } from "$lib/ndk"; |
||||
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
||||
import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons"; |
||||
|
||||
let { |
||||
comments = [], |
||||
sectionTitles = new Map<string, string>(), |
||||
}: { |
||||
comments: NDKEvent[]; |
||||
sectionTitles?: Map<string, string>; |
||||
} = $props(); |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
// State management |
||||
let profiles = $state(new Map<string, any>()); |
||||
let expandedSections = $state(new Set<string>()); |
||||
|
||||
/** |
||||
* Group comments by their target event address |
||||
* Extracts the target from #a or #e tags |
||||
*/ |
||||
let groupedComments = $derived.by(() => { |
||||
const groups = new Map<string, NDKEvent[]>(); |
||||
|
||||
for (const comment of comments) { |
||||
// Look for #a tag first (addressable events - preferred) |
||||
const aTag = comment.tags.find(t => t[0] === "a"); |
||||
if (aTag && aTag[1]) { |
||||
const address = aTag[1]; |
||||
if (!groups.has(address)) { |
||||
groups.set(address, []); |
||||
} |
||||
groups.get(address)!.push(comment); |
||||
continue; |
||||
} |
||||
|
||||
// Fallback to #e tag (event ID) |
||||
const eTag = comment.tags.find(t => t[0] === "e"); |
||||
if (eTag && eTag[1]) { |
||||
const eventId = eTag[1]; |
||||
if (!groups.has(eventId)) { |
||||
groups.set(eventId, []); |
||||
} |
||||
groups.get(eventId)!.push(comment); |
||||
} |
||||
} |
||||
|
||||
console.log(`[CommentPanel] Grouped ${comments.length} comments into ${groups.size} sections`); |
||||
return groups; |
||||
}); |
||||
|
||||
/** |
||||
* Get a display label for a target address/id |
||||
* Uses provided section titles, or falls back to address/id |
||||
*/ |
||||
function getTargetLabel(target: string): string { |
||||
// Check if we have a title for this address |
||||
if (sectionTitles.has(target)) { |
||||
return sectionTitles.get(target)!; |
||||
} |
||||
|
||||
// Parse address format: kind:pubkey:d-tag |
||||
const parts = target.split(":"); |
||||
if (parts.length === 3) { |
||||
const [kind, _pubkey, dTag] = parts; |
||||
if (kind === "30040") { |
||||
return "Comments on Collection"; |
||||
} |
||||
if (kind === "30041" && dTag) { |
||||
return `Section: ${dTag}`; |
||||
} |
||||
} |
||||
|
||||
// Fallback to truncated address/id |
||||
return target.length > 20 ? `${target.substring(0, 20)}...` : target; |
||||
} |
||||
|
||||
/** |
||||
* Fetch profile for a pubkey |
||||
*/ |
||||
async function fetchProfile(pubkey: string) { |
||||
if (profiles.has(pubkey)) return; |
||||
|
||||
try { |
||||
const npub = toNpub(pubkey); |
||||
if (!npub) { |
||||
setFallbackProfile(pubkey); |
||||
return; |
||||
} |
||||
|
||||
const profile = await getUserMetadata(npub, ndk, true); |
||||
const newProfiles = new Map(profiles); |
||||
newProfiles.set(pubkey, profile); |
||||
profiles = newProfiles; |
||||
|
||||
console.log(`[CommentPanel] Fetched profile for ${pubkey}:`, profile); |
||||
} catch (err) { |
||||
console.warn(`[CommentPanel] Failed to fetch profile for ${pubkey}:`, err); |
||||
setFallbackProfile(pubkey); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Set fallback profile using truncated npub |
||||
*/ |
||||
function setFallbackProfile(pubkey: string) { |
||||
const npub = toNpub(pubkey) || pubkey; |
||||
const truncated = `${npub.slice(0, 12)}...${npub.slice(-4)}`; |
||||
const fallbackProfile = { |
||||
name: truncated, |
||||
displayName: truncated, |
||||
picture: null |
||||
}; |
||||
const newProfiles = new Map(profiles); |
||||
newProfiles.set(pubkey, fallbackProfile); |
||||
profiles = newProfiles; |
||||
} |
||||
|
||||
/** |
||||
* Get display name for a pubkey |
||||
*/ |
||||
function getDisplayName(pubkey: string): string { |
||||
const profile = profiles.get(pubkey); |
||||
if (profile) { |
||||
return profile.displayName || profile.name || profile.pubkey || pubkey; |
||||
} |
||||
// Return truncated npub while loading |
||||
const npub = toNpub(pubkey) || pubkey; |
||||
return `${npub.slice(0, 12)}...${npub.slice(-4)}`; |
||||
} |
||||
|
||||
/** |
||||
* Toggle section expansion |
||||
*/ |
||||
function toggleSection(target: string) { |
||||
const newExpanded = new Set(expandedSections); |
||||
if (newExpanded.has(target)) { |
||||
newExpanded.delete(target); |
||||
} else { |
||||
newExpanded.add(target); |
||||
} |
||||
expandedSections = newExpanded; |
||||
} |
||||
|
||||
/** |
||||
* Format timestamp |
||||
*/ |
||||
function formatTimestamp(timestamp: number): string { |
||||
const date = new Date(timestamp * 1000); |
||||
const now = new Date(); |
||||
const diffMs = now.getTime() - date.getTime(); |
||||
const diffMins = Math.floor(diffMs / 60000); |
||||
const diffHours = Math.floor(diffMs / 3600000); |
||||
const diffDays = Math.floor(diffMs / 86400000); |
||||
|
||||
if (diffMins < 60) { |
||||
return `${diffMins}m ago`; |
||||
} else if (diffHours < 24) { |
||||
return `${diffHours}h ago`; |
||||
} else if (diffDays < 7) { |
||||
return `${diffDays}d ago`; |
||||
} else { |
||||
return date.toLocaleDateString(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Pre-fetch all profiles when comments change |
||||
*/ |
||||
$effect(() => { |
||||
const uniquePubkeys = new Set(comments.map(c => c.pubkey)); |
||||
console.log(`[CommentPanel] Pre-fetching ${uniquePubkeys.size} profiles`); |
||||
for (const pubkey of uniquePubkeys) { |
||||
fetchProfile(pubkey); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#if comments.length > 0} |
||||
<div class="fixed right-4 top-20 bottom-4 w-96 bg-white dark:bg-gray-800 rounded-lg shadow-xl overflow-hidden flex flex-col z-40"> |
||||
<!-- Header --> |
||||
<div class="p-4 border-b border-gray-200 dark:border-gray-700"> |
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100"> |
||||
Comments ({comments.length}) |
||||
</h3> |
||||
</div> |
||||
|
||||
<!-- Comment groups --> |
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4"> |
||||
{#each Array.from(groupedComments.entries()) as [target, targetComments]} |
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> |
||||
<!-- Section header --> |
||||
<button |
||||
class="w-full px-4 py-3 flex items-center justify-between bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" |
||||
onclick={() => toggleSection(target)} |
||||
> |
||||
<div class="flex items-center gap-2"> |
||||
{#if expandedSections.has(target)} |
||||
<ChevronDownOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" /> |
||||
{:else} |
||||
<ChevronRightOutline class="w-4 h-4 text-gray-600 dark:text-gray-400" /> |
||||
{/if} |
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"> |
||||
{getTargetLabel(target)} |
||||
</span> |
||||
</div> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{targetComments.length} {targetComments.length === 1 ? 'comment' : 'comments'} |
||||
</span> |
||||
</button> |
||||
|
||||
<!-- Comment list --> |
||||
{#if expandedSections.has(target)} |
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700"> |
||||
{#each targetComments as comment (comment.id)} |
||||
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"> |
||||
<!-- Comment header --> |
||||
<div class="flex items-start gap-3 mb-2"> |
||||
<div class="flex-1 min-w-0"> |
||||
<div class="flex items-baseline gap-2"> |
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate"> |
||||
{getDisplayName(comment.pubkey)} |
||||
</span> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{formatTimestamp(comment.created_at || 0)} |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- Comment content --> |
||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none"> |
||||
{@render basicMarkup(comment.content)} |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
|
||||
{#if groupedComments.size === 0 && comments.length > 0} |
||||
<div class="text-center py-8"> |
||||
<p class="text-sm text-gray-500 dark:text-gray-400"> |
||||
Comments loaded but couldn't determine their targets |
||||
</p> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
/* Custom scrollbar for comment panel */ |
||||
.overflow-y-auto { |
||||
scrollbar-width: thin; |
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent; |
||||
} |
||||
|
||||
.overflow-y-auto::-webkit-scrollbar { |
||||
width: 6px; |
||||
} |
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track { |
||||
background: transparent; |
||||
} |
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb { |
||||
background-color: rgba(156, 163, 175, 0.5); |
||||
border-radius: 3px; |
||||
} |
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover { |
||||
background-color: rgba(156, 163, 175, 0.7); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,323 @@
@@ -0,0 +1,323 @@
|
||||
<script lang="ts"> |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; |
||||
import { getNdkContext } from "$lib/ndk"; |
||||
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte"; |
||||
import { ChevronDownOutline, ChevronRightOutline } from "flowbite-svelte-icons"; |
||||
import { nip19 } from "nostr-tools"; |
||||
|
||||
let { |
||||
sectionAddress, |
||||
comments = [], |
||||
visible = true, |
||||
}: { |
||||
sectionAddress: string; |
||||
comments: NDKEvent[]; |
||||
visible?: boolean; |
||||
} = $props(); |
||||
|
||||
const ndk = getNdkContext(); |
||||
|
||||
// State management |
||||
let profiles = $state(new Map<string, any>()); |
||||
let expandedThreads = $state(new Set<string>()); |
||||
|
||||
/** |
||||
* Parse comment threading structure |
||||
* Root comments have no 'e' tag with 'reply' marker |
||||
*/ |
||||
function buildThreadStructure(allComments: NDKEvent[]) { |
||||
const rootComments: NDKEvent[] = []; |
||||
const repliesByParent = new Map<string, NDKEvent[]>(); |
||||
|
||||
for (const comment of allComments) { |
||||
// Check if this is a reply by looking for 'e' tags with 'reply' marker |
||||
const replyTag = comment.tags.find(t => t[0] === 'e' && t[3] === 'reply'); |
||||
|
||||
if (replyTag) { |
||||
const parentId = replyTag[1]; |
||||
if (!repliesByParent.has(parentId)) { |
||||
repliesByParent.set(parentId, []); |
||||
} |
||||
repliesByParent.get(parentId)!.push(comment); |
||||
} else { |
||||
// This is a root comment (no reply tag) |
||||
rootComments.push(comment); |
||||
} |
||||
} |
||||
|
||||
return { rootComments, repliesByParent }; |
||||
} |
||||
|
||||
let threadStructure = $derived(buildThreadStructure(comments)); |
||||
|
||||
/** |
||||
* Count replies for a comment thread |
||||
*/ |
||||
function countReplies(commentId: string, repliesMap: Map<string, NDKEvent[]>): number { |
||||
const directReplies = repliesMap.get(commentId) || []; |
||||
let count = directReplies.length; |
||||
|
||||
// Recursively count nested replies |
||||
for (const reply of directReplies) { |
||||
count += countReplies(reply.id, repliesMap); |
||||
} |
||||
|
||||
return count; |
||||
} |
||||
|
||||
/** |
||||
* Get display name for a pubkey |
||||
*/ |
||||
function getDisplayName(pubkey: string): string { |
||||
const profile = profiles.get(pubkey); |
||||
if (profile) { |
||||
return profile.displayName || profile.name || profile.pubkey || pubkey; |
||||
} |
||||
const npub = toNpub(pubkey) || pubkey; |
||||
return `${npub.slice(0, 12)}...`; |
||||
} |
||||
|
||||
/** |
||||
* Format timestamp |
||||
*/ |
||||
function formatTimestamp(timestamp: number): string { |
||||
const date = new Date(timestamp * 1000); |
||||
const now = new Date(); |
||||
const diffMs = now.getTime() - date.getTime(); |
||||
const diffMins = Math.floor(diffMs / 60000); |
||||
const diffHours = Math.floor(diffMs / 3600000); |
||||
const diffDays = Math.floor(diffMs / 86400000); |
||||
|
||||
if (diffMins < 60) { |
||||
return `${diffMins}m ago`; |
||||
} else if (diffHours < 24) { |
||||
return `${diffHours}h ago`; |
||||
} else if (diffDays < 7) { |
||||
return `${diffDays}d ago`; |
||||
} else { |
||||
return date.toLocaleDateString(); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetch profile for a pubkey |
||||
*/ |
||||
async function fetchProfile(pubkey: string) { |
||||
if (profiles.has(pubkey)) return; |
||||
|
||||
try { |
||||
const npub = toNpub(pubkey); |
||||
if (!npub) { |
||||
setFallbackProfile(pubkey); |
||||
return; |
||||
} |
||||
|
||||
const profile = await getUserMetadata(npub, ndk, true); |
||||
const newProfiles = new Map(profiles); |
||||
newProfiles.set(pubkey, profile); |
||||
profiles = newProfiles; |
||||
} catch (err) { |
||||
setFallbackProfile(pubkey); |
||||
} |
||||
} |
||||
|
||||
function setFallbackProfile(pubkey: string) { |
||||
const npub = toNpub(pubkey) || pubkey; |
||||
const truncated = `${npub.slice(0, 12)}...`; |
||||
const fallbackProfile = { |
||||
name: truncated, |
||||
displayName: truncated, |
||||
picture: null |
||||
}; |
||||
const newProfiles = new Map(profiles); |
||||
newProfiles.set(pubkey, fallbackProfile); |
||||
profiles = newProfiles; |
||||
} |
||||
|
||||
/** |
||||
* Toggle thread expansion |
||||
*/ |
||||
function toggleThread(commentId: string) { |
||||
const newExpanded = new Set(expandedThreads); |
||||
if (newExpanded.has(commentId)) { |
||||
newExpanded.delete(commentId); |
||||
} else { |
||||
newExpanded.add(commentId); |
||||
} |
||||
expandedThreads = newExpanded; |
||||
} |
||||
|
||||
/** |
||||
* Render nested replies recursively |
||||
*/ |
||||
function renderReplies(parentId: string, repliesMap: Map<string, NDKEvent[]>, level: number = 0) { |
||||
const replies = repliesMap.get(parentId) || []; |
||||
return replies; |
||||
} |
||||
|
||||
/** |
||||
* Copy nevent to clipboard |
||||
*/ |
||||
async function copyNevent(event: NDKEvent) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ |
||||
id: event.id, |
||||
author: event.pubkey, |
||||
kind: event.kind, |
||||
}); |
||||
await navigator.clipboard.writeText(nevent); |
||||
console.log('Copied nevent to clipboard:', nevent); |
||||
} catch (err) { |
||||
console.error('Failed to copy nevent:', err); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Pre-fetch profiles for all comment authors |
||||
*/ |
||||
$effect(() => { |
||||
const uniquePubkeys = new Set(comments.map(c => c.pubkey)); |
||||
for (const pubkey of uniquePubkeys) { |
||||
fetchProfile(pubkey); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#if visible && threadStructure.rootComments.length > 0} |
||||
<div class="space-y-1"> |
||||
{#each threadStructure.rootComments as rootComment (rootComment.id)} |
||||
{@const replyCount = countReplies(rootComment.id, threadStructure.repliesByParent)} |
||||
{@const isExpanded = expandedThreads.has(rootComment.id)} |
||||
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm"> |
||||
<!-- Multi-row collapsed view --> |
||||
{#if !isExpanded} |
||||
<div class="flex gap-2 px-3 py-2 text-sm"> |
||||
<button |
||||
class="flex-shrink-0 mt-1" |
||||
onclick={() => toggleThread(rootComment.id)} |
||||
aria-label="Expand comment" |
||||
> |
||||
<ChevronRightOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
||||
</button> |
||||
|
||||
<div class="flex-1 min-w-0"> |
||||
<p class="line-clamp-3 text-gray-700 dark:text-gray-300 mb-1"> |
||||
{rootComment.content} |
||||
</p> |
||||
<div class="flex items-center gap-2 text-xs"> |
||||
<button |
||||
class="text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 transition-colors" |
||||
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }} |
||||
title="Copy nevent to clipboard" |
||||
> |
||||
{getDisplayName(rootComment.pubkey)} |
||||
</button> |
||||
{#if replyCount > 0} |
||||
<span class="text-gray-400 dark:text-gray-500">•</span> |
||||
<span class="text-blue-600 dark:text-blue-400"> |
||||
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{:else} |
||||
<!-- Expanded view --> |
||||
<div class="flex flex-col"> |
||||
<!-- Expanded header row --> |
||||
<div class="flex items-center gap-2 px-3 py-2 text-sm border-b border-gray-200 dark:border-gray-700"> |
||||
<button |
||||
class="flex-shrink-0" |
||||
onclick={() => toggleThread(rootComment.id)} |
||||
aria-label="Collapse comment" |
||||
> |
||||
<ChevronDownOutline class="w-3 h-3 text-gray-600 dark:text-gray-400" /> |
||||
</button> |
||||
|
||||
<button |
||||
class="flex-shrink-0 font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
||||
onclick={(e) => { e.stopPropagation(); copyNevent(rootComment); }} |
||||
title="Copy nevent to clipboard" |
||||
> |
||||
{getDisplayName(rootComment.pubkey)} |
||||
</button> |
||||
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{formatTimestamp(rootComment.created_at || 0)} |
||||
</span> |
||||
|
||||
{#if replyCount > 0} |
||||
<span class="text-xs text-blue-600 dark:text-blue-400"> |
||||
{replyCount} {replyCount === 1 ? 'reply' : 'replies'} |
||||
</span> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Full content --> |
||||
<div class="px-3 py-3"> |
||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none mb-3"> |
||||
{@render basicMarkup(rootComment.content)} |
||||
</div> |
||||
|
||||
<!-- Replies --> |
||||
{#if replyCount > 0} |
||||
<div class="pl-4 border-l-2 border-gray-200 dark:border-gray-600 space-y-2"> |
||||
{#each renderReplies(rootComment.id, threadStructure.repliesByParent) as reply (reply.id)} |
||||
<div class="bg-gray-50 dark:bg-gray-700/30 rounded p-3"> |
||||
<div class="flex items-baseline gap-2 mb-2"> |
||||
<button |
||||
class="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
||||
onclick={(e) => { e.stopPropagation(); copyNevent(reply); }} |
||||
title="Copy nevent to clipboard" |
||||
> |
||||
{getDisplayName(reply.pubkey)} |
||||
</button> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{formatTimestamp(reply.created_at || 0)} |
||||
</span> |
||||
</div> |
||||
<div class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm dark:prose-invert max-w-none"> |
||||
{@render basicMarkup(reply.content)} |
||||
</div> |
||||
|
||||
<!-- Nested replies (one level deep) --> |
||||
{#each renderReplies(reply.id, threadStructure.repliesByParent) as nestedReply (nestedReply.id)} |
||||
<div class="ml-4 mt-2 bg-gray-100 dark:bg-gray-600/30 rounded p-2"> |
||||
<div class="flex items-baseline gap-2 mb-1"> |
||||
<button |
||||
class="text-xs font-medium text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" |
||||
onclick={(e) => { e.stopPropagation(); copyNevent(nestedReply); }} |
||||
title="Copy nevent to clipboard" |
||||
> |
||||
{getDisplayName(nestedReply.pubkey)} |
||||
</button> |
||||
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||
{formatTimestamp(nestedReply.created_at || 0)} |
||||
</span> |
||||
</div> |
||||
<div class="text-xs text-gray-700 dark:text-gray-300"> |
||||
{@render basicMarkup(nestedReply.content)} |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
/* Ensure proper text wrapping */ |
||||
.prose { |
||||
word-wrap: break-word; |
||||
overflow-wrap: break-word; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import type NDK from "@nostr-dev-kit/ndk"; |
||||
|
||||
/** |
||||
* Generate mock comment data for testing comment UI and threading |
||||
* Creates realistic thread structures with root comments and nested replies |
||||
*/ |
||||
|
||||
const loremIpsumComments = [ |
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", |
||||
"Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", |
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", |
||||
"Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", |
||||
"Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.", |
||||
"Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores.", |
||||
"Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.", |
||||
"At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.", |
||||
"Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio.", |
||||
"Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae.", |
||||
]; |
||||
|
||||
const loremIpsumReplies = [ |
||||
"Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.", |
||||
"Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.", |
||||
"Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat.", |
||||
"Omnis voluptas assumenda est, omnis dolor repellendus.", |
||||
"Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur.", |
||||
"Facere possimus, omnis voluptas assumenda est.", |
||||
"Sed ut perspiciatis unde omnis iste natus error.", |
||||
"Accusantium doloremque laudantium, totam rem aperiam.", |
||||
]; |
||||
|
||||
const mockPubkeys = [ |
||||
"a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd", |
||||
"b2c3d4e5f6789012345678901234567890123456789012345678901234abcde", |
||||
"c3d4e5f6789012345678901234567890123456789012345678901234abcdef0", |
||||
"d4e5f6789012345678901234567890123456789012345678901234abcdef01", |
||||
]; |
||||
|
||||
/** |
||||
* Create a mock NDKEvent that looks like a real comment |
||||
*/ |
||||
function createMockComment( |
||||
id: string, |
||||
content: string, |
||||
pubkey: string, |
||||
targetAddress: string, |
||||
createdAt: number, |
||||
replyToId?: string, |
||||
replyToAuthor?: string |
||||
): any { |
||||
const tags: string[][] = [ |
||||
["A", targetAddress, "wss://relay.damus.io", pubkey], |
||||
["K", "30041"], |
||||
["P", pubkey, "wss://relay.damus.io"], |
||||
["a", targetAddress, "wss://relay.damus.io"], |
||||
["k", "30041"], |
||||
["p", pubkey, "wss://relay.damus.io"], |
||||
]; |
||||
|
||||
if (replyToId && replyToAuthor) { |
||||
tags.push(["e", replyToId, "wss://relay.damus.io", "reply"]); |
||||
tags.push(["p", replyToAuthor, "wss://relay.damus.io"]); |
||||
} |
||||
|
||||
// Return a plain object that matches NDKEvent structure
|
||||
return { |
||||
id, |
||||
kind: 1111, |
||||
pubkey, |
||||
created_at: createdAt, |
||||
content, |
||||
tags, |
||||
sig: "mock-signature-" + id, |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Generate mock comment thread structure |
||||
* @param sectionAddress - The section address to attach comments to |
||||
* @param numRootComments - Number of root comments to generate (default: 3) |
||||
* @param numRepliesPerThread - Number of replies per thread (default: 2) |
||||
* @returns Array of mock comment objects |
||||
*/ |
||||
export function generateMockComments( |
||||
sectionAddress: string, |
||||
numRootComments: number = 3, |
||||
numRepliesPerThread: number = 2 |
||||
): any[] { |
||||
const comments: any[] = []; |
||||
const now = Math.floor(Date.now() / 1000); |
||||
let commentIndex = 0; |
||||
|
||||
// Generate root comments
|
||||
for (let i = 0; i < numRootComments; i++) { |
||||
const rootId = `mock-root-${i}-${Date.now()}`; |
||||
const rootPubkey = mockPubkeys[i % mockPubkeys.length]; |
||||
const rootContent = loremIpsumComments[i % loremIpsumComments.length]; |
||||
const rootCreatedAt = now - (numRootComments - i) * 3600; // Stagger by hours
|
||||
|
||||
const rootComment = createMockComment( |
||||
rootId, |
||||
rootContent, |
||||
rootPubkey, |
||||
sectionAddress, |
||||
rootCreatedAt |
||||
); |
||||
|
||||
comments.push(rootComment); |
||||
|
||||
// Generate replies to this root comment
|
||||
for (let j = 0; j < numRepliesPerThread; j++) { |
||||
const replyId = `mock-reply-${i}-${j}-${Date.now()}`; |
||||
const replyPubkey = mockPubkeys[(i + j + 1) % mockPubkeys.length]; |
||||
const replyContent = loremIpsumReplies[commentIndex % loremIpsumReplies.length]; |
||||
const replyCreatedAt = rootCreatedAt + (j + 1) * 1800; // 30 min after each
|
||||
|
||||
const reply = createMockComment( |
||||
replyId, |
||||
replyContent, |
||||
replyPubkey, |
||||
sectionAddress, |
||||
replyCreatedAt, |
||||
rootId, |
||||
rootPubkey |
||||
); |
||||
|
||||
comments.push(reply); |
||||
|
||||
// Optionally add a nested reply (reply to reply)
|
||||
if (j === 0 && i < 2) { |
||||
const nestedId = `mock-nested-${i}-${j}-${Date.now()}`; |
||||
const nestedPubkey = mockPubkeys[(i + j + 2) % mockPubkeys.length]; |
||||
const nestedContent = loremIpsumReplies[(commentIndex + 1) % loremIpsumReplies.length]; |
||||
const nestedCreatedAt = replyCreatedAt + 900; // 15 min after reply
|
||||
|
||||
const nested = createMockComment( |
||||
nestedId, |
||||
nestedContent, |
||||
nestedPubkey, |
||||
sectionAddress, |
||||
nestedCreatedAt, |
||||
replyId, |
||||
replyPubkey |
||||
); |
||||
|
||||
comments.push(nested); |
||||
} |
||||
|
||||
commentIndex++; |
||||
} |
||||
} |
||||
|
||||
return comments; |
||||
} |
||||
|
||||
/** |
||||
* Generate mock comments for multiple sections |
||||
* @param sectionAddresses - Array of section addresses |
||||
* @returns Array of all mock comments across all sections |
||||
*/ |
||||
export function generateMockCommentsForSections( |
||||
sectionAddresses: string[] |
||||
): any[] { |
||||
const allComments: any[] = []; |
||||
|
||||
sectionAddresses.forEach((address, index) => { |
||||
// Vary the number of comments per section
|
||||
const numRoot = 2 + (index % 3); // 2-4 root comments
|
||||
const numReplies = 1 + (index % 2); // 1-2 replies per thread
|
||||
|
||||
const sectionComments = generateMockComments(address, numRoot, numReplies); |
||||
allComments.push(...sectionComments); |
||||
}); |
||||
|
||||
return allComments; |
||||
} |
||||
@ -0,0 +1,911 @@
@@ -0,0 +1,911 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
||||
import { get, writable } from 'svelte/store'; |
||||
import type { UserState } from '../../src/lib/stores/userStore'; |
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
|
||||
// Mock userStore
|
||||
const createMockUserStore = (signedIn: boolean = false) => { |
||||
const store = writable<UserState>({ |
||||
pubkey: signedIn ? 'a'.repeat(64) : null, |
||||
npub: signedIn ? 'npub1test' : null, |
||||
profile: signedIn ? { |
||||
name: 'Test User', |
||||
displayName: 'Test User', |
||||
picture: 'https://example.com/avatar.jpg', |
||||
} : null, |
||||
relays: { inbox: [], outbox: [] }, |
||||
loginMethod: signedIn ? 'extension' : null, |
||||
ndkUser: null, |
||||
signer: signedIn ? { sign: vi.fn() } as any : null, |
||||
signedIn, |
||||
}); |
||||
return store; |
||||
}; |
||||
|
||||
// Mock activeOutboxRelays
|
||||
const mockActiveOutboxRelays = writable<string[]>(['wss://relay.example.com']); |
||||
|
||||
// Mock NDK
|
||||
const createMockNDK = () => ({ |
||||
fetchEvent: vi.fn(), |
||||
publish: vi.fn(), |
||||
}); |
||||
|
||||
describe('CommentButton - Address Parsing', () => { |
||||
it('parses valid event address correctly', () => { |
||||
const address = '30041:abc123def456:my-article'; |
||||
const parts = address.split(':'); |
||||
|
||||
expect(parts).toHaveLength(3); |
||||
|
||||
const [kindStr, pubkey, dTag] = parts; |
||||
const kind = parseInt(kindStr); |
||||
|
||||
expect(kind).toBe(30041); |
||||
expect(pubkey).toBe('abc123def456'); |
||||
expect(dTag).toBe('my-article'); |
||||
expect(isNaN(kind)).toBe(false); |
||||
}); |
||||
|
||||
it('handles dTag with colons correctly', () => { |
||||
const address = '30041:abc123:article:with:colons'; |
||||
const parts = address.split(':'); |
||||
|
||||
expect(parts.length).toBeGreaterThanOrEqual(3); |
||||
|
||||
const [kindStr, pubkey, ...dTagParts] = parts; |
||||
const dTag = dTagParts.join(':'); |
||||
|
||||
expect(parseInt(kindStr)).toBe(30041); |
||||
expect(pubkey).toBe('abc123'); |
||||
expect(dTag).toBe('article:with:colons'); |
||||
}); |
||||
|
||||
it('returns null for invalid address format (too few parts)', () => { |
||||
const address = '30041:abc123'; |
||||
const parts = address.split(':'); |
||||
|
||||
if (parts.length !== 3) { |
||||
expect(parts.length).toBeLessThan(3); |
||||
} |
||||
}); |
||||
|
||||
it('returns null for invalid address format (invalid kind)', () => { |
||||
const address = 'invalid:abc123:dtag'; |
||||
const parts = address.split(':'); |
||||
const kind = parseInt(parts[0]); |
||||
|
||||
expect(isNaN(kind)).toBe(true); |
||||
}); |
||||
|
||||
it('parses different publication kinds correctly', () => { |
||||
const addresses = [ |
||||
'30040:pubkey:section-id', // Zettel section
|
||||
'30041:pubkey:article-id', // Long-form article
|
||||
'30818:pubkey:wiki-id', // Wiki article
|
||||
'30023:pubkey:blog-id', // Blog post
|
||||
]; |
||||
|
||||
addresses.forEach(address => { |
||||
const parts = address.split(':'); |
||||
const kind = parseInt(parts[0]); |
||||
|
||||
expect(isNaN(kind)).toBe(false); |
||||
expect(kind).toBeGreaterThan(0); |
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - NIP-22 Event Creation', () => { |
||||
let mockNDK: any; |
||||
let mockUserStore: any; |
||||
let mockActiveOutboxRelays: any; |
||||
|
||||
beforeEach(() => { |
||||
mockNDK = createMockNDK(); |
||||
mockUserStore = createMockUserStore(true); |
||||
mockActiveOutboxRelays = writable(['wss://relay.example.com']); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
it('creates kind 1111 comment event', async () => { |
||||
const address = '30041:' + 'a'.repeat(64) + ':my-article'; |
||||
const content = 'This is my comment'; |
||||
|
||||
// Mock event creation
|
||||
const commentEvent = new NDKEvent(mockNDK); |
||||
commentEvent.kind = 1111; |
||||
commentEvent.content = content; |
||||
|
||||
expect(commentEvent.kind).toBe(1111); |
||||
expect(commentEvent.content).toBe(content); |
||||
}); |
||||
|
||||
it('includes correct uppercase tags (A, K, P) for root', () => { |
||||
const address = '30041:' + 'b'.repeat(64) + ':article-id'; |
||||
const authorPubkey = 'b'.repeat(64); |
||||
const kind = 30041; |
||||
const relayHint = 'wss://relay.example.com'; |
||||
|
||||
const tags = [ |
||||
['A', address, relayHint, authorPubkey], |
||||
['K', kind.toString()], |
||||
['P', authorPubkey, relayHint], |
||||
]; |
||||
|
||||
// Verify uppercase root tags
|
||||
expect(tags[0][0]).toBe('A'); |
||||
expect(tags[0][1]).toBe(address); |
||||
expect(tags[0][2]).toBe(relayHint); |
||||
expect(tags[0][3]).toBe(authorPubkey); |
||||
|
||||
expect(tags[1][0]).toBe('K'); |
||||
expect(tags[1][1]).toBe(kind.toString()); |
||||
|
||||
expect(tags[2][0]).toBe('P'); |
||||
expect(tags[2][1]).toBe(authorPubkey); |
||||
expect(tags[2][2]).toBe(relayHint); |
||||
}); |
||||
|
||||
it('includes correct lowercase tags (a, k, p) for parent', () => { |
||||
const address = '30041:' + 'c'.repeat(64) + ':article-id'; |
||||
const authorPubkey = 'c'.repeat(64); |
||||
const kind = 30041; |
||||
const relayHint = 'wss://relay.example.com'; |
||||
|
||||
const tags = [ |
||||
['a', address, relayHint], |
||||
['k', kind.toString()], |
||||
['p', authorPubkey, relayHint], |
||||
]; |
||||
|
||||
// Verify lowercase parent tags
|
||||
expect(tags[0][0]).toBe('a'); |
||||
expect(tags[0][1]).toBe(address); |
||||
expect(tags[0][2]).toBe(relayHint); |
||||
|
||||
expect(tags[1][0]).toBe('k'); |
||||
expect(tags[1][1]).toBe(kind.toString()); |
||||
|
||||
expect(tags[2][0]).toBe('p'); |
||||
expect(tags[2][1]).toBe(authorPubkey); |
||||
expect(tags[2][2]).toBe(relayHint); |
||||
}); |
||||
|
||||
it('includes e tag with event ID when available', () => { |
||||
const eventId = 'd'.repeat(64); |
||||
const relayHint = 'wss://relay.example.com'; |
||||
|
||||
const eTag = ['e', eventId, relayHint]; |
||||
|
||||
expect(eTag[0]).toBe('e'); |
||||
expect(eTag[1]).toBe(eventId); |
||||
expect(eTag[2]).toBe(relayHint); |
||||
expect(eTag[1]).toHaveLength(64); |
||||
}); |
||||
|
||||
it('creates complete NIP-22 tag structure', () => { |
||||
const address = '30041:' + 'e'.repeat(64) + ':test-article'; |
||||
const authorPubkey = 'e'.repeat(64); |
||||
const kind = 30041; |
||||
const eventId = 'f'.repeat(64); |
||||
const relayHint = 'wss://relay.example.com'; |
||||
|
||||
const tags = [ |
||||
// Root scope - uppercase tags
|
||||
['A', address, relayHint, authorPubkey], |
||||
['K', kind.toString()], |
||||
['P', authorPubkey, relayHint], |
||||
|
||||
// Parent scope - lowercase tags
|
||||
['a', address, relayHint], |
||||
['k', kind.toString()], |
||||
['p', authorPubkey, relayHint], |
||||
|
||||
// Event ID
|
||||
['e', eventId, relayHint], |
||||
]; |
||||
|
||||
// Verify all tags are present
|
||||
expect(tags).toHaveLength(7); |
||||
|
||||
// Verify root tags
|
||||
expect(tags.filter(t => t[0] === 'A')).toHaveLength(1); |
||||
expect(tags.filter(t => t[0] === 'K')).toHaveLength(1); |
||||
expect(tags.filter(t => t[0] === 'P')).toHaveLength(1); |
||||
|
||||
// Verify parent tags
|
||||
expect(tags.filter(t => t[0] === 'a')).toHaveLength(1); |
||||
expect(tags.filter(t => t[0] === 'k')).toHaveLength(1); |
||||
expect(tags.filter(t => t[0] === 'p')).toHaveLength(1); |
||||
|
||||
// Verify event tag
|
||||
expect(tags.filter(t => t[0] === 'e')).toHaveLength(1); |
||||
}); |
||||
|
||||
it('uses correct relay hints from activeOutboxRelays', () => { |
||||
const relays = get(mockActiveOutboxRelays); |
||||
const relayHint = relays[0]; |
||||
|
||||
expect(relayHint).toBe('wss://relay.example.com'); |
||||
expect(relays).toHaveLength(1); |
||||
}); |
||||
|
||||
it('handles multiple outbox relays correctly', () => { |
||||
const multipleRelays = writable([ |
||||
'wss://relay1.example.com', |
||||
'wss://relay2.example.com', |
||||
'wss://relay3.example.com', |
||||
]); |
||||
|
||||
const relays = get(multipleRelays); |
||||
const relayHint = relays[0]; |
||||
|
||||
expect(relayHint).toBe('wss://relay1.example.com'); |
||||
expect(relays).toHaveLength(3); |
||||
}); |
||||
|
||||
it('handles empty relay list gracefully', () => { |
||||
const emptyRelays = writable<string[]>([]); |
||||
const relays = get(emptyRelays); |
||||
const relayHint = relays[0] || ''; |
||||
|
||||
expect(relayHint).toBe(''); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - Event Signing and Publishing', () => { |
||||
let mockNDK: any; |
||||
let mockSigner: any; |
||||
|
||||
beforeEach(() => { |
||||
mockNDK = createMockNDK(); |
||||
mockSigner = { |
||||
sign: vi.fn().mockResolvedValue(undefined), |
||||
}; |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
it('signs event with user signer', async () => { |
||||
const commentEvent = new NDKEvent(mockNDK); |
||||
commentEvent.kind = 1111; |
||||
commentEvent.content = 'Test comment'; |
||||
|
||||
await mockSigner.sign(commentEvent); |
||||
|
||||
expect(mockSigner.sign).toHaveBeenCalledWith(commentEvent); |
||||
expect(mockSigner.sign).toHaveBeenCalledTimes(1); |
||||
}); |
||||
|
||||
it('publishes to outbox relays', async () => { |
||||
const publishMock = vi.fn().mockResolvedValue(new Set(['wss://relay.example.com'])); |
||||
|
||||
const commentEvent = new NDKEvent(mockNDK); |
||||
commentEvent.publish = publishMock; |
||||
|
||||
const publishedRelays = await commentEvent.publish(); |
||||
|
||||
expect(publishMock).toHaveBeenCalled(); |
||||
expect(publishedRelays.size).toBeGreaterThan(0); |
||||
}); |
||||
|
||||
it('handles publishing errors gracefully', async () => { |
||||
const publishMock = vi.fn().mockResolvedValue(new Set()); |
||||
|
||||
const commentEvent = new NDKEvent(mockNDK); |
||||
commentEvent.publish = publishMock; |
||||
|
||||
const publishedRelays = await commentEvent.publish(); |
||||
|
||||
expect(publishedRelays.size).toBe(0); |
||||
}); |
||||
|
||||
it('throws error when publishing fails', async () => { |
||||
const publishMock = vi.fn().mockRejectedValue(new Error('Network error')); |
||||
|
||||
const commentEvent = new NDKEvent(mockNDK); |
||||
commentEvent.publish = publishMock; |
||||
|
||||
await expect(commentEvent.publish()).rejects.toThrow('Network error'); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - User Authentication', () => { |
||||
it('requires user to be signed in', () => { |
||||
const signedOutStore = createMockUserStore(false); |
||||
const user = get(signedOutStore); |
||||
|
||||
expect(user.signedIn).toBe(false); |
||||
expect(user.signer).toBeNull(); |
||||
}); |
||||
|
||||
it('shows error when user is not signed in', () => { |
||||
const signedOutStore = createMockUserStore(false); |
||||
const user = get(signedOutStore); |
||||
|
||||
if (!user.signedIn || !user.signer) { |
||||
const error = 'You must be signed in to comment'; |
||||
expect(error).toBe('You must be signed in to comment'); |
||||
} |
||||
}); |
||||
|
||||
it('allows commenting when user is signed in', () => { |
||||
const signedInStore = createMockUserStore(true); |
||||
const user = get(signedInStore); |
||||
|
||||
expect(user.signedIn).toBe(true); |
||||
expect(user.signer).not.toBeNull(); |
||||
}); |
||||
|
||||
it('displays user profile information when signed in', () => { |
||||
const signedInStore = createMockUserStore(true); |
||||
const user = get(signedInStore); |
||||
|
||||
expect(user.profile).not.toBeNull(); |
||||
expect(user.profile?.displayName).toBe('Test User'); |
||||
expect(user.profile?.picture).toBe('https://example.com/avatar.jpg'); |
||||
}); |
||||
|
||||
it('handles missing user profile gracefully', () => { |
||||
const storeWithoutProfile = writable<UserState>({ |
||||
pubkey: 'a'.repeat(64), |
||||
npub: 'npub1test', |
||||
profile: null, |
||||
relays: { inbox: [], outbox: [] }, |
||||
loginMethod: 'extension', |
||||
ndkUser: null, |
||||
signer: { sign: vi.fn() } as any, |
||||
signedIn: true, |
||||
}); |
||||
|
||||
const user = get(storeWithoutProfile); |
||||
const displayName = user.profile?.displayName || user.profile?.name || 'Anonymous'; |
||||
|
||||
expect(displayName).toBe('Anonymous'); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - User Interactions', () => { |
||||
it('prevents submission of empty comment', () => { |
||||
const commentContent = ''; |
||||
const isEmpty = !commentContent.trim(); |
||||
|
||||
expect(isEmpty).toBe(true); |
||||
}); |
||||
|
||||
it('allows submission of non-empty comment', () => { |
||||
const commentContent = 'This is a valid comment'; |
||||
const isEmpty = !commentContent.trim(); |
||||
|
||||
expect(isEmpty).toBe(false); |
||||
}); |
||||
|
||||
it('handles whitespace-only comments as empty', () => { |
||||
const commentContent = ' \n\t '; |
||||
const isEmpty = !commentContent.trim(); |
||||
|
||||
expect(isEmpty).toBe(true); |
||||
}); |
||||
|
||||
it('clears input after successful comment', () => { |
||||
let commentContent = 'This is my comment'; |
||||
|
||||
// Simulate successful submission
|
||||
commentContent = ''; |
||||
|
||||
expect(commentContent).toBe(''); |
||||
}); |
||||
|
||||
it('closes comment UI after successful posting', () => { |
||||
let showCommentUI = true; |
||||
|
||||
// Simulate successful post with delay
|
||||
setTimeout(() => { |
||||
showCommentUI = false; |
||||
}, 0); |
||||
|
||||
// Initially still open
|
||||
expect(showCommentUI).toBe(true); |
||||
}); |
||||
|
||||
it('calls onCommentPosted callback when provided', () => { |
||||
const onCommentPosted = vi.fn(); |
||||
|
||||
// Simulate successful comment post
|
||||
onCommentPosted(); |
||||
|
||||
expect(onCommentPosted).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('does not error when onCommentPosted is not provided', () => { |
||||
const onCommentPosted = undefined; |
||||
|
||||
expect(() => { |
||||
if (onCommentPosted) { |
||||
onCommentPosted(); |
||||
} |
||||
}).not.toThrow(); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - UI State Management', () => { |
||||
it('button is hidden by default', () => { |
||||
const sectionHovered = false; |
||||
const showCommentUI = false; |
||||
const visible = sectionHovered || showCommentUI; |
||||
|
||||
expect(visible).toBe(false); |
||||
}); |
||||
|
||||
it('button appears on section hover', () => { |
||||
const sectionHovered = true; |
||||
const showCommentUI = false; |
||||
const visible = sectionHovered || showCommentUI; |
||||
|
||||
expect(visible).toBe(true); |
||||
}); |
||||
|
||||
it('button remains visible when comment UI is shown', () => { |
||||
const sectionHovered = false; |
||||
const showCommentUI = true; |
||||
const visible = sectionHovered || showCommentUI; |
||||
|
||||
expect(visible).toBe(true); |
||||
}); |
||||
|
||||
it('toggles comment UI when button is clicked', () => { |
||||
let showCommentUI = false; |
||||
|
||||
// Simulate button click
|
||||
showCommentUI = !showCommentUI; |
||||
expect(showCommentUI).toBe(true); |
||||
|
||||
// Click again
|
||||
showCommentUI = !showCommentUI; |
||||
expect(showCommentUI).toBe(false); |
||||
}); |
||||
|
||||
it('resets error state when toggling UI', () => { |
||||
let error: string | null = 'Previous error'; |
||||
let success = true; |
||||
|
||||
// Simulate UI toggle
|
||||
error = null; |
||||
success = false; |
||||
|
||||
expect(error).toBeNull(); |
||||
expect(success).toBe(false); |
||||
}); |
||||
|
||||
it('shows error message when present', () => { |
||||
const error = 'Failed to post comment'; |
||||
|
||||
expect(error).toBeDefined(); |
||||
expect(error.length).toBeGreaterThan(0); |
||||
}); |
||||
|
||||
it('shows success message after posting', () => { |
||||
const success = true; |
||||
const successMessage = 'Comment posted successfully!'; |
||||
|
||||
if (success) { |
||||
expect(successMessage).toBe('Comment posted successfully!'); |
||||
} |
||||
}); |
||||
|
||||
it('disables submit button when submitting', () => { |
||||
const isSubmitting = true; |
||||
const disabled = isSubmitting; |
||||
|
||||
expect(disabled).toBe(true); |
||||
}); |
||||
|
||||
it('disables submit button when comment is empty', () => { |
||||
const commentContent = ''; |
||||
const isSubmitting = false; |
||||
const disabled = isSubmitting || !commentContent.trim(); |
||||
|
||||
expect(disabled).toBe(true); |
||||
}); |
||||
|
||||
it('enables submit button when comment is valid', () => { |
||||
const commentContent = 'Valid comment'; |
||||
const isSubmitting = false; |
||||
const disabled = isSubmitting || !commentContent.trim(); |
||||
|
||||
expect(disabled).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - Edge Cases', () => { |
||||
it('handles invalid address format gracefully', () => { |
||||
const invalidAddresses = [ |
||||
'', |
||||
'invalid', |
||||
'30041:', |
||||
':pubkey:dtag', |
||||
'30041:pubkey', |
||||
'not-a-number:pubkey:dtag', |
||||
]; |
||||
|
||||
invalidAddresses.forEach(address => { |
||||
const parts = address.split(':'); |
||||
const isValid = parts.length === 3 && !isNaN(parseInt(parts[0])); |
||||
|
||||
expect(isValid).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
it('handles network errors during event fetch', async () => { |
||||
const mockNDK = { |
||||
fetchEvent: vi.fn().mockRejectedValue(new Error('Network error')), |
||||
}; |
||||
|
||||
let eventId = ''; |
||||
try { |
||||
await mockNDK.fetchEvent({}); |
||||
} catch (err) { |
||||
// Handle gracefully, continue without event ID
|
||||
eventId = ''; |
||||
} |
||||
|
||||
expect(eventId).toBe(''); |
||||
}); |
||||
|
||||
it('handles missing relay information', () => { |
||||
const emptyRelays: string[] = []; |
||||
const relayHint = emptyRelays[0] || ''; |
||||
|
||||
expect(relayHint).toBe(''); |
||||
}); |
||||
|
||||
it('handles very long comment text without truncation', () => { |
||||
const longComment = 'a'.repeat(10000); |
||||
const content = longComment; |
||||
|
||||
expect(content.length).toBe(10000); |
||||
expect(content).toBe(longComment); |
||||
}); |
||||
|
||||
it('handles special characters in comments', () => { |
||||
const specialComments = [ |
||||
'Comment with "quotes"', |
||||
'Comment with emoji 😊', |
||||
'Comment with\nnewlines', |
||||
'Comment with\ttabs', |
||||
'Comment with <html> tags', |
||||
'Comment with & ampersands', |
||||
]; |
||||
|
||||
specialComments.forEach(comment => { |
||||
expect(comment.length).toBeGreaterThan(0); |
||||
expect(typeof comment).toBe('string'); |
||||
}); |
||||
}); |
||||
|
||||
it('handles event creation failure', async () => { |
||||
const address = 'invalid:address'; |
||||
const parts = address.split(':'); |
||||
|
||||
if (parts.length !== 3) { |
||||
const error = 'Invalid event address'; |
||||
expect(error).toBe('Invalid event address'); |
||||
} |
||||
}); |
||||
|
||||
it('handles signing errors', async () => { |
||||
const mockSigner = { |
||||
sign: vi.fn().mockRejectedValue(new Error('Signing failed')), |
||||
}; |
||||
|
||||
const event = { kind: 1111, content: 'test' }; |
||||
|
||||
await expect(mockSigner.sign(event)).rejects.toThrow('Signing failed'); |
||||
}); |
||||
|
||||
it('handles publish failure when no relays accept event', async () => { |
||||
const publishMock = vi.fn().mockResolvedValue(new Set()); |
||||
|
||||
const relaySet = await publishMock(); |
||||
|
||||
if (relaySet.size === 0) { |
||||
const error = 'Failed to publish to any relays'; |
||||
expect(error).toBe('Failed to publish to any relays'); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - Cancel Functionality', () => { |
||||
it('clears comment content when canceling', () => { |
||||
let commentContent = 'This comment will be canceled'; |
||||
|
||||
// Simulate cancel
|
||||
commentContent = ''; |
||||
|
||||
expect(commentContent).toBe(''); |
||||
}); |
||||
|
||||
it('closes comment UI when canceling', () => { |
||||
let showCommentUI = true; |
||||
|
||||
// Simulate cancel
|
||||
showCommentUI = false; |
||||
|
||||
expect(showCommentUI).toBe(false); |
||||
}); |
||||
|
||||
it('clears error state when canceling', () => { |
||||
let error: string | null = 'Some error'; |
||||
|
||||
// Simulate cancel
|
||||
error = null; |
||||
|
||||
expect(error).toBeNull(); |
||||
}); |
||||
|
||||
it('clears success state when canceling', () => { |
||||
let success = true; |
||||
|
||||
// Simulate cancel
|
||||
success = false; |
||||
|
||||
expect(success).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - Event Fetching', () => { |
||||
let mockNDK: any; |
||||
|
||||
beforeEach(() => { |
||||
mockNDK = createMockNDK(); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
vi.clearAllMocks(); |
||||
}); |
||||
|
||||
it('fetches target event to get event ID', async () => { |
||||
const address = '30041:' + 'a'.repeat(64) + ':article'; |
||||
const parts = address.split(':'); |
||||
const [kindStr, authorPubkey, dTag] = parts; |
||||
const kind = parseInt(kindStr); |
||||
|
||||
const mockEvent = { |
||||
id: 'b'.repeat(64), |
||||
kind, |
||||
pubkey: authorPubkey, |
||||
tags: [['d', dTag]], |
||||
}; |
||||
|
||||
mockNDK.fetchEvent.mockResolvedValue(mockEvent); |
||||
|
||||
const targetEvent = await mockNDK.fetchEvent({ |
||||
kinds: [kind], |
||||
authors: [authorPubkey], |
||||
'#d': [dTag], |
||||
}); |
||||
|
||||
expect(mockNDK.fetchEvent).toHaveBeenCalled(); |
||||
expect(targetEvent?.id).toBe('b'.repeat(64)); |
||||
}); |
||||
|
||||
it('continues without event ID when fetch fails', async () => { |
||||
mockNDK.fetchEvent.mockRejectedValue(new Error('Fetch failed')); |
||||
|
||||
let eventId = ''; |
||||
try { |
||||
const targetEvent = await mockNDK.fetchEvent({}); |
||||
if (targetEvent) { |
||||
eventId = targetEvent.id; |
||||
} |
||||
} catch (err) { |
||||
// Continue without event ID
|
||||
eventId = ''; |
||||
} |
||||
|
||||
expect(eventId).toBe(''); |
||||
}); |
||||
|
||||
it('handles null event from fetch', async () => { |
||||
mockNDK.fetchEvent.mockResolvedValue(null); |
||||
|
||||
const targetEvent = await mockNDK.fetchEvent({}); |
||||
let eventId = ''; |
||||
|
||||
if (targetEvent) { |
||||
eventId = targetEvent.id; |
||||
} |
||||
|
||||
expect(eventId).toBe(''); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - CSS Classes and Styling', () => { |
||||
it('applies visible class when section is hovered', () => { |
||||
const sectionHovered = true; |
||||
const showCommentUI = false; |
||||
const hasVisibleClass = sectionHovered || showCommentUI; |
||||
|
||||
expect(hasVisibleClass).toBe(true); |
||||
}); |
||||
|
||||
it('removes visible class when not hovered and UI closed', () => { |
||||
const sectionHovered = false; |
||||
const showCommentUI = false; |
||||
const hasVisibleClass = sectionHovered || showCommentUI; |
||||
|
||||
expect(hasVisibleClass).toBe(false); |
||||
}); |
||||
|
||||
it('button has correct aria-label', () => { |
||||
const ariaLabel = 'Add comment'; |
||||
|
||||
expect(ariaLabel).toBe('Add comment'); |
||||
}); |
||||
|
||||
it('button has correct title attribute', () => { |
||||
const title = 'Add comment'; |
||||
|
||||
expect(title).toBe('Add comment'); |
||||
}); |
||||
|
||||
it('submit button shows loading state when submitting', () => { |
||||
const isSubmitting = true; |
||||
const buttonText = isSubmitting ? 'Posting...' : 'Post Comment'; |
||||
|
||||
expect(buttonText).toBe('Posting...'); |
||||
}); |
||||
|
||||
it('submit button shows normal state when not submitting', () => { |
||||
const isSubmitting = false; |
||||
const buttonText = isSubmitting ? 'Posting...' : 'Post Comment'; |
||||
|
||||
expect(buttonText).toBe('Post Comment'); |
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - NIP-22 Compliance', () => { |
||||
it('uses kind 1111 for comment events', () => { |
||||
const kind = 1111; |
||||
|
||||
expect(kind).toBe(1111); |
||||
}); |
||||
|
||||
it('includes all required NIP-22 tags for addressable events', () => { |
||||
const requiredRootTags = ['A', 'K', 'P']; |
||||
const requiredParentTags = ['a', 'k', 'p']; |
||||
|
||||
const tags = [ |
||||
['A', 'address', 'relay', 'pubkey'], |
||||
['K', 'kind'], |
||||
['P', 'pubkey', 'relay'], |
||||
['a', 'address', 'relay'], |
||||
['k', 'kind'], |
||||
['p', 'pubkey', 'relay'], |
||||
]; |
||||
|
||||
requiredRootTags.forEach(tag => { |
||||
expect(tags.some(t => t[0] === tag)).toBe(true); |
||||
}); |
||||
|
||||
requiredParentTags.forEach(tag => { |
||||
expect(tags.some(t => t[0] === tag)).toBe(true); |
||||
}); |
||||
}); |
||||
|
||||
it('A tag includes relay hint and author pubkey', () => { |
||||
const aTag = ['A', '30041:pubkey:dtag', 'wss://relay.com', 'pubkey']; |
||||
|
||||
expect(aTag).toHaveLength(4); |
||||
expect(aTag[0]).toBe('A'); |
||||
expect(aTag[2]).toMatch(/^wss:\/\//); |
||||
expect(aTag[3]).toBeTruthy(); |
||||
}); |
||||
|
||||
it('P tag includes relay hint', () => { |
||||
const pTag = ['P', 'pubkey', 'wss://relay.com']; |
||||
|
||||
expect(pTag).toHaveLength(3); |
||||
expect(pTag[0]).toBe('P'); |
||||
expect(pTag[2]).toMatch(/^wss:\/\//); |
||||
}); |
||||
|
||||
it('lowercase tags for parent scope match root tags', () => { |
||||
const address = '30041:pubkey:dtag'; |
||||
const kind = '30041'; |
||||
const pubkey = 'pubkey'; |
||||
const relay = 'wss://relay.com'; |
||||
|
||||
const rootTags = [ |
||||
['A', address, relay, pubkey], |
||||
['K', kind], |
||||
['P', pubkey, relay], |
||||
]; |
||||
|
||||
const parentTags = [ |
||||
['a', address, relay], |
||||
['k', kind], |
||||
['p', pubkey, relay], |
||||
]; |
||||
|
||||
// Verify parent tags match root tags (lowercase)
|
||||
expect(parentTags[0][1]).toBe(rootTags[0][1]); // address
|
||||
expect(parentTags[1][1]).toBe(rootTags[1][1]); // kind
|
||||
expect(parentTags[2][1]).toBe(rootTags[2][1]); // pubkey
|
||||
}); |
||||
}); |
||||
|
||||
describe('CommentButton - Integration Scenarios', () => { |
||||
it('complete comment flow for signed-in user', () => { |
||||
const userStore = createMockUserStore(true); |
||||
const user = get(userStore); |
||||
|
||||
// User is signed in
|
||||
expect(user.signedIn).toBe(true); |
||||
|
||||
// Comment content is valid
|
||||
const content = 'Great article!'; |
||||
expect(content.trim().length).toBeGreaterThan(0); |
||||
|
||||
// Address is valid
|
||||
const address = '30041:' + 'a'.repeat(64) + ':article'; |
||||
const parts = address.split(':'); |
||||
expect(parts.length).toBe(3); |
||||
|
||||
// Event would be created with kind 1111
|
||||
const kind = 1111; |
||||
expect(kind).toBe(1111); |
||||
}); |
||||
|
||||
it('prevents comment flow for signed-out user', () => { |
||||
const userStore = createMockUserStore(false); |
||||
const user = get(userStore); |
||||
|
||||
expect(user.signedIn).toBe(false); |
||||
|
||||
if (!user.signedIn) { |
||||
const error = 'You must be signed in to comment'; |
||||
expect(error).toBeTruthy(); |
||||
} |
||||
}); |
||||
|
||||
it('handles comment with event ID lookup', async () => { |
||||
const mockNDK = createMockNDK(); |
||||
const eventId = 'c'.repeat(64); |
||||
|
||||
mockNDK.fetchEvent.mockResolvedValue({ id: eventId }); |
||||
|
||||
const targetEvent = await mockNDK.fetchEvent({}); |
||||
|
||||
const tags = [ |
||||
['e', targetEvent.id, 'wss://relay.com'], |
||||
]; |
||||
|
||||
expect(tags[0][1]).toBe(eventId); |
||||
}); |
||||
|
||||
it('handles comment without event ID lookup', () => { |
||||
const eventId = ''; |
||||
|
||||
const tags = [ |
||||
['A', 'address', 'relay', 'pubkey'], |
||||
['K', 'kind'], |
||||
['P', 'pubkey', 'relay'], |
||||
['a', 'address', 'relay'], |
||||
['k', 'kind'], |
||||
['p', 'pubkey', 'relay'], |
||||
]; |
||||
|
||||
// No e tag should be included
|
||||
expect(tags.filter(t => t[0] === 'e')).toHaveLength(0); |
||||
|
||||
// But all other required tags should be present
|
||||
expect(tags.length).toBe(6); |
||||
}); |
||||
}); |
||||
Loading…
Reference in new issue