From e294cd58e52e1ae6571c82717389b4f082d2117b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 7 Jun 2026 18:19:56 +0200 Subject: [PATCH] bug-fixes --- src/components/Library/LibrarySearchBar.tsx | 5 + src/hooks/useLibraryPublications.ts | 107 ++++++++++++++---- src/i18n/locales/de.ts | 1 + src/i18n/locales/en.ts | 1 + .../event-metadata.publication-index.test.ts | 11 ++ src/lib/event-metadata.ts | 18 ++- src/lib/gutenberg-cover.test.ts | 12 ++ src/lib/gutenberg-cover.ts | 14 +++ src/lib/library-publication-index.test.ts | 29 +++++ src/lib/library-publication-index.ts | 91 ++++++++++++++- src/pages/primary/LibraryPage/index.tsx | 6 +- 11 files changed, 268 insertions(+), 27 deletions(-) diff --git a/src/components/Library/LibrarySearchBar.tsx b/src/components/Library/LibrarySearchBar.tsx index b9acced7..49745f22 100644 --- a/src/components/Library/LibrarySearchBar.tsx +++ b/src/components/Library/LibrarySearchBar.tsx @@ -10,6 +10,7 @@ export default function LibrarySearchBar({ onSearchQueryChange, showOnlyMine, onShowOnlyMineChange, + mineFilterLoading, onSearchRelays, relaySearchLoading, disabled @@ -18,6 +19,7 @@ export default function LibrarySearchBar({ onSearchQueryChange: (value: string) => void showOnlyMine: boolean onShowOnlyMineChange: (value: boolean) => void + mineFilterLoading?: boolean onSearchRelays?: () => void relaySearchLoading?: boolean disabled?: boolean @@ -66,6 +68,9 @@ export default function LibrarySearchBar({ + {mineFilterLoading ? ( + + ) : null} ) diff --git a/src/hooks/useLibraryPublications.ts b/src/hooks/useLibraryPublications.ts index a14b3571..d1af0885 100644 --- a/src/hooks/useLibraryPublications.ts +++ b/src/hooks/useLibraryPublications.ts @@ -2,14 +2,15 @@ import { clearAllLibraryIndexCaches, filterLibraryPublicationsByUser, buildLibraryRelayUrls, - libraryPublicationEntriesFromIndex, + libraryPublicationEntriesForUserFromIndexAsync, loadLibraryPublicationIndex, peekLibrarySearchResults, refreshLibraryEngagement, searchLibraryPublications, searchLibraryPublicationsOnRelays, type LibraryPublicationEntry, - type PublicationEngagementMaps + type PublicationEngagementMaps, + type LibraryMineFilterOpts } from '@/lib/library-publication-index' import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label' import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls' @@ -62,20 +63,36 @@ export function useLibraryPublications(isActive: boolean) { const [topLevelCount, setTopLevelCount] = useState(0) const [pinListEvent, setPinListEvent] = useState(null) const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) + const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false) const loadGenRef = useRef(0) + const [mineIndexEntries, setMineIndexEntries] = useState([]) + const [mineFilterComputing, setMineFilterComputing] = useState(false) + const mineIndexCacheRef = useRef<{ + indexEvents: Event[] + engagement: PublicationEngagementMaps + pubkey: string + mineFilterOpts: LibraryMineFilterOpts + entries: LibraryPublicationEntry[] + } | null>(null) const loadMyBooklistTargets = useCallback(async () => { if (!pubkey) { setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS) + setBooklistTargetsLoading(false) return } - const relays = await buildAccountListRelayUrlsForMerge({ - accountPubkey: pubkey, - favoriteRelays: favoriteRelays ?? [], - blockedRelays: blockedRelays ?? [] - }) - const targets = await fetchViewerBooklistTargets(pubkey, relays) - setMyBooklistTargets(targets) + setBooklistTargetsLoading(true) + try { + const relays = await buildAccountListRelayUrlsForMerge({ + accountPubkey: pubkey, + favoriteRelays: favoriteRelays ?? [], + blockedRelays: blockedRelays ?? [] + }) + const targets = await fetchViewerBooklistTargets(pubkey, relays) + setMyBooklistTargets(targets) + } finally { + setBooklistTargetsLoading(false) + } }, [pubkey, favoriteRelays, blockedRelays]) useEffect(() => { @@ -273,21 +290,67 @@ export function useLibraryPublications(isActive: boolean) { } }, [searchQuery, pubkey, indexEvents, engagement, blockedRelays]) - const filteredEntries = useMemo(() => { - const q = debouncedSearch.trim() - const mineFilterOpts = { + const mineFilterOpts = useMemo( + () => ({ bookmarkListEvent, pinListEvent, myBooklistAddresses: myBooklistTargets.addresses, 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[] if (showOnlyMine && !q) { - list = filterLibraryPublicationsByUser( - libraryPublicationEntriesFromIndex(indexEvents, engagement), - pubkey, - mineFilterOpts - ) + list = mineFilterComputing ? [] : mineIndexEntries } else { list = q ? (searchResults ?? []) : entries if (showOnlyMine) { @@ -301,11 +364,9 @@ export function useLibraryPublications(isActive: boolean) { pubkey, debouncedSearch, searchResults, - indexEvents, - engagement, - bookmarkListEvent, - pinListEvent, - myBooklistTargets + mineIndexEntries, + mineFilterComputing, + mineFilterOpts ]) return { @@ -314,6 +375,8 @@ export function useLibraryPublications(isActive: boolean) { setSearchQuery, showOnlyMine, setShowOnlyMine, + mineFilterLoading: + mineFilterComputing || (showOnlyMine && booklistTargetsLoading), loading, engagementLoading, searchLoading, diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 96fb7736..893b6869 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1654,6 +1654,7 @@ export default { 'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.', 'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.', 'Library loading': 'Publikationen werden von Dokument-Relays geladen…', + 'Library mine filter loading': 'Deine Publikationen werden gefiltert…', 'Library engagement loading': 'Engagement-Filter werden aktualisiert…', 'Library search loading': 'Publikationen werden durchsucht…', 'Library search relays': 'Relays durchsuchen', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index b84b2591..68e502ae 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1677,6 +1677,7 @@ export default { 'Library empty': 'No publications found on your relays yet.', 'Library empty filtered': 'No publications match your filters.', 'Library loading': 'Loading publications from document relays…', + 'Library mine filter loading': 'Filtering your publications…', 'Library engagement loading': 'Updating engagement filters…', 'Library search loading': 'Searching publications…', 'Library search relays': 'Search the relays', diff --git a/src/lib/event-metadata.publication-index.test.ts b/src/lib/event-metadata.publication-index.test.ts index e93a4c9b..2162352f 100644 --- a/src/lib/event-metadata.publication-index.test.ts +++ b/src/lib/event-metadata.publication-index.test.ts @@ -86,6 +86,17 @@ describe('getPublicationIndexMetadataFromEvent', () => { 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', () => { const event = indexEvent([ ['d', 'book'], diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 37b480dd..dc75c393 100644 --- a/src/lib/event-metadata.ts +++ b/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 { Event, kinds } from 'nostr-tools' 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 { getAmountFromInvoice, getLightningAddressFromProfile } from './lightning' 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 if (image) { image = normalizeGutenbergCoverImageUrl(image) } else { - image = resolveGutenbergCoverImageUrl(source) + image = + resolveGutenbergCoverImageUrl(source) ?? + (gutenbergIdFromDTag ? gutenbergCoverImageUrl(gutenbergIdFromDTag) : undefined) } return { diff --git a/src/lib/gutenberg-cover.test.ts b/src/lib/gutenberg-cover.test.ts index 3773a332..3e3cfbeb 100644 --- a/src/lib/gutenberg-cover.test.ts +++ b/src/lib/gutenberg-cover.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { gutenbergCoverImageUrl, + gutenbergEbookPageUrl, normalizeGutenbergCoverImageUrl, parseGutenbergEbookId, + parseGutenbergEbookIdFromDTag, resolveGutenbergCoverImageUrl } from '@/lib/gutenberg-cover' @@ -31,6 +33,16 @@ describe('gutenberg-cover', () => { 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', () => { expect(normalizeGutenbergCoverImageUrl('https://www.gutenberg.org/ebooks/16702')).toBe( 'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg' diff --git a/src/lib/gutenberg-cover.ts b/src/lib/gutenberg-cover.ts index 43077cd3..2add5f77 100644 --- a/src/lib/gutenberg-cover.ts +++ b/src/lib/gutenberg-cover.ts @@ -3,6 +3,8 @@ const GUTENBERG_EBOOK_URL = /gutenberg\.org\/ebooks\/(\d+)/i const GUTENBERG_FILES_URL = /gutenberg\.org\/files\/(\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 @@ -21,6 +23,18 @@ export function gutenbergCoverImageUrl(ebookId: string): string { 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. */ export function resolveGutenbergCoverImageUrl(source: string | undefined): string | undefined { if (!source?.trim()) return undefined diff --git a/src/lib/library-publication-index.test.ts b/src/lib/library-publication-index.test.ts index ceef187b..30dc9f69 100644 --- a/src/lib/library-publication-index.test.ts +++ b/src/lib/library-publication-index.test.ts @@ -8,7 +8,9 @@ import { filterEngagedPublications, filterLibraryPublicationsBySearch, filterLibraryPublicationsByUser, + libraryPublicationEntriesForUserFromIndex, pickLibraryPublicationEntries, + publicationRootBelongsToUser, peekLibrarySearchResults, publicationIndexMatchesSearchQuery, publicationQueryDTagVariants, @@ -361,6 +363,33 @@ describe('library-publication-index', () => { 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', () => { const viewerPk = 'f'.repeat(64) const rootAddr = `30040:${PK}:jane-eyre` diff --git a/src/lib/library-publication-index.ts b/src/lib/library-publication-index.ts index 0eaf5f10..ff5ba5f5 100644 --- a/src/lib/library-publication-index.ts +++ b/src/lib/library-publication-index.ts @@ -1,4 +1,3 @@ -import { findSessionBooklistLabelForPublication } from '@/lib/booklist-label' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { eventMatchesGeneralSearchQuery, @@ -770,12 +769,100 @@ export function publicationEntryBelongsToUser( if (entry.hasMyBooklistLabel || entry.hasMyComment || entry.hasMyHighlight) return true if (rootAddr && opts.myBooklistAddresses?.has(rootAddr)) 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.pinListEvent && isEventInPinList(opts.pinListEvent, event)) return true return false } +export type LibraryMineFilterOpts = { + bookmarkListEvent?: Event | null + pinListEvent?: Event | null + myBooklistAddresses?: Set + myBooklistEventIds?: Set +} + +/** Cheap membership test on a top-level index — no full {@link LibraryPublicationEntry} build. */ +export function publicationRootBelongsToUser( + root: Event, + indexByAddress: Map, + 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 { + 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. */ export function publicationIndexSearchHaystack(event: Event): string { const base = generalSearchHaystack(event) diff --git a/src/pages/primary/LibraryPage/index.tsx b/src/pages/primary/LibraryPage/index.tsx index 9f912f6f..c3abb04f 100644 --- a/src/pages/primary/LibraryPage/index.tsx +++ b/src/pages/primary/LibraryPage/index.tsx @@ -20,6 +20,7 @@ const LibraryPage = forwardRef((_props, ref) => { setSearchQuery, showOnlyMine, setShowOnlyMine, + mineFilterLoading, loading, engagementLoading, searchLoading, @@ -64,6 +65,7 @@ const LibraryPage = forwardRef((_props, ref) => { onSearchQueryChange={setSearchQuery} showOnlyMine={showOnlyMine} onShowOnlyMineChange={setShowOnlyMine} + mineFilterLoading={mineFilterLoading} onSearchRelays={() => void searchOnRelays()} relaySearchLoading={relaySearchLoading} disabled={loading && !hasIndexData} @@ -80,6 +82,8 @@ const LibraryPage = forwardRef((_props, ref) => {

{t('Library engagement loading')}

) : searchLoading ? (

{t('Library search loading')}

+ ) : mineFilterLoading ? ( +

{t('Library mine filter loading')}

) : relaySearchLoading ? (

{t('Library relay search loading')}

) : null} @@ -88,7 +92,7 @@ const LibraryPage = forwardRef((_props, ref) => { ) : null}