Browse Source

bug-fix publications

imwald
Silberengel 1 week ago
parent
commit
94bd08dceb
  1. 25
      src/components/Library/LibrarySearchBar.tsx
  2. 123
      src/components/Note/PublicationCard.tsx
  3. 218
      src/components/Note/PublicationIndexMetadata.tsx
  4. 24
      src/components/Note/index.tsx
  5. 99
      src/hooks/useLibraryPublications.ts
  6. 10
      src/i18n/locales/de.ts
  7. 10
      src/i18n/locales/en.ts
  8. 61
      src/lib/event-metadata.publication-index.test.ts
  9. 76
      src/lib/event-metadata.ts
  10. 3
      src/lib/general-search-text-match.ts
  11. 65
      src/lib/index-relay-http.ts
  12. 96
      src/lib/library-publication-index.test.ts
  13. 523
      src/lib/library-publication-index.ts
  14. 14
      src/pages/primary/LibraryPage/index.tsx

25
src/components/Library/LibrarySearchBar.tsx

@ -1,7 +1,8 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Search } from 'lucide-react' import { Loader2, Search, Wifi } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function LibrarySearchBar({ export default function LibrarySearchBar({
@ -9,15 +10,20 @@ export default function LibrarySearchBar({
onSearchQueryChange, onSearchQueryChange,
showOnlyMine, showOnlyMine,
onShowOnlyMineChange, onShowOnlyMineChange,
onSearchRelays,
relaySearchLoading,
disabled disabled
}: { }: {
searchQuery: string searchQuery: string
onSearchQueryChange: (value: string) => void onSearchQueryChange: (value: string) => void
showOnlyMine: boolean showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void onShowOnlyMineChange: (value: boolean) => void
onSearchRelays?: () => void
relaySearchLoading?: boolean
disabled?: boolean disabled?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading
return ( return (
<div className="space-y-3"> <div className="space-y-3">
@ -33,6 +39,23 @@ export default function LibrarySearchBar({
aria-label={t('Library search placeholder')} aria-label={t('Library search placeholder')}
/> />
</div> </div>
{onSearchRelays ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={disabled || !canSearchRelays}
onClick={onSearchRelays}
>
{relaySearchLoading ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<Wifi className="size-4" aria-hidden />
)}
{t('Library search relays')}
</Button>
) : null}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="library-show-mine" id="library-show-mine"

123
src/components/Note/PublicationCard.tsx

@ -1,16 +1,37 @@
import { ExtendedKind } from '@/constants'
import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb' import { cardEventBodyBlurb } from '@/lib/card-event-body-blurb'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { extractBookMetadata } from '@/lib/bookstr-parser'
import {
getLongFormArticleMetadataFromEvent,
getPublicationIndexMetadataFromEvent
} from '@/lib/event-metadata'
import { persistLibraryPublicationForReading } from '@/lib/library-publication-index'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager' import { useSecondaryPageOptional, useSmartNoteNavigationOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider' import { useScreenSizeOptional } from '@/providers/ScreenSizeProvider'
import { BookOpen } from 'lucide-react'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react' import { useMemo } from 'react'
import Image from '../Image' import Image from '../Image'
import { extractBookMetadata } from '@/lib/bookstr-parser' import ArticleCardCoverImage from './ArticleCardCoverImage'
import { persistLibraryPublicationForReading } from '@/lib/library-publication-index' import PublicationIndexMetadata from './PublicationIndexMetadata'
import { ExtendedKind } from '@/constants'
function PublicationCoverFallback({ layout }: { layout: 'stacked' | 'row' }) {
return (
<div
className={cn(
'flex items-center justify-center rounded-lg bg-muted text-muted-foreground',
layout === 'stacked'
? 'mb-3 aspect-video w-full max-w-full'
: 'aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg xl:aspect-video xl:max-w-[400px]'
)}
>
<BookOpen className={layout === 'stacked' ? 'size-10' : 'size-12'} aria-hidden />
</div>
)
}
export default function PublicationCard({ export default function PublicationCard({
event, event,
@ -29,10 +50,17 @@ export default function PublicationCard({
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event) const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const indexMetadata = useMemo(
() => (event.kind === ExtendedKind.PUBLICATION ? getPublicationIndexMetadataFromEvent(event) : null),
[event]
)
const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content]) const bodyBlurb = useMemo(() => cardEventBodyBlurb(event.content), [event.content])
const summaryText = (metadata.summary?.trim() || bodyBlurb).trim() const summaryText = (metadata.summary?.trim() || bodyBlurb).trim()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book const isBookstrEvent =
(event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) &&
!!bookMetadata.book
const isPublicationIndex = event.kind === ExtendedKind.PUBLICATION && !isBookstrEvent
const handleCardClick = (e: React.MouseEvent) => { const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
@ -41,12 +69,14 @@ export default function PublicationCard({
navigateToNote(toNote(event), event) navigateToNote(toNote(event), event)
} }
const titleComponent = metadata.title ? <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> : null const titleComponent = metadata.title ? (
<div className="min-w-0 text-xl font-semibold break-words sm:line-clamp-2">{metadata.title}</div>
) : null
const formatBookName = (book: string) => { const formatBookName = (book: string) => {
return book return book
.split('-') .split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ') .join(' ')
} }
@ -84,16 +114,62 @@ export default function PublicationCard({
</div> </div>
) : null ) : null
if (isSmallScreen) { const cardShellClass = cn(
'min-w-0 rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)
if (isPublicationIndex && indexMetadata) {
const coverImage = indexMetadata.image?.trim()
const cover =
coverImage ? (
<Image
image={{ url: coverImage, pubkey: event.pubkey }}
className={
isSmallScreen
? 'mb-3 aspect-video w-full max-w-full'
: 'aspect-[4/3] h-44 max-h-44 w-auto max-w-[min(400px,42%)] min-w-0 shrink rounded-lg bg-foreground object-cover xl:aspect-video xl:max-w-[400px]'
}
classNames={
isSmallScreen ? undefined : { wrapper: 'w-auto max-w-[min(400px,42%)] shrink-0 xl:max-w-[400px]' }
}
hideIfError
holdUntilClick={!autoLoadMedia}
/>
) : (
<PublicationCoverFallback layout={isSmallScreen ? 'stacked' : 'row'} />
)
if (isSmallScreen) {
return (
<div className={cn('w-full min-w-0', className)}>
<div className={cardShellClass} onClick={disableNavigation ? undefined : handleCardClick}>
{cover}
<PublicationIndexMetadata event={event} variant="compact" />
</div>
</div>
)
}
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className={cn( className={cn(cardShellClass, 'overflow-hidden')}
'min-w-0 rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick} onClick={disableNavigation ? undefined : handleCardClick}
> >
<div className="flex min-w-0 gap-4">
{cover}
<PublicationIndexMetadata event={event} variant="compact" className="min-h-0 min-w-[10rem] flex-1 basis-0" />
</div>
</div>
</div>
)
}
if (isSmallScreen) {
return (
<div className={cn('w-full min-w-0', className)}>
<div className={cardShellClass} onClick={disableNavigation ? undefined : handleCardClick}>
{metadata.image ? ( {metadata.image ? (
<Image <Image
image={{ url: metadata.image, pubkey: event.pubkey }} image={{ url: metadata.image, pubkey: event.pubkey }}
@ -101,7 +177,14 @@ export default function PublicationCard({
hideIfError hideIfError
holdUntilClick={!autoLoadMedia} holdUntilClick={!autoLoadMedia}
/> />
) : null} ) : (
<ArticleCardCoverImage
event={event}
imageUrl={metadata.image}
autoLoadMedia={autoLoadMedia}
layout="stacked"
/>
)}
<div className="min-w-0 space-y-2 overflow-hidden"> <div className="min-w-0 space-y-2 overflow-hidden">
{titleComponent} {titleComponent}
{bookstrMetadataComponent} {bookstrMetadataComponent}
@ -117,10 +200,7 @@ export default function PublicationCard({
return ( return (
<div className={cn('w-full min-w-0', className)}> <div className={cn('w-full min-w-0', className)}>
<div <div
className={cn( className={cn(cardShellClass, 'overflow-hidden')}
'min-w-0 overflow-hidden rounded-lg border p-4 transition-colors',
disableNavigation ? '' : 'cursor-pointer hover:bg-muted/50'
)}
onClick={disableNavigation ? undefined : handleCardClick} onClick={disableNavigation ? undefined : handleCardClick}
> >
<div className="flex min-w-0 gap-4"> <div className="flex min-w-0 gap-4">
@ -132,7 +212,14 @@ export default function PublicationCard({
hideIfError hideIfError
holdUntilClick={!autoLoadMedia} holdUntilClick={!autoLoadMedia}
/> />
) : null} ) : (
<ArticleCardCoverImage
event={event}
imageUrl={metadata.image}
autoLoadMedia={autoLoadMedia}
layout="row"
/>
)}
<div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden"> <div className="min-h-0 min-w-[10rem] flex-1 basis-0 space-y-2 overflow-hidden">
{titleComponent} {titleComponent}
{bookstrMetadataComponent} {bookstrMetadataComponent}

218
src/components/Note/PublicationIndexMetadata.tsx

@ -0,0 +1,218 @@
import { ExtendedKind } from '@/constants'
import {
getPublicationIndexMetadataFromEvent,
type PublicationAuthor
} from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { cn } from '@/lib/utils'
import { useSecondaryPageOptional } from '@/PageManager'
import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia'
import { BookOpen, ExternalLink } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Image from '../Image'
function formatAuthorLine(authors: PublicationAuthor[]): string {
if (authors.length === 0) return ''
return authors
.map(({ name, role }) => {
const normalizedRole = role?.trim().toLowerCase()
if (!normalizedRole || normalizedRole === 'author') return name
return `${name} (${role})`
})
.join(' · ')
}
function formatPublicationType(type: string): string {
return type
.split(/[\s_-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(' ')
}
function sourceHostname(source: string): string {
try {
return new URL(source).hostname.replace(/^www\./, '')
} catch {
return source
}
}
function MetaChip({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground',
className
)}
>
{children}
</span>
)
}
export default function PublicationIndexMetadata({
event,
variant = 'compact',
showTitle = true,
className
}: {
event: Event
variant?: 'compact' | 'full'
showTitle?: boolean
className?: string
}) {
const { t } = useTranslation()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
const autoLoadMedia = useShouldAutoLoadMedia(event.pubkey, event)
const metadata = useMemo(() => getPublicationIndexMetadataFromEvent(event), [event])
if (event.kind !== ExtendedKind.PUBLICATION) return null
const authorLine = formatAuthorLine(metadata.authors)
const isFull = variant === 'full'
const title =
metadata.title?.trim() ||
event.tags.find((tag) => tag[0] === 'd')?.[1]?.replace(/-/g, ' ') ||
t('Publication Note')
const metaChips: React.ReactNode[] = []
if (metadata.type) {
metaChips.push(<MetaChip key="type">{formatPublicationType(metadata.type)}</MetaChip>)
}
if (metadata.language) {
metaChips.push(<MetaChip key="lang">{metadata.language.toUpperCase()}</MetaChip>)
}
if (metadata.version) {
metaChips.push(<MetaChip key="version">{t('Publication version', { version: metadata.version })}</MetaChip>)
}
if (metadata.sectionCount > 0) {
metaChips.push(
<MetaChip key="sections">
{t('Publication sections', { count: metadata.sectionCount })}
</MetaChip>
)
}
const tagsComponent =
metadata.tags.length > 0 ? (
<div className="flex min-w-0 flex-wrap gap-1">
{metadata.tags.map((tag) => (
<button
key={tag}
type="button"
className="inline-flex max-w-full min-w-0 items-center gap-0.5 rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
<span className="shrink-0">#</span>
<span className="min-w-0 truncate">{tag}</span>
</button>
))}
</div>
) : null
return (
<div className={cn('min-w-0 space-y-2', isFull && 'space-y-4', className)}>
{isFull && metadata.image?.trim() ? (
<Image
image={{ url: metadata.image.trim(), pubkey: event.pubkey }}
className="aspect-[16/10] w-full max-w-xl rounded-lg bg-foreground object-cover"
hideIfError
holdUntilClick={!autoLoadMedia}
/>
) : isFull ? (
<div className="flex aspect-[16/10] w-full max-w-xl items-center justify-center rounded-lg bg-muted text-muted-foreground">
<BookOpen className="size-14" aria-hidden />
</div>
) : null}
{showTitle ? (
<div
className={cn(
'min-w-0 font-semibold break-words text-foreground',
isFull ? 'text-2xl leading-tight sm:text-3xl' : 'text-xl sm:line-clamp-2'
)}
>
{title}
</div>
) : null}
{authorLine ? (
<div
className={cn(
'min-w-0 break-words text-muted-foreground',
isFull ? 'text-base sm:text-lg' : 'text-sm line-clamp-2'
)}
>
{authorLine}
</div>
) : null}
{metaChips.length > 0 ? (
<div className="flex min-w-0 flex-wrap gap-1.5">{metaChips}</div>
) : null}
{metadata.releaseDate ? (
<div className={cn('text-muted-foreground', isFull ? 'text-sm' : 'text-xs')}>
{t('Publication released', { date: metadata.releaseDate })}
</div>
) : null}
{metadata.summary ? (
<div
className={cn(
'min-w-0 break-words text-muted-foreground',
isFull ? 'text-base leading-relaxed' : 'text-sm line-clamp-3'
)}
>
{metadata.summary}
</div>
) : null}
{metadata.source ? (
<a
href={metadata.source}
target="_blank"
rel="noopener noreferrer"
className={cn(
'inline-flex min-w-0 max-w-full items-center gap-1.5 text-primary hover:underline',
isFull ? 'text-sm' : 'text-xs'
)}
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">{sourceHostname(metadata.source)}</span>
</a>
) : null}
{tagsComponent}
{isFull && metadata.sections.length > 0 ? (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium text-foreground">
<BookOpen className="size-4 shrink-0 text-muted-foreground" aria-hidden />
{t('Publication table of contents')}
</div>
<ol className="max-h-64 space-y-1 overflow-y-auto text-sm text-muted-foreground">
{metadata.sections.map((section, index) => (
<li key={`${section.coordinate}-${index}`} className="flex min-w-0 gap-2">
<span className="shrink-0 tabular-nums text-muted-foreground/80">{index + 1}.</span>
<span className="min-w-0 break-words">
{section.label ||
section.coordinate.split(':').pop()?.replace(/-/g, ' ') ||
section.coordinate}
</span>
</li>
))}
</ol>
</div>
) : null}
</div>
)
}

24
src/components/Note/index.tsx

@ -12,7 +12,7 @@ import {
import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks' import { mergeNip84MarkedIntervals, renderPlaintextWithNip84MergedMarks } from '@/lib/nip84-op-body-marks'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder' import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { encodeArticleLikePublicationNaddr, openAlexandriaPublicationFromNaddr, toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
DISCUSSION_DOWNVOTE_DISPLAY, DISCUSSION_DOWNVOTE_DISPLAY,
@ -66,6 +66,7 @@ import LiveEvent from './LiveEvent'
import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle'
import PublicationCard from './PublicationCard' import PublicationCard from './PublicationCard'
import PublicationIndexMetadata from './PublicationIndexMetadata'
import NostrSpecCard from './NostrSpecCard' import NostrSpecCard from './NostrSpecCard'
import WikiCard from './WikiCard' import WikiCard from './WikiCard'
import LongFormCard from './LongFormCard' import LongFormCard from './LongFormCard'
@ -76,7 +77,6 @@ import Poll from './Poll'
import NotificationEventCard from './NotificationEventCard' import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay' import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
import { Button } from '@/components/ui/button'
import VideoNote from './VideoNote' import VideoNote from './VideoNote'
import MusicTrackNote from './MusicTrackNote' import MusicTrackNote from './MusicTrackNote'
import RelayReview from './RelayReview' import RelayReview from './RelayReview'
@ -489,25 +489,7 @@ export default function Note({
) )
} else if (event.kind === ExtendedKind.PUBLICATION) { } else if (event.kind === ExtendedKind.PUBLICATION) {
if (showFull) { if (showFull) {
const naddrFull = encodeArticleLikePublicationNaddr(displayEvent) content = <PublicationIndexMetadata className="mt-2" event={displayEvent} variant="full" />
content = (
<div className="mt-2 space-y-3">
<PublicationCard event={displayEvent} disableNavigation />
{naddrFull ? (
<Button
type="button"
size="lg"
className="w-full font-semibold"
onClick={(e) => {
e.stopPropagation()
openAlexandriaPublicationFromNaddr(naddrFull)
}}
>
{t('View on Alexandria')}
</Button>
) : null}
</div>
)
} else { } else {
content = <PublicationCard className="mt-2" event={displayEvent} /> content = <PublicationCard className="mt-2" event={displayEvent} />
} }

99
src/hooks/useLibraryPublications.ts

@ -1,26 +1,43 @@
import { import {
clearAllLibraryIndexCaches, clearAllLibraryIndexCaches,
filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
buildLibraryRelayUrls, buildLibraryRelayUrls,
loadLibraryPublicationIndex, loadLibraryPublicationIndex,
type LibraryPublicationEntry peekLibrarySearchResults,
searchLibraryPublications,
searchLibraryPublicationsOnRelays,
type LibraryPublicationEntry,
type PublicationEngagementMaps
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { getTopLevelIndexEvents } from '@/lib/publication-index'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
const LOAD_TIMEOUT_MS = 120_000 const LOAD_TIMEOUT_MS = 120_000
const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
labelAddresses: new Set(),
labelEventIds: new Set(),
commentAddresses: new Set(),
highlightAddresses: new Set()
}
export function useLibraryPublications(isActive: boolean) { export function useLibraryPublications(isActive: boolean) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) const [entries, setEntries] = useState<LibraryPublicationEntry[]>([])
const [indexEvents, setIndexEvents] = useState<Event[]>([])
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [showOnlyMine, setShowOnlyMine] = useState(false) const [showOnlyMine, setShowOnlyMine] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [engagementLoading, setEngagementLoading] = useState(false) const [engagementLoading, setEngagementLoading] = useState(false)
const [searchLoading, setSearchLoading] = useState(false)
const [relaySearchLoading, setRelaySearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<LibraryPublicationEntry[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [allIndexCount, setAllIndexCount] = useState(0) const [allIndexCount, setAllIndexCount] = useState(0)
const [topLevelCount, setTopLevelCount] = useState(0) const [topLevelCount, setTopLevelCount] = useState(0)
@ -53,6 +70,7 @@ export function useLibraryPublications(isActive: boolean) {
onIndexesReady: (snapshot) => { onIndexesReady: (snapshot) => {
if (gen !== loadGenRef.current) return if (gen !== loadGenRef.current) return
setEntries(snapshot.engaged) setEntries(snapshot.engaged)
setIndexEvents(snapshot.indexEvents)
setAllIndexCount(snapshot.allIndexCount) setAllIndexCount(snapshot.allIndexCount)
setTopLevelCount(snapshot.topLevelCount) setTopLevelCount(snapshot.topLevelCount)
setLoading(false) setLoading(false)
@ -63,6 +81,8 @@ export function useLibraryPublications(isActive: boolean) {
]) ])
if (gen !== loadGenRef.current) return if (gen !== loadGenRef.current) return
setEntries(result.engaged) setEntries(result.engaged)
setIndexEvents(result.indexEvents)
setEngagement(result.engagement)
setAllIndexCount(result.allIndexCount) setAllIndexCount(result.allIndexCount)
setTopLevelCount(result.topLevelCount) setTopLevelCount(result.topLevelCount)
} finally { } finally {
@ -90,20 +110,79 @@ export function useLibraryPublications(isActive: boolean) {
void load(false) void load(false)
}, [isActive, load]) }, [isActive, load])
useEffect(() => {
const q = debouncedSearch.trim()
if (!q) {
setSearchResults(null)
setSearchLoading(false)
return
}
const cached = peekLibrarySearchResults(q, { indexEvents, engagement })
if (cached) {
setSearchResults(cached)
setSearchLoading(false)
return
}
let cancelled = false
setSearchLoading(true)
void searchLibraryPublications(q, { indexEvents, engagement }).then((results) => {
if (cancelled) return
setSearchResults(results)
setSearchLoading(false)
})
return () => {
cancelled = true
}
}, [debouncedSearch, indexEvents, engagement])
const refresh = useCallback(() => { const refresh = useCallback(() => {
void clearAllLibraryIndexCaches().then(() => load(true)) void clearAllLibraryIndexCaches().then(() => load(true))
}, [load]) }, [load])
const searchOnRelays = useCallback(async () => {
const q = searchQuery.trim()
if (!q) return
setRelaySearchLoading(true)
setError(null)
try {
const relays = await buildLibraryRelayUrls(pubkey || undefined)
const { events, mergedIndexEvents, entries, fromCache } = await searchLibraryPublicationsOnRelays(
q,
relays,
{ indexEvents, engagement }
)
setIndexEvents(mergedIndexEvents)
setAllIndexCount(mergedIndexEvents.length)
setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length)
if (import.meta.env.DEV) {
logger.info('[Library] relay search merged', {
newEvents: events.length,
fromCache
})
}
setSearchResults(entries)
} catch (e) {
const message = e instanceof Error ? e.message : 'Relay search failed'
setError(message)
if (import.meta.env.DEV) {
logger.warn('[Library] relay search failed', { message })
}
} finally {
setRelaySearchLoading(false)
}
}, [searchQuery, pubkey, indexEvents, engagement])
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
let list = entries const q = debouncedSearch.trim()
let list = q ? (searchResults ?? []) : entries
if (showOnlyMine) { if (showOnlyMine) {
list = filterLibraryPublicationsByUser(list, pubkey) list = filterLibraryPublicationsByUser(list, pubkey)
} }
if (debouncedSearch.trim()) {
list = filterLibraryPublicationsBySearch(list, debouncedSearch)
}
return list return list
}, [entries, showOnlyMine, pubkey, debouncedSearch]) }, [entries, showOnlyMine, pubkey, debouncedSearch, searchResults])
return { return {
entries: filteredEntries, entries: filteredEntries,
@ -113,9 +192,13 @@ export function useLibraryPublications(isActive: boolean) {
setShowOnlyMine, setShowOnlyMine,
loading, loading,
engagementLoading, engagementLoading,
searchLoading,
relaySearchLoading,
error, error,
allIndexCount, allIndexCount,
topLevelCount, topLevelCount,
refresh refresh,
searchOnRelays,
hasIndexData: indexEvents.length > 0
} }
} }

10
src/i18n/locales/de.ts

@ -1649,16 +1649,24 @@ export default {
'Search on Alexandria': 'Mit Alexandria suchen', 'Search on Alexandria': 'Mit Alexandria suchen',
Library: 'Bibliothek', Library: 'Bibliothek',
'Library page title': 'Bibliothek', 'Library page title': 'Bibliothek',
'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…', 'Library search placeholder': 'Publikationen nach Titel, Autor, Quelle, Tag oder Abschnitt suchen…',
'Library show only my publications': 'Nur meine Publikationen', 'Library show only my publications': 'Nur meine Publikationen',
'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.', 'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.',
'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.', 'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.',
'Library loading': 'Publikationen werden von Dokument-Relays geladen…', 'Library loading': 'Publikationen werden von Dokument-Relays geladen…',
'Library engagement loading': 'Engagement-Filter werden aktualisiert…', 'Library engagement loading': 'Engagement-Filter werden aktualisiert…',
'Library search loading': 'Publikationen werden durchsucht…',
'Library search relays': 'Relays durchsuchen',
'Library relay search loading': 'Dokument-Relays werden durchsucht…',
'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', 'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen',
'Library badge label': 'Label', 'Library badge label': 'Label',
'Library badge comment': 'Kommentar', 'Library badge comment': 'Kommentar',
'Library badge highlight': 'Markierung', 'Library badge highlight': 'Markierung',
'Publication version': 'v{{version}}',
'Publication sections_one': '{{count}} Abschnitt',
'Publication sections_other': '{{count}} Abschnitte',
'Publication released': 'Veröffentlicht {{date}}',
'Publication table of contents': 'Inhalt',
'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex', 'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex',
'libraryIndexCache.sectionBlurb': 'libraryIndexCache.sectionBlurb':
'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.', 'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.',

10
src/i18n/locales/en.ts

@ -1672,16 +1672,24 @@ export default {
'Search on Alexandria': 'Search on Alexandria', 'Search on Alexandria': 'Search on Alexandria',
Library: 'Library', Library: 'Library',
'Library page title': 'Library', 'Library page title': 'Library',
'Library search placeholder': 'Search publications by title, author, or tag…', 'Library search placeholder': 'Search publications by title, author, source, tag, or section…',
'Library show only my publications': 'Show only my publications', 'Library show only my publications': 'Show only my publications',
'Library empty': 'No publications found on your relays yet.', 'Library empty': 'No publications found on your relays yet.',
'Library empty filtered': 'No publications match your filters.', 'Library empty filtered': 'No publications match your filters.',
'Library loading': 'Loading publications from document relays…', 'Library loading': 'Loading publications from document relays…',
'Library engagement loading': 'Updating engagement filters…', 'Library engagement loading': 'Updating engagement filters…',
'Library search loading': 'Searching publications…',
'Library search relays': 'Search the relays',
'Library relay search loading': 'Searching document relays…',
'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',
'Library badge label': 'Label', 'Library badge label': 'Label',
'Library badge comment': 'Comment', 'Library badge comment': 'Comment',
'Library badge highlight': 'Highlight', 'Library badge highlight': 'Highlight',
'Publication version': 'v{{version}}',
'Publication sections_one': '{{count}} section',
'Publication sections_other': '{{count}} sections',
'Publication released': 'Released {{date}}',
'Publication table of contents': 'Contents',
'libraryIndexCache.sectionTitle': 'Library publication index', 'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb': 'libraryIndexCache.sectionBlurb':
'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.', 'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',

61
src/lib/event-metadata.publication-index.test.ts

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants'
import { getPublicationIndexMetadataFromEvent } from '@/lib/event-metadata'
import type { Event } from 'nostr-tools'
const PK = 'a'.repeat(64)
function indexEvent(tags: string[][]): Event {
return {
id: '1'.repeat(64),
kind: ExtendedKind.PUBLICATION,
pubkey: PK,
created_at: 100,
content: '',
tags,
sig: 'c'.repeat(128)
}
}
describe('getPublicationIndexMetadataFromEvent', () => {
it('extracts NKBIP-01 index tags', () => {
const event = indexEvent([
['d', 'little-clay-cart'],
['title', 'The Little Clay Cart'],
['author', 'Sudraka', 'author'],
['author', 'Arthur W. Ryder', 'translator'],
['source', 'https://www.gutenberg.org/ebooks/21020'],
['l', 'en', 'ISO-639-1'],
['release_date', 'April 10, 2007'],
['type', 'book'],
['version', '1.0'],
['summary', 'A classic Sanskrit play.'],
['a', `30041:${PK}:chapter-1`, 'wss://relay.example', 'Chapter One'],
['a', `30041:${PK}:chapter-2`]
])
const meta = getPublicationIndexMetadataFromEvent(event)
expect(meta.title).toBe('The Little Clay Cart')
expect(meta.authors).toEqual([
{ name: 'Sudraka', role: 'author' },
{ name: 'Arthur W. Ryder', role: 'translator' }
])
expect(meta.source).toBe('https://www.gutenberg.org/ebooks/21020')
expect(meta.language).toBe('en')
expect(meta.releaseDate).toBe('April 10, 2007')
expect(meta.type).toBe('book')
expect(meta.version).toBe('1.0')
expect(meta.summary).toBe('A classic Sanskrit play.')
expect(meta.sectionCount).toBe(2)
expect(meta.sections[0].label).toBe('Chapter One')
expect(meta.sections[1].label).toBeUndefined()
})
it('falls back to d-tag title casing', () => {
const event = indexEvent([['d', 'village-life-in-china'], ['a', `30041:${PK}:intro`]])
const meta = getPublicationIndexMetadataFromEvent(event)
expect(meta.title).toBe('Village Life In China')
expect(meta.sectionCount).toBe(1)
})
})

76
src/lib/event-metadata.ts

@ -655,6 +655,82 @@ export function getLongFormArticleMetadataFromEvent(event: Event) {
return { title, summary, image, tags: Array.from(tags) } return { title, summary, image, tags: Array.from(tags) }
} }
export type PublicationAuthor = {
name: string
role?: string
}
export type PublicationSectionRef = {
coordinate: string
label?: string
}
export type PublicationIndexMetadata = {
title?: string
summary?: string
image?: string
tags: string[]
authors: PublicationAuthor[]
source?: string
type?: string
version?: string
releaseDate?: string
language?: string
sectionCount: number
sections: PublicationSectionRef[]
}
/** NKBIP-01 kind 30040 index metadata from tags (content is always empty). */
export function getPublicationIndexMetadataFromEvent(event: Event): PublicationIndexMetadata {
const base = getLongFormArticleMetadataFromEvent(event)
const authors: PublicationAuthor[] = []
const sections: PublicationSectionRef[] = []
let source: string | undefined
let type: string | undefined
let version: string | undefined
let releaseDate: string | undefined
let language: string | undefined
for (const tag of event.tags) {
const name = (tag[0] || '').trim().toLowerCase()
const value = tag[1]?.trim()
if (!value) continue
if (name === 'author') {
const role = tag[2]?.trim()
authors.push({ name: value, role: role || undefined })
} else if (name === 'source') {
source = value
} else if (name === 'type') {
type = value
} else if (name === 'version') {
version = value
} else if (name === 'release_date') {
releaseDate = value
} else if (name === 'l' && !language) {
language = value
} else if (name === 'a') {
const label = tag[3]?.trim() || tag[2]?.trim()
sections.push({
coordinate: value,
label: label && !label.startsWith('wss://') && !label.startsWith('ws://') ? label : undefined
})
}
}
return {
...base,
authors,
source,
type,
version,
releaseDate,
language,
sectionCount: sections.length,
sections
}
}
export function getLiveEventMetadataFromEvent(event: Event) { export function getLiveEventMetadataFromEvent(event: Event) {
let title: string | undefined let title: string | undefined
let room: string | undefined let room: string | undefined

3
src/lib/general-search-text-match.ts

@ -46,6 +46,9 @@ const GENERAL_SEARCH_TEXT_TAG_NAMES = new Set([
'location', 'location',
'editor', 'editor',
'version', 'version',
'source',
'type',
'release_date',
'llm' 'llm'
]) ])

65
src/lib/index-relay-http.ts

@ -41,6 +41,9 @@ function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
if (f.kinds?.length) body.kinds = f.kinds if (f.kinds?.length) body.kinds = f.kinds
if (f.since != null) body.since = f.since if (f.since != null) body.since = f.since
if (f.until != null) body.until = f.until if (f.until != null) body.until = f.until
if (typeof f.search === 'string' && f.search.trim()) {
body.search = f.search.trim()
}
/** Index relays expect NIP-01 lowercase single-letter tag keys (`#e` not `#E`). */ /** Index relays expect NIP-01 lowercase single-letter tag keys (`#e` not `#E`). */
const tagBuckets = new Map<string, string[]>() const tagBuckets = new Map<string, string[]>()
for (const key of Object.keys(f)) { for (const key of Object.keys(f)) {
@ -417,6 +420,68 @@ export async function queryIndexRelayForLibrary(
} }
} }
/** Kind-30040 discovery search: keeps NIP-50 `search` (unlike bulk {@link queryIndexRelayForLibrary}). */
export async function queryIndexRelayPublicationSearch(
baseUrl: string,
filter: Filter,
options?: { signal?: AbortSignal }
): Promise<TIndexRelayLibraryPage> {
const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayFilterUrl(base)
if (shouldSkipDevIndexRelayFetch(endpoint)) {
return { events: [], apiRowCount: 0 }
}
const body = nostrFilterToIndexRelayBody(filter)
try {
const res = await fetchWithTimeout(endpoint, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
signal: options?.signal,
timeoutMs: 25_000
})
if (!res.ok) {
if (res.status >= 500) {
markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint)
throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`))
}
return { events: [], apiRowCount: 0 }
}
clearDevIndexRelayUnavailableThisSession()
const json = (await res.json()) as { data?: unknown }
const data = json.data
if (!Array.isArray(data)) return { events: [], apiRowCount: 0 }
const events: NEvent[] = []
const seen = new Set<string>()
for (const item of data) {
if (!item || typeof item !== 'object') continue
const ev = rawToIndexRelayEvent(item as Record<string, unknown>)
if (ev && !seen.has(ev.id)) {
seen.add(ev.id)
events.push(ev)
}
}
return { events, apiRowCount: data.length }
} catch (e) {
if ((e as Error).name === 'AbortError') throw e
if (e instanceof IndexRelayTransportError) throw e
if (isIndexRelayTransportFailure(e)) {
handleFilterTransportFailure(endpoint, e)
throw new IndexRelayTransportError(e)
}
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication search request error', {
endpoint,
error: e
})
return { events: [], apiRowCount: 0 }
}
}
function filterForIndexRelay(f: Filter): Filter { function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown } const rest = { ...f } as Filter & { search?: unknown }
delete rest.search delete rest.search

96
src/lib/library-publication-index.test.ts

@ -2,10 +2,17 @@ import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildEngagementMapsFromEvents, buildEngagementMapsFromEvents,
buildLibraryPublicationRelaySearchFilters,
buildRecentPublicationEntries, buildRecentPublicationEntries,
clearLibrarySearchSessionCache,
filterEngagedPublications, filterEngagedPublications,
filterLibraryPublicationsBySearch, filterLibraryPublicationsBySearch,
pickLibraryPublicationEntries pickLibraryPublicationEntries,
peekLibrarySearchResults,
publicationIndexMatchesSearchQuery,
publicationQueryDTagVariants,
searchLibraryPublicationIndex,
searchLibraryPublications
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { buildIndexByAddress } from '@/lib/publication-index' import { buildIndexByAddress } from '@/lib/publication-index'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -84,6 +91,93 @@ describe('library-publication-index', () => {
expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0) expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0)
}) })
it('publicationIndexMatchesSearchQuery matches author, source, and section labels', () => {
const root = indexEvent('book', [`30041:${PK}:intro`])
root.tags.push(['author', 'Sudraka', 'author'])
root.tags.push(['source', 'https://www.gutenberg.org/ebooks/21020'])
root.tags.push(['type', 'book'])
root.tags.push(['a', `30041:${PK}:intro`, 'wss://relay.example', 'Introduction'])
expect(publicationIndexMatchesSearchQuery(root, 'sudraka')).toBe(true)
expect(publicationIndexMatchesSearchQuery(root, 'gutenberg')).toBe(true)
expect(publicationIndexMatchesSearchQuery(root, 'introduction')).toBe(true)
expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false)
})
it('buildLibraryPublicationRelaySearchFilters uses kind 30040 for d-tag and search', () => {
expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china')
const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
expect(filters.length).toBeGreaterThan(0)
expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe(
true
)
const dFilter = filters.find((f) => f['#d'])
expect(dFilter?.['#d']).toContain('village-life-in-china')
const searchFilter = filters.find((f) => f.search === 'Village Life in China')
expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION])
})
it('searchLibraryPublications caches results for repeated queries', async () => {
clearLibrarySearchSessionCache()
const root = indexEvent('book', [`30041:${PK}:intro`])
root.tags = [['d', 'book'], ['title', 'Title book'], ['a', `30041:${PK}:intro`]]
const indexEvents = [root]
const engagement = buildEngagementMapsFromEvents([], [], [])
const first = await searchLibraryPublications('title book', { indexEvents, engagement })
expect(first).toHaveLength(1)
const peeked = peekLibrarySearchResults('title book', { indexEvents, engagement })
expect(peeked?.map((e) => e.event.id)).toEqual([root.id])
const second = await searchLibraryPublications('title book', { indexEvents, engagement })
expect(second.map((e) => e.event.id)).toEqual([root.id])
})
it('searchLibraryPublications cache invalidates when index corpus changes', async () => {
clearLibrarySearchSessionCache()
const root = indexEvent('book', [`30041:${PK}:intro`])
root.tags = [['d', 'book'], ['title', 'Title book'], ['a', `30041:${PK}:intro`]]
const other = indexEvent('other', [`30041:${PK}:ch`])
other.tags = [['d', 'other'], ['title', 'Other title'], ['a', `30041:${PK}:ch`]]
const engagement = buildEngagementMapsFromEvents([], [], [])
await searchLibraryPublications('title book', { indexEvents: [root, other], engagement })
expect(peekLibrarySearchResults('title book', { indexEvents: [root, other], engagement })).toHaveLength(1)
expect(peekLibrarySearchResults('title book', { indexEvents: [root], engagement })).toBeNull()
const results = await searchLibraryPublications('other title', {
indexEvents: [root, other],
engagement
})
expect(results).toHaveLength(1)
expect(results[0].event.id).toBe(other.id)
})
it('searchLibraryPublicationIndex searches all indexes and maps nested hits to roots', () => {
const leafAddr = `30041:${PK}:chapter-1`
const childAddr = `30040:${PK}:part-1`
const root = indexEvent('book', [childAddr])
root.tags = [['d', 'book'], ['title', 'Root Book Title'], ['a', childAddr]]
const child = indexEvent('part-1', [leafAddr], '2'.repeat(64))
child.tags = [
['d', 'part-1'],
['title', 'Part One'],
['a', leafAddr, 'wss://relay.example', 'Chapter One']
]
const indexEvents = [root, child]
const indexByAddress = buildIndexByAddress(indexEvents)
const byRootTitle = searchLibraryPublicationIndex('root book', indexEvents, indexByAddress)
expect(byRootTitle.map((ev) => ev.id)).toEqual([root.id])
const bySection = searchLibraryPublicationIndex('chapter one', indexEvents, indexByAddress)
expect(bySection.map((ev) => ev.id)).toEqual([root.id])
})
it('pickLibraryPublicationEntries falls back to newest roots without engagement', () => { it('pickLibraryPublicationEntries falls back to newest roots without engagement', () => {
const older = indexEvent('old-book', [`30041:${PK}:a`], '1'.repeat(64)) const older = indexEvent('old-book', [`30041:${PK}:a`], '1'.repeat(64))
older.created_at = 10 older.created_at = 10

523
src/lib/library-publication-index.ts

@ -1,12 +1,20 @@
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants'
import {
eventMatchesGeneralSearchQuery,
generalSearchHaystack,
generalSearchQueryTerms,
normalizeGeneralSearchQuery
} from '@/lib/general-search-text-match'
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { queryIndexRelay, queryIndexRelayForLibrary } from '@/lib/index-relay-http' import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import { import {
buildIndexByAddress, buildIndexByAddress,
collectPublicationIndexEventIds, collectPublicationIndexEventIds,
collectReachableAddressesCached, collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
filterValidIndexEvents, filterValidIndexEvents,
getReferencedChild30040Addresses,
getTopLevelIndexEvents, getTopLevelIndexEvents,
hydrateNestedIndexEvents hydrateNestedIndexEvents
} from '@/lib/publication-index' } from '@/lib/publication-index'
@ -37,6 +45,9 @@ const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64 const HYDRATE_MISSING_CAP = 64
export const LIBRARY_RECENT_FALLBACK_LIMIT = 120 export const LIBRARY_RECENT_FALLBACK_LIMIT = 120
const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000 const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100
const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000
const QUERY_OPTS = { const QUERY_OPTS = {
globalTimeout: 18_000, globalTimeout: 18_000,
eoseTimeout: 3_000, eoseTimeout: 3_000,
@ -67,6 +78,69 @@ type LibraryIndexCache = {
let sessionCache: LibraryIndexCache | null = null let sessionCache: LibraryIndexCache | null = null
type LibrarySearchSessionRow = {
fingerprint: string
entries: LibraryPublicationEntry[]
mergedIndexEvents: Event[]
relaySearched: boolean
}
const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>()
function librarySearchQueryKey(query: string): string {
return normalizeGeneralSearchQuery(query).toLowerCase()
}
function librarySearchFingerprint(context: LibrarySearchContext): string {
const engagement = context.engagement
const engagementSize = engagement
? engagement.labelAddresses.size +
engagement.labelEventIds.size +
engagement.commentAddresses.size +
engagement.highlightAddresses.size
: 0
return `${context.indexEvents.length}:${engagementSize}`
}
function getLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
opts?: { requireRelaySearch?: boolean }
): LibrarySearchSessionRow | null {
const key = librarySearchQueryKey(query)
if (!key) return null
const row = librarySearchSessionCache.get(key)
if (!row) return null
if (row.fingerprint !== librarySearchFingerprint(context)) return null
if (opts?.requireRelaySearch && !row.relaySearched) return null
return row
}
function putLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
row: Omit<LibrarySearchSessionRow, 'fingerprint'>
): void {
const key = librarySearchQueryKey(query)
if (!key) return
librarySearchSessionCache.set(key, {
...row,
fingerprint: librarySearchFingerprint(context)
})
}
/** Sync read of cached search hits for the current index + engagement snapshot. */
export function peekLibrarySearchResults(
query: string,
context: LibrarySearchContext
): LibraryPublicationEntry[] | null {
return getLibrarySearchSessionRow(query, context)?.entries ?? null
}
export function clearLibrarySearchSessionCache(): void {
librarySearchSessionCache.clear()
}
function relaySetKey(urls: string[]): string { function relaySetKey(urls: string[]): string {
return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|') return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|')
} }
@ -448,8 +522,201 @@ export function sortLibraryPublications(entries: LibraryPublicationEntry[]): Lib
}) })
} }
function normalizeSearchQuery(query: string): string { const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
return query.trim().toLowerCase() labelAddresses: new Set(),
labelEventIds: new Set(),
commentAddresses: new Set(),
highlightAddresses: new Set()
}
/** Haystack for kind-30040 index search: general fields plus section refs and language tags. */
export function publicationIndexSearchHaystack(event: Event): string {
const base = generalSearchHaystack(event)
if (event.kind !== ExtendedKind.PUBLICATION) return base
const extra: string[] = []
for (const tag of event.tags ?? []) {
const name = (tag[0] || '').trim().toLowerCase()
if (name === 'l' && tag[1]?.trim()) {
extra.push(tag[1].trim())
} else if (name === 'a') {
const coord = tag[1]?.trim()
if (coord) extra.push(coord.replace(/:/g, ' ').replace(/-/g, ' '))
const label = tag[3]?.trim() || (tag[2]?.trim() && !/^wss?:\/\//i.test(tag[2]) ? tag[2].trim() : '')
if (label) extra.push(label)
}
}
if (extra.length === 0) return base
return `${base}\n${extra.join('\n')}`.toLowerCase()
}
export function publicationIndexMatchesSearchQuery(event: Event, query: string): boolean {
if (eventMatchesGeneralSearchQuery(event, query)) return true
if (event.kind !== ExtendedKind.PUBLICATION) return false
const raw = query.trim()
if (!raw) return false
const haystack = publicationIndexSearchHaystack(event)
const normalized = normalizeGeneralSearchQuery(raw).toLowerCase()
const qSpace = normalized.replace(/-/g, ' ')
const needles = qSpace !== normalized ? [normalized, qSpace] : [normalized]
for (const needle of needles) {
if (needle && haystack.includes(needle)) return true
}
const words = generalSearchQueryTerms(raw)
if (words.length >= 2 && words.every((w) => haystack.includes(w))) return true
return false
}
function buildAddressToRootMap(
topLevel: Event[],
indexByAddress: Map<string, Event>
): Map<string, Event> {
const map = new Map<string, Event>()
for (const root of topLevel) {
const rootAddr = eventTagAddress(root)
if (rootAddr) map.set(rootAddr, root)
for (const addr of collectReachableAddressesCached(root, indexByAddress)) {
map.set(addr, root)
}
}
return map
}
function libraryEntriesFromRoots(
roots: Event[],
indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] {
return roots.map((root) => {
const engaged = filterEngagedPublications([root], indexByAddress, engagement)
if (engaged.length > 0) return engaged[0]
return {
event: root,
hasLabel: false,
hasComment: false,
hasHighlight: false,
engagementCount: 0
}
})
}
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex(
query: string,
indexEvents: Event[],
indexByAddress: Map<string, Event>
): Event[] {
const q = query.trim()
if (!q || indexEvents.length === 0) return []
const topLevel = getTopLevelIndexEvents(indexEvents)
const topLevelIds = new Set(topLevel.map((ev) => ev.id))
const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
const roots = new Map<string, Event>()
for (const ev of indexEvents) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (topLevelIds.has(ev.id)) {
roots.set(ev.id, ev)
continue
}
const addr = eventTagAddress(ev)
const root = addr ? addressToRoot.get(addr) : undefined
if (root) roots.set(root.id, root)
}
return [...roots.values()]
}
export type LibrarySearchContext = {
indexEvents: Event[]
engagement?: PublicationEngagementMaps
}
/**
* Search publications across the library index cache (all loaded kind-30040 rows) and the
* publication reading cache ({@link StoreNames.PUBLICATION_EVENTS}).
*/
export async function searchLibraryPublications(
query: string,
context: LibrarySearchContext
): Promise<LibraryPublicationEntry[]> {
const q = query.trim()
if (!q) return []
const cached = getLibrarySearchSessionRow(q, context)
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] search cache hit', { query: q, relaySearched: cached.relaySearched })
}
return cached.entries
}
let indexEvents = context.indexEvents
if (indexEvents.length === 0) {
const cachedIndex = await loadLibraryIndexCacheEvents()
indexEvents = filterValidIndexEvents(cachedIndex)
}
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const indexByAddress = buildIndexByAddress(indexEvents)
const fromIndex = searchLibraryPublicationIndex(q, indexEvents, indexByAddress)
const rootMap = new Map<string, Event>()
for (const root of fromIndex) rootMap.set(root.id, root)
const topLevel = getTopLevelIndexEvents(indexEvents)
const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
try {
const fromReadingCache = await indexedDb.getCachedEventsForSearch(
q,
LIBRARY_SEARCH_READING_CACHE_LIMIT,
[ExtendedKind.PUBLICATION],
{ scanBudget: 12_000, collectCap: 400 }
)
for (const ev of fromReadingCache) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (rootMap.has(ev.id)) continue
const addr = eventTagAddress(ev)
const indexedRoot = addr ? addressToRoot.get(addr) : undefined
if (indexedRoot) {
rootMap.set(indexedRoot.id, indexedRoot)
continue
}
if (filterValidIndexEvents([ev]).length === 0) continue
const referenced = getReferencedChild30040Addresses(indexEvents)
if (addr && referenced.has(addr)) continue
rootMap.set(ev.id, ev)
}
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] reading-cache search failed', {
message: e instanceof Error ? e.message : String(e)
})
}
}
const roots = [...rootMap.values()]
const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement))
const searchContext: LibrarySearchContext = { indexEvents, engagement }
const prev = getLibrarySearchSessionRow(q, searchContext)
putLibrarySearchSessionRow(q, searchContext, {
entries,
mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
relaySearched: prev?.relaySearched ?? false
})
return entries
} }
function tryNpubFromQuery(query: string): string | null { function tryNpubFromQuery(query: string): string | null {
@ -466,11 +733,228 @@ function tryNpubFromQuery(query: string): string | null {
return null return null
} }
/** NIP-54-style d-tag slug (matches publication draft normalization). */
function normalizePublicationDTag(term: string): string {
return term
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
/** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */
export function publicationQueryDTagVariants(query: string): string[] {
const raw = query.trim()
if (!raw) return []
const seen = new Set<string>()
const add = (value: string) => {
const v = value.trim().toLowerCase()
if (v) seen.add(v)
}
add(normalizeToDTag(raw))
add(normalizePublicationDTag(raw))
add(raw.toLowerCase().replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''))
return [...seen]
}
/**
* OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search`
* (title, author, summary/description on index relays).
*/
export function buildLibraryPublicationRelaySearchFilters(opts: {
query: string
limit?: number
}): Filter[] {
const searchRaw = opts.query.trim()
if (!searchRaw) return []
const limit = Math.max(1, Math.min(opts.limit ?? LIBRARY_RELAY_SEARCH_LIMIT, 100))
const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>()
const out: Filter[] = []
const add = (filter: Filter) => {
const key = JSON.stringify(filter)
if (seen.has(key)) return
seen.add(key)
out.push(filter)
}
const npub = tryNpubFromQuery(searchRaw)
if (npub) {
add({ kinds: [kind], authors: [npub], limit })
return out
}
const dTags = publicationQueryDTagVariants(searchRaw)
if (dTags.length > 0) {
add({ kinds: [kind], '#d': dTags, limit })
}
const searchNorm = normalizeGeneralSearchQuery(searchRaw)
add({ kinds: [kind], search: searchRaw, limit })
if (searchNorm !== searchRaw) {
add({ kinds: [kind], search: searchNorm, limit })
}
const adv = parseAdvancedSearch(searchRaw)
const titleValues = adv.title
? Array.isArray(adv.title)
? adv.title
: [adv.title]
: []
for (const title of titleValues) {
const t = title.trim()
if (!t) continue
add({ kinds: [kind], search: t, limit })
const titleDTags = publicationQueryDTagVariants(t)
if (titleDTags.length > 0) {
add({ kinds: [kind], '#d': titleDTags, limit })
}
}
const authorValues = adv.author
? Array.isArray(adv.author)
? adv.author
: [adv.author]
: []
for (const author of authorValues) {
const a = author.trim()
if (a) add({ kinds: [kind], search: a, limit })
}
const descriptionValues = adv.description
? Array.isArray(adv.description)
? adv.description
: [adv.description]
: []
for (const description of descriptionValues) {
const d = description.trim()
if (d) add({ kinds: [kind], search: d, limit })
}
return out
}
/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
export async function searchLibraryPublicationsOnRelays(
query: string,
relayUrls: string[],
context: LibrarySearchContext,
options?: { forceRefresh?: boolean }
): Promise<{
events: Event[]
entries: LibraryPublicationEntry[]
mergedIndexEvents: Event[]
fromCache: boolean
}> {
const q = query.trim()
if (!q) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
if (!options?.forceRefresh) {
const cached = getLibrarySearchSessionRow(q, context, { requireRelaySearch: true })
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] relay search cache hit', { query: q })
}
return {
events: [],
entries: cached.entries,
mergedIndexEvents: cached.mergedIndexEvents,
fromCache: true
}
}
}
const filters = buildLibraryPublicationRelaySearchFilters({ query: q })
if (filters.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const indexRelays = libraryIndexRelayUrls(relayUrls)
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
const batches: Promise<Event[]>[] = []
if (wsRelays.length > 0) {
batches.push(
queryService
.fetchEvents(wsRelays, filters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000,
firstRelayResultGraceMs: false
})
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', {
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
for (const httpRelay of httpRelays) {
for (const filter of filters) {
batches.push(
queryIndexRelayPublicationSearch(httpRelay, filter)
.then((page) => page.events as Event[])
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication search failed', {
relay: httpRelay,
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
}
const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat())
const valid = filterValidIndexEvents(networkEvents)
if (valid.length > 0) {
void persistLibraryIndexCacheEvents(valid)
}
const mergedIndex = dedupeEventsById([...(context.indexEvents ?? []), ...valid])
const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const entries = sortLibraryPublications(
libraryEntriesFromRoots(roots, indexByAddress, engagement)
)
const searchContext: LibrarySearchContext = {
indexEvents: mergedIndex,
engagement
}
putLibrarySearchSessionRow(q, searchContext, {
entries,
mergedIndexEvents: mergedIndex,
relaySearched: true
})
if (import.meta.env.DEV) {
logger.info('[Library] relay search done', {
filters: filters.length,
network: networkEvents.length,
valid: valid.length,
roots: roots.length
})
}
return { events: valid, entries, mergedIndexEvents: mergedIndex, fromCache: false }
}
export function filterLibraryPublicationsBySearch( export function filterLibraryPublicationsBySearch(
entries: LibraryPublicationEntry[], entries: LibraryPublicationEntry[],
query: string query: string
): LibraryPublicationEntry[] { ): LibraryPublicationEntry[] {
const q = normalizeSearchQuery(query) const q = query.trim()
if (!q) return entries if (!q) return entries
const npub = tryNpubFromQuery(q) const npub = tryNpubFromQuery(q)
@ -478,20 +962,7 @@ export function filterLibraryPublicationsBySearch(
return entries.filter(({ event }) => event.pubkey.toLowerCase() === npub) return entries.filter(({ event }) => event.pubkey.toLowerCase() === npub)
} }
return entries.filter(({ event }) => { return entries.filter(({ event }) => publicationIndexMatchesSearchQuery(event, q))
const title = event.tags.find((t) => t[0] === 'title')?.[1]?.toLowerCase() ?? ''
const author = event.tags.find((t) => t[0] === 'author')?.[1]?.toLowerCase() ?? ''
const nip05 = event.tags.find((t) => t[0] === 'nip05')?.[1]?.toLowerCase() ?? ''
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]?.toLowerCase() ?? ''
const pubkey = event.pubkey.toLowerCase()
return (
title.includes(q) ||
author.includes(q) ||
nip05.includes(q) ||
dTag.includes(q) ||
pubkey.includes(q)
)
})
} }
export function filterLibraryPublicationsByUser( export function filterLibraryPublicationsByUser(
@ -550,12 +1021,15 @@ export async function loadLibraryPublicationIndex(
engaged: LibraryPublicationEntry[] engaged: LibraryPublicationEntry[]
allIndexCount: number allIndexCount: number
topLevelCount: number topLevelCount: number
indexEvents: Event[]
}) => void }) => void
} }
): Promise<{ ): Promise<{
engaged: LibraryPublicationEntry[] engaged: LibraryPublicationEntry[]
allIndexCount: number allIndexCount: number
topLevelCount: number topLevelCount: number
indexEvents: Event[]
engagement: PublicationEngagementMaps
}> { }> {
const key = relaySetKey(relayUrls) const key = relaySetKey(relayUrls)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -575,7 +1049,9 @@ export async function loadLibraryPublicationIndex(
return { return {
engaged, engaged,
allIndexCount: sessionCache.indexEvents.length, allIndexCount: sessionCache.indexEvents.length,
topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length,
indexEvents: sessionCache.indexEvents,
engagement: sessionCache.engagement
} }
} }
@ -590,7 +1066,8 @@ export async function loadLibraryPublicationIndex(
options?.onIndexesReady?.({ options?.onIndexesReady?.({
engaged: buildRecentPublicationEntries(topLevel), engaged: buildRecentPublicationEntries(topLevel),
allIndexCount: indexEvents.length, allIndexCount: indexEvents.length,
topLevelCount: topLevel.length topLevelCount: topLevel.length,
indexEvents
}) })
const topLevelForHydrate = topLevel const topLevelForHydrate = topLevel
@ -669,17 +1146,21 @@ export async function loadLibraryPublicationIndex(
return { return {
engaged, engaged,
allIndexCount: indexEvents.length, allIndexCount: indexEvents.length,
topLevelCount: topLevel.length topLevelCount: topLevel.length,
indexEvents,
engagement
} }
} }
export function clearLibraryPublicationIndexCache(): void { export function clearLibraryPublicationIndexCache(): void {
sessionCache = null sessionCache = null
clearLibrarySearchSessionCache()
} }
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */ /** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
export async function clearAllLibraryIndexCaches(): Promise<void> { export async function clearAllLibraryIndexCaches(): Promise<void> {
sessionCache = null sessionCache = null
clearLibrarySearchSessionCache()
await clearLibraryIndexIdbCache() await clearLibraryIndexIdbCache()
} }

14
src/pages/primary/LibraryPage/index.tsx

@ -22,10 +22,14 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
setShowOnlyMine, setShowOnlyMine,
loading, loading,
engagementLoading, engagementLoading,
searchLoading,
relaySearchLoading,
error, error,
allIndexCount, allIndexCount,
topLevelCount, topLevelCount,
refresh refresh,
searchOnRelays,
hasIndexData
} = useLibraryPublications(isActive) } = useLibraryPublications(isActive)
useImperativeHandle( useImperativeHandle(
@ -60,7 +64,9 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
showOnlyMine={showOnlyMine} showOnlyMine={showOnlyMine}
onShowOnlyMineChange={setShowOnlyMine} onShowOnlyMineChange={setShowOnlyMine}
disabled={loading} onSearchRelays={() => void searchOnRelays()}
relaySearchLoading={relaySearchLoading}
disabled={loading && !hasIndexData}
/> />
</div> </div>
{error ? ( {error ? (
@ -72,6 +78,10 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
<p className="mb-4 text-xs text-muted-foreground">{t('Library loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library loading')}</p>
) : engagementLoading ? ( ) : engagementLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library engagement loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library engagement loading')}</p>
) : searchLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library search loading')}</p>
) : relaySearchLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library relay search loading')}</p>
) : null} ) : null}
{statusLine ? ( {statusLine ? (
<p className="mb-4 text-xs text-muted-foreground">{statusLine}</p> <p className="mb-4 text-xs text-muted-foreground">{statusLine}</p>

Loading…
Cancel
Save