import { ExtendedKind } from '@/constants' import { Event, kinds, nip19 } from 'nostr-tools' import { useEffect, useMemo, useState, useCallback } 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 { upsertRenderedPublicationEvents } from '@/lib/publication-rendered-events' interface PublicationReference { coordinate?: string 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 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 }: { event: Event className?: string isNested?: boolean parentImageUrl?: 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 === '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 [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, 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) // 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 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 } for (const ref of referencesWithEvents) { const coord = ref.coordinate || ref.eventId || '' if (!coord) continue let title: string if (ref.event) { const titleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1] const dTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1] let rawTitle: string if (titleTag) rawTitle = titleTag else if (dTag) rawTitle = dTag else rawTitle = 'Untitled' title = titleTag ? rawTitle : formatBookstrTitle(rawTitle, 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 [kindStr, , identifier] = tag[1].split(':') const kind = parseInt(kindStr) if ( !isNaN(kind) && (kind === ExtendedKind.PUBLICATION_CONTENT || kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === kinds.LongFormArticle || kind === kinds.ShortTextNote || kind === ExtendedKind.PUBLICATION) ) { // For this simplified version, we'll just extract the title from the coordinate const rawNestedTitle = identifier || 'Untitled' // Format for bookstr events (check if kind is bookstr-related) const nestedTitle = kind === ExtendedKind.PUBLICATION || kind === ExtendedKind.PUBLICATION_CONTENT ? rawNestedTitle .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' ') : kind === kinds.ShortTextNote ? 'Note' : rawNestedTitle nestedRefs.push({ title: nestedTitle, coordinate: tag[1], kind }) } } } if (nestedRefs.length > 0) { tocItem.children = nestedRefs } } toc.push(tocItem) } return toc }, [referencesWithEvents, formatBookstrTitle]) // 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 && (
{metadata.title &&

{metadata.title}

} {!metadata.title && isBookstrEvent && (

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

)}
{metadata.summary && (

{metadata.summary}

)} {/* Display image for top-level 30040 publication */} {metadata.image && (
)}
{metadata.author && (
Author: {metadata.author}
)} {metadata.version && !isBookstrEvent && (
Version: {metadata.version}
)} {metadata.type && !isBookstrEvent && (
Type: {metadata.type}
)} {isBookstrEvent && ( <> {bookMetadata.type && (
Type: {bookMetadata.type}
)} {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) { return (
{!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 && ( )}
) })}
)}
) } // 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) => ( ))}
    )}
  • ) }