diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index 76e9ccd..d6afe0e 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -465,11 +465,13 @@ const isKind30040 = $derived(event?.kind === 30040); // Load highlights for event + // Highlights are loaded for all content events (kind 30041, kind 1, etc.) + // but NOT for kind 30040 index events (which have no content) async function loadHighlights(abortSignal: AbortSignal) { if (!event || !content) return; // Skip highlights for kind 30040 index events (they have no content) - // Highlights should be loaded for the indexed sections instead + // Highlights should be loaded for the indexed sections (kind 30041) instead if (isKind30040 && !content.trim()) { if (!abortSignal.aborted) { highlightsLoaded = true; @@ -486,7 +488,8 @@ } try { - // Load highlights for this specific event (which could be a section event) + // Load highlights for this specific event (kind 30041, kind 1, etc.) + // Highlights will be displayed with user badges and links to the highlight events const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; const eventHighlights = await getHighlightsForEvent( event.id, diff --git a/src/lib/modules/events/EventView.svelte b/src/lib/modules/events/EventView.svelte index 6484fb2..657564d 100644 --- a/src/lib/modules/events/EventView.svelte +++ b/src/lib/modules/events/EventView.svelte @@ -63,6 +63,7 @@ let totalItemCount = $derived(countTotalItems(eventIndexItems)); // Check if event is metadata-only (no content to display) + // Note: kind 30040 (Publication Index) is excluded because it should render its children let isMetadataOnly = $derived(rootEvent ? ( rootEvent.kind === KIND.METADATA || rootEvent.kind === KIND.RELAY_LIST || @@ -70,7 +71,7 @@ rootEvent.kind === KIND.BLOCKED_RELAYS || rootEvent.kind === KIND.LOCAL_RELAYS || rootEvent.kind === KIND.USER_STATUS || - (rootEvent.content === '' && rootEvent.tags.length > 0) + (rootEvent.content === '' && rootEvent.tags.length > 0 && rootEvent.kind !== 30040) ) : false); onMount(async () => { @@ -130,6 +131,15 @@ eventIndexItems = result.items; missingEvents = result.missingEvents; console.log(`Loaded ${result.items.length} events from index hierarchy`); + // Debug: log items with children + const itemsWithChildren = result.items.filter(item => item.children && item.children.length > 0); + console.log(`[EventView] Items with children: ${itemsWithChildren.length}`, itemsWithChildren.map(item => ({ + id: item.event.id, + kind: item.event.kind, + level: item.level, + childrenCount: item.children?.length || 0, + title: item.event.tags.find(t => t[0] === 'title')?.[1] + }))); if (result.missingEvents.length > 0) { console.warn(`[EventView] ${result.missingEvents.length} events are missing from the index hierarchy`); } @@ -148,14 +158,114 @@

Error: {error}

{:else if rootEvent}
- + {#if rootEvent.kind === 30040} {@const titleTag = rootEvent.tags.find(t => t[0] === 'title')} + {@const authorTag = rootEvent.tags.find(t => t[0] === 'author')} + {@const typeTag = rootEvent.tags.find(t => t[0] === 'type')} + {@const versionTag = rootEvent.tags.find(t => t[0] === 'version')} + {@const publishedOnTag = rootEvent.tags.find(t => t[0] === 'published_on')} + {@const publishedByTag = rootEvent.tags.find(t => t[0] === 'published_by')} + {@const imageTag = rootEvent.tags.find(t => t[0] === 'image')} + {@const summaryTag = rootEvent.tags.find(t => t[0] === 'summary')} + {@const sourceTag = rootEvent.tags.find(t => t[0] === 'source')} + {@const iTags = rootEvent.tags.filter(t => t[0] === 'i')} + {@const tTags = rootEvent.tags.filter(t => t[0] === 't')} + {@const pTag = rootEvent.tags.find(t => t[0] === 'p')} + {@const ETag = rootEvent.tags.find(t => t[0] === 'E')} + {#if titleTag && titleTag[1]}

{titleTag[1]}

{/if} + + +
+ {#if authorTag && authorTag[1]} +

+ Author: {authorTag[1]} +

+ {/if} + + {#if typeTag && typeTag[1]} +

+ Type: {typeTag[1]} +

+ {/if} + + {#if versionTag && versionTag[1]} +

+ Version: {versionTag[1]} +

+ {/if} + + {#if publishedOnTag && publishedOnTag[1]} +

+ Published: {publishedOnTag[1]} + {#if publishedByTag && publishedByTag[1]} + by {publishedByTag[1]} + {/if} +

+ {:else if publishedByTag && publishedByTag[1]} +

+ Published by: {publishedByTag[1]} +

+ {/if} + + {#if iTags.length > 0} + {@const plural = iTags.length > 1 ? 's' : ''} +

+ Identifier{plural}: + {#each iTags as iTag, index} + {iTag[1]}{#if index < iTags.length - 1}, {/if} + {/each} +

+ {/if} + + {#if tTags.length > 0} + {@const plural = tTags.length > 1 ? 's' : ''} +

+ Topic{plural}: + {#each tTags as tTag} + {tTag[1]} + {/each} +

+ {/if} + + {#if sourceTag && sourceTag[1]} +

+ Source: + + {sourceTag[1]} + +

+ {/if} + + {#if summaryTag && summaryTag[1]} +

+ {summaryTag[1]} +

+ {/if} + + {#if imageTag && imageTag[1]} +
+ Cover image for {titleTag?.[1] || 'publication'} +
+ {/if} + + {#if pTag && pTag[1]} +

+ Original author: + + {pTag[1]} + + {#if ETag && ETag[1]} + (derivative work) + {/if} +

+ {/if} +
{/if} @@ -191,12 +301,12 @@ {#snippet renderIndexItems(items: EventIndexItem[], parentLevel: number = 0)} {#each items as item (item.event.id)}
- - {#if item.event.kind === 30040 && item.level > 0} + + {#if item.event.kind === 30040} {@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'} + {@const textSize = item.level === 0 ? 'xl' : item.level === 1 ? 'lg' : 'base'} {#if headingLevel === 2}

{indexTitleTag[1]} @@ -219,10 +329,15 @@

{/if} {/if} - {/if} - - - {#if item.event.kind !== 30040} + + + {#if item.children && item.children.length > 0} +
+ {@render renderIndexItems(item.children, item.level)} +
+ {/if} + {:else} + {#if item.event.kind === 30041 || item.event.kind === 1 || item.event.kind === 30817} {@const chapterTitleTag = item.event.tags.find(t => t[0] === 'title')} @@ -243,13 +358,13 @@
- {/if} - - - {#if item.children && item.children.length > 0} -
- {@render renderIndexItems(item.children, item.level)} -
+ + + {#if item.children && item.children.length > 0} +
+ {@render renderIndexItems(item.children, item.level)} +
+ {/if} {/if} {/each} @@ -350,6 +465,41 @@ border-bottom-color: var(--fog-dark-border, #374151); } + .publication-metadata { + padding: 1rem; + background: var(--fog-bg-secondary, #f9fafb); + border-radius: 0.5rem; + border: 1px solid var(--fog-border, #e5e7eb); + } + + :global(.dark) .publication-metadata { + background: var(--fog-dark-bg-secondary, #1f2937); + border-color: var(--fog-dark-border, #374151); + } + + .topic-tag { + display: inline-block; + padding: 0.25rem 0.5rem; + margin: 0.125rem; + background: var(--fog-accent, #3b82f6); + color: white; + border-radius: 0.25rem; + font-size: 0.875rem; + } + + :global(.dark) .topic-tag { + background: var(--fog-dark-accent, #2563eb); + } + + .publication-cover-image { + margin-top: 1rem; + } + + .publication-cover-image img { + max-width: 100%; + height: auto; + } + .chapter-title { margin-top: 1.5rem; margin-bottom: 0.75rem; diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts index 1ed05db..dd39210 100644 --- a/src/lib/services/nostr/config.ts +++ b/src/lib/services/nostr/config.ts @@ -5,17 +5,18 @@ const DEFAULT_RELAYS = [ 'wss://theforest.nostr1.com', - 'wss://nostr21.com', - 'wss://nostr.land', - 'wss://orly-relay.imwald.eu', - 'wss://nostr.wine' + 'wss://thecitadel.nostr1.com', + 'wss://nostr.land' ]; const PROFILE_RELAYS = [ 'wss://relay.damus.io', 'wss://aggr.nostr.land', 'wss://profiles.nostr1.com', - 'wss://relay.primal.net' + 'wss://relay.primal.net', + 'wss://orly-relay.imwald.eu', + 'wss://nostr.wine', + 'wss://nostr21.com' ]; const THREAD_PUBLISH_RELAYS = [ diff --git a/src/lib/services/nostr/event-index-loader.ts b/src/lib/services/nostr/event-index-loader.ts index 5d061b0..5a1ead1 100644 --- a/src/lib/services/nostr/event-index-loader.ts +++ b/src/lib/services/nostr/event-index-loader.ts @@ -38,6 +38,17 @@ export interface LoadEventIndexResult { missingEvents: MissingEventInfo[]; } +/** + * Chunk an array into batches of specified size + */ +function chunkArray(array: T[], chunkSize: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < array.length; i += chunkSize) { + chunks.push(array.slice(i, i + chunkSize)); + } + return chunks; +} + /** * Internal recursive function to load event index hierarchy * @param opEvent The kind 30040 event to load @@ -122,85 +133,193 @@ async function loadEventIndexRecursive( // Track which event IDs are already loaded by a-tags (a-tags take precedence) const eventIdsLoadedByATags = new Set(); + // Batch fetch all a-tags and e-tags in parallel // Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method // Format: ["a", "", "", ""] - if (aTags.length > 0) { - for (const aTagInfo of aTags) { - const parts = aTagInfo.address.split(':'); + + // Collect all event IDs from a-tags for batch fetching + const aTagEventIds: string[] = []; + const aTagEventIdMap = new Map(); // eventId -> aTagInfo + + // Collect all address queries for a-tags (group by kind/pubkey/d-tag) + const addressQueries: Array<{ kind: number; pubkey: string; dTag: string; aTagInfo: ATagInfo }> = []; + + for (const aTagInfo of aTags) { + 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) { + // If event ID is provided, add to batch fetch list + if (aTagInfo.eventId) { + aTagEventIds.push(aTagInfo.eventId); + aTagEventIdMap.set(aTagInfo.eventId, aTagInfo); + } + // Also add address query + addressQueries.push({ kind, pubkey, dTag, aTagInfo }); + } + } + } + + // Collect all relay hints from a-tags + const relayHintsForATags = new Set(); + for (const aTagInfo of aTags) { + if (aTagInfo.relayHint) { + relayHintsForATags.add(aTagInfo.relayHint); + } + } + const allRelaysForATags = relayHintsForATags.size > 0 + ? [...new Set([...Array.from(relayHintsForATags), ...relays])] + : relays; + + // Batch fetch all a-tag event IDs in parallel (chunked to 100 per batch) + const BATCH_SIZE = 100; + const aTagEventsById: NostrEvent[] = []; + if (aTagEventIds.length > 0) { + const aTagEventIdChunks = chunkArray(aTagEventIds, BATCH_SIZE); + const aTagFetchPromises = aTagEventIdChunks.map(chunk => + nostrClient.fetchEvents( + [{ ids: chunk, limit: chunk.length }], + allRelaysForATags, + { useCache: true, cacheResults: true } + ) + ); + const aTagFetchResults = await Promise.all(aTagFetchPromises); + for (const result of aTagFetchResults) { + aTagEventsById.push(...result); + } + } + + // Group address queries by (kind, pubkey) to batch fetch + const addressQueryGroups = new Map>(); + for (const query of addressQueries) { + const key = `${query.kind}:${query.pubkey}`; + if (!addressQueryGroups.has(key)) { + addressQueryGroups.set(key, []); + } + addressQueryGroups.get(key)!.push({ dTag: query.dTag, aTagInfo: query.aTagInfo }); + } + + // Batch fetch all addresses in parallel (chunked to 100 per batch) + const addressFetchPromises: Promise[] = []; + const addressFetchMap = new Map(); // query key -> aTagInfo + + for (const [key, queries] of addressQueryGroups.entries()) { + const [kindStr, pubkey] = key.split(':'); + const kind = parseInt(kindStr, 10); + + // Chunk queries into batches of 100 + const queryChunks = chunkArray(queries, BATCH_SIZE); + + for (const queryChunk of queryChunks) { + const dTags = queryChunk.map(q => q.dTag); + // Create fetch promise for this chunk + const fetchPromise = nostrClient.fetchEvents( + [{ kinds: [kind], authors: [pubkey], '#d': dTags, limit: dTags.length }], + allRelaysForATags, + { useCache: true, cacheResults: true } + ); + + addressFetchPromises.push(fetchPromise); + // Store mapping for each d-tag in this chunk + for (const query of queryChunk) { + addressFetchMap.set(`${key}:${query.dTag}`, query.aTagInfo); + } + } + } + + // Wait for all address fetches in parallel + const addressFetchResults = await Promise.all(addressFetchPromises); + const allAddressEvents: NostrEvent[] = []; + for (const result of addressFetchResults) { + allAddressEvents.push(...result); + } + + // Process a-tag results + const aTagResults = new Map(); // address or eventId -> event + + // Process events fetched by ID + for (const event of aTagEventsById) { + const aTagInfo = aTagEventIdMap.get(event.id); + if (aTagInfo) { + const parts = aTagInfo.address.split(':'); if (parts.length === 3) { const kind = parseInt(parts[0], 10); const pubkey = parts[1]; const dTag = parts[2]; + // Verify the event matches the address + if (event.kind === kind && + event.pubkey === pubkey && + event.tags.some(t => t[0] === 'd' && t[1] === dTag)) { + aTagResults.set(aTagInfo.address, event); + eventIdsLoadedByATags.add(event.id); + } + } + } + } + + // Process events fetched by address + for (const event of allAddressEvents) { + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + const key = `${event.kind}:${event.pubkey}:${dTag}`; + const aTagInfo = addressFetchMap.get(key); + if (aTagInfo && !aTagResults.has(aTagInfo.address)) { + // Get newest version if multiple found + const existing = aTagResults.get(aTagInfo.address); + if (!existing || event.created_at > existing.created_at) { + aTagResults.set(aTagInfo.address, event); + eventIdsLoadedByATags.add(event.id); + } + } + } + } + + // Process all a-tag results + console.log(`[EventIndex] Processing ${aTags.length} a-tags, found ${aTagResults.size} events`); + for (const aTagInfo of aTags) { + 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 event = aTagResults.get(aTagInfo.address); - if (!isNaN(kind) && pubkey && dTag) { - 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 }], - 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); + if (event) { + console.log(`[EventIndex] Processing event ${event.id}, kind ${event.kind}, level ${level}`); + // Check if this event is also a kind 30040 (nested index) + if (event.kind === 30040) { + console.log(`[EventIndex] Found kind 30040 event ${event.id} at level ${level}, loading children...`); + // Recursively load nested index + const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth); + console.log(`[EventIndex] Loaded nested index ${event.id} with ${nestedResult.items.length} children at level ${level + 1}`, nestedResult.items.map(i => ({ id: i.event.id, kind: i.event.kind, level: i.level }))); + // 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 { - 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})`); + // Regular event (content section) + items.push({ event, order: aTagInfo.order, level }); } + loadedAddresses.add(aTagInfo.address); + if (aTagInfo.eventId) { + loadedEventIds.add(aTagInfo.eventId); + } + } else { + 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})`); } } } @@ -223,15 +342,24 @@ async function loadEventIndexRecursive( } } - // Fetch all e-tag events at once + // Fetch all e-tag events in batches of 100 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 } + + const eTagIdChunks = chunkArray(eTagIdsToLoad, BATCH_SIZE); + const eTagFetchPromises = eTagIdChunks.map(chunk => + nostrClient.fetchEvents( + [{ ids: chunk, limit: chunk.length }], + allRelays, + { useCache: true, cacheResults: true } + ) ); + const eTagFetchResults = await Promise.all(eTagFetchPromises); + const eventsById: NostrEvent[] = []; + for (const result of eTagFetchResults) { + eventsById.push(...result); + } for (const eTagInfo of eTags) { // Skip if already loaded by a-tag @@ -396,6 +524,9 @@ async function loadEventIndexRecursive( items.sort((a, b) => a.order - b.order); missingEvents.sort((a, b) => a.order - b.order); + const itemsWithChildren = items.filter(item => item.children && item.children.length > 0); + console.log(`[EventIndex] Returning ${items.length} items (${itemsWithChildren.length} with children) at level ${level}`); + return { items, missingEvents }; }