From 441b91f52ece9b42af07bcf88eb997be87b1b9cb Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 11 May 2026 08:37:53 +0200 Subject: [PATCH] remove publication parser --- src/components/Note/PublicationCard.tsx | 24 +- .../PublicationIndex/PublicationIndex.tsx | 963 ------------------ src/components/Note/index.tsx | 32 +- src/components/NoteOptions/useMenuActions.tsx | 26 +- src/hooks/usePublicationSectionLoader.ts | 399 -------- src/lib/link.ts | 44 +- src/lib/publication-rendered-events.ts | 58 -- 7 files changed, 88 insertions(+), 1458 deletions(-) delete mode 100644 src/components/Note/PublicationIndex/PublicationIndex.tsx delete mode 100644 src/hooks/usePublicationSectionLoader.ts delete mode 100644 src/lib/publication-rendered-events.ts diff --git a/src/components/Note/PublicationCard.tsx b/src/components/Note/PublicationCard.tsx index a8010444..fc21f55e 100644 --- a/src/components/Note/PublicationCard.tsx +++ b/src/components/Note/PublicationCard.tsx @@ -13,10 +13,13 @@ import { ExtendedKind } from '@/constants' export default function PublicationCard({ event, - className + className, + disableNavigation = false }: { event: Event className?: string + /** When true (e.g. full note view), card is display-only; no navigate-to-note on click. */ + disableNavigation?: boolean }) { const screenSize = useScreenSizeOptional() const isSmallScreen = screenSize?.isSmallScreen ?? false @@ -33,6 +36,7 @@ export default function PublicationCard({ const handleCardClick = (e: React.MouseEvent) => { e.stopPropagation() + if (disableNavigation) return navigateToNote(toNote(event), event) } @@ -82,9 +86,12 @@ export default function PublicationCard({ if (isSmallScreen) { return (
-
{metadata.image && autoLoadMedia && ( -
{metadata.image && autoLoadMedia && ( diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx deleted file mode 100644 index 01c37833..00000000 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ /dev/null @@ -1,963 +0,0 @@ -import { ExtendedKind } from '@/constants' -import { Event, kinds, nip19 } from 'nostr-tools' -import { useEffect, useMemo, useState, useCallback, useSyncExternalStore, useRef } 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 ImageWithLightbox from '@/components/ImageWithLightbox' -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, - parentSummary, - flattenHierarchy = false, - chapterDepth = 0, - publicationFootnotesContainerId -}: { - event: Event - className?: string - isNested?: boolean - parentImageUrl?: string - parentSummary?: 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 initialSectionLoadCount = isNested ? 1 : 3 - const sectionLoadStep = isNested ? 1 : 3 - const effectiveParentSummary = metadata.summary || parentSummary - const resolvedPublicationFootnotesContainerId = useMemo( - () => - publicationFootnotesContainerId ?? - (isTopLevelPublication ? `publication-footnotes-${event.id}` : undefined), - [publicationFootnotesContainerId, isTopLevelPublication, event.id] - ) - const [isRetrying, setIsRetrying] = useState(false) - const [sectionLoadCount, setSectionLoadCount] = useState(initialSectionLoadCount) - const lazyLoadSentinelRef = useRef(null) - - // 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 { requestKeys, retryKeys, failedKeys, referencesWithEvents } = - usePublicationSectionLoader(event, referencesData, { autoLoad: false }) - 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 targetId = `section-${coordinate.replace(/:/g, '-')}` - const sectionIndex = referencesWithEvents.findIndex( - (ref) => (ref.coordinate || ref.eventId || '') === coordinate - ) - if (sectionIndex >= 0) { - setSectionLoadCount((prev) => Math.max(prev, sectionIndex + 1)) - const key = publicationRefKey(referencesWithEvents[sectionIndex] || {}) - if (key) requestKeys([key]) - } - - let attempts = 0 - const tryScroll = () => { - const element = document.getElementById(targetId) - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'start' }) - return - } - if (attempts < 8) { - attempts += 1 - window.setTimeout(tryScroll, 80) - } - } - tryScroll() - } - - - 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]) - - useEffect(() => { - setSectionLoadCount(initialSectionLoadCount) - }, [event.id, initialSectionLoadCount]) - - useEffect(() => { - const keysToRequest = referencesWithEvents - .slice(0, sectionLoadCount) - .filter((ref) => ref.loadStatus === 'idle') - .map((ref) => publicationRefKey(ref)) - .filter(Boolean) - if (keysToRequest.length > 0) { - requestKeys(keysToRequest) - } - }, [referencesWithEvents, requestKeys, sectionLoadCount]) - - useEffect(() => { - const sentinel = lazyLoadSentinelRef.current - if (!sentinel) return - if (sectionLoadCount >= referencesWithEvents.length) return - const observer = new IntersectionObserver( - (entries) => { - if (!entries[0]?.isIntersecting) return - setSectionLoadCount((prev) => Math.min(prev + sectionLoadStep, referencesWithEvents.length)) - }, - { rootMargin: '220px 0px' } - ) - observer.observe(sentinel) - return () => observer.disconnect() - }, [referencesWithEvents.length, sectionLoadCount, sectionLoadStep]) - - const visibleReferences = useMemo( - () => referencesWithEvents.slice(0, sectionLoadCount), - [referencesWithEvents, sectionLoadCount] - ) - - 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]) - - const normalizedParentImage = (parentImageUrl || '').trim() - const normalizedOwnImage = (metadata.image || '').trim() - const normalizedParentSummary = (parentSummary || '').trim() - const normalizedOwnSummary = (metadata.summary || '').trim() - const showNestedImagePreview = - isNested && - !!normalizedOwnImage && - normalizedOwnImage !== normalizedParentImage - const showNestedSummaryPreview = - isNested && - !!normalizedOwnSummary && - normalizedOwnSummary !== normalizedParentSummary - - - 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 ? { label: 'Type', value: metadata.type } : null, - metadata.version ? { label: 'Version', value: metadata.version } : null, - metadata.publishedOn ? { label: 'Published', value: metadata.publishedOn } : null, - metadata.publishedBy ? { label: 'Publisher', value: metadata.publishedBy } : null - ] - .filter((item): item is { label: string; value: string } => !!item) - .map((item, index) => ( -
- {index > 0 && ( - - · - - )} - {item.label} - {item.value} -
- ))} -
- )} - {metadata.tags.length > 0 && ( -
- {metadata.tags.map((tag) => ( - - {tag} - - ))} -
- )} - {metadata.source && ( -
- Source:{' '} - - {metadata.source} - -
- )} - {/* Display image for top-level 30040 publication */} - {metadata.image && ( -
- -
- )} - {metadata.summary && ( -
-
- Summary -
-

- {metadata.summary} -

-
- )} -
-
-
- {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()} -
- )} - - )} -
-
-
- )} - {isNested && (showNestedImagePreview || showNestedSummaryPreview) && ( -
- {showNestedImagePreview && metadata.image && ( -
- -
- )} - {showNestedSummaryPreview && metadata.summary && ( -

- {metadata.summary} -

- )} -
- )} - - {/* 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. -
- ) : ( -
- {visibleReferences.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 - const sectionSummaryTag = ref.event.tags.find((tag) => tag[0] === 'summary')?.[1] - const sectionImageTag = ref.event.tags.find((tag) => tag[0] === 'image')?.[1] - const normalizedParentSummaryForSection = (effectiveParentSummary || '').trim() - const normalizedSectionSummary = (sectionSummaryTag || '').trim() - const normalizedParentImageForSection = (effectiveParentImageUrl || '').trim() - const normalizedSectionImage = (sectionImageTag || '').trim() - const showSectionSummaryPreview = - !!normalizedSectionSummary && - normalizedSectionSummary !== normalizedParentSummaryForSection - const showSectionImagePreview = - !!normalizedSectionImage && - normalizedSectionImage !== normalizedParentImageForSection - - 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 && ( - - )} - -
- {(showSectionImagePreview || showSectionSummaryPreview) && ( -
- {showSectionImagePreview && sectionImageTag && ( -
- -
- )} - {showSectionSummaryPreview && sectionSummaryTag && ( -

- {sectionSummaryTag} -

- )} -
- )} - -
- ) - } - - // All non-publication, non-AsciiDoc section kinds use markdown renderer. - return ( -
-
- {!isNested && ( - - )} - -
- -
- ) - })} - {sectionLoadCount < referencesWithEvents.length && ( -
- )} -
- )} - {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) => ( - - ))} -
    - )} -
  • - ) -} - diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 86ee2838..1e9cef9a 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -12,7 +12,7 @@ import { shouldHideInteractions } from '@/lib/event-filtering' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { relayHintsFromEventTags } from '@/lib/relay-list-builder' -import { toNote } from '@/lib/link' +import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link' import { cn } from '@/lib/utils' import { DISCUSSION_DOWNVOTE_DISPLAY, @@ -60,7 +60,6 @@ import LiveEvent from './LiveEvent' import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' -import PublicationIndex from './PublicationIndex/PublicationIndex' import WikiCard from './WikiCard' import LongFormCard from './LongFormCard' import MutedNote from './MutedNote' @@ -73,6 +72,7 @@ import ReactionEmojiDisplay from './ReactionEmojiDisplay' import UnknownNote from './UnknownNote' import NoteKindLabel from './NoteKindLabel' import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '@/components/ui/button' import VideoNote from './VideoNote' import RelayReview from './RelayReview' import Zap from './Zap' @@ -320,11 +320,29 @@ export default function Note({ ) } else if (event.kind === ExtendedKind.PUBLICATION) { - content = showFull ? ( - - ) : ( - - ) + if (showFull) { + const naddrFull = encodeArticleLikePublicationNaddr(displayEvent) + content = ( +
    + + {naddrFull ? ( + + ) : null} +
    + ) + } else { + content = + } } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( renderEventContent() diff --git a/src/components/NoteOptions/useMenuActions.tsx b/src/components/NoteOptions/useMenuActions.tsx index 8a505a49..269f16b7 100644 --- a/src/components/NoteOptions/useMenuActions.tsx +++ b/src/components/NoteOptions/useMenuActions.tsx @@ -2,7 +2,7 @@ import { ExtendedKind, READ_ALOUD_KINDS } from '@/constants' import { getNoteBech32Id, isProtectedEvent, getRootEventHexId } from '@/lib/event' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { buildHiveTalkJoinUrl } from '@/lib/hivetalk' -import { toAlexandria } from '@/lib/link' +import { toAlexandria, encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr } from '@/lib/link' import logger from '@/lib/logger' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { @@ -55,7 +55,6 @@ import { Languages } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { nip19 } from 'nostr-tools' import { articleHasTranslatableTitle, eventHasTranslatableTextBody, @@ -509,26 +508,7 @@ export function useMenuActions({ [event.pubkey] ) - // Generate naddr for Alexandria URL - const naddr = useMemo(() => { - if (!isArticleType || !dTag) return '' - try { - const relays = event.tags - .filter(tag => tag[0] === 'relay') - .map(tag => tag[1]) - .filter(Boolean) - - return nip19.naddrEncode({ - kind: event.kind, - pubkey: event.pubkey, - identifier: dTag, - relays: relays.length > 0 ? relays : undefined - }) - } catch (error) { - logger.error('Error generating naddr', { error }) - return '' - } - }, [isArticleType, event, dTag]) + const naddr = useMemo(() => encodeArticleLikePublicationNaddr(event) ?? '', [event]) const menuActions: MenuAction[] = useMemo(() => { const rebroadcastEntirePublication = (selectedRelayUrls: string[]) => { @@ -855,7 +835,7 @@ export function useMenuActions({ const handleViewOnAlexandria = () => { if (!naddr) return closeDrawer() - window.open(`https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}`, '_blank', 'noopener,noreferrer') + openAlexandriaPublicationFromNaddr(naddr) } const handleViewOnDecentNewsroom = () => { diff --git a/src/hooks/usePublicationSectionLoader.ts b/src/hooks/usePublicationSectionLoader.ts deleted file mode 100644 index 519b269f..00000000 --- a/src/hooks/usePublicationSectionLoader.ts +++ /dev/null @@ -1,399 +0,0 @@ -import logger from '@/lib/logger' -import { - batchFetchPublicationSectionEvents, - buildPublicationSectionRelayUrls, - parsePublicationATagCoordinate, - publicationRefKey, - resolvePublicationEventIdToHex, - type PublicationSectionRef -} from '@/lib/publication-section-fetch' -import { eventService, queryService } from '@/services/client.service' -import indexedDb from '@/services/indexed-db.service' -import type { Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -type LoadStatus = 'idle' | 'loading' | 'loaded' | 'error' - -type Row = PublicationSectionRef & { - key: string - event?: Event - status: LoadStatus -} - -type CachedState = { - loaded: Map - failed: Set -} - -const indexCache = new Map() -const SINGLE_REF_TIMEOUT_MS = 6_000 - -function withTimeout(p: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timer = window.setTimeout(() => reject(new Error('timeout')), ms) - p.then( - (v) => { - clearTimeout(timer) - resolve(v) - }, - (err) => { - clearTimeout(timer) - reject(err) - } - ) - }) -} - -function signatureOfRefs(refs: PublicationSectionRef[]): string { - return refs.map((r) => publicationRefKey(r)).join('|') -} - -function dedupeRelayUrls(urls: string[]): string[] { - const out: string[] = [] - const seen = new Set() - for (const url of urls) { - const u = (url || '').trim() - if (!u || seen.has(u)) continue - seen.add(u) - out.push(u) - } - return out -} - -export function usePublicationSectionLoader( - indexEvent: Event, - refs: PublicationSectionRef[], - options?: { autoLoad?: boolean } -) { - const indexId = indexEvent.id - const refsSignature = useMemo(() => signatureOfRefs(refs), [refs]) - const [relayUrls, setRelayUrls] = useState([]) - const [fallbackRelayUrls, setFallbackRelayUrls] = useState([]) - const [rows, setRows] = useState([]) - const inflightKeysRef = useRef>(new Set()) - const autoLoadedSignatureRef = useRef(null) - const autoLoad = options?.autoLoad ?? true - - useEffect(() => { - const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() } - const next: Row[] = [] - for (const ref of refs) { - const key = publicationRefKey(ref) - if (!key) continue - const cachedEvent = cached.loaded.get(key) - if (cachedEvent) { - next.push({ ...ref, key, event: cachedEvent, status: 'loaded' }) - continue - } - if (cached.failed.has(key)) { - next.push({ ...ref, key, status: 'error' }) - continue - } - next.push({ ...ref, key, status: 'idle' }) - } - setRows(next) - }, [indexId, refsSignature, refs]) - - useEffect(() => { - let cancelled = false - ;(async () => { - const primary = await buildPublicationSectionRelayUrls(indexEvent, refs, 30, false) - if (cancelled) return - if (import.meta.env.DEV) { - logger.info('[PublicationSection] relay_urls_primary', { - indexId, - count: primary.length, - relays: primary - }) - } - setRelayUrls(primary) - - const fallback = await buildPublicationSectionRelayUrls(indexEvent, refs, 60, true) - if (cancelled) return - if (import.meta.env.DEV) { - const uniqueExtra = fallback.filter((u) => !primary.includes(u)) - logger.info('[PublicationSection] relay_urls_searchable_fallback', { - indexId, - count: fallback.length, - extraCount: uniqueExtra.length, - relays: fallback - }) - } - setFallbackRelayUrls(fallback) - })().catch((err) => { - if (import.meta.env.DEV) { - logger.warn('[PublicationSection] relay_build_failed', { - indexId, - message: err instanceof Error ? err.message : String(err) - }) - } - if (!cancelled) { - setRelayUrls([]) - setFallbackRelayUrls([]) - } - }) - return () => { - cancelled = true - } - }, [indexId, refsSignature, indexEvent, refs]) - - const applyLoadedAndFailed = useCallback( - (loaded: Map, failedKeys: string[]) => { - const cached = indexCache.get(indexId) ?? { loaded: new Map(), failed: new Set() } - for (const [k, ev] of loaded) { - cached.loaded.set(k, ev) - cached.failed.delete(k) - } - for (const k of failedKeys) { - if (!loaded.has(k)) cached.failed.add(k) - } - indexCache.set(indexId, cached) - - setRows((prev) => - prev.map((row) => { - const ev = loaded.get(row.key) - if (ev) return { ...row, event: ev, status: 'loaded' as const } - if (failedKeys.includes(row.key)) return { ...row, status: 'error' as const } - if (inflightKeysRef.current.has(row.key)) return { ...row, status: 'loading' as const } - return row - }) - ) - }, - [indexId] - ) - - const runFetch = useCallback( - async (keys: string[]) => { - const selectedRows = rows.filter((r) => keys.includes(r.key)) - if (selectedRows.length === 0) return - if (import.meta.env.DEV) { - logger.info('[PublicationSection] run_fetch_start', { - indexId, - keyCount: selectedRows.length, - keys: selectedRows.map((r) => r.key), - relayCount: relayUrls.length - }) - } - - const byDb = new Map() - const stillNeed: Row[] = [] - - await Promise.all( - selectedRows.map(async (row) => { - try { - let ev: Event | undefined - if (row.type === 'e' && row.eventId) { - const hex = resolvePublicationEventIdToHex(row.eventId) - if (hex) ev = await indexedDb.getEventFromPublicationStore(hex) - } - if (!ev && row.coordinate) { - ev = await indexedDb.getPublicationEvent(row.coordinate) - } - if (ev) byDb.set(row.key, ev) - else stillNeed.push(row) - } catch { - stillNeed.push(row) - } - }) - ) - - if (import.meta.env.DEV) { - logger.info('[PublicationSection] after_idb', { - fromDb: byDb.size, - stillNeed: stillNeed.map((r) => r.key) - }) - } - - let fromNet = new Map() - if (stillNeed.length > 0 && relayUrls.length > 0) { - fromNet = await batchFetchPublicationSectionEvents(stillNeed, relayUrls) - if (import.meta.env.DEV) { - logger.info('[PublicationSection] after_batch_fetch', { fromNet: fromNet.size }) - } - } - - const merged = new Map([...byDb, ...fromNet]) - let unresolved = stillNeed.filter((r) => !merged.has(r.key)) - - // Second pass: unresolved refs on broader searchable relay set. - if (unresolved.length > 0 && fallbackRelayUrls.length > 0) { - const fallbackOnly = fallbackRelayUrls.filter((u) => !relayUrls.includes(u)) - const relaysForFallback = fallbackOnly.length > 0 ? fallbackRelayUrls : [] - if (relaysForFallback.length > 0) { - if (import.meta.env.DEV) { - logger.info('[PublicationSection] searchable_fallback_start', { - unresolved: unresolved.map((r) => r.key), - relayCount: relaysForFallback.length - }) - } - const fromSearchFallback = await batchFetchPublicationSectionEvents( - unresolved, - relaysForFallback - ) - for (const [k, ev] of fromSearchFallback) merged.set(k, ev) - unresolved = unresolved.filter((r) => !merged.has(r.key)) - if (import.meta.env.DEV) { - logger.info('[PublicationSection] searchable_fallback_done', { - fromSearchFallback: fromSearchFallback.size, - stillNeed: unresolved.map((r) => r.key) - }) - } - } - } - const bySingle = new Map() - - await Promise.all( - unresolved.map(async (row) => { - try { - // Only `e` refs are fetched by event id; `a` refs resolve by coordinate. - if (row.type === 'e' && row.eventId) { - const ev = await withTimeout( - eventService.fetchEvent(row.eventId), - SINGLE_REF_TIMEOUT_MS - ) - if (ev) bySingle.set(row.key, ev) - return - } - if (row.coordinate) { - const parsed = parsePublicationATagCoordinate(row.coordinate) - if (parsed) { - // Relay hints in `a` tags are often stale. Keep the hint first, but also try - // current section relay sets so one dead hinted relay cannot force a false miss. - const relaysToTry = dedupeRelayUrls( - row.relay - ? [row.relay, ...relayUrls, ...fallbackRelayUrls] - : [...relayUrls, ...fallbackRelayUrls] - ) - const ev = await withTimeout( - queryService - .fetchEvents( - relaysToTry, - { - authors: [parsed.pubkey], - kinds: [parsed.kind], - '#d': [parsed.identifier], - limit: 1 - }, - { - globalTimeout: 6_000, - eoseTimeout: 1_500 - } - ) - .then((arr) => arr[0]), - SINGLE_REF_TIMEOUT_MS - ) - if (ev) { - bySingle.set(row.key, ev) - return - } - } - - // Last per-ref fallback for `a` tags: try historical snapshot id (tag[3]). - // Some publication chains point to a specific revision that is fetchable by id - // even when relays don't resolve the coordinate in current indexes. - if (row.eventId) { - const byId = await withTimeout( - eventService.fetchEvent(row.eventId), - SINGLE_REF_TIMEOUT_MS - ) - if (byId) bySingle.set(row.key, byId) - } - } - } catch { - // unresolved single-ref fallback - } - }) - ) - - for (const [k, ev] of bySingle) merged.set(k, ev) - - const failed = selectedRows - .map((r) => r.key) - .filter((k) => !merged.has(k)) - - if (import.meta.env.DEV) { - logger.info('[PublicationSection] run_fetch_done', { - indexId, - loadedCount: merged.size, - failedCount: failed.length, - failedKeys: failed - }) - } - - applyLoadedAndFailed(merged, failed) - }, - [applyLoadedAndFailed, fallbackRelayUrls, relayUrls, rows] - ) - - const requestKeys = useCallback( - (keys: string[]) => { - const unique = [...new Set(keys.filter(Boolean))] - if (unique.length === 0) return - const eligible = rows.filter((r) => unique.includes(r.key) && r.status !== 'loaded' && r.status !== 'loading') - if (eligible.length === 0) return - - const keysToLoad = eligible.map((r) => r.key) - for (const k of keysToLoad) inflightKeysRef.current.add(k) - setRows((prev) => prev.map((r) => (keysToLoad.includes(r.key) ? { ...r, status: 'loading' } : r))) - - void runFetch(keysToLoad).finally(() => { - for (const k of keysToLoad) inflightKeysRef.current.delete(k) - }) - }, - [rows, runFetch] - ) - - const retryKeys = useCallback( - (keys: string[]) => { - const unique = [...new Set(keys.filter(Boolean))] - if (unique.length === 0) return - const cached = indexCache.get(indexId) - if (cached) { - for (const key of unique) cached.failed.delete(key) - } - setRows((prev) => - prev.map((r) => (unique.includes(r.key) && r.status !== 'loaded' ? { ...r, status: 'idle' } : r)) - ) - requestKeys(unique) - }, - [indexId, requestKeys] - ) - - useEffect(() => { - if (!autoLoad) return - if (relayUrls.length === 0) return - const sig = `${indexId}:${refsSignature}` - if (autoLoadedSignatureRef.current === sig) return - const idleKeys = rows.filter((r) => r.status === 'idle').map((r) => r.key) - if (idleKeys.length === 0) return - autoLoadedSignatureRef.current = sig - if (import.meta.env.DEV) { - logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length }) - } - requestKeys(idleKeys) - }, [autoLoad, indexId, refsSignature, relayUrls, rows, requestKeys]) - - const referencesWithEvents = useMemo( - () => - rows.map((row) => ({ - ...row, - loadStatus: row.status - })), - [rows] - ) - - const failedKeys = useMemo( - () => - rows - .filter((r) => r.status === 'error') - .map((r) => r.key), - [rows] - ) - - return { - requestKeys, - retryKeys, - failedKeys, - referencesWithEvents - } -} diff --git a/src/lib/link.ts b/src/lib/link.ts index 1dc859cb..932d79db 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -1,7 +1,49 @@ -import { Event, nip19 } from 'nostr-tools' +import { Event, kinds, nip19 } from 'nostr-tools' +import { ExtendedKind } from '@/constants' import { getNoteBech32Id, isReplaceableEvent } from './event' import { TSearchParams } from '@/types' +/** Same kinds as {@link useMenuActions} `isArticleType` for naddr + Alexandria publication URLs. */ +const ALEXANDRIA_PUBLICATION_NADDR_KINDS = new Set([ + kinds.LongFormArticle, + ExtendedKind.PUBLICATION, + ExtendedKind.PUBLICATION_CONTENT, + ExtendedKind.WIKI_ARTICLE, + ExtendedKind.WIKI_ARTICLE_MARKDOWN +]) + +/** NIP-19 `naddr` for article-like replaceable events (`d` tag required). */ +export function encodeArticleLikePublicationNaddr(event: Event): string | null { + if (!ALEXANDRIA_PUBLICATION_NADDR_KINDS.has(event.kind)) return null + const d = event.tags.find((t) => t[0] === 'd')?.[1] + if (!d) return null + try { + const relays = event.tags + .filter((tag) => tag[0] === 'relay') + .map((tag) => tag[1]) + .filter(Boolean) as string[] + return nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: d, + relays: relays.length > 0 ? relays : undefined + }) + } catch { + return null + } +} + +/** Full Alexandria reader URL for a publication `naddr` (matches NoteOptions “View on Alexandria”). */ +export function getAlexandriaPublicationUrlFromNaddr(naddr: string): string { + return `https://next-alexandria.gitcitadel.eu/publication/naddr/${naddr}` +} + +export function openAlexandriaPublicationFromNaddr(naddr: string): void { + const trimmed = naddr.trim() + if (!trimmed) return + window.open(getAlexandriaPublicationUrlFromNaddr(trimmed), '_blank', 'noopener,noreferrer') +} + /** * Note URL path segment. When `eventOrId` is a 64-char hex id and `hexResolutionEvent` is a loaded * replaceable/addressable event for that note, use its naddr/nevent so links stay canonical. diff --git a/src/lib/publication-rendered-events.ts b/src/lib/publication-rendered-events.ts deleted file mode 100644 index bb4e5502..00000000 --- a/src/lib/publication-rendered-events.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Event } from 'nostr-tools' - -const renderedByPublication = new Map>() -let renderedVersion = 0 -const listeners = new Set<() => void>() - -function normId(id: string): string { - return id.trim().toLowerCase() -} - -export function upsertRenderedPublicationEvents(publicationId: string, events: Event[]): void { - const pubId = normId(publicationId) - let byId = renderedByPublication.get(pubId) - if (!byId) { - byId = new Map() - renderedByPublication.set(pubId, byId) - } - for (const ev of events) { - if (!ev?.id) continue - byId.set(normId(ev.id), ev) - } - renderedVersion += 1 - for (const listener of listeners) listener() -} - -export function subscribeRenderedPublicationEvents(listener: () => void): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -export function getRenderedPublicationEventsVersion(): number { - return renderedVersion -} - -/** - * Deep collection for nested 30040 publications that were rendered in this session. - */ -export function getRenderedPublicationEventsDeep(publicationId: string, maxDepth = 6): Event[] { - const seenPublicationIds = new Set() - const outByEventId = new Map() - - const walk = (pubIdRaw: string, depth: number) => { - const pubId = normId(pubIdRaw) - if (depth > maxDepth || seenPublicationIds.has(pubId)) return - seenPublicationIds.add(pubId) - const direct = renderedByPublication.get(pubId) - if (!direct) return - for (const ev of direct.values()) { - outByEventId.set(normId(ev.id), ev) - if (ev.kind === 30040) { - walk(ev.id, depth + 1) - } - } - } - - walk(publicationId, 0) - return [...outByEventId.values()] -}