Browse Source

Merge PR #2: Comments

master
limina1 4 months ago
parent
commit
7727ae40a3
  1. 520
      src/lib/components/publications/CommentButton.svelte
  2. 282
      src/lib/components/publications/CommentLayer.svelte
  3. 280
      src/lib/components/publications/CommentPanel.svelte
  4. 323
      src/lib/components/publications/SectionComments.svelte
  5. 177
      src/lib/utils/mockCommentData.ts
  6. 31
      src/lib/utils/nostrUtils.ts
  7. 911
      tests/unit/commentButton.test.ts

520
src/lib/components/publications/CommentButton.svelte

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

282
src/lib/components/publications/CommentLayer.svelte

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

280
src/lib/components/publications/CommentPanel.svelte

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

323
src/lib/components/publications/SectionComments.svelte

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

177
src/lib/utils/mockCommentData.ts

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

31
src/lib/utils/nostrUtils.ts

@ -610,6 +610,37 @@ export async function signEvent(event: { @@ -610,6 +610,37 @@ export async function signEvent(event: {
return bytesToHex(sig);
}
/**
* Converts a pubkey to a consistent hue value (0-360) for color mapping.
* The same pubkey will always produce the same hue.
* @param pubkey The pubkey to convert (hex or npub format)
* @returns A hue value between 0 and 360
*/
export function pubkeyToHue(pubkey: string): number {
// Normalize pubkey to hex format
let hexPubkey = pubkey;
try {
if (pubkey.startsWith("npub")) {
const decoded = nip19.decode(pubkey);
if (decoded.type === "npub") {
hexPubkey = decoded.data as string;
}
}
} catch {
// If decode fails, use the original pubkey
}
// Hash the pubkey using SHA-256
const hash = sha256(hexPubkey);
// Use the first 4 bytes to generate a number
const num = (hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3];
// Map to 0-360 range
return Math.abs(num) % 360;
}
/**
* Prefixes Nostr addresses (npub, nprofile, nevent, naddr, note, etc.) with "nostr:"
* if they are not already prefixed and are not part of a hyperlink

911
tests/unit/commentButton.test.ts

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