import { ExtendedKind } from '@/constants' import { Event } from 'nostr-tools' import { useEffect, useMemo, useState } from 'react' import { cn } from '@/lib/utils' import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' import { generateBech32IdFromATag } from '@/lib/tag' import client from '@/services/client.service' import logger from '@/lib/logger' import { Button } from '@/components/ui/button' import { MoreVertical } from 'lucide-react' import indexedDb from '@/services/indexed-db.service' import { isReplaceableEvent } from '@/lib/event' 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 } 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[] } export default function PublicationIndex({ event, className }: { event: Event className?: string }) { // 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 if (!meta.title) { meta.title = event.tags.find(tag => tag[0] === 'd')?.[1] } return meta }, [event]) const [references, setReferences] = useState([]) const [visitedIndices, setVisitedIndices] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) // Build table of contents from references const tableOfContents = useMemo(() => { const toc: ToCItem[] = [] for (const ref of references) { if (!ref.event) continue // Extract title from the event const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || ref.event.tags.find(tag => tag[0] === 'd')?.[1] || 'Untitled' const tocItem: ToCItem = { title, coordinate: ref.coordinate || ref.eventId || '', 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?.kind === ExtendedKind.PUBLICATION) && ref.event) { const nestedRefs: ToCItem[] = [] // Parse nested references from this publication (both 'a' and 'e' tags) 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 === ExtendedKind.PUBLICATION) { // For this simplified version, we'll just extract the title from the coordinate const nestedTitle = identifier || 'Untitled' nestedRefs.push({ title: nestedTitle, coordinate: tag[1], kind }) } } else if (tag[0] === 'e' && tag[1]) { // For 'e' tags, we can't extract title from the tag alone // The title will come from the fetched event if available const nestedTitle = ref.event?.tags.find(t => t[0] === 'title')?.[1] || 'Untitled' nestedRefs.push({ title: nestedTitle, coordinate: tag[1], // Use event ID as coordinate kind: ref.event?.kind }) } } if (nestedRefs.length > 0) { tocItem.children = nestedRefs } } toc.push(tocItem) } return toc }, [references]) // Scroll to section const scrollToSection = (coordinate: string) => { const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }) } } // Export publication as AsciiDoc const exportPublication = async () => { try { // Collect all content from references const contentParts: string[] = [] for (const ref of references) { if (!ref.event) continue // Extract title const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled' // For AsciiDoc, output the raw content with title contentParts.push(`= ${title}\n\n${ref.event.content}\n\n`) } const fullContent = contentParts.join('\n') const filename = `${metadata.title || 'publication'}.adoc` // Export as AsciiDoc const blob = new Blob([fullContent], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) logger.info('[PublicationIndex] Exported publication as .adoc') } catch (error) { logger.error('[PublicationIndex] Error exporting publication:', error) alert('Failed to export publication. Please try again.') } } // 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]) { // Addressable event (kind:pubkey:identifier) const [kindStr, pubkey, identifier] = tag[1].split(':') const kind = parseInt(kindStr) if (!isNaN(kind)) { refs.push({ type: 'a', coordinate: tag[1], kind, pubkey, identifier: identifier || '', relay: tag[2], eventId: tag[3] // Optional event ID for version tracking }) } } else if (tag[0] === 'e' && tag[1]) { // Event ID reference refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) } } return refs }, [event]) // Add current event to visited set const currentCoordinate = useMemo(() => { const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' return `${event.kind}:${event.pubkey}:${dTag}` }, [event]) useEffect(() => { setVisitedIndices(prev => new Set([...prev, currentCoordinate])) // Cache the current publication index event as replaceable event indexedDb.putReplaceableEvent(event).catch(err => { logger.error('[PublicationIndex] Error caching publication event:', err) }) }, [currentCoordinate, event]) // Fetch referenced events useEffect(() => { let isMounted = true const fetchReferences = async () => { setIsLoading(true) const fetchedRefs: PublicationReference[] = [] // Capture current visitedIndices at the start of the fetch const currentVisited = visitedIndices // Add a timeout to prevent infinite loading on mobile const timeout = setTimeout(() => { if (isMounted) { logger.warn('[PublicationIndex] Fetch timeout reached, setting loaded state') setIsLoading(false) } }, 30000) // 30 second timeout try { for (const ref of referencesData) { if (!isMounted) break // Skip if this is a 30040 event we've already visited (prevent circular references) if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) { if (currentVisited.has(ref.coordinate)) { logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) fetchedRefs.push({ ...ref, event: undefined }) continue } } try { let fetchedEvent: Event | undefined = undefined if (ref.type === 'a' && ref.coordinate) { // Handle addressable event (a tag) const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] const bech32Id = generateBech32IdFromATag(aTag) if (bech32Id) { // Try to get by coordinate (replaceable event) fetchedEvent = await indexedDb.getPublicationEvent(ref.coordinate) // If not found, try to fetch from relay if (!fetchedEvent) { fetchedEvent = await client.fetchEvent(bech32Id) // Save to cache as replaceable event if (fetchedEvent) { await indexedDb.putReplaceableEvent(fetchedEvent) logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate) } } else { logger.debug('[PublicationIndex] Loaded from cache by coordinate:', ref.coordinate) } } else { logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) } } else if (ref.type === 'e' && ref.eventId) { // Handle event ID reference (e tag) // Try to fetch by event ID first fetchedEvent = await client.fetchEvent(ref.eventId) if (fetchedEvent) { // Check if this is a replaceable event kind if (isReplaceableEvent(fetchedEvent.kind)) { // Save to cache as replaceable event (will be linked to master via putPublicationWithNestedEvents) await indexedDb.putReplaceableEvent(fetchedEvent) logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) } else { // For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents // Just cache them for now without master link - they'll be properly linked when we call putPublicationWithNestedEvents logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId) } } else { logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) } } if (fetchedEvent && isMounted) { fetchedRefs.push({ ...ref, event: fetchedEvent }) } else if (isMounted) { const identifier = ref.type === 'a' ? ref.coordinate : ref.eventId logger.warn('[PublicationIndex] Could not fetch event for:', identifier || 'unknown') fetchedRefs.push({ ...ref, event: undefined }) } } catch (error) { logger.error('[PublicationIndex] Error fetching reference:', error) if (isMounted) { fetchedRefs.push({ ...ref, event: undefined }) } } } if (isMounted) { setReferences(fetchedRefs) setIsLoading(false) // Store master publication with all nested events const nestedEvents = fetchedRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) if (nestedEvents.length > 0) { indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => { logger.error('[PublicationIndex] Error caching publication with nested events:', err) }) } } } finally { clearTimeout(timeout) } } if (referencesData.length > 0) { fetchReferences() } else { setIsLoading(false) } return () => { isMounted = false } }, [referencesData, visitedIndices]) // Now include visitedIndices but capture it inside return (
{/* Publication Metadata */}

{metadata.title}

{metadata.summary && (

{metadata.summary}

)}
{metadata.author && (
Author: {metadata.author}
)} {metadata.version && (
Version: {metadata.version}
)} {metadata.type && (
Type: {metadata.type}
)}
{/* Table of Contents */} {!isLoading && tableOfContents.length > 0 && (

Table of Contents

)} {/* Content - render referenced events */} {isLoading ? (
Loading publication content...
If this takes too long, the content may not be available.
) : references.length === 0 ? (
No content loaded
Unable to load publication content. The referenced events may not be available on the current relays.
) : (
{references.map((ref, index) => { if (!ref.event) { return (
Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'}
) } // Render based on event kind const coordinate = ref.coordinate || ref.eventId || '' const sectionId = `section-${coordinate.replace(/:/g, '-')}` const eventKind = ref.kind || ref.event.kind if (eventKind === ExtendedKind.PUBLICATION) { // Recursively render nested 30040 publication index return (
) } else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { // Render 30041 or 30818 content as AsciidocArticle return (
) } else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { // Render 30817 content as MarkdownArticle return (
) } else { // Fallback for other kinds - just show a placeholder return (
Reference {index + 1}: Unsupported kind {eventKind}
) } })}
)}
) } // 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) => ( ))}
    )}
  • ) }