Browse Source

bug-fix publications

imwald
Silberengel 5 months ago
parent
commit
03b3005eee
  1. 5
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 26
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 797
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  4. 28
      src/services/indexed-db.service.ts

5
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -365,6 +365,11 @@ export default function AsciidocArticle({
</header> </header>
)} )}
{/* Show title inline when used as nested content */}
{hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div <div
ref={contentRef} ref={contentRef}

26
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -489,17 +489,21 @@ export default function MarkdownArticle({
text-decoration: underline !important; text-decoration: underline !important;
} }
`}</style> `}</style>
<div <div
ref={contentRef} ref={contentRef}
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`} className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}
> >
{!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>} {!hideMetadata && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideMetadata && metadata.summary && ( {!hideMetadata && metadata.summary && (
<blockquote> <blockquote>
<p className="break-words">{metadata.summary}</p> <p className="break-words">{metadata.summary}</p>
</blockquote> </blockquote>
)} )}
{!hideMetadata && metadata.image && (() => { {/* Show title inline when metadata is hidden (for nested content) */}
{hideMetadata && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{!hideMetadata && metadata.image && (() => {
// Find the index of the metadata image in allImages // Find the index of the metadata image in allImages
const cleanedMetadataImage = cleanUrl(metadata.image) const cleanedMetadataImage = cleanUrl(metadata.image)
const metadataImageIndex = cleanedMetadataImage const metadataImageIndex = cleanedMetadataImage

797
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -9,7 +9,7 @@ import { generateBech32IdFromATag } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { MoreVertical, RefreshCw } from 'lucide-react' import { MoreVertical, RefreshCw, ArrowUp } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager' import { useSecondaryPage } from '@/PageManager'
@ -91,79 +91,316 @@ export default function PublicationIndex({
const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([]) const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([])
const maxRetries = 5 const maxRetries = 5
// 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])
// Recursive helper function to build ToC item from a reference
const buildToCItemFromRef = useCallback((ref: PublicationReference, allReferences: PublicationReference[], visitedCoords: Set<string> = new Set()): ToCItem | null => {
if (!ref.event) {
// If no event but we have a coordinate/eventId, create placeholder
if (ref.coordinate || ref.eventId) {
return {
title: 'Loading...',
coordinate: ref.coordinate || ref.eventId || '',
kind: ref.kind || 0
}
}
return null
}
// 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 coordinate = ref.coordinate || ref.eventId || ''
const coordKey = ref.coordinate || ref.eventId || ''
// Prevent infinite recursion
if (visitedCoords.has(coordKey)) {
return null
}
visitedCoords.add(coordKey)
const tocItem: ToCItem = {
title,
coordinate,
event: ref.event,
kind: ref.kind || ref.event?.kind || 0
}
// Build children recursively - check both nestedRefs and event tags
const children: ToCItem[] = []
const processedCoords = new Set<string>()
// First, process discovered nestedRefs if they exist
if (ref.nestedRefs && ref.nestedRefs.length > 0) {
for (const nestedRef of ref.nestedRefs) {
const nestedCoord = nestedRef.coordinate || nestedRef.eventId || ''
if (nestedCoord && !processedCoords.has(nestedCoord)) {
processedCoords.add(nestedCoord)
// Look up the full reference (with fetched event) from allReferences
const fullNestedRef = allReferences.find(r =>
(r.coordinate && nestedRef.coordinate && r.coordinate === nestedRef.coordinate) ||
(r.eventId && nestedRef.eventId && r.eventId === nestedRef.eventId) ||
(r.event && nestedRef.event && r.event.id === nestedRef.event.id)
) || nestedRef
const nestedToCItem = buildToCItemFromRef(fullNestedRef, allReferences, new Set(visitedCoords))
if (nestedToCItem) {
children.push(nestedToCItem)
}
}
}
}
// Also process tags from publication events (for publications that reference other publications)
if ((ref.kind === ExtendedKind.PUBLICATION || ref.event?.kind === ExtendedKind.PUBLICATION) && ref.event) {
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)) {
const tagCoord = tag[1]
if (!processedCoords.has(tagCoord)) {
processedCoords.add(tagCoord)
// Look up the fetched event from allReferences
const fetchedNestedEvent = allReferences.find(r =>
r.coordinate === tagCoord ||
(r.type === 'a' && r.coordinate === tagCoord) ||
(r.event && r.event.kind === kind && r.event.pubkey && tagCoord.includes(r.event.pubkey))
)
if (fetchedNestedEvent) {
const nestedToCItem = buildToCItemFromRef(fetchedNestedEvent, allReferences, new Set(visitedCoords))
if (nestedToCItem) {
children.push(nestedToCItem)
}
} else {
// Event not fetched yet, create placeholder
children.push({
title: identifier || 'Untitled',
coordinate: tagCoord,
kind
})
}
}
}
} else if (tag[0] === 'e' && tag[1]) {
const eventId = tag[1]
if (!processedCoords.has(eventId)) {
processedCoords.add(eventId)
// Look up the fetched event from allReferences
const fetchedNestedEvent = allReferences.find(r =>
(r.type === 'e' && r.eventId === eventId) ||
(r.event && r.event.id === eventId) ||
(r.coordinate === eventId) ||
(r.eventId === eventId)
)
if (fetchedNestedEvent) {
const nestedToCItem = buildToCItemFromRef(fetchedNestedEvent, allReferences, new Set(visitedCoords))
if (nestedToCItem) {
children.push(nestedToCItem)
}
} else {
// Event not fetched yet, create placeholder
children.push({
title: 'Loading...',
coordinate: eventId,
kind: 0
})
}
}
}
}
}
if (children.length > 0) {
tocItem.children = children
}
return tocItem
}, [])
// Build table of contents from references // Build table of contents from references
// Also include items from main event tags if references haven't loaded yet
const tableOfContents = useMemo<ToCItem[]>(() => { const tableOfContents = useMemo<ToCItem[]>(() => {
const toc: ToCItem[] = [] const toc: ToCItem[] = []
// Build ToC from all fetched references (recursively)
for (const ref of references) { for (const ref of references) {
if (!ref.event) continue // Only add top-level references (those directly in the main event)
const isTopLevel = referencesData.some(rd =>
// Extract title from the event (rd.coordinate && ref.coordinate && rd.coordinate === ref.coordinate) ||
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || (rd.eventId && ref.eventId && rd.eventId === ref.eventId)
ref.event.tags.find(tag => tag[0] === 'd')?.[1] || )
'Untitled'
const tocItem: ToCItem = { if (isTopLevel) {
title, const tocItem = buildToCItemFromRef(ref, references)
coordinate: ref.coordinate || ref.eventId || '', if (tocItem) {
event: ref.event, toc.push(tocItem)
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) { // If no references have loaded yet, build initial ToC from main event tags
const nestedRefs: ToCItem[] = [] if (toc.length === 0 && references.length === 0) {
// Parse a and e tags from the main event
// Parse nested references from this publication (both 'a' and 'e' tags) for (const tag of event.tags) {
for (const tag of ref.event.tags) { if (tag[0] === 'a' && tag[1]) {
if (tag[0] === 'a' && tag[1]) { const [kindStr, , identifier] = tag[1].split(':')
const [kindStr, , identifier] = tag[1].split(':') const kind = parseInt(kindStr)
const kind = parseInt(kindStr)
if (!isNaN(kind) && (kind === ExtendedKind.PUBLICATION_CONTENT ||
if (!isNaN(kind) && kind === ExtendedKind.PUBLICATION_CONTENT ||
kind === ExtendedKind.WIKI_ARTICLE || kind === ExtendedKind.WIKI_ARTICLE ||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN ||
kind === ExtendedKind.PUBLICATION) { kind === ExtendedKind.PUBLICATION)) {
// For this simplified version, we'll just extract the title from the coordinate toc.push({
const nestedTitle = identifier || 'Untitled' title: identifier || 'Untitled',
coordinate: tag[1],
nestedRefs.push({ kind
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
}) })
} }
} } else if (tag[0] === 'e' && tag[1]) {
// For e-tags without fetched event, use event ID as placeholder
if (nestedRefs.length > 0) { toc.push({
tocItem.children = nestedRefs title: 'Loading...',
coordinate: tag[1],
kind: 0
})
} }
} }
toc.push(tocItem)
} }
return toc return toc
}, [references]) }, [references, event, referencesData, buildToCItemFromRef])
// Scroll to section // Scroll to ToC
const scrollToSection = (coordinate: string) => { const scrollToToc = useCallback(() => {
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) const tocElement = document.getElementById('publication-toc')
if (element) { if (tocElement) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) const rect = tocElement.getBoundingClientRect()
const elementTop = rect.top + window.scrollY
const offset = 96
const scrollPosition = Math.max(0, elementTop - offset)
window.scrollTo({ top: scrollPosition, behavior: 'smooth' })
} }
} }, [])
// Scroll to section with offset for fixed header
const scrollToSection = useCallback((coordinate: string) => {
if (!coordinate) {
console.warn('[PublicationIndex] Cannot scroll: coordinate is empty')
return
}
// For e-tags, coordinate is the event ID (hex string, no colons)
// For a-tags, coordinate is kind:pubkey:identifier (has colons)
const sectionId = `section-${coordinate.replace(/:/g, '-')}`
console.log('[PublicationIndex] Scrolling to section:', { coordinate, sectionId })
// Try to find the element, with retry for elements that might still be rendering
const findAndScroll = (attempt = 0) => {
// Try the primary section ID format
let element = document.getElementById(sectionId)
// Try alternative ID formats if primary not found
if (!element) {
const altId1 = `section-${coordinate}`
element = document.getElementById(altId1)
if (element) {
console.log('[PublicationIndex] Found section with alternative ID:', altId1)
}
}
if (element) {
// Get the element's position relative to the viewport
const rect = element.getBoundingClientRect()
const elementTop = rect.top + window.scrollY
// Account for fixed header/titlebar (typically around 4rem/64px, using 6rem/96px for safety)
const offset = 96
const scrollPosition = Math.max(0, elementTop - offset)
console.log('[PublicationIndex] Found section element, scrolling to:', {
scrollPosition,
elementTop,
elementId: element.id,
windowScrollY: window.scrollY,
rectTop: rect.top
})
// Use requestAnimationFrame for smoother scrolling
requestAnimationFrame(() => {
window.scrollTo({ top: scrollPosition, behavior: 'smooth' })
})
return true
}
// Retry up to 10 times with increasing delay if element not found (might still be rendering)
if (attempt < 10) {
const delay = Math.min(100 * (attempt + 1), 1000) // Max 1 second delay
setTimeout(() => findAndScroll(attempt + 1), delay)
return false
}
// Log all section IDs for debugging
const allSectionIds = Array.from(document.querySelectorAll('[id^="section-"]'))
.map(el => el.id)
.filter(id => id) // Filter out empty IDs
console.warn('[PublicationIndex] Section not found after retries:', {
coordinate,
sectionId,
altId1: `section-${coordinate}`,
attempt,
availableSections: allSectionIds.slice(0, 20) // Limit output
})
// Show a helpful message
console.warn(`[PublicationIndex] Could not find section with ID: ${sectionId}. Available sections:`, allSectionIds.slice(0, 10))
return false
}
findAndScroll()
}, [])
// Export publication as AsciiDoc // Export publication as AsciiDoc
const exportPublication = async () => { const exportPublication = async () => {
@ -203,37 +440,6 @@ export default function PublicationIndex({
} }
} }
// 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 // Add current event to visited set
const currentCoordinate = useMemo(() => { const currentCoordinate = useMemo(() => {
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || ''
@ -249,6 +455,147 @@ export default function PublicationIndex({
}) })
}, [currentCoordinate, event]) }, [currentCoordinate, event])
// Helper function to build comprehensive relay list
const buildComprehensiveRelayList = useCallback(async (
additionalRelays: string[] = []
): Promise<string[]> => {
const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS, SEARCHABLE_RELAY_URLS } = await import('@/constants')
const relayUrls = new Set<string>()
// Add FAST_READ_RELAY_URLS
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add additional relays (from tag relay hints)
additionalRelays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add user's favorite relays (kind 10012) and relay list (kind 10002) if logged in
try {
const userPubkey = (client as any).pubkey
if (userPubkey) {
// Fetch user's relay list (includes cache relays)
const userRelayList = await client.fetchRelayList(userPubkey)
if (userRelayList?.read) {
userRelayList.read.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Fetch user's favorite relays (kind 10012)
try {
const { ExtendedKind } = await import('@/constants')
const favoriteRelaysEvent = await (client as any).fetchReplaceableEvent?.(userPubkey, ExtendedKind.FAVORITE_RELAYS)
if (favoriteRelaysEvent) {
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]: [string, string]) => {
if (tagName === 'relay' && tagValue) {
const normalized = normalizeUrl(tagValue)
if (normalized) relayUrls.add(normalized)
}
})
}
} catch (error) {
// Ignore if favorite relays can't be fetched
}
}
} catch (error) {
// Ignore if user relay list can't be fetched
}
// Add BIG_RELAY_URLS as fallback
BIG_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add SEARCHABLE_RELAY_URLS
SEARCHABLE_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
return Array.from(relayUrls)
}, [])
// Helper function to fetch event using subscription-style query with comprehensive relay list
const fetchEventWithSubscription = useCallback(async (
filter: any,
relayUrls: string[],
logPrefix: string
): Promise<Event | undefined> => {
try {
let foundEvent: Event | undefined = undefined
let hasEosed = false
let subscriptionClosed = false
const { closer } = await client.subscribeTimeline(
[{ urls: relayUrls, filter }],
{
onEvents: (events, eosed) => {
if (events.length > 0 && !foundEvent) {
foundEvent = events[0]
logger.debug(`[PublicationIndex] Found event via ${logPrefix} subscription`)
}
if (eosed) {
hasEosed = true
}
// Close subscription once we have an event and eosed
if ((foundEvent || hasEosed) && !subscriptionClosed) {
subscriptionClosed = true
closer()
}
},
onNew: () => {} // Not needed for one-time fetch
},
{ needSort: false }
)
// Wait for up to 10 seconds for events to arrive or eosed
const startTime = Date.now()
while (!foundEvent && !hasEosed && Date.now() - startTime < 10000) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// Close subscription if still open
if (!subscriptionClosed) {
closer()
}
if (foundEvent) {
return foundEvent
}
} catch (subError) {
logger.warn(`[PublicationIndex] Subscription error for ${logPrefix}, falling back to fetchEvents:`, subError)
// Fallback to regular fetchEvents if subscription fails
const events = await client.fetchEvents(relayUrls, [filter])
if (events.length > 0) {
logger.debug(`[PublicationIndex] Found event via ${logPrefix} fetchEvents fallback`)
return events[0]
}
}
return undefined
}, [])
// Unified method to fetch event for both a and e tags
const fetchEventFromRelay = useCallback(async (
filter: any,
additionalRelays: string[],
logPrefix: string
): Promise<Event | undefined> => {
// Build comprehensive relay list
const finalRelayUrls = await buildComprehensiveRelayList(additionalRelays)
logger.debug(`[PublicationIndex] Using ${finalRelayUrls.length} relays for ${logPrefix} query`)
// Fetch using subscription-style query with comprehensive relay list
return await fetchEventWithSubscription(filter, finalRelayUrls, logPrefix)
}, [buildComprehensiveRelayList, fetchEventWithSubscription])
// Fetch a single reference with retry logic // Fetch a single reference with retry logic
const fetchSingleReference = useCallback(async ( const fetchSingleReference = useCallback(async (
ref: PublicationReference, ref: PublicationReference,
@ -292,127 +639,9 @@ export default function PublicationIndex({
filter['#d'] = [decoded.data.identifier] filter['#d'] = [decoded.data.identifier]
} }
// Use comprehensive relay list (same as initial fetch in client.service) // Build comprehensive relay list and fetch using unified method
// Build relay list: FAST_READ_RELAY_URLS, user's favorite relays, user's relay list, decoded relays, BIG_RELAY_URLS const additionalRelays = decoded.data.relays || []
const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS } = await import('@/constants') fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'naddr')
const relayUrls = new Set<string>()
// Add FAST_READ_RELAY_URLS
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add user's favorite relays (kind 10012) and relay list (kind 10002) if logged in
try {
const userPubkey = (client as any).pubkey
if (userPubkey) {
// Fetch user's relay list (includes cache relays)
const userRelayList = await client.fetchRelayList(userPubkey)
if (userRelayList?.read) {
userRelayList.read.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Fetch user's favorite relays (kind 10012)
try {
const { ExtendedKind } = await import('@/constants')
const favoriteRelaysEvent = await (client as any).fetchReplaceableEvent?.(userPubkey, ExtendedKind.FAVORITE_RELAYS)
if (favoriteRelaysEvent) {
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]: [string, string]) => {
if (tagName === 'relay' && tagValue) {
const normalized = normalizeUrl(tagValue)
if (normalized) relayUrls.add(normalized)
}
})
}
} catch (error) {
// Ignore if favorite relays can't be fetched
}
}
} catch (error) {
// Ignore if user relay list can't be fetched
}
// Add relays from decoded naddr if available
if (decoded.data.relays && decoded.data.relays.length > 0) {
decoded.data.relays.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Add BIG_RELAY_URLS as fallback
BIG_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add SEARCHABLE_RELAY_URLS (important for finding events that search page finds)
const { SEARCHABLE_RELAY_URLS } = await import('@/constants')
SEARCHABLE_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
const finalRelayUrls = Array.from(relayUrls)
logger.debug('[PublicationIndex] Using', finalRelayUrls.length, 'relays for naddr query')
// Fetch using subscription-style query (more reliable for naddr)
// Use subscribeTimeline approach for better reliability (waits for eosed signals)
// This is the same approach NoteListPage uses, which successfully finds events
try {
let foundEvent: Event | undefined = undefined
let hasEosed = false
let subscriptionClosed = false
const { closer } = await client.subscribeTimeline(
[{ urls: finalRelayUrls, filter }],
{
onEvents: (events, eosed) => {
if (events.length > 0 && !foundEvent) {
foundEvent = events[0]
logger.debug('[PublicationIndex] Found event via naddr subscription:', ref.coordinate)
}
if (eosed) {
hasEosed = true
}
// Close subscription once we have an event and eosed
if ((foundEvent || hasEosed) && !subscriptionClosed) {
subscriptionClosed = true
closer()
}
},
onNew: () => {} // Not needed for one-time fetch
},
{ needSort: false }
)
// Wait for up to 10 seconds for events to arrive or eosed
const startTime = Date.now()
while (!foundEvent && !hasEosed && Date.now() - startTime < 10000) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// Close subscription if still open
if (!subscriptionClosed) {
closer()
}
if (foundEvent) {
fetchedEvent = foundEvent
}
} catch (subError) {
logger.warn('[PublicationIndex] Subscription error, falling back to fetchEvents:', subError)
// Fallback to regular fetchEvents if subscription fails
const events = await client.fetchEvents(finalRelayUrls, [filter])
if (events.length > 0) {
fetchedEvent = events[0]
logger.debug('[PublicationIndex] Found event via naddr fetchEvents fallback:', ref.coordinate)
}
}
} }
} catch (error) { } catch (error) {
logger.warn('[PublicationIndex] Error trying naddr filter query:', error) logger.warn('[PublicationIndex] Error trying naddr filter query:', error)
@ -438,26 +667,56 @@ export default function PublicationIndex({
logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate)
} }
} else if (ref.type === 'e' && ref.eventId) { } else if (ref.type === 'e' && ref.eventId) {
// Handle event ID reference (e tag) // Handle event ID reference (e tag) - same as a tags
// Try to fetch by event ID first // First check indexedDb PUBLICATION_EVENTS store (events cached as part of publications)
if (isRetry) { const hexId = ref.eventId.length === 64 ? ref.eventId : undefined
// On retry, use force retry to try more relays if (hexId) {
fetchedEvent = await client.fetchEventForceRetry(ref.eventId) try {
} else { // Check PUBLICATION_EVENTS store first (for non-replaceable events stored with master)
fetchedEvent = await client.fetchEvent(ref.eventId) fetchedEvent = await indexedDb.getEventFromPublicationStore(hexId)
if (fetchedEvent) {
logger.debug('[PublicationIndex] Loaded from indexedDb PUBLICATION_EVENTS store by event ID:', ref.eventId)
}
} catch (error) {
logger.debug('[PublicationIndex] PUBLICATION_EVENTS store lookup failed:', error)
}
// Also check if it's a replaceable event (check by pubkey and kind if we have them)
if (!fetchedEvent && ref.kind && ref.pubkey && isReplaceableEvent(ref.kind)) {
try {
const replaceableEvent = await indexedDb.getReplaceableEvent(ref.pubkey, ref.kind)
if (replaceableEvent && replaceableEvent.id === hexId) {
fetchedEvent = replaceableEvent
logger.debug('[PublicationIndex] Loaded from indexedDb replaceable cache by event ID:', ref.eventId)
}
} catch (error) {
logger.debug('[PublicationIndex] Replaceable cache lookup failed:', error)
}
}
} }
if (fetchedEvent) { // If not found in indexedDb cache, try to fetch from relay using unified method
// Check if this is a replaceable event kind if (!fetchedEvent) {
if (isReplaceableEvent(fetchedEvent.kind)) { // Build comprehensive relay list and fetch using unified method
// Save to cache as replaceable event (will be linked to master via putPublicationWithNestedEvents) const additionalRelays = ref.relay ? [ref.relay] : []
await indexedDb.putReplaceableEvent(fetchedEvent) const filter = { ids: [hexId || ref.eventId], limit: 1 }
logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) fetchedEvent = await fetchEventFromRelay(filter, additionalRelays, 'e tag')
} else {
// For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents // Cache the fetched event if found
logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', 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
logger.debug('[PublicationIndex] Fetched non-replaceable event with ID (will link to master):', ref.eventId)
}
} }
} else { }
if (!fetchedEvent) {
logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId)
} }
} }
@ -511,13 +770,28 @@ export default function PublicationIndex({
} }
} }
return { ...ref, event: fetchedEvent, nestedRefs } // For e-tags, ensure coordinate is set to eventId if not already set
const updatedRef = { ...ref, event: fetchedEvent, nestedRefs }
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) {
updatedRef.coordinate = ref.eventId
}
return updatedRef
} else { } else {
return { ...ref, event: undefined } // For e-tags, ensure coordinate is set to eventId even if fetch failed
const updatedRef = { ...ref, event: undefined }
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) {
updatedRef.coordinate = ref.eventId
}
return updatedRef
} }
} catch (error) { } catch (error) {
logger.error('[PublicationIndex] Error fetching reference:', error) logger.error('[PublicationIndex] Error fetching reference:', error)
return { ...ref, event: undefined } // For e-tags, ensure coordinate is set to eventId even on error
const updatedRef = { ...ref, event: undefined }
if (ref.type === 'e' && ref.eventId && !updatedRef.coordinate) {
updatedRef.coordinate = ref.eventId
}
return updatedRef
} }
}, [referencesData]) }, [referencesData])
@ -826,7 +1100,7 @@ export default function PublicationIndex({
{/* Table of Contents - only show for top-level publications */} {/* Table of Contents - only show for top-level publications */}
{!isNested && !isLoading && tableOfContents.length > 0 && ( {!isNested && !isLoading && tableOfContents.length > 0 && (
<div className="border rounded-lg p-6 bg-muted/30"> <div id="publication-toc" className="border rounded-lg p-6 bg-muted/30 scroll-mt-24">
<h2 className="text-xl font-semibold mb-4">Table of Contents</h2> <h2 className="text-xl font-semibold mb-4">Table of Contents</h2>
<nav> <nav>
<ul className="space-y-2"> <ul className="space-y-2">
@ -950,28 +1224,76 @@ export default function PublicationIndex({
} }
// Render based on event kind // Render based on event kind
// Use the same coordinate logic as ToC: coordinate || eventId
// For e-tags, coordinate might be empty, so use eventId
// For a-tags, coordinate is set (kind:pubkey:identifier)
const coordinate = ref.coordinate || ref.eventId || '' const coordinate = ref.coordinate || ref.eventId || ''
const sectionId = `section-${coordinate.replace(/:/g, '-')}` const sectionId = `section-${coordinate.replace(/:/g, '-')}`
const eventKind = ref.kind || ref.event.kind const eventKind = ref.kind || ref.event.kind
// Debug: log section ID generation
logger.debug('[PublicationIndex] Rendering section:', {
coordinate,
sectionId,
hasCoordinate: !!ref.coordinate,
hasEventId: !!ref.eventId,
eventId: ref.eventId?.substring(0, 16) + '...'
})
if (eventKind === ExtendedKind.PUBLICATION) { if (eventKind === ExtendedKind.PUBLICATION) {
// Recursively render nested 30040 publication index // Recursively render nested 30040 publication index
return ( return (
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-4"> <div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<PublicationIndex event={ref.event} isNested={true} /> <PublicationIndex event={ref.event} isNested={true} />
</div> </div>
) )
} else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { } else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) {
// Render 30041 or 30818 content as AsciidocArticle // Render 30041 or 30818 content as AsciidocArticle
return ( return (
<div key={index} id={sectionId} className="scroll-mt-4"> <div key={index} id={sectionId} className="scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} /> <AsciidocArticle event={ref.event} hideImagesAndInfo={true} />
</div> </div>
) )
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { } else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
// Render 30817 content as MarkdownArticle // Render 30817 content as MarkdownArticle
return ( return (
<div key={index} id={sectionId} className="scroll-mt-4"> <div key={index} id={sectionId} className="scroll-mt-24 pt-6 relative">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="absolute top-0 right-0 opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<MarkdownArticle event={ref.event} showImageGallery={false} hideMetadata={true} /> <MarkdownArticle event={ref.event} showImageGallery={false} hideMetadata={true} />
</div> </div>
) )
@ -1004,10 +1326,19 @@ function ToCItemComponent({
}) { }) {
const indentClass = level > 0 ? `ml-${level * 4}` : '' const indentClass = level > 0 ? `ml-${level * 4}` : ''
const handleClick = () => {
if (!item.coordinate) {
console.warn('[PublicationIndex] ToC item has no coordinate:', item)
return
}
console.debug('[PublicationIndex] ToC item clicked:', { title: item.title, coordinate: item.coordinate })
onItemClick(item.coordinate)
}
return ( return (
<li className={cn('list-none', indentClass)}> <li className={cn('list-none', indentClass)}>
<button <button
onClick={() => onItemClick(item.coordinate)} onClick={handleClick}
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" 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} {item.title}

28
src/services/indexed-db.service.ts

@ -661,6 +661,34 @@ class IndexedDbService {
return Promise.resolve(undefined) return Promise.resolve(undefined)
} }
async getEventFromPublicationStore(eventId: string): Promise<Event | undefined> {
// Get event from PUBLICATION_EVENTS store by event ID
// This is used for non-replaceable events stored as part of publications
await this.initPromise
return new Promise((resolve, reject) => {
if (!this.db) {
return reject('database not initialized')
}
if (!this.db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return resolve(undefined)
}
const transaction = this.db.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const request = store.get(eventId)
request.onsuccess = () => {
transaction.commit()
const result = request.result as TValue<Event> | undefined
resolve(result?.value || undefined)
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> { async getPublicationStoreItems(storeName: string): Promise<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// For publication stores, only return master events with nested counts // For publication stores, only return master events with nested counts
await this.initPromise await this.initPromise

Loading…
Cancel
Save