7 changed files with 2524 additions and 0 deletions
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
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 @@ |
|||||||
|
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