Browse Source

virtual scrolling

master
Silberengel 1 month ago
parent
commit
3720edd127
  1. 31
      package-lock.json
  2. 1
      package.json
  3. 4
      public/healthz.json
  4. 56
      src/lib/components/content/MarkdownRenderer.svelte
  5. 1
      src/lib/components/layout/Header.svelte
  6. 1
      src/lib/components/layout/ProfileBadge.svelte
  7. 84
      src/lib/modules/feed/FeedPage.svelte
  8. 58
      src/lib/modules/feed/FeedPost.svelte
  9. 59
      src/lib/modules/profiles/ProfilePage.svelte
  10. 339
      src/lib/modules/threads/ThreadView.svelte
  11. 385
      src/lib/services/nostr/event-index-loader.ts

31
package-lock.json generated

@ -1,16 +1,17 @@ @@ -1,16 +1,17 @@
{
"name": "aitherboard",
"version": "0.1.0",
"version": "0.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aitherboard",
"version": "0.1.0",
"version": "0.1.1",
"license": "MIT",
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0",
"asciidoctor": "3.0.x",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
@ -1319,6 +1320,32 @@ @@ -1319,6 +1320,32 @@
"vite": "^5.0.0"
}
},
"node_modules/@tanstack/svelte-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.18.tgz",
"integrity": "sha512-BHh8WkFK58eE9KzLctPQkCkvCj46LnM9tIGkpwo5Unx5YaBPf0uBJBqvSdc2jMwdT8gLXLHFHtCnSujlZP69BA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",

1
package.json

@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
"dependencies": {
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@tanstack/svelte-virtual": "^3.0.0",
"dompurify": "^3.0.6",
"emoji-picker-element": "^1.28.1",
"idb": "^8.0.0",

4
public/healthz.json

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
"status": "ok",
"service": "aitherboard",
"version": "0.1.1",
"buildTime": "2026-02-05T09:08:05.545Z",
"buildTime": "2026-02-05T09:41:28.201Z",
"gitCommit": "unknown",
"timestamp": 1770282485545
"timestamp": 1770284488201
}

56
src/lib/components/content/MarkdownRenderer.svelte

@ -4,7 +4,6 @@ @@ -4,7 +4,6 @@
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js';
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js';
import HighlightOverlay from './HighlightOverlay.svelte';
@ -12,6 +11,10 @@ @@ -12,6 +11,10 @@
import { mountComponent } from './mount-component-action.js';
import type { NostrEvent } from '../../types/nostr.js';
// Lazy load EmbeddedEvent component (heavy component) - will be loaded on demand
let EmbeddedEventComponent: any = null;
let embeddedEventLoading = $state(false);
interface Props {
content: string;
event?: NostrEvent; // Optional event for emoji resolution
@ -24,6 +27,10 @@ @@ -24,6 +27,10 @@
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]);
let highlightsLoaded = $state(false);
// Cache for rendered markdown to avoid re-rendering same content
const markdownCache = new Map<string, string>();
const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat
// Validate if a string is a valid bech32 or hex string
function isValidNostrId(str: string): boolean {
if (!str || typeof str !== 'string') return false;
@ -521,6 +528,12 @@ @@ -521,6 +528,12 @@
function renderMarkdown(text: string): string {
if (!content) return '';
// Check cache first
const cached = markdownCache.get(content);
if (cached !== undefined) {
return cached;
}
const processed = processContent(content);
let html: string;
@ -658,6 +671,16 @@ @@ -658,6 +671,16 @@
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
// Cache the result (with size limit to prevent memory bloat)
if (markdownCache.size >= MAX_CACHE_SIZE) {
// Remove oldest entry (simple FIFO)
const firstKey = markdownCache.keys().next().value;
if (firstKey !== undefined) {
markdownCache.delete(firstKey);
}
}
markdownCache.set(content, sanitized);
return sanitized;
}
@ -712,8 +735,26 @@ @@ -712,8 +735,26 @@
}
}
// Mount EmbeddedEvent components after rendering
function mountEmbeddedEvents() {
// Lazy load EmbeddedEvent component when needed
async function loadEmbeddedEventComponent() {
if (EmbeddedEventComponent) return EmbeddedEventComponent;
if (embeddedEventLoading) return null;
embeddedEventLoading = true;
try {
const module = await import('./EmbeddedEvent.svelte');
EmbeddedEventComponent = module.default;
return EmbeddedEventComponent;
} catch (error) {
console.error('Error loading EmbeddedEvent component:', error);
return null;
} finally {
embeddedEventLoading = false;
}
}
// Mount EmbeddedEvent components after rendering (lazy loaded)
async function mountEmbeddedEvents() {
if (!containerRef) return;
// Find all event placeholders and mount EmbeddedEvent components
@ -722,6 +763,13 @@ @@ -722,6 +763,13 @@
if (placeholders.length > 0) {
console.debug(`Mounting ${placeholders.length} EmbeddedEvent components`);
// Load component only when we have placeholders to mount
const Component = await loadEmbeddedEventComponent();
if (!Component) {
console.warn('Failed to load EmbeddedEvent component');
return;
}
placeholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
if (eventId) {
@ -731,7 +779,7 @@ @@ -731,7 +779,7 @@
// Clear and mount component
placeholder.innerHTML = '';
// Mount EmbeddedEvent component - it will decode and fetch the event
const instance = mountComponent(placeholder as HTMLElement, EmbeddedEvent as any, { eventId });
const instance = mountComponent(placeholder as HTMLElement, Component as any, { eventId });
if (!instance) {
console.warn('EmbeddedEvent mount returned null', { eventId });

1
src/lib/components/layout/Header.svelte

@ -25,6 +25,7 @@ @@ -25,6 +25,7 @@
src="/aither.png"
alt="aitherboard banner"
class="w-full h-full object-cover opacity-90 dark:opacity-70"
loading="eager"
/>
<!-- Overlay gradient for text readability -->
<div class="absolute inset-0 bg-gradient-to-b from-fog-bg/30 to-fog-bg/80 dark:from-fog-dark-bg/40 dark:to-fog-dark-bg/90 pointer-events-none"></div>

1
src/lib/components/layout/ProfileBadge.svelte

@ -97,6 +97,7 @@ @@ -97,6 +97,7 @@
src={profile.picture}
alt={profile.name || pubkey}
class="profile-picture w-6 h-6 rounded flex-shrink-0"
loading="lazy"
onerror={() => {
imageError = true;
}}

84
src/lib/modules/feed/FeedPage.svelte

@ -9,6 +9,35 @@ @@ -9,6 +9,35 @@
import { onMount, tick } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
// Virtual scrolling - lazy load component
let Virtualizer: any = null;
let virtualizerLoading = $state(false);
async function loadVirtualizer() {
if (Virtualizer) return Virtualizer;
if (virtualizerLoading) return null;
virtualizerLoading = true;
try {
const module = await import('@tanstack/svelte-virtual');
Virtualizer = module.Virtualizer;
return Virtualizer;
} catch (error) {
console.warn('Virtual scrolling not available, falling back to progressive rendering:', error);
return null;
} finally {
virtualizerLoading = false;
}
}
// Load virtualizer when component mounts
$effect(() => {
if (visibleEvents.length > 50) {
// Only use virtual scrolling for large lists
loadVirtualizer();
}
});
interface Props {
singleRelay?: string; // If provided, use only this relay and disable cache
}
@ -27,6 +56,15 @@ @@ -27,6 +56,15 @@
let oldestTimestamp = $state<number | null>(null);
let relayError = $state<string | null>(null); // Error message for single-relay mode
// Progressive rendering: only render first N items initially, then expand as user scrolls
const INITIAL_RENDER_LIMIT = 25;
const RENDER_INCREMENT = 25;
let visibleItemCount = $state(INITIAL_RENDER_LIMIT);
// Derived: sorted all events (memoized to avoid re-sorting on every render)
const allEvents = $derived.by(() => [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at));
const visibleEvents = $derived.by(() => allEvents.slice(0, visibleItemCount));
// List filter state
let availableLists = $state<Array<{ kind: number; name: string; event: NostrEvent }>>([]);
let selectedListId = $state<string | null>(null); // Format: "kind:eventId"
@ -224,6 +262,36 @@ @@ -224,6 +262,36 @@
highlights = [...allHighlights];
otherFeedEvents = [...allOtherFeedEvents];
}
// Reset visible count when data changes (new feed loaded)
visibleItemCount = INITIAL_RENDER_LIMIT;
});
// Auto-expand visible items when user scrolls near bottom (debounced)
$effect(() => {
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
const handleScroll = () => {
if (loading || loadingMore) return;
if (visibleItemCount >= allEvents.length) return;
const scrollPosition = window.innerHeight + window.scrollY;
const documentHeight = document.documentElement.scrollHeight;
const distanceFromBottom = documentHeight - scrollPosition;
// If within 500px of bottom, auto-expand (debounced)
if (distanceFromBottom < 500) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
visibleItemCount = Math.min(visibleItemCount + RENDER_INCREMENT, allEvents.length);
}, 100); // Debounce by 100ms
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (scrollTimeout) clearTimeout(scrollTimeout);
};
});
// Cleanup subscription on unmount
@ -1162,8 +1230,9 @@ @@ -1162,8 +1230,9 @@
</p>
</div>
{:else}
{#key visibleItemCount}
<div class="feed-posts">
{#each [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at) as event (event.id)}
{#each visibleEvents as event (event.id)}
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE}
<HighlightCard highlight={event} onOpenEvent={openDrawer} />
{:else}
@ -1177,7 +1246,20 @@ @@ -1177,7 +1246,20 @@
/>
{/if}
{/each}
{#if allEvents.length > visibleItemCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visibleItemCount = Math.min(visibleItemCount + RENDER_INCREMENT, allEvents.length);
}}
>
Load {Math.min(RENDER_INCREMENT, allEvents.length - visibleItemCount)} more posts
</button>
</div>
{/if}
</div>
{/key}
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />

58
src/lib/modules/feed/FeedPost.svelte

@ -6,7 +6,27 @@ @@ -6,7 +6,27 @@
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import PollCard from '../../components/content/PollCard.svelte';
// Lazy load PollCard component (heavy component)
let PollCardComponent: any = $state(null);
let pollCardLoading = $state(false);
async function loadPollCard() {
if (PollCardComponent) return PollCardComponent;
if (pollCardLoading) return null;
pollCardLoading = true;
try {
const module = await import('../../components/content/PollCard.svelte');
PollCardComponent = module.default;
return PollCardComponent;
} catch (error) {
console.error('Error loading PollCard component:', error);
return null;
} finally {
pollCardLoading = false;
}
}
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -137,6 +157,19 @@ @@ -137,6 +157,19 @@
}
});
// Lazy load PollCard when post is a poll and component is visible
$effect(() => {
if (post.kind === KIND.POLL && !PollCardComponent && !pollCardLoading) {
// Use IntersectionObserver to load only when visible
if (typeof window !== 'undefined' && 'IntersectionObserver' in window) {
// Load immediately for polls (they're usually important content)
loadPollCard();
} else {
loadPollCard();
}
}
});
onMount(async () => {
// Only load zap count if not provided (fallback for edge cases)
// In most cases, FeedPage will have pre-loaded the zap count
@ -475,6 +508,16 @@ @@ -475,6 +508,16 @@
/>
{/if}
<!-- Display title prominently for kind 30040 (book index) and 30041 (chapter sections) -->
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h1 class="post-title text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">
{title}
</h1>
{/if}
{/if}
<div class="post-header flex items-center gap-2 mb-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} />
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">{getRelativeTime()}</span>
@ -502,7 +545,20 @@ @@ -502,7 +545,20 @@
<div class="post-content mb-2">
<MediaAttachments event={post} />
{#if post.kind === KIND.POLL}
{#if PollCardComponent}
{@const PollCard = PollCardComponent}
<PollCard pollEvent={post} />
{:else}
<div class="poll-loading">
{#if pollCardLoading}
Loading poll...
{:else}
<button onclick={loadPollCard} class="text-fog-accent dark:text-fog-dark-accent">
Load poll
</button>
{/if}
</div>
{/if}
{:else}
<MarkdownRenderer content={post.content} event={post} />
{/if}

59
src/lib/modules/profiles/ProfilePage.svelte

@ -18,6 +18,18 @@ @@ -18,6 +18,18 @@
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<ProfileData | null>(null);
let userStatus = $state<string | null>(null);
let posts = $state<NostrEvent[]>([]);
@ -453,6 +465,11 @@ @@ -453,6 +465,11 @@
);
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));
const responseEvents = await nostrClient.fetchEvents(
@ -619,9 +636,21 @@ @@ -619,9 +636,21 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet.</p>
{:else}
<div class="posts-list">
{#each posts as post (post.id)}
{#each visiblePosts as post (post.id)}
<FeedPost post={post} onOpenEvent={openDrawer} />
{/each}
{#if posts.length > visiblePostsCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visiblePostsCount = Math.min(visiblePostsCount + RENDER_INCREMENT, posts.length);
}}
>
Load {Math.min(RENDER_INCREMENT, posts.length - visiblePostsCount)} more posts
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'responses'}
@ -629,9 +658,21 @@ @@ -629,9 +658,21 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">No responses yet.</p>
{:else}
<div class="responses-list">
{#each responses as response (response.id)}
{#each visibleResponses as response (response.id)}
<FeedPost post={response} onOpenEvent={openDrawer} />
{/each}
{#if responses.length > visibleResponsesCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visibleResponsesCount = Math.min(visibleResponsesCount + RENDER_INCREMENT, responses.length);
}}
>
Load {Math.min(RENDER_INCREMENT, responses.length - visibleResponsesCount)} more responses
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'interactions'}
@ -639,9 +680,21 @@ @@ -639,9 +680,21 @@
<p class="text-fog-text-light dark:text-fog-dark-text-light">No interactions with you yet.</p>
{:else}
<div class="interactions-list">
{#each interactionsWithMe as interaction (interaction.id)}
{#each visibleInteractions as interaction (interaction.id)}
<FeedPost post={interaction} onOpenEvent={openDrawer} />
{/each}
{#if interactionsWithMe.length > visibleInteractionsCount}
<div class="load-more-visible text-center py-4">
<button
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-80"
onclick={() => {
visibleInteractionsCount = Math.min(visibleInteractionsCount + RENDER_INCREMENT, interactionsWithMe.length);
}}
>
Load {Math.min(RENDER_INCREMENT, interactionsWithMe.length - visibleInteractionsCount)} more interactions
</button>
</div>
{/if}
</div>
{/if}
{:else if activeTab === 'pins'}

339
src/lib/modules/threads/ThreadView.svelte

@ -3,9 +3,33 @@ @@ -3,9 +3,33 @@
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<HTMLElement | null>(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;
}
@ -15,6 +39,27 @@ @@ -15,6 +39,27 @@
let rootEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
// For kind 30040: event index hierarchy
let isEventIndex = $derived(rootEvent?.kind === 30040);
let eventIndexItems = $state<EventIndexItem[]>([]);
let missingEvents = $state<MissingEventInfo[]>([]);
let loadingIndex = $state(false);
let indexError = $state<string | null>(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();
loadRootEvent();
@ -83,6 +128,8 @@ @@ -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 @@ @@ -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;
}
}
</script>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if rootEvent}
<article class="thread-view">
<!-- Display title prominently for kind 30040 (book index) -->
{#if rootEvent.kind === 30040}
{@const titleTag = rootEvent.tags.find(t => t[0] === 'title')}
{#if titleTag && titleTag[1]}
<h1 class="book-title text-3xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">
{titleTag[1]}
</h1>
{/if}
{/if}
<!-- Display the root OP event -->
<div class="op-section">
<FeedPost post={rootEvent} />
</div>
<!-- Display all replies using CommentThread -->
{#if isEventIndex}
<!-- For kind 30040: Display event index hierarchy with virtual scrolling -->
<div class="event-index-section">
{#if loadingIndex}
<p class="text-fog-text dark:text-fog-dark-text">Loading event index hierarchy... ({eventIndexItems.length} loaded so far)</p>
{:else if indexError}
<p class="text-fog-text dark:text-fog-dark-text error-message">Error: {indexError}</p>
{:else if eventIndexItems.length === 0 && missingEvents.length === 0}
<p class="text-fog-text dark:text-fog-dark-text">No events found in index</p>
{:else}
<!-- Display missing events first -->
{#if missingEvents.length > 0}
<div class="missing-events-section mb-4">
{#each missingEvents as missing (missing.order)}
<div class="missing-event-message">
<p class="text-fog-text dark:text-fog-dark-text">
This event <code class="d-tag-code">{missing.dTag}</code> is missing.
</p>
</div>
{/each}
</div>
{/if}
{#if eventIndexItems.length > 0}
<!-- Recursive rendering function for nested structures -->
{#snippet renderIndexItems(items: EventIndexItem[], parentLevel: number = 0)}
{#each items as item (item.event.id)}
<div class="event-index-item" data-index={item.order} data-level={item.level}>
<!-- Display title for nested kind 30040 indexes (chapter/subchapter indexes) -->
{#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}
<h2 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
</h2>
{:else if headingLevel === 3}
<h3 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
</h3>
{:else if headingLevel === 4}
<h4 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
</h4>
{:else if headingLevel === 5}
<h5 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
</h5>
{:else}
<h6 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
</h6>
{/if}
{/if}
{/if}
<!-- Display chapter title prominently for kind 30041 (chapter sections) -->
{#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]}
<h2 class="chapter-title text-xl font-semibold mb-3 text-fog-text dark:text-fog-dark-text">
{chapterTitleTag[1]}
</h2>
{/if}
{/if}
<!-- Render the event itself -->
<FeedPost post={item.event} />
<!-- Recursively render children if this is a nested index -->
{#if item.children && item.children.length > 0}
<div class="nested-index" style="margin-left: {item.level * 1.5}rem; margin-top: 1rem;">
{@render renderIndexItems(item.children, item.level)}
</div>
{/if}
</div>
{/each}
{/snippet}
<!-- Note: Virtual scrolling doesn't work well with nested structures, so we disable it for nested indexes -->
{#if Virtualizer && totalItemCount > 100 && eventIndexItems.every(item => !item.children || item.children.length === 0)}
<!-- Use virtual scrolling only for flat lists (100+ items, no nesting) -->
{#if Virtualizer}
{@const V = Virtualizer}
<div bind:this={virtualizerContainer} class="virtual-scroll-container" style="height: 80vh; overflow: auto;">
<V
count={eventIndexItems.length}
getScrollElement={() => virtualizerContainer}
estimateSize={() => 200}
overscan={5}
>
{@render renderIndexItems(eventIndexItems)}
</V>
</div>
{/if}
{:else}
<!-- Progressive rendering for smaller lists or nested structures -->
<div class="event-index-list">
{@render renderIndexItems(eventIndexItems)}
</div>
{/if}
{/if}
{/if}
</div>
{:else}
<!-- Display all replies using CommentThread for regular threads -->
<div class="comments-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
{/if}
</article>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
@ -140,4 +336,145 @@ @@ -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);
}
</style>

385
src/lib/services/nostr/event-index-loader.ts

@ -1,6 +1,10 @@ @@ -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", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
* E-tag format: ["e", "<event id>", "<relay hint>"]
* 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'; @@ -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<EventIndexItem[]> {
async function loadEventIndexRecursive(
opEvent: NostrEvent,
level: number = 0,
maxDepth: number = 10
): Promise<LoadEventIndexResult> {
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<string>();
const loadedAddresses = new Set<string>();
const missingIds: string[] = [];
const missingAddresses: string[] = [];
const missingEventIds: string[] = [];
const missingEvents: MissingEventInfo[] = [];
// Parse a-tags and e-tags from OP event
const aTags: string[] = [];
const eTags: string[] = [];
// Parse a-tags (standard) and e-tags (optional) from OP event
// A-tag format per NKBIP-01: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
// E-tag format: ["e", "<event id>", "<relay hint>"]
// 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
}
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);
// Track which event IDs are already loaded by a-tags (a-tags take precedence)
const eventIdsLoadedByATags = new Set<string>();
if (event) {
items.push({ event, order: i });
loadedEventIds.add(eventId);
} else {
missingIds.push(eventId);
}
}
}
// Load events by address (a-tags)
// Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method
// Format: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
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,74 +134,257 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte @@ -76,74 +134,257 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
// Fetch from relays (cache is checked inside fetchEvents)
let event: NostrEvent | undefined;
// If event ID is provided in a-tag (4th element), try to fetch by ID first for version tracking
if (aTagInfo.eventId) {
const eventsById = await nostrClient.fetchEvents(
[{ ids: [aTagInfo.eventId], limit: 1 }],
aTagInfo.relayHint ? [aTagInfo.relayHint] : relays,
{ useCache: true, cacheResults: true }
);
if (eventsById.length > 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 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 }],
relays,
fetchRelays,
{ useCache: true, cacheResults: true }
);
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);
// 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<string>();
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);
}
}
}
@ -153,6 +394,14 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte @@ -153,6 +394,14 @@ export async function loadEventIndex(opEvent: NostrEvent): Promise<EventIndexIte
// Sort by original order
items.sort((a, b) => a.order - b.order);
missingEvents.sort((a, b) => a.order - b.order);
return { items, missingEvents };
}
return items;
/**
* Public function to load event index hierarchy (starts at level 0)
*/
export async function loadEventIndex(opEvent: NostrEvent): Promise<LoadEventIndexResult> {
return loadEventIndexRecursive(opEvent, 0);
}

Loading…
Cancel
Save