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.
821 lines
30 KiB
821 lines
30 KiB
<script lang="ts"> |
|
import Comment from './Comment.svelte'; |
|
import CommentForm from './CommentForm.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 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 = []; |
|
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 |
|
// Failed to initialize client |
|
} |
|
// 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) |
|
*/ |
|
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: check e tag |
|
if (replyEvent.kind === KIND.SHORT_TEXT_NOTE || replyEvent.kind === KIND.VOICE_REPLY) { |
|
// 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 |
|
// Only include if parent is explicitly the root thread, not if parent is null |
|
const parentId = getParentEventId(replyEvent); |
|
return 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])); |
|
|
|
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)) { |
|
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) |
|
); |
|
|
|
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; |
|
} |
|
} |
|
|
|
// 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()); |
|
|
|
// 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); |
|
|
|
// 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], |
|
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) { |
|
// Cache error (non-critical) |
|
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 } |
|
]; |
|
|
|
// 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) |
|
]); |
|
|
|
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)]; |
|
|
|
// 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()); |
|
|
|
// 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) => { |
|
// Failed to fetch nested replies |
|
subscribeToNestedReplies(); |
|
}); |
|
|
|
} catch (error) { |
|
// Failed to load comments |
|
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) |
|
]); |
|
|
|
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 } |
|
]; |
|
|
|
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 |
|
// Failed to subscribe to replies |
|
} |
|
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) |
|
])); |
|
|
|
// 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 } |
|
]; |
|
|
|
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; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* 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); |
|
} |
|
|
|
/** |
|
* Sort thread items with proper nesting |
|
*/ |
|
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> { |
|
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' }>(); |
|
const replyMap = new Map<string, string[]>(); // parentId -> childIds[] |
|
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> = []; |
|
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' }> = []; |
|
const processed = new Set<string>(); |
|
|
|
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' }) { |
|
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' } => 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' } => 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' }> { |
|
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> = [ |
|
...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 })) |
|
]; |
|
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 }); |
|
|
|
// 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); |
|
|
|
// 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) => { |
|
// Failed to fetch nested replies |
|
// Still set up subscription even if fetch fails |
|
subscribeToNestedReplies(); |
|
}); |
|
|
|
} catch (error) { |
|
// Failed to reload comments |
|
} 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} |
|
<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> |
|
{/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>
|
|
|