Compare commits

..

6 Commits

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

4
package-lock.json generated

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

2
package.json

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

4
src/components/AccountManager/index.tsx

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

40
src/components/CacheBrowser/CacheBrowserDialog.tsx

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

5
src/components/Embedded/EmbeddedNote.tsx

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

75
src/components/InBrowserCacheSetting/index.tsx

@ -1,4 +1,8 @@ @@ -1,4 +1,8 @@
import { Button } from '@/components/ui/button'
import {
clearAppServiceWorkerAndCaches,
refreshAppBrowserCache
} from '@/lib/app-cache-maintenance'
import { clearConsoleLogBuffer } from '@/lib/console-log-buffer'
import { useConsoleLogBuffer } from '@/hooks/useConsoleLogBuffer'
import logger from '@/lib/logger'
@ -15,7 +19,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f @@ -15,7 +19,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { useCacheBrowser } from '../../contexts/cache-browser-context'
export default function InBrowserCacheSetting() {
@ -93,11 +96,11 @@ export default function InBrowserCacheSetting() { @@ -93,11 +96,11 @@ export default function InBrowserCacheSetting() {
const handleRefreshCache = async () => {
try {
setCacheRefreshBusy(true)
await indexedDb.forceDatabaseUpgrade()
if (pubkey) {
await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, relayList)
}
await refreshAppBrowserCache({
pubkey,
relayList,
requestAccountNetworkHydrate
})
toast.success(t('Cache refreshed successfully'))
} catch (error) {
logger.error('Failed to refresh cache', { error })
@ -113,65 +116,7 @@ export default function InBrowserCacheSetting() { @@ -113,65 +116,7 @@ export default function InBrowserCacheSetting() {
}
try {
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 })
}
}
const { unregisteredCount, cacheClearedCount } = await clearAppServiceWorkerAndCaches()
if (unregisteredCount > 0 || cacheClearedCount > 0) {
const message = unregisteredCount > 0 && cacheClearedCount > 0

290
src/components/Library/LibrarySearchBar.tsx

@ -1,13 +1,32 @@ @@ -1,13 +1,32 @@
import SearchInput from '@/components/SearchInput'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Loader2, Search, Wifi } from 'lucide-react'
import { normalizeToDTag } from '@/lib/search-parser'
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'
type LibrarySearchOption = {
axis: LibraryPublicationRelaySearchAxis | null
search: string
input?: string
}
export default function LibrarySearchBar({
searchQuery,
onSearchQueryChange,
committedSearch,
searchAxis,
onCommitSearch,
showOnlyMine,
onShowOnlyMineChange,
mineFilterLoading,
@ -17,6 +36,9 @@ export default function LibrarySearchBar({ @@ -17,6 +36,9 @@ export default function LibrarySearchBar({
}: {
searchQuery: string
onSearchQueryChange: (value: string) => void
committedSearch: string
searchAxis: LibraryPublicationRelaySearchAxis | null
onCommitSearch: (query: string, axis: LibraryPublicationRelaySearchAxis | null) => void
showOnlyMine: boolean
onShowOnlyMineChange: (value: boolean) => void
mineFilterLoading?: boolean
@ -25,22 +47,166 @@ export default function LibrarySearchBar({ @@ -25,22 +47,166 @@ export default function LibrarySearchBar({
disabled?: boolean
}) {
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 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 (
<div className="space-y-3">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
{displayList && list ? (
<div
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"
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onChange={(e) => {
setSearching(true)
setSelectedIndex(-1)
onSearchQueryChange(e.target.value)
}}
onPaste={() => setSearching(true)}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
placeholder={t('Library search placeholder')}
className="pl-9"
className={cn('bg-surface-background pl-3', displayList && 'z-50')}
disabled={disabled}
aria-label={t('Library search placeholder')}
/>
</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 ? (
<Button
type="button"
@ -75,3 +241,115 @@ export default function LibrarySearchBar({ @@ -75,3 +241,115 @@ export default function LibrarySearchBar({
</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,11 +1,13 @@ @@ -1,11 +1,13 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { refreshAppBrowserCacheAndClearServiceWorker } from '@/lib/app-cache-maintenance'
import logger from '@/lib/logger'
import {
getPwaApplyUpdate,
initPwaUpdate,
probePwaWaitingWorker,
subscribePwaNeedRefresh
} from '@/lib/pwa-update'
import { useNostrOptional } from '@/providers/nostr-context'
import { RefreshCw, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -21,6 +23,7 @@ function readVersionUpdateDismissed(): boolean { @@ -21,6 +23,7 @@ function readVersionUpdateDismissed(): boolean {
export default function VersionUpdateBanner() {
const { t } = useTranslation()
const nostr = useNostrOptional()
const [updateAvailable, setUpdateAvailable] = useState(false)
const [isDismissed, setIsDismissed] = useState(readVersionUpdateDismissed)
const [isUpdating, setIsUpdating] = useState(false)
@ -65,16 +68,18 @@ export default function VersionUpdateBanner() { @@ -65,16 +68,18 @@ export default function VersionUpdateBanner() {
setIsDismissed(true)
setIsUpdating(true)
const reload = () => {
window.location.reload()
}
const apply = getPwaApplyUpdate()
if (apply) {
void apply().catch(reload)
return
void (async () => {
try {
await refreshAppBrowserCacheAndClearServiceWorker({
pubkey: nostr?.pubkey,
relayList: nostr?.relayList,
requestAccountNetworkHydrate: nostr?.requestAccountNetworkHydrate
})
} catch (error) {
logger.warn('[VersionUpdateBanner] Pre-update cache refresh failed', { error })
}
reload()
window.location.reload()
})()
}
const handleDismiss = () => {

2
src/constants.ts

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

93
src/hooks/useLibraryPublications.ts

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

34
src/hooks/useNip07ExtensionAvailable.ts

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
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,6 +1650,15 @@ export default { @@ -1650,6 +1650,15 @@ export default {
Library: 'Bibliothek',
'Library page title': 'Bibliothek',
'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 empty': 'Noch keine Publikationen auf deinen Relays gefunden.',
'Library empty filtered': 'Keine Publikationen entsprechen den Filtern.',

13
src/i18n/locales/en.ts

@ -1673,6 +1673,15 @@ export default { @@ -1673,6 +1673,15 @@ export default {
Library: 'Library',
'Library page title': 'Library',
'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 empty': 'No publications found on your relays yet.',
'Library empty filtered': 'No publications match your filters.',
@ -1708,7 +1717,7 @@ export default { @@ -1708,7 +1717,7 @@ export default {
'Read this book': 'Read this book',
'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb':
'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.',
'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.',
'libraryIndexCache.defaultsMobile':
'Default on mobile web: up to {{entries}} indexes, ~{{mb}} MB.',
'libraryIndexCache.defaultsElectron':
@ -1720,7 +1729,7 @@ export default { @@ -1720,7 +1729,7 @@ export default {
'libraryIndexCache.clear': 'Clear library index cache',
'libraryIndexCache.clearing': 'Clearing…',
'libraryIndexCache.clearConfirm':
'Clear the Library index cache? The Library tab will reload indexes from relays on next visit. Opened publications stay in your publication reading cache.',
'Clear relay-discovered library catalog entries? Opened publications and their sections stay in the publication cache.',
'libraryIndexCache.clearedToast': 'Library index cache cleared.',
'libraryIndexCache.clearFailed': 'Failed to clear library index cache.',
'Search page clear': 'Clear',

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

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

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

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

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

@ -3,16 +3,19 @@ import { ExtendedKind } from '@/constants' @@ -3,16 +3,19 @@ import { ExtendedKind } from '@/constants'
import {
buildEngagementMapsFromEvents,
buildLibraryPublicationRelaySearchFilters,
buildLibraryPublicationRelaySearchFiltersForAxis,
buildRecentPublicationEntries,
clearLibrarySearchSessionCache,
computeLibraryFeedRootOrder,
filterEngagedPublications,
filterEventsForPublicationRelaySearchAxis,
filterLibraryPublicationsBySearch,
filterLibraryPublicationsByUser,
libraryDefaultFeedSlice,
libraryPublicationEntriesForUserFromIndex,
LIBRARY_PAGE_SIZE,
pickLibraryPublicationEntries,
publicationMetadataTagMatchesQuery,
publicationRootBelongsToUser,
peekLibrarySearchResults,
publicationIndexMatchesSearchQuery,
@ -233,20 +236,99 @@ describe('library-publication-index', () => { @@ -233,20 +236,99 @@ describe('library-publication-index', () => {
expect(publicationIndexMatchesSearchQuery(root, 'missing')).toBe(false)
})
it('buildLibraryPublicationRelaySearchFilters uses kind 30040 for d-tag and search', () => {
it('buildLibraryPublicationRelaySearchFilters splits kind 30040 into d-tag, title, and author without NIP-50', () => {
expect(publicationQueryDTagVariants('Village Life in China')).toContain('village-life-in-china')
const filters = buildLibraryPublicationRelaySearchFilters({ query: 'Village Life in China' })
expect(filters.length).toBeGreaterThan(0)
expect(filters.every((f) => f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLICATION)).toBe(
true
)
const dTagFilters = buildLibraryPublicationRelaySearchFiltersForAxis('d-tag', {
query: 'Village Life in China'
})
expect(dTagFilters).toHaveLength(1)
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 dFilter = filters.find((f) => f['#d'])
expect(dFilter?.['#d']).toContain('village-life-in-china')
const byAuthor = await searchLibraryPublications('aristotle', { indexEvents, engagement }, 'author')
expect(byAuthor.map((e) => e.event.id)).toEqual([fromAuthor.id])
const searchFilter = filters.find((f) => f.search === 'Village Life in China')
expect(searchFilter?.kinds).toEqual([ExtendedKind.PUBLICATION])
expect(peekLibrarySearchResults('aristotle', { indexEvents, engagement }, 'author')).toHaveLength(1)
expect(peekLibrarySearchResults('aristotle', { indexEvents, engagement })).toHaveLength(2)
})
it('searchLibraryPublications caches results for repeated queries', async () => {

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

@ -8,11 +8,10 @@ import { @@ -8,11 +8,10 @@ import {
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger'
import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationMetadataSearch } from '@/lib/index-relay-http'
import {
buildIndexByAddress,
buildStructuralPublicationIndexMap,
collectPublicationIndexEventIds,
collectReachableAddressesCached,
eventTagAddress,
filterValidIndexEvents,
@ -55,7 +54,11 @@ const INDEX_MAX_PAGES_PER_RELAY = 100 @@ -55,7 +54,11 @@ const INDEX_MAX_PAGES_PER_RELAY = 100
const INDEX_VERIFY_CHUNK = 80
const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480
/** Cap engagement relay queries to the first slice of the catalog (not the full index corpus). */
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
export const LIBRARY_PAGE_SIZE = 120
/** @deprecated Use {@link LIBRARY_PAGE_SIZE} */
@ -63,6 +66,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE @@ -63,6 +66,8 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE
const LIBRARY_SEARCH_READING_CACHE_LIMIT = 200
export const LIBRARY_RELAY_SEARCH_LIMIT = 100
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). */
const PIN_LIST_KIND = 10001
/** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */
@ -198,8 +203,13 @@ type LibrarySearchSessionRow = { @@ -198,8 +203,13 @@ type LibrarySearchSessionRow = {
const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>()
function librarySearchQueryKey(query: string): string {
return normalizeGeneralSearchQuery(query).toLowerCase()
function librarySearchQueryKey(
query: string,
axis?: LibraryPublicationRelaySearchAxis | null
): string {
const base = normalizeGeneralSearchQuery(query).toLowerCase()
if (!axis) return base
return `${axis}:${base}`
}
function librarySearchFingerprint(context: LibrarySearchContext): string {
@ -230,9 +240,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string { @@ -230,9 +240,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
function getLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
opts?: { requireRelaySearch?: boolean }
opts?: { requireRelaySearch?: boolean; axis?: LibraryPublicationRelaySearchAxis | null }
): LibrarySearchSessionRow | null {
const key = librarySearchQueryKey(query)
const key = librarySearchQueryKey(query, opts?.axis)
if (!key) return null
const row = librarySearchSessionCache.get(key)
if (!row) return null
@ -244,9 +254,10 @@ function getLibrarySearchSessionRow( @@ -244,9 +254,10 @@ function getLibrarySearchSessionRow(
function putLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
row: Omit<LibrarySearchSessionRow, 'fingerprint'>
row: Omit<LibrarySearchSessionRow, 'fingerprint'>,
axis?: LibraryPublicationRelaySearchAxis | null
): void {
const key = librarySearchQueryKey(query)
const key = librarySearchQueryKey(query, axis)
if (!key) return
librarySearchSessionCache.set(key, {
...row,
@ -257,9 +268,10 @@ function putLibrarySearchSessionRow( @@ -257,9 +268,10 @@ function putLibrarySearchSessionRow(
/** Sync read of cached search hits for the current index + engagement snapshot. */
export function peekLibrarySearchResults(
query: string,
context: LibrarySearchContext
context: LibrarySearchContext,
axis?: LibraryPublicationRelaySearchAxis | null
): LibraryPublicationEntry[] | null {
return getLibrarySearchSessionRow(query, context)?.entries ?? null
return getLibrarySearchSessionRow(query, context, { axis })?.entries ?? null
}
export function clearLibrarySearchSessionCache(): void {
@ -909,8 +921,21 @@ export async function fetchPublicationEngagementMaps( @@ -909,8 +921,21 @@ export async function fetchPublicationEngagementMaps(
return emptyPublicationEngagementMaps()
}
const addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK)
const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK)
return withEngagementTimeout(
fetchPublicationEngagementMapsInner(relayUrls, targetAddresses, targetEventIds, options),
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 useWsEngagement = wsRelays.length > 0
if (import.meta.env.DEV) {
@ -1563,8 +1588,10 @@ export async function refreshLibraryEngagement( @@ -1563,8 +1588,10 @@ export async function refreshLibraryEngagement(
viewerPubkey?: string | null
): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> {
const indexByAddress = buildIndexByAddress(indexEvents)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
indexRelayUrls,
@ -1589,23 +1616,24 @@ export async function refreshLibraryEngagement( @@ -1589,23 +1616,24 @@ export async function refreshLibraryEngagement(
}
}
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex(
const LIBRARY_SEARCH_BATCH_SIZE = 80
function collectLibraryPublicationIndexSearchRoots(
query: string,
indexEvents: Event[],
indexByAddress: Map<string, Event>
): Event[] {
topLevelIds: Set<string>,
addressToRoot: Map<string, Event>,
axis: LibraryPublicationRelaySearchAxis | null | undefined,
roots: Map<string, Event>,
start: number,
end: number
): void {
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 (const ev of indexEvents) {
for (let i = start; i < end; i++) {
const ev = indexEvents[i]
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue
if (topLevelIds.has(ev.id)) {
roots.set(ev.id, ev)
@ -1616,10 +1644,78 @@ export function searchLibraryPublicationIndex( @@ -1616,10 +1644,78 @@ export function searchLibraryPublicationIndex(
const root = addr ? addressToRoot.get(addr) : undefined
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()]
}
/** 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 = {
indexEvents: Event[]
engagement?: PublicationEngagementMaps
@ -1631,15 +1727,20 @@ export type LibrarySearchContext = { @@ -1631,15 +1727,20 @@ export type LibrarySearchContext = {
*/
export async function searchLibraryPublications(
query: string,
context: LibrarySearchContext
context: LibrarySearchContext,
axis?: LibraryPublicationRelaySearchAxis | null
): Promise<LibraryPublicationEntry[]> {
const q = query.trim()
if (!q) return []
const cached = getLibrarySearchSessionRow(q, context)
const cached = getLibrarySearchSessionRow(q, context, { axis })
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] search cache hit', { query: q, relaySearched: cached.relaySearched })
logger.info('[Library] search cache hit', {
query: q,
axis: axis ?? 'all',
relaySearched: cached.relaySearched
})
}
return cached.entries
}
@ -1651,7 +1752,7 @@ export async function searchLibraryPublications( @@ -1651,7 +1752,7 @@ export async function searchLibraryPublications(
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const indexByAddress = buildIndexByAddress(indexEvents)
const fromIndex = searchLibraryPublicationIndex(q, indexEvents, indexByAddress)
const fromIndex = await searchLibraryPublicationIndexAsync(q, indexEvents, indexByAddress, axis)
const rootMap = new Map<string, Event>()
for (const root of fromIndex) rootMap.set(root.id, root)
@ -1667,7 +1768,7 @@ export async function searchLibraryPublications( @@ -1667,7 +1768,7 @@ export async function searchLibraryPublications(
)
for (const ev of fromReadingCache) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue
if (rootMap.has(ev.id)) continue
const addr = eventTagAddress(ev)
@ -1694,12 +1795,17 @@ export async function searchLibraryPublications( @@ -1694,12 +1795,17 @@ export async function searchLibraryPublications(
const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement))
const searchContext: LibrarySearchContext = { indexEvents, engagement }
const prev = getLibrarySearchSessionRow(q, searchContext)
putLibrarySearchSessionRow(q, searchContext, {
const prev = getLibrarySearchSessionRow(q, searchContext, { axis })
putLibrarySearchSessionRow(
q,
searchContext,
{
entries,
mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
relaySearched: prev?.relaySearched ?? false
})
},
axis
)
return entries
}
@ -1727,6 +1833,15 @@ function normalizePublicationDTag(term: string): string { @@ -1727,6 +1833,15 @@ function normalizePublicationDTag(term: string): string {
.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. */
export function publicationQueryDTagVariants(query: string): string[] {
const raw = query.trim()
@ -1742,14 +1857,106 @@ export function publicationQueryDTagVariants(query: string): string[] { @@ -1742,14 +1857,106 @@ export function publicationQueryDTagVariants(query: string): string[] {
return [...seen]
}
/**
* OR-merge REQ filters for kind **30040** publication indexes: `#d` slugs plus NIP-50 `search`
* (title, author, summary/description on index relays).
*/
export function buildLibraryPublicationRelaySearchFilters(opts: {
/** 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
limit?: number
}): Filter[] {
): 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 []
@ -1757,67 +1964,162 @@ export function buildLibraryPublicationRelaySearchFilters(opts: { @@ -1757,67 +1964,162 @@ export function buildLibraryPublicationRelaySearchFilters(opts: {
const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>()
const out: Filter[] = []
const add = (filter: 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).
* Title and author text use HTTP metadata search (not NIP-50). Only `#d` and pubkey `authors` use NIP-01 filters.
*/
export function buildLibraryPublicationRelaySearchFilters(opts: {
query: string
limit?: number
}): Filter[] {
const seen = new Set<string>()
const out: Filter[] = []
for (const axis of LIBRARY_PUBLICATION_RELAY_SEARCH_AXES) {
for (const filter of buildLibraryPublicationRelaySearchFiltersForAxis(axis, opts)) {
const key = JSON.stringify(filter)
if (seen.has(key)) return
if (seen.has(key)) continue
seen.add(key)
out.push(filter)
}
const npub = tryNpubFromQuery(searchRaw)
if (npub) {
add({ kinds: [kind], authors: [npub], limit })
}
return out
}
const dTags = publicationQueryDTagVariants(searchRaw)
if (dTags.length > 0) {
add({ kinds: [kind], '#d': dTags, limit })
export function filterEventsForPublicationRelaySearchAxis(
events: Event[],
axis: LibraryPublicationRelaySearchAxis,
query: string
): Event[] {
const terms = publicationRelaySearchTermsForAxis(axis, query)
if (terms.length === 0) return []
return events.filter((event) => {
if (event.kind !== ExtendedKind.PUBLICATION) return false
if (axis === 'author') {
const npub = tryNpubFromQuery(query.trim())
if (npub && event.pubkey.toLowerCase() === npub) return true
}
const tagName = axis === 'd-tag' ? 'd' : axis
return terms.some((term) => publicationMetadataTagMatchesQuery(event, tagName, term))
})
}
const searchNorm = normalizeGeneralSearchQuery(searchRaw)
add({ kinds: [kind], search: searchRaw, limit })
if (searchNorm !== searchRaw) {
add({ kinds: [kind], search: searchNorm, limit })
export function publicationIndexMatchesSearchQueryWithAxis(
event: Event,
query: string,
axis?: LibraryPublicationRelaySearchAxis | null
): boolean {
if (!axis) return publicationIndexMatchesSearchQuery(event, query)
return filterEventsForPublicationRelaySearchAxis([event], axis, query).length > 0
}
const adv = parseAdvancedSearch(searchRaw)
const titleValues = adv.title
? Array.isArray(adv.title)
? adv.title
: [adv.title]
: []
for (const title of titleValues) {
const t = title.trim()
if (!t) continue
add({ kinds: [kind], search: t, limit })
const titleDTags = publicationQueryDTagVariants(t)
if (titleDTags.length > 0) {
add({ kinds: [kind], '#d': titleDTags, limit })
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[]) => {
for (const ev of filterEventsForPublicationRelaySearchAxis(batch, axis, term)) {
if (!seen.has(ev.id)) {
seen.add(ev.id)
matched.push(ev)
}
}
}
const authorValues = adv.author
? Array.isArray(adv.author)
? adv.author
: [adv.author]
: []
for (const author of authorValues) {
const a = author.trim()
if (a) add({ kinds: [kind], search: a, limit })
let firstPage: Event[]
try {
firstPage = (await queryIndexRelayForLibrary(httpRelay, filter)).events as Event[]
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication scan first page failed', {
relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return []
}
const descriptionValues = adv.description
? Array.isArray(adv.description)
? adv.description
: [adv.description]
: []
for (const description of descriptionValues) {
const d = description.trim()
if (d) add({ kinds: [kind], search: d, limit })
collect(firstPage)
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage.length === 0) {
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT)
}
return out
let until = oldestCreatedAt(firstPage) - 1
for (let page = 1; page < LIBRARY_RELAY_SEARCH_SCAN_MAX_PAGES; page++) {
if (until < 0) break
let batch: Event[] = []
let apiRowCount = 0
try {
const pageResult = await queryIndexRelayForLibrary(httpRelay, { ...filter, until })
batch = pageResult.events as Event[]
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)
}
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}. */
@ -1825,7 +2127,7 @@ export async function searchLibraryPublicationsOnRelays( @@ -1825,7 +2127,7 @@ export async function searchLibraryPublicationsOnRelays(
query: string,
relayUrls: string[],
context: LibrarySearchContext,
options?: { forceRefresh?: boolean }
options?: { forceRefresh?: boolean; axis?: LibraryPublicationRelaySearchAxis | null }
): Promise<{
events: Event[]
entries: LibraryPublicationEntry[]
@ -1838,7 +2140,10 @@ export async function searchLibraryPublicationsOnRelays( @@ -1838,7 +2140,10 @@ export async function searchLibraryPublicationsOnRelays(
}
if (!options?.forceRefresh) {
const cached = getLibrarySearchSessionRow(q, context, { requireRelaySearch: true })
const cached = getLibrarySearchSessionRow(q, context, {
requireRelaySearch: true,
axis: options?.axis
})
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] relay search cache hit', { query: q })
@ -1852,26 +2157,36 @@ export async function searchLibraryPublicationsOnRelays( @@ -1852,26 +2157,36 @@ 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 { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
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) {
if (wsRelays.length > 0 && hasNip01Filters) {
batches.push(
queryService
.fetchEvents(wsRelays, filters, {
.fetchEvents(wsRelays, axisFilters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000,
firstRelayResultGraceMs: false
})
.then((events) => filterEventsForPublicationRelaySearchAxis(events, axis, q))
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', {
axis,
message: e instanceof Error ? e.message : String(e)
})
}
@ -1881,14 +2196,35 @@ export async function searchLibraryPublicationsOnRelays( @@ -1881,14 +2196,35 @@ export async function searchLibraryPublicationsOnRelays(
}
for (const httpRelay of httpRelays) {
for (const filter of filters) {
if (hasNip01Filters) {
for (const filter of axisFilters) {
batches.push(
queryIndexRelayPublicationSearch(httpRelay, filter)
.then((page) => page.events as Event[])
queryIndexRelayForLibrary(httpRelay, filter)
.then((page) => filterEventsForPublicationRelaySearchAxis(page.events as Event[], axis, q))
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP publication search failed', {
logger.warn('[Library] HTTP publication filter 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,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
@ -1897,6 +2233,11 @@ export async function searchLibraryPublicationsOnRelays( @@ -1897,6 +2233,11 @@ export async function searchLibraryPublicationsOnRelays(
)
}
}
}
if (batches.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat())
@ -1908,7 +2249,7 @@ export async function searchLibraryPublicationsOnRelays( @@ -1908,7 +2249,7 @@ export async function searchLibraryPublicationsOnRelays(
void persistLibraryIndexCacheEvents(mergedIndex)
}
const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress, options?.axis)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const entries = sortLibraryPublications(
libraryEntriesFromRoots(roots, indexByAddress, engagement)
@ -1918,15 +2259,22 @@ export async function searchLibraryPublicationsOnRelays( @@ -1918,15 +2259,22 @@ export async function searchLibraryPublicationsOnRelays(
indexEvents: mergedIndex,
engagement
}
putLibrarySearchSessionRow(q, searchContext, {
putLibrarySearchSessionRow(
q,
searchContext,
{
entries,
mergedIndexEvents: mergedIndex,
relaySearched: true
})
},
options?.axis
)
if (import.meta.env.DEV) {
logger.info('[Library] relay search done', {
filters: filters.length,
axes: LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length,
filters: filterCount,
batches: batches.length,
network: networkEvents.length,
valid: valid.length,
roots: roots.length
@ -1977,19 +2325,66 @@ function collectTargetAddressesFromIndexes( @@ -1977,19 +2325,66 @@ function collectTargetAddressesFromIndexes(
indexEvents: Event[],
indexByAddress: Map<string, Event>
): 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 eventIds = new Set<string>()
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)) {
addresses.add(addr)
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
const indexed = indexByAddress.get(addr)
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)
if (rootAddr) {
addresses.add(rootAddr)
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
if (addresses.size >= MAX_TARGET_ADDRESSES || eventIds.size >= MAX_TARGET_EVENT_IDS) {
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(
@ -2002,8 +2397,10 @@ async function buildEngagedFromCache( @@ -2002,8 +2397,10 @@ async function buildEngagedFromCache(
const topLevel = getTopLevelIndexEvents(indexEvents)
let maps = engagement
if (!maps) {
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
indexByAddress
)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
indexRelayUrls,
@ -2080,11 +2477,10 @@ async function runLibraryPublicationIndexLoad( @@ -2080,11 +2477,10 @@ async function runLibraryPublicationIndexLoad(
if (!options?.forceRefresh && sessionCache?.relayKey === key) {
const cachedIndexEvents = indexEventsFromCache(sessionCache)
if (sessionCache.viewerPubkey !== viewerPubkey) {
const targetAddresses = collectTargetAddressesFromIndexes(
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
cachedIndexEvents,
sessionCache.indexByAddress
)
const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
relayUrls,
@ -2148,8 +2544,10 @@ async function runLibraryPublicationIndexLoad( @@ -2148,8 +2544,10 @@ async function runLibraryPublicationIndexLoad(
}
topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
buildIndexByAddress(indexEvents)
)
if (import.meta.env.DEV) {
logger.info('[Library] fetching engagement', {
targetAddresses: targetAddresses.size,
@ -2210,7 +2608,7 @@ export function clearLibraryPublicationIndexCache(): void { @@ -2210,7 +2608,7 @@ export function clearLibraryPublicationIndexCache(): void {
clearLibrarySearchSessionCache()
}
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
/** Clears Library tab session cache and relay-discovered catalog masters (opened publications stay). */
export async function clearAllLibraryIndexCaches(): Promise<void> {
sessionCache = null
indexLoadJob = null

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

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

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

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

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

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

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

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

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

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
import { pubkeyFromNip07Extension } from '@/lib/pubkey'
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. */
export async function getExtensionPubkeyHex(): Promise<string> {
const signer = new Nip07Signer()
@ -22,12 +27,7 @@ export class Nip07Signer implements ISigner { @@ -22,12 +27,7 @@ export class Nip07Signer implements ISigner {
private pubkey: string | null = null
async init() {
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++) {
for (let attempt = 0; attempt < NIP07_INJECT_MAX_ATTEMPTS; attempt++) {
if (window.nostr) {
this.signer = window.nostr
if (typeof this.signer.enable === 'function') {
@ -35,7 +35,7 @@ export class Nip07Signer implements ISigner { @@ -35,7 +35,7 @@ export class Nip07Signer implements ISigner {
}
return
}
await new Promise((resolve) => setTimeout(resolve, checkInterval))
await new Promise((resolve) => setTimeout(resolve, NIP07_INJECT_CHECK_INTERVAL_MS))
}
throw new Error(

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

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
/** Legacy object store names removed in DB migrations (do not re-add to {@link StoreNames}). */
const LEGACY_DELETED_OBJECT_STORES = ['relayInfoEvents', 'spellListSourceEvents'] as const
const LEGACY_DELETED_OBJECT_STORES = [
'relayInfoEvents',
'spellListSourceEvents',
'libraryPublicationIndex'
] as const
import {
publicationCoordinateLookupKeys,
splitPublicationCoordinate
@ -28,9 +32,7 @@ import logger from '@/lib/logger' @@ -28,9 +32,7 @@ import logger from '@/lib/logger'
import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import {
eventTagAddress,
isStructuralPublicationIndex,
isVerifiedPublicationIndex,
pickNewerPublicationIndexEvent,
type PublicationIndexMap
} from '@/lib/publication-index'
@ -71,6 +73,12 @@ type TValue<T = any> = { @@ -71,6 +73,12 @@ type TValue<T = any> = {
value: T | null
addedAt: number
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}. */
@ -150,7 +158,6 @@ export const StoreNames = { @@ -150,7 +158,6 @@ export const StoreNames = {
/** Piper / read-aloud WAV blobs keyed by SHA-256 of endpoint + text + speed. */
PIPER_TTS_CACHE: 'piperTtsCache',
/** Library kind-30040 index LRU (rotating cache; separate byte/entry budget from EVENT_ARCHIVE). */
LIBRARY_PUBLICATION_INDEX: 'libraryPublicationIndex',
/** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */
CALENDAR_EVENTS: 'calendarEvents',
/** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */
@ -182,7 +189,6 @@ export type TCalendarRsvpCacheRow = { @@ -182,7 +189,6 @@ export type TCalendarRsvpCacheRow = {
const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set([
StoreNames.SETTINGS,
StoreNames.PIPER_TTS_CACHE,
StoreNames.LIBRARY_PUBLICATION_INDEX,
StoreNames.RELAY_INFOS,
StoreNames.NIP66_DISCOVERY,
StoreNames.GIF_CACHE,
@ -237,7 +243,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([ @@ -237,7 +243,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 41
const DB_VERSION = 43
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@ -253,7 +259,7 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): @@ -253,7 +259,7 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]):
return new Error(message)
}
type TLibraryPublicationIndexCacheRow = {
type TLegacyLibraryPublicationIndexRow = {
key: string
value: Event
addedAt: number
@ -261,7 +267,7 @@ type TLibraryPublicationIndexCacheRow = { @@ -261,7 +267,7 @@ type TLibraryPublicationIndexCacheRow = {
approxBytes: number
}
function approxLibraryPublicationIndexRowBytes(ev: Event): number {
function approxPublicationCatalogMasterBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
@ -269,53 +275,125 @@ function approxLibraryPublicationIndexRowBytes(ev: Event): number { @@ -269,53 +275,125 @@ function approxLibraryPublicationIndexRowBytes(ev: Event): number {
}
}
/** v41: re-key library index rows from event id to kind:pubkey:d; dedupe by address. */
function migrateLibraryPublicationIndexCacheToAddressKeys(transaction: IDBTransaction): void {
const store = transaction.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX)
const rows: TLibraryPublicationIndexCacheRow[] = []
function publicationStoreRowKeyForEvent(event: Event): string {
const [, d] = event.tags.find(tagNameEquals('d')) ?? []
const trimmed = event.pubkey.trim()
const canonPk = /^[0-9a-f]{64}$/i.test(trimmed) ? trimmed.toLowerCase() : trimmed
return d === undefined ? canonPk : `${canonPk}:${d}`
}
const readReq = store.openCursor()
readReq.onsuccess = () => {
const cursor = readReq.result
if (cursor) {
rows.push(cursor.value as TLibraryPublicationIndexCacheRow)
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()
}
}
}
const byAddress = new Map<string, TLibraryPublicationIndexCacheRow>()
for (const row of rows) {
const ev = row?.value
if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
const addr = eventTagAddress(ev)
if (!addr) continue
const existing = byAddress.get(addr)
if (!existing) {
byAddress.set(addr, {
key: addr,
value: ev,
addedAt: row.addedAt ?? Date.now(),
lastAccessAt: row.lastAccessAt ?? row.addedAt ?? Date.now(),
approxBytes: row.approxBytes ?? approxLibraryPublicationIndexRowBytes(ev)
})
continue
/** v43: merge legacy libraryPublicationIndex into publicationEvents; catalogMaster index. */
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 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 readReq = legacyStore.openCursor()
readReq.onsuccess = () => {
const cursor = readReq.result as IDBCursorWithValue | null
if (cursor) {
legacyRows.push(cursor.value as TLegacyLibraryPublicationIndexRow)
cursor.continue()
return
}
const clearReq = store.clear()
clearReq.onsuccess = () => {
for (const row of byAddress.values()) {
store.put(row)
for (const row of legacyRows) {
const ev = row?.value
if (!ev || ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
const key = publicationStoreRowKeyForEvent(ev)
const getReq = pubStore.get(key)
getReq.onsuccess = () => {
const prev = getReq.result as TValue<Event> | undefined
const winner =
prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id
? prev.value
: ev
const merged: TValue<Event> = buildPublicationStoreRow(key, winner, prev)
merged.addedAt = Math.min(prev?.addedAt ?? row.addedAt ?? Date.now(), row.addedAt ?? Date.now())
merged.lastAccessAt = Math.max(
prev?.lastAccessAt ?? 0,
row.lastAccessAt ?? row.addedAt ?? 0
)
merged.catalogBytes = approxPublicationCatalogMasterBytes(winner)
pubStore.put(merged)
}
}
}
@ -347,9 +425,11 @@ function ensureMissingObjectStores(db: IDBDatabase): void { @@ -347,9 +425,11 @@ function ensureMissingObjectStores(db: IDBDatabase): void {
const pa = db.createObjectStore(storeName, { keyPath: 'key' })
pa.createIndex('authorPubkey', 'authorPubkey', { unique: false })
pa.createIndex('targetEventId', 'targetEventId', { unique: false })
} else if (storeName === StoreNames.LIBRARY_PUBLICATION_INDEX) {
const lib = db.createObjectStore(storeName, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
} else if (storeName === StoreNames.PUBLICATION_EVENTS) {
const store = db.createObjectStore(storeName, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(store)
} else if (storeName === 'libraryPublicationIndex') {
/* dropped in v43 — do not recreate */
} else {
db.createObjectStore(storeName, { keyPath: 'key' })
}
@ -514,7 +594,8 @@ class IndexedDbService { @@ -514,7 +594,8 @@ class IndexedDbService {
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
const pub = db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(pub)
}
if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) {
db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' })
@ -591,15 +672,29 @@ class IndexedDbService { @@ -591,15 +672,29 @@ class IndexedDbService {
}
}
if (event.oldVersion < 40) {
if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' })
if (!db.objectStoreNames.contains('libraryPublicationIndex')) {
const lib = db.createObjectStore('libraryPublicationIndex', { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
}
}
if (event.oldVersion < 41) {
const tx = (event.target as IDBOpenDBRequest).transaction
if (tx && db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
migrateLibraryPublicationIndexCacheToAddressKeys(tx)
if (tx && db.objectStoreNames.contains('libraryPublicationIndex')) {
// v41 migration superseded by v43 consolidation into publicationEvents
}
}
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)
@ -690,7 +785,11 @@ class IndexedDbService { @@ -690,7 +785,11 @@ class IndexedDbService {
return resolve(oldValue.value)
}
const putRequest = store.put(this.formatValue(key, cleanEvent))
const putRequest = store.put(
storeName === StoreNames.PUBLICATION_EVENTS
? buildPublicationStoreRow(key, cleanEvent, oldValue)
: this.formatValue(key, cleanEvent)
)
putRequest.onsuccess = () => {
transaction.commit()
resolve(cleanEvent)
@ -1385,16 +1484,14 @@ class IndexedDbService { @@ -1385,16 +1484,14 @@ class IndexedDbService {
if (oldValue?.value && oldValue.value.created_at > cleanEvent.created_at) {
// Update master key link even if event is not newer
if (oldValue.masterPublicationKey !== masterKey) {
const value = this.formatValue(key, oldValue.value)
value.masterPublicationKey = masterKey
const value = buildPublicationStoreRow(key, oldValue.value, oldValue, masterKey)
store.put(value)
}
transaction.commit()
return resolve(oldValue.value)
}
// Store with master key link
const value = this.formatValue(key, cleanEvent)
value.masterPublicationKey = masterKey
const value = buildPublicationStoreRow(key, cleanEvent, oldValue, masterKey)
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
@ -1442,8 +1539,7 @@ class IndexedDbService { @@ -1442,8 +1539,7 @@ class IndexedDbService {
// For non-replaceable events, use event ID as key
const key = event.id
// For non-replaceable events, always update with master key link
const value = this.formatValue(key, event)
value.masterPublicationKey = masterKey
const value = buildPublicationStoreRow(key, event, undefined, masterKey)
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
@ -2080,7 +2176,7 @@ class IndexedDbService { @@ -2080,7 +2176,7 @@ class IndexedDbService {
storeInfo[storeName] = req.result
pending--
if (pending === 0) {
resolve(storeInfo)
void this.normalizePublicationStoreInfoCount(storeInfo).then(resolve).catch(reject)
}
}
req.onerror = (ev) => {
@ -2090,6 +2186,116 @@ class IndexedDbService { @@ -2090,6 +2186,116 @@ 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>[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
@ -2287,13 +2493,27 @@ class IndexedDbService { @@ -2287,13 +2493,27 @@ class IndexedDbService {
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
const kind = this.getKindByStoreName(storeName)
if (!kind || !this.isReplaceableEventKind(kind)) {
return Promise.reject('Not a replaceable event store')
return Promise.reject(new Error('Not a replaceable event store'))
}
// First pass: identify duplicates
return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) =>
this.getReplaceableEventKeyFromEvent(event)
)
}
private async cleanupDuplicateEventsByDedupeKey(
storeName: string,
dedupeKeyForEvent: (event: Event) => string
): Promise<{ deleted: number; kept: number }> {
const allItems = await this.getStoreItems(storeName)
const eventMap = new Map<string, { key: string; event: Event; addedAt: number }>()
const keysToDelete: string[] = []
@ -2305,47 +2525,41 @@ class IndexedDbService { @@ -2305,47 +2525,41 @@ class IndexedDbService {
continue
}
// Skip if event doesn't have required fields
if (!item.value.pubkey || !item.value.kind || !item.value.created_at) {
invalidItemsCount++
continue
}
try {
const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value)
const existing = eventMap.get(replaceableKey)
const dedupeKey = dedupeKeyForEvent(item.value)
const existing = eventMap.get(dedupeKey)
if (!existing ||
if (
!existing ||
item.value.created_at > existing.event.created_at ||
(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
(item.value.created_at === existing.event.created_at && item.addedAt > existing.addedAt)
) {
if (existing) {
keysToDelete.push(existing.key)
}
eventMap.set(replaceableKey, {
eventMap.set(dedupeKey, {
key: item.key,
event: item.value,
addedAt: item.addedAt
})
} else {
// This event is older or same, mark it for deletion
keysToDelete.push(item.key)
}
} catch (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 })
logger.warn('Failed to get dedupe key for item', { key: item.key, error })
invalidItemsCount++
continue
}
}
// Second pass: delete duplicates
const totalProcessed = eventMap.size + keysToDelete.length
const actualKept = eventMap.size
if (keysToDelete.length === 0) {
// No duplicates found, but verify counts match
const totalProcessed = eventMap.size + keysToDelete.length
if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch while cleaning up replaceable events', {
totalItems: allItems.length,
@ -2353,7 +2567,7 @@ class IndexedDbService { @@ -2353,7 +2567,7 @@ class IndexedDbService {
invalid: invalidItemsCount
})
}
return Promise.resolve({ deleted: 0, kept: actualKept })
return { deleted: 0, kept: actualKept }
}
return new Promise((resolve) => {
@ -2363,14 +2577,13 @@ class IndexedDbService { @@ -2363,14 +2577,13 @@ class IndexedDbService {
let deletedCount = 0
let completedCount = 0
keysToDelete.forEach(key => {
keysToDelete.forEach((key) => {
const deleteRequest = store.delete(key)
deleteRequest.onsuccess = () => {
deletedCount++
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
const actualKept = eventMap.size
const totalProcessed = actualKept + deletedCount
if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch after deletion', {
@ -2387,7 +2600,6 @@ class IndexedDbService { @@ -2387,7 +2600,6 @@ class IndexedDbService {
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
const actualKept = eventMap.size
resolve({ deleted: deletedCount, kept: actualKept })
}
}
@ -3756,22 +3968,19 @@ class IndexedDbService { @@ -3756,22 +3968,19 @@ class IndexedDbService {
}
}
private approxLibraryPublicationIndexBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
return 2048
}
}
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
private async listPublicationCatalogMasterRows(): Promise<
Array<{ key: string; lastAccessAt: number; bytes: number; hasNested: boolean }>
> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return []
const nestedMasterKeys = new Set<string>()
const masters: Array<{ key: string; lastAccessAt: number; bytes: number }> = []
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()
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
@ -3779,80 +3988,20 @@ class IndexedDbService { @@ -3779,80 +3988,20 @@ class IndexedDbService {
resolve()
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
if (row?.value?.kind === ExtendedKind.PUBLICATION && !isVerifiedPublicationIndex(row.value)) {
toDelete.push(cursor.key as string)
}
cursor.continue()
}
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
const row = cursor.value as TValue<Event>
const key = cursor.key as string
if (row?.masterPublicationKey) {
nestedMasterKeys.add(row.masterPublicationKey)
} else if (
row?.catalogMaster === 1 ||
(row?.value?.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey)
) {
tx.commit()
resolve(true)
return
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
masters.push({
key,
lastAccessAt: row.lastAccessAt ?? row.addedAt,
bytes: row.catalogBytes ?? approxPublicationCatalogMasterBytes(row.value as Event)
})
}
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
const out: Event[] = []
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
if (row?.value?.kind === ExtendedKind.PUBLICATION) out.push(row.value)
cursor.continue()
}
req.onerror = (e) => {
@ -3860,215 +4009,94 @@ class IndexedDbService { @@ -3860,215 +4009,94 @@ class IndexedDbService {
reject(idbEventToError(e))
}
})
}
async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
return { count: 0, bytes: 0 }
return masters.map((row) => ({ ...row, hasNested: nestedMasterKeys.has(row.key) }))
}
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
let count = 0
let bytes = 0
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve({ count, bytes })
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
count += 1
bytes += row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
async getPublicationCatalogFootprint(): Promise<{ count: number; bytes: number }> {
const rows = await this.listPublicationCatalogMasterRows()
return {
count: rows.length,
bytes: rows.reduce((sum, row) => sum + row.bytes, 0)
}
})
}
async mergeLibraryPublicationIndexCacheEvents(
async mergePublicationCatalogIndexEvents(
events: Event[],
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX) || events.length === 0) {
return
}
const now = Date.now()
const storeName = StoreNames.LIBRARY_PUBLICATION_INDEX
const rowsToWrite: Array<{ key: string; event: Event }> = []
for (const ev of events) {
if (ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
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.putReplaceableEvent(ev)
}
existing.event = pickNewerPublicationIndexEvent(existing.event, ev)
await this.prunePublicationCatalogMasters(opts.maxEntries, opts.maxBytes)
}
if (rowsToWrite.length === 0) return
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(storeName, 'readwrite')
const store = tx.objectStore(storeName)
let pending = rowsToWrite.length
async prunePublicationCatalogMasters(maxEntries: number, maxBytes: number): Promise<void> {
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>()
const finishOne = () => {
pending -= 1
if (pending === 0) {
tx.commit()
resolve()
}
for (const victim of candidates) {
if (totalCount <= maxEntries && totalBytes <= maxBytes) break
toDelete.add(victim.key)
totalBytes -= victim.bytes
totalCount -= 1
}
for (const { key, event: ev } of rowsToWrite) {
const get = store.get(key)
get.onsuccess = () => {
const prev = get.result as TLibraryPublicationIndexCacheRow | undefined
if (prev?.value && pickNewerPublicationIndexEvent(prev.value, ev).id === prev.value.id) {
finishOne()
return
}
const row: TLibraryPublicationIndexCacheRow = {
key,
value: ev,
addedAt: prev?.addedAt ?? now,
lastAccessAt: now,
approxBytes: this.approxLibraryPublicationIndexBytes(ev)
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key)
}
const put = store.put(row)
put.onsuccess = () => finishOne()
put.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
/** Remove relay-discovered catalog masters that were never opened (no nested sections cached). */
async clearPublicationCatalogDiscoveryOnly(): Promise<void> {
const rows = await this.listPublicationCatalogMasterRows()
for (const row of rows) {
if (!row.hasNested) {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, row.key)
}
get.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
}
})
await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes)
/** @deprecated Use {@link getPublicationCatalogFootprint}. */
async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
return this.getPublicationCatalogFootprint()
}
/** Drop invalid, legacy id-keyed, and superseded rows after an address-keyed merge. */
async reconcileLibraryPublicationIndexCache(canonical: PublicationIndexMap): Promise<void> {
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 getPublicationCatalogIndexEvents}. */
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
return this.getPublicationCatalogIndexEvents()
}
})
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
/** @deprecated Use {@link mergePublicationCatalogIndexEvents}. */
async mergeLibraryPublicationIndexCacheEvents(
events: Event[],
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
return this.mergePublicationCatalogIndexEvents(events, opts)
}
async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
const rows: Array<{ key: string; lastAccessAt: number; bytes: number }> = []
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve()
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
rows.push({
key: cursor.key as string,
lastAccessAt: row.lastAccessAt ?? row.addedAt,
bytes: row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
})
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
/** @deprecated Use {@link clearPublicationCatalogDiscoveryOnly}. */
async clearLibraryPublicationIndexCacheStore(): Promise<void> {
return this.clearPublicationCatalogDiscoveryOnly()
}
})
rows.sort((a, b) => a.lastAccessAt - b.lastAccessAt)
const toDelete = new Set<string>()
let totalBytes = rows.reduce((s, r) => s + r.bytes, 0)
let totalCount = rows.length
while (totalCount > maxEntries || totalBytes > maxBytes) {
const victim = rows.shift()
if (!victim) break
toDelete.add(victim.key)
totalBytes -= victim.bytes
totalCount -= 1
}
/** @deprecated No-op after v43 consolidation. */
async reconcileLibraryPublicationIndexCache(_canonical: PublicationIndexMap): Promise<void> {}
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
/** @deprecated No-op after v43 consolidation. */
async libraryPublicationIndexCacheHasLegacyKeys(): Promise<boolean> {
return false
}
async clearLibraryPublicationIndexCacheStore(): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readwrite')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).clear()
req.onsuccess = () => {
tx.commit()
resolve()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
/** @deprecated No-op after v43 consolidation. */
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
return 0
}
/**

2
src/services/nip89.service.ts

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

Loading…
Cancel
Save