import { ExtendedKind } from '@/constants' import { Event, kinds, nip19 } from 'nostr-tools' import { useEffect, useMemo, useState, useCallback, useSyncExternalStore } from 'react' import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader' import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch' import { cn } from '@/lib/utils' import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' import { generateBech32IdFromATag } from '@/lib/tag' import logger from '@/lib/logger' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { RefreshCw, ArrowUp } from 'lucide-react' import indexedDb from '@/services/indexed-db.service' import { useSecondaryPageOptional } from '@/PageManager' import { extractBookMetadata } from '@/lib/bookstr-parser' import { dTagToTitleCase } from '@/lib/event-metadata' import Image from '@/components/Image' import NoteOptions from '@/components/NoteOptions' import { getRenderedPublicationEventsVersion, getRenderedPublicationEventsDeep, subscribeRenderedPublicationEvents, upsertRenderedPublicationEvents } from '@/lib/publication-rendered-events' interface PublicationReference { coordinate?: string /** * Optional historical snapshot id (`a` tag field 4) or direct `e` tag id. * For `a` references this is metadata only and MUST NOT drive section fetches. */ eventId?: string event?: Event kind?: number pubkey?: string identifier?: string relay?: string type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID nestedRefs?: PublicationReference[] // Discovered nested references } interface ToCItem { title: string coordinate: string event?: Event kind: number children?: ToCItem[] } interface PublicationMetadata { title?: string summary?: string image?: string author?: string version?: string type?: string source?: string publishedOn?: string publishedBy?: string tags: string[] } function publicationSectionNotesLink(ref: { coordinate?: string eventId?: string relay?: string }): string | null { if (ref.coordinate) { const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] const bech32Id = generateBech32IdFromATag(aTag) if (bech32Id) return `/notes?events=${encodeURIComponent(bech32Id)}` } if (ref.eventId) { if ( ref.eventId.startsWith('note1') || ref.eventId.startsWith('nevent1') || ref.eventId.startsWith('naddr1') ) { return `/notes?events=${encodeURIComponent(ref.eventId)}` } if (/^[0-9a-f]{64}$/i.test(ref.eventId)) { try { const nevent = nip19.neventEncode({ id: ref.eventId }) return `/notes?events=${encodeURIComponent(nevent)}` } catch { return `/notes?events=${encodeURIComponent(ref.eventId)}` } } } return null } export default function PublicationIndex({ event, className, isNested = false, parentImageUrl, flattenHierarchy = false, chapterDepth = 0, publicationFootnotesContainerId }: { event: Event className?: string isNested?: boolean parentImageUrl?: string flattenHierarchy?: boolean chapterDepth?: number publicationFootnotesContainerId?: string }) { const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) // Parse publication metadata from event tags const metadata = useMemo(() => { const meta: PublicationMetadata = { tags: [] } for (const [tagName, tagValue] of event.tags) { if (tagName === 'title') { meta.title = tagValue } else if (tagName === 'summary') { meta.summary = tagValue } else if (tagName === 'image') { meta.image = tagValue } else if (tagName === 'author') { meta.author = tagValue } else if (tagName === 'version') { meta.version = tagValue } else if (tagName === 'type') { meta.type = tagValue } else if (tagName === 'source') { meta.source = tagValue } else if (tagName === 'published_on') { meta.publishedOn = tagValue } else if (tagName === 'published_by') { meta.publishedBy = tagValue } else if (tagName === 't' && tagValue) { meta.tags.push(tagValue.toLowerCase()) } } // Fallback title from d-tag if no title (convert to title case) if (!meta.title) { const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] if (dTag) { meta.title = dTagToTitleCase(dTag) } } return meta }, [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isTopLevelPublication = !isNested && event.kind === ExtendedKind.PUBLICATION const forceFlatHierarchy = flattenHierarchy || isBookstrEvent || isTopLevelPublication const resolvedPublicationFootnotesContainerId = useMemo( () => publicationFootnotesContainerId ?? (isTopLevelPublication ? `publication-footnotes-${event.id}` : undefined), [publicationFootnotesContainerId, isTopLevelPublication, event.id] ) const [isRetrying, setIsRetrying] = useState(false) // Extract references from 'a' tags (addressable events) and 'e' tags (event IDs) const referencesData = useMemo(() => { const refs: PublicationReference[] = [] for (const tag of event.tags) { if (tag[0] === 'a' && tag[1]) { const parsed = parsePublicationATagCoordinate(tag[1]) if (parsed) { refs.push({ type: 'a', coordinate: parsed.coordinate, // `a[3]` is historization metadata for this coordinate revision only. // Keep it for diagnostics/UI context; fetches resolve by coordinate, not by this id. eventId: tag[3], kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier, relay: tag[2] }) } } else if (tag[0] === 'e' && tag[1]) { // Event ID reference refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) } } return refs }, [event]) const { retryKeys, failedKeys, referencesWithEvents } = usePublicationSectionLoader(event, referencesData) const renderedEventsVersion = useSyncExternalStore( subscribeRenderedPublicationEvents, getRenderedPublicationEventsVersion, getRenderedPublicationEventsVersion ) // Helper function to format bookstr titles (remove hyphens, title case) const formatBookstrTitle = useCallback((title: string, event?: Event): string => { if (!event) return title // Check if this is a bookstr event const bookMetadata = extractBookMetadata(event) const isBookstr = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book if (isBookstr) { // Remove hyphens and convert to title case return title .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') } return title }, []) // Build table of contents from references (tag-derived titles before sections load) const tableOfContents = useMemo(() => { const toc: ToCItem[] = [] const coordinateOfEvent = (ev: Event): string | null => { const d = ev.tags.find((tag) => tag[0] === 'd')?.[1] if (!d) return null return `${ev.kind}:${ev.pubkey.toLowerCase()}:${d}` } const titleFromEvent = (ev: Event): string => { const titleTag = ev.tags.find((tag) => tag[0] === 'title')?.[1] if (titleTag) return titleTag const dTag = ev.tags.find((tag) => tag[0] === 'd')?.[1] if (dTag) return formatBookstrTitle(dTag, ev) return 'Untitled' } const titleFromIdentifier = (identifier: string, kind?: number) => { const raw = identifier || 'Untitled' if ( kind === ExtendedKind.PUBLICATION || kind === ExtendedKind.PUBLICATION_CONTENT || kind === kinds.LongFormArticle || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN ) { return raw .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') } return raw } const knownByCoordinate = new Map() for (const ref of referencesWithEvents) { if (!ref.event) continue const coord = coordinateOfEvent(ref.event) if (coord) knownByCoordinate.set(coord, ref.event) } for (const ev of getRenderedPublicationEventsDeep(event.id)) { const coord = coordinateOfEvent(ev) if (coord && !knownByCoordinate.has(coord)) { knownByCoordinate.set(coord, ev) } } for (const ref of referencesWithEvents) { const coord = ref.coordinate || ref.eventId || '' if (!coord) continue let title: string if (ref.event) { title = titleFromEvent(ref.event) } else if (ref.type === 'a' && ref.kind === kinds.ShortTextNote) { title = 'Note' } else if (ref.type === 'a' && ref.identifier) { title = titleFromIdentifier(ref.identifier, ref.kind) } else { title = 'Section' } const tocItem: ToCItem = { title, coordinate: coord, event: ref.event, kind: ref.kind || ref.event?.kind || 0 } // For nested 30040 publications, recursively get their ToC if (ref.kind === ExtendedKind.PUBLICATION && ref.event) { const nestedRefs: ToCItem[] = [] // Parse nested references from this publication for (const tag of ref.event.tags) { if (tag[0] === 'a' && tag[1]) { const parsed = parsePublicationATagCoordinate(tag[1]) if (!parsed) continue const kind = parsed.kind if ( kind === ExtendedKind.PUBLICATION_CONTENT || kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === kinds.LongFormArticle || kind === kinds.ShortTextNote || kind === ExtendedKind.PUBLICATION ) { const knownNestedEvent = knownByCoordinate.get(parsed.coordinate) const nestedTitle = knownNestedEvent ? titleFromEvent(knownNestedEvent) : kind === kinds.ShortTextNote ? 'Note' : titleFromIdentifier(parsed.identifier, kind) nestedRefs.push({ title: nestedTitle, coordinate: parsed.coordinate, kind, event: knownNestedEvent }) } } } if (nestedRefs.length > 0) { tocItem.children = nestedRefs } } toc.push(tocItem) } return toc }, [referencesWithEvents, formatBookstrTitle, event.id, renderedEventsVersion]) // Scroll to ToC (scroll to top of page) const scrollToToc = useCallback(() => { // Find the scrollable container (could be window or a drawer/scrollable div) let scrollContainer: HTMLElement | Window = window const tocElement = document.getElementById('publication-toc') if (tocElement) { // Walk up the DOM tree to find the scrollable container let element = tocElement.parentElement while (element && element !== document.body) { const style = window.getComputedStyle(element) const overflowY = style.overflowY // Check if this element is scrollable if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') { if (element.scrollHeight > element.clientHeight) { scrollContainer = element break } } element = element.parentElement } } // Scroll to top if (scrollContainer === window) { window.scrollTo({ top: 0, behavior: 'smooth' }) } else { (scrollContainer as HTMLElement).scrollTo({ top: 0, behavior: 'smooth' }) } }, []) // Scroll to section const scrollToSection = (coordinate: string) => { const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } useEffect(() => { void indexedDb.putReplaceableEvent(event).catch((err) => { logger.error('[PublicationIndex] Error caching publication event:', err) }) }, [event]) useEffect(() => { const loaded = referencesWithEvents .filter((r) => r.event) .map((r) => r.event!) if (loaded.length > 0) { upsertRenderedPublicationEvents(event.id, loaded) } if (loaded.length === 0) return const t = window.setTimeout(() => { void indexedDb.putPublicationWithNestedEvents(event, loaded).catch((err) => { logger.error('[PublicationIndex] Error caching publication with nested events:', err) }) }, 400) return () => clearTimeout(t) }, [referencesWithEvents, event]) const handleManualRetry = useCallback(() => { setIsRetrying(true) const keys = failedKeys.length > 0 ? failedKeys : (referencesData.map((r) => r.coordinate || r.eventId).filter(Boolean) as string[]) retryKeys(keys) window.setTimeout(() => setIsRetrying(false), 600) }, [failedKeys, referencesData, retryKeys]) return (
{/* Publication Metadata - only show for top-level publications */} {!isNested && (
Publication

{metadata.title || (isBookstrEvent ? bookMetadata.book ? bookMetadata.book .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') : 'Bookstr Publication' : 'Untitled Publication')}

{metadata.author && (
by {metadata.author}
)} {(metadata.type || metadata.version || metadata.publishedOn || metadata.publishedBy) && (
{metadata.type && Type: {metadata.type}} {metadata.version && Version: {metadata.version}} {metadata.publishedOn && Published: {metadata.publishedOn}} {metadata.publishedBy && Publisher: {metadata.publishedBy}}
)} {metadata.tags.length > 0 && (
{metadata.tags.map((tag) => ( {tag} ))}
)} {metadata.source && (
Source:{' '} {metadata.source}
)} {metadata.summary && (

{metadata.summary}

)} {/* Display image for top-level 30040 publication */} {metadata.image && (
)}
{isBookstrEvent && ( <> {bookMetadata.book && (
Book: {bookMetadata.book .split('-') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ')}
)} {bookMetadata.chapter && (
Chapter: {bookMetadata.chapter}
)} {bookMetadata.verse && (
Verse: {bookMetadata.verse}
)} {bookMetadata.version && (
Version: {bookMetadata.version.toUpperCase()}
)} )}
)} {/* Table of Contents - only show for top-level publications */} {!isNested && tableOfContents.length > 0 && (

Table of Contents

)} {/* Failed sections banner */} {!isNested && failedKeys.length > 0 && referencesWithEvents.length > 0 && (
{failedKeys.length} section{failedKeys.length !== 1 ? 's' : ''} failed to load.
)} {/* Sections */} {referencesData.length === 0 ? (
This publication index has no linked sections.
) : (
{referencesWithEvents.map((ref, index) => { const sectionKey = publicationRefKey(ref) const coordinate = ref.coordinate || ref.eventId || '' const sectionId = `section-${coordinate.replace(/:/g, '-')}` const notesLink = publicationSectionNotesLink(ref) if (!ref.event) { if (ref.loadStatus === 'error') { return (
) } return (
) } const eventKind = ref.event?.kind ?? ref.kind ?? 0 const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl if (eventKind === ExtendedKind.PUBLICATION) { const publicationTitleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1] const publicationDTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1] const publicationTitle = publicationTitleTag ? publicationTitleTag : publicationDTag ? formatBookstrTitle(publicationDTag, ref.event) : 'Publication' const publicationDepth = chapterDepth + 1 const sectionTitleClassName = publicationDepth <= 1 ? 'font-serif text-2xl md:text-3xl font-semibold leading-tight tracking-wide break-words' : publicationDepth === 2 ? 'font-serif text-xl md:text-2xl font-medium leading-tight tracking-wide break-words text-muted-foreground' : 'font-serif text-lg md:text-xl font-medium leading-tight tracking-wide break-words text-muted-foreground' const useInlinePublicationHeader = forceFlatHierarchy const publicationContainerClassName = isNested ? forceFlatHierarchy ? 'scroll-mt-24 pt-6 relative' : 'border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative' : 'scroll-mt-24 pt-6 relative' return (
{useInlinePublicationHeader ? (
{!isNested && ( )}
Section

{publicationTitle}

) : (
{!isNested && ( )}
)}
) } const renderAsAsciidoc = eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE if (renderAsAsciidoc) { return (
{!isNested && ( )}
) } // All non-publication, non-AsciiDoc section kinds use markdown renderer. return (
{!isNested && ( )}
) })}
)} {isTopLevelPublication && resolvedPublicationFootnotesContainerId && (
)}
) } // ToC Item Component - renders nested table of contents items function ToCItemComponent({ item, onItemClick, level }: { item: ToCItem onItemClick: (coordinate: string) => void level: number }) { const indentClass = level > 0 ? `ml-${level * 4}` : '' return (
  • {item.children && item.children.length > 0 && (
      {item.children.map((child, childIndex) => ( ))}
    )}
  • ) }