clone of repo on github
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

885 lines
32 KiB

<script lang="ts">
import { Button, P, Heading } from "flowbite-svelte";
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 EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte";
const { event } = $props<{ event: NDKEvent }>();
// 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
// AI-NOTE: 2025-01-24 - Added support for kind 9802 highlights (NIP-84)
// Highlights are displayed with special styling and include source attribution
// 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;
interface CommentNode {
event: NDKEvent;
children: CommentNode[];
level: number;
}
// Simple profile fetching
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Force fetch to ensure we get the latest profile data
const profile = await getUserMetadata(npub, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
console.log(`[CommentViewer] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`Failed to fetch profile for ${pubkey}:`, err);
// Set a fallback profile to avoid repeated failed requests
const fallbackProfile = {
name: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
displayName: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
}
// Fetch comments once when component mounts
async function fetchComments() {
if (!event?.id) return;
loading = true;
error = null;
comments = [];
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`);
console.log(`[CommentViewer] Event kind: ${event.kind}`);
console.log(`[CommentViewer] Event pubkey: ${event.pubkey}`);
console.log(`[CommentViewer] Available relays: ${$activeInboxRelays.length}`);
// 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 {
// Build address for NIP-22 search if this is a replaceable event
let eventAddress: string | null = null;
if (event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
}
}
console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`);
// Use more targeted filters to reduce noise
const filters = [
// Primary filter: events that explicitly reference our target via e-tags
{
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 50,
}
];
// Add NIP-22 filter only if we have a valid event address
if (eventAddress) {
filters.push({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
} as any);
}
console.log(`[CommentViewer] Setting up subscription with ${filters.length} filters:`, filters);
// Debug: Check if the provided event would match our filters
console.log(`[CommentViewer] Debug: Checking if event b9a15298f2b203d42ba6d0c56c43def87efc887697460c0febb9542515d5a00b would match our filters`);
console.log(`[CommentViewer] Debug: Target event ID: ${event.id}`);
console.log(`[CommentViewer] Debug: Event address: ${eventAddress}`);
// Get all available relays for a more comprehensive search
// Use the full NDK pool relays instead of just active relays
const ndkPoolRelays = Array.from($ndkInstance.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Try all filters to find comments with full relay set
activeSub = $ndkInstance.subscribe(filters);
// Also try a direct search for the specific comment we're looking for
console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`);
const specificCommentSub = $ndkInstance.subscribe({
ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"]
});
specificCommentSub.on("event", (specificEvent: NDKEvent) => {
console.log(`[CommentViewer] Found specific comment via direct search:`, specificEvent.id);
console.log(`[CommentViewer] Specific comment tags:`, specificEvent.tags);
// Check if this specific comment references our target
const eTags = specificEvent.getMatchingTags("e");
const aTags = specificEvent.getMatchingTags("a");
console.log(`[CommentViewer] Specific comment e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Specific comment a-tags:`, aTags.map(t => t[1]));
const hasETag = eTags.some(tag => tag[1] === event.id);
const hasATag = eventAddress ? aTags.some(tag => tag[1] === eventAddress) : false;
console.log(`[CommentViewer] Specific comment has matching e-tag: ${hasETag}`);
console.log(`[CommentViewer] Specific comment has matching a-tag: ${hasATag}`);
});
specificCommentSub.on("eose", () => {
console.log(`[CommentViewer] Specific comment search EOSE`);
specificCommentSub.stop();
});
const timeout = setTimeout(() => {
console.log(`[CommentViewer] Subscription timeout - no comments found`);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
}, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => {
console.log(`[CommentViewer] Received comment: ${commentEvent.id}`);
console.log(`[CommentViewer] Comment kind: ${commentEvent.kind}`);
console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`);
console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`);
// Special debug for the specific comment we're looking for
if (commentEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Comment tags:`, commentEvent.tags);
}
// Check if this event actually references our target event
let referencesTarget = false;
let referenceMethod = "";
// Check e-tags (standard format)
const eTags = commentEvent.getMatchingTags("e");
console.log(`[CommentViewer] Checking e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Target event ID: ${event.id}`);
const hasETag = eTags.some(tag => tag[1] === event.id);
console.log(`[CommentViewer] Has matching e-tag: ${hasETag}`);
if (hasETag) {
referencesTarget = true;
referenceMethod = "e-tag";
}
// Check a-tags (NIP-22 format) if not found via e-tags
if (!referencesTarget && eventAddress) {
const aTags = commentEvent.getMatchingTags("a");
console.log(`[CommentViewer] Checking a-tags:`, aTags.map(t => t[1]));
console.log(`[CommentViewer] Expected a-tag: ${eventAddress}`);
const hasATag = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] Has matching a-tag: ${hasATag}`);
if (hasATag) {
referencesTarget = true;
referenceMethod = "a-tag";
}
}
if (referencesTarget) {
console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`);
comments = [...comments, commentEvent];
fetchProfile(commentEvent.pubkey);
// Fetch nested replies for this comment
fetchNestedReplies(commentEvent.id);
} else {
console.log(`[CommentViewer] Comment does not reference target event - skipping`);
console.log(`[CommentViewer] e-tags:`, eTags.map(t => t[1]));
if (eventAddress) {
console.log(`[CommentViewer] a-tags:`, commentEvent.getMatchingTags("a").map(t => t[1]));
console.log(`[CommentViewer] expected a-tag:`, eventAddress);
}
}
});
activeSub.on("eose", () => {
console.log(`[CommentViewer] EOSE received, found ${comments.length} comments`);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
// Pre-fetch all profiles after comments are loaded
preFetchAllProfiles();
// AI-NOTE: 2025-01-24 - Fetch nested replies for all found comments
comments.forEach(comment => {
fetchNestedReplies(comment.id);
});
// AI-NOTE: 2025-01-24 - Test for comments if none were found
if (comments.length === 0) {
testForComments();
}
});
activeSub.on("error", (err: any) => {
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;
}
}
// Pre-fetch all profiles for comments
async function preFetchAllProfiles() {
const uniquePubkeys = new Set<string>();
comments.forEach(comment => {
if (comment.pubkey && !profiles.has(comment.pubkey)) {
uniquePubkeys.add(comment.pubkey);
}
});
console.log(`[CommentViewer] Pre-fetching ${uniquePubkeys.size} profiles`);
// Fetch profiles in parallel
const profilePromises = Array.from(uniquePubkeys).map(pubkey => fetchProfile(pubkey));
await Promise.allSettled(profilePromises);
console.log(`[CommentViewer] Pre-fetching complete`);
}
// AI-NOTE: 2025-01-24 - Function to manually test for comments
async function testForComments() {
if (!event?.id) return;
console.log(`[CommentViewer] Testing for comments on event: ${event.id}`);
try {
// Try a broader search to see if there are any events that might be comments
const testSub = $ndkInstance.subscribe({
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 10,
});
let testComments = 0;
testSub.on("event", (testEvent: NDKEvent) => {
testComments++;
console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}, content: ${testEvent.content?.slice(0, 50)}...`);
// Special debug for the specific comment we're looking for
if (testEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Test found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Test comment tags:`, testEvent.tags);
}
// Show the e-tags to help debug
const eTags = testEvent.getMatchingTags("e");
console.log(`[CommentViewer] Test event e-tags:`, eTags.map(t => t[1]));
});
testSub.on("eose", () => {
console.log(`[CommentViewer] Test search found ${testComments} potential comments`);
testSub.stop();
});
// Stop the test after 5 seconds
setTimeout(() => {
testSub.stop();
}, 5000);
} catch (err) {
console.error(`[CommentViewer] Test search error:`, err);
}
}
// Build 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;
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: Use NIP-22 threading format
// First try to find parent using 'a' tags (NIP-22 parent scope)
const aTags = event.getMatchingTags("a");
for (const tag of aTags) {
const address = tag[1];
// Extract event ID from address if it's a coordinate
const parts = address.split(":");
if (parts.length >= 3) {
const [kind, pubkey, dTag] = parts;
// Look for the parent event with this address
for (const [eventId, parentEvent] of eventMap) {
if (parentEvent.kind === parseInt(kind) &&
parentEvent.pubkey === pubkey &&
parentEvent.getMatchingTags("d")[0]?.[1] === dTag) {
parentId = eventId;
break;
}
}
if (parentId) break;
}
}
// Fallback to 'e' tags if no parent found via 'a' tags
if (!parentId) {
for (const tag of eTags) {
const referencedId = tag[1];
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;
}
} else {
rootComments.push(node);
}
});
// 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;
}
return sortRecursive(rootComments);
}
// Derived value for threaded comments
let threadedComments = $derived(buildCommentThread(comments));
// Fetch comments when event changes
$effect(() => {
if (event?.id) {
console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
fetchComments();
}
});
// AI-NOTE: 2025-01-24 - Add recursive comment fetching for nested replies
let isFetchingNestedReplies = $state(false);
let nestedReplyIds = $state<Set<string>>(new Set());
// Function to fetch nested replies for a given event
async function fetchNestedReplies(eventId: string) {
if (isFetchingNestedReplies || nestedReplyIds.has(eventId)) {
console.log(`[CommentViewer] Skipping nested reply fetch for ${eventId} - already fetching or processed`);
return;
}
console.log(`[CommentViewer] Starting nested reply fetch for event: ${eventId}`);
isFetchingNestedReplies = true;
nestedReplyIds.add(eventId);
try {
console.log(`[CommentViewer] Fetching nested replies for event:`, eventId);
// Search for replies to this specific event
const nestedSub = $ndkInstance.subscribe({
kinds: [1, 1111, 9802],
"#e": [eventId],
limit: 50,
});
let nestedCount = 0;
nestedSub.on("event", (nestedEvent: NDKEvent) => {
console.log(`[CommentViewer] Found nested reply:`, nestedEvent.id, `kind:`, nestedEvent.kind);
// Check if this event actually references the target event
const eTags = nestedEvent.getMatchingTags("e");
const referencesTarget = eTags.some(tag => tag[1] === eventId);
console.log(`[CommentViewer] Nested reply references target:`, referencesTarget, `eTags:`, eTags);
if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) {
console.log(`[CommentViewer] Adding nested reply to comments`);
comments = [...comments, nestedEvent];
fetchProfile(nestedEvent.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nestedEvent.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] Nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] Nested reply already exists in comments`);
}
});
nestedSub.on("eose", () => {
console.log(`[CommentViewer] Nested replies EOSE, found ${nestedCount} replies`);
nestedSub.stop();
isFetchingNestedReplies = false;
});
// Also search for NIP-22 format nested replies
const event = comments.find(c => c.id === eventId);
if (event && event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
const nip22Sub = $ndkInstance.subscribe({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
});
nip22Sub.on("event", (nip22Event: NDKEvent) => {
console.log(`[CommentViewer] Found NIP-22 nested reply:`, nip22Event.id, `kind:`, nip22Event.kind);
const aTags = nip22Event.getMatchingTags("a");
const referencesTarget = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] NIP-22 nested reply references target:`, referencesTarget, `aTags:`, aTags, `eventAddress:`, eventAddress);
if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) {
console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`);
comments = [...comments, nip22Event];
fetchProfile(nip22Event.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nip22Event.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] NIP-22 nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] NIP-22 nested reply already exists in comments`);
}
});
nip22Sub.on("eose", () => {
console.log(`[CommentViewer] NIP-22 nested replies EOSE`);
nip22Sub.stop();
});
}
}
} catch (err) {
console.error(`[CommentViewer] Error fetching nested replies:`, err);
isFetchingNestedReplies = false;
}
}
// Cleanup on unmount
onMount(() => {
return () => {
if (activeSub) {
activeSub.stop();
activeSub = null;
}
};
});
// Navigation functions
function getNeventUrl(commentEvent: NDKEvent): string {
try {
console.log(`[CommentViewer] Generating nevent for:`, commentEvent.id, `kind:`, commentEvent.kind);
const nevent = neventEncode(commentEvent, $activeInboxRelays);
console.log(`[CommentViewer] Generated nevent:`, nevent);
return nevent;
} catch (error) {
console.error(`[CommentViewer] Error generating nevent:`, error);
// Fallback to just the event ID
return commentEvent.id;
}
}
// AI-NOTE: 2025-01-24 - View button functionality is working correctly
// This function navigates to the specific event as the main event, allowing
// users to view replies as the primary content
function navigateToComment(commentEvent: NDKEvent) {
try {
const nevent = getNeventUrl(commentEvent);
console.log(`[CommentViewer] Navigating to comment:`, nevent);
goto(`/events?id=${encodeURIComponent(nevent)}`);
} catch (error) {
console.error(`[CommentViewer] Error navigating to comment:`, error);
// Fallback to event ID
goto(`/events?id=${commentEvent.id}`);
}
}
// Utility functions
function formatDate(timestamp: number): string {
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 = profiles.get(pubkey);
if (profile?.displayName) return profile.displayName;
if (profile?.name) return profile.name;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
function getAuthorPicture(pubkey: string): string | null {
const profile = profiles.get(pubkey);
return profile?.picture || null;
}
function getIndentation(level: number): string {
const maxLevel = 5;
const actualLevel = Math.min(level, maxLevel);
return `${actualLevel * 16}px`;
}
// AI-NOTE: 2025-01-24 - Get highlight source information
function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null {
// Check for e-tags (nostr events)
const eTags = highlightEvent.getMatchingTags("e");
if (eTags.length > 0) {
return { type: "nostr_event", value: eTags[0][1] };
}
// Check for r-tags (URLs)
const rTags = highlightEvent.getMatchingTags("r");
if (rTags.length > 0) {
return { type: "url", value: rTags[0][1], url: rTags[0][1] };
}
return null;
}
// AI-NOTE: 2025-01-24 - Get highlight attribution
function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> {
const pTags = highlightEvent.getMatchingTags("p");
return pTags.map(tag => ({
pubkey: tag[1],
role: tag[3] || undefined
}));
}
// AI-NOTE: 2025-01-24 - Check if highlight has comment
function hasHighlightComment(highlightEvent: NDKEvent): boolean {
return highlightEvent.getMatchingTags("comment").length > 0;
}
</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 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">
<button
class="cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{#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 hover:opacity-80 transition-opacity"
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 hover:opacity-80 transition-opacity">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{getAuthorName(node.event.pubkey).charAt(0).toUpperCase()}
</span>
</div>
{/if}
</button>
<div class="flex flex-col min-w-0">
<button
class="font-medium text-gray-900 dark:text-white truncate hover:underline cursor-pointer text-left"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{getAuthorName(node.event.pubkey)}
</button>
<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>
<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
size="xs"
color="light"
onclick={() => navigateToComment(node.event)}
>
View
</Button>
</div>
</div>
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words overflow-hidden">
{#if node.event.kind === 9802}
<!-- Highlight rendering -->
<div class="highlight-container bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-3 rounded-r">
{#if hasHighlightComment(node.event)}
<!-- Quote highlight with comment -->
<div class="highlight-quote bg-gray-50 dark:bg-gray-800 p-3 rounded mb-3 border-l-4 border-blue-400">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Highlighted content:</span>
</div>
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content text-gray-800 dark:text-gray-200">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
</div>
<div class="highlight-comment">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Comment:</span>
</div>
<EmbeddedEvent nostrIdentifier={node.event.getMatchingTags("comment")[0]?.[1]} nestingLevel={0} />
</div>
{:else}
<!-- Simple highlight -->
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
{/if}
{#if getHighlightAttribution(node.event).length > 0}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span class="font-medium">Attribution:</span>
{#each getHighlightAttribution(node.event) as attribution}
<button
class="ml-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(attribution.pubkey)}`)}
>
{getAuthorName(attribution.pubkey)}
{#if attribution.role}
<span class="text-gray-400">({attribution.role})</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Regular comment content -->
<EmbeddedEvent nostrIdentifier={node.event.id} nestingLevel={0} />
{/if}
</div>
</div>
{#if node.children.length > 0}
<div class="space-y-4">
{#each node.children as childNode, index (childNode.event.id + '-' + index)}
{@render CommentItem(childNode)}
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="mt-6">
<Heading tag="h3" class="h-leather mb-4">
Comments & Highlights ({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 or highlights yet. Be the first to engage!</P>
</div>
{:else}
<div class="space-y-4">
{#each threadedComments as node, index (node.event.id + '-root-' + index)}
{@render CommentItem(node)}
{/each}
</div>
{/if}
</div>
<style>
/* Highlight styles */
.highlight-container {
position: relative;
}
.highlight-content {
font-style: italic;
background: linear-gradient(transparent 0%, transparent 40%, rgba(255, 255, 0, 0.3) 40%, rgba(255, 255, 0, 0.3) 100%);
}
.highlight-quote {
position: relative;
}
.highlight-quote::before {
content: '"';
position: absolute;
top: -5px;
left: -10px;
font-size: 2rem;
color: #3b82f6;
opacity: 0.5;
}
.highlight-context {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
</style>