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.
 
 
 
 

892 lines
31 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, getNdkContext } from "$lib/ndk";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { basicMarkup } from "$lib/snippets/MarkupSnippets.svelte";
const { event } = $props<{ event: NDKEvent }>();
const ndk = getNdkContext();
// AI-NOTE: 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: 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;
let isFetching = $state(false); // Track if we're currently fetching to prevent duplicate fetches
let retryCount = $state(0); // Track retry attempts for failed fetches
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, ndk, 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;
// AI-NOTE: Prevent duplicate fetches for the same event
if (isFetching) {
console.log(`[CommentViewer] Already fetching comments, skipping`);
return;
}
isFetching = true;
loading = true;
error = null;
comments = [];
retryCount = 0; // Reset retry count for new event
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}`);
// AI-NOTE: Use a single comprehensive filter to ensure all comments are found
// Multiple filters can cause issues with NDK subscription handling
const filter = {
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 100, // Increased limit to ensure we get all comments
};
console.log(`[CommentViewer] Setting up subscription with filter:`, filter);
console.log(`[CommentViewer] Target event ID: ${event.id}`);
console.log(`[CommentViewer] Event address: ${eventAddress}`);
// Use the full NDK pool relays for comprehensive search
const ndkPoolRelays = Array.from(ndk.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Subscribe with single filter
activeSub = ndk.subscribe(filter);
const timeout = setTimeout(() => {
console.log(`[CommentViewer] Subscription timeout - no comments found`);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
isFetching = 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)}...`);
// 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`);
// AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, commentEvent];
comments = newComments;
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;
isFetching = false;
retryCount = 0; // Reset retry count on successful fetch
// Pre-fetch all profiles after comments are loaded
preFetchAllProfiles();
// AI-NOTE: Fetch nested replies for all found comments
comments.forEach(comment => {
fetchNestedReplies(comment.id);
});
// AI-NOTE: If no comments found and we haven't retried too many times, try again
if (comments.length === 0 && retryCount < 2) {
console.log(`[CommentViewer] No comments found, retrying... (attempt ${retryCount + 1})`);
retryCount++;
setTimeout(() => {
if (!isFetching) {
fetchComments();
}
}, 10000); // Wait 10 seconds before retry
}
});
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;
isFetching = false;
});
} catch (err) {
console.error(`[CommentViewer] Error setting up subscription:`, err);
error = "Error setting up subscription";
loading = false;
isFetching = 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`);
}
// 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));
// AI-NOTE: Comment feed update issue when navigating via e-tags
// When clicking e-tags in EventDetails, the comment feed sometimes doesn't update properly
// This can manifest as:
// 1. Empty comment feed even when comments exist
// 2. Flash between nested and flat thread views
// 3. Delayed comment loading
//
// Potential causes:
// - Race condition between event prop change and comment fetching
// - Subscription cleanup timing issues
// - Nested reply fetching interfering with main comment display
// - Relay availability or timeout issues
//
// TODO: Consider adding a small delay before fetching comments to ensure
// the event prop has fully settled, or implement a more robust state
// management system for comment fetching
$effect(() => {
if (event?.id) {
console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind);
// AI-NOTE: Clean up previous subscription and reset state
if (activeSub) {
activeSub.stop();
activeSub = null;
}
// Reset state for new event
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
retryCount = 0;
// AI-NOTE: Add small delay to prevent race conditions during navigation
setTimeout(() => {
if (event?.id && !isFetching) { // Double-check we're not already fetching
fetchComments();
}
}, 100);
} else {
// Clear state when no event
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
isFetching = false;
retryCount = 0;
}
});
// AI-NOTE: 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 = ndk.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`);
// AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, nestedEvent];
comments = newComments;
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 = ndk.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`);
// AI-NOTE: Use immutable update to prevent UI flashing
const newComments = [...comments, nip22Event];
comments = newComments;
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;
}
}
/**
* Public method to refresh comments (e.g., after creating a new one)
*/
export function refresh() {
console.log(`[CommentViewer] Refreshing comments for event:`, event?.id);
// Clean up previous subscription
if (activeSub) {
activeSub.stop();
activeSub = null;
}
// Reset state
comments = [];
profiles = new Map();
nestedReplyIds = new Set();
isFetchingNestedReplies = false;
retryCount = 0;
isFetching = false;
// Refetch comments
if (event?.id && !isFetching) {
fetchComments();
}
}
// 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: 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: 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: 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: 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)}
{@const comment = node.event.getMatchingTags("comment")[0]?.[1] || "No comment content"}
<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>
<div class="text-sm text-gray-700 dark:text-gray-300">
{@render basicMarkup(comment, ndk)}
</div>
</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 -->
<div class="text-sm text-gray-700 dark:text-gray-300">
{@render basicMarkup(node.event.content || "No content", ndk)}
</div>
{/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>