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.
 
 
 
 
 

858 lines
33 KiB

<script lang="ts">
import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte';
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
threadId: string; // The event ID of the root event
event?: NostrEvent; // The root event itself (optional, used to determine reply types)
onCommentsLoaded?: (eventIds: string[]) => void; // Callback when comments are loaded
preloadedReactions?: Map<string, NostrEvent[]>; // Pre-loaded reactions by event ID
hideCommentForm?: boolean; // If true, don't show the comment form at the bottom
}
let { threadId, event, onCommentsLoaded, preloadedReactions, hideCommentForm = false }: Props = $props();
let comments = $state<NostrEvent[]>([]); // kind 1111
let kind1Replies = $state<NostrEvent[]>([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything)
let yakBacks = $state<NostrEvent[]>([]); // kind 1244 (voice replies)
let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts)
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
let loadingPromise: Promise<void> | null = null; // Track ongoing load to prevent concurrent calls
let nestedSubscriptionActive = $state(false); // Track if nested subscription is active
let isProcessingUpdate = $state(false); // Prevent recursive update processing
const isKind1 = $derived(event?.kind === KIND.SHORT_TEXT_NOTE);
const rootKind = $derived(event?.kind || null);
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
// Cleanup on unmount
$effect(() => {
return () => {
isMounted = false;
activeFetchPromises.clear();
};
});
onMount(async () => {
isMounted = true;
await nostrClient.initialize();
});
// Reload comments when threadId changes
$effect(() => {
if (!threadId) {
// Reset state when threadId is cleared
comments = [];
kind1Replies = [];
yakBacks = [];
zapReceipts = [];
nestedSubscriptionActive = false;
isProcessingUpdate = false;
return;
}
// Prevent concurrent loads
if (loadingPromise) {
return;
}
// Reset subscription flag when loading new thread
nestedSubscriptionActive = false;
isProcessingUpdate = false;
// Load comments - filters will adapt based on whether event is available
// Ensure nostrClient is initialized first
loadingPromise = nostrClient.initialize().then(() => {
if (!isMounted) return; // Don't load if unmounted
return loadComments();
}).catch((error) => {
if (isMounted) { // Only log if still mounted
console.error('Error initializing nostrClient in CommentThread:', error);
}
// Still try to load comments even if initialization fails
if (isMounted) {
return loadComments();
}
}).finally(() => {
loadingPromise = null;
});
});
/**
* Get the parent event ID from a reply event
* For kind 1111: checks both E/e and A/a tags (NIP-22)
* For kind 1: checks e tag (NIP-10)
* For kind 1244: checks E/e and A/a tags (follows NIP-22)
* For kind 9735: checks e tag
*/
function getParentEventId(replyEvent: NostrEvent): string | null {
// For kind 1111, check both uppercase and lowercase E and A tags
if (replyEvent.kind === KIND.COMMENT) {
// Check uppercase E tag first (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1]) {
// If it points to root, check lowercase e for parent
if (eTag[1] === threadId) {
const parentETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId);
if (parentETag && parentETag[1]) return parentETag[1];
} else {
// E tag points to parent (non-standard but some clients do this)
return eTag[1];
}
}
// Check lowercase e tag
const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] !== threadId && t[1] !== replyEvent.id);
if (lowerETag && lowerETag[1]) return lowerETag[1];
// Check uppercase A tag (NIP-22 for addressable events)
const aTag = replyEvent.tags.find((t) => t[0] === 'A');
if (aTag && aTag[1]) {
// If it points to root, check lowercase a for parent
const parentATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] !== aTag[1]);
if (parentATag && parentATag[1]) {
// Need to find event by address - for now, check if we have it
// This is complex, so we'll handle it in the parent lookup
}
}
// Check lowercase a tag
const lowerATag = replyEvent.tags.find((t) => t[0] === 'a');
if (lowerATag && lowerATag[1]) {
// Try to find event with matching address
// For now, we'll handle this by checking all events
}
}
// For kind 1, 1244, 9735: check e tag
if (replyEvent.kind === KIND.SHORT_TEXT_NOTE || replyEvent.kind === KIND.VOICE_REPLY || replyEvent.kind === KIND.ZAP_RECEIPT) {
// For kind 1, check all e tags (NIP-10)
const eTags = replyEvent.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== replyEvent.id);
// Prefer e tag with 'reply' marker, otherwise use first e tag
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag && replyTag[1]) return replyTag[1];
if (eTags.length > 0 && eTags[0][1]) return eTags[0][1];
}
return null;
}
/**
* Check if a reply event references the root thread
* For kind 1111: checks both E/e and A/a tags (NIP-22)
* For other kinds: checks e tag
*/
function referencesRoot(replyEvent: NostrEvent): boolean {
if (replyEvent.kind === KIND.COMMENT) {
// Check uppercase E tag (NIP-22 standard for root)
const eTag = replyEvent.tags.find((t) => t[0] === 'E');
if (eTag && eTag[1] === threadId) return true;
// Check lowercase e tag (fallback)
const lowerETag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId);
if (lowerETag) return true;
// Check A and a tags for addressable events
// If root event has an address (a-tag), check if reply references it
if (event) {
const rootATag = event.tags.find((t) => t[0] === 'a');
if (rootATag && rootATag[1]) {
const replyATag = replyEvent.tags.find((t) => t[0] === 'A');
if (replyATag && replyATag[1] === rootATag[1]) return true;
const replyLowerATag = replyEvent.tags.find((t) => t[0] === 'a' && t[1] === rootATag[1]);
if (replyLowerATag) return true;
}
}
// If no direct reference found, check if parent is root
const parentId = getParentEventId(replyEvent);
return parentId === null || parentId === threadId;
}
// For other kinds, check e tag
const eTag = replyEvent.tags.find((t) => t[0] === 'e' && t[1] === threadId);
return !!eTag;
}
function handleReplyUpdate(updated: NostrEvent[]) {
if (!isMounted) return; // Don't update if unmounted
// Prevent recursive calls
if (isProcessingUpdate) {
return;
}
// Process immediately - don't batch with requestAnimationFrame for faster UI updates
isProcessingUpdate = true;
try {
let hasNewReplies = false;
const commentsMap = new Map(comments.map(c => [c.id, c]));
const kind1RepliesMap = new Map(kind1Replies.map(r => [r.id, r]));
const yakBacksMap = new Map(yakBacks.map(y => [y.id, y]));
const zapReceiptsMap = new Map(zapReceipts.map(z => [z.id, z]));
for (const reply of updated) {
// Skip if we already have this reply
if (commentsMap.has(reply.id) || kind1RepliesMap.has(reply.id) ||
yakBacksMap.has(reply.id) || zapReceiptsMap.has(reply.id)) {
continue;
}
// Check if this reply references the root OR is a reply to any existing comment/reply
const parentId = getParentEventId(reply);
const isReplyToRoot = referencesRoot(reply);
const isReplyToExisting = parentId && (
parentId === threadId ||
commentsMap.has(parentId) ||
kind1RepliesMap.has(parentId) ||
yakBacksMap.has(parentId) ||
zapReceiptsMap.has(parentId)
);
if (!isReplyToRoot && !isReplyToExisting) {
continue;
}
// Add the reply to the appropriate map
if (reply.kind === KIND.COMMENT) {
commentsMap.set(reply.id, reply);
hasNewReplies = true;
} else if (reply.kind === KIND.SHORT_TEXT_NOTE) {
kind1RepliesMap.set(reply.id, reply);
hasNewReplies = true;
} else if (reply.kind === KIND.VOICE_REPLY) {
yakBacksMap.set(reply.id, reply);
hasNewReplies = true;
} else if (reply.kind === KIND.ZAP_RECEIPT) {
zapReceiptsMap.set(reply.id, reply);
hasNewReplies = true;
}
}
// Update state immediately if we have new replies
if (!isMounted) return; // Don't update if unmounted
if (hasNewReplies) {
const allComments = Array.from(commentsMap.values());
const allKind1Replies = Array.from(kind1RepliesMap.values());
const allYakBacks = Array.from(yakBacksMap.values());
const allZapReceipts = Array.from(zapReceiptsMap.values());
// Limit array sizes to prevent memory bloat (keep most recent 500 of each type)
const MAX_COMMENTS = 500;
const MAX_REPLIES = 500;
// Sort by created_at descending and take most recent
comments = allComments
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_COMMENTS);
kind1Replies = allKind1Replies
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
yakBacks = allYakBacks
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
zapReceipts = allZapReceipts
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
// Notify parent when comments are loaded
if (onCommentsLoaded) {
const allEventIds = [
...allComments.map(c => c.id),
...allKind1Replies.map(r => r.id),
...allYakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
// Clear loading flag as soon as we get the first results
// This allows comments to render immediately instead of waiting for all fetches
if (loading) {
loading = false;
}
} else if (updated.length > 0 && loading) {
// If we got events but they were all filtered out, still clear loading
// This prevents the UI from being stuck in loading state
// The events might be nested replies that will be processed later
loading = false;
}
} finally {
isProcessingUpdate = false;
}
}
async function loadComments() {
if (!isMounted) return;
if (!threadId) {
loading = false;
return;
}
// Load from cache first (fast - instant display)
// Optimized: Batch all kinds into single cache query
try {
const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js');
// Batch all kinds into one call (optimized in getRecentCachedEvents to use single transaction)
const allCached = await getRecentCachedEvents(
[KIND.COMMENT, KIND.SHORT_TEXT_NOTE, KIND.VOICE_REPLY, KIND.ZAP_RECEIPT],
60 * 60 * 1000,
config.feedLimit * 4 // Get more since we're filtering by thread
);
// Filter cached events to only those that reference this thread
const cachedReplies = allCached.filter(r => referencesRoot(r));
if (cachedReplies.length > 0 && isMounted) {
// Process cached replies immediately
handleReplyUpdate(cachedReplies);
loading = false; // Show cached content immediately
} else {
loading = true; // Only show loading if no cache
}
} catch (error) {
console.debug('Error loading cached comments:', error);
loading = true; // Show loading if cache check fails
}
const allRelays = relayManager.getProfileReadRelays();
const replyFilters: any[] = [
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit }
];
// Stream fresh data from relays (progressive enhancement)
try {
// Use cache-first strategy - already shown cache above, now stream updates
const fetchPromise = nostrClient.fetchEvents(
replyFilters,
allRelays,
{
useCache: 'cache-first', // Already shown cache above, now stream updates
cacheResults: true,
timeout: config.longTimeout,
onUpdate: handleReplyUpdate, // Stream events as they arrive
priority: 'high'
}
);
activeFetchPromises.add(fetchPromise);
const allReplies = await fetchPromise;
activeFetchPromises.delete(fetchPromise);
if (!isMounted) return; // Don't process if unmounted
// Process initial results (from relays or cache)
// Note: onUpdate may have already updated the state and cleared loading
// But if onUpdate didn't process them (e.g., filtered out), we need to process them here
const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Only update if we have new replies not already processed by onUpdate
const existingIds = new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]);
const newRootReplies = rootReplies.filter(r => !existingIds.has(r.id));
if (newRootReplies.length > 0) {
// Merge with existing (onUpdate may have already added some)
const allComments = [...comments, ...newRootReplies.filter(e => e.kind === KIND.COMMENT)];
const allKind1Replies = [...kind1Replies, ...newRootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE)];
const allYakBacks = [...yakBacks, ...newRootReplies.filter(e => e.kind === KIND.VOICE_REPLY)];
const allZapReceipts = [...zapReceipts, ...newRootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT)];
// Deduplicate
comments = Array.from(new Map(allComments.map(c => [c.id, c])).values());
kind1Replies = Array.from(new Map(allKind1Replies.map(r => [r.id, r])).values());
yakBacks = Array.from(new Map(allYakBacks.map(y => [y.id, y])).values());
zapReceipts = Array.from(new Map(allZapReceipts.map(z => [z.id, z])).values());
// Notify parent when comments are loaded
if (onCommentsLoaded) {
const allEventIds = [
...allComments.map(c => c.id),
...allKind1Replies.map(r => r.id),
...allYakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
}
// Notify parent after initial load completes (even if no new replies)
if (onCommentsLoaded && comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0) {
// Still notify with empty array so parent knows loading is complete
onCommentsLoaded([]);
} else if (onCommentsLoaded && (comments.length > 0 || kind1Replies.length > 0 || yakBacks.length > 0)) {
// Notify with all event IDs
const allEventIds = [
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
// ALWAYS clear loading flag after fetch completes, even if no events matched
// This prevents the UI from being stuck in loading state
loading = false;
// Recursively fetch all nested replies (non-blocking - let it run in background)
fetchNestedReplies().then(() => {
subscribeToNestedReplies();
}).catch((error) => {
console.error('Error fetching nested replies:', error);
subscribeToNestedReplies();
});
} catch (error) {
console.error('Error loading comments:', error);
loading = false;
}
}
function subscribeToNestedReplies() {
// Prevent duplicate subscriptions
if (nestedSubscriptionActive) {
return;
}
// Subscribe to replies to any existing comments/replies
const allRelays = relayManager.getProfileReadRelays();
const allReplyIds = new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]);
if (allReplyIds.size === 0) {
return;
}
nestedSubscriptionActive = true;
// Limit reply IDs to prevent massive subscriptions
const limitedReplyIds = Array.from(allReplyIds).slice(0, 100);
// Use a single subscription that covers all reply IDs
const nestedFilters: any[] = [
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
];
if (!isMounted) return; // Don't subscribe if unmounted
const subscriptionPromise = nostrClient.fetchEvents(
nestedFilters,
allRelays,
{
useCache: true,
cacheResults: true,
onUpdate: handleReplyUpdate,
priority: 'high'
}
);
activeFetchPromises.add(subscriptionPromise);
subscriptionPromise.catch(error => {
if (isMounted) { // Only log if still mounted
console.error('Error subscribing to nested replies:', error);
}
nestedSubscriptionActive = false;
}).finally(() => {
activeFetchPromises.delete(subscriptionPromise);
});
}
async function fetchNestedReplies() {
if (!isMounted) return; // Don't fetch if unmounted
// Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays + cache
const allRelays = relayManager.getProfileReadRelays();
let hasNewReplies = true;
let iterations = 0;
const maxIterations = 3; // Reduced from 10 to prevent excessive fetching
const maxReplyIdsPerIteration = 100; // Limit number of reply IDs to check per iteration
while (isMounted && hasNewReplies && iterations < maxIterations) {
iterations++;
hasNewReplies = false;
const allReplyIds = Array.from(new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
]));
// Limit the number of reply IDs to prevent massive queries
const limitedReplyIds = allReplyIds.slice(0, maxReplyIdsPerIteration);
if (limitedReplyIds.length > 0) {
const nestedFilters: any[] = [
// Fetch nested kind 1111 comments - check both e/E and a/A tags
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#a': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#A': limitedReplyIds, limit: config.feedLimit },
// Fetch nested kind 1 replies
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested yak backs
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested zap receipts
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
];
const fetchPromise = nostrClient.fetchEvents(
nestedFilters,
allRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
activeFetchPromises.add(fetchPromise);
const nestedReplies = await fetchPromise;
activeFetchPromises.delete(fetchPromise);
if (!isMounted) break; // Exit loop if unmounted
// Add new replies by type
for (const reply of nestedReplies) {
if (reply.kind === KIND.COMMENT && !comments.some(c => c.id === reply.id)) {
comments.push(reply);
hasNewReplies = true;
} else if (reply.kind === KIND.SHORT_TEXT_NOTE && !kind1Replies.some(r => r.id === reply.id)) {
kind1Replies.push(reply);
hasNewReplies = true;
} else if (reply.kind === KIND.VOICE_REPLY && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply);
hasNewReplies = true;
} else if (reply.kind === KIND.ZAP_RECEIPT && !zapReceipts.some(z => z.id === reply.id)) {
zapReceipts.push(reply);
hasNewReplies = true;
}
}
}
}
}
/**
* Get parent event from any of our loaded events
*/
function getParentEvent(replyEvent: NostrEvent): NostrEvent | undefined {
const parentId = getParentEventId(replyEvent);
if (!parentId) return undefined;
// Check if parent is the root event
if (parentId === threadId) return event || undefined;
// Find parent in loaded events
return comments.find(c => c.id === parentId)
|| kind1Replies.find(r => r.id === parentId)
|| yakBacks.find(y => y.id === parentId)
|| zapReceipts.find(z => z.id === parentId);
}
/**
* Sort thread items with proper nesting
*/
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const allEventIds = new Set<string>();
// First pass: build event map
for (const item of items) {
eventMap.set(item.event.id, item);
allEventIds.add(item.event.id);
}
// Second pass: determine parent-child relationships
for (const item of items) {
const parentId = getParentEventId(item.event);
if (parentId) {
if (parentId === threadId) {
// This is a direct reply to the root OP
if (!replyMap.has(threadId)) {
replyMap.set(threadId, []);
}
replyMap.get(threadId)!.push(item.event.id);
} else if (allEventIds.has(parentId)) {
// This is a reply to another reply
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(item.event.id);
} else {
// Parent not found - treat as root item (orphaned reply)
rootItems.push(item);
}
} else {
// No parent - treat as root item (direct reply without parent tag)
rootItems.push(item);
}
}
// Third pass: recursively collect all items in thread order
const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
result.push(item);
// Add all replies to this item
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of replyItems) {
addThread(reply);
}
}
// First, add all direct replies to the root OP (threadId)
const rootReplies = replyMap.get(threadId) || [];
const rootReplyItems = rootReplies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of rootReplyItems) {
addThread(reply);
}
// Then, add all root items (orphaned replies) sorted by time
rootItems.sort((a, b) => a.event.created_at - b.event.created_at);
for (const root of rootItems) {
addThread(root);
}
return result;
}
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [
...comments.map(c => ({ event: c, type: 'comment' as const })),
...kind1Replies.map(r => ({ event: r, type: 'reply' as const })),
...yakBacks.map(y => ({ event: y, type: 'yak' as const })),
...zapReceipts.map(z => ({ event: z, type: 'zap' as const }))
];
return sortThreadItems(items);
}
function handleReply(replyEvent: NostrEvent) {
replyingTo = replyEvent;
}
async function handleCommentPublished() {
replyingTo = null;
// Wait a short delay to allow the comment to propagate to relays
await new Promise(resolve => setTimeout(resolve, 1500));
// Reload comments without using cache to ensure we get the new comment
await loadCommentsFresh();
}
async function loadCommentsFresh() {
if (!isMounted) return;
if (!threadId) {
loading = false;
return;
}
loading = true;
try {
// Use all relay sources: profileRelays + defaultRelays + user's inboxes + user's localrelays
const allRelays = relayManager.getProfileReadRelays();
const replyFilters: any[] = [];
// Always fetch kind 1111 comments - check both e and E tags, and a and A tags
replyFilters.push(
{ kinds: [KIND.COMMENT], '#e': [threadId], limit: config.feedLimit }, // Lowercase e tag
{ kinds: [KIND.COMMENT], '#E': [threadId], limit: config.feedLimit }, // Uppercase E tag (NIP-22)
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: config.feedLimit }, // Lowercase a tag (some clients use wrong tags)
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: config.feedLimit } // Uppercase A tag (NIP-22 for addressable events)
);
// For kind 1 events, fetch kind 1 replies
// Also fetch kind 1 replies for any event (some apps use kind 1 for everything)
replyFilters.push({ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: config.feedLimit });
// Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit });
// Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit });
// Don't use cache when reloading after publishing - we want fresh data
// Use high priority to ensure comments load before background fetches
const allReplies = await nostrClient.fetchEvents(
replyFilters,
allRelays,
{ useCache: false, cacheResults: true, timeout: config.longTimeout, priority: 'high' }
);
// Filter to only replies that reference the root
const rootReplies = allReplies.filter(reply => referencesRoot(reply));
// Separate by type
comments = rootReplies.filter(e => e.kind === KIND.COMMENT);
kind1Replies = rootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
yakBacks = rootReplies.filter(e => e.kind === KIND.VOICE_REPLY);
zapReceipts = rootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT);
// Recursively fetch all nested replies (non-blocking - let it run in background)
fetchNestedReplies().then(() => {
// After fetching nested replies, set up a single persistent subscription
// This subscription will handle all future updates for nested replies
subscribeToNestedReplies();
}).catch((error) => {
console.error('Error fetching nested replies:', error);
// Still set up subscription even if fetch fails
subscribeToNestedReplies();
});
} catch (error) {
console.error('Error reloading comments:', error);
} finally {
loading = false;
}
}
/**
* Determine what kind of reply is allowed for a given event
*/
function getAllowedReplyKind(targetEvent: NostrEvent | null): number {
if (!targetEvent) {
// If replying to root, check root kind
if (isKind1) return KIND.SHORT_TEXT_NOTE;
return KIND.COMMENT;
}
// If target is kind 1, allow kind 1 reply
if (targetEvent.kind === KIND.SHORT_TEXT_NOTE) return KIND.SHORT_TEXT_NOTE;
// Everything else gets kind 1111
return KIND.COMMENT;
}
// Calculate total comment count (includes all reply types)
const totalCommentCount = $derived.by(() => {
return getThreadItems().length;
});
</script>
<div class="comment-thread">
<h2 class="font-bold mb-4" style="font-size: 1.25em;">
Comments
{#if !loading && totalCommentCount > 0}
<span class="font-normal text-fog-text-light dark:text-fog-dark-text-light ml-2">
({totalCommentCount})
</span>
{/if}
</h2>
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p>
{:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0 && zapReceipts.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet. Be the first to reply!</p>
{:else}
<div class="comments-list">
{#each getThreadItems() as item (item.event.id)}
{@const parent = getParentEvent(item.event)}
{#if item.type === 'comment'}
<Comment
comment={item.event}
parentEvent={parent}
onReply={handleReply}
rootEventKind={rootKind ?? undefined}
threadId={threadId}
rootEvent={event}
onCommentPublished={handleCommentPublished}
/>
{:else if item.type === 'reply'}
<!-- Kind 1 reply - render as FeedPost -->
<div class="kind1-reply mb-4">
<FeedPost
post={item.event}
fullView={true}
preloadedReactions={preloadedReactions?.get(item.event.id)}
/>
</div>
{:else if item.type === 'yak'}
<!-- Yak back (kind 1244) - render as FeedPost -->
<div class="yak-back mb-4">
<FeedPost
post={item.event}
fullView={true}
preloadedReactions={preloadedReactions?.get(item.event.id)}
/>
</div>
{:else if item.type === 'zap'}
<!-- Zap receipt - render with lightning bolt -->
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parent}
onReply={handleReply}
/>
{/if}
{/each}
</div>
{/if}
{#if !hideCommentForm}
{#if replyingTo}
<div class="reply-form-container mt-4">
<CommentForm
threadId={threadId}
rootEvent={event}
parentEvent={replyingTo}
onPublished={handleCommentPublished}
onCancel={() => (replyingTo = null)}
/>
</div>
{:else}
<div class="new-comment-container mt-4">
<CommentForm
threadId={threadId}
rootEvent={event}
onPublished={handleCommentPublished}
/>
</div>
{/if}
{/if}
</div>
<style>
.comment-thread {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.comments-list {
margin-bottom: 2rem;
}
</style>