2 changed files with 319 additions and 0 deletions
@ -0,0 +1,316 @@
@@ -0,0 +1,316 @@
|
||||
<script lang="ts"> |
||||
import { Button, P, Heading } from "flowbite-svelte"; |
||||
import { getUserMetadata } from "$lib/utils/nostrUtils"; |
||||
import { neventEncode } from "$lib/utils"; |
||||
import { activeInboxRelays, ndkInstance } from "$lib/ndk"; |
||||
import { goto } from "$app/navigation"; |
||||
import { onMount } from "svelte"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
|
||||
const { event } = $props<{ event: NDKEvent }>(); |
||||
|
||||
// State for comments and threading |
||||
let comments: NDKEvent[] = $state([]); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let activeSub: any = null; |
||||
|
||||
// Profile cache for comment authors |
||||
let profileCache = $state(new Map<string, any>()); |
||||
|
||||
interface CommentNode { |
||||
event: NDKEvent; |
||||
children: CommentNode[]; |
||||
level: number; |
||||
} |
||||
|
||||
// AI-NOTE: 2025-01-08 - Clean threaded comment implementation |
||||
// This component fetches and displays threaded comments with proper hierarchy |
||||
function fetchComments() { |
||||
if (!event?.id) return; |
||||
|
||||
loading = true; |
||||
error = null; |
||||
|
||||
// Clear previous comments |
||||
comments = []; |
||||
|
||||
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`); |
||||
|
||||
// Subscribe to comments that reference this event |
||||
activeSub = $ndkInstance.subscribe({ |
||||
kinds: [1, 1111], // Text notes and comments |
||||
"#e": [event.id], // Events that reference this event |
||||
}); |
||||
|
||||
const timeout = setTimeout(() => { |
||||
if (activeSub) { |
||||
activeSub.stop(); |
||||
activeSub = null; |
||||
} |
||||
loading = false; |
||||
}, 10000); // 10 second timeout |
||||
|
||||
activeSub.on("event", (commentEvent: NDKEvent) => { |
||||
// Only add if we haven't seen this event ID yet |
||||
if (!comments.find(c => c.id === commentEvent.id)) { |
||||
comments = [...comments, commentEvent]; |
||||
console.log(`[CommentViewer] Found comment: ${commentEvent.id}`); |
||||
|
||||
// Fetch profile for the comment author |
||||
if (commentEvent.pubkey) { |
||||
getUserMetadata(commentEvent.pubkey).then((profile) => { |
||||
profileCache.set(commentEvent.pubkey, profile); |
||||
}); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
activeSub.on("eose", () => { |
||||
clearTimeout(timeout); |
||||
if (activeSub) { |
||||
activeSub.stop(); |
||||
activeSub = null; |
||||
} |
||||
loading = false; |
||||
console.log(`[CommentViewer] Finished fetching ${comments.length} comments`); |
||||
}); |
||||
|
||||
activeSub.on("error", (err: any) => { |
||||
console.error("[CommentViewer] Subscription error:", err); |
||||
error = "Failed to fetch comments"; |
||||
loading = false; |
||||
}); |
||||
} |
||||
|
||||
// Build the threaded comment structure |
||||
function buildCommentThread(events: NDKEvent[]): CommentNode[] { |
||||
if (events.length === 0) return []; |
||||
|
||||
const eventMap = new Map<string, NDKEvent>(); |
||||
const commentMap = new Map<string, CommentNode>(); |
||||
const rootComments: CommentNode[] = []; |
||||
|
||||
// Create nodes for all events |
||||
events.forEach(event => { |
||||
eventMap.set(event.id, event); |
||||
commentMap.set(event.id, { |
||||
event, |
||||
children: [], |
||||
level: 0 |
||||
}); |
||||
}); |
||||
|
||||
// Build parent-child relationships |
||||
events.forEach(event => { |
||||
const node = commentMap.get(event.id); |
||||
if (!node) return; |
||||
|
||||
let parentId: string | null = null; |
||||
|
||||
// Find the immediate parent by looking at e-tags |
||||
const eTags = event.getMatchingTags("e"); |
||||
|
||||
if (event.kind === 1) { |
||||
// Kind 1: Look for the last e-tag that references another comment |
||||
for (let i = eTags.length - 1; i >= 0; i--) { |
||||
const tag = eTags[i]; |
||||
const referencedId = tag[1]; |
||||
if (eventMap.has(referencedId) && referencedId !== event.id) { |
||||
parentId = referencedId; |
||||
break; |
||||
} |
||||
} |
||||
} else if (event.kind === 1111) { |
||||
// Kind 1111: Look for lowercase e-tags (immediate parent) |
||||
for (const tag of eTags) { |
||||
const referencedId = tag[1]; |
||||
// Check if this is a lowercase e-tag (immediate parent) |
||||
if (eventMap.has(referencedId) && referencedId !== event.id) { |
||||
parentId = referencedId; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Add to parent or root |
||||
if (parentId && commentMap.has(parentId)) { |
||||
const parent = commentMap.get(parentId); |
||||
if (parent) { |
||||
parent.children.push(node); |
||||
node.level = parent.level + 1; |
||||
console.log(`[CommentViewer] Added ${event.id} as child of ${parentId} at level ${node.level}`); |
||||
} |
||||
} else { |
||||
// This is a root comment (direct reply to the main event) |
||||
rootComments.push(node); |
||||
console.log(`[CommentViewer] Added ${event.id} as root comment`); |
||||
} |
||||
}); |
||||
|
||||
// Sort by creation time (newest first) |
||||
function sortComments(nodes: CommentNode[]): CommentNode[] { |
||||
return nodes.sort((a, b) => (b.event.created_at || 0) - (a.event.created_at || 0)); |
||||
} |
||||
|
||||
function sortRecursive(nodes: CommentNode[]): CommentNode[] { |
||||
const sorted = sortComments(nodes); |
||||
sorted.forEach(node => { |
||||
node.children = sortRecursive(node.children); |
||||
}); |
||||
return sorted; |
||||
} |
||||
|
||||
const result = sortRecursive(rootComments); |
||||
console.log(`[CommentViewer] Built thread with ${result.length} root comments`); |
||||
return result; |
||||
} |
||||
|
||||
// Derived value for threaded comments |
||||
let threadedComments = $derived(buildCommentThread(comments)); |
||||
|
||||
// Fetch comments when event changes |
||||
$effect(() => { |
||||
if (event?.id) { |
||||
comments = []; |
||||
profileCache.clear(); |
||||
if (activeSub) { |
||||
activeSub.stop(); |
||||
activeSub = null; |
||||
} |
||||
fetchComments(); |
||||
} |
||||
}); |
||||
|
||||
// Cleanup on unmount |
||||
onMount(() => { |
||||
return () => { |
||||
if (activeSub) { |
||||
activeSub.stop(); |
||||
activeSub = null; |
||||
} |
||||
}; |
||||
}); |
||||
|
||||
// Navigation functions |
||||
function getNeventUrl(commentEvent: NDKEvent): string { |
||||
return neventEncode(commentEvent, $activeInboxRelays); |
||||
} |
||||
|
||||
function navigateToComment(commentEvent: NDKEvent) { |
||||
const nevent = getNeventUrl(commentEvent); |
||||
goto(`/events?id=${encodeURIComponent(nevent)}`); |
||||
} |
||||
|
||||
// Utility functions |
||||
function formatDate(timestamp: number): string { |
||||
return new Date(timestamp * 1000).toLocaleDateString(); |
||||
} |
||||
|
||||
function shortenNevent(nevent: string): string { |
||||
if (nevent.length <= 20) return nevent; |
||||
return nevent.slice(0, 10) + "…" + nevent.slice(-10); |
||||
} |
||||
|
||||
function getAuthorName(pubkey: string): string { |
||||
const profile = profileCache.get(pubkey); |
||||
return profile?.displayName || profile?.name || "Anonymous"; |
||||
} |
||||
|
||||
</script> |
||||
|
||||
<div class="mt-6"> |
||||
<Heading tag="h3" class="h-leather mb-4"> |
||||
Comments ({threadedComments.length}) |
||||
</Heading> |
||||
|
||||
{#if loading} |
||||
<div class="text-center py-4"> |
||||
<P>Loading comments...</P> |
||||
</div> |
||||
{:else if error} |
||||
<div class="text-center py-4"> |
||||
<P class="text-red-600">{error}</P> |
||||
</div> |
||||
{:else if threadedComments.length === 0} |
||||
<div class="text-center py-4"> |
||||
<P class="text-gray-500">No comments yet. Be the first to comment!</P> |
||||
</div> |
||||
{:else} |
||||
<div class="space-y-4"> |
||||
{#each threadedComments as node (node.event.id)} |
||||
<div class="mb-4"> |
||||
<div |
||||
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700" |
||||
style="margin-left: {node.level * 20}px;" |
||||
> |
||||
<div class="flex justify-between items-start mb-2"> |
||||
<div class="flex items-center space-x-2"> |
||||
<span class="font-medium text-gray-900 dark:text-white"> |
||||
{getAuthorName(node.event.pubkey)} |
||||
</span> |
||||
<span class="text-sm text-gray-500"> |
||||
{formatDate(node.event.created_at || 0)} Kind: {node.event.kind} |
||||
</span> |
||||
</div> |
||||
<div class="flex items-center space-x-2"> |
||||
<span class="text-sm text-gray-600 dark:text-gray-300"> |
||||
{shortenNevent(getNeventUrl(node.event))} |
||||
</span> |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => navigateToComment(node.event)} |
||||
> |
||||
View |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap"> |
||||
{@html node.event.content || ""} |
||||
</div> |
||||
</div> |
||||
|
||||
{#if node.children.length > 0} |
||||
{#each node.children as childNode (childNode.event.id)} |
||||
<div class="mb-4"> |
||||
<div |
||||
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700" |
||||
style="margin-left: {childNode.level * 20}px;" |
||||
> |
||||
<div class="flex justify-between items-start mb-2"> |
||||
<div class="flex items-center space-x-2"> |
||||
<span class="font-medium text-gray-900 dark:text-white"> |
||||
{getAuthorName(childNode.event.pubkey)} |
||||
</span> |
||||
<span class="text-sm text-gray-500"> |
||||
{formatDate(childNode.event.created_at || 0)} Kind: {childNode.event.kind} |
||||
</span> |
||||
</div> |
||||
<div class="flex items-center space-x-2"> |
||||
<span class="text-sm text-gray-600 dark:text-gray-300"> |
||||
{shortenNevent(getNeventUrl(childNode.event))} |
||||
</span> |
||||
<Button |
||||
size="xs" |
||||
color="light" |
||||
onclick={() => navigateToComment(childNode.event)} |
||||
> |
||||
View |
||||
</Button> |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap"> |
||||
{@html childNode.event.content || ""} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
Loading…
Reference in new issue