Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
e294cd58e5
  1. 5
      src/components/Library/LibrarySearchBar.tsx
  2. 107
      src/hooks/useLibraryPublications.ts
  3. 1
      src/i18n/locales/de.ts
  4. 1
      src/i18n/locales/en.ts
  5. 11
      src/lib/event-metadata.publication-index.test.ts
  6. 18
      src/lib/event-metadata.ts
  7. 12
      src/lib/gutenberg-cover.test.ts
  8. 14
      src/lib/gutenberg-cover.ts
  9. 29
      src/lib/library-publication-index.test.ts
  10. 91
      src/lib/library-publication-index.ts
  11. 6
      src/pages/primary/LibraryPage/index.tsx

5
src/components/Library/LibrarySearchBar.tsx

@ -10,6 +10,7 @@ export default function LibrarySearchBar({
onSearchQueryChange, onSearchQueryChange,
showOnlyMine, showOnlyMine,
onShowOnlyMineChange, onShowOnlyMineChange,
mineFilterLoading,
onSearchRelays, onSearchRelays,
relaySearchLoading, relaySearchLoading,
disabled disabled
@ -18,6 +19,7 @@ export default function LibrarySearchBar({
onSearchQueryChange: (value: string) => void onSearchQueryChange: (value: string) => void
showOnlyMine: boolean showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void onShowOnlyMineChange: (value: boolean) => void
mineFilterLoading?: boolean
onSearchRelays?: () => void onSearchRelays?: () => void
relaySearchLoading?: boolean relaySearchLoading?: boolean
disabled?: boolean disabled?: boolean
@ -66,6 +68,9 @@ export default function LibrarySearchBar({
<Label htmlFor="library-show-mine" className="text-sm text-muted-foreground cursor-pointer"> <Label htmlFor="library-show-mine" className="text-sm text-muted-foreground cursor-pointer">
{t('Library show only my publications')} {t('Library show only my publications')}
</Label> </Label>
{mineFilterLoading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden />
) : null}
</div> </div>
</div> </div>
) )

107
src/hooks/useLibraryPublications.ts

@ -2,14 +2,15 @@ import {
clearAllLibraryIndexCaches, clearAllLibraryIndexCaches,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
buildLibraryRelayUrls, buildLibraryRelayUrls,
libraryPublicationEntriesFromIndex, libraryPublicationEntriesForUserFromIndexAsync,
loadLibraryPublicationIndex, loadLibraryPublicationIndex,
peekLibrarySearchResults, peekLibrarySearchResults,
refreshLibraryEngagement, refreshLibraryEngagement,
searchLibraryPublications, searchLibraryPublications,
searchLibraryPublicationsOnRelays, searchLibraryPublicationsOnRelays,
type LibraryPublicationEntry, type LibraryPublicationEntry,
type PublicationEngagementMaps type PublicationEngagementMaps,
type LibraryMineFilterOpts
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label' import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
@ -62,20 +63,36 @@ export function useLibraryPublications(isActive: boolean) {
const [topLevelCount, setTopLevelCount] = useState(0) const [topLevelCount, setTopLevelCount] = useState(0)
const [pinListEvent, setPinListEvent] = useState<Event | null>(null) const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS)
const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false)
const loadGenRef = useRef(0) const loadGenRef = useRef(0)
const [mineIndexEntries, setMineIndexEntries] = useState<LibraryPublicationEntry[]>([])
const [mineFilterComputing, setMineFilterComputing] = useState(false)
const mineIndexCacheRef = useRef<{
indexEvents: Event[]
engagement: PublicationEngagementMaps
pubkey: string
mineFilterOpts: LibraryMineFilterOpts
entries: LibraryPublicationEntry[]
} | null>(null)
const loadMyBooklistTargets = useCallback(async () => { const loadMyBooklistTargets = useCallback(async () => {
if (!pubkey) { if (!pubkey) {
setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS)
setBooklistTargetsLoading(false)
return return
} }
const relays = await buildAccountListRelayUrlsForMerge({ setBooklistTargetsLoading(true)
accountPubkey: pubkey, try {
favoriteRelays: favoriteRelays ?? [], const relays = await buildAccountListRelayUrlsForMerge({
blockedRelays: blockedRelays ?? [] accountPubkey: pubkey,
}) favoriteRelays: favoriteRelays ?? [],
const targets = await fetchViewerBooklistTargets(pubkey, relays) blockedRelays: blockedRelays ?? []
setMyBooklistTargets(targets) })
const targets = await fetchViewerBooklistTargets(pubkey, relays)
setMyBooklistTargets(targets)
} finally {
setBooklistTargetsLoading(false)
}
}, [pubkey, favoriteRelays, blockedRelays]) }, [pubkey, favoriteRelays, blockedRelays])
useEffect(() => { useEffect(() => {
@ -273,21 +290,67 @@ export function useLibraryPublications(isActive: boolean) {
} }
}, [searchQuery, pubkey, indexEvents, engagement, blockedRelays]) }, [searchQuery, pubkey, indexEvents, engagement, blockedRelays])
const filteredEntries = useMemo(() => { const mineFilterOpts = useMemo(
const q = debouncedSearch.trim() () => ({
const mineFilterOpts = {
bookmarkListEvent, bookmarkListEvent,
pinListEvent, pinListEvent,
myBooklistAddresses: myBooklistTargets.addresses, myBooklistAddresses: myBooklistTargets.addresses,
myBooklistEventIds: myBooklistTargets.eventIds myBooklistEventIds: myBooklistTargets.eventIds
}),
[bookmarkListEvent, pinListEvent, myBooklistTargets]
)
useEffect(() => {
if (!showOnlyMine || !pubkey || indexEvents.length === 0 || debouncedSearch.trim()) {
setMineFilterComputing(false)
return
}
const cached = mineIndexCacheRef.current
if (
cached &&
cached.indexEvents === indexEvents &&
cached.engagement === engagement &&
cached.pubkey === pubkey &&
cached.mineFilterOpts === mineFilterOpts
) {
setMineIndexEntries(cached.entries)
setMineFilterComputing(false)
return
} }
const signal = { cancelled: false }
setMineFilterComputing(true)
void libraryPublicationEntriesForUserFromIndexAsync(
indexEvents,
engagement,
pubkey,
mineFilterOpts,
signal
).then((computed) => {
if (signal.cancelled) return
mineIndexCacheRef.current = {
indexEvents,
engagement,
pubkey,
mineFilterOpts,
entries: computed
}
setMineIndexEntries(computed)
setMineFilterComputing(false)
})
return () => {
signal.cancelled = true
}
}, [showOnlyMine, pubkey, indexEvents, engagement, mineFilterOpts, debouncedSearch])
const filteredEntries = useMemo(() => {
const q = debouncedSearch.trim()
let list: LibraryPublicationEntry[] let list: LibraryPublicationEntry[]
if (showOnlyMine && !q) { if (showOnlyMine && !q) {
list = filterLibraryPublicationsByUser( list = mineFilterComputing ? [] : mineIndexEntries
libraryPublicationEntriesFromIndex(indexEvents, engagement),
pubkey,
mineFilterOpts
)
} else { } else {
list = q ? (searchResults ?? []) : entries list = q ? (searchResults ?? []) : entries
if (showOnlyMine) { if (showOnlyMine) {
@ -301,11 +364,9 @@ export function useLibraryPublications(isActive: boolean) {
pubkey, pubkey,
debouncedSearch, debouncedSearch,
searchResults, searchResults,
indexEvents, mineIndexEntries,
engagement, mineFilterComputing,
bookmarkListEvent, mineFilterOpts
pinListEvent,
myBooklistTargets
]) ])
return { return {
@ -314,6 +375,8 @@ export function useLibraryPublications(isActive: boolean) {
setSearchQuery, setSearchQuery,
showOnlyMine, showOnlyMine,
setShowOnlyMine, setShowOnlyMine,
mineFilterLoading:
mineFilterComputing || (showOnlyMine && booklistTargetsLoading),
loading, loading,
engagementLoading, engagementLoading,
searchLoading, searchLoading,

1
src/i18n/locales/de.ts

@ -1654,6 +1654,7 @@ export default {
'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 mine filter loading': 'Deine Publikationen werden gefiltert…',
'Library engagement loading': 'Engagement-Filter werden aktualisiert…', 'Library engagement loading': 'Engagement-Filter werden aktualisiert…',
'Library search loading': 'Publikationen werden durchsucht…', 'Library search loading': 'Publikationen werden durchsucht…',
'Library search relays': 'Relays durchsuchen', 'Library search relays': 'Relays durchsuchen',

1
src/i18n/locales/en.ts

@ -1677,6 +1677,7 @@ export default {
'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 mine filter loading': 'Filtering your publications…',
'Library engagement loading': 'Updating engagement filters…', 'Library engagement loading': 'Updating engagement filters…',
'Library search loading': 'Searching publications…', 'Library search loading': 'Searching publications…',
'Library search relays': 'Search the relays', 'Library search relays': 'Search the relays',

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

@ -86,6 +86,17 @@ describe('getPublicationIndexMetadataFromEvent', () => {
expect(meta.image).toBe('https://example.com/cover.jpg') expect(meta.image).toBe('https://example.com/cover.jpg')
}) })
it('infers Gutenberg source and cover from pg-prefixed d-tag when tags are missing', () => {
const event = indexEvent([
['d', 'pg28217-dante-et-goethe-dialogues'],
['title', 'Dante et Goethe: Dialogues'],
['a', `30041:${PK}:intro`]
])
const meta = getPublicationIndexMetadataFromEvent(event)
expect(meta.source).toBe('https://www.gutenberg.org/ebooks/28217')
expect(meta.image).toBe('https://www.gutenberg.org/cache/epub/28217/pg28217.cover.medium.jpg')
})
it('normalizes Gutenberg ebook page in image tag to cover JPG', () => { it('normalizes Gutenberg ebook page in image tag to cover JPG', () => {
const event = indexEvent([ const event = indexEvent([
['d', 'book'], ['d', 'book'],

18
src/lib/event-metadata.ts

@ -2,7 +2,13 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE }
import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types' import { TEmoji, TMailboxRelay, TPollType, TRelayList, TRelaySet, TPaymentInfo, TProfile } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { buildATag } from './draft-event' import { buildATag } from './draft-event'
import { normalizeGutenbergCoverImageUrl, resolveGutenbergCoverImageUrl } from './gutenberg-cover' import {
gutenbergCoverImageUrl,
gutenbergEbookPageUrl,
normalizeGutenbergCoverImageUrl,
parseGutenbergEbookIdFromDTag,
resolveGutenbergCoverImageUrl
} from './gutenberg-cover'
import { getLatestEvent, getReplaceableEventIdentifier } from './event' import { getLatestEvent, getReplaceableEventIdentifier } from './event'
import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' import { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning'
import { formatPubkey, pubkeyToNpub } from './pubkey' import { formatPubkey, pubkeyToNpub } from './pubkey'
@ -719,11 +725,19 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI
} }
} }
const dTag = event.tags.find((tag) => tag[0] === 'd')?.[1]?.trim()
const gutenbergIdFromDTag = dTag ? parseGutenbergEbookIdFromDTag(dTag) : null
if (!source && gutenbergIdFromDTag) {
source = gutenbergEbookPageUrl(gutenbergIdFromDTag)
}
let image = base.image?.trim() || undefined let image = base.image?.trim() || undefined
if (image) { if (image) {
image = normalizeGutenbergCoverImageUrl(image) image = normalizeGutenbergCoverImageUrl(image)
} else { } else {
image = resolveGutenbergCoverImageUrl(source) image =
resolveGutenbergCoverImageUrl(source) ??
(gutenbergIdFromDTag ? gutenbergCoverImageUrl(gutenbergIdFromDTag) : undefined)
} }
return { return {

12
src/lib/gutenberg-cover.test.ts

@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
gutenbergCoverImageUrl, gutenbergCoverImageUrl,
gutenbergEbookPageUrl,
normalizeGutenbergCoverImageUrl, normalizeGutenbergCoverImageUrl,
parseGutenbergEbookId, parseGutenbergEbookId,
parseGutenbergEbookIdFromDTag,
resolveGutenbergCoverImageUrl resolveGutenbergCoverImageUrl
} from '@/lib/gutenberg-cover' } from '@/lib/gutenberg-cover'
@ -31,6 +33,16 @@ describe('gutenberg-cover', () => {
expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined() expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined()
}) })
it('parses ebook id from legacy pg-prefixed d-tags', () => {
expect(parseGutenbergEbookIdFromDTag('pg28217-dante-et-goethe-dialogues')).toBe('28217')
expect(parseGutenbergEbookIdFromDTag('pg28217')).toBe('28217')
expect(parseGutenbergEbookIdFromDTag('jane-eyre')).toBeNull()
})
it('builds gutenberg.org ebook page URL', () => {
expect(gutenbergEbookPageUrl('28217')).toBe('https://www.gutenberg.org/ebooks/28217')
})
it('normalizeGutenbergCoverImageUrl converts ebook pages to cover JPG', () => { it('normalizeGutenbergCoverImageUrl converts ebook pages to cover JPG', () => {
expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe( expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe(
'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg' 'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg'

14
src/lib/gutenberg-cover.ts

@ -3,6 +3,8 @@
const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i
const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\d+)/i
const GUTENBERG_CACHE_URL = /gutenberg\.org\/cache\/epub\/(\d+)/i const GUTENBERG_CACHE_URL = /gutenberg\.org\/cache\/epub\/(\d+)/i
/** Legacy publication d-tags: `pg28217-dante-et-goethe-dialogues`, `pg28217`, … */
const GUTENBERG_DTAG = /^pg(\d+)(?:-.*)?$/i
const DIRECT_IMAGE_EXT = /\.(?:jpe?g|png|gif|webp|avif)(?:[?#]|$)/i const DIRECT_IMAGE_EXT = /\.(?:jpe?g|png|gif|webp|avif)(?:[?#]|$)/i
@ -21,6 +23,18 @@ export function gutenbergCoverImageUrl(ebookId: string): string {
return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.medium.jpg` return `https://www.gutenberg.org/cache/epub/${id}/pg${id}.cover.medium.jpg`
} }
export function gutenbergEbookPageUrl(ebookId: string): string {
return `https://www.gutenberg.org/ebooks/${ebookId.trim()}`
}
/** Parse Project Gutenberg ebook id from a kind-30040 `d` tag (e.g. `pg28217-…`). */
export function parseGutenbergEbookIdFromDTag(dTag: string): string | null {
const trimmed = dTag.trim()
if (!trimmed) return null
const match = trimmed.match(GUTENBERG_DTAG)
return match?.[1] ?? null
}
/** When `source` points at Project Gutenberg, return the standard medium cover URL. */ /** When `source` points at Project Gutenberg, return the standard medium cover URL. */
export function resolveGutenbergCoverImageUrl(source: string | undefined): string | undefined { export function resolveGutenbergCoverImageUrl(source: string | undefined): string | undefined {
if (!source?.trim()) return undefined if (!source?.trim()) return undefined

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

@ -8,7 +8,9 @@ import {
filterEngagedPublications, filterEngagedPublications,
filterLibraryPublicationsBySearch, filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
libraryPublicationEntriesForUserFromIndex,
pickLibraryPublicationEntries, pickLibraryPublicationEntries,
publicationRootBelongsToUser,
peekLibrarySearchResults, peekLibrarySearchResults,
publicationIndexMatchesSearchQuery, publicationIndexMatchesSearchQuery,
publicationQueryDTagVariants, publicationQueryDTagVariants,
@ -361,6 +363,33 @@ describe('library-publication-index', () => {
expect(filterLibraryPublicationsByUser(results, viewerPk)).toHaveLength(1) expect(filterLibraryPublicationsByUser(results, viewerPk)).toHaveLength(1)
}) })
it('libraryPublicationEntriesForUserFromIndex builds only matching roots', () => {
const viewerPk = 'f'.repeat(64)
const mine = indexEvent('mine', [`30041:${PK}:a`], '1'.repeat(64))
mine.pubkey = viewerPk
const other = indexEvent('other', [`30041:${PK}:b`], '2'.repeat(64))
const indexEvents = [mine, other]
const engagement = buildEngagementMapsFromEvents([], [], [])
const entries = libraryPublicationEntriesForUserFromIndex(indexEvents, engagement, viewerPk, {
myBooklistAddresses: new Set()
})
expect(entries).toHaveLength(1)
expect(entries[0].event.id).toBe(mine.id)
})
it('publicationRootBelongsToUser matches booklist address without building entries', () => {
const viewerPk = 'f'.repeat(64)
const rootAddr = `30040:${PK}:jane-eyre`
const root = indexEvent('jane-eyre', [`30041:${PK}:intro`], '9'.repeat(64))
const indexByAddress = buildIndexByAddress([root])
const engagement = buildEngagementMapsFromEvents([], [], [])
expect(
publicationRootBelongsToUser(root, indexByAddress, engagement, viewerPk, {
myBooklistAddresses: new Set([rootAddr])
})
).toBe(true)
})
it('filterLibraryPublicationsByUser matches myBooklistAddresses without engagement flags', () => { it('filterLibraryPublicationsByUser matches myBooklistAddresses without engagement flags', () => {
const viewerPk = 'f'.repeat(64) const viewerPk = 'f'.repeat(64)
const rootAddr = `30040:${PK}:jane-eyre` const rootAddr = `30040:${PK}:jane-eyre`

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

@ -1,4 +1,3 @@
import { findSessionBooklistLabelForPublication } from '@/lib/booklist-label'
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants'
import { import {
eventMatchesGeneralSearchQuery, eventMatchesGeneralSearchQuery,
@ -770,12 +769,100 @@ export function publicationEntryBelongsToUser(
if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true
if (rootAddr && opts.myBooklistAddresses?.has(rootAddr)) return true if (rootAddr && opts.myBooklistAddresses?.has(rootAddr)) return true
if (opts.myBooklistEventIds?.has(event.id.toLowerCase())) return true if (opts.myBooklistEventIds?.has(event.id.toLowerCase())) return true
if (findSessionBooklistLabelForPublication(opts.userPubkey, event)) return true
if (opts.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, event)) return true if (opts.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, event)) return true
if (opts.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true if (opts.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true
return false return false
} }
export type LibraryMineFilterOpts = {
bookmarkListEvent?: Event | null
pinListEvent?: Event | null
myBooklistAddresses?: Set<string>
myBooklistEventIds?: Set<string>
}
/** Cheap membership test on a top-level index — no full {@link LibraryPublicationEntry} build. */
export function publicationRootBelongsToUser(
root: Event,
indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps,
userPubkey: string,
opts?: LibraryMineFilterOpts
): boolean {
const pk = userPubkey.toLowerCase()
if (root.pubkey.toLowerCase() === pk) return true
if (root.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true
const rootAddr = eventTagAddress(root)
if (rootAddr && opts?.myBooklistAddresses?.has(rootAddr)) return true
if (opts?.myBooklistEventIds?.has(root.id.toLowerCase())) return true
if (opts?.bookmarkListEvent && isEventInBookmarkList(opts.bookmarkListEvent, root)) return true
if (opts?.pinListEvent && isEventInPinList(opts.pinListEvent, root)) return true
const reachable = collectReachableAddressesCached(root, indexByAddress)
if (rootAddr) reachable.add(rootAddr)
for (const addr of reachable) {
const indexed = indexByAddress.get(addr)
const eventId = indexed?.id ?? (addr === rootAddr ? root.id : undefined)
if (collectBooklistFlagsForTarget(addr, eventId, engagement).hasMyBooklistLabel) return true
const myFlags = collectMyEngagementFlagsForTarget(addr, eventId, engagement)
if (myFlags.hasMyComment || myFlags.hasMyHighlight) return true
}
return false
}
const MINE_FILTER_BATCH_SIZE = 40
/** Build library rows only for publications belonging to the viewer (fast path for “My publications”). */
export function libraryPublicationEntriesForUserFromIndex(
indexEvents: Event[],
engagement: PublicationEngagementMaps,
userPubkey: string,
opts?: LibraryMineFilterOpts
): LibraryPublicationEntry[] {
if (!userPubkey) return []
const indexByAddress = buildIndexByAddress(indexEvents)
const out: LibraryPublicationEntry[] = []
for (const root of getTopLevelIndexEvents(indexEvents)) {
if (!publicationRootBelongsToUser(root, indexByAddress, engagement, userPubkey, opts)) continue
out.push(buildLibraryPublicationEntry(root, indexByAddress, engagement))
}
return sortLibraryPublications(out)
}
/** Yields between root batches so the UI stays responsive on large indexes. */
export function libraryPublicationEntriesForUserFromIndexAsync(
indexEvents: Event[],
engagement: PublicationEngagementMaps,
userPubkey: string,
opts?: LibraryMineFilterOpts,
signal?: { cancelled: boolean }
): Promise<LibraryPublicationEntry[]> {
if (!userPubkey) return Promise.resolve([])
const indexByAddress = buildIndexByAddress(indexEvents)
const roots = getTopLevelIndexEvents(indexEvents)
const out: LibraryPublicationEntry[] = []
let i = 0
return new Promise((resolve) => {
const step = () => {
if (signal?.cancelled) return
const end = Math.min(i + MINE_FILTER_BATCH_SIZE, roots.length)
for (; i < end; i++) {
const root = roots[i]
if (!publicationRootBelongsToUser(root, indexByAddress, engagement, userPubkey, opts)) continue
out.push(buildLibraryPublicationEntry(root, indexByAddress, engagement))
}
if (signal?.cancelled) return
if (i < roots.length) {
requestAnimationFrame(step)
} else {
resolve(sortLibraryPublications(out))
}
}
requestAnimationFrame(step)
})
}
/** Haystack for kind-30040 index search: general fields plus section refs and language tags. */ /** Haystack for kind-30040 index search: general fields plus section refs and language tags. */
export function publicationIndexSearchHaystack(event: Event): string { export function publicationIndexSearchHaystack(event: Event): string {
const base = generalSearchHaystack(event) const base = generalSearchHaystack(event)

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

@ -20,6 +20,7 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
setSearchQuery, setSearchQuery,
showOnlyMine, showOnlyMine,
setShowOnlyMine, setShowOnlyMine,
mineFilterLoading,
loading, loading,
engagementLoading, engagementLoading,
searchLoading, searchLoading,
@ -64,6 +65,7 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
showOnlyMine={showOnlyMine} showOnlyMine={showOnlyMine}
onShowOnlyMineChange={setShowOnlyMine} onShowOnlyMineChange={setShowOnlyMine}
mineFilterLoading={mineFilterLoading}
onSearchRelays={() => void searchOnRelays()} onSearchRelays={() => void searchOnRelays()}
relaySearchLoading={relaySearchLoading} relaySearchLoading={relaySearchLoading}
disabled={loading && !hasIndexData} disabled={loading && !hasIndexData}
@ -80,6 +82,8 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
<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 ? ( ) : searchLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library search loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library search loading')}</p>
) : mineFilterLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library mine filter loading')}</p>
) : relaySearchLoading ? ( ) : relaySearchLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library relay search loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library relay search loading')}</p>
) : null} ) : null}
@ -88,7 +92,7 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
) : null} ) : null}
<LibraryPublicationGrid <LibraryPublicationGrid
entries={entries} entries={entries}
loading={loading && entries.length === 0} loading={(loading && entries.length === 0) || (showOnlyMine && mineFilterLoading)}
emptyMessage={ emptyMessage={
searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty')
} }

Loading…
Cancel
Save