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.
 
 
 

529 lines
19 KiB

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<PublicationMetadata>(() => {
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<PublicationReference[]>([])
const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
// Build table of contents from references
const tableOfContents = useMemo<ToCItem[]>(() => {
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 (
<div className={cn('space-y-6', className)}>
{/* Publication Metadata */}
<div className="prose prose-zinc max-w-none dark:prose-invert">
<header className="mb-8 border-b pb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>
<Button
variant="ghost"
size="icon"
className="shrink-0"
onClick={exportPublication}
title="Export as AsciiDoc"
>
<MoreVertical className="h-5 w-5" />
</Button>
</div>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed">
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
<div className="text-sm text-muted-foreground space-y-1">
{metadata.author && (
<div>
<span className="font-semibold">Author:</span> {metadata.author}
</div>
)}
{metadata.version && (
<div>
<span className="font-semibold">Version:</span> {metadata.version}
</div>
)}
{metadata.type && (
<div>
<span className="font-semibold">Type:</span> {metadata.type}
</div>
)}
</div>
</header>
</div>
{/* Table of Contents */}
{!isLoading && tableOfContents.length > 0 && (
<div className="border rounded-lg p-6 bg-muted/30">
<h2 className="text-xl font-semibold mb-4">Table of Contents</h2>
<nav>
<ul className="space-y-2">
{tableOfContents.map((item, index) => (
<ToCItemComponent
key={index}
item={item}
onItemClick={scrollToSection}
level={0}
/>
))}
</ul>
</nav>
</div>
)}
{/* Content - render referenced events */}
{isLoading ? (
<div className="text-muted-foreground">
<div>Loading publication content...</div>
<div className="text-xs mt-2">If this takes too long, the content may not be available.</div>
</div>
) : references.length === 0 ? (
<div className="p-6 border rounded-lg bg-muted/30 text-center">
<div className="text-lg font-semibold mb-2">No content loaded</div>
<div className="text-sm text-muted-foreground">
Unable to load publication content. The referenced events may not be available on the current relays.
</div>
</div>
) : (
<div className="space-y-8">
{references.map((ref, index) => {
if (!ref.event) {
return (
<div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="text-sm text-muted-foreground">
Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'}
</div>
</div>
)
}
// 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 (
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-4">
<PublicationIndex event={ref.event} />
</div>
)
} else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) {
// Render 30041 or 30818 content as AsciidocArticle
return (
<div key={index} id={sectionId} className="scroll-mt-4">
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} />
</div>
)
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
// Render 30817 content as MarkdownArticle
return (
<div key={index} id={sectionId} className="scroll-mt-4">
<MarkdownArticle event={ref.event} showImageGallery={false} />
</div>
)
} else {
// Fallback for other kinds - just show a placeholder
return (
<div key={index} className="p-4 border rounded-lg">
<div className="text-sm text-muted-foreground">
Reference {index + 1}: Unsupported kind {eventKind}
</div>
</div>
)
}
})}
</div>
)}
</div>
)
}
// 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 (
<li className={cn('list-none', indentClass)}>
<button
onClick={() => onItemClick(item.coordinate)}
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
>
{item.title}
</button>
{item.children && item.children.length > 0 && (
<ul className="mt-2 space-y-1">
{item.children.map((child, childIndex) => (
<ToCItemComponent
key={childIndex}
item={child}
onItemClick={onItemClick}
level={level + 1}
/>
))}
</ul>
)}
</li>
)
}