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 @@
-
+
{#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'}]({imageTag[1]})
+
+ {/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 };
}