Browse Source

bug-fix

imwald
Silberengel 1 week ago
parent
commit
343230e8e6
  1. 39
      src/hooks/useLibraryPublications.ts
  2. 1
      src/i18n/locales/de.ts
  3. 1
      src/i18n/locales/en.ts
  4. 6
      src/lib/index-relay-http.ts
  5. 8
      src/lib/library-publication-index.test.ts
  6. 381
      src/lib/library-publication-index.ts
  7. 11
      src/lib/publication-index.test.ts
  8. 85
      src/lib/publication-index.ts
  9. 3
      src/pages/primary/LibraryPage/index.tsx

39
src/hooks/useLibraryPublications.ts

@ -6,10 +6,12 @@ import {
loadLibraryPublicationIndex, loadLibraryPublicationIndex,
type LibraryPublicationEntry type LibraryPublicationEntry
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
const LOAD_TIMEOUT_MS = 90_000
export function useLibraryPublications(isActive: boolean) { export function useLibraryPublications(isActive: boolean) {
const { pubkey } = useNostr() const { pubkey } = useNostr()
@ -22,6 +24,7 @@ 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 loadGenRef = useRef(0) const loadGenRef = useRef(0)
const inFlightRef = useRef(0)
useEffect(() => { useEffect(() => {
const t = window.setTimeout(() => setDebouncedSearch(searchQuery), SEARCH_DEBOUNCE_MS) const t = window.setTimeout(() => setDebouncedSearch(searchQuery), SEARCH_DEBOUNCE_MS)
@ -31,20 +34,42 @@ export function useLibraryPublications(isActive: boolean) {
const load = useCallback( const load = useCallback(
async (forceRefresh = false) => { async (forceRefresh = false) => {
const gen = ++loadGenRef.current const gen = ++loadGenRef.current
inFlightRef.current += 1
setLoading(true) setLoading(true)
setError(null) setError(null)
if (import.meta.env.DEV) {
logger.info('[Library] page load requested', { forceRefresh, gen })
}
try { try {
const relays = await buildLibraryRelayUrls(pubkey || undefined) const relays = await buildLibraryRelayUrls(pubkey || undefined)
const result = await loadLibraryPublicationIndex(relays, { forceRefresh }) let timeoutId: number | undefined
if (gen !== loadGenRef.current) return const timeoutPromise = new Promise<never>((_, reject) => {
setEntries(result.engaged) timeoutId = window.setTimeout(() => reject(new Error('Library load timed out')), LOAD_TIMEOUT_MS)
setAllIndexCount(result.allIndexCount) })
setTopLevelCount(result.topLevelCount) try {
const result = await Promise.race([
loadLibraryPublicationIndex(relays, { forceRefresh }),
timeoutPromise
])
if (gen !== loadGenRef.current) return
setEntries(result.engaged)
setAllIndexCount(result.allIndexCount)
setTopLevelCount(result.topLevelCount)
} finally {
if (timeoutId != null) window.clearTimeout(timeoutId)
}
} catch (e) { } catch (e) {
if (gen !== loadGenRef.current) return if (gen !== loadGenRef.current) return
setError(e instanceof Error ? e.message : 'Failed to load library') const message = e instanceof Error ? e.message : 'Failed to load library'
setError(message)
if (import.meta.env.DEV) {
logger.warn('[Library] page load failed', { message, gen })
}
} finally { } finally {
if (gen === loadGenRef.current) setLoading(false) inFlightRef.current = Math.max(0, inFlightRef.current - 1)
if (inFlightRef.current === 0) {
setLoading(false)
}
} }
}, },
[pubkey] [pubkey]

1
src/i18n/locales/de.ts

@ -1653,6 +1653,7 @@ export default {
'Library show only my publications': 'Nur meine Publikationen', 'Library show only my publications': 'Nur meine Publikationen',
'Library empty': 'Noch keine Publikationen mit Interaktionen auf deinen Relays.', 'Library empty': 'Noch keine Publikationen mit Interaktionen auf deinen Relays.',
'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 status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', 'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen',
'Library badge label': 'Label', 'Library badge label': 'Label',
'Library badge comment': 'Kommentar', 'Library badge comment': 'Kommentar',

1
src/i18n/locales/en.ts

@ -1676,6 +1676,7 @@ export default {
'Library show only my publications': 'Show only my publications', 'Library show only my publications': 'Show only my publications',
'Library empty': 'No engaged publications found on your relays yet.', 'Library empty': 'No engaged 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 status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',
'Library badge label': 'Label', 'Library badge label': 'Label',
'Library badge comment': 'Comment', 'Library badge comment': 'Comment',

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

@ -188,7 +188,7 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null {
const created_at = raw.created_at const created_at = raw.created_at
const kind = raw.kind const kind = raw.kind
const tags = raw.tags const tags = raw.tags
const content = raw.content const contentRaw = raw.content
const sig = raw.sig const sig = raw.sig
if ( if (
typeof id !== 'string' || typeof id !== 'string' ||
@ -196,11 +196,13 @@ function rawToVerifiedEvent(raw: Record<string, unknown>): NEvent | null {
typeof created_at !== 'number' || typeof created_at !== 'number' ||
typeof kind !== 'number' || typeof kind !== 'number' ||
!Array.isArray(tags) || !Array.isArray(tags) ||
typeof content !== 'string' ||
typeof sig !== 'string' typeof sig !== 'string'
) { ) {
return null return null
} }
const content =
typeof contentRaw === 'string' ? contentRaw : contentRaw == null ? '' : null
if (content === null) return null
const ev = { id, pubkey, created_at, kind, tags, content, sig } as NEvent const ev = { id, pubkey, created_at, kind, tags, content, sig } as NEvent
return verifyEvent(ev) ? ev : null return verifyEvent(ev) ? ev : null
} catch { } catch {

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

@ -24,7 +24,7 @@ function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0,
} }
describe('library-publication-index', () => { describe('library-publication-index', () => {
it('matches engagement on nested 30041 addresses', async () => { it('matches engagement on nested 30041 addresses', () => {
const leafAddr = `30041:${PK}:chapter-1` const leafAddr = `30041:${PK}:chapter-1`
const childAddr = `30040:${PK}:part-1` const childAddr = `30040:${PK}:part-1`
const root = indexEvent('book', [childAddr]) const root = indexEvent('book', [childAddr])
@ -42,14 +42,14 @@ describe('library-publication-index', () => {
} }
const engagement = buildEngagementMapsFromEvents([], [], [highlight]) const engagement = buildEngagementMapsFromEvents([], [], [highlight])
const engaged = await filterEngagedPublications([root], indexByAddress, engagement, []) const engaged = filterEngagedPublications([root], indexByAddress, engagement)
expect(engaged).toHaveLength(1) expect(engaged).toHaveLength(1)
expect(engaged[0].hasHighlight).toBe(true) expect(engaged[0].hasHighlight).toBe(true)
expect(engaged[0].hasLabel).toBe(false) expect(engaged[0].hasLabel).toBe(false)
}) })
it('matches labels by root event id', async () => { it('matches labels by root event id', () => {
const root = indexEvent('book', [`30041:${PK}:intro`]) const root = indexEvent('book', [`30041:${PK}:intro`])
const indexByAddress = buildIndexByAddress([root]) const indexByAddress = buildIndexByAddress([root])
const label: Event = { const label: Event = {
@ -62,7 +62,7 @@ describe('library-publication-index', () => {
sig: 'e'.repeat(128) sig: 'e'.repeat(128)
} }
const engagement = buildEngagementMapsFromEvents([label], [], []) const engagement = buildEngagementMapsFromEvents([label], [], [])
const engaged = await filterEngagedPublications([root], indexByAddress, engagement, []) const engaged = filterEngagedPublications([root], indexByAddress, engagement)
expect(engaged).toHaveLength(1) expect(engaged).toHaveLength(1)
expect(engaged[0].hasLabel).toBe(true) expect(engaged[0].hasLabel).toBe(true)
}) })

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

@ -1,20 +1,38 @@
import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants' import { ExtendedKind, LIBRARY_RELAY_URLS } from '@/constants'
import logger from '@/lib/logger'
import { queryIndexRelay } from '@/lib/index-relay-http'
import { import {
buildIndexByAddress, buildIndexByAddress,
collectReachableAddresses, collectPublicationIndexEventIds,
collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
fetchMissingIndexByAddress,
filterValidIndexEvents, filterValidIndexEvents,
getTopLevelIndexEvents getTopLevelIndexEvents,
hydrateNestedIndexEvents
} from '@/lib/publication-index' } from '@/lib/publication-index'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import {
canonicalRelaySessionKey,
httpIndexBasesForRelayQuery,
normalizeHttpRelayUrl,
normalizeUrl
} from '@/lib/url'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
const INDEX_FETCH_LIMIT = 500 const INDEX_FETCH_LIMIT = 500
const ENGAGEMENT_FETCH_LIMIT = 500 const INDEX_HTTP_PAGE_LIMIT = 100
const INDEX_HTTP_MAX_PAGES = 5
const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64
const QUERY_OPTS = {
globalTimeout: 18_000,
eoseTimeout: 3_000,
firstRelayResultGraceMs: false as const
}
export type PublicationEngagementMaps = { export type PublicationEngagementMaps = {
labelAddresses: Set<string> labelAddresses: Set<string>
@ -44,17 +62,18 @@ function relaySetKey(urls: string[]): string {
return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|') return [...new Set(urls.map((u) => normalizeUrl(u) || u))].sort().join('|')
} }
export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string[]> { function splitWsAndHttpRelays(relayUrls: string[]): { wsRelays: string[]; httpRelays: string[] } {
const base = LIBRARY_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) const httpKeys = new Set(
const urls = await buildComprehensiveRelayList({ httpIndexBasesForRelayQuery(relayUrls, []).map((u) => canonicalRelaySessionKey(u))
userPubkey, )
includeUserOwnRelays: true, const wsRelays: string[] = []
includeFastReadRelays: true, const httpRelays: string[] = []
includeSearchableRelays: true, for (const url of relayUrls) {
includeFavoriteRelays: true, const key = canonicalRelaySessionKey(normalizeUrl(url) || url)
relayHints: base if (httpKeys.has(key)) httpRelays.push(url)
}) else if (!/^https?:\/\//i.test(url.trim())) wsRelays.push(url)
return [...new Set([...base, ...urls])] }
return { wsRelays, httpRelays }
} }
function dedupeEventsById(events: Event[]): Event[] { function dedupeEventsById(events: Event[]): Event[] {
@ -66,53 +85,167 @@ function dedupeEventsById(events: Event[]): Event[] {
return [...byId.values()] return [...byId.values()]
} }
function chunkArray<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
async function fetchPaginatedFromHttpIndexRelay(baseUrl: string, filter: Filter): Promise<Event[]> {
const out: Event[] = []
const seen = new Set<string>()
let until: number | undefined
for (let page = 0; page < INDEX_HTTP_MAX_PAGES; page++) {
const pageFilter: Filter = {
...filter,
limit: INDEX_HTTP_PAGE_LIMIT,
...(until != null ? { until } : {})
}
const batch = await queryIndexRelay(baseUrl, pageFilter)
if (batch.length === 0) break
let oldest = batch[0].created_at
for (const ev of batch) {
if (ev.created_at < oldest) oldest = ev.created_at
if (seen.has(ev.id)) continue
seen.add(ev.id)
out.push(ev)
}
if (batch.length < INDEX_HTTP_PAGE_LIMIT) break
until = oldest - 1
}
return out
}
function normalizeLibraryRelayUrl(url: string): string {
const trimmed = url.trim()
if (!trimmed) return ''
const http = normalizeHttpRelayUrl(trimmed)
if (http) return http
return normalizeUrl(trimmed) || trimmed
}
function libraryIndexRelayUrls(extraRelayUrls: string[] = []): string[] {
const base = LIBRARY_RELAY_URLS.map(normalizeLibraryRelayUrl).filter(Boolean)
const extra = extraRelayUrls.map(normalizeLibraryRelayUrl).filter(Boolean)
return [...new Set([...base, ...extra])]
}
export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string[]> {
const base = libraryIndexRelayUrls()
const urls = await buildComprehensiveRelayList({
userPubkey,
includeUserOwnRelays: true,
includeFastReadRelays: false,
includeSearchableRelays: false,
includeFavoriteRelays: false,
relayHints: base
})
return libraryIndexRelayUrls([...urls])
}
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> {
if (relayUrls.length === 0) return [] const indexRelays = libraryIndexRelayUrls(relayUrls)
if (indexRelays.length === 0) return []
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT }
const events = await queryService.fetchEvents(relayUrls, [filter], { const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
globalTimeout: 25_000,
eoseTimeout: 4_000, const batches: Promise<Event[]>[] = []
firstRelayResultGraceMs: false if (wsRelays.length > 0) {
}) batches.push(queryService.fetchEvents(wsRelays, [filter], QUERY_OPTS))
return filterValidIndexEvents(dedupeEventsById(events)) }
for (const httpRelay of httpRelays) {
batches.push(fetchPaginatedFromHttpIndexRelay(httpRelay, filter))
}
const merged = dedupeEventsById((await Promise.all(batches)).flat())
const valid = filterValidIndexEvents(merged)
if (import.meta.env.DEV) {
logger.info('[Library] index fetch', {
indexRelays: indexRelays.length,
wsRelays: wsRelays.length,
httpRelays: httpRelays.length,
mergedCount: merged.length,
validCount: valid.length
})
}
return valid
} }
export function buildEngagementMapsFromEvents( export function buildEngagementMapsFromEvents(
labels: Event[], labels: Event[],
comments: Event[], comments: Event[],
highlights: Event[] highlights: Event[],
targetAddresses?: Set<string>,
targetEventIds?: Set<string>
): PublicationEngagementMaps { ): PublicationEngagementMaps {
const labelAddresses = new Set<string>() const labelAddresses = new Set<string>()
const labelEventIds = new Set<string>() const labelEventIds = new Set<string>()
const commentAddresses = new Set<string>() const commentAddresses = new Set<string>()
const highlightAddresses = new Set<string>() const highlightAddresses = new Set<string>()
const addressMatches = (addr: string) => !targetAddresses || targetAddresses.has(addr)
const eventIdMatches = (id: string) => !targetEventIds || targetEventIds.has(id.toLowerCase())
for (const ev of labels) { for (const ev of labels) {
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'a' && tag[1]) labelAddresses.add(tag[1]) if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) labelAddresses.add(tag[1])
if (tag[0] === 'e' && tag[1]) labelEventIds.add(tag[1].toLowerCase()) if (tag[0] === 'e' && tag[1] && eventIdMatches(tag[1])) labelEventIds.add(tag[1].toLowerCase())
} }
} }
for (const ev of comments) { for (const ev of comments) {
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'A' && tag[1]) commentAddresses.add(tag[1]) if (tag[0] === 'A' && tag[1] && addressMatches(tag[1])) commentAddresses.add(tag[1])
} }
} }
for (const ev of highlights) { for (const ev of highlights) {
for (const tag of ev.tags) { for (const tag of ev.tags) {
if (tag[0] === 'a' && tag[1]) highlightAddresses.add(tag[1]) if (tag[0] === 'a' && tag[1] && addressMatches(tag[1])) highlightAddresses.add(tag[1])
} }
} }
return { labelAddresses, labelEventIds, commentAddresses, highlightAddresses } return { labelAddresses, labelEventIds, commentAddresses, highlightAddresses }
} }
async function fetchHttpEngagementByAddresses(
httpRelays: string[],
kind: number,
tagKey: '#a' | '#A',
addressChunks: string[][]
): Promise<Event[]> {
if (httpRelays.length === 0 || addressChunks.length === 0) return []
const out: Event[] = []
const seen = new Set<string>()
for (const relay of httpRelays) {
for (const chunk of addressChunks) {
if (chunk.length === 0) continue
const filter = {
kinds: [kind],
[tagKey]: chunk,
limit: Math.min(chunk.length * 10, INDEX_HTTP_PAGE_LIMIT)
} as Filter
const batch = await queryIndexRelay(relay, filter)
for (const ev of batch) {
if (seen.has(ev.id)) continue
seen.add(ev.id)
out.push(ev)
}
}
}
return out
}
export async function fetchPublicationEngagementMaps( export async function fetchPublicationEngagementMaps(
relayUrls: string[] relayUrls: string[],
targetAddresses: Set<string>,
targetEventIds: Set<string>
): Promise<PublicationEngagementMaps> { ): Promise<PublicationEngagementMaps> {
if (relayUrls.length === 0) { if (relayUrls.length === 0 || targetAddresses.size === 0) {
return { return {
labelAddresses: new Set(), labelAddresses: new Set(),
labelEventIds: new Set(), labelEventIds: new Set(),
@ -121,34 +254,59 @@ export async function fetchPublicationEngagementMaps(
} }
} }
const opts = { const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK)
globalTimeout: 25_000, const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK)
eoseTimeout: 4_000, const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls)
firstRelayResultGraceMs: false as const
}
const [labels, comments, highlights] = await Promise.all([ const highlightFilters = addressChunks.map(
queryService.fetchEvents( (chunk): Filter => ({ kinds: [kinds.Highlights], '#a': chunk, limit: chunk.length * 12 })
relayUrls, )
[{ kinds: [ExtendedKind.LABEL], limit: ENGAGEMENT_FETCH_LIMIT }], const labelAddressFilters = addressChunks.map(
opts (chunk): Filter => ({ kinds: [ExtendedKind.LABEL], '#a': chunk, limit: chunk.length * 8 })
), )
queryService.fetchEvents( const labelEventFilters = eventIdChunks.map(
relayUrls, (chunk): Filter => ({ kinds: [ExtendedKind.LABEL], '#e': chunk, limit: chunk.length * 6 })
[{ kinds: [ExtendedKind.COMMENT], limit: ENGAGEMENT_FETCH_LIMIT }], )
opts const commentWsFilters = addressChunks.map(
), (chunk): Filter => ({ kinds: [ExtendedKind.COMMENT], '#A': chunk, limit: chunk.length * 12 })
queryService.fetchEvents( )
relayUrls,
[{ kinds: [kinds.Highlights], limit: ENGAGEMENT_FETCH_LIMIT }], const highlightPromise = Promise.all([
opts wsRelays.length > 0 && highlightFilters.length > 0
) ? queryService.fetchEvents(wsRelays, highlightFilters, QUERY_OPTS)
: Promise.resolve([] as Event[]),
fetchHttpEngagementByAddresses(httpRelays, kinds.Highlights, '#a', addressChunks)
]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk]))
const labelPromise = Promise.all([
wsRelays.length > 0 && labelAddressFilters.length > 0
? queryService.fetchEvents(wsRelays, labelAddressFilters, QUERY_OPTS)
: Promise.resolve([] as Event[]),
wsRelays.length > 0 && labelEventFilters.length > 0
? queryService.fetchEvents(wsRelays, labelEventFilters, QUERY_OPTS)
: Promise.resolve([] as Event[]),
fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.LABEL, '#a', addressChunks)
]).then(([byAddress, byEvent, bulk]) => dedupeEventsById([...byAddress, ...byEvent, ...bulk]))
const commentPromise = Promise.all([
wsRelays.length > 0 && commentWsFilters.length > 0
? queryService.fetchEvents(wsRelays, commentWsFilters, QUERY_OPTS)
: Promise.resolve([] as Event[]),
fetchHttpEngagementByAddresses(httpRelays, ExtendedKind.COMMENT, '#A', addressChunks)
]).then(([scoped, bulk]) => dedupeEventsById([...scoped, ...bulk]))
const [highlights, labels, comments] = await Promise.all([
highlightPromise,
labelPromise,
commentPromise
]) ])
return buildEngagementMapsFromEvents( return buildEngagementMapsFromEvents(
dedupeEventsById(labels), dedupeEventsById(labels),
dedupeEventsById(comments), dedupeEventsById(comments),
dedupeEventsById(highlights) dedupeEventsById(highlights),
targetAddresses,
targetEventIds
) )
} }
@ -165,17 +323,15 @@ function addressHasEngagement(
return { hasLabel, hasComment, hasHighlight } return { hasLabel, hasComment, hasHighlight }
} }
export async function filterEngagedPublications( export function filterEngagedPublications(
roots: Event[], roots: Event[],
indexByAddress: Map<string, Event>, indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps, engagement: PublicationEngagementMaps
relayUrls: string[] ): LibraryPublicationEntry[] {
): Promise<LibraryPublicationEntry[]> {
const fetchMissing = (address: string) => fetchMissingIndexByAddress(address, relayUrls)
const out: LibraryPublicationEntry[] = [] const out: LibraryPublicationEntry[] = []
for (const root of roots) { for (const root of roots) {
const reachable = await collectReachableAddresses(root, indexByAddress, fetchMissing) const reachable = collectReachableAddressesCached(root, indexByAddress)
const rootAddr = eventTagAddress(root) const rootAddr = eventTagAddress(root)
if (rootAddr) reachable.add(rootAddr) if (rootAddr) reachable.add(rootAddr)
@ -278,6 +434,41 @@ export function filterLibraryPublicationsByUser(
}) })
} }
function collectTargetAddressesFromIndexes(
indexEvents: Event[],
indexByAddress: Map<string, Event>
): Set<string> {
const addresses = new Set<string>()
outer: for (const root of getTopLevelIndexEvents(indexEvents)) {
for (const addr of collectReachableAddressesCached(root, indexByAddress)) {
addresses.add(addr)
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
}
const rootAddr = eventTagAddress(root)
if (rootAddr) {
addresses.add(rootAddr)
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
}
}
return addresses
}
async function buildEngagedFromCache(
relayUrls: string[],
indexEvents: Event[],
indexByAddress: Map<string, Event>,
engagement?: PublicationEngagementMaps
): Promise<LibraryPublicationEntry[]> {
const topLevel = getTopLevelIndexEvents(indexEvents)
let maps = engagement
if (!maps) {
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds)
}
return sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, maps))
}
export async function loadLibraryPublicationIndex( export async function loadLibraryPublicationIndex(
relayUrls: string[], relayUrls: string[],
options?: { forceRefresh?: boolean } options?: { forceRefresh?: boolean }
@ -287,32 +478,76 @@ export async function loadLibraryPublicationIndex(
topLevelCount: number topLevelCount: number
}> { }> {
const key = relaySetKey(relayUrls) const key = relaySetKey(relayUrls)
if (import.meta.env.DEV) {
logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key })
}
if (!options?.forceRefresh && sessionCache?.relayKey === key) { if (!options?.forceRefresh && sessionCache?.relayKey === key) {
const engaged = await buildEngagedFromCache(
relayUrls,
sessionCache.indexEvents,
sessionCache.indexByAddress,
sessionCache.engagement
)
if (import.meta.env.DEV) {
logger.info('[Library] load from cache', { engaged: engaged.length })
}
return { return {
engaged: sortLibraryPublications( engaged,
await filterEngagedPublications(
getTopLevelIndexEvents(sessionCache.indexEvents),
sessionCache.indexByAddress,
sessionCache.engagement,
relayUrls
)
),
allIndexCount: sessionCache.indexEvents.length, allIndexCount: sessionCache.indexEvents.length,
topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length topLevelCount: getTopLevelIndexEvents(sessionCache.indexEvents).length
} }
} }
const [indexEvents, engagement] = await Promise.all([ const indexEvents = await fetchLibraryIndexEvents(relayUrls)
fetchLibraryIndexEvents(relayUrls), if (import.meta.env.DEV) {
fetchPublicationEngagementMaps(relayUrls) logger.info('[Library] indexes fetched', { validCount: indexEvents.length })
]) }
const indexByAddress = buildIndexByAddress(indexEvents) const indexByAddress = buildIndexByAddress(indexEvents)
const topLevelForHydrate = getTopLevelIndexEvents(indexEvents)
await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, {
maxPasses: 1,
maxMissingPerPass: HYDRATE_MISSING_CAP,
scanRoots: topLevelForHydrate
})
if (import.meta.env.DEV) {
logger.info('[Library] nested hydrate done', { indexCount: indexEvents.length })
}
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
if (import.meta.env.DEV) {
logger.info('[Library] fetching engagement', {
targetAddresses: targetAddresses.size,
targetEventIds: targetEventIds.size
})
}
const engagement = await fetchPublicationEngagementMaps(
relayUrls,
targetAddresses,
targetEventIds
)
if (import.meta.env.DEV) {
logger.info('[Library] engagement maps built', {
labels: engagement.labelAddresses.size + engagement.labelEventIds.size,
comments: engagement.commentAddresses.size,
highlights: engagement.highlightAddresses.size
})
}
sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement }
const topLevel = getTopLevelIndexEvents(indexEvents) const topLevel = getTopLevelIndexEvents(indexEvents)
const engaged = sortLibraryPublications( const engaged = sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, engagement))
await filterEngagedPublications(topLevel, indexByAddress, engagement, relayUrls)
) if (import.meta.env.DEV) {
logger.info('[Library] load done', {
engaged: engaged.length,
topLevel: topLevel.length,
allIndexCount: indexEvents.length
})
}
return { return {
engaged, engaged,

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

@ -3,6 +3,7 @@ import { ExtendedKind } from '@/constants'
import { import {
buildIndexByAddress, buildIndexByAddress,
collectReachableAddresses, collectReachableAddresses,
collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
filterValidIndexEvents, filterValidIndexEvents,
getTopLevelIndexEvents getTopLevelIndexEvents
@ -40,7 +41,9 @@ describe('publication-index', () => {
const valid = indexEvent('book', [`30041:${PK}:chapter-1`]) const valid = indexEvent('book', [`30041:${PK}:chapter-1`])
const withContent = { ...valid, content: 'not empty' } const withContent = { ...valid, content: 'not empty' }
const noTitle = { ...valid, tags: [['d', 'book'], ['a', `30041:${PK}:chapter-1`]] } const noTitle = { ...valid, tags: [['d', 'book'], ['a', `30041:${PK}:chapter-1`]] }
const nullContent = { ...valid, content: null as unknown as string }
expect(filterValidIndexEvents([valid])).toHaveLength(1) expect(filterValidIndexEvents([valid])).toHaveLength(1)
expect(filterValidIndexEvents([nullContent])).toHaveLength(1)
expect(filterValidIndexEvents([withContent, noTitle])).toHaveLength(0) expect(filterValidIndexEvents([withContent, noTitle])).toHaveLength(0)
}) })
@ -53,18 +56,14 @@ describe('publication-index', () => {
expect(eventTagAddress(top[0])).toBe(`30040:${PK}:book`) expect(eventTagAddress(top[0])).toBe(`30040:${PK}:book`)
}) })
it('collectReachableAddresses walks nested 30040 and 30041 refs', async () => { it('collectReachableAddressesCached walks nested 30040 and 30041 refs', () => {
const childAddr = `30040:${PK}:part-1` const childAddr = `30040:${PK}:part-1`
const leafAddr = `30041:${PK}:chapter-1` const leafAddr = `30041:${PK}:chapter-1`
const root = indexEvent('book', [childAddr, `30041:${PK}:intro`]) const root = indexEvent('book', [childAddr, `30041:${PK}:intro`])
const child = indexEvent('part-1', [leafAddr], '2'.repeat(64)) const child = indexEvent('part-1', [leafAddr], '2'.repeat(64))
const indexByAddress = buildIndexByAddress([root, child]) const indexByAddress = buildIndexByAddress([root, child])
const reachable = await collectReachableAddresses( const reachable = collectReachableAddressesCached(root, indexByAddress)
root,
indexByAddress,
async () => null
)
expect(reachable.has(`30040:${PK}:book`)).toBe(true) expect(reachable.has(`30040:${PK}:book`)).toBe(true)
expect(reachable.has(childAddr)).toBe(true) expect(reachable.has(childAddr)).toBe(true)

85
src/lib/publication-index.ts

@ -17,8 +17,10 @@ export function filterValidIndexEvents(events: Event[]): Event[] {
return events.filter((event) => { return events.filter((event) => {
if (event.kind !== ExtendedKind.PUBLICATION) return false if (event.kind !== ExtendedKind.PUBLICATION) return false
if (event.content != null && event.content.length > 0) return false if (event.content != null && event.content.length > 0) return false
const hasTitle = event.tags.some((t) => t[0] === 'title' && t[1]) const hasTitle = event.tags.some(
const hasD = event.tags.some((t) => t[0] === 'd' && t[1]) (t) => (t[0] || '').trim().toLowerCase() === 'title' && t[1]
)
const hasD = event.tags.some((t) => (t[0] || '').trim().toLowerCase() === 'd' && t[1])
const hasA = event.tags.some((t) => t[0] === 'a' && t[1]) const hasA = event.tags.some((t) => t[0] === 'a' && t[1])
const hasE = event.tags.some((t) => t[0] === 'e' && t[1]) const hasE = event.tags.some((t) => t[0] === 'e' && t[1])
return hasTitle && hasD && (hasA || hasE) return hasTitle && hasD && (hasA || hasE)
@ -91,6 +93,85 @@ export function buildIndexByAddress(events: Event[]): Map<string, Event> {
return map return map
} }
/** BFS over addresses already present in `indexByAddress` (no network I/O). */
export function collectReachableAddressesCached(
root: Event,
indexByAddress: Map<string, Event>
): Set<string> {
const reachable = new Set<string>()
const rootAddr = eventTagAddress(root)
if (!rootAddr) return reachable
const queue = [rootAddr]
while (queue.length > 0) {
const addr = queue.shift()!
if (reachable.has(addr)) continue
reachable.add(addr)
const event = indexByAddress.get(addr)
if (!event || event.kind !== ExtendedKind.PUBLICATION) continue
for (const child of collectChildAddressesFromIndex(event)) {
if (!reachable.has(child)) queue.push(child)
}
}
return reachable
}
export function collectPublicationIndexEventIds(events: Event[]): Set<string> {
return new Set(events.map((ev) => ev.id.toLowerCase()))
}
export type HydrateNestedIndexOptions = {
maxPasses?: number
/** Cap missing nested 30040 fetches per pass (library bulk load). */
maxMissingPerPass?: number
/** When set, only scan these roots for missing nested 30040 refs. */
scanRoots?: Event[]
}
/** Batch-fetch nested kind 30040 indexes referenced by `a` tags but missing from cache. */
export async function hydrateNestedIndexEvents(
indexEvents: Event[],
indexByAddress: Map<string, Event>,
relayUrls: string[],
options?: HydrateNestedIndexOptions | number
): Promise<void> {
const opts: HydrateNestedIndexOptions =
typeof options === 'number' ? { maxPasses: options } : (options ?? {})
const maxPasses = opts.maxPasses ?? 2
const maxMissingPerPass = opts.maxMissingPerPass
const scanEvents = opts.scanRoots ?? indexEvents
for (let pass = 0; pass < maxPasses; pass++) {
const missingRefs: PublicationSectionRef[] = []
const seenCoords = new Set<string>()
for (const event of scanEvents) {
if (event.kind !== ExtendedKind.PUBLICATION) continue
for (const ref of collectPublicationATagRefs(event)) {
if (ref.kind !== ExtendedKind.PUBLICATION || !ref.coordinate) continue
if (indexByAddress.has(ref.coordinate) || seenCoords.has(ref.coordinate)) continue
seenCoords.add(ref.coordinate)
missingRefs.push(ref)
if (maxMissingPerPass != null && missingRefs.length >= maxMissingPerPass) break
}
if (maxMissingPerPass != null && missingRefs.length >= maxMissingPerPass) break
}
if (missingRefs.length === 0) break
const fetched = await batchFetchPublicationSectionEvents(missingRefs, relayUrls)
let added = 0
for (const ev of fetched.values()) {
const addr = eventTagAddress(ev)
if (!addr || indexByAddress.has(addr)) continue
indexByAddress.set(addr, ev)
indexEvents.push(ev)
added++
}
if (added === 0) break
}
}
export async function collectReachableAddresses( export async function collectReachableAddresses(
root: Event, root: Event,
indexByAddress: Map<string, Event>, indexByAddress: Map<string, Event>,

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

@ -67,6 +67,9 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
{error} {error}
</div> </div>
) : null} ) : null}
{loading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library loading')}</p>
) : null}
{statusLine ? ( {statusLine ? (
<p className="mb-4 text-xs text-muted-foreground">{statusLine}</p> <p className="mb-4 text-xs text-muted-foreground">{statusLine}</p>
) : null} ) : null}

Loading…
Cancel
Save