{#if post.kind === KIND.POLL} - + {#if PollCardComponent} + {@const PollCard = PollCardComponent} + + {:else} +
+ {#if pollCardLoading} + Loading poll... + {:else} + + {/if} +
+ {/if} {:else} {/if} diff --git a/src/lib/modules/profiles/ProfilePage.svelte b/src/lib/modules/profiles/ProfilePage.svelte index aae1bf2..21a3aeb 100644 --- a/src/lib/modules/profiles/ProfilePage.svelte +++ b/src/lib/modules/profiles/ProfilePage.svelte @@ -17,6 +17,18 @@ import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '../../types/nostr.js'; import { KIND, getFeedKinds } from '../../types/kind-lookup.js'; + + // Progressive rendering for profile posts + const INITIAL_RENDER_LIMIT = 25; + const RENDER_INCREMENT = 25; + let visiblePostsCount = $state(INITIAL_RENDER_LIMIT); + let visibleResponsesCount = $state(INITIAL_RENDER_LIMIT); + let visibleInteractionsCount = $state(INITIAL_RENDER_LIMIT); + + // Derived: visible items for each tab + const visiblePosts = $derived.by(() => posts.slice(0, visiblePostsCount)); + const visibleResponses = $derived.by(() => responses.slice(0, visibleResponsesCount)); + const visibleInteractions = $derived.by(() => interactionsWithMe.slice(0, visibleInteractionsCount)); let profile = $state(null); let userStatus = $state(null); @@ -452,6 +464,11 @@ { useCache: true, cacheResults: true, timeout: 5000 } ); posts = feedEvents.sort((a, b) => b.created_at - a.created_at); + + // Reset visible counts when new data loads + visiblePostsCount = INITIAL_RENDER_LIMIT; + visibleResponsesCount = INITIAL_RENDER_LIMIT; + visibleInteractionsCount = INITIAL_RENDER_LIMIT; // Load responses in parallel with posts (but filter after posts are loaded) const userPostIds = new Set(posts.map(p => p.id)); @@ -619,9 +636,21 @@

No posts yet.

{:else}
- {#each posts as post (post.id)} + {#each visiblePosts as post (post.id)} {/each} + {#if posts.length > visiblePostsCount} +
+ +
+ {/if}
{/if} {:else if activeTab === 'responses'} @@ -629,9 +658,21 @@

No responses yet.

{:else}
- {#each responses as response (response.id)} + {#each visibleResponses as response (response.id)} {/each} + {#if responses.length > visibleResponsesCount} +
+ +
+ {/if}
{/if} {:else if activeTab === 'interactions'} @@ -639,9 +680,21 @@

No interactions with you yet.

{:else}
- {#each interactionsWithMe as interaction (interaction.id)} + {#each visibleInteractions as interaction (interaction.id)} {/each} + {#if interactionsWithMe.length > visibleInteractionsCount} +
+ +
+ {/if}
{/if} {:else if activeTab === 'pins'} diff --git a/src/lib/modules/threads/ThreadView.svelte b/src/lib/modules/threads/ThreadView.svelte index 6b0850d..ef27dd8 100644 --- a/src/lib/modules/threads/ThreadView.svelte +++ b/src/lib/modules/threads/ThreadView.svelte @@ -3,8 +3,32 @@ import CommentThread from '../comments/CommentThread.svelte'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; + import { loadEventIndex, type EventIndexItem, type MissingEventInfo } from '../../services/nostr/event-index-loader.js'; import { onMount } from 'svelte'; import type { NostrEvent } from '../../types/nostr.js'; + + // Virtual scrolling for kind 30040 (event indexes with 36k+ events) + let Virtualizer: any = $state(null); + let virtualizerLoading = $state(false); + let virtualizerContainer = $state(null); + + async function loadVirtualizer() { + if (Virtualizer) return Virtualizer; + if (virtualizerLoading) return null; + + virtualizerLoading = true; + try { + const module = await import('@tanstack/svelte-virtual'); + // @tanstack/svelte-virtual exports Virtualizer component + Virtualizer = module.Virtualizer; + return Virtualizer; + } catch (error) { + console.error('Failed to load virtual scrolling:', error); + return null; + } finally { + virtualizerLoading = false; + } + } interface Props { threadId: string; @@ -14,6 +38,27 @@ let rootEvent = $state(null); let loading = $state(true); + + // For kind 30040: event index hierarchy + let isEventIndex = $derived(rootEvent?.kind === 30040); + let eventIndexItems = $state([]); + let missingEvents = $state([]); + let loadingIndex = $state(false); + let indexError = $state(null); + + // Count total items including nested ones for virtual scrolling decision + function countTotalItems(items: EventIndexItem[]): number { + let count = 0; + for (const item of items) { + count += 1; // Count the item itself + if (item.children && item.children.length > 0) { + count += countTotalItems(item.children); // Recursively count children + } + } + return count; + } + + let totalItemCount = $derived(countTotalItems(eventIndexItems)); onMount(async () => { await nostrClient.initialize(); @@ -83,6 +128,8 @@ async function loadRootEvent() { loading = true; + indexError = null; + eventIndexItems = []; try { const threadRelays = relayManager.getThreadReadRelays(); const feedRelays = relayManager.getFeedReadRelays(); @@ -93,28 +140,177 @@ if (event) { // Find the root OP by traversing up the chain rootEvent = await findRootEvent(event); + + // If it's a kind 30040 event index, load the entire hierarchy + if (rootEvent && rootEvent.kind === 30040) { + await loadEventIndexHierarchy(rootEvent); + // Load virtualizer for large lists + await loadVirtualizer(); + } } } catch (error) { console.error('Error loading thread:', error); + indexError = error instanceof Error ? error.message : 'Failed to load event'; } finally { loading = false; } } + + async function loadEventIndexHierarchy(opEvent: NostrEvent) { + if (opEvent.kind !== 30040) return; + + loadingIndex = true; + indexError = null; + missingEvents = []; + try { + console.log('Loading event index hierarchy for kind 30040...'); + const result = await loadEventIndex(opEvent); + eventIndexItems = result.items; + missingEvents = result.missingEvents; + console.log(`Loaded ${result.items.length} events from index hierarchy`); + if (result.missingEvents.length > 0) { + console.warn(`[ThreadView] ${result.missingEvents.length} events are missing from the index hierarchy`); + } + } catch (error) { + console.error('Error loading event index:', error); + indexError = error instanceof Error ? error.message : 'Failed to load event index'; + } finally { + loadingIndex = false; + } + } {#if loading}

Loading thread...

{:else if rootEvent}
+ + {#if rootEvent.kind === 30040} + {@const titleTag = rootEvent.tags.find(t => t[0] === 'title')} + {#if titleTag && titleTag[1]} +

+ {titleTag[1]} +

+ {/if} + {/if} +
- -
- -
+ {#if isEventIndex} + +
+ {#if loadingIndex} +

Loading event index hierarchy... ({eventIndexItems.length} loaded so far)

+ {:else if indexError} +

Error: {indexError}

+ {:else if eventIndexItems.length === 0 && missingEvents.length === 0} +

No events found in index

+ {:else} + + {#if missingEvents.length > 0} +
+ {#each missingEvents as missing (missing.order)} +
+

+ This event {missing.dTag} is missing. +

+
+ {/each} +
+ {/if} + + {#if eventIndexItems.length > 0} + + {#snippet renderIndexItems(items: EventIndexItem[], parentLevel: number = 0)} + {#each items as item (item.event.id)} +
+ + {#if item.event.kind === 30040 && item.level > 0} + {@const indexTitleTag = item.event.tags.find(t => t[0] === 'title')} + {#if indexTitleTag && indexTitleTag[1]} + {@const headingLevel = Math.min(2 + item.level, 6)} + {@const textSize = item.level === 1 ? 'xl' : item.level === 2 ? 'lg' : 'base'} + {#if headingLevel === 2} +

+ {indexTitleTag[1]} +

+ {:else if headingLevel === 3} +

+ {indexTitleTag[1]} +

+ {:else if headingLevel === 4} +

+ {indexTitleTag[1]} +

+ {:else if headingLevel === 5} +
+ {indexTitleTag[1]} +
+ {:else} +
+ {indexTitleTag[1]} +
+ {/if} + {/if} + {/if} + + + {#if item.event.kind === 30041 || item.event.kind === 1 || item.event.kind === 30817} + {@const chapterTitleTag = item.event.tags.find(t => t[0] === 'title')} + {#if chapterTitleTag && chapterTitleTag[1]} +

+ {chapterTitleTag[1]} +

+ {/if} + {/if} + + + + + + {#if item.children && item.children.length > 0} +
+ {@render renderIndexItems(item.children, item.level)} +
+ {/if} +
+ {/each} + {/snippet} + + + {#if Virtualizer && totalItemCount > 100 && eventIndexItems.every(item => !item.children || item.children.length === 0)} + + {#if Virtualizer} + {@const V = Virtualizer} +
+ virtualizerContainer} + estimateSize={() => 200} + overscan={5} + > + {@render renderIndexItems(eventIndexItems)} + +
+ {/if} + {:else} + +
+ {@render renderIndexItems(eventIndexItems)} +
+ {/if} + {/if} + {/if} +
+ {:else} + +
+ +
+ {/if}
{:else}

Thread not found

@@ -140,4 +336,145 @@ .comments-section { margin-top: 2rem; } + + .event-index-section { + margin-top: 2rem; + } + + .virtual-scroll-container { + border: 1px solid var(--fog-border, #e5e7eb); + border-radius: 0.5rem; + padding: 1rem; + } + + :global(.dark) .virtual-scroll-container { + border-color: var(--fog-dark-border, #374151); + } + + .event-index-item { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--fog-border, #e5e7eb); + } + + .event-index-item:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; + } + + .book-title { + margin-top: 1rem; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .book-title { + border-bottom-color: var(--fog-dark-border, #374151); + } + + .chapter-title { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--fog-border, #e5e7eb); + } + + .chapter-title:first-child { + margin-top: 0; + } + + :global(.dark) .chapter-title { + border-bottom-color: var(--fog-dark-border, #374151); + } + + .index-title { + margin-top: 1.5rem; + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--fog-border, #e5e7eb); + } + + .index-title:first-child { + margin-top: 0; + } + + :global(.dark) .index-title { + border-bottom-color: var(--fog-dark-border, #374151); + } + + .nested-index { + border-left: 2px solid var(--fog-border, #e5e7eb); + padding-left: 1rem; + margin-top: 0.5rem; + } + + :global(.dark) .nested-index { + border-left-color: var(--fog-dark-border, #374151); + } + + .event-index-item[data-level="1"] { + margin-left: 0.5rem; + } + + .event-index-item[data-level="2"] { + margin-left: 1rem; + } + + .event-index-item[data-level="3"] { + margin-left: 1.5rem; + } + + :global(.dark) .event-index-item { + border-bottom-color: var(--fog-dark-border, #374151); + } + + .event-index-list { + max-height: none; + } + + .error-message { + color: var(--fog-error, #ef4444); + padding: 1rem; + background: var(--fog-error-bg, #fee2e2); + border-radius: 0.5rem; + } + + :global(.dark) .error-message { + background: var(--fog-dark-error-bg, #7f1d1d); + } + + .missing-events-section { + padding: 1rem; + background: var(--fog-warning-bg, #fef3c7); + border: 1px solid var(--fog-warning-border, #fbbf24); + border-radius: 0.5rem; + margin-bottom: 1rem; + } + + :global(.dark) .missing-events-section { + background: var(--fog-dark-warning-bg, #78350f); + border-color: var(--fog-dark-warning-border, #d97706); + } + + .missing-event-message { + margin-bottom: 0.5rem; + } + + .missing-event-message:last-child { + margin-bottom: 0; + } + + .d-tag-code { + font-family: monospace; + background: var(--fog-code-bg, #f3f4f6); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.875rem; + } + + :global(.dark) .d-tag-code { + background: var(--fog-dark-code-bg, #374151); + } diff --git a/src/lib/services/nostr/event-index-loader.ts b/src/lib/services/nostr/event-index-loader.ts index fc15dff..5d061b0 100644 --- a/src/lib/services/nostr/event-index-loader.ts +++ b/src/lib/services/nostr/event-index-loader.ts @@ -1,6 +1,10 @@ /** - * Event index loader for kind 30040 - * Handles lazy-loading of event-index hierarchy with a-tags and e-tags + * Event index loader for kind 30040 (Publication Index per NKBIP-01) + * Handles lazy-loading of event-index hierarchy using a-tags (standard) and e-tags (optional) + * A-tag format: ["a", "", "", ""] + * E-tag format: ["e", "", ""] + * The event ID in a-tags (4th element) is optional and enables version tracking + * A-tags are the standard method; e-tags are supported for backwards compatibility */ import type { NostrEvent } from '../../types/nostr.js'; @@ -11,64 +15,118 @@ import { getEvent } from '../cache/event-cache.js'; export interface EventIndexItem { event: NostrEvent; order: number; // Original order in index + level: number; // Nesting level (0 = root, 1 = first level nested, etc.) + children?: EventIndexItem[]; // Nested items if this is a kind 30040 index +} + +export interface MissingEventInfo { + dTag: string; // The d-tag or event ID of the missing event + order: number; // Original order in index + type: 'a-tag' | 'e-tag'; // Whether referenced by a-tag (standard) or e-tag (optional) +} + +/** + * Load entire event-index hierarchy for a kind 30040 event (per NKBIP-01) + * Uses a-tags (standard) and e-tags (optional) to reference events in desired display order + * A-tags take precedence over e-tags when both reference the same event + * Maintains original order from the index event + * Recursively loads nested kind 30040 indexes + * Returns both loaded items and information about missing events + */ +export interface LoadEventIndexResult { + items: EventIndexItem[]; + missingEvents: MissingEventInfo[]; } /** - * Load entire event-index hierarchy for a kind 30040 event - * Handles both a-tags and e-tags, maintains original order + * Internal recursive function to load event index hierarchy + * @param opEvent The kind 30040 event to load + * @param level Current nesting level (0 = root) + * @param maxDepth Maximum recursion depth to prevent infinite loops (default: 10) */ -export async function loadEventIndex(opEvent: NostrEvent): Promise { +async function loadEventIndexRecursive( + opEvent: NostrEvent, + level: number = 0, + maxDepth: number = 10 +): Promise { if (opEvent.kind !== 30040) { throw new Error('Event is not kind 30040'); } + // Prevent infinite recursion + if (level >= maxDepth) { + console.warn(`[EventIndex] Maximum recursion depth (${maxDepth}) reached for event ${opEvent.id}`); + return { items: [], missingEvents: [] }; + } + + // Validate per NKBIP-01 spec + // The content field MUST be empty + if (opEvent.content && opEvent.content.trim() !== '') { + console.warn('[EventIndex] Kind 30040 event has non-empty content (per NKBIP-01, content MUST be empty)'); + } + + // MUST include a title tag + const titleTag = opEvent.tags.find(t => t[0] === 'title'); + if (!titleTag || !titleTag[1]) { + console.warn('[EventIndex] Kind 30040 event missing title tag (per NKBIP-01, title tag is REQUIRED)'); + } + const items: EventIndexItem[] = []; const loadedEventIds = new Set(); const loadedAddresses = new Set(); - const missingIds: string[] = []; const missingAddresses: string[] = []; + const missingEventIds: string[] = []; + const missingEvents: MissingEventInfo[] = []; + + // Parse a-tags (standard) and e-tags (optional) from OP event + // A-tag format per NKBIP-01: ["a", "", "", ""] + // E-tag format: ["e", "", ""] + // The event ID in a-tags (4th element) is optional and enables version tracking + interface ATagInfo { + address: string; // kind:pubkey:dtag + relayHint?: string; // Optional relay hint (2nd element) + eventId?: string; // Optional event ID for version tracking (3rd element) + order: number; // Original order in index + } - // Parse a-tags and e-tags from OP event - const aTags: string[] = []; - const eTags: string[] = []; + interface ETagInfo { + eventId: string; + relayHint?: string; // Optional relay hint (2nd element) + order: number; // Original order in index + } + + const aTags: ATagInfo[] = []; + const eTags: ETagInfo[] = []; - for (const tag of opEvent.tags) { + for (let i = 0; i < opEvent.tags.length; i++) { + const tag = opEvent.tags[i]; if (tag[0] === 'a' && tag[1]) { - aTags.push(tag[1]); + aTags.push({ + address: tag[1], + relayHint: tag[2] || undefined, + eventId: tag[3] || undefined, // Optional event ID for version tracking + order: i + }); } else if (tag[0] === 'e' && tag[1]) { - eTags.push(tag[1]); + eTags.push({ + eventId: tag[1], + relayHint: tag[2] || undefined, + order: i + }); } } // First pass: try to load all events from cache and relays const relays = relayManager.getProfileReadRelays(); - // Load events by ID (e-tags) - if (eTags.length > 0) { - const eventsById = await nostrClient.fetchEvents( - [{ ids: eTags, limit: eTags.length }], - relays, - { useCache: true, cacheResults: true } - ); - - for (let i = 0; i < eTags.length; i++) { - const eventId = eTags[i]; - const event = eventsById.find(e => e.id === eventId); - - if (event) { - items.push({ event, order: i }); - loadedEventIds.add(eventId); - } else { - missingIds.push(eventId); - } - } - } + // Track which event IDs are already loaded by a-tags (a-tags take precedence) + const eventIdsLoadedByATags = new Set(); - // Load events by address (a-tags) + // Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method + // Format: ["a", "", "", ""] if (aTags.length > 0) { - for (let i = 0; i < aTags.length; i++) { - const aTag = aTags[i]; - const parts = aTag.split(':'); + for (const aTagInfo of aTags) { + const parts = aTagInfo.address.split(':'); if (parts.length === 3) { const kind = parseInt(parts[0], 10); @@ -76,83 +134,274 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise 0) { + const fetchedEvent = eventsById[0]; + // Verify the event matches the address (kind, pubkey, d-tag) + if (fetchedEvent.kind === kind && + fetchedEvent.pubkey === pubkey && + fetchedEvent.tags.some(t => t[0] === 'd' && t[1] === dTag)) { + event = fetchedEvent; + } + } + } - if (events.length > 0) { - // Get newest version - const event = events.sort((a, b) => b.created_at - a.created_at)[0]; - items.push({ event, order: eTags.length + i }); - loadedAddresses.add(aTag); + // If not found by event ID, or no event ID provided, fetch by address + if (!event) { + const fetchRelays = aTagInfo.relayHint ? [aTagInfo.relayHint, ...relays] : relays; + const events = await nostrClient.fetchEvents( + [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], + fetchRelays, + { useCache: true, cacheResults: true } + ); + + if (events.length > 0) { + // Get newest version (for replaceable events) + event = events.sort((a, b) => b.created_at - a.created_at)[0]; + } + } + + if (event) { + // Check if this event is also a kind 30040 (nested index) + if (event.kind === 30040) { + // Recursively load nested index + const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth); + // Create a parent item with children + const parentItem: EventIndexItem = { + event, + order: aTagInfo.order, + level, + children: nestedResult.items + }; + items.push(parentItem); + // Merge missing events from nested index + missingEvents.push(...nestedResult.missingEvents); + } else { + // Regular event (content section) + items.push({ event, order: aTagInfo.order, level }); + } + loadedAddresses.add(aTagInfo.address); + if (aTagInfo.eventId) { + loadedEventIds.add(aTagInfo.eventId); + eventIdsLoadedByATags.add(aTagInfo.eventId); + } + // Also track by event ID if we have it + eventIdsLoadedByATags.add(event.id); } else { - missingAddresses.push(aTag); + missingAddresses.push(aTagInfo.address); + missingEvents.push({ dTag, order: aTagInfo.order, type: 'a-tag' }); + console.warn(`[EventIndex] Missing event referenced by a-tag: ${aTagInfo.address} (d-tag: ${dTag})`); } } } } } - // Second pass: retry missing events (but don't loop infinitely) - if (missingIds.length > 0 || missingAddresses.length > 0) { - // Wait a bit before retry - await new Promise(resolve => setTimeout(resolve, 1000)); + // Load events by ID (e-tags) - optional, for backwards compatibility + // Only load e-tags that weren't already loaded by a-tags + // E-tags maintain their original order but come after a-tags + if (eTags.length > 0) { + const eTagIdsToLoad = eTags + .filter(eTag => !eventIdsLoadedByATags.has(eTag.eventId)) + .map(eTag => eTag.eventId); - // Retry missing IDs - if (missingIds.length > 0) { - const retryEvents = await nostrClient.fetchEvents( - [{ ids: missingIds, limit: missingIds.length }], - relays, - { useCache: false, cacheResults: true } // Force relay query + if (eTagIdsToLoad.length > 0) { + // Collect relay hints from e-tags + const relayHintsForETags = new Set(); + for (const eTag of eTags) { + if (eTag.relayHint && !eventIdsLoadedByATags.has(eTag.eventId)) { + relayHintsForETags.add(eTag.relayHint); + } + } + + // Fetch all e-tag events at once + const allRelays = relayHintsForETags.size > 0 + ? [...new Set([...Array.from(relayHintsForETags), ...relays])] + : relays; + const eventsById = await nostrClient.fetchEvents( + [{ ids: eTagIdsToLoad, limit: eTagIdsToLoad.length }], + allRelays, + { useCache: true, cacheResults: true } ); - for (const eventId of missingIds) { - const event = retryEvents.find(e => e.id === eventId); + for (const eTagInfo of eTags) { + // Skip if already loaded by a-tag + if (eventIdsLoadedByATags.has(eTagInfo.eventId)) { + continue; + } + + // Use relay hint if available + const fetchRelays = eTagInfo.relayHint + ? [eTagInfo.relayHint, ...relays] + : allRelays; + + const event = eventsById.find(e => e.id === eTagInfo.eventId); + if (event) { - const originalIndex = eTags.indexOf(eventId); - if (originalIndex >= 0) { - items.push({ event, order: originalIndex }); - loadedEventIds.add(eventId); + // Check if this event is also a kind 30040 (nested index) + if (event.kind === 30040) { + // Recursively load nested index + const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth); + // Create a parent item with children + const parentItem: EventIndexItem = { + event, + order: eTagInfo.order, + level, + children: nestedResult.items + }; + items.push(parentItem); + // Merge missing events from nested index + missingEvents.push(...nestedResult.missingEvents); + } else { + // Regular event (content section) + items.push({ event, order: eTagInfo.order, level }); + } + loadedEventIds.add(eTagInfo.eventId); + } else { + missingEventIds.push(eTagInfo.eventId); + // For e-tags, try to extract d-tag from the event if we can find it + // Otherwise use event ID as identifier + let dTag = eTagInfo.eventId; + // Try to find a corresponding a-tag that might have the d-tag + const correspondingATag = aTags.find(aTag => aTag.eventId === eTagInfo.eventId); + if (correspondingATag) { + const parts = correspondingATag.address.split(':'); + if (parts.length === 3) { + dTag = parts[2]; // Extract d-tag from a-tag + } } + missingEvents.push({ dTag, order: eTagInfo.order, type: 'e-tag' }); + console.warn(`[EventIndex] Missing event referenced by e-tag: ${eTagInfo.eventId} (displaying as: ${dTag})`); } } } + } + + // Second pass: retry missing events (but don't loop infinitely) + if (missingAddresses.length > 0 || missingEventIds.length > 0) { + // Wait a bit before retry + await new Promise(resolve => setTimeout(resolve, 1000)); // Retry missing addresses - if (missingAddresses.length > 0) { - for (const aTag of missingAddresses) { - const parts = aTag.split(':'); + for (const aTagInfo of aTags) { + if (missingAddresses.includes(aTagInfo.address)) { + const parts = aTagInfo.address.split(':'); if (parts.length === 3) { const kind = parseInt(parts[0], 10); const pubkey = parts[1]; const dTag = parts[2]; if (!isNaN(kind) && pubkey && dTag) { + const fetchRelays = aTagInfo.relayHint ? [aTagInfo.relayHint, ...relays] : relays; const retryEvents = await nostrClient.fetchEvents( [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], - relays, + fetchRelays, { useCache: false, cacheResults: true } // Force relay query ); if (retryEvents.length > 0) { const event = retryEvents.sort((a, b) => b.created_at - a.created_at)[0]; - const originalIndex = aTags.indexOf(aTag); - if (originalIndex >= 0) { - items.push({ event, order: eTags.length + originalIndex }); - loadedAddresses.add(aTag); + // Check if this event is also a kind 30040 (nested index) + if (event.kind === 30040) { + // Recursively load nested index + const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth); + // Create a parent item with children + const parentItem: EventIndexItem = { + event, + order: aTagInfo.order, + level, + children: nestedResult.items + }; + items.push(parentItem); + // Merge missing events from nested index + missingEvents.push(...nestedResult.missingEvents); + } else { + // Regular event (content section) + items.push({ event, order: aTagInfo.order, level }); + } + loadedAddresses.add(aTagInfo.address); + // Remove from missing events since we found it + const missingIndex = missingEvents.findIndex(m => m.order === aTagInfo.order && m.type === 'a-tag' && m.dTag === dTag); + if (missingIndex >= 0) { + missingEvents.splice(missingIndex, 1); + } + // Remove from missing addresses list + const addrIndex = missingAddresses.indexOf(aTagInfo.address); + if (addrIndex >= 0) { + missingAddresses.splice(addrIndex, 1); } } } } } } + + // Retry missing e-tag events + if (missingEventIds.length > 0) { + const retryEvents = await nostrClient.fetchEvents( + [{ ids: missingEventIds, limit: missingEventIds.length }], + relays, + { useCache: false, cacheResults: true } // Force relay query + ); + + for (const eTagInfo of eTags) { + if (missingEventIds.includes(eTagInfo.eventId)) { + const event = retryEvents.find(e => e.id === eTagInfo.eventId); + if (event) { + // Check if this event is also a kind 30040 (nested index) + if (event.kind === 30040) { + // Recursively load nested index + const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth); + // Create a parent item with children + const parentItem: EventIndexItem = { + event, + order: eTagInfo.order, + level, + children: nestedResult.items + }; + items.push(parentItem); + // Merge missing events from nested index + missingEvents.push(...nestedResult.missingEvents); + } else { + // Regular event (content section) + items.push({ event, order: eTagInfo.order, level }); + } + loadedEventIds.add(eTagInfo.eventId); + // Remove from missing events + const missingIndex = missingEvents.findIndex(m => m.order === eTagInfo.order && m.type === 'e-tag'); + if (missingIndex >= 0) { + missingEvents.splice(missingIndex, 1); + } + // Remove from missing IDs list + const idIndex = missingEventIds.indexOf(eTagInfo.eventId); + if (idIndex >= 0) { + missingEventIds.splice(idIndex, 1); + } + } + } + } + } } // Sort by original order items.sort((a, b) => a.order - b.order); + missingEvents.sort((a, b) => a.order - b.order); - return items; + return { items, missingEvents }; +} + +/** + * Public function to load event index hierarchy (starts at level 0) + */ +export async function loadEventIndex(opEvent: NostrEvent): Promise { + return loadEventIndexRecursive(opEvent, 0); }