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 @@ @@ -1,46 +1,77 @@
<script lang="ts">
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 { activeInboxRelays, ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
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 }>();
// 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 loading = $state(false);
let error = $state<string | null>(null);
let profiles = $state(new Map<string, any>());
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() {
// Simple profile fetching
async function fetchProfile(pubkey: string) {
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;
loading = true;
error = null;
// Clear previous comments
comments = [];
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({
kinds: [1, 1111], // Text notes and comments
"#e": [event.id], // Events that reference this event
kinds: [1, 1111],
"#e": [event.id],
});
const timeout = setTimeout(() => {
@ -49,41 +80,43 @@ @@ -49,41 +80,43 @@
activeSub = null;
}
loading = false;
}, 10000); // 10 second timeout
}, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => {
// Only add if we haven't seen this event ID yet
if (!comments.find(c => c.id === commentEvent.id)) {
console.log(`[CommentViewer] Received comment: ${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);
});
}
}
fetchProfile(commentEvent.pubkey);
});
activeSub.on("eose", () => {
console.log(`[CommentViewer] EOSE received, found ${comments.length} comments`);
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";
console.error(`[CommentViewer] Subscription error:`, err);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
error = "Error fetching comments";
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[] {
if (events.length === 0) return [];
@ -107,8 +140,6 @@ @@ -107,8 +140,6 @@
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) {
@ -125,7 +156,6 @@ @@ -125,7 +156,6 @@
// 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;
@ -139,12 +169,9 @@ @@ -139,12 +169,9 @@
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`);
}
});
@ -161,9 +188,7 @@ @@ -161,9 +188,7 @@
return sorted;
}
const result = sortRecursive(rootComments);
console.log(`[CommentViewer] Built thread with ${result.length} root comments`);
return result;
return sortRecursive(rootComments);
}
// Derived value for threaded comments
@ -172,8 +197,6 @@ @@ -172,8 +197,6 @@
// Fetch comments when event changes
$effect(() => {
if (event?.id) {
comments = [];
profileCache.clear();
if (activeSub) {
activeSub.stop();
activeSub = null;
@ -207,54 +230,117 @@ @@ -207,54 +230,117 @@
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 {
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";
const profile = profiles.get(pubkey);
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">
<Heading tag="h3" class="h-leather mb-4">
Comments ({threadedComments.length})
</Heading>
function getIndentation(level: number): string {
const maxLevel = 5;
const actualLevel = Math.min(level, maxLevel);
return `${actualLevel * 16}px`;
}
{#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)}
async function parseContent(content: string): Promise<string> {
if (!content) return "";
let parsedContent = await parseBasicmarkup(content);
// Make images blurry until clicked
parsedContent = parsedContent.replace(
/<img([^>]+)>/g,
'<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\'">'
);
return parsedContent;
}
</script>
<!-- Recursive Comment Item Component -->
{#snippet CommentItem(node: CommentNode)}
<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;"
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words"
style="margin-left: {getIndentation(node.level)};"
>
<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">
{#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)}
</span>
<span class="text-sm text-gray-500">
{formatDate(node.event.created_at || 0)} Kind: {node.event.kind}
<span
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>
</div>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600 dark:text-gray-300">
</div>
<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))}
</span>
<Button
@ -267,49 +353,46 @@ @@ -267,49 +353,46 @@
</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 || ""}
{/await}
</div>
</div>
{#if node.children.length > 0}
<div class="space-y-4">
{#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>
{@render CommentItem(childNode)}
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
{@html childNode.event.content || ""}
</div>
<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>
{/each}
{/if}
{: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)}
{@render CommentItem(node)}
{/each}
</div>
{/if}

Loading…
Cancel
Save