You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

668 lines
24 KiB

<script lang="ts">
import FeedPost from '../feed/FeedPost.svelte';
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';
import { KIND } from '../../types/kind-lookup.js';
import MetadataCard from '../../components/content/MetadataCard.svelte';
interface Props {
eventId: string;
preloadedEvent?: NostrEvent | null; // Optional preloaded event to avoid re-fetching
}
let { eventId, preloadedEvent = null }: Props = $props();
// 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');
Virtualizer = module.Virtualizer;
return Virtualizer;
} catch (error) {
console.error('Failed to load virtual scrolling:', error);
return null;
} finally {
virtualizerLoading = false;
}
}
let rootEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let lastLoadedEventId = $state<string | null>(null); // Track last loaded eventId to prevent loops
// 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;
if (item.children && item.children.length > 0) {
count += countTotalItems(item.children);
}
}
return count;
}
let totalItemCount = $derived(countTotalItems(eventIndexItems));
// Check if event is metadata-only (no content to display)
// These kinds should only show metadata, not content
const METADATA_ONLY_KINDS: number[] = [
KIND.METADATA, // 0 - Profile metadata
KIND.CONTACTS, // 3 - Contact list
KIND.RELAY_LIST, // 10002 - Relay list metadata
KIND.MUTE_LIST, // 10000 - Mute list
KIND.BLOCKED_RELAYS, // 10006 - Blocked relays
KIND.FAVORITE_RELAYS, // 10012 - Favorite relays
KIND.LOCAL_RELAYS, // 10432 - Local relays
KIND.PIN_LIST, // 10001 - Pin list
KIND.BOOKMARKS, // 10003 - Bookmarks
KIND.INTEREST_LIST, // 10015 - Interest list
KIND.EMOJI_SET, // 10030 - Emoji set
KIND.EMOJI_PACK, // 30030 - Emoji pack
KIND.BADGES, // 30008 - Badges
KIND.FOLLOW_SET, // 30000 - Follow set
KIND.PAYMENT_ADDRESSES, // 10133 - Payment addresses
KIND.LABEL, // 1985 - Label
KIND.REPORT, // 1984 - Report
KIND.HTTP_AUTH, // 27235 - HTTP Auth
KIND.RSS_FEED, // 10895 - RSS Feed
KIND.REPO_ANNOUNCEMENT, // 30617 - Repository announcement (metadata only)
KIND.USER_STATUS, // 30315 - User status (metadata only, shown in profile)
];
// Kinds that should render even with empty content (they have media or special rendering)
const ALWAYS_RENDER_CONTENT_KINDS: number[] = [
KIND.PICTURE_NOTE, // 20 - Has images in imeta tags
KIND.VIDEO_NOTE, // 21 - Has videos in imeta tags
KIND.SHORT_VIDEO_NOTE, // 22 - Has videos in imeta tags
KIND.VOICE_NOTE, // 1222 - Has audio in imeta tags
KIND.VOICE_REPLY, // 1244 - Has audio in imeta tags
KIND.FILE_METADATA, // 1063 - Has files in tags
KIND.POLL, // 1068 - Has poll rendering
KIND.PUBLICATION_INDEX, // 30040 - Has children to render
KIND.ISSUE, // 1621 - Repository issues (may have empty content but have tags)
KIND.STATUS_OPEN, // 1630 - Status events
KIND.STATUS_APPLIED, // 1631
KIND.STATUS_CLOSED, // 1632
KIND.STATUS_DRAFT, // 1633
];
let isMetadataOnly = $derived(rootEvent ? (
METADATA_ONLY_KINDS.includes(rootEvent.kind) ||
(rootEvent.content === '' &&
rootEvent.tags.length > 0 &&
!ALWAYS_RENDER_CONTENT_KINDS.includes(rootEvent.kind))
) : false);
onMount(async () => {
await nostrClient.initialize();
loadEvent();
});
$effect(() => {
// Only load if eventId changed and we're not already loading
// Also reload if preloadedEvent changes (e.g., from null to an actual event)
if (eventId && eventId !== lastLoadedEventId && !loading) {
loadEvent();
} else if (preloadedEvent && preloadedEvent.id === eventId && !rootEvent && !loading) {
// If we have a preloaded event and haven't loaded yet, use it immediately
rootEvent = preloadedEvent;
loading = false;
if (preloadedEvent.kind === 30040) {
loadEventIndexHierarchy(preloadedEvent);
loadVirtualizer();
}
}
});
async function loadEvent() {
// Prevent loading if already loading the same event
if (loading && lastLoadedEventId === eventId) {
return;
}
loading = true;
error = null;
indexError = null;
eventIndexItems = [];
rootEvent = null;
lastLoadedEventId = eventId; // Track that we're loading this eventId
try {
// Use preloaded event if available and matches eventId
let event: NostrEvent | null = null;
if (preloadedEvent && preloadedEvent.id === eventId) {
event = preloadedEvent;
} else {
// Load the event by ID
const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])];
event = await nostrClient.getEventById(eventId, allRelays);
}
if (event) {
rootEvent = event;
// If it's a kind 30040 event index, load the entire hierarchy
if (event.kind === 30040) {
await loadEventIndexHierarchy(event);
// Load virtualizer for large lists
await loadVirtualizer();
}
} else {
error = 'Event not found';
}
} catch (err) {
console.error('Error loading event:', err);
error = err instanceof Error ? err.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`);
// 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`);
}
} catch (err) {
console.error('Error loading event index:', err);
indexError = err instanceof Error ? err.message : 'Failed to load event index';
} finally {
loadingIndex = false;
}
}
</script>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p>
{:else if error}
<p class="text-fog-text dark:text-fog-dark-text error-message">Error: {error}</p>
{:else if rootEvent}
<article class="event-view">
<!-- Display title and metadata for kind 30040 (book index) -->
{#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]}
<h1 class="book-title text-3xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">
{titleTag[1]}
</h1>
{/if}
<!-- Publication metadata -->
<div class="publication-metadata mb-6 space-y-2">
{#if authorTag && authorTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Author:</span> {authorTag[1]}
</p>
{/if}
{#if typeTag && typeTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Type:</span> {typeTag[1]}
</p>
{/if}
{#if versionTag && versionTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Version:</span> {versionTag[1]}
</p>
{/if}
{#if publishedOnTag && publishedOnTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Published:</span> {publishedOnTag[1]}
{#if publishedByTag && publishedByTag[1]}
<span> by {publishedByTag[1]}</span>
{/if}
</p>
{:else if publishedByTag && publishedByTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Published by:</span> {publishedByTag[1]}
</p>
{/if}
{#if iTags.length > 0}
{@const plural = iTags.length > 1 ? 's' : ''}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Identifier{plural}:</span>
{#each iTags as iTag, index}
{iTag[1]}{#if index < iTags.length - 1}, {/if}
{/each}
</p>
{/if}
{#if tTags.length > 0}
{@const plural = tTags.length > 1 ? 's' : ''}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Topic{plural}:</span>
{#each tTags as tTag}
<span class="topic-tag">{tTag[1]}</span>
{/each}
</p>
{/if}
{#if sourceTag && sourceTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Source:</span>
<a href={sourceTag[1]} target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">
{sourceTag[1]}
</a>
</p>
{/if}
{#if summaryTag && summaryTag[1]}
<p class="text-fog-text dark:text-fog-dark-text mt-4">
{summaryTag[1]}
</p>
{/if}
{#if imageTag && imageTag[1]}
<div class="publication-cover-image mt-4">
<img src={imageTag[1]} alt="Cover image for {titleTag?.[1] || 'publication'}" class="max-w-md rounded-lg shadow-lg" />
</div>
{/if}
{#if pTag && pTag[1]}
<p class="text-fog-text dark:text-fog-dark-text mt-4 text-sm italic">
<span class="font-semibold">Original author:</span>
<a href="/profile/{pTag[1]}" class="text-blue-600 dark:text-blue-400 hover:underline">
{pTag[1]}
</a>
{#if ETag && ETag[1]}
<span> (derivative work)</span>
{/if}
</p>
{/if}
</div>
{/if}
<!-- Display metadata-only events with MetadataCard -->
{#if isMetadataOnly}
<div class="metadata-section">
<MetadataCard event={rootEvent} />
</div>
{:else 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 kind 30040 indexes (chapter/subchapter indexes) -->
{#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 === 0 ? 'xl' : item.level === 1 ? '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}
<!-- Recursively render children of kind 30040 indexes -->
{#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}
{:else}
<!-- For content events (not kind 30040 indexes), render the content -->
<!-- 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 content -->
<div class="event-with-comments">
<!-- Hide title in FeedPost since we're already showing it above as chapter-title -->
<FeedPost post={item.event} fullView={true} hideTitle={true} />
<!-- Load and display comments for each event in the index -->
<div class="comments-section mt-4">
<CommentThread threadId={item.event.id} event={item.event} />
</div>
</div>
<!-- Recursively render children if this content event has nested children -->
{#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}
{/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 regular events using FeedPost -->
<div class="event-section">
<FeedPost post={rootEvent} fullView={true} hideTitle={false} />
</div>
<!-- Load and display comments for all event types -->
{#if rootEvent && !isMetadataOnly}
<div class="comments-section mt-6">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
{/if}
{/if}
</article>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Event not found</p>
{/if}
<style>
.event-view {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.metadata-section {
margin-bottom: 2rem;
}
.event-section {
margin-bottom: 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);
}
.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;
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>