Compare commits

..

No commits in common. '15905310fc1093a50c7f0dc060562481ce97172f' and '2cadd4b2a934ca17ba0db659f6018750ccee48c6' have entirely different histories.

  1. 2
      nip66-cron/index.mjs
  2. 4
      package-lock.json
  3. 2
      package.json
  4. 4
      src/components/AccountManager/index.tsx
  5. 40
      src/components/CacheBrowser/CacheBrowserDialog.tsx
  6. 5
      src/components/Embedded/EmbeddedNote.tsx
  7. 75
      src/components/InBrowserCacheSetting/index.tsx
  8. 290
      src/components/Library/LibrarySearchBar.tsx
  9. 25
      src/components/VersionUpdateBanner/index.tsx
  10. 2
      src/constants.ts
  11. 93
      src/hooks/useLibraryPublications.ts
  12. 34
      src/hooks/useNip07ExtensionAvailable.ts
  13. 9
      src/i18n/locales/de.ts
  14. 13
      src/i18n/locales/en.ts
  15. 98
      src/lib/app-cache-maintenance.ts
  16. 39
      src/lib/index-relay-http.ts
  17. 33
      src/lib/library-index-idb-cache.ts
  18. 102
      src/lib/library-publication-index.test.ts
  19. 624
      src/lib/library-publication-index.ts
  20. 7
      src/lib/metadata-policy-curated-relays.test.ts
  21. 1
      src/lib/new-user-template-broadcast.ts
  22. 3
      src/lib/new-user-template.ts
  23. 11
      src/pages/primary/LibraryPage/index.tsx
  24. 14
      src/providers/NostrProvider/nip-07.signer.ts
  25. 712
      src/services/indexed-db.service.ts
  26. 2
      src/services/nip89.service.ts

2
nip66-cron/index.mjs

@ -51,6 +51,7 @@ const DEFAULT_RELAYS_TO_MONITOR = [
'wss://nostr.wine', 'wss://nostr.wine',
'wss://nostr21.com', 'wss://nostr21.com',
'wss://aggr.nostr.land', 'wss://aggr.nostr.land',
'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.gifbuddy.lol', 'wss://relay.gifbuddy.lol',
@ -74,6 +75,7 @@ const DEFAULT_RELAYS_TO_MONITOR = [
/** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */ /** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */
const DEFAULT_PUBLISH_RELAYS = [ const DEFAULT_PUBLISH_RELAYS = [
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.damus.io',
'wss://relay.nostr.watch', 'wss://relay.nostr.watch',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relaypag.es', 'wss://relaypag.es',

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.8", "version": "23.21.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.21.8", "version": "23.21.3",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.21.8", "version": "23.21.4",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

4
src/components/AccountManager/index.tsx

@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { useNip07ExtensionAvailable } from '@/hooks/useNip07ExtensionAvailable'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { generateSecretKey } from 'nostr-tools' import { generateSecretKey } from 'nostr-tools'
import { nsecEncode } from 'nostr-tools/nip19' import { nsecEncode } from 'nostr-tools/nip19'
@ -45,7 +44,6 @@ function AccountManagerNav({
const { t } = useTranslation() const { t } = useTranslation()
const { nip07Login, nsecLogin, accounts, isNip07LoginInFlight, requestAccountNetworkHydrate } = const { nip07Login, nsecLogin, accounts, isNip07LoginInFlight, requestAccountNetworkHydrate } =
useNostr() useNostr()
const nip07ExtensionAvailable = useNip07ExtensionAvailable()
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [signingUp, setSigningUp] = useState(false) const [signingUp, setSigningUp] = useState(false)
const [extensionLoginPending, setExtensionLoginPending] = useState(false) const [extensionLoginPending, setExtensionLoginPending] = useState(false)
@ -86,7 +84,7 @@ function AccountManagerNav({
{t('Add an Account')} {t('Add an Account')}
</div> </div>
<div className="space-y-2 mt-4"> <div className="space-y-2 mt-4">
{nip07ExtensionAvailable && ( {!!window.nostr && (
<Button <Button
onClick={() => void handleExtensionLogin()} onClick={() => void handleExtensionLogin()}
disabled={extensionLoginPending || isNip07LoginInFlight} disabled={extensionLoginPending || isNip07LoginInFlight}

40
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -6,6 +6,7 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy,
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service' import indexedDb, { isLikelyCachedNostrEvent, StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service'
import { clearAllLibraryIndexCaches } from '@/lib/library-publication-index'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
@ -41,7 +42,6 @@ export default function CacheBrowserDialog({
const [globalSearchLoading, setGlobalSearchLoading] = useState(false) const [globalSearchLoading, setGlobalSearchLoading] = useState(false)
const [globalSearchTruncated, setGlobalSearchTruncated] = useState(false) const [globalSearchTruncated, setGlobalSearchTruncated] = useState(false)
const [publicationListFull, setPublicationListFull] = useState(false) const [publicationListFull, setPublicationListFull] = useState(false)
const [publicationStoreTotalRows, setPublicationStoreTotalRows] = useState<number | null>(null)
const globalSearchRequestId = useRef(0) const globalSearchRequestId = useRef(0)
const loadCacheInfo = async () => { const loadCacheInfo = async () => {
@ -63,7 +63,6 @@ export default function CacheBrowserDialog({
setGlobalSearchHits([]) setGlobalSearchHits([])
setGlobalSearchTruncated(false) setGlobalSearchTruncated(false)
setPublicationListFull(false) setPublicationListFull(false)
setPublicationStoreTotalRows(null)
void loadCacheInfo() void loadCacheInfo()
}, [open]) }, [open])
@ -102,20 +101,13 @@ export default function CacheBrowserDialog({
setSelectedStore(storeName) setSelectedStore(storeName)
setSearchQuery('') setSearchQuery('')
setPublicationListFull(false) setPublicationListFull(false)
setPublicationStoreTotalRows(null)
setLoadingItems(true) setLoadingItems(true)
try { try {
if (storeName === StoreNames.PUBLICATION_EVENTS) { const items =
const [items, allRows] = await Promise.all([ storeName === 'publicationEvents'
indexedDb.getPublicationStoreItems(storeName), ? await indexedDb.getPublicationStoreItems(storeName)
indexedDb.getStoreItems(storeName) : await indexedDb.getStoreItems(storeName)
])
setPublicationStoreTotalRows(allRows.length)
setStoreItems(items)
} else {
const items = await indexedDb.getStoreItems(storeName)
setStoreItems(items) setStoreItems(items)
}
} catch (error) { } catch (error) {
logger.error('Failed to load store items', { error }) logger.error('Failed to load store items', { error })
toast.error(t('Failed to load store items')) toast.error(t('Failed to load store items'))
@ -186,7 +178,11 @@ export default function CacheBrowserDialog({
if (!selectedStore) return if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try { try {
if (selectedStore === StoreNames.LIBRARY_PUBLICATION_INDEX) {
await clearAllLibraryIndexCaches()
} else {
await indexedDb.clearStore(selectedStore) await indexedDb.clearStore(selectedStore)
}
setStoreItems([]) setStoreItems([])
void loadCacheInfo() void loadCacheInfo()
toast.success(t('All items deleted successfully')) toast.success(t('All items deleted successfully'))
@ -203,14 +199,12 @@ export default function CacheBrowserDialog({
setLoadingItems(true) setLoadingItems(true)
try { try {
const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore) const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore)
const items = await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
setSearchQuery('') setSearchQuery('')
void loadCacheInfo() void loadCacheInfo()
const items = const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore)
selectedStore === StoreNames.PUBLICATION_EVENTS const actualCount = itemsAfterCleanup.length
? await indexedDb.getPublicationStoreItems(selectedStore)
: await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
const actualCount = items.length
if (actualCount !== result.kept) { if (actualCount !== result.kept) {
toast.success( toast.success(
t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', { t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', {
@ -224,8 +218,7 @@ export default function CacheBrowserDialog({
} }
} catch (error) { } catch (error) {
logger.error('Failed to cleanup duplicates', { error }) logger.error('Failed to cleanup duplicates', { error })
const message = error instanceof Error ? error.message : String(error) if (error instanceof Error && error.message === 'Not a replaceable event store') {
if (message === 'Not a replaceable event store') {
toast.error(t('This store does not contain replaceable events')) toast.error(t('This store does not contain replaceable events'))
} else { } else {
toast.error(t('Failed to cleanup duplicates')) toast.error(t('Failed to cleanup duplicates'))
@ -472,11 +465,6 @@ export default function CacheBrowserDialog({
<div className="mb-2 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-2 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-xs text-muted-foreground"> <div className="min-w-0 text-xs text-muted-foreground">
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} {filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')}
{selectedStore === StoreNames.PUBLICATION_EVENTS &&
publicationStoreTotalRows != null &&
publicationStoreTotalRows > storeItems.length
? ` (${publicationStoreTotalRows} rows incl. nested sections)`
: ''}
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`} {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
</div> </div>
<div className="flex min-w-0 flex-shrink-0 flex-wrap gap-2 sm:justify-end"> <div className="flex min-w-0 flex-shrink-0 flex-wrap gap-2 sm:justify-end">

5
src/components/Embedded/EmbeddedNote.tsx

@ -552,8 +552,9 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
const x = u.toLowerCase() const x = u.toLowerCase()
if (x.includes('nos.lol')) return 0 if (x.includes('nos.lol')) return 0
if (x.includes('nostr.land')) return 1 if (x.includes('nostr.land')) return 1
if (x.includes('relay.primal.net')) return 2 if (x.includes('relay.damus.io')) return 2
if (x.includes('nostr.wine')) return 3 if (x.includes('relay.primal.net')) return 3
if (x.includes('nostr.wine')) return 4
return 30 return 30
} }
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b)) return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b))

75
src/components/InBrowserCacheSetting/index.tsx

@ -1,8 +1,4 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import {
clearAppServiceWorkerAndCaches,
refreshAppBrowserCache
} from '@/lib/app-cache-maintenance'
import { clearConsoleLogBuffer } from '@/lib/console-log-buffer' import { clearConsoleLogBuffer } from '@/lib/console-log-buffer'
import { useConsoleLogBuffer } from '@/hooks/useConsoleLogBuffer' import { useConsoleLogBuffer } from '@/hooks/useConsoleLogBuffer'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -19,6 +15,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner' import { toast } from 'sonner'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useCacheBrowser } from '../../contexts/cache-browser-context' import { useCacheBrowser } from '../../contexts/cache-browser-context'
export default function InBrowserCacheSetting() { export default function InBrowserCacheSetting() {
@ -96,11 +93,11 @@ export default function InBrowserCacheSetting() {
const handleRefreshCache = async () => { const handleRefreshCache = async () => {
try { try {
setCacheRefreshBusy(true) setCacheRefreshBusy(true)
await refreshAppBrowserCache({ await indexedDb.forceDatabaseUpgrade()
pubkey, if (pubkey) {
relayList, await requestAccountNetworkHydrate()
requestAccountNetworkHydrate await syncUserDeletionTombstones(pubkey, relayList)
}) }
toast.success(t('Cache refreshed successfully')) toast.success(t('Cache refreshed successfully'))
} catch (error) { } catch (error) {
logger.error('Failed to refresh cache', { error }) logger.error('Failed to refresh cache', { error })
@ -116,7 +113,65 @@ export default function InBrowserCacheSetting() {
} }
try { try {
const { unregisteredCount, cacheClearedCount } = await clearAppServiceWorkerAndCaches() const currentOrigin = window.location.origin
let unregisteredCount = 0
let cacheClearedCount = 0
if (window.isSecureContext && 'serviceWorker' in navigator) {
let registrations: readonly ServiceWorkerRegistration[] = []
try {
registrations = await navigator.serviceWorker.getRegistrations()
} catch (error) {
logger.warn('Failed to get service worker registrations', { error })
}
if (registrations.length > 0) {
const unregisterPromises = registrations.map(async (registration) => {
try {
const scope = registration.scope
if (scope.startsWith(currentOrigin)) {
const result = await registration.unregister()
if (result) unregisteredCount++
return result
}
return false
} catch (error) {
logger.warn('Failed to unregister a service worker', { error })
return false
}
})
await Promise.all(unregisterPromises)
}
}
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
const appCaches = cacheNames.filter(name => {
if (appCacheNames.includes(name)) return true
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true
if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) return true
return false
})
await Promise.all(appCaches.map(name => {
cacheClearedCount++
return caches.delete(name).catch(error => {
logger.warn(`Failed to delete cache: ${name}`, { error })
cacheClearedCount--
})
}))
} catch (error) {
logger.warn('Failed to clear some caches', { error })
}
}
if (unregisteredCount > 0 || cacheClearedCount > 0) { if (unregisteredCount > 0 || cacheClearedCount > 0) {
const message = unregisteredCount > 0 && cacheClearedCount > 0 const message = unregisteredCount > 0 && cacheClearedCount > 0

290
src/components/Library/LibrarySearchBar.tsx

@ -1,32 +1,13 @@
import SearchInput from '@/components/SearchInput'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { normalizeToDTag } from '@/lib/search-parser' import { Loader2, Search, Wifi } from 'lucide-react'
import type { LibraryPublicationRelaySearchAxis } from '@/lib/library-publication-index'
import { cn } from '@/lib/utils'
import { FileText, Loader2, Search, User, Wifi } from 'lucide-react'
import {
HTMLAttributes,
useCallback,
useMemo,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
type LibrarySearchOption = {
axis: LibraryPublicationRelaySearchAxis | null
search: string
input?: string
}
export default function LibrarySearchBar({ export default function LibrarySearchBar({
searchQuery, searchQuery,
onSearchQueryChange, onSearchQueryChange,
committedSearch,
searchAxis,
onCommitSearch,
showOnlyMine, showOnlyMine,
onShowOnlyMineChange, onShowOnlyMineChange,
mineFilterLoading, mineFilterLoading,
@ -36,9 +17,6 @@ export default function LibrarySearchBar({
}: { }: {
searchQuery: string searchQuery: string
onSearchQueryChange: (value: string) => void onSearchQueryChange: (value: string) => void
committedSearch: string
searchAxis: LibraryPublicationRelaySearchAxis | null
onCommitSearch: (query: string, axis: LibraryPublicationRelaySearchAxis | null) => void
showOnlyMine: boolean showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void onShowOnlyMineChange: (value: boolean) => void
mineFilterLoading?: boolean mineFilterLoading?: boolean
@ -47,166 +25,22 @@ export default function LibrarySearchBar({
disabled?: boolean disabled?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [searching, setSearching] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const searchInputRef = useRef<HTMLInputElement>(null)
const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading const canSearchRelays = searchQuery.trim().length > 0 && !relaySearchLoading
const selectableOptions = useMemo((): LibrarySearchOption[] => {
const search = searchQuery.trim()
if (!search) return []
const normalizedDTag = normalizeToDTag(search)
return [
{ axis: null, search },
{ axis: 'title', search },
{ axis: 'author', search },
...(normalizedDTag
? [{ axis: 'd-tag' as const, search: normalizedDTag, input: search }]
: [])
]
}, [searchQuery])
const displayList = searching && selectableOptions.length > 0
const blur = () => {
setSearching(false)
setSelectedIndex(-1)
searchInputRef.current?.blur()
}
const applyOption = useCallback(
(option: LibrarySearchOption) => {
onCommitSearch(option.input ?? option.search, option.axis)
blur()
},
[onCommitSearch]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.stopPropagation()
if (selectableOptions.length <= 0) return
applyOption(selectableOptions[selectedIndex >= 0 ? selectedIndex : 0])
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
if (selectableOptions.length <= 0) return
setSelectedIndex((prev) => (prev + 1) % selectableOptions.length)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (selectableOptions.length <= 0) return
setSelectedIndex((prev) => (prev - 1 + selectableOptions.length) % selectableOptions.length)
return
}
if (e.key === 'Escape') {
blur()
}
},
[applyOption, selectableOptions, selectedIndex]
)
const list = useMemo(() => {
if (selectableOptions.length <= 0) return null
return (
<>
{selectableOptions.map((option, index) => {
if (option.axis === null) {
return (
<AllFieldsItem
key="all"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
if (option.axis === 'title') {
return (
<TitleItem
key="title"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
if (option.axis === 'author') {
return (
<AuthorItem
key="author"
search={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
}
return (
<DTagItem
key="dtag"
dtag={option.search}
selected={selectedIndex === index}
onClick={() => applyOption(option)}
/>
)
})}
</>
)
}, [applyOption, selectableOptions, selectedIndex])
const isCommitted = committedSearch.trim().length > 0 && committedSearch.trim() === searchQuery.trim()
const scopeLabel =
isCommitted && searchAxis === 'title'
? t('Library search scope title')
: isCommitted && searchAxis === 'author'
? t('Library search scope author')
: isCommitted && searchAxis === 'd-tag'
? t('Library search scope dtag')
: null
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">
{displayList && list ? ( <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<div <Input
className="absolute top-full z-50 -translate-y-1 inset-x-0 rounded-b-lg border border-border/80 bg-surface-background pt-1 shadow-lg"
onMouseDown={(e) => e.preventDefault()}
>
<div className="h-fit">{list}</div>
</div>
) : null}
<SearchInput
ref={searchInputRef}
type="search" type="search"
value={searchQuery} value={searchQuery}
onChange={(e) => { onChange={(e) => onSearchQueryChange(e.target.value)}
setSearching(true)
setSelectedIndex(-1)
onSearchQueryChange(e.target.value)
}}
onPaste={() => setSearching(true)}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
placeholder={t('Library search placeholder')} placeholder={t('Library search placeholder')}
className={cn('bg-surface-background pl-3', displayList && 'z-50')} className="pl-9"
disabled={disabled} disabled={disabled}
aria-label={t('Library search placeholder')} aria-label={t('Library search placeholder')}
/> />
</div> </div>
{scopeLabel ? (
<p className="text-xs text-muted-foreground">{scopeLabel}</p>
) : searchQuery.trim() && !isCommitted ? (
<p className="text-xs text-muted-foreground">{t('Library search commit hint')}</p>
) : null}
{onSearchRelays ? ( {onSearchRelays ? (
<Button <Button
type="button" type="button"
@ -241,115 +75,3 @@ export default function LibrarySearchBar({
</div> </div>
) )
} }
function Item({
className,
children,
selected,
...props
}: HTMLAttributes<HTMLDivElement> & { selected?: boolean }) {
return (
<div
className={cn(
'flex gap-2 items-center px-2 py-3 hover:bg-accent rounded-md cursor-pointer',
selected ? 'bg-accent' : '',
className
)}
{...props}
>
{children}
</div>
)
}
function AllFieldsItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<Search className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown all')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function TitleItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<FileText className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown title')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function AuthorItem({
search,
onClick,
selected
}: {
search: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<User className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown author')}
</span>
</div>
<div className="font-semibold truncate">{search}</div>
</Item>
)
}
function DTagItem({
dtag,
onClick,
selected
}: {
dtag: string
onClick?: () => void
selected?: boolean
}) {
const { t } = useTranslation()
return (
<Item onClick={onClick} selected={selected}>
<div className="flex flex-col items-center gap-0.5">
<FileText className="text-muted-foreground" />
<span className="text-[10px] text-muted-foreground/70 uppercase leading-none">
{t('Library search dropdown dtag')}
</span>
</div>
<div className="font-semibold truncate">{dtag}</div>
</Item>
)
}

25
src/components/VersionUpdateBanner/index.tsx

@ -1,13 +1,11 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { refreshAppBrowserCacheAndClearServiceWorker } from '@/lib/app-cache-maintenance'
import logger from '@/lib/logger'
import { import {
getPwaApplyUpdate,
initPwaUpdate, initPwaUpdate,
probePwaWaitingWorker, probePwaWaitingWorker,
subscribePwaNeedRefresh subscribePwaNeedRefresh
} from '@/lib/pwa-update' } from '@/lib/pwa-update'
import { useNostrOptional } from '@/providers/nostr-context'
import { RefreshCw, X } from 'lucide-react' import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -23,7 +21,6 @@ function readVersionUpdateDismissed(): boolean {
export default function VersionUpdateBanner() { export default function VersionUpdateBanner() {
const { t } = useTranslation() const { t } = useTranslation()
const nostr = useNostrOptional()
const [updateAvailable, setUpdateAvailable] = useState(false) const [updateAvailable, setUpdateAvailable] = useState(false)
const [isDismissed, setIsDismissed] = useState(readVersionUpdateDismissed) const [isDismissed, setIsDismissed] = useState(readVersionUpdateDismissed)
const [isUpdating, setIsUpdating] = useState(false) const [isUpdating, setIsUpdating] = useState(false)
@ -68,18 +65,16 @@ export default function VersionUpdateBanner() {
setIsDismissed(true) setIsDismissed(true)
setIsUpdating(true) setIsUpdating(true)
void (async () => { const reload = () => {
try {
await refreshAppBrowserCacheAndClearServiceWorker({
pubkey: nostr?.pubkey,
relayList: nostr?.relayList,
requestAccountNetworkHydrate: nostr?.requestAccountNetworkHydrate
})
} catch (error) {
logger.warn('[VersionUpdateBanner] Pre-update cache refresh failed', { error })
}
window.location.reload() window.location.reload()
})() }
const apply = getPwaApplyUpdate()
if (apply) {
void apply().catch(reload)
return
}
reload()
} }
const handleDismiss = () => { const handleDismiss = () => {

2
src/constants.ts

@ -536,6 +536,7 @@ export const FAST_READ_RELAY_URLS = [
// Optimized relay list for write operations (no aggregator since it's read-only) // Optimized relay list for write operations (no aggregator since it's read-only)
export const FAST_WRITE_RELAY_URLS = [ export const FAST_WRITE_RELAY_URLS = [
'wss://relay.damus.io',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://nos.lol' 'wss://nos.lol'
@ -584,6 +585,7 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550
export const PROFILE_RELAY_URLS = [ export const PROFILE_RELAY_URLS = [
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://indexer.coracle.social/', 'wss://indexer.coracle.social/',
'wss://purplepag.es' 'wss://purplepag.es'

93
src/hooks/useLibraryPublications.ts

@ -10,7 +10,6 @@ import {
searchLibraryPublications, searchLibraryPublications,
searchLibraryPublicationsOnRelays, searchLibraryPublicationsOnRelays,
type LibraryPublicationEntry, type LibraryPublicationEntry,
type LibraryPublicationRelaySearchAxis,
type PublicationEngagementMaps, type PublicationEngagementMaps,
type LibraryMineFilterOpts type LibraryMineFilterOpts
} from '@/lib/library-publication-index' } from '@/lib/library-publication-index'
@ -23,10 +22,8 @@ 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'
import { useTranslation } from 'react-i18next'
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
const RELAY_SEARCH_TIMEOUT_MS = 30_000
const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
labelAddresses: new Set(), labelAddresses: new Set(),
@ -54,7 +51,6 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = {
const EMPTY_BOOKLIST_TARGETS = { addresses: new Set<string>(), eventIds: new Set<string>() } const EMPTY_BOOKLIST_TARGETS = { addresses: new Set<string>(), eventIds: new Set<string>() }
export function useLibraryPublications(isActive: boolean) { export function useLibraryPublications(isActive: boolean) {
const { t } = useTranslation()
const { pubkey, bookmarkListEvent } = useNostr() const { pubkey, bookmarkListEvent } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [entries, setEntries] = useState<LibraryPublicationEntry[]>([]) const [entries, setEntries] = useState<LibraryPublicationEntry[]>([])
@ -63,11 +59,10 @@ export function useLibraryPublications(isActive: boolean) {
const [indexEvents, setIndexEvents] = useState<Event[]>([]) const [indexEvents, setIndexEvents] = useState<Event[]>([])
const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT) const [engagement, setEngagement] = useState<PublicationEngagementMaps>(EMPTY_ENGAGEMENT)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [committedSearch, setCommittedSearch] = useState('')
const [searchAxis, setSearchAxis] = useState<LibraryPublicationRelaySearchAxis | null>(null)
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('')
const [showOnlyMine, setShowOnlyMine] = useState(false) const [showOnlyMine, setShowOnlyMine] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [engagementLoading, setEngagementLoading] = useState(false)
const [searchLoading, setSearchLoading] = useState(false) const [searchLoading, setSearchLoading] = useState(false)
const [relaySearchLoading, setRelaySearchLoading] = useState(false) const [relaySearchLoading, setRelaySearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<LibraryPublicationEntry[] | null>(null) const [searchResults, setSearchResults] = useState<LibraryPublicationEntry[] | null>(null)
@ -135,31 +130,13 @@ export function useLibraryPublications(isActive: boolean) {
}, [pubkey]) }, [pubkey])
useEffect(() => { useEffect(() => {
const t = window.setTimeout(() => setDebouncedSearch(committedSearch), SEARCH_DEBOUNCE_MS) const t = window.setTimeout(() => setDebouncedSearch(searchQuery), SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(t) return () => window.clearTimeout(t)
}, [committedSearch])
useEffect(() => {
if (!searchQuery.trim()) {
setCommittedSearch('')
setSearchAxis(null)
}
}, [searchQuery]) }, [searchQuery])
const commitSearch = useCallback(
(query: string, axis: LibraryPublicationRelaySearchAxis | null) => {
const trimmed = query.trim()
if (!trimmed) return
setSearchQuery(trimmed)
setCommittedSearch(trimmed)
setSearchAxis(axis)
},
[]
)
useEffect(() => { useEffect(() => {
setFeedPageIndex(0) setFeedPageIndex(0)
}, [debouncedSearch, showOnlyMine, searchAxis]) }, [debouncedSearch, showOnlyMine])
const applyDefaultFeedSlice = useCallback( const applyDefaultFeedSlice = useCallback(
(indexEventsSlice: Event[], engagementMaps: PublicationEngagementMaps, pageIndex: number) => { (indexEventsSlice: Event[], engagementMaps: PublicationEngagementMaps, pageIndex: number) => {
@ -194,6 +171,7 @@ export function useLibraryPublications(isActive: boolean) {
let cancelled = false let cancelled = false
indexesReadyRef.current = false indexesReadyRef.current = false
setLoading(true) setLoading(true)
setEngagementLoading(false)
setError(null) setError(null)
setFeedPageIndex(0) setFeedPageIndex(0)
const forceRefresh = forceRefreshNextLoadRef.current const forceRefresh = forceRefreshNextLoadRef.current
@ -221,19 +199,12 @@ export function useLibraryPublications(isActive: boolean) {
} }
applyIndexesSnapshot(snapshot, EMPTY_ENGAGEMENT, 0) applyIndexesSnapshot(snapshot, EMPTY_ENGAGEMENT, 0)
setLoading(false) setLoading(false)
setEngagementLoading(true)
} }
}) })
if (cancelled) return if (cancelled) return
applyIndexesSnapshot(result, result.engagement, 0)
setEngagement(result.engagement) setEngagement(result.engagement)
applyIndexesSnapshot(
{
indexEvents: result.indexEvents,
allIndexCount: result.allIndexCount,
topLevelCount: result.topLevelCount
},
result.engagement,
0
)
} catch (e) { } catch (e) {
if (cancelled) return if (cancelled) return
if (indexesReadyRef.current) { if (indexesReadyRef.current) {
@ -252,6 +223,7 @@ export function useLibraryPublications(isActive: boolean) {
} finally { } finally {
if (!cancelled) { if (!cancelled) {
setLoading(false) setLoading(false)
setEngagementLoading(false)
} }
} }
})() })()
@ -301,7 +273,7 @@ export function useLibraryPublications(isActive: boolean) {
return return
} }
const cached = peekLibrarySearchResults(q, { indexEvents, engagement }, searchAxis) const cached = peekLibrarySearchResults(q, { indexEvents, engagement })
if (cached) { if (cached) {
setSearchResults(cached) setSearchResults(cached)
setSearchLoading(false) setSearchLoading(false)
@ -310,7 +282,7 @@ export function useLibraryPublications(isActive: boolean) {
let cancelled = false let cancelled = false
setSearchLoading(true) setSearchLoading(true)
void searchLibraryPublications(q, { indexEvents, engagement }, searchAxis).then((results) => { void searchLibraryPublications(q, { indexEvents, engagement }).then((results) => {
if (cancelled) return if (cancelled) return
setSearchResults(results) setSearchResults(results)
setSearchLoading(false) setSearchLoading(false)
@ -319,39 +291,20 @@ export function useLibraryPublications(isActive: boolean) {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [debouncedSearch, indexEvents, engagement, searchAxis]) }, [debouncedSearch, indexEvents, engagement])
const searchOnRelays = useCallback(async () => { const searchOnRelays = useCallback(async () => {
const q = searchQuery.trim() const q = searchQuery.trim()
if (!q) return if (!q) return
setCommittedSearch(q)
setRelaySearchLoading(true) setRelaySearchLoading(true)
setError(null) setError(null)
try { try {
const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? [])
let timeoutId: number | undefined const { events, mergedIndexEvents, fromCache } = await searchLibraryPublicationsOnRelays(
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(
() => reject(new Error('Relay search timed out')),
RELAY_SEARCH_TIMEOUT_MS
)
})
let events: Event[]
let mergedIndexEvents: Event[]
let fromCache: boolean
try {
;({ events, mergedIndexEvents, fromCache } = await Promise.race([
searchLibraryPublicationsOnRelays(
q, q,
relays, relays,
{ indexEvents, engagement }, { indexEvents, engagement }
{ axis: searchAxis } )
),
timeoutPromise
]))
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
}
setIndexEvents(mergedIndexEvents) setIndexEvents(mergedIndexEvents)
setAllIndexCount(mergedIndexEvents.length) setAllIndexCount(mergedIndexEvents.length)
setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length) setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length)
@ -372,26 +325,18 @@ export function useLibraryPublications(isActive: boolean) {
const entries = await searchLibraryPublications(q, { const entries = await searchLibraryPublications(q, {
indexEvents: mergedIndexEvents, indexEvents: mergedIndexEvents,
engagement: nextEngagement engagement: nextEngagement
}, searchAxis) })
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'
const local = await searchLibraryPublications(q, { indexEvents, engagement }, searchAxis) setError(message)
if (local.length > 0) {
setSearchResults(local)
setError(null)
} else {
setError(
message === 'Relay search timed out' ? t('Library relay search timed out') : message
)
}
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] relay search failed', { message, localFallback: local.length }) logger.warn('[Library] relay search failed', { message })
} }
} finally { } finally {
setRelaySearchLoading(false) setRelaySearchLoading(false)
} }
}, [searchQuery, searchAxis, pubkey, indexEvents, engagement, blockedRelays, t]) }, [searchQuery, pubkey, indexEvents, engagement, blockedRelays])
const mineFilterOpts = useMemo( const mineFilterOpts = useMemo(
() => ({ () => ({
@ -490,14 +435,12 @@ export function useLibraryPublications(isActive: boolean) {
entries: filteredEntries, entries: filteredEntries,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
committedSearch,
searchAxis,
commitSearch,
showOnlyMine, showOnlyMine,
setShowOnlyMine, setShowOnlyMine,
mineFilterLoading: mineFilterLoading:
mineFilterComputing || (showOnlyMine && booklistTargetsLoading), mineFilterComputing || (showOnlyMine && booklistTargetsLoading),
loading, loading,
engagementLoading,
searchLoading, searchLoading,
relaySearchLoading, relaySearchLoading,
error, error,

34
src/hooks/useNip07ExtensionAvailable.ts

@ -1,34 +0,0 @@
import {
NIP07_INJECT_CHECK_INTERVAL_MS,
NIP07_INJECT_MAX_ATTEMPTS
} from '@/providers/NostrProvider/nip-07.signer'
import { useEffect, useState } from 'react'
/**
* True once a NIP-07 browser extension has injected `window.nostr`.
* Polls briefly mobile browsers often expose the API after first paint.
*/
export function useNip07ExtensionAvailable(): boolean {
const [available, setAvailable] = useState(() => !!window.nostr)
useEffect(() => {
if (available) return
let attempt = 0
const id = window.setInterval(() => {
if (window.nostr) {
setAvailable(true)
window.clearInterval(id)
return
}
attempt += 1
if (attempt >= NIP07_INJECT_MAX_ATTEMPTS) {
window.clearInterval(id)
}
}, NIP07_INJECT_CHECK_INTERVAL_MS)
return () => window.clearInterval(id)
}, [available])
return available
}

9
src/i18n/locales/de.ts

@ -1650,15 +1650,6 @@ export default {
Library: 'Bibliothek', Library: 'Bibliothek',
'Library page title': 'Bibliothek', 'Library page title': 'Bibliothek',
'Library search placeholder': 'Publikationen nach Titel, Autor, Quelle, Tag oder Abschnitt suchen…', 'Library search placeholder': 'Publikationen nach Titel, Autor, Quelle, Tag oder Abschnitt suchen…',
'Library search dropdown all': 'ALLE FELDER',
'Library search dropdown title': 'TITEL',
'Library search dropdown author': 'AUTOR',
'Library search dropdown dtag': 'D-TAG',
'Library search scope title': 'Suche nach Titel',
'Library search scope author': 'Suche nach Autor',
'Library search scope dtag': 'Suche nach d-Tag',
'Library search commit hint': 'Enter drücken oder einen Suchtyp auswählen',
'Library relay search timed out': 'Relay-Suche abgelaufen. Treffer aus dem lokalen Bibliotheks-Cache werden angezeigt, sofern vorhanden.',
'Library show only my publications': 'Meine Publikationen', 'Library show only my publications': 'Meine Publikationen',
'Library empty': 'Noch keine Publikationen auf deinen Relays gefunden.', '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.',

13
src/i18n/locales/en.ts

@ -1673,15 +1673,6 @@ export default {
Library: 'Library', Library: 'Library',
'Library page title': 'Library', 'Library page title': 'Library',
'Library search placeholder': 'Search publications by title, author, source, tag, or section…', 'Library search placeholder': 'Search publications by title, author, source, tag, or section…',
'Library search dropdown all': 'ALL FIELDS',
'Library search dropdown title': 'TITLE',
'Library search dropdown author': 'AUTHOR',
'Library search dropdown dtag': 'D-TAG',
'Library search scope title': 'Searching by title',
'Library search scope author': 'Searching by author',
'Library search scope dtag': 'Searching by d-tag',
'Library search commit hint': 'Press Enter or choose a search type below',
'Library relay search timed out': 'Relay search timed out. Showing matches from your local library cache when available.',
'Library show only my publications': 'My publications', 'Library show only my publications': 'My publications',
'Library empty': 'No 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.',
@ -1717,7 +1708,7 @@ export default {
'Read this book': 'Read this book', 'Read this book': 'Read this book',
'libraryIndexCache.sectionTitle': 'Library publication index', 'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb': 'libraryIndexCache.sectionBlurb':
'Kind-30040 catalog rows in your publication cache (same store as opened books). Clearing removes relay-discovered titles only—publications you have opened stay cached with their sections.', 'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',
'libraryIndexCache.defaultsMobile': 'libraryIndexCache.defaultsMobile':
'Default on mobile web: up to {{entries}} indexes, ~{{mb}} MB.', 'Default on mobile web: up to {{entries}} indexes, ~{{mb}} MB.',
'libraryIndexCache.defaultsElectron': 'libraryIndexCache.defaultsElectron':
@ -1729,7 +1720,7 @@ export default {
'libraryIndexCache.clear': 'Clear library index cache', 'libraryIndexCache.clear': 'Clear library index cache',
'libraryIndexCache.clearing': 'Clearing…', 'libraryIndexCache.clearing': 'Clearing…',
'libraryIndexCache.clearConfirm': 'libraryIndexCache.clearConfirm':
'Clear relay-discovered library catalog entries? Opened publications and their sections stay in the publication cache.', 'Clear the Library index cache? The Library tab will reload indexes from relays on next visit. Opened publications stay in your publication reading cache.',
'libraryIndexCache.clearedToast': 'Library index cache cleared.', 'libraryIndexCache.clearedToast': 'Library index cache cleared.',
'libraryIndexCache.clearFailed': 'Failed to clear library index cache.', 'libraryIndexCache.clearFailed': 'Failed to clear library index cache.',
'Search page clear': 'Clear', 'Search page clear': 'Clear',

98
src/lib/app-cache-maintenance.ts

@ -1,98 +0,0 @@
import logger from '@/lib/logger'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import indexedDb from '@/services/indexed-db.service'
import type { TRelayList } from '@/types'
const APP_CACHE_NAMES = ['nostr-images', 'satellite-images', 'external-images'] as const
function isAppRelatedCacheName(name: string, origin: string): boolean {
if ((APP_CACHE_NAMES as readonly string[]).includes(name)) return true
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true
const host = origin.replace(/https?:\/\//, '').split('/')[0]
return name.includes(host)
}
export type ClearAppServiceWorkerResult = {
unregisteredCount: number
cacheClearedCount: number
}
export async function clearAppPrecacheCaches(): Promise<number> {
if (typeof window === 'undefined' || !('caches' in window)) return 0
let cleared = 0
try {
const cacheNames = await caches.keys()
const origin = window.location.origin
const appCaches = cacheNames.filter((name) => isAppRelatedCacheName(name, origin))
await Promise.all(
appCaches.map((name) =>
caches
.delete(name)
.then((ok) => {
if (ok) cleared++
})
.catch((error) => {
logger.warn('[app-cache] Failed to delete cache', { name, error })
})
)
)
} catch (error) {
logger.warn('[app-cache] Failed to clear precache caches', { error })
}
return cleared
}
export async function unregisterAppServiceWorkers(): Promise<number> {
if (typeof window === 'undefined' || !window.isSecureContext || !('serviceWorker' in navigator)) {
return 0
}
let count = 0
try {
const registrations = await navigator.serviceWorker.getRegistrations()
const origin = window.location.origin
await Promise.all(
registrations.map(async (registration) => {
if (!registration.scope.startsWith(origin)) return
try {
if (await registration.unregister()) count++
} catch (error) {
logger.warn('[app-cache] Failed to unregister service worker', { error })
}
})
)
} catch (error) {
logger.warn('[app-cache] Failed to get service worker registrations', { error })
}
return count
}
export async function clearAppServiceWorkerAndCaches(): Promise<ClearAppServiceWorkerResult> {
const cacheClearedCount = await clearAppPrecacheCaches()
const unregisteredCount = await unregisterAppServiceWorkers()
return { unregisteredCount, cacheClearedCount }
}
export type RefreshAppBrowserCacheOptions = {
pubkey?: string | null
relayList?: TRelayList | null
requestAccountNetworkHydrate?: () => Promise<void>
}
export async function refreshAppBrowserCache(options?: RefreshAppBrowserCacheOptions): Promise<void> {
await indexedDb.forceDatabaseUpgrade()
const pubkey = options?.pubkey?.trim()
if (pubkey && options?.requestAccountNetworkHydrate) {
await options.requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, options.relayList ?? null)
}
}
/** IndexedDB refresh + service worker unregister + Cache API clear (settings / update banner). */
export async function refreshAppBrowserCacheAndClearServiceWorker(
options?: RefreshAppBrowserCacheOptions
): Promise<ClearAppServiceWorkerResult> {
await refreshAppBrowserCache(options)
return clearAppServiceWorkerAndCaches()
}

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

@ -30,10 +30,6 @@ function indexRelayPublishUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events` return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/events`
} }
function indexRelayPublicationMetadataSearchUrl(baseUrl: string): string {
return `${trimSlash(normalizeHttpRelayUrl(baseUrl) || baseUrl)}/api/publications/search`
}
/** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */ /** Map a Nostr filter to gc_index_relay POST body (requires `limit` 1–100; strips unsupported keys). */
function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> { function nostrFilterToIndexRelayBody(f: Filter): Record<string, unknown> {
const body: Record<string, unknown> = {} const body: Record<string, unknown> = {}
@ -422,37 +418,19 @@ export async function queryIndexRelayForLibrary(
} }
} }
/** Kind-30040 filter query via POST /api/events/filter (NIP-01 only — no NIP-50 `search`). */ /** Kind-30040 discovery search: keeps NIP-50 `search` (unlike bulk {@link queryIndexRelayForLibrary}). */
export async function queryIndexRelayPublicationSearch( export async function queryIndexRelayPublicationSearch(
baseUrl: string, baseUrl: string,
filter: Filter, filter: Filter,
options?: { signal?: AbortSignal } options?: { signal?: AbortSignal }
): Promise<TIndexRelayLibraryPage> { ): Promise<TIndexRelayLibraryPage> {
return queryIndexRelayForLibrary(baseUrl, filter, options)
}
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
return rest as Filter
}
/** Kind-30040 metadata search (d / title / author / source) on Mercury-style index relays. */
export async function queryIndexRelayPublicationMetadataSearch(
baseUrl: string,
query: string,
options?: { limit?: number; signal?: AbortSignal }
): Promise<TIndexRelayLibraryPage> {
const q = query.trim()
if (!q) return { events: [], apiRowCount: 0 }
const base = devHttpIndexRelayBaseForFetch(baseUrl) const base = devHttpIndexRelayBaseForFetch(baseUrl)
const endpoint = indexRelayPublicationMetadataSearchUrl(base) const endpoint = indexRelayFilterUrl(base)
if (shouldSkipDevIndexRelayFetch(endpoint)) { if (shouldSkipDevIndexRelayFetch(endpoint)) {
return { events: [], apiRowCount: 0 } return { events: [], apiRowCount: 0 }
} }
const limit = Math.max(1, Math.min(options?.limit ?? 100, 100)) const body = nostrFilterToIndexRelayBody(filter)
try { try {
const res = await fetchWithTimeout(endpoint, { const res = await fetchWithTimeout(endpoint, {
method: 'POST', method: 'POST',
@ -460,12 +438,11 @@ export async function queryIndexRelayPublicationMetadataSearch(
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ q, limit }), body: JSON.stringify(body),
signal: options?.signal, signal: options?.signal,
timeoutMs: 25_000 timeoutMs: 25_000
}) })
if (!res.ok) { if (!res.ok) {
if (res.status === 404 || res.status === 405) return { events: [], apiRowCount: 0 }
if (res.status >= 500) { if (res.status >= 500) {
markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint) markDevIndexRelayUnavailableFromHttpStatus(res.status, endpoint)
throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`)) throw new IndexRelayTransportError(new Error(`HTTP ${res.status}`))
@ -495,7 +472,7 @@ export async function queryIndexRelayPublicationMetadataSearch(
handleFilterTransportFailure(endpoint, e) handleFilterTransportFailure(endpoint, e)
throw new IndexRelayTransportError(e) throw new IndexRelayTransportError(e)
} }
warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication metadata search request error', { warnIndexRelayHttpThrottled(endpoint, '[IndexRelayHttp] publication search request error', {
endpoint, endpoint,
error: e error: e
}) })
@ -503,6 +480,12 @@ export async function queryIndexRelayPublicationMetadataSearch(
} }
} }
function filterForIndexRelay(f: Filter): Filter {
const rest = { ...f } as Filter & { search?: unknown }
delete rest.search
return rest as Filter
}
export async function publishEventToHttpRelay( export async function publishEventToHttpRelay(
baseUrl: string, baseUrl: string,
event: NEvent, event: NEvent,

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

@ -12,22 +12,30 @@ import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
type PersistLibraryIndexCacheOptions = { type PersistLibraryIndexCacheOptions = {
/** Accepted for API compat; reconcile is a no-op after v43 store consolidation. */ /** When false, merge rows only — skip reconcile so partial batches cannot wipe unrelated cache rows. */
reconcile?: boolean reconcile?: boolean
} }
let persistQueue: Promise<void> = Promise.resolve() let persistQueue: Promise<void> = Promise.resolve()
/** Load kind-30040 catalog masters from {@link StoreNames.PUBLICATION_EVENTS}. */
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> { export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try { try {
const cached = await indexedDb.getPublicationCatalogIndexEvents() const cached = await indexedDb.getLibraryPublicationIndexCacheEvents()
// Structural re-check + address dedupe only — avoid ~5k verifyEvent on read (main-thread hang).
const structural = filterStructuralIndexEvents(cached) const structural = filterStructuralIndexEvents(cached)
const map = buildStructuralPublicationIndexMap(structural) const map = buildStructuralPublicationIndexMap(structural)
return publicationIndexMapValues(map) const normalized = publicationIndexMapValues(map)
if (structural.length < cached.length) {
void indexedDb.pruneUnverifiedLibraryPublicationIndexCacheEvents().catch(() => {})
}
const hasLegacyKeys = await indexedDb.libraryPublicationIndexCacheHasLegacyKeys()
if (normalized.length !== cached.length || hasLegacyKeys) {
void persistLibraryIndexCacheEvents(normalized, { reconcile: true }).catch(() => {})
}
return normalized
} catch (e) { } catch (e) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] publication catalog IDB read failed', { logger.warn('[Library] index IDB read failed', {
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -35,22 +43,25 @@ export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
} }
} }
/** Persist kind-30040 catalog masters into {@link StoreNames.PUBLICATION_EVENTS}. */
export async function persistLibraryIndexCacheEvents( export async function persistLibraryIndexCacheEvents(
events: Event[], events: Event[],
_options?: PersistLibraryIndexCacheOptions options?: PersistLibraryIndexCacheOptions
): Promise<void> { ): Promise<void> {
const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events)) const map = buildStructuralPublicationIndexMap(filterStructuralIndexEvents(events))
const normalized = publicationIndexMapValues(map) const normalized = publicationIndexMapValues(map)
if (normalized.length === 0) return if (normalized.length === 0) return
const reconcile = options?.reconcile !== false
const run = async () => { const run = async () => {
try { try {
const budget = getLibraryIndexCacheBudget() const budget = getLibraryIndexCacheBudget()
await indexedDb.mergePublicationCatalogIndexEvents(normalized, budget) await indexedDb.mergeLibraryPublicationIndexCacheEvents(normalized, budget)
if (reconcile) {
await indexedDb.reconcileLibraryPublicationIndexCache(map)
}
} catch (e) { } catch (e) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] publication catalog IDB write failed', { logger.warn('[Library] index IDB write failed', {
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -63,14 +74,14 @@ export async function persistLibraryIndexCacheEvents(
export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> { export async function getLibraryIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
try { try {
return await indexedDb.getPublicationCatalogFootprint() return await indexedDb.getLibraryPublicationIndexCacheFootprint()
} catch { } catch {
return { count: 0, bytes: 0 } return { count: 0, bytes: 0 }
} }
} }
export async function clearLibraryIndexIdbCache(): Promise<void> { export async function clearLibraryIndexIdbCache(): Promise<void> {
await indexedDb.clearPublicationCatalogDiscoveryOnly() await indexedDb.clearLibraryPublicationIndexCacheStore()
} }
export { approxLibraryIndexEventBytes, getLibraryIndexCacheBudget } export { approxLibraryIndexEventBytes, getLibraryIndexCacheBudget }

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

@ -3,19 +3,16 @@ import { ExtendedKind } from '@/constants'
import { import {
buildEngagementMapsFromEvents, buildEngagementMapsFromEvents,
buildLibraryPublicationRelaySearchFilters, buildLibraryPublicationRelaySearchFilters,
buildLibraryPublicationRelaySearchFiltersForAxis,
buildRecentPublicationEntries, buildRecentPublicationEntries,
clearLibrarySearchSessionCache, clearLibrarySearchSessionCache,
computeLibraryFeedRootOrder, computeLibraryFeedRootOrder,
filterEngagedPublications, filterEngagedPublications,
filterEventsForPublicationRelaySearchAxis,
filterLibraryPublicationsBySearch, filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser, filterLibraryPublicationsByUser,
libraryDefaultFeedSlice, libraryDefaultFeedSlice,
libraryPublicationEntriesForUserFromIndex, libraryPublicationEntriesForUserFromIndex,
LIBRARY_PAGE_SIZE, LIBRARY_PAGE_SIZE,
pickLibraryPublicationEntries, pickLibraryPublicationEntries,
publicationMetadataTagMatchesQuery,
publicationRootBelongsToUser, publicationRootBelongsToUser,
peekLibrarySearchResults, peekLibrarySearchResults,
publicationIndexMatchesSearchQuery, publicationIndexMatchesSearchQuery,
@ -236,99 +233,20 @@ describe('library-publication-index', () => {
expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false) expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false)
}) })
it('buildLibraryPublicationRelaySearchFilters splits kind 30040 into d-tag, title, and author without NIP-50', () => { it('buildLibraryPublicationRelaySearchFilters uses kind 30040 for d-tag and search', () => {
expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china') expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china')
const dTagFilters = buildLibraryPublicationRelaySearchFiltersForAxis('d-tag', { const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
query: 'Village Life in China' expect(filters.length).toBeGreaterThan(0)
}) expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe(
expect(dTagFilters).toHaveLength(1) true
expect(dTagFilters[0].kinds).toEqual([ExtendedKind.PUBLICATION]) )
expect(dTagFilters[0]['#d']).toContain('village-life-in-china')
expect(dTagFilters[0].search).toBeUndefined()
const titleFilters = buildLibraryPublicationRelaySearchFiltersForAxis('title', {
query: 'Village Life in China'
})
expect(titleFilters).toHaveLength(0)
const authorFilters = buildLibraryPublicationRelaySearchFiltersForAxis('author', {
query: 'Village Life in China'
})
expect(authorFilters).toHaveLength(0)
const merged = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
expect(merged).toHaveLength(1)
expect(merged[0]['#d']).toContain('village-life-in-china')
expect(merged.every((f) => f.search == null)).toBe(true)
})
it('filterEventsForPublicationRelaySearchAxis keeps axis-specific kind-30040 matches', () => {
const root = indexEvent('jane-eyre', [`30041:${PK}:intro`])
root.tags = [
['d', 'jane-eyre'],
['title', 'Jane Eyre'],
['author', 'Charlotte Brontë'],
['a', `30041:${PK}:intro`]
]
const byDTag = filterEventsForPublicationRelaySearchAxis([root], 'd-tag', 'jane-eyre')
expect(byDTag).toHaveLength(1)
const byTitle = filterEventsForPublicationRelaySearchAxis([root], 'title', 'jane eyre')
expect(byTitle).toHaveLength(1)
const byAuthor = filterEventsForPublicationRelaySearchAxis([root], 'author', 'charlotte brontë')
expect(byAuthor).toHaveLength(1)
expect(filterEventsForPublicationRelaySearchAxis([root], 'title', 'charlotte')).toHaveLength(0)
expect(filterEventsForPublicationRelaySearchAxis([root], 'author', 'charlotte')).toHaveLength(1)
expect(publicationMetadataTagMatchesQuery(root, 'title', 'Jane Eyre')).toBe(true)
expect(publicationMetadataTagMatchesQuery(root, 'author', 'Brontë')).toBe(true)
})
it('author and title axes match partial metadata text but d-tag stays exact', () => {
const root = indexEvent('faust', [`30041:${PK}:intro`])
root.tags = [
['d', 'faust-part-one'],
['title', 'Faust: Der Tragödie erster Teil'],
['author', 'Johann Wolfgang von Goethe'],
['a', `30041:${PK}:intro`]
]
expect(publicationMetadataTagMatchesQuery(root, 'author', 'goethe')).toBe(true)
expect(publicationMetadataTagMatchesQuery(root, 'title', 'tragödie')).toBe(true)
expect(publicationMetadataTagMatchesQuery(root, 'd', 'faust')).toBe(false)
expect(publicationMetadataTagMatchesQuery(root, 'd', 'faust-part-one')).toBe(true)
})
it('searchLibraryPublications respects author axis and keeps separate cache keys', async () => {
clearLibrarySearchSessionCache()
const about = indexEvent('about-aristotle', [`30041:${PK}:intro`])
about.tags = [
['d', 'about-aristotle'],
['title', 'Aristotle: A Very Short Introduction'],
['author', 'John Smith'],
['a', `30041:${PK}:intro`]
]
const fromAuthor = indexEvent('nicomachean-ethics', [`30041:${PK}:ch`])
fromAuthor.tags = [
['d', 'nicomachean-ethics'],
['title', 'Nicomachean Ethics'],
['author', 'Aristotle'],
['a', `30041:${PK}:ch`]
]
const indexEvents = [about, fromAuthor]
const engagement = buildEngagementMapsFromEvents([], [], [])
const broad = await searchLibraryPublications('aristotle', { indexEvents, engagement })
expect(broad.map((e) => e.event.id).sort()).toEqual([about.id, fromAuthor.id].sort())
const byAuthor = await searchLibraryPublications('aristotle', { indexEvents, engagement }, 'author') const dFilter = filters.find((f) => f['#d'])
expect(byAuthor.map((e) => e.event.id)).toEqual([fromAuthor.id]) expect(dFilter?.['#d']).toContain('village-life-in-china')
expect(peekLibrarySearchResults('aristotle', { indexEvents, engagement }, 'author')).toHaveLength(1) const searchFilter = filters.find((f) => f.search === 'Village Life in China')
expect(peekLibrarySearchResults('aristotle', { indexEvents, engagement })).toHaveLength(2) expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION])
}) })
it('searchLibraryPublications caches results for repeated queries', async () => { it('searchLibraryPublications caches results for repeated queries', async () => {

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

@ -8,10 +8,11 @@ import {
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser' import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label' import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationMetadataSearch } from '@/lib/index-relay-http' import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import { import {
buildIndexByAddress, buildIndexByAddress,
buildStructuralPublicationIndexMap, buildStructuralPublicationIndexMap,
collectPublicationIndexEventIds,
collectReachableAddressesCached, collectReachableAddressesCached,
eventTagAddress, eventTagAddress,
filterValidIndexEvents, filterValidIndexEvents,
@ -54,11 +55,7 @@ const INDEX_MAX_PAGES_PER_RELAY = 100
const INDEX_VERIFY_CHUNK = 80 const INDEX_VERIFY_CHUNK = 80
const ENGAGEMENT_ADDRESS_CHUNK = 36 const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44 const ENGAGEMENT_EVENT_ID_CHUNK = 44
/** Cap engagement relay queries to the first slice of the catalog (not the full index corpus). */ const MAX_TARGET_ADDRESSES = 480
const MAX_TARGET_ADDRESSES = 120
const MAX_TARGET_EVENT_IDS = 160
const MAX_ENGAGEMENT_HTTP_CHUNKS = 6
const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000
const HYDRATE_MISSING_CAP = 64 const HYDRATE_MISSING_CAP = 64
export const LIBRARY_PAGE_SIZE = 120 export const LIBRARY_PAGE_SIZE = 120
/** @deprecated Use {@link LIBRARY_PAGE_SIZE} */ /** @deprecated Use {@link LIBRARY_PAGE_SIZE} */
@ -66,8 +63,6 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200 const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100 export const LIBRARY_RELAY_SEARCH_LIMIT = 100
const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000
/** Max paginated HTTP pages when title/author metadata API is unavailable (Mercury v0.2.0). */
const LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES = 80
/** NIP-51 pin list (kind 10001). */ /** NIP-51 pin list (kind 10001). */
const PIN_LIST_KIND = 10001 const PIN_LIST_KIND = 10001
/** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */ /** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */
@ -203,13 +198,8 @@ type LibrarySearchSessionRow = {
const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>() const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>()
function librarySearchQueryKey( function librarySearchQueryKey(query: string): string {
query: string, return normalizeGeneralSearchQuery(query).toLowerCase()
axis?: LibraryPublicationRelaySearchAxis | null
): string {
const base = normalizeGeneralSearchQuery(query).toLowerCase()
if (!axis) return base
return `${axis}:${base}`
} }
function librarySearchFingerprint(context: LibrarySearchContext): string { function librarySearchFingerprint(context: LibrarySearchContext): string {
@ -240,9 +230,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
function getLibrarySearchSessionRow( function getLibrarySearchSessionRow(
query: string, query: string,
context: LibrarySearchContext, context: LibrarySearchContext,
opts?: { requireRelaySearch?: boolean; axis?: LibraryPublicationRelaySearchAxis | null } opts?: { requireRelaySearch?: boolean }
): LibrarySearchSessionRow | null { ): LibrarySearchSessionRow | null {
const key = librarySearchQueryKey(query, opts?.axis) const key = librarySearchQueryKey(query)
if (!key) return null if (!key) return null
const row = librarySearchSessionCache.get(key) const row = librarySearchSessionCache.get(key)
if (!row) return null if (!row) return null
@ -254,10 +244,9 @@ function getLibrarySearchSessionRow(
function putLibrarySearchSessionRow( function putLibrarySearchSessionRow(
query: string, query: string,
context: LibrarySearchContext, context: LibrarySearchContext,
row: Omit<LibrarySearchSessionRow, 'fingerprint'>, row: Omit<LibrarySearchSessionRow, 'fingerprint'>
axis?: LibraryPublicationRelaySearchAxis | null
): void { ): void {
const key = librarySearchQueryKey(query, axis) const key = librarySearchQueryKey(query)
if (!key) return if (!key) return
librarySearchSessionCache.set(key, { librarySearchSessionCache.set(key, {
...row, ...row,
@ -268,10 +257,9 @@ function putLibrarySearchSessionRow(
/** Sync read of cached search hits for the current index + engagement snapshot. */ /** Sync read of cached search hits for the current index + engagement snapshot. */
export function peekLibrarySearchResults( export function peekLibrarySearchResults(
query: string, query: string,
context: LibrarySearchContext, context: LibrarySearchContext
axis?: LibraryPublicationRelaySearchAxis | null
): LibraryPublicationEntry[] | null { ): LibraryPublicationEntry[] | null {
return getLibrarySearchSessionRow(query, context, { axis })?.entries ?? null return getLibrarySearchSessionRow(query, context)?.entries ?? null
} }
export function clearLibrarySearchSessionCache(): void { export function clearLibrarySearchSessionCache(): void {
@ -921,21 +909,8 @@ export async function fetchPublicationEngagementMaps(
return emptyPublicationEngagementMaps() return emptyPublicationEngagementMaps()
} }
return withEngagementTimeout( const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK)
fetchPublicationEngagementMapsInner(relayUrls, targetAddresses, targetEventIds, options), const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK)
emptyPublicationEngagementMaps(),
'maps'
)
}
async function fetchPublicationEngagementMapsInner(
relayUrls: string[],
targetAddresses: Set<string>,
targetEventIds: Set<string>,
options?: { viewerPubkey?: string | null }
): Promise<PublicationEngagementMaps> {
const addressChunks = limitEngagementChunks(chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK))
const eventIdChunks = limitEngagementChunks(chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK))
const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls) const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls)
const useWsEngagement = wsRelays.length > 0 const useWsEngagement = wsRelays.length > 0
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
@ -1588,10 +1563,8 @@ export async function refreshLibraryEngagement(
viewerPubkey?: string | null viewerPubkey?: string | null
): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> { ): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> {
const indexByAddress = buildIndexByAddress(indexEvents) const indexByAddress = buildIndexByAddress(indexEvents)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets( const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
indexEvents, const targetEventIds = collectPublicationIndexEventIds(indexEvents)
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls( const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined, viewerPubkey ?? undefined,
indexRelayUrls, indexRelayUrls,
@ -1616,24 +1589,23 @@ export async function refreshLibraryEngagement(
} }
} }
const LIBRARY_SEARCH_BATCH_SIZE = 80 /** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex(
function collectLibraryPublicationIndexSearchRoots(
query: string, query: string,
indexEvents: Event[], indexEvents: Event[],
topLevelIds: Set<string>, indexByAddress: Map<string, Event>
addressToRoot: Map<string, Event>, ): Event[] {
axis: LibraryPublicationRelaySearchAxis | null | undefined,
roots: Map<string, Event>,
start: number,
end: number
): void {
const q = query.trim() const q = query.trim()
if (!q || indexEvents.length === 0) return []
const topLevel = getTopLevelIndexEvents(indexEvents)
const topLevelIds = new Set(topLevel.map((ev) => ev.id))
const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
const roots = new Map<string, Event>()
for (let i = start; i < end; i++) { for (const ev of indexEvents) {
const ev = indexEvents[i]
if (ev.kind !== ExtendedKind.PUBLICATION) continue if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (topLevelIds.has(ev.id)) { if (topLevelIds.has(ev.id)) {
roots.set(ev.id, ev) roots.set(ev.id, ev)
@ -1644,78 +1616,10 @@ function collectLibraryPublicationIndexSearchRoots(
const root = addr ? addressToRoot.get(addr) : undefined const root = addr ? addressToRoot.get(addr) : undefined
if (root) roots.set(root.id, root) if (root) roots.set(root.id, root)
} }
}
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex(
query: string,
indexEvents: Event[],
indexByAddress: Map<string, Event>,
axis?: LibraryPublicationRelaySearchAxis | null
): Event[] {
const q = query.trim()
if (!q || indexEvents.length === 0) return []
const topLevel = getTopLevelIndexEvents(indexEvents)
const topLevelIds = new Set(topLevel.map((ev) => ev.id))
const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
const roots = new Map<string, Event>()
collectLibraryPublicationIndexSearchRoots(
q,
indexEvents,
topLevelIds,
addressToRoot,
axis,
roots,
0,
indexEvents.length
)
return [...roots.values()] return [...roots.values()]
} }
/** Yields between batches so large index scans do not freeze the UI. */
export function searchLibraryPublicationIndexAsync(
query: string,
indexEvents: Event[],
indexByAddress: Map<string, Event>,
axis?: LibraryPublicationRelaySearchAxis | null,
signal?: { cancelled: boolean }
): Promise<Event[]> {
const q = query.trim()
if (!q || indexEvents.length === 0) return Promise.resolve([])
const topLevel = getTopLevelIndexEvents(indexEvents)
const topLevelIds = new Set(topLevel.map((ev) => ev.id))
const addressToRoot = buildAddressToRootMap(topLevel, indexByAddress)
const roots = new Map<string, Event>()
let i = 0
return new Promise((resolve) => {
const step = () => {
if (signal?.cancelled) return
const end = Math.min(i + LIBRARY_SEARCH_BATCH_SIZE, indexEvents.length)
collectLibraryPublicationIndexSearchRoots(
q,
indexEvents,
topLevelIds,
addressToRoot,
axis,
roots,
i,
end
)
i = end
if (signal?.cancelled) return
if (i < indexEvents.length) {
requestAnimationFrame(step)
} else {
resolve([...roots.values()])
}
}
requestAnimationFrame(step)
})
}
export type LibrarySearchContext = { export type LibrarySearchContext = {
indexEvents: Event[] indexEvents: Event[]
engagement?: PublicationEngagementMaps engagement?: PublicationEngagementMaps
@ -1727,20 +1631,15 @@ export type LibrarySearchContext = {
*/ */
export async function searchLibraryPublications( export async function searchLibraryPublications(
query: string, query: string,
context: LibrarySearchContext, context: LibrarySearchContext
axis?: LibraryPublicationRelaySearchAxis | null
): Promise<LibraryPublicationEntry[]> { ): Promise<LibraryPublicationEntry[]> {
const q = query.trim() const q = query.trim()
if (!q) return [] if (!q) return []
const cached = getLibrarySearchSessionRow(q, context, { axis }) const cached = getLibrarySearchSessionRow(q, context)
if (cached) { if (cached) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] search cache hit', { logger.info('[Library] search cache hit', { query: q, relaySearched: cached.relaySearched })
query: q,
axis: axis ?? 'all',
relaySearched: cached.relaySearched
})
} }
return cached.entries return cached.entries
} }
@ -1752,7 +1651,7 @@ export async function searchLibraryPublications(
const engagement = context.engagement ?? EMPTY_ENGAGEMENT const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const indexByAddress = buildIndexByAddress(indexEvents) const indexByAddress = buildIndexByAddress(indexEvents)
const fromIndex = await searchLibraryPublicationIndexAsync(q, indexEvents, indexByAddress, axis) const fromIndex = searchLibraryPublicationIndex(q, indexEvents, indexByAddress)
const rootMap = new Map<string, Event>() const rootMap = new Map<string, Event>()
for (const root of fromIndex) rootMap.set(root.id, root) for (const root of fromIndex) rootMap.set(root.id, root)
@ -1768,7 +1667,7 @@ export async function searchLibraryPublications(
) )
for (const ev of fromReadingCache) { for (const ev of fromReadingCache) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (rootMap.has(ev.id)) continue if (rootMap.has(ev.id)) continue
const addr = eventTagAddress(ev) const addr = eventTagAddress(ev)
@ -1795,17 +1694,12 @@ export async function searchLibraryPublications(
const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement)) const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement))
const searchContext: LibrarySearchContext = { indexEvents, engagement } const searchContext: LibrarySearchContext = { indexEvents, engagement }
const prev = getLibrarySearchSessionRow(q, searchContext, { axis }) const prev = getLibrarySearchSessionRow(q, searchContext)
putLibrarySearchSessionRow( putLibrarySearchSessionRow(q, searchContext, {
q,
searchContext,
{
entries, entries,
mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents, mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
relaySearched: prev?.relaySearched ?? false relaySearched: prev?.relaySearched ?? false
}, })
axis
)
return entries return entries
} }
@ -1833,15 +1727,6 @@ function normalizePublicationDTag(term: string): string {
.replace(/^-|-$/g, '') .replace(/^-|-$/g, '')
} }
/** Relay search axis for kind-30040 publication indexes. */
export type LibraryPublicationRelaySearchAxis = 'd-tag' | 'title' | 'author'
export const LIBRARY_PUBLICATION_RELAY_SEARCH_AXES: LibraryPublicationRelaySearchAxis[] = [
'd-tag',
'title',
'author'
]
/** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */ /** d-tag filter values: hyphenated slug variants for relay `#d` REQ. */
export function publicationQueryDTagVariants(query: string): string[] { export function publicationQueryDTagVariants(query: string): string[] {
const raw = query.trim() const raw = query.trim()
@ -1857,269 +1742,82 @@ export function publicationQueryDTagVariants(query: string): string[] {
return [...seen] return [...seen]
} }
/** Normalized needles for publication metadata tag match (d / title / author). */
export function publicationQueryNeedles(query: string): string[] {
const raw = normalizeGeneralSearchQuery(query.trim())
if (!raw) return []
const lower = raw.toLowerCase()
const normalized = lower.replace(/\s+/g, ' ').trim()
const hyphen = lower
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return [...new Set([lower, normalized, hyphen].filter(Boolean))]
}
function publicationTagValueMatchesNeedles(
tagValue: string,
needles: string[],
exactOnly: boolean
): boolean {
const val = tagValue.trim().toLowerCase()
const valSpaced = val.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
for (const needle of needles) {
if (!needle) continue
const needleSpaced = needle.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
if (val === needle || valSpaced === needleSpaced) return true
if (exactOnly || needle.length < 2) continue
if (val.includes(needle) || valSpaced.includes(needleSpaced)) return true
}
return false
}
export function publicationMetadataTagMatchesQuery(
event: Event,
tagName: 'd' | 'title' | 'author',
query: string
): boolean {
const needles = publicationQueryNeedles(query)
if (needles.length === 0) return false
const exactOnly = tagName === 'd'
for (const tag of event.tags ?? []) {
if ((tag[0] || '').toLowerCase() !== tagName) continue
const value = tag[1]?.trim()
if (value && publicationTagValueMatchesNeedles(value, needles, exactOnly)) return true
}
return false
}
function publicationRelaySearchSourceTerms(query: string): string[] {
const raw = query.trim()
if (!raw) return []
const terms = new Set<string>([raw])
const adv = parseAdvancedSearch(raw)
if (adv.title) {
for (const title of Array.isArray(adv.title) ? adv.title : [adv.title]) {
const t = title.trim()
if (t) terms.add(t)
}
}
return [...terms]
}
function publicationRelaySearchTermsForAxis(
axis: LibraryPublicationRelaySearchAxis,
query: string
): string[] {
const raw = query.trim()
if (!raw) return []
const adv = parseAdvancedSearch(raw)
if (axis === 'title' && adv.title) {
const titles = Array.isArray(adv.title) ? adv.title : [adv.title]
const trimmed = titles.map((t) => t.trim()).filter(Boolean)
if (trimmed.length > 0) return [...new Set(trimmed)]
}
if (axis === 'author' && adv.author) {
const authors = Array.isArray(adv.author) ? adv.author : [adv.author]
const trimmed = authors.map((a) => a.trim()).filter(Boolean)
if (trimmed.length > 0) return [...new Set(trimmed)]
}
if (axis === 'd-tag') {
return publicationRelaySearchSourceTerms(raw)
}
return [raw]
}
function addPublicationKindFilter(
out: Filter[],
seen: Set<string>,
filter: Filter
) {
const key = JSON.stringify(filter)
if (seen.has(key)) return
seen.add(key)
out.push(filter)
}
/** One axis of kind-30040 relay discovery: `#d`, metadata title/author (HTTP), or `authors` for npub. */
export function buildLibraryPublicationRelaySearchFiltersForAxis(
axis: LibraryPublicationRelaySearchAxis,
opts: { query: string; limit?: number }
): Filter[] {
const searchRaw = opts.query.trim()
if (!searchRaw) return []
const limit = Math.max(1, Math.min(opts.limit ?? LIBRARY_RELAY_SEARCH_LIMIT, 100))
const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>()
const out: Filter[] = []
if (axis === 'author') {
const npub = tryNpubFromQuery(searchRaw)
if (npub) {
addPublicationKindFilter(out, seen, { kinds: [kind], authors: [npub], limit })
return out
}
return out
}
if (axis === 'title') {
return out
}
if (axis === 'd-tag') {
const dTags = new Set<string>()
for (const term of publicationRelaySearchTermsForAxis('d-tag', searchRaw)) {
for (const d of publicationQueryDTagVariants(term)) dTags.add(d)
}
if (dTags.size === 0) return []
addPublicationKindFilter(out, seen, { kinds: [kind], '#d': [...dTags], limit })
return out
}
return out
}
/** /**
* REQ filters for kind **30040** publication indexes, split by axis (d-tag, title, author). * OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search`
* Title and author text use HTTP metadata search (not NIP-50). Only `#d` and pubkey `authors` use NIP-01 filters. * (title, author, summary/description on index relays).
*/ */
export function buildLibraryPublicationRelaySearchFilters(opts: { export function buildLibraryPublicationRelaySearchFilters(opts: {
query: string query: string
limit?: number limit?: number
}): Filter[] { }): Filter[] {
const searchRaw = opts.query.trim()
if (!searchRaw) return []
const limit = Math.max(1, Math.min(opts.limit ?? LIBRARY_RELAY_SEARCH_LIMIT, 100))
const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>() const seen = new Set<string>()
const out: Filter[] = [] const out: Filter[] = []
for (const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES) { const add = (filter: Filter) => {
for (const filter of buildLibraryPublicationRelaySearchFiltersForAxis(axis, opts)) {
const key = JSON.stringify(filter) const key = JSON.stringify(filter)
if (seen.has(key)) continue if (seen.has(key)) return
seen.add(key) seen.add(key)
out.push(filter) out.push(filter)
} }
}
return out
}
export function filterEventsForPublicationRelaySearchAxis(
events: Event[],
axis: LibraryPublicationRelaySearchAxis,
query: string
): Event[] {
const terms = publicationRelaySearchTermsForAxis(axis, query)
if (terms.length === 0) return []
return events.filter((event) => { const npub = tryNpubFromQuery(searchRaw)
if (event.kind !== ExtendedKind.PUBLICATION) return false if (npub) {
if (axis === 'author') { add({ kinds: [kind], authors: [npub], limit })
const npub = tryNpubFromQuery(query.trim()) return out
if (npub && event.pubkey.toLowerCase() === npub) return true
} }
const tagName = axis === 'd-tag' ? 'd' : axis
return terms.some((term) => publicationMetadataTagMatchesQuery(event, tagName, term))
})
}
export function publicationIndexMatchesSearchQueryWithAxis(
event: Event,
query: string,
axis?: LibraryPublicationRelaySearchAxis | null
): boolean {
if (!axis) return publicationIndexMatchesSearchQuery(event, query)
return filterEventsForPublicationRelaySearchAxis([event], axis, query).length > 0
}
async function scanHttpIndexRelayForPublicationAxis(
httpRelay: string,
axis: LibraryPublicationRelaySearchAxis,
term: string
): Promise<Event[]> {
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_HTTP_PAGE_LIMIT }
const matched: Event[] = []
const seen = new Set<string>()
const collect = (batch: Event[]) => { const dTags = publicationQueryDTagVariants(searchRaw)
for (const ev of filterEventsForPublicationRelaySearchAxis(batch, axis, term)) { if (dTags.length > 0) {
if (!seen.has(ev.id)) { add({ kinds: [kind], '#d': dTags, limit })
seen.add(ev.id)
matched.push(ev)
}
} }
const searchNorm = normalizeGeneralSearchQuery(searchRaw)
add({ kinds: [kind], search: searchRaw, limit })
if (searchNorm !== searchRaw) {
add({ kinds: [kind], search: searchNorm, limit })
} }
let firstPage: Event[] const adv = parseAdvancedSearch(searchRaw)
try { const titleValues = adv.title
firstPage = (await queryIndexRelayForLibrary(httpRelay, filter)).events as Event[] ? Array.isArray(adv.title)
} catch (e) { ? adv.title
if (import.meta.env.DEV) { : [adv.title]
logger.warn('[Library] HTTP publication scan first page failed', { : []
relay: httpRelay, for (const title of titleValues) {
axis, const t = title.trim()
message: e instanceof Error ? e.message : String(e) if (!t) continue
}) add({ kinds: [kind], search: t, limit })
const titleDTags = publicationQueryDTagVariants(t)
if (titleDTags.length > 0) {
add({ kinds: [kind], '#d': titleDTags, limit })
} }
return []
} }
collect(firstPage) const authorValues = adv.author
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage.length === 0) { ? Array.isArray(adv.author)
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT) ? adv.author
: [adv.author]
: []
for (const author of authorValues) {
const a = author.trim()
if (a) add({ kinds: [kind], search: a, limit })
} }
let until = oldestCreatedAt(firstPage) - 1 const descriptionValues = adv.description
for (let page = 1; page < LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES; page++) { ? Array.isArray(adv.description)
if (until < 0) break ? adv.description
let batch: Event[] = [] : [adv.description]
let apiRowCount = 0 : []
try { for (const description of descriptionValues) {
const pageResult = await queryIndexRelayForLibrary(httpRelay, { ...filter, until }) const d = description.trim()
batch = pageResult.events as Event[] if (d) add({ kinds: [kind], search: d, limit })
apiRowCount = pageResult.apiRowCount
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication scan page failed', {
relay: httpRelay,
axis,
page,
message: e instanceof Error ? e.message : String(e)
})
}
break
}
if (apiRowCount === 0) break
collect(batch)
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT) break
if (apiRowCount < INDEX_HTTP_PAGE_LIMIT) break
const oldest = oldestCreatedAt(batch)
if (oldest === Number.MAX_SAFE_INTEGER) break
until = oldest - 1
} }
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT) return out
}
async function searchHttpIndexRelayPublicationAxis(
httpRelay: string,
axis: LibraryPublicationRelaySearchAxis,
term: string
): Promise<Event[]> {
const meta = await queryIndexRelayPublicationMetadataSearch(httpRelay, term, {
limit: LIBRARY_RELAY_SEARCH_LIMIT
})
const fromApi = filterEventsForPublicationRelaySearchAxis(meta.events as Event[], axis, term)
if (fromApi.length > 0) return fromApi
return scanHttpIndexRelayForPublicationAxis(httpRelay, axis, term)
} }
/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */ /** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
@ -2127,7 +1825,7 @@ export async function searchLibraryPublicationsOnRelays(
query: string, query: string,
relayUrls: string[], relayUrls: string[],
context: LibrarySearchContext, context: LibrarySearchContext,
options?: { forceRefresh?: boolean; axis?: LibraryPublicationRelaySearchAxis | null } options?: { forceRefresh?: boolean }
): Promise<{ ): Promise<{
events: Event[] events: Event[]
entries: LibraryPublicationEntry[] entries: LibraryPublicationEntry[]
@ -2140,10 +1838,7 @@ export async function searchLibraryPublicationsOnRelays(
} }
if (!options?.forceRefresh) { if (!options?.forceRefresh) {
const cached = getLibrarySearchSessionRow(q, context, { const cached = getLibrarySearchSessionRow(q, context, { requireRelaySearch: true })
requireRelaySearch: true,
axis: options?.axis
})
if (cached) { if (cached) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] relay search cache hit', { query: q }) logger.info('[Library] relay search cache hit', { query: q })
@ -2157,36 +1852,26 @@ export async function searchLibraryPublicationsOnRelays(
} }
} }
const filters = buildLibraryPublicationRelaySearchFilters({ query: q })
if (filters.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const indexRelays = libraryIndexRelayUrls(relayUrls) const indexRelays = libraryIndexRelayUrls(relayUrls)
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
const batches: Promise<Event[]>[] = [] const batches: Promise<Event[]>[] = []
let filterCount = 0
const axes = options?.axis ? [options.axis] : LIBRARY_PUBLICATION_RELAY_SEARCH_AXES
for (const axis of axes) {
const npubQuery = tryNpubFromQuery(q)
if (npubQuery && axis !== 'author') continue
const axisFilters = buildLibraryPublicationRelaySearchFiltersForAxis(axis, { query: q })
const hasNip01Filters = axisFilters.length > 0
const hasMetadataSearch = axis === 'title' || (axis === 'author' && !npubQuery)
if (!hasNip01Filters && !hasMetadataSearch) continue
filterCount += axisFilters.length
if (wsRelays.length > 0 && hasNip01Filters) { if (wsRelays.length > 0) {
batches.push( batches.push(
queryService queryService
.fetchEvents(wsRelays, axisFilters, { .fetchEvents(wsRelays, filters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS, globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000, eoseTimeout: 8_000,
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
}) })
.then((events) => filterEventsForPublicationRelaySearchAxis(events, axis, q))
.catch((e) => { .catch((e) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', { logger.warn('[Library] WS publication search failed', {
axis,
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -2196,35 +1881,14 @@ export async function searchLibraryPublicationsOnRelays(
} }
for (const httpRelay of httpRelays) { for (const httpRelay of httpRelays) {
if (hasNip01Filters) { for (const filter of filters) {
for (const filter of axisFilters) {
batches.push( batches.push(
queryIndexRelayForLibrary(httpRelay, filter) queryIndexRelayPublicationSearch(httpRelay, filter)
.then((page) => filterEventsForPublicationRelaySearchAxis(page.events as Event[], axis, q)) .then((page) => page.events as Event[])
.catch((e) => { .catch((e) => {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication filter search failed', { logger.warn('[Library] HTTP publication search failed', {
relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
}
if (!hasMetadataSearch) continue
for (const term of publicationRelaySearchTermsForAxis(axis, q)) {
filterCount += 1
batches.push(
searchHttpIndexRelayPublicationAxis(httpRelay, axis, term).catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication metadata search failed', {
relay: httpRelay, relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e) message: e instanceof Error ? e.message : String(e)
}) })
} }
@ -2233,11 +1897,6 @@ export async function searchLibraryPublicationsOnRelays(
) )
} }
} }
}
if (batches.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const settled = await Promise.all(batches) const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat()) const networkEvents = dedupeEventsById(settled.flat())
@ -2249,7 +1908,7 @@ export async function searchLibraryPublicationsOnRelays(
void persistLibraryIndexCacheEvents(mergedIndex) void persistLibraryIndexCacheEvents(mergedIndex)
} }
const indexByAddress = buildIndexByAddress(mergedIndex) const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress, options?.axis) const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const entries = sortLibraryPublications( const entries = sortLibraryPublications(
libraryEntriesFromRoots(roots, indexByAddress, engagement) libraryEntriesFromRoots(roots, indexByAddress, engagement)
@ -2259,22 +1918,15 @@ export async function searchLibraryPublicationsOnRelays(
indexEvents: mergedIndex, indexEvents: mergedIndex,
engagement engagement
} }
putLibrarySearchSessionRow( putLibrarySearchSessionRow(q, searchContext, {
q,
searchContext,
{
entries, entries,
mergedIndexEvents: mergedIndex, mergedIndexEvents: mergedIndex,
relaySearched: true relaySearched: true
}, })
options?.axis
)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] relay search done', { logger.info('[Library] relay search done', {
axes: LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length, filters: filters.length,
filters: filterCount,
batches: batches.length,
network: networkEvents.length, network: networkEvents.length,
valid: valid.length, valid: valid.length,
roots: roots.length roots: roots.length
@ -2325,66 +1977,19 @@ function collectTargetAddressesFromIndexes(
indexEvents: Event[], indexEvents: Event[],
indexByAddress: Map<string, Event> indexByAddress: Map<string, Event>
): Set<string> { ): Set<string> {
return collectEngagementTargets(indexEvents, indexByAddress).addresses
}
/** Capped address + event-id targets for label/comment/highlight relay queries. */
export function collectEngagementTargets(
indexEvents: Event[],
indexByAddress: Map<string, Event>
): { addresses: Set<string>; eventIds: Set<string> } {
const addresses = new Set<string>() const addresses = new Set<string>()
const eventIds = new Set<string>()
outer: for (const root of getTopLevelIndexEvents(indexEvents)) { outer: for (const root of getTopLevelIndexEvents(indexEvents)) {
eventIds.add(root.id.toLowerCase())
if (eventIds.size >= MAX_TARGET_EVENT_IDS) break
for (const addr of collectReachableAddressesCached(root, indexByAddress)) { for (const addr of collectReachableAddressesCached(root, indexByAddress)) {
addresses.add(addr) addresses.add(addr)
const indexed = indexByAddress.get(addr) if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
if (indexed) eventIds.add(indexed.id.toLowerCase())
if (addresses.size >= MAX_TARGET_ADDRESSES || eventIds.size >= MAX_TARGET_EVENT_IDS) {
break outer
}
} }
const rootAddr = eventTagAddress(root) const rootAddr = eventTagAddress(root)
if (rootAddr) { if (rootAddr) {
addresses.add(rootAddr) addresses.add(rootAddr)
if (addresses.size >= MAX_TARGET_ADDRESSES || eventIds.size >= MAX_TARGET_EVENT_IDS) { if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
break outer
} }
} }
} return addresses
return { addresses, eventIds }
}
function limitEngagementChunks<T>(chunks: T[][]): T[][] {
return chunks.length <= MAX_ENGAGEMENT_HTTP_CHUNKS
? chunks
: chunks.slice(0, MAX_ENGAGEMENT_HTTP_CHUNKS)
}
async function withEngagementTimeout<T>(
promise: Promise<T>,
fallback: T,
label: string
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
promise,
new Promise<T>((resolve) => {
timer = setTimeout(() => {
if (import.meta.env.DEV) {
logger.warn('[Library] engagement fetch timed out', { label, ms: ENGAGEMENT_FETCH_TIMEOUT_MS })
}
resolve(fallback)
}, ENGAGEMENT_FETCH_TIMEOUT_MS)
})
])
} finally {
if (timer) clearTimeout(timer)
}
} }
async function buildEngagedFromCache( async function buildEngagedFromCache(
@ -2397,10 +2002,8 @@ async function buildEngagedFromCache(
const topLevel = getTopLevelIndexEvents(indexEvents) const topLevel = getTopLevelIndexEvents(indexEvents)
let maps = engagement let maps = engagement
if (!maps) { if (!maps) {
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets( const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
indexEvents, const targetEventIds = collectPublicationIndexEventIds(indexEvents)
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls( const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined, viewerPubkey ?? undefined,
indexRelayUrls, indexRelayUrls,
@ -2477,10 +2080,11 @@ async function runLibraryPublicationIndexLoad(
if (!options?.forceRefresh && sessionCache?.relayKey === key) { if (!options?.forceRefresh && sessionCache?.relayKey === key) {
const cachedIndexEvents = indexEventsFromCache(sessionCache) const cachedIndexEvents = indexEventsFromCache(sessionCache)
if (sessionCache.viewerPubkey !== viewerPubkey) { if (sessionCache.viewerPubkey !== viewerPubkey) {
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets( const targetAddresses = collectTargetAddressesFromIndexes(
cachedIndexEvents, cachedIndexEvents,
sessionCache.indexByAddress sessionCache.indexByAddress
) )
const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls( const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined, viewerPubkey ?? undefined,
relayUrls, relayUrls,
@ -2544,10 +2148,8 @@ async function runLibraryPublicationIndexLoad(
} }
topLevel = getTopLevelIndexEventsFromMap(indexByAddress) topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets( const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
indexEvents, const targetEventIds = collectPublicationIndexEventIds(indexEvents)
buildIndexByAddress(indexEvents)
)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] fetching engagement', { logger.info('[Library] fetching engagement', {
targetAddresses: targetAddresses.size, targetAddresses: targetAddresses.size,
@ -2608,7 +2210,7 @@ export function clearLibraryPublicationIndexCache(): void {
clearLibrarySearchSessionCache() clearLibrarySearchSessionCache()
} }
/** Clears Library tab session cache and relay-discovered catalog masters (opened publications stay). */ /** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
export async function clearAllLibraryIndexCaches(): Promise<void> { export async function clearAllLibraryIndexCaches(): Promise<void> {
sessionCache = null sessionCache = null
indexLoadJob = null indexLoadJob = null

7
src/lib/metadata-policy-curated-relays.test.ts

@ -1,4 +1,4 @@
import { DOCUMENT_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
isMetadataPolicyActiveReadGrantRelay, isMetadataPolicyActiveReadGrantRelay,
@ -15,14 +15,13 @@ describe('metadata-policy-curated-relays', () => {
it('operation scope excludes FAST_READ widening', () => { it('operation scope excludes FAST_READ widening', () => {
expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyOperationScopedRelay(DOCUMENT_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true) expect(isMetadataPolicyOperationScopedRelay(PROFILE_RELAY_URLS[0]!)).toBe(true)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr21.com/')).toBe(false) expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyOperationScopedRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
it('active read grant includes search and discovery stacks', () => { it('active read grant includes search and discovery stacks', () => {
expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true) expect(isMetadataPolicyActiveReadGrantRelay('wss://search.nos.today/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr21.com/')).toBe(true) expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://relay.primal.net/')).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false) expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false)
}) })
}) })

1
src/lib/new-user-template-broadcast.ts

@ -65,6 +65,7 @@ function prioritizeNewUserTemplateRelays(urls: string[]): string[] {
'wss://profiles.nostr1.com', 'wss://profiles.nostr1.com',
'wss://nos.lol', 'wss://nos.lol',
'wss://relay.primal.net', 'wss://relay.primal.net',
'wss://relay.damus.io',
'wss://thecitadel.nostr1.com' 'wss://thecitadel.nostr1.com'
] ]
const byKey = new Map(urls.map((u) => [templateRelayKey(u), u])) const byKey = new Map(urls.map((u) => [templateRelayKey(u), u]))

3
src/lib/new-user-template.ts

@ -14,7 +14,6 @@ import {
createProfileDraftEvent, createProfileDraftEvent,
createRelayListDraftEvent createRelayListDraftEvent
} from '@/lib/draft-event' } from '@/lib/draft-event'
import { dedupeNormalizeRelayUrlsOrdered } from '@/lib/relay-url-priority'
import { TDraftEvent, TMailboxRelay } from '@/types' import { TDraftEvent, TMailboxRelay } from '@/types'
export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/' export const NEW_USER_HTTP_RELAY_URL = 'https://mercury-relay.imwald.eu/'
@ -76,7 +75,7 @@ export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent { export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent( return createFavoriteRelaysDraftEvent(
dedupeNormalizeRelayUrlsOrdered([...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL]), [...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL],
[] []
) )
} }

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

@ -20,13 +20,11 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
entries, entries,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
committedSearch,
searchAxis,
commitSearch,
showOnlyMine, showOnlyMine,
setShowOnlyMine, setShowOnlyMine,
mineFilterLoading, mineFilterLoading,
loading, loading,
engagementLoading,
searchLoading, searchLoading,
relaySearchLoading, relaySearchLoading,
error, error,
@ -70,9 +68,6 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
<LibrarySearchBar <LibrarySearchBar
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery} onSearchQueryChange={setSearchQuery}
committedSearch={committedSearch}
searchAxis={searchAxis}
onCommitSearch={commitSearch}
showOnlyMine={showOnlyMine} showOnlyMine={showOnlyMine}
onShowOnlyMineChange={setShowOnlyMine} onShowOnlyMineChange={setShowOnlyMine}
mineFilterLoading={mineFilterLoading} mineFilterLoading={mineFilterLoading}
@ -88,6 +83,8 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
) : null} ) : null}
{loading ? ( {loading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library loading')}</p>
) : engagementLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library engagement loading')}</p>
) : searchLoading ? ( ) : searchLoading ? (
<p className="mb-4 text-xs text-muted-foreground">{t('Library search loading')}</p> <p className="mb-4 text-xs text-muted-foreground">{t('Library search loading')}</p>
) : mineFilterLoading ? ( ) : mineFilterLoading ? (
@ -105,7 +102,7 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
(showOnlyMine && mineFilterLoading) (showOnlyMine && mineFilterLoading)
} }
emptyMessage={ emptyMessage={
committedSearch.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty')
} }
/> />
{defaultFeedHasMore ? ( {defaultFeedHasMore ? (

14
src/providers/NostrProvider/nip-07.signer.ts

@ -1,11 +1,6 @@
import { pubkeyFromNip07Extension } from '@/lib/pubkey' import { pubkeyFromNip07Extension } from '@/lib/pubkey'
import { ISigner, TDraftEvent, TNip07 } from '@/types' import { ISigner, TDraftEvent, TNip07 } from '@/types'
/** Poll interval while waiting for a NIP-07 extension to inject `window.nostr`. */
export const NIP07_INJECT_CHECK_INTERVAL_MS = 100
/** Some mobile browsers inject the extension API well after first paint. */
export const NIP07_INJECT_MAX_ATTEMPTS = 120
/** Fresh extension pubkey (hex), after init + optional enable. */ /** Fresh extension pubkey (hex), after init + optional enable. */
export async function getExtensionPubkeyHex(): Promise<string> { export async function getExtensionPubkeyHex(): Promise<string> {
const signer = new Nip07Signer() const signer = new Nip07Signer()
@ -27,7 +22,12 @@ export class Nip07Signer implements ISigner {
private pubkey: string | null = null private pubkey: string | null = null
async init() { async init() {
for (let attempt = 0; attempt < NIP07_INJECT_MAX_ATTEMPTS; attempt++) { const checkInterval = 100
// Some browser extensions inject `window.nostr` a bit later during startup/reload.
// Keep waiting longer to avoid false "no signer extension" failures on session restore.
const maxAttempts = 120
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (window.nostr) { if (window.nostr) {
this.signer = window.nostr this.signer = window.nostr
if (typeof this.signer.enable === 'function') { if (typeof this.signer.enable === 'function') {
@ -35,7 +35,7 @@ export class Nip07Signer implements ISigner {
} }
return return
} }
await new Promise((resolve) => setTimeout(resolve, NIP07_INJECT_CHECK_INTERVAL_MS)) await new Promise((resolve) => setTimeout(resolve, checkInterval))
} }
throw new Error( throw new Error(

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

@ -1,11 +1,7 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
/** Legacy object store names removed in DB migrations (do not re-add to {@link StoreNames}). */ /** Legacy object store names removed in DB migrations (do not re-add to {@link StoreNames}). */
const LEGACY_DELETED_OBJECT_STORES = [ const LEGACY_DELETED_OBJECT_STORES = ['relayInfoEvents', 'spellListSourceEvents'] as const
'relayInfoEvents',
'spellListSourceEvents',
'libraryPublicationIndex'
] as const
import { import {
publicationCoordinateLookupKeys, publicationCoordinateLookupKeys,
splitPublicationCoordinate splitPublicationCoordinate
@ -32,7 +28,9 @@ import logger from '@/lib/logger'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search' import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { import {
eventTagAddress,
isStructuralPublicationIndex, isStructuralPublicationIndex,
isVerifiedPublicationIndex,
pickNewerPublicationIndexEvent, pickNewerPublicationIndexEvent,
type PublicationIndexMap type PublicationIndexMap
} from '@/lib/publication-index' } from '@/lib/publication-index'
@ -73,12 +71,6 @@ type TValue<T = any> = {
value: T | null value: T | null
addedAt: number addedAt: number
masterPublicationKey?: string // For nested publication events, link to master publication masterPublicationKey?: string // For nested publication events, link to master publication
/** 1 when `value` is a kind-30040 library catalog master (indexed for Library tab). */
catalogMaster?: 0 | 1
/** LRU touch time for catalog masters in {@link StoreNames.PUBLICATION_EVENTS}. */
lastAccessAt?: number
/** Approximate JSON size for catalog-master LRU pruning. */
catalogBytes?: number
} }
/** One matching row from {@link IndexedDbService.searchAllCachedEventsFullText}. */ /** One matching row from {@link IndexedDbService.searchAllCachedEventsFullText}. */
@ -158,6 +150,7 @@ export const StoreNames = {
/** 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 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). */
@ -189,6 +182,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,
@ -243,7 +237,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 = 43 const DB_VERSION = 41
/** 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
@ -259,7 +253,7 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]):
return new Error(message) return new Error(message)
} }
type TLegacyLibraryPublicationIndexRow = { type TLibraryPublicationIndexCacheRow = {
key: string key: string
value: Event value: Event
addedAt: number addedAt: number
@ -267,7 +261,7 @@ type TLegacyLibraryPublicationIndexRow = {
approxBytes: number approxBytes: number
} }
function approxPublicationCatalogMasterBytes(ev: Event): number { function approxLibraryPublicationIndexRowBytes(ev: Event): number {
try { try {
return new Blob([JSON.stringify(ev)]).size return new Blob([JSON.stringify(ev)]).size
} catch { } catch {
@ -275,125 +269,53 @@ function approxPublicationCatalogMasterBytes(ev: Event): number {
} }
} }
function publicationStoreRowKeyForEvent(event: Event): string { /** v41: re-key library index rows from event id to kind:pubkey:d; dedupe by address. */
const [, d] = event.tags.find(tagNameEquals('d')) ?? [] function migrateLibraryPublicationIndexCacheToAddressKeys(transaction: IDBTransaction): void {
const trimmed = event.pubkey.trim() const store = transaction.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX)
const canonPk = /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed const rows: TLibraryPublicationIndexCacheRow[] = []
return d === undefined ? canonPk : `${canonPk}:${d}`
}
function buildPublicationStoreRow(
key: string,
event: Event,
prev: TValue<Event> | undefined,
masterPublicationKey?: string
): TValue<Event> {
const now = Date.now()
const isCatalogMaster = event.kind === ExtendedKind.PUBLICATION && !masterPublicationKey
const row: TValue<Event> = {
key,
value: event,
addedAt: prev?.addedAt ?? now,
...(masterPublicationKey ? { masterPublicationKey } : {})
}
if (isCatalogMaster) {
row.catalogMaster = 1
row.lastAccessAt = Math.max(prev?.lastAccessAt ?? 0, now)
row.catalogBytes = approxPublicationCatalogMasterBytes(event)
} else if (storeRowIsPublicationEvent(event)) {
row.catalogMaster = 0
}
return row
}
function storeRowIsPublicationEvent(event: Event): boolean {
return (
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.NOSTR_SPECIFICATION ||
event.kind === kinds.LongFormArticle
)
}
function ensurePublicationEventsCatalogIndexes(store: IDBObjectStore): void {
if (!store.indexNames.contains('catalogMaster')) {
store.createIndex('catalogMaster', 'catalogMaster', { unique: false })
}
}
function backfillPublicationCatalogMetadata(store: IDBObjectStore): void {
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) return
const row = cursor.value as TValue<Event>
const event = row?.value
if (!event || !storeRowIsPublicationEvent(event)) {
cursor.continue()
return
}
const isCatalogMaster = event.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey
const next: TValue<Event> = { ...row }
if (isCatalogMaster) {
next.catalogMaster = 1
next.lastAccessAt = row.lastAccessAt ?? row.addedAt ?? Date.now()
next.catalogBytes = row.catalogBytes ?? approxPublicationCatalogMasterBytes(event)
} else {
next.catalogMaster = 0
}
if (
next.catalogMaster !== row.catalogMaster ||
next.lastAccessAt !== row.lastAccessAt ||
next.catalogBytes !== row.catalogBytes
) {
const updateReq = cursor.update(next)
updateReq.onsuccess = () => cursor.continue()
updateReq.onerror = () => cursor.continue()
} else {
cursor.continue()
}
}
}
/** v43: merge legacy libraryPublicationIndex into publicationEvents; catalogMaster index. */ const readReq = store.openCursor()
function migrateLegacyLibraryPublicationIndexStore(transaction: IDBTransaction, db: IDBDatabase): void {
const legacyName = 'libraryPublicationIndex'
if (!db.objectStoreNames.contains(legacyName) || !db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return
}
const legacyStore = transaction.objectStore(legacyName)
const pubStore = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const legacyRows: TLegacyLibraryPublicationIndexRow[] = []
const readReq = legacyStore.openCursor()
readReq.onsuccess = () => { readReq.onsuccess = () => {
const cursor = readReq.result as IDBCursorWithValue | null const cursor = readReq.result
if (cursor) { if (cursor) {
legacyRows.push(cursor.value as TLegacyLibraryPublicationIndexRow) rows.push(cursor.value as TLibraryPublicationIndexCacheRow)
cursor.continue() cursor.continue()
return return
} }
for (const row of legacyRows) { const byAddress = new Map<string, TLibraryPublicationIndexCacheRow>()
for (const row of rows) {
const ev = row?.value const ev = row?.value
if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
const key = publicationStoreRowKeyForEvent(ev) const addr = eventTagAddress(ev)
const getReq = pubStore.get(key) if (!addr) continue
getReq.onsuccess = () => {
const prev = getReq.result as TValue<Event> | undefined const existing = byAddress.get(addr)
const winner = if (!existing) {
prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id byAddress.set(addr, {
? prev.value key: addr,
: ev value: ev,
const merged: TValue<Event> = buildPublicationStoreRow(key, winner, prev) addedAt: row.addedAt ?? Date.now(),
merged.addedAt = Math.min(prev?.addedAt ?? row.addedAt ?? Date.now(), row.addedAt ?? Date.now()) lastAccessAt: row.lastAccessAt ?? row.addedAt ?? Date.now(),
merged.lastAccessAt = Math.max( approxBytes: row.approxBytes ?? approxLibraryPublicationIndexRowBytes(ev)
prev?.lastAccessAt ?? 0, })
row.lastAccessAt ?? row.addedAt ?? 0 continue
) }
merged.catalogBytes = approxPublicationCatalogMasterBytes(winner)
pubStore.put(merged) const winner = pickNewerPublicationIndexEvent(existing.value, ev)
byAddress.set(addr, {
key: addr,
value: winner,
addedAt: Math.min(existing.addedAt, row.addedAt ?? existing.addedAt),
lastAccessAt: Math.max(existing.lastAccessAt, row.lastAccessAt ?? row.addedAt ?? 0),
approxBytes: approxLibraryPublicationIndexRowBytes(winner)
})
}
const clearReq = store.clear()
clearReq.onsuccess = () => {
for (const row of byAddress.values()) {
store.put(row)
} }
} }
} }
@ -425,11 +347,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.PUBLICATION_EVENTS) { } else if (storeName === StoreNames.LIBRARY_PUBLICATION_INDEX) {
const store = db.createObjectStore(storeName, { keyPath: 'key' }) const lib = db.createObjectStore(storeName, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(store) lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
} else if (storeName === 'libraryPublicationIndex') {
/* dropped in v43 — do not recreate */
} else { } else {
db.createObjectStore(storeName, { keyPath: 'key' }) db.createObjectStore(storeName, { keyPath: 'key' })
} }
@ -594,8 +514,7 @@ class IndexedDbService {
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' }) db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
} }
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
const pub = db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(pub)
} }
if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) { if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) {
db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' }) db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' })
@ -672,29 +591,15 @@ class IndexedDbService {
} }
} }
if (event.oldVersion < 40) { if (event.oldVersion < 40) {
if (!db.objectStoreNames.contains('libraryPublicationIndex')) { if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
const lib = db.createObjectStore('libraryPublicationIndex', { keyPath: 'key' }) const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false }) lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
} }
} }
if (event.oldVersion < 41) { if (event.oldVersion < 41) {
const tx = (event.target as IDBOpenDBRequest).transaction const tx = (event.target as IDBOpenDBRequest).transaction
if (tx && db.objectStoreNames.contains('libraryPublicationIndex')) { if (tx && db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
// v41 migration superseded by v43 consolidation into publicationEvents migrateLibraryPublicationIndexCacheToAddressKeys(tx)
}
}
if (event.oldVersion < 43) {
const tx = (event.target as IDBOpenDBRequest).transaction
if (tx && db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
const pubStore = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
ensurePublicationEventsCatalogIndexes(pubStore)
backfillPublicationCatalogMetadata(pubStore)
}
if (tx) {
migrateLegacyLibraryPublicationIndexStore(tx, db)
}
if (db.objectStoreNames.contains('libraryPublicationIndex')) {
db.deleteObjectStore('libraryPublicationIndex')
} }
} }
ensureMissingObjectStores(db) ensureMissingObjectStores(db)
@ -785,11 +690,7 @@ class IndexedDbService {
return resolve(oldValue.value) return resolve(oldValue.value)
} }
const putRequest = store.put( const putRequest = store.put(this.formatValue(key, cleanEvent))
storeName === StoreNames.PUBLICATION_EVENTS
? buildPublicationStoreRow(key, cleanEvent, oldValue)
: this.formatValue(key, cleanEvent)
)
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
transaction.commit() transaction.commit()
resolve(cleanEvent) resolve(cleanEvent)
@ -1484,14 +1385,16 @@ class IndexedDbService {
if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) { if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
// Update master key link even if event is not newer // Update master key link even if event is not newer
if (oldValue.masterPublicationKey !== masterKey) { if (oldValue.masterPublicationKey !== masterKey) {
const value = buildPublicationStoreRow(key, oldValue.value, oldValue, masterKey) const value = this.formatValue(key, oldValue.value)
value.masterPublicationKey = masterKey
store.put(value) store.put(value)
} }
transaction.commit() transaction.commit()
return resolve(oldValue.value) return resolve(oldValue.value)
} }
// Store with master key link // Store with master key link
const value = buildPublicationStoreRow(key, cleanEvent, oldValue, masterKey) const value = this.formatValue(key, cleanEvent)
value.masterPublicationKey = masterKey
const putRequest = store.put(value) const putRequest = store.put(value)
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
transaction.commit() transaction.commit()
@ -1539,7 +1442,8 @@ class IndexedDbService {
// For non-replaceable events, use event ID as key // For non-replaceable events, use event ID as key
const key = event.id const key = event.id
// For non-replaceable events, always update with master key link // For non-replaceable events, always update with master key link
const value = buildPublicationStoreRow(key, event, undefined, masterKey) const value = this.formatValue(key, event)
value.masterPublicationKey = masterKey
const putRequest = store.put(value) const putRequest = store.put(value)
putRequest.onsuccess = () => { putRequest.onsuccess = () => {
transaction.commit() transaction.commit()
@ -2176,7 +2080,7 @@ class IndexedDbService {
storeInfo[storeName] = req.result storeInfo[storeName] = req.result
pending-- pending--
if (pending === 0) { if (pending === 0) {
void this.normalizePublicationStoreInfoCount(storeInfo).then(resolve).catch(reject) resolve(storeInfo)
} }
} }
req.onerror = (ev) => { req.onerror = (ev) => {
@ -2186,116 +2090,6 @@ class IndexedDbService {
}) })
} }
/** Master kind-30040 catalog rows in {@link StoreNames.PUBLICATION_EVENTS}. */
async countPublicationStoreMasterEvents(): Promise<number> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return 0
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
if (store.indexNames.contains('catalogMaster')) {
const req = store.index('catalogMaster').count(IDBKeyRange.only(1))
req.onsuccess = () => {
tx.commit()
resolve(req.result)
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
return
}
const req = store.openCursor()
let count = 0
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(count)
return
}
const item = cursor.value as TValue<Event> | undefined
if (item?.catalogMaster === 1 || (item?.value?.kind === ExtendedKind.PUBLICATION && !item.masterPublicationKey)) {
count += 1
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
/** Kind-30040 library catalog masters in {@link StoreNames.PUBLICATION_EVENTS}. */
async getPublicationCatalogIndexEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
const out: Event[] = []
const collectRow = (row: TValue<Event> | undefined) => {
const event = row?.value
if (!event || event.kind !== ExtendedKind.PUBLICATION) return
if (row?.catalogMaster === 1 || !row.masterPublicationKey) out.push(event)
}
if (store.indexNames.contains('catalogMaster')) {
const req = store.index('catalogMaster').openCursor(IDBKeyRange.only(1))
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
collectRow(cursor.value as TValue<Event>)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
return
}
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
collectRow(cursor.value as TValue<Event>)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
/** @deprecated Use {@link getPublicationCatalogIndexEvents}. */
async getMasterPublicationIndexEventsFromReadingCache(): Promise<Event[]> {
return this.getPublicationCatalogIndexEvents()
}
private async normalizePublicationStoreInfoCount(storeInfo: Record<string, number>): Promise<Record<string, number>> {
if (!storeInfo[StoreNames.PUBLICATION_EVENTS]) return storeInfo
try {
storeInfo[StoreNames.PUBLICATION_EVENTS] = await this.countPublicationStoreMasterEvents()
} catch (e) {
logger.warn('[indexedDb] countPublicationStoreMasterEvents failed', { e })
}
return storeInfo
}
async getStoreItems(storeName: string): Promise<TValue<any>[]> { async getStoreItems(storeName: string): Promise<TValue<any>[]> {
await this.initPromise await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) { if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
@ -2493,27 +2287,13 @@ class IndexedDbService {
return { deleted: 0, kept: 0 } return { deleted: 0, kept: 0 }
} }
if (storeName === StoreNames.PUBLICATION_EVENTS) {
return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) =>
this.getReplaceableEventKeyFromEvent(event)
)
}
// Get the kind for this store - only clean up replaceable event stores // Get the kind for this store - only clean up replaceable event stores
const kind = this.getKindByStoreName(storeName) const kind = this.getKindByStoreName(storeName)
if (!kind || !this.isReplaceableEventKind(kind)) { if (!kind || !this.isReplaceableEventKind(kind)) {
return Promise.reject(new Error('Not a replaceable event store')) return Promise.reject('Not a replaceable event store')
} }
return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) => // First pass: identify duplicates
this.getReplaceableEventKeyFromEvent(event)
)
}
private async cleanupDuplicateEventsByDedupeKey(
storeName: string,
dedupeKeyForEvent: (event: Event) => string
): Promise<{ deleted: number; kept: number }> {
const allItems = await this.getStoreItems(storeName) const allItems = await this.getStoreItems(storeName)
const eventMap = new Map<string, { key: string; event: Event; addedAt: number }>() const eventMap = new Map<string, { key: string; event: Event; addedAt: number }>()
const keysToDelete: string[] = [] const keysToDelete: string[] = []
@ -2525,41 +2305,47 @@ class IndexedDbService {
continue continue
} }
// Skip if event doesn't have required fields
if (!item.value.pubkey || !item.value.kind || !item.value.created_at) { if (!item.value.pubkey || !item.value.kind || !item.value.created_at) {
invalidItemsCount++ invalidItemsCount++
continue continue
} }
try { try {
const dedupeKey = dedupeKeyForEvent(item.value) const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value)
const existing = eventMap.get(dedupeKey) const existing = eventMap.get(replaceableKey)
if ( if (!existing ||
!existing ||
item.value.created_at > existing.event.created_at || item.value.created_at > existing.event.created_at ||
(item.value.created_at === existing.event.created_at && item.addedAt > existing.addedAt) (item.value.created_at === existing.event.created_at &&
) { item.addedAt > existing.addedAt)) {
// This event is newer, mark the old one for deletion if it exists
if (existing) { if (existing) {
keysToDelete.push(existing.key) keysToDelete.push(existing.key)
} }
eventMap.set(dedupeKey, { eventMap.set(replaceableKey, {
key: item.key, key: item.key,
event: item.value, event: item.value,
addedAt: item.addedAt addedAt: item.addedAt
}) })
} else { } else {
// This event is older or same, mark it for deletion
keysToDelete.push(item.key) keysToDelete.push(item.key)
} }
} catch (error) { } catch (error) {
logger.warn('Failed to get dedupe key for item', { key: item.key, error }) // If we can't generate a replaceable key, skip this item
logger.warn('Failed to get replaceable key for item', { key: item.key, error })
invalidItemsCount++ invalidItemsCount++
continue
} }
} }
// Second pass: delete duplicates
const totalProcessed = eventMap.size + keysToDelete.length
const actualKept = eventMap.size const actualKept = eventMap.size
if (keysToDelete.length === 0) { if (keysToDelete.length === 0) {
const totalProcessed = eventMap.size + keysToDelete.length // No duplicates found, but verify counts match
if (totalProcessed + invalidItemsCount !== allItems.length) { if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch while cleaning up replaceable events', { logger.warn('Count mismatch while cleaning up replaceable events', {
totalItems: allItems.length, totalItems: allItems.length,
@ -2567,7 +2353,7 @@ class IndexedDbService {
invalid: invalidItemsCount invalid: invalidItemsCount
}) })
} }
return { deleted: 0, kept: actualKept } return Promise.resolve({ deleted: 0, kept: actualKept })
} }
return new Promise((resolve) => { return new Promise((resolve) => {
@ -2577,13 +2363,14 @@ class IndexedDbService {
let deletedCount = 0 let deletedCount = 0
let completedCount = 0 let completedCount = 0
keysToDelete.forEach((key) => { keysToDelete.forEach(key => {
const deleteRequest = store.delete(key) const deleteRequest = store.delete(key)
deleteRequest.onsuccess = () => { deleteRequest.onsuccess = () => {
deletedCount++ deletedCount++
completedCount++ completedCount++
if (completedCount === keysToDelete.length) { if (completedCount === keysToDelete.length) {
transaction.commit() transaction.commit()
const actualKept = eventMap.size
const totalProcessed = actualKept + deletedCount const totalProcessed = actualKept + deletedCount
if (totalProcessed + invalidItemsCount !== allItems.length) { if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch after deletion', { logger.warn('Count mismatch after deletion', {
@ -2600,6 +2387,7 @@ class IndexedDbService {
completedCount++ completedCount++
if (completedCount === keysToDelete.length) { if (completedCount === keysToDelete.length) {
transaction.commit() transaction.commit()
const actualKept = eventMap.size
resolve({ deleted: deletedCount, kept: actualKept }) resolve({ deleted: deletedCount, kept: actualKept })
} }
} }
@ -3968,19 +3756,22 @@ class IndexedDbService {
} }
} }
private async listPublicationCatalogMasterRows(): Promise< private approxLibraryPublicationIndexBytes(ev: Event): number {
Array<{ key: string; lastAccessAt: number; bytes: number; hasNested: boolean }> try {
> { return new Blob([JSON.stringify(ev)]).size
await this.initPromise } catch {
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return [] return 2048
}
}
const nestedMasterKeys = new Set<string>() async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
const masters: Array<{ key: string; lastAccessAt: number; bytes: number }> = [] await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0
const toDelete: string[] = []
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS) const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
const req = store.openCursor()
req.onsuccess = () => { req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null const cursor = req.result as IDBCursorWithValue | null
if (!cursor) { if (!cursor) {
@ -3988,20 +3779,80 @@ class IndexedDbService {
resolve() resolve()
return return
} }
const row = cursor.value as TValue<Event> const row = cursor.value as TLibraryPublicationIndexCacheRow
const key = cursor.key as string if (row?.value?.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(row.value)) {
if (row?.masterPublicationKey) { toDelete.push(cursor.key as string)
nestedMasterKeys.add(row.masterPublicationKey) }
} else if ( cursor.continue()
row?.catalogMaster === 1 || }
(row?.value?.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey) req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
return toDelete.length
}
/** True when any row is keyed by event id instead of kind:pubkey:d address. */
async libraryPublicationIndexCacheHasLegacyKeys(): Promise<boolean> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return false
return new Promise((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(false)
return
}
const rowKey = cursor.key as string
const row = cursor.value as TLibraryPublicationIndexCacheRow
const ev = row?.value
const addr = ev ? eventTagAddress(ev) : null
if (
!ev ||
ev.kind !== ExtendedKind.PUBLICATION ||
!isStructuralPublicationIndex(ev) ||
!addr ||
rowKey !== addr
) { ) {
masters.push({ tx.commit()
key, resolve(true)
lastAccessAt: row.lastAccessAt ?? row.addedAt, return
bytes: row.catalogBytes ?? approxPublicationCatalogMasterBytes(row.value as Event) }
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
}) })
} }
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() cursor.continue()
} }
req.onerror = (e) => { req.onerror = (e) => {
@ -4009,94 +3860,215 @@ class IndexedDbService {
reject(idbEventToError(e)) reject(idbEventToError(e))
} }
}) })
}
return masters.map((row) => ({ ...row, hasNested: nestedMasterKeys.has(row.key) })) async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
return { count: 0, bytes: 0 }
} }
async getPublicationCatalogFootprint(): Promise<{ count: number; bytes: number }> { return new Promise((resolve, reject) => {
const rows = await this.listPublicationCatalogMasterRows() const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
return { const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
count: rows.length, let count = 0
bytes: rows.reduce((sum, row) => sum + row.bytes, 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 mergePublicationCatalogIndexEvents( async mergeLibraryPublicationIndexCacheEvents(
events: Event[], events: Event[],
opts: { maxEntries: number; maxBytes: number } opts: { maxEntries: number; maxBytes: number }
): Promise<void> { ): 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
const rowsToWrite: Array<{ key: string; event: Event }> = []
for (const ev of events) { for (const ev of events) {
if (ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue if (ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
await this.putReplaceableEvent(ev) const key = eventTagAddress(ev)
if (!key) continue
const existing = rowsToWrite.find((row) => row.key === key)
if (!existing) {
rowsToWrite.push({ key, event: ev })
continue
} }
await this.prunePublicationCatalogMasters(opts.maxEntries, opts.maxBytes) existing.event = pickNewerPublicationIndexEvent(existing.event, ev)
} }
async prunePublicationCatalogMasters(maxEntries: number, maxBytes: number): Promise<void> { if (rowsToWrite.length === 0) return
const rows = await this.listPublicationCatalogMasterRows()
const candidates = rows
.filter((r) => !r.hasNested)
.sort((a, b) => a.lastAccessAt - b.lastAccessAt)
let totalBytes = rows.reduce((s, r) => s + r.bytes, 0)
let totalCount = rows.length
const toDelete = new Set<string>()
for (const victim of candidates) { await new Promise<void>((resolve, reject) => {
if (totalCount <= maxEntries && totalBytes <= maxBytes) break const tx = this.db!.transaction(storeName, 'readwrite')
toDelete.add(victim.key) const store = tx.objectStore(storeName)
totalBytes -= victim.bytes let pending = rowsToWrite.length
totalCount -= 1
}
for (const key of toDelete) { const finishOne = () => {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key) pending -= 1
if (pending === 0) {
tx.commit()
resolve()
} }
} }
/** Remove relay-discovered catalog masters that were never opened (no nested sections cached). */ for (const { key, event: ev } of rowsToWrite) {
async clearPublicationCatalogDiscoveryOnly(): Promise<void> { const get = store.get(key)
const rows = await this.listPublicationCatalogMasterRows() get.onsuccess = () => {
for (const row of rows) { const prev = get.result as TLibraryPublicationIndexCacheRow | undefined
if (!row.hasNested) { if (prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id) {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, row.key) finishOne()
return
}
const row: TLibraryPublicationIndexCacheRow = {
key,
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))
} }
} }
})
/** @deprecated Use {@link getPublicationCatalogFootprint}. */ await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes)
async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
return this.getPublicationCatalogFootprint()
} }
/** @deprecated Use {@link getPublicationCatalogIndexEvents}. */ /** Drop invalid, legacy id-keyed, and superseded rows after an address-keyed merge. */
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> { async reconcileLibraryPublicationIndexCache(canonical: PublicationIndexMap): Promise<void> {
return this.getPublicationCatalogIndexEvents() await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
const toDelete: string[] = []
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 rowKey = cursor.key as string
const row = cursor.value as TLibraryPublicationIndexCacheRow
const ev = row?.value
const addr = ev ? eventTagAddress(ev) : null
const canon = addr ? canonical.get(addr) : undefined
const invalid =
!ev ||
ev.kind !== ExtendedKind.PUBLICATION ||
!isStructuralPublicationIndex(ev) ||
!addr ||
rowKey !== addr
const superseded = Boolean(canon && canon.id !== ev?.id)
if (invalid || superseded) {
toDelete.push(rowKey)
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
} }
})
/** @deprecated Use {@link mergePublicationCatalogIndexEvents}. */ for (const key of toDelete) {
async mergeLibraryPublicationIndexCacheEvents( await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
events: Event[], }
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
return this.mergePublicationCatalogIndexEvents(events, opts)
} }
/** @deprecated Use {@link clearPublicationCatalogDiscoveryOnly}. */ async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise<void> {
async clearLibraryPublicationIndexCacheStore(): Promise<void> { await this.initPromise
return this.clearPublicationCatalogDiscoveryOnly() 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))
} }
})
/** @deprecated No-op after v43 consolidation. */ rows.sort((a, b) => a.lastAccessAt - b.lastAccessAt)
async reconcileLibraryPublicationIndexCache(_canonical: PublicationIndexMap): Promise<void> {} 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
}
/** @deprecated No-op after v43 consolidation. */ for (const key of toDelete) {
async libraryPublicationIndexCacheHasLegacyKeys(): Promise<boolean> { await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
return false }
} }
/** @deprecated No-op after v43 consolidation. */ async clearLibraryPublicationIndexCacheStore(): Promise<void> {
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> { await this.initPromise
return 0 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))
}
})
} }
/** /**

2
src/services/nip89.service.ts

@ -237,7 +237,7 @@ class Nip89Service {
desktop: 'imwald://note/bech32' desktop: 'imwald://note/bech32'
}, },
relays: [ relays: [
'wss://thecitadel.nostr1.com', 'wss://relay.damus.io',
'wss://relay.snort.social', 'wss://relay.snort.social',
'wss://nos.lol' 'wss://nos.lol'
] ]

Loading…
Cancel
Save