Browse Source

show newest 10 publications, if there are none with interactions

imwald
Silberengel 1 week ago
parent
commit
871ee720b4
  1. 2
      src/PageManager.tsx
  2. 20
      src/hooks/useReplyIngress.ts
  3. 2
      src/i18n/locales/de.ts
  4. 2
      src/i18n/locales/en.ts
  5. 48
      src/lib/library-index-cache-config.test.ts
  6. 39
      src/lib/library-index-cache-config.ts
  7. 50
      src/lib/library-index-idb-cache.ts
  8. 29
      src/lib/library-publication-index.test.ts
  9. 48
      src/lib/library-publication-index.ts
  10. 25
      src/pages/secondary/NotePage/index.tsx
  11. 7
      src/providers/ReplyProvider.tsx
  12. 207
      src/services/indexed-db.service.ts

2
src/PageManager.tsx

@ -454,7 +454,7 @@ function extractValidNoteId(raw: string): string | null {
function parseNoteUrl(url: string): { noteId: string; context?: string } | null { function parseNoteUrl(url: string): { noteId: string; context?: string } | null {
// Match patterns like /discussions/notes/{noteId} or /notes/{noteId} // Match patterns like /discussions/notes/{noteId} or /notes/{noteId}
const contextualMatch = url.match( const contextualMatch = url.match(
/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ /\/(discussions|search|library|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/
) )
if (contextualMatch) { if (contextualMatch) {
const noteId = extractValidNoteId(contextualMatch[2]) const noteId = extractValidNoteId(contextualMatch[2])

20
src/hooks/useReplyIngress.ts

@ -1,5 +1,16 @@
import { useReply } from '@/providers/ReplyProvider' import { type TRepliesMap } from '@/lib/reply-index'
import { useReplyOptional } from '@/providers/ReplyProvider'
import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider' import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider'
import type { Event } from 'nostr-tools'
const noopAddReplies = (_replies: Event[]) => {}
const EMPTY_REPLIES_MAP: TRepliesMap = new Map()
const REPLY_INGRESS_FALLBACK = {
repliesMap: EMPTY_REPLIES_MAP,
addReplies: noopAddReplies,
scoped: false as const
}
/** /**
* Reply map ingress for the open note panel: prefers per-thread storage when * Reply map ingress for the open note panel: prefers per-thread storage when
@ -7,9 +18,12 @@ import { useThreadReplyOptional } from '@/providers/ThreadReplyProvider'
*/ */
export function useReplyIngress() { export function useReplyIngress() {
const thread = useThreadReplyOptional() const thread = useThreadReplyOptional()
const global = useReply()
if (thread) { if (thread) {
return { repliesMap: thread.repliesMap, addReplies: thread.addReplies, scoped: true as const } return { repliesMap: thread.repliesMap, addReplies: thread.addReplies, scoped: true as const }
} }
return { repliesMap: global.repliesMap, addReplies: global.addReplies, scoped: false as const } const global = useReplyOptional()
if (global) {
return { repliesMap: global.repliesMap, addReplies: global.addReplies, scoped: false as const }
}
return REPLY_INGRESS_FALLBACK
} }

2
src/i18n/locales/de.ts

@ -1651,7 +1651,7 @@ export default {
'Library page title': 'Bibliothek', 'Library page title': 'Bibliothek',
'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…', 'Library search placeholder': 'Publikationen nach Titel, Autor oder Tag suchen…',
'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 auf deinen Relays gefunden.',
'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.', 'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.',
'Library loading': 'Publikationen werden von Dokument-Relays geladen…', 'Library loading': 'Publikationen werden von Dokument-Relays geladen…',
'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen', 'Library status line': '{{shown}} angezeigt · {{topLevel}} Top-Level · {{total}} Indizes geladen',

2
src/i18n/locales/en.ts

@ -1674,7 +1674,7 @@ export default {
'Library page title': 'Library', 'Library page title': 'Library',
'Library search placeholder': 'Search publications by title, author, or tag…', 'Library search placeholder': 'Search publications by title, author, or tag…',
'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 publications found on your relays yet.',
'Library empty filtered': 'No publications match your filters.', 'Library empty filtered': 'No publications match your filters.',
'Library loading': 'Loading publications from document relays…', 'Library loading': 'Loading publications from document relays…',
'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded', 'Library status line': '{{shown}} shown · {{topLevel}} top-level · {{total}} indexes loaded',

48
src/lib/library-index-cache-config.test.ts

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
approxLibraryIndexEventBytes,
getLibraryIndexCacheBudget,
LIBRARY_INDEX_CACHE_DEFAULTS
} from '@/lib/library-index-cache-config'
import { ExtendedKind } from '@/constants'
import type { Event } from 'nostr-tools'
function sampleIndexEvent(tagCount: number): Event {
const tags: string[][] = [
['d', 'sample-book'],
['title', 'Sample Book']
]
for (let i = 0; i < tagCount; i++) {
tags.push(['a', `30041:${'a'.repeat(64)}:chapter-${i}`, 'wss://relay.example', 'b'.repeat(64)])
}
return {
id: 'c'.repeat(64),
kind: ExtendedKind.PUBLICATION,
pubkey: 'a'.repeat(64),
created_at: 1_700_000_000,
content: '',
tags,
sig: 'd'.repeat(128)
}
}
describe('library-index-cache-config', () => {
it('approxLibraryIndexEventBytes returns positive size', () => {
const bytes = approxLibraryIndexEventBytes(sampleIndexEvent(3))
expect(bytes).toBeGreaterThan(100)
})
it('getLibraryIndexCacheBudget returns platform defaults', () => {
const budget = getLibraryIndexCacheBudget()
expect(budget.maxEntries).toBeGreaterThanOrEqual(LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesMobile)
expect(budget.maxBytes).toBeGreaterThanOrEqual(LIBRARY_INDEX_CACHE_DEFAULTS.maxMbMobile * 1024 * 1024)
})
it('5000 mercury-sized indexes land near documented desktop budget', () => {
const avgMercuryBytes = 14_131
const est5000Mb = (avgMercuryBytes * LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesDesktop) / (1024 * 1024)
expect(est5000Mb).toBeGreaterThan(50)
expect(est5000Mb).toBeLessThan(120)
expect(LIBRARY_INDEX_CACHE_DEFAULTS.maxMbDesktop).toBeGreaterThanOrEqual(Math.ceil(est5000Mb))
})
})

39
src/lib/library-index-cache-config.ts

@ -0,0 +1,39 @@
import { isImwaldElectron, isMobileBrowserProfile } from '@/lib/client-platform'
import type { Event } from 'nostr-tools'
/** Platform caps for the dedicated Library kind-30040 index LRU store (separate from EVENT_ARCHIVE). */
export const LIBRARY_INDEX_CACHE_DEFAULTS = {
maxEntriesMobile: 400,
maxEntriesDesktop: 5000,
maxEntriesElectron: 5000,
maxMbMobile: 40,
maxMbDesktop: 96,
maxMbElectron: 128
} as const
export function approxLibraryIndexEventBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
return 2048
}
}
export function getLibraryIndexCacheBudget(): { maxEntries: number; maxBytes: number } {
if (isImwaldElectron()) {
return {
maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesElectron,
maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbElectron * 1024 * 1024
}
}
if (isMobileBrowserProfile()) {
return {
maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesMobile,
maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbMobile * 1024 * 1024
}
}
return {
maxEntries: LIBRARY_INDEX_CACHE_DEFAULTS.maxEntriesDesktop,
maxBytes: LIBRARY_INDEX_CACHE_DEFAULTS.maxMbDesktop * 1024 * 1024
}
}

50
src/lib/library-index-idb-cache.ts

@ -0,0 +1,50 @@
import { ExtendedKind } from '@/constants'
import {
approxLibraryIndexEventBytes,
getLibraryIndexCacheBudget
} from '@/lib/library-index-cache-config'
import logger from '@/lib/logger'
import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools'
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try {
return await indexedDb.getLibraryPublicationIndexCacheEvents()
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] index IDB read failed', {
message: e instanceof Error ? e.message : String(e)
})
}
return []
}
}
export async function persistLibraryIndexCacheEvents(events: Event[]): Promise<void> {
const kind30040 = events.filter((ev) => ev.kind === ExtendedKind.PUBLICATION)
if (kind30040.length === 0) return
try {
const budget = getLibraryIndexCacheBudget()
await indexedDb.mergeLibraryPublicationIndexCacheEvents(kind30040, budget)
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] index IDB write failed', {
message: e instanceof Error ? e.message : String(e)
})
}
}
}
export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
try {
return await indexedDb.getLibraryPublicationIndexCacheFootprint()
} catch {
return { count: 0, bytes: 0 }
}
}
export async function clearLibraryIndexIdbCache(): Promise<void> {
await indexedDb.clearLibraryPublicationIndexCacheStore()
}
export { approxLibraryIndexEventBytes, getLibraryIndexCacheBudget }

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

@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import {
buildEngagementMapsFromEvents, buildEngagementMapsFromEvents,
buildRecentPublicationEntries,
filterEngagedPublications, filterEngagedPublications,
filterLibraryPublicationsBySearch filterLibraryPublicationsBySearch,
pickLibraryPublicationEntries
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
import { buildIndexByAddress } from '@/lib/publication-index' import { buildIndexByAddress } from '@/lib/publication-index'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -81,4 +83,29 @@ describe('library-publication-index', () => {
expect(filterLibraryPublicationsBySearch(entries, 'title book')).toHaveLength(1) expect(filterLibraryPublicationsBySearch(entries, 'title book')).toHaveLength(1)
expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0) expect(filterLibraryPublicationsBySearch(entries, 'missing')).toHaveLength(0)
}) })
it('pickLibraryPublicationEntries falls back to newest roots without engagement', () => {
const older = indexEvent('old-book', [`30041:${PK}:a`], '1'.repeat(64))
older.created_at = 10
const newer = indexEvent('new-book', [`30041:${PK}:b`], '2'.repeat(64))
newer.created_at = 20
const indexByAddress = buildIndexByAddress([older, newer])
const engagement = buildEngagementMapsFromEvents([], [], [])
const picked = pickLibraryPublicationEntries([older, newer], indexByAddress, engagement)
expect(picked).toHaveLength(2)
expect(picked[0].event.id).toBe(newer.id)
expect(picked.every((e) => e.engagementCount === 0)).toBe(true)
})
it('buildRecentPublicationEntries caps at limit', () => {
const roots = Array.from({ length: 12 }, (_, i) => {
const ev = indexEvent(`book-${i}`, [`30041:${PK}:ch-${i}`], String(i).padEnd(64, '0').slice(0, 64))
ev.created_at = i
return ev
})
expect(buildRecentPublicationEntries(roots, 10)).toHaveLength(10)
expect(buildRecentPublicationEntries(roots, 10)[0].event.created_at).toBe(11)
})
}) })

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

@ -11,6 +11,10 @@ import {
hydrateNestedIndexEvents hydrateNestedIndexEvents
} from '@/lib/publication-index' } from '@/lib/publication-index'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import {
loadLibraryIndexCacheEvents,
persistLibraryIndexCacheEvents
} from '@/lib/library-index-idb-cache'
import { import {
canonicalRelaySessionKey, canonicalRelaySessionKey,
httpIndexBasesForRelayQuery, httpIndexBasesForRelayQuery,
@ -28,6 +32,7 @@ const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44 const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480 const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64 const HYDRATE_MISSING_CAP = 64
export const LIBRARY_RECENT_FALLBACK_LIMIT = 10
const QUERY_OPTS = { const QUERY_OPTS = {
globalTimeout: 18_000, globalTimeout: 18_000,
eoseTimeout: 3_000, eoseTimeout: 3_000,
@ -150,6 +155,8 @@ export async function buildLibraryRelayUrls(userPubkey?: string): Promise<string
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> {
const indexRelays = libraryIndexRelayUrls(relayUrls) const indexRelays = libraryIndexRelayUrls(relayUrls)
if (indexRelays.length === 0) return [] if (indexRelays.length === 0) return []
const cached = await loadLibraryIndexCacheEvents()
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT }
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
@ -161,13 +168,18 @@ export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Even
batches.push(fetchPaginatedFromHttpIndexRelay(httpRelay, filter)) batches.push(fetchPaginatedFromHttpIndexRelay(httpRelay, filter))
} }
const merged = dedupeEventsById((await Promise.all(batches)).flat()) const networkMerged =
batches.length > 0 ? dedupeEventsById((await Promise.all(batches)).flat()) : []
const merged = dedupeEventsById([...cached, ...networkMerged])
const valid = filterValidIndexEvents(merged) const valid = filterValidIndexEvents(merged)
void persistLibraryIndexCacheEvents(valid)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] index fetch', { logger.info('[Library] index fetch', {
indexRelays: indexRelays.length, indexRelays: indexRelays.length,
wsRelays: wsRelays.length, wsRelays: wsRelays.length,
httpRelays: httpRelays.length, httpRelays: httpRelays.length,
cachedCount: cached.length,
networkCount: networkMerged.length,
mergedCount: merged.length, mergedCount: merged.length,
validCount: valid.length validCount: valid.length
}) })
@ -368,6 +380,33 @@ export function filterEngagedPublications(
return out return out
} }
export function buildRecentPublicationEntries(
roots: Event[],
limit = LIBRARY_RECENT_FALLBACK_LIMIT
): LibraryPublicationEntry[] {
return [...roots]
.sort((a, b) => b.created_at - a.created_at)
.slice(0, limit)
.map((event) => ({
event,
hasLabel: false,
hasComment: false,
hasHighlight: false,
engagementCount: 0
}))
}
/** Engaged publications first; when none match, show the newest top-level indexes. */
export function pickLibraryPublicationEntries(
roots: Event[],
indexByAddress: Map<string, Event>,
engagement: PublicationEngagementMaps
): LibraryPublicationEntry[] {
const engaged = sortLibraryPublications(filterEngagedPublications(roots, indexByAddress, engagement))
if (engaged.length > 0) return engaged
return buildRecentPublicationEntries(roots)
}
export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] { export function sortLibraryPublications(entries: LibraryPublicationEntry[]): LibraryPublicationEntry[] {
return [...entries].sort((a, b) => { return [...entries].sort((a, b) => {
if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1 if (a.hasLabel !== b.hasLabel) return a.hasLabel ? -1 : 1
@ -466,7 +505,7 @@ async function buildEngagedFromCache(
const targetEventIds = collectPublicationIndexEventIds(indexEvents) const targetEventIds = collectPublicationIndexEventIds(indexEvents)
maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds) maps = await fetchPublicationEngagementMaps(relayUrls, targetAddresses, targetEventIds)
} }
return sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, maps)) return pickLibraryPublicationEntries(topLevel, indexByAddress, maps)
} }
export async function loadLibraryPublicationIndex( export async function loadLibraryPublicationIndex(
@ -539,13 +578,14 @@ export async function loadLibraryPublicationIndex(
sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement } sessionCache = { relayKey: key, indexEvents, indexByAddress, engagement }
const topLevel = getTopLevelIndexEvents(indexEvents) const topLevel = getTopLevelIndexEvents(indexEvents)
const engaged = sortLibraryPublications(filterEngagedPublications(topLevel, indexByAddress, engagement)) const engaged = pickLibraryPublicationEntries(topLevel, indexByAddress, engagement)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] load done', { logger.info('[Library] load done', {
engaged: engaged.length, engaged: engaged.length,
topLevel: topLevel.length, topLevel: topLevel.length,
allIndexCount: indexEvents.length allIndexCount: indexEvents.length,
recentFallback: engaged.length > 0 && engaged.every((e) => e.engagementCount === 0)
}) })
} }

25
src/pages/secondary/NotePage/index.tsx

@ -32,6 +32,7 @@ import {
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNote, toNoteList } from '@/lib/link' import { toNote, toNoteList } from '@/lib/link'
import { getCachedThreadContextEvents } from '@/lib/navigation-related-events' import { getCachedThreadContextEvents } from '@/lib/navigation-related-events'
import { resolveNoteEventSync } from '@/lib/resolve-note-event-sync'
import { import {
prewarmArchivesNotePage, prewarmArchivesNotePage,
profilesFromArchivesNotePageBundle profilesFromArchivesNotePageBundle
@ -125,6 +126,26 @@ function eventPointersReferenceSameNote(a: string | undefined, b: string | undef
} }
const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => { const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => {
const threadKey = useMemo(() => {
const sync = resolveNoteEventSync(id, initialEvent)
return sync?.id ?? id?.trim() ?? 'pending'
}, [id, initialEvent])
return (
<ThreadReplyProvider threadKey={threadKey}>
<NotePageBody
ref={ref}
id={id}
index={index}
hideTitlebar={hideTitlebar}
initialEvent={initialEvent}
/>
</ThreadReplyProvider>
)
})
NotePage.displayName = 'NotePage'
const NotePageBody = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: { id?: string; index?: number; hideTitlebar?: boolean; initialEvent?: Event }, ref) => {
const { t } = useTranslation() const { t } = useTranslation()
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent) const { event, isFetching, refetch: refetchMain } = useFetchEvent(id, initialEvent)
@ -538,7 +559,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
} }
return ( return (
<ThreadReplyProvider threadKey={finalEvent.id}>
<ThreadProfileBatchProvider seedEvents={[finalEvent]} seedProfiles={archivesSeedProfiles}> <ThreadProfileBatchProvider seedEvents={[finalEvent]} seedProfiles={archivesSeedProfiles}>
<SecondaryPageLayout <SecondaryPageLayout
ref={ref} ref={ref}
@ -610,10 +630,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
</div> </div>
</SecondaryPageLayout> </SecondaryPageLayout>
</ThreadProfileBatchProvider> </ThreadProfileBatchProvider>
</ThreadReplyProvider>
) )
}) })
NotePage.displayName = 'NotePage' NotePageBody.displayName = 'NotePageBody'
export default NotePage export default NotePage
function ThreadContextSkeleton() { function ThreadContextSkeleton() {

7
src/providers/ReplyProvider.tsx

@ -9,8 +9,13 @@ type TReplyContext = {
const ReplyContext = createContext<TReplyContext | undefined>(undefined) const ReplyContext = createContext<TReplyContext | undefined>(undefined)
/** Returns undefined outside provider (e.g. isolated `createRoot` embeds or HMR context splits). */
export function useReplyOptional(): TReplyContext | undefined {
return useContext(ReplyContext)
}
export const useReply = () => { export const useReply = () => {
const context = useContext(ReplyContext) const context = useReplyOptional()
if (!context) { if (!context) {
throw new Error('useReply must be used within a ReplyProvider') throw new Error('useReply must be used within a ReplyProvider')
} }

207
src/services/indexed-db.service.ts

@ -142,6 +142,8 @@ export const StoreNames = {
TIMELINE_STATE: 'timelineState', TIMELINE_STATE: 'timelineState',
/** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */ /** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */
PIPER_TTS_CACHE: 'piperTtsCache', PIPER_TTS_CACHE: 'piperTtsCache',
/** Library kind-30040 index LRU (rotating cache; separate byte/entry budget from EVENT_ARCHIVE). */
LIBRARY_PUBLICATION_INDEX: 'libraryPublicationIndex',
/** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */ /** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */
CALENDAR_EVENTS: 'calendarEvents', CALENDAR_EVENTS: 'calendarEvents',
/** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */ /** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */
@ -173,6 +175,7 @@ export type TCalendarRsvpCacheRow = {
const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set([ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set([
StoreNames.SETTINGS, StoreNames.SETTINGS,
StoreNames.PIPER_TTS_CACHE, StoreNames.PIPER_TTS_CACHE,
StoreNames.LIBRARY_PUBLICATION_INDEX,
StoreNames.RELAY_INFOS, StoreNames.RELAY_INFOS,
StoreNames.NIP66_DISCOVERY, StoreNames.NIP66_DISCOVERY,
StoreNames.GIF_CACHE, StoreNames.GIF_CACHE,
@ -227,7 +230,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37' const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */ /** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 39 const DB_VERSION = 40
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */ /** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000 const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@ -243,6 +246,14 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]):
return new Error(message) return new Error(message)
} }
type TLibraryPublicationIndexCacheRow = {
key: string
value: Event
addedAt: number
lastAccessAt: number
approxBytes: number
}
/** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */ /** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */
function ensureMissingObjectStores(db: IDBDatabase): void { function ensureMissingObjectStores(db: IDBDatabase): void {
for (const storeName of Object.values(StoreNames)) { for (const storeName of Object.values(StoreNames)) {
@ -269,6 +280,9 @@ function ensureMissingObjectStores(db: IDBDatabase): void {
const pa = db.createObjectStore(storeName, { keyPath: 'key' }) const pa = db.createObjectStore(storeName, { keyPath: 'key' })
pa.createIndex('authorPubkey', 'authorPubkey', { unique: false }) pa.createIndex('authorPubkey', 'authorPubkey', { unique: false })
pa.createIndex('targetEventId', 'targetEventId', { unique: false }) pa.createIndex('targetEventId', 'targetEventId', { unique: false })
} else if (storeName === StoreNames.LIBRARY_PUBLICATION_INDEX) {
const lib = db.createObjectStore(storeName, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
} else { } else {
db.createObjectStore(storeName, { keyPath: 'key' }) db.createObjectStore(storeName, { keyPath: 'key' })
} }
@ -509,6 +523,12 @@ class IndexedDbService {
pa.createIndex('targetEventId', 'targetEventId', { unique: false }) pa.createIndex('targetEventId', 'targetEventId', { unique: false })
} }
} }
if (event.oldVersion < 40) {
if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
}
}
ensureMissingObjectStores(db) ensureMissingObjectStores(db)
} }
} }
@ -3663,6 +3683,191 @@ class IndexedDbService {
} }
} }
private approxLibraryPublicationIndexBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
return 2048
}
}
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
const out: Event[] = []
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
if (row?.value?.kind === ExtendedKind.PUBLICATION) out.push(row.value)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
return { count: 0, bytes: 0 }
}
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
let count = 0
let bytes = 0
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve({ count, bytes })
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
count += 1
bytes += row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async mergeLibraryPublicationIndexCacheEvents(
events: Event[],
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX) || events.length === 0) {
return
}
const now = Date.now()
const storeName = StoreNames.LIBRARY_PUBLICATION_INDEX
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(storeName, 'readwrite')
const store = tx.objectStore(storeName)
let pending = events.length
if (pending === 0) {
tx.commit()
resolve()
return
}
const finishOne = () => {
pending -= 1
if (pending === 0) {
tx.commit()
resolve()
}
}
for (const ev of events) {
const get = store.get(ev.id)
get.onsuccess = () => {
const prev = get.result as TLibraryPublicationIndexCacheRow | undefined
const row: TLibraryPublicationIndexCacheRow = {
key: ev.id,
value: ev,
addedAt: prev?.addedAt ?? now,
lastAccessAt: now,
approxBytes: this.approxLibraryPublicationIndexBytes(ev)
}
const put = store.put(row)
put.onsuccess = () => finishOne()
put.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
}
get.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
}
})
await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes)
}
async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
const rows: Array<{ key: string; lastAccessAt: number; bytes: number }> = []
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve()
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
rows.push({
key: cursor.key as string,
lastAccessAt: row.lastAccessAt ?? row.addedAt,
bytes: row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
})
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
rows.sort((a, b) => a.lastAccessAt - b.lastAccessAt)
const toDelete = new Set<string>()
let totalBytes = rows.reduce((s, r) => s + r.bytes, 0)
let totalCount = rows.length
while (totalCount > maxEntries || totalBytes > maxBytes) {
const victim = rows.shift()
if (!victim) break
toDelete.add(victim.key)
totalBytes -= victim.bytes
totalCount -= 1
}
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
}
async clearLibraryPublicationIndexCacheStore(): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readwrite')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).clear()
req.onsuccess = () => {
tx.commit()
resolve()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
/** /**
* Get all tombstoned keys * Get all tombstoned keys
*/ */

Loading…
Cancel
Save