Browse Source

working kind 1 thread

master
silberengel 7 months ago
parent
commit
976771fc65
  1. 283
      src/lib/components/CommentViewer.svelte

283
src/lib/components/CommentViewer.svelte

@ -1,46 +1,77 @@
<script lang="ts"> <script lang="ts">
import { Button, P, Heading } from "flowbite-svelte"; import { Button, P, Heading } from "flowbite-svelte";
import { getUserMetadata } from "$lib/utils/nostrUtils"; import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { neventEncode } from "$lib/utils"; import { neventEncode } from "$lib/utils";
import { activeInboxRelays, ndkInstance } from "$lib/ndk"; import { activeInboxRelays, ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { onMount } from "svelte"; import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
// State for comments and threading // AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation
// This component fetches and displays threaded comments with proper hierarchy
// Uses simple, reliable profile fetching and efficient state management
// State management
let comments: NDKEvent[] = $state([]); let comments: NDKEvent[] = $state([]);
let loading = $state(false); let loading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let profiles = $state(new Map<string, any>());
let activeSub: any = null; let activeSub: any = null;
// Profile cache for comment authors
let profileCache = $state(new Map<string, any>());
interface CommentNode { interface CommentNode {
event: NDKEvent; event: NDKEvent;
children: CommentNode[]; children: CommentNode[];
level: number; level: number;
} }
// AI-NOTE: 2025-01-08 - Clean threaded comment implementation // Simple profile fetching
// This component fetches and displays threaded comments with proper hierarchy async function fetchProfile(pubkey: string) {
function fetchComments() { if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) return;
const profile = await getUserMetadata(npub);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
} catch (err) {
console.warn(`Failed to fetch profile for ${pubkey}:`, err);
}
}
// Fetch comments once when component mounts
async function fetchComments() {
if (!event?.id) return; if (!event?.id) return;
loading = true; loading = true;
error = null; error = null;
// Clear previous comments
comments = []; comments = [];
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`); console.log(`[CommentViewer] Fetching comments for event: ${event.id}`);
// Subscribe to comments that reference this event // Wait for relays to be available
let attempts = 0;
while ($activeInboxRelays.length === 0 && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
if ($activeInboxRelays.length === 0) {
error = "No relays available";
loading = false;
return;
}
try {
activeSub = $ndkInstance.subscribe({ activeSub = $ndkInstance.subscribe({
kinds: [1, 1111], // Text notes and comments kinds: [1, 1111],
"#e": [event.id], // Events that reference this event "#e": [event.id],
}); });
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
@ -49,41 +80,43 @@
activeSub = null; activeSub = null;
} }
loading = false; loading = false;
}, 10000); // 10 second timeout }, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => { activeSub.on("event", (commentEvent: NDKEvent) => {
// Only add if we haven't seen this event ID yet console.log(`[CommentViewer] Received comment: ${commentEvent.id}`);
if (!comments.find(c => c.id === commentEvent.id)) {
comments = [...comments, commentEvent]; comments = [...comments, commentEvent];
console.log(`[CommentViewer] Found comment: ${commentEvent.id}`); fetchProfile(commentEvent.pubkey);
// Fetch profile for the comment author
if (commentEvent.pubkey) {
getUserMetadata(commentEvent.pubkey).then((profile) => {
profileCache.set(commentEvent.pubkey, profile);
});
}
}
}); });
activeSub.on("eose", () => { activeSub.on("eose", () => {
console.log(`[CommentViewer] EOSE received, found ${comments.length} comments`);
clearTimeout(timeout); clearTimeout(timeout);
if (activeSub) { if (activeSub) {
activeSub.stop(); activeSub.stop();
activeSub = null; activeSub = null;
} }
loading = false; loading = false;
console.log(`[CommentViewer] Finished fetching ${comments.length} comments`);
}); });
activeSub.on("error", (err: any) => { activeSub.on("error", (err: any) => {
console.error("[CommentViewer] Subscription error:", err); console.error(`[CommentViewer] Subscription error:`, err);
error = "Failed to fetch comments"; clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
error = "Error fetching comments";
loading = false; loading = false;
}); });
} catch (err) {
console.error(`[CommentViewer] Error setting up subscription:`, err);
error = "Error setting up subscription";
loading = false;
}
} }
// Build the threaded comment structure // Build threaded comment structure
function buildCommentThread(events: NDKEvent[]): CommentNode[] { function buildCommentThread(events: NDKEvent[]): CommentNode[] {
if (events.length === 0) return []; if (events.length === 0) return [];
@ -107,8 +140,6 @@
if (!node) return; if (!node) return;
let parentId: string | null = null; let parentId: string | null = null;
// Find the immediate parent by looking at e-tags
const eTags = event.getMatchingTags("e"); const eTags = event.getMatchingTags("e");
if (event.kind === 1) { if (event.kind === 1) {
@ -125,7 +156,6 @@
// Kind 1111: Look for lowercase e-tags (immediate parent) // Kind 1111: Look for lowercase e-tags (immediate parent)
for (const tag of eTags) { for (const tag of eTags) {
const referencedId = tag[1]; const referencedId = tag[1];
// Check if this is a lowercase e-tag (immediate parent)
if (eventMap.has(referencedId) && referencedId !== event.id) { if (eventMap.has(referencedId) && referencedId !== event.id) {
parentId = referencedId; parentId = referencedId;
break; break;
@ -139,12 +169,9 @@
if (parent) { if (parent) {
parent.children.push(node); parent.children.push(node);
node.level = parent.level + 1; node.level = parent.level + 1;
console.log(`[CommentViewer] Added ${event.id} as child of ${parentId} at level ${node.level}`);
} }
} else { } else {
// This is a root comment (direct reply to the main event)
rootComments.push(node); rootComments.push(node);
console.log(`[CommentViewer] Added ${event.id} as root comment`);
} }
}); });
@ -161,9 +188,7 @@
return sorted; return sorted;
} }
const result = sortRecursive(rootComments); return sortRecursive(rootComments);
console.log(`[CommentViewer] Built thread with ${result.length} root comments`);
return result;
} }
// Derived value for threaded comments // Derived value for threaded comments
@ -172,8 +197,6 @@
// Fetch comments when event changes // Fetch comments when event changes
$effect(() => { $effect(() => {
if (event?.id) { if (event?.id) {
comments = [];
profileCache.clear();
if (activeSub) { if (activeSub) {
activeSub.stop(); activeSub.stop();
activeSub = null; activeSub = null;
@ -207,54 +230,117 @@
return new Date(timestamp * 1000).toLocaleDateString(); return new Date(timestamp * 1000).toLocaleDateString();
} }
function formatRelativeDate(timestamp: number): string {
const now = Date.now();
const date = timestamp * 1000;
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds} seconds ago`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
}
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) {
return `${diffInWeeks} week${diffInWeeks !== 1 ? 's' : ''} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths !== 1 ? 's' : ''} ago`;
}
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears} year${diffInYears !== 1 ? 's' : ''} ago`;
}
function shortenNevent(nevent: string): string { function shortenNevent(nevent: string): string {
if (nevent.length <= 20) return nevent; if (nevent.length <= 20) return nevent;
return nevent.slice(0, 10) + "…" + nevent.slice(-10); return nevent.slice(0, 10) + "…" + nevent.slice(-10);
} }
function getAuthorName(pubkey: string): string { function getAuthorName(pubkey: string): string {
const profile = profileCache.get(pubkey); const profile = profiles.get(pubkey);
return profile?.displayName || profile?.name || "Anonymous"; return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
} }
</script> function getAuthorPicture(pubkey: string): string | null {
const profile = profiles.get(pubkey);
return profile?.picture || null;
}
<div class="mt-6"> function getIndentation(level: number): string {
<Heading tag="h3" class="h-leather mb-4"> const maxLevel = 5;
Comments ({threadedComments.length}) const actualLevel = Math.min(level, maxLevel);
</Heading> return `${actualLevel * 16}px`;
}
{#if loading} async function parseContent(content: string): Promise<string> {
<div class="text-center py-4"> if (!content) return "";
<P>Loading comments...</P>
</div> let parsedContent = await parseBasicmarkup(content);
{:else if error}
<div class="text-center py-4"> // Make images blurry until clicked
<P class="text-red-600">{error}</P> parsedContent = parsedContent.replace(
</div> /<img([^>]+)>/g,
{:else if threadedComments.length === 0} '<img$1 class="blur-sm hover:blur-none transition-all duration-300 cursor-pointer" onclick="this.classList.toggle(\'blur-sm\')" style="filter: blur(4px);" onload="this.style.filter=\'blur(4px)\'" onerror="(e) => (e.target as HTMLImageElement).style.display = \'none\'">'
<div class="text-center py-4"> );
<P class="text-gray-500">No comments yet. Be the first to comment!</P>
</div> return parsedContent;
{:else} }
<div class="space-y-4"> </script>
{#each threadedComments as node (node.event.id)}
<!-- Recursive Comment Item Component -->
{#snippet CommentItem(node: CommentNode)}
<div class="mb-4"> <div class="mb-4">
<div <div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700" class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words"
style="margin-left: {node.level * 20}px;" style="margin-left: {getIndentation(node.level)};"
> >
<div class="flex justify-between items-start mb-2"> <div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="font-medium text-gray-900 dark:text-white"> {#if getAuthorPicture(node.event.pubkey)}
<img
src={getAuthorPicture(node.event.pubkey)}
alt={getAuthorName(node.event.pubkey)}
class="w-8 h-8 rounded-full object-cover"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{getAuthorName(node.event.pubkey).charAt(0).toUpperCase()}
</span>
</div>
{/if}
<div class="flex flex-col min-w-0">
<span class="font-medium text-gray-900 dark:text-white truncate">
{getAuthorName(node.event.pubkey)} {getAuthorName(node.event.pubkey)}
</span> </span>
<span class="text-sm text-gray-500"> <span
{formatDate(node.event.created_at || 0)} Kind: {node.event.kind} class="text-sm text-gray-500 cursor-help"
title={formatDate(node.event.created_at || 0)}
>
{formatRelativeDate(node.event.created_at || 0)} • Kind: {node.event.kind}
</span> </span>
</div> </div>
<div class="flex items-center space-x-2"> </div>
<span class="text-sm text-gray-600 dark:text-gray-300"> <div class="flex items-center space-x-2 flex-shrink-0">
<span class="text-sm text-gray-600 dark:text-gray-300 truncate max-w-32">
{shortenNevent(getNeventUrl(node.event))} {shortenNevent(getNeventUrl(node.event))}
</span> </span>
<Button <Button
@ -267,49 +353,46 @@
</div> </div>
</div> </div>
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap"> <div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words overflow-hidden">
{#await parseContent(node.event.content || "") then parsedContent}
{@html parsedContent}
{:catch}
{@html node.event.content || ""} {@html node.event.content || ""}
{/await}
</div> </div>
</div> </div>
{#if node.children.length > 0} {#if node.children.length > 0}
<div class="space-y-4">
{#each node.children as childNode (childNode.event.id)} {#each node.children as childNode (childNode.event.id)}
<div class="mb-4"> {@render CommentItem(childNode)}
<div {/each}
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>
{/if}
</div> </div>
{/snippet}
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap"> <div class="mt-6">
{@html childNode.event.content || ""} <Heading tag="h3" class="h-leather mb-4">
</div> Comments ({threadedComments.length})
</Heading>
{#if loading}
<div class="text-center py-4">
<P>Loading comments...</P>
</div> </div>
{:else if error}
<div class="text-center py-4">
<P class="text-red-600">{error}</P>
</div> </div>
{/each} {:else if threadedComments.length === 0}
{/if} <div class="text-center py-4">
<P class="text-gray-500">No comments yet. Be the first to comment!</P>
</div> </div>
{:else}
<div class="space-y-4">
{#each threadedComments as node (node.event.id)}
{@render CommentItem(node)}
{/each} {/each}
</div> </div>
{/if} {/if}

Loading…
Cancel
Save