Browse Source

bug-fixes

imwald
Silberengel 1 week ago
parent
commit
d04bd724b6
  1. 15
      src/components/Image/index.tsx
  2. 2
      src/components/Note/PublicationCoverImage.tsx
  3. 71
      src/hooks/useLibraryPublications.ts
  4. 51
      src/lib/booklist-label.ts
  5. 11
      src/lib/event-metadata.publication-index.test.ts
  6. 6
      src/lib/event-metadata.ts
  7. 18
      src/lib/gutenberg-cover.test.ts
  8. 18
      src/lib/gutenberg-cover.ts
  9. 44
      src/lib/library-publication-index.test.ts
  10. 68
      src/lib/library-publication-index.ts
  11. 6
      vite.config.ts

15
src/components/Image/index.tsx

@ -229,19 +229,6 @@ export default function Image({
if (el.complete && el.naturalWidth > 0) { if (el.complete && el.naturalWidth > 0) {
captureIntrinsicDim(el) captureIntrinsicDim(el)
notifyLoaded() notifyLoaded()
return
}
if (typeof el.decode === 'function') {
let cancelled = false
el.decode().then(() => {
if (!cancelled && el.naturalWidth > 0) {
captureIntrinsicDim(el)
notifyLoaded()
}
}).catch(() => {})
return () => {
cancelled = true
}
} }
}, [revealed, badSrc, imageUrl, notifyLoaded, captureIntrinsicDim]) }, [revealed, badSrc, imageUrl, notifyLoaded, captureIntrinsicDim])
@ -398,7 +385,7 @@ export default function Image({
src={imageUrl} src={imageUrl}
alt={finalAlt} alt={finalAlt}
referrerPolicy="no-referrer-when-downgrade" referrerPolicy="no-referrer-when-downgrade"
decoding={effectiveHoldUntilClick ? 'async' : 'sync'} decoding="async"
// `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly. // `lazy` often never starts the request inside nested feed scrollers; always-load should fetch eagerly.
loading="eager" loading="eager"
{...(fetchPriority ? { fetchpriority: fetchPriority } : {})} {...(fetchPriority ? { fetchpriority: fetchPriority } : {})}

2
src/components/Note/PublicationCoverImage.tsx

@ -38,7 +38,7 @@ export default function PublicationCoverImage({
<Image <Image
image={{ url: imageUrl, pubkey }} image={{ url: imageUrl, pubkey }}
className={cn(maxClass, 'max-w-full object-contain')} className={cn(maxClass, 'max-w-full object-contain')}
classNames={{ wrapper: 'inline-block w-auto max-w-full' }} classNames={{ wrapper: 'block w-full max-w-full' }}
hideIfError hideIfError
holdUntilClick={!autoLoadMedia} holdUntilClick={!autoLoadMedia}
/> />

71
src/hooks/useLibraryPublications.ts

@ -11,10 +11,12 @@ import {
type LibraryPublicationEntry, type LibraryPublicationEntry,
type PublicationEngagementMaps type PublicationEngagementMaps
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { BOOKLIST_LABEL_UPDATED_EVENT } from '@/lib/booklist-label' import { BOOKLIST_LABEL_UPDATED_EVENT, fetchViewerBooklistTargets } from '@/lib/booklist-label'
import { buildAccountListRelayUrlsForMerge } from '@/lib/account-list-relay-urls'
import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest' import { fetchNewestPinListForPubkey } from '@/lib/replaceable-list-latest'
import { getTopLevelIndexEvents } from '@/lib/publication-index' import { getTopLevelIndexEvents } from '@/lib/publication-index'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -39,8 +41,11 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
highlightAddresses: new Set() highlightAddresses: new Set()
} }
const EMPTY_BOOKLIST_TARGETS = { addresses: new Set<string>(), eventIds: new Set<string>() }
export function useLibraryPublications(isActive: boolean) { export function useLibraryPublications(isActive: boolean) {
const { pubkey, bookmarkListEvent } = useNostr() const { pubkey, bookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) const [entries, setEntries] = useState<LibraryPublicationEntry[]>([])
const [indexEvents, setIndexEvents] = useState<Event[]>([]) const [indexEvents, setIndexEvents] = useState<Event[]>([])
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT) const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT)
@ -56,8 +61,31 @@ export function useLibraryPublications(isActive: boolean) {
const [allIndexCount, setAllIndexCount] = useState(0) const [allIndexCount, setAllIndexCount] = useState(0)
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 loadGenRef = useRef(0) const loadGenRef = useRef(0)
const loadMyBooklistTargets = useCallback(async () => {
if (!pubkey) {
setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS)
return
}
const relays = await buildAccountListRelayUrlsForMerge({
accountPubkey: pubkey,
favoriteRelays: favoriteRelays ?? [],
blockedRelays: blockedRelays ?? []
})
const targets = await fetchViewerBooklistTargets(pubkey, relays)
setMyBooklistTargets(targets)
}, [pubkey, favoriteRelays, blockedRelays])
useEffect(() => {
if (!isActive || !pubkey) {
setMyBooklistTargets(EMPTY_BOOKLIST_TARGETS)
return
}
void loadMyBooklistTargets()
}, [isActive, pubkey, loadMyBooklistTargets])
useEffect(() => { useEffect(() => {
if (!pubkey) { if (!pubkey) {
setPinListEvent(null) setPinListEvent(null)
@ -65,9 +93,9 @@ export function useLibraryPublications(isActive: boolean) {
} }
let cancelled = false let cancelled = false
void (async () => { void (async () => {
const relays = await buildLibraryRelayUrls(pubkey) const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? [])
const pinList = await fetchNewestPinListForPubkey(pubkey, relays) const pinList = await fetchNewestPinListForPubkey(pubkey, relays)
if (!cancelled) setPinListEvent(pinList) if (!cancelled) setPinListEvent(pinList ?? null)
})() })()
return () => { return () => {
cancelled = true cancelled = true
@ -89,7 +117,7 @@ export function useLibraryPublications(isActive: boolean) {
logger.info('[Library] page load requested', { forceRefresh, gen }) logger.info('[Library] page load requested', { forceRefresh, gen })
} }
try { try {
const relays = await buildLibraryRelayUrls(pubkey || undefined) const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? [])
let timeoutId: number | undefined let timeoutId: number | undefined
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => reject(new Error('Library load timed out')), LOAD_TIMEOUT_MS) timeoutId = window.setTimeout(() => reject(new Error('Library load timed out')), LOAD_TIMEOUT_MS)
@ -134,7 +162,7 @@ export function useLibraryPublications(isActive: boolean) {
} }
} }
}, },
[pubkey] [pubkey, blockedRelays]
) )
useEffect(() => { useEffect(() => {
@ -147,7 +175,8 @@ export function useLibraryPublications(isActive: boolean) {
let cancelled = false let cancelled = false
const onBooklistUpdated = () => { const onBooklistUpdated = () => {
void (async () => { void (async () => {
const relays = await buildLibraryRelayUrls(pubkey) await loadMyBooklistTargets()
const relays = await buildLibraryRelayUrls(pubkey, blockedRelays ?? [])
const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement( const { engagement: nextEngagement, engaged } = await refreshLibraryEngagement(
relays, relays,
indexEvents, indexEvents,
@ -165,7 +194,7 @@ export function useLibraryPublications(isActive: boolean) {
cancelled = true cancelled = true
window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated) window.removeEventListener(BOOKLIST_LABEL_UPDATED_EVENT, onBooklistUpdated)
} }
}, [isActive, pubkey, indexEvents, debouncedSearch]) }, [isActive, pubkey, indexEvents, debouncedSearch, loadMyBooklistTargets, blockedRelays])
useEffect(() => { useEffect(() => {
const q = debouncedSearch.trim() const q = debouncedSearch.trim()
@ -205,8 +234,8 @@ export function useLibraryPublications(isActive: boolean) {
setRelaySearchLoading(true) setRelaySearchLoading(true)
setError(null) setError(null)
try { try {
const relays = await buildLibraryRelayUrls(pubkey || undefined) const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? [])
const { events, mergedIndexEvents, entries, fromCache } = await searchLibraryPublicationsOnRelays( const { events, mergedIndexEvents, fromCache } = await searchLibraryPublicationsOnRelays(
q, q,
relays, relays,
{ indexEvents, engagement } { indexEvents, engagement }
@ -220,6 +249,18 @@ export function useLibraryPublications(isActive: boolean) {
fromCache fromCache
}) })
} }
let nextEngagement = engagement
if (pubkey) {
const refreshed = await refreshLibraryEngagement(relays, mergedIndexEvents, pubkey)
nextEngagement = refreshed.engagement
setEngagement(nextEngagement)
}
const entries = await searchLibraryPublications(q, {
indexEvents: mergedIndexEvents,
engagement: nextEngagement
})
setSearchResults(entries) setSearchResults(entries)
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : 'Relay search failed' const message = e instanceof Error ? e.message : 'Relay search failed'
@ -230,11 +271,16 @@ export function useLibraryPublications(isActive: boolean) {
} finally { } finally {
setRelaySearchLoading(false) setRelaySearchLoading(false)
} }
}, [searchQuery, pubkey, indexEvents, engagement]) }, [searchQuery, pubkey, indexEvents, engagement, blockedRelays])
const filteredEntries = useMemo(() => { const filteredEntries = useMemo(() => {
const q = debouncedSearch.trim() const q = debouncedSearch.trim()
const mineFilterOpts = { bookmarkListEvent, pinListEvent } const mineFilterOpts = {
bookmarkListEvent,
pinListEvent,
myBooklistAddresses: myBooklistTargets.addresses,
myBooklistEventIds: myBooklistTargets.eventIds
}
let list: LibraryPublicationEntry[] let list: LibraryPublicationEntry[]
if (showOnlyMine && !q) { if (showOnlyMine && !q) {
list = filterLibraryPublicationsByUser( list = filterLibraryPublicationsByUser(
@ -258,7 +304,8 @@ export function useLibraryPublications(isActive: boolean) {
indexEvents, indexEvents,
engagement, engagement,
bookmarkListEvent, bookmarkListEvent,
pinListEvent pinListEvent,
myBooklistTargets
]) ])
return { return {

51
src/lib/booklist-label.ts

@ -9,6 +9,57 @@ import type { Event, Filter } from 'nostr-tools'
export const BOOKLIST_LABEL_UPDATED_EVENT = 'booklist-label-updated' export const BOOKLIST_LABEL_UPDATED_EVENT = 'booklist-label-updated'
export type ViewerBooklistTargets = {
addresses: Set<string>
eventIds: Set<string>
}
function collectBooklistTargetsFromLabelEvents(events: Event[]): ViewerBooklistTargets {
const addresses = new Set<string>()
const eventIds = new Set<string>()
for (const ev of events) {
if (!labelEventHasBooklistTag(ev)) continue
for (const tag of ev.tags) {
if (tag[0] === 'a' && tag[1]?.trim()) addresses.add(tag[1].trim())
if (tag[0] === 'e' && tag[1]?.trim()) eventIds.add(tag[1].trim().toLowerCase())
}
}
return { addresses, eventIds }
}
/** All publication coordinates the viewer has on their booklist (session + network). */
export async function fetchViewerBooklistTargets(
userPubkey: string,
relayUrls: string[]
): Promise<ViewerBooklistTargets> {
const sessionHits = eventService.listSessionEventsAuthoredBy(userPubkey, {
kinds: [ExtendedKind.LABEL],
limit: 200
})
const merged = new Map<string, Event>()
for (const ev of sessionHits) {
if (labelEventHasBooklistTag(ev)) merged.set(ev.id, ev)
}
if (relayUrls.length > 0) {
const filter: Filter = {
kinds: [ExtendedKind.LABEL],
authors: [userPubkey],
'#l': [NIP32_BOOKLIST_LABEL],
limit: 500
}
const network = await client.fetchEvents(relayUrls, [filter], {
globalTimeout: 12_000,
eoseTimeout: 3_000
})
for (const ev of network) {
if (labelEventHasBooklistTag(ev)) merged.set(ev.id, ev)
}
}
return collectBooklistTargetsFromLabelEvents([...merged.values()])
}
export function dispatchBooklistLabelUpdated(publication: Event): void { export function dispatchBooklistLabelUpdated(publication: Event): void {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent(BOOKLIST_LABEL_UPDATED_EVENT, { new CustomEvent(BOOKLIST_LABEL_UPDATED_EVENT, {

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

@ -85,4 +85,15 @@ describe('getPublicationIndexMetadataFromEvent', () => {
const meta = getPublicationIndexMetadataFromEvent(event) const meta = getPublicationIndexMetadataFromEvent(event)
expect(meta.image).toBe('https://example.com/cover.jpg') expect(meta.image).toBe('https://example.com/cover.jpg')
}) })
it('normalizes Gutenberg ebook page in image tag to cover JPG', () => {
const event = indexEvent([
['d', 'book'],
['title', 'Book'],
['image', 'https://www.gutenberg.org/ebooks/16702'],
['a', `30041:${PK}:intro`]
])
const meta = getPublicationIndexMetadataFromEvent(event)
expect(meta.image).toBe('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg')
})
}) })

6
src/lib/event-metadata.ts

@ -2,7 +2,7 @@ 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 { resolveGutenbergCoverImageUrl } from './gutenberg-cover' import { normalizeGutenbergCoverImageUrl, 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'
@ -720,7 +720,9 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI
} }
let image = base.image?.trim() || undefined let image = base.image?.trim() || undefined
if (!image) { if (image) {
image = normalizeGutenbergCoverImageUrl(image)
} else {
image = resolveGutenbergCoverImageUrl(source) image = resolveGutenbergCoverImageUrl(source)
} }

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

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
gutenbergCoverImageUrl, gutenbergCoverImageUrl,
normalizeGutenbergCoverImageUrl,
parseGutenbergEbookId, parseGutenbergEbookId,
resolveGutenbergCoverImageUrl resolveGutenbergCoverImageUrl
} from '@/lib/gutenberg-cover' } from '@/lib/gutenberg-cover'
@ -12,6 +13,9 @@ describe('gutenberg-cover', () => {
expect(parseGutenbergEbookId('https://www.gutenberg.org/files/21020/21020-h/21020-h.htm')).toBe( expect(parseGutenbergEbookId('https://www.gutenberg.org/files/21020/21020-h/21020-h.htm')).toBe(
'21020' '21020'
) )
expect(
parseGutenbergEbookId('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg')
).toBe('16702')
}) })
it('builds medium cover URL', () => { it('builds medium cover URL', () => {
@ -26,4 +30,18 @@ describe('gutenberg-cover', () => {
).toBe('https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg') ).toBe('https://www.gutenberg.org/cache/epub/58363/pg58363.cover.medium.jpg')
expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined() expect(resolveGutenbergCoverImageUrl('https://example.com/book')).toBeUndefined()
}) })
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'
)
expect(
normalizeGutenbergCoverImageUrl(
'https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg'
)
).toBe('https://www.gutenberg.org/cache/epub/16702/pg16702.cover.medium.jpg')
expect(normalizeGutenbergCoverImageUrl('https://example.com/cover.jpg')).toBe(
'https://example.com/cover.jpg'
)
})
}) })

18
src/lib/gutenberg-cover.ts

@ -2,11 +2,14 @@
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 DIRECT_IMAGE_EXT = /\.(?:jpe?g|png|gif|webp|avif)(?:[?#]|$)/i
export function parseGutenbergEbookId(source: string): string | null { export function parseGutenbergEbookId(source: string): string | null {
const trimmed = source.trim() const trimmed = source.trim()
if (!trimmed) return null if (!trimmed) return null
for (const pattern of [GUTENBERG_EBOOK_URL, GUTENBERG_FILES_URL]) { for (const pattern of [GUTENBERG_EBOOK_URL, GUTENBERG_FILES_URL, GUTENBERG_CACHE_URL]) {
const match = trimmed.match(pattern) const match = trimmed.match(pattern)
if (match?.[1]) return match[1] if (match?.[1]) return match[1]
} }
@ -26,3 +29,16 @@ export function resolveGutenbergCoverImageUrl(source: string | undefined): strin
if (!id) return undefined if (!id) return undefined
return gutenbergCoverImageUrl(id) return gutenbergCoverImageUrl(id)
} }
/**
* Normalize a publication `image` tag URL. Gutenberg ebook/files pages become cache cover JPGs;
* direct `.jpg` / cache cover URLs are kept as-is.
*/
export function normalizeGutenbergCoverImageUrl(url: string): string {
const trimmed = url.trim()
if (!trimmed.toLowerCase().includes('gutenberg')) return trimmed
const id = parseGutenbergEbookId(trimmed)
if (!id) return trimmed
if (DIRECT_IMAGE_EXT.test(trimmed)) return trimmed
return gutenbergCoverImageUrl(id)
}

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

@ -338,4 +338,48 @@ describe('library-publication-index', () => {
[authored.id, booklisted.id, commented.id, unrelated.id].sort() [authored.id, booklisted.id, commented.id, unrelated.id].sort()
) )
}) })
it('searchLibraryPublications keeps my booklist flags for booklist-only publications', async () => {
clearLibrarySearchSessionCache()
const viewerPk = 'f'.repeat(64)
const rootAddr = `30040:${PK}:jane-eyre`
const root = indexEvent('jane-eyre', [`30041:${PK}:intro`], '9'.repeat(64))
root.tags = [['d', 'jane-eyre'], ['title', 'Jane Eyre'], ['a', `30041:${PK}:intro`]]
const label: Event = {
id: '7'.repeat(64),
kind: ExtendedKind.LABEL,
pubkey: viewerPk,
created_at: 50,
content: '',
tags: [['L', 'ugc'], ['l', 'booklist', 'ugc'], ['a', rootAddr]],
sig: 'e'.repeat(128)
}
const engagement = buildEngagementMapsFromEvents([label], [], [], undefined, undefined, viewerPk)
const results = await searchLibraryPublications('jane eyre', { indexEvents: [root], engagement })
expect(results).toHaveLength(1)
expect(results[0].hasMyBooklistLabel).toBe(true)
expect(filterLibraryPublicationsByUser(results, viewerPk)).toHaveLength(1)
})
it('filterLibraryPublicationsByUser matches myBooklistAddresses without engagement flags', () => {
const viewerPk = 'f'.repeat(64)
const rootAddr = `30040:${PK}:jane-eyre`
const root = indexEvent('jane-eyre', [`30041:${PK}:intro`], '9'.repeat(64))
const entry = {
event: root,
hasLabel: false,
labelNames: [],
hasBooklistLabel: false,
hasMyBooklistLabel: false,
hasMyComment: false,
hasMyHighlight: false,
hasComment: false,
hasHighlight: false,
engagementCount: 0
}
const filtered = filterLibraryPublicationsByUser([entry], viewerPk, {
myBooklistAddresses: new Set([rootAddr])
})
expect(filtered).toHaveLength(1)
})
}) })

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

@ -1,3 +1,4 @@
import { findSessionBooklistLabelForPublication } from '@/lib/booklist-label'
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants'
import { import {
eventMatchesGeneralSearchQuery, eventMatchesGeneralSearchQuery,
@ -21,6 +22,7 @@ import {
} from '@/lib/publication-index' } from '@/lib/publication-index'
import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event'
import { isEventInPinList } from '@/lib/replaceable-list-latest' import { isEventInPinList } from '@/lib/replaceable-list-latest'
import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { import {
clearLibraryIndexIdbCache, clearLibraryIndexIdbCache,
@ -117,7 +119,15 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
? engagement.labelAddresses.size + ? engagement.labelAddresses.size +
engagement.labelEventIds.size + engagement.labelEventIds.size +
engagement.commentAddresses.size + engagement.commentAddresses.size +
engagement.highlightAddresses.size engagement.highlightAddresses.size +
engagement.booklistAddresses.size +
engagement.booklistEventIds.size +
engagement.myBooklistAddresses.size +
engagement.myBooklistEventIds.size +
engagement.myCommentAddresses.size +
engagement.myCommentEventIds.size +
engagement.myHighlightAddresses.size +
engagement.myHighlightEventIds.size
: 0 : 0
return `${context.indexEvents.length}:${engagementSize}` return `${context.indexEvents.length}:${engagementSize}`
} }
@ -247,23 +257,38 @@ function normalizeLibraryRelayUrl(url: string): string {
return normalizeUrl(trimmed) || trimmed return normalizeUrl(trimmed) || trimmed
} }
function libraryIndexRelayUrls(extraRelayUrls: string[] = []): string[] { function filterBlockedLibraryRelays(urls: string[], blockedRelays: readonly string[] = []): string[] {
const base = LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean) if (blockedRelays.length === 0) return urls
const extra = extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean) return urls.filter((url) => !isRelayBlockedByUser(url, blockedRelays))
}
function libraryIndexRelayUrls(extraRelayUrls: string[] = [], blockedRelays: readonly string[] = []): string[] {
const base = filterBlockedLibraryRelays(
LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean),
blockedRelays
)
const extra = filterBlockedLibraryRelays(
extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean),
blockedRelays
)
return [...new Set([...base, ...extra])] return [...new Set([...base, ...extra])]
} }
export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string[]> { export async function buildLibraryRelayUrls(
const base = libraryIndexRelayUrls() userPubkey?: string,
blockedRelays: string[] = []
): Promise<string[]> {
const base = libraryIndexRelayUrls([], blockedRelays)
const urls = await buildComprehensiveRelayList({ const urls = await buildComprehensiveRelayList({
userPubkey, userPubkey,
includeUserOwnRelays: true, includeUserOwnRelays: true,
includeFastReadRelays: false, includeFastReadRelays: false,
includeSearchableRelays: false, includeSearchableRelays: false,
includeFavoriteRelays: false, includeFavoriteRelays: false,
relayHints: base relayHints: base,
blockedRelays
}) })
return libraryIndexRelayUrls([...urls]) return libraryIndexRelayUrls([...urls], blockedRelays)
} }
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> {
@ -733,13 +758,19 @@ export function publicationEntryBelongsToUser(
userPubkey: string userPubkey: string
bookmarkListEvent?: Event | null bookmarkListEvent?: Event | null
pinListEvent?: Event | null pinListEvent?: Event | null
myBooklistAddresses?: Set<string>
myBooklistEventIds?: Set<string>
} }
): boolean { ): boolean {
const { event } = entry const { event } = entry
const pk = opts.userPubkey.toLowerCase() const pk = opts.userPubkey.toLowerCase()
const rootAddr = eventTagAddress(event)
if (event.pubkey.toLowerCase() === pk) return true if (event.pubkey.toLowerCase() === pk) return true
if (event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true if (event.tags.some((t) => t[0] === 'p' && t[1]?.toLowerCase() === pk)) return true
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 (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
@ -806,20 +837,7 @@ function libraryEntriesFromRoots(
indexByAddress: Map<string, Event>, indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] { ): LibraryPublicationEntry[] {
return roots.map((root) => { return roots.map((root) => buildLibraryPublicationEntry(root, indexByAddress, engagement))
const engaged = filterEngagedPublications([root], indexByAddress, engagement)
if (engaged.length > 0) return engaged[0]
return {
event: root,
hasLabel: false,
labelNames: [],
hasBooklistLabel: false,
hasMyBooklistLabel: false,
hasComment: false,
hasHighlight: false,
engagementCount: 0
}
})
} }
/** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */ /** Re-fetch engagement maps for the current library index snapshot (e.g. after booklist toggle). */
@ -1213,6 +1231,8 @@ export function filterLibraryPublicationsByUser(
opts?: { opts?: {
bookmarkListEvent?: Event | null bookmarkListEvent?: Event | null
pinListEvent?: Event | null pinListEvent?: Event | null
myBooklistAddresses?: Set<string>
myBooklistEventIds?: Set<string>
} }
): LibraryPublicationEntry[] { ): LibraryPublicationEntry[] {
if (!userPubkey) return entries if (!userPubkey) return entries
@ -1220,7 +1240,9 @@ export function filterLibraryPublicationsByUser(
publicationEntryBelongsToUser(entry, { publicationEntryBelongsToUser(entry, {
userPubkey, userPubkey,
bookmarkListEvent: opts?.bookmarkListEvent, bookmarkListEvent: opts?.bookmarkListEvent,
pinListEvent: opts?.pinListEvent pinListEvent: opts?.pinListEvent,
myBooklistAddresses: opts?.myBooklistAddresses,
myBooklistEventIds: opts?.myBooklistEventIds
}) })
) )
} }

6
vite.config.ts

@ -527,6 +527,12 @@ export default defineConfig(({ mode }) => {
cacheableResponse: { statuses: [200] } cacheableResponse: { statuses: [200] }
} }
}, },
{
// Project Gutenberg covers: bypass SW cache — CacheFirst can serve stale/truncated
// HTML error bodies for .jpg URLs and the browser reports "image corrupt or truncated".
urlPattern: /^https:\/\/(?:www\.)?gutenberg\.org\//i,
handler: 'NetworkOnly'
},
{ {
// Generic cross-origin images by file extension (covers hosts not matched above) // Generic cross-origin images by file extension (covers hosts not matched above)
urlPattern: /^https?:\/\/.+\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)(?:\?.*)?$/i, urlPattern: /^https?:\/\/.+\.(?:png|jpg|jpeg|gif|webp|avif|svg|ico)(?:\?.*)?$/i,

Loading…
Cancel
Save