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

107
src/hooks/useLibraryPublications.ts

@ -2,14 +2,15 @@ import { @@ -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) { @@ -62,20 +63,36 @@ export function useLibraryPublications(isActive: boolean) {
const [topLevelCount, setTopLevelCount] = useState(0)
const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS)
const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false)
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 () => {
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) { @@ -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) { @@ -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) { @@ -314,6 +375,8 @@ export function useLibraryPublications(isActive: boolean) {
setSearchQuery,
showOnlyMine,
setShowOnlyMine,
mineFilterLoading:
mineFilterComputing || (showOnlyMine && booklistTargetsLoading),
loading,
engagementLoading,
searchLoading,

1
src/i18n/locales/de.ts

@ -1654,6 +1654,7 @@ export default { @@ -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',

1
src/i18n/locales/en.ts

@ -1677,6 +1677,7 @@ export default { @@ -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',

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

@ -86,6 +86,17 @@ describe('getPublicationIndexMetadataFromEvent', () => { @@ -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'],

18
src/lib/event-metadata.ts

@ -2,7 +2,13 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, POLL_TYPE } @@ -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 @@ -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 {

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

@ -1,8 +1,10 @@ @@ -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', () => { @@ -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'

14
src/lib/gutenberg-cover.ts

@ -3,6 +3,8 @@ @@ -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 { @@ -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

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

@ -8,7 +8,9 @@ import { @@ -8,7 +8,9 @@ import {
filterEngagedPublications,
filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser,
libraryPublicationEntriesForUserFromIndex,
pickLibraryPublicationEntries,
publicationRootBelongsToUser,
peekLibrarySearchResults,
publicationIndexMatchesSearchQuery,
publicationQueryDTagVariants,
@ -361,6 +363,33 @@ describe('library-publication-index', () => { @@ -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`

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

@ -1,4 +1,3 @@ @@ -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( @@ -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<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. */
export function publicationIndexSearchHaystack(event: Event): string {
const base = generalSearchHaystack(event)

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

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

Loading…
Cancel
Save