Compare commits

..

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

  1. 2
      nip66-cron/index.mjs
  2. 4
      package-lock.json
  3. 2
      package.json
  4. 4
      src/components/AccountManager/index.tsx
  5. 44
      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. 97
      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. 674
      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. 732
      src/services/indexed-db.service.ts
  26. 2
      src/services/nip89.service.ts

2
nip66-cron/index.mjs

@ -51,6 +51,7 @@ const DEFAULT_RELAYS_TO_MONITOR = [ @@ -51,6 +51,7 @@ 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',
@ -74,6 +75,7 @@ const DEFAULT_RELAYS_TO_MONITOR = [ @@ -74,6 +75,7 @@ const DEFAULT_RELAYS_TO_MONITOR = [
/** Relays to publish 30166/10166 and to REQ kind 10002 from; broad enough for Imwald + NIP-66 discovery. */
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.8",
"version": "23.21.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "imwald",
"version": "23.21.8",
"version": "23.21.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

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

4
src/components/AccountManager/index.tsx

@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' @@ -2,7 +2,6 @@ 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'
@ -45,7 +44,6 @@ function AccountManagerNav({ @@ -45,7 +44,6 @@ 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)
@ -86,7 +84,7 @@ function AccountManagerNav({ @@ -86,7 +84,7 @@ function AccountManagerNav({
{t('Add an Account')}
</div>
<div className="space-y-2 mt-4">
{nip07ExtensionAvailable && (
{!!window.nostr && (
<Button
onClick={() => void handleExtensionLogin()}
disabled={extensionLoginPending || isNip07LoginInFlight}

44
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -6,6 +6,7 @@ import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy, @@ -6,6 +6,7 @@ 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'
@ -41,7 +42,6 @@ export default function CacheBrowserDialog({ @@ -41,7 +42,6 @@ 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,7 +63,6 @@ export default function CacheBrowserDialog({ @@ -63,7 +63,6 @@ export default function CacheBrowserDialog({
setGlobalSearchHits([])
setGlobalSearchTruncated(false)
setPublicationListFull(false)
setPublicationStoreTotalRows(null)
void loadCacheInfo()
}, [open])
@ -102,20 +101,13 @@ export default function CacheBrowserDialog({ @@ -102,20 +101,13 @@ export default function CacheBrowserDialog({
setSelectedStore(storeName)
setSearchQuery('')
setPublicationListFull(false)
setPublicationStoreTotalRows(null)
setLoadingItems(true)
try {
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)
}
const items =
storeName === 'publicationEvents'
? await indexedDb.getPublicationStoreItems(storeName)
: await indexedDb.getStoreItems(storeName)
setStoreItems(items)
} catch (error) {
logger.error('Failed to load store items', { error })
toast.error(t('Failed to load store items'))
@ -186,7 +178,11 @@ export default function CacheBrowserDialog({ @@ -186,7 +178,11 @@ export default function CacheBrowserDialog({
if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try {
await indexedDb.clearStore(selectedStore)
if (selectedStore === StoreNames.LIBRARY_PUBLICATION_INDEX) {
await clearAllLibraryIndexCaches()
} else {
await indexedDb.clearStore(selectedStore)
}
setStoreItems([])
void loadCacheInfo()
toast.success(t('All items deleted successfully'))
@ -203,14 +199,12 @@ export default function CacheBrowserDialog({ @@ -203,14 +199,12 @@ 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 items =
selectedStore === StoreNames.PUBLICATION_EVENTS
? await indexedDb.getPublicationStoreItems(selectedStore)
: await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
const actualCount = items.length
const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore)
const actualCount = itemsAfterCleanup.length
if (actualCount !== result.kept) {
toast.success(
t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', {
@ -224,8 +218,7 @@ export default function CacheBrowserDialog({ @@ -224,8 +218,7 @@ export default function CacheBrowserDialog({
}
} catch (error) {
logger.error('Failed to cleanup duplicates', { error })
const message = error instanceof Error ? error.message : String(error)
if (message === 'Not a replaceable event store') {
if (error instanceof Error && error.message === 'Not a replaceable event store') {
toast.error(t('This store does not contain replaceable events'))
} else {
toast.error(t('Failed to cleanup duplicates'))
@ -472,11 +465,6 @@ export default function CacheBrowserDialog({ @@ -472,11 +465,6 @@ export default function CacheBrowserDialog({
<div className="mb-2 flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="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,8 +552,9 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { @@ -552,8 +552,9 @@ 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.primal.net')) return 2
if (x.includes('nostr.wine')) return 3
if (x.includes('relay.damus.io')) return 2
if (x.includes('relay.primal.net')) return 3
if (x.includes('nostr.wine')) return 4
return 30
}
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b))

75
src/components/InBrowserCacheSetting/index.tsx

@ -1,8 +1,4 @@ @@ -1,8 +1,4 @@
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'
@ -19,6 +15,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f @@ -19,6 +15,7 @@ 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() {
@ -96,11 +93,11 @@ export default function InBrowserCacheSetting() { @@ -96,11 +93,11 @@ export default function InBrowserCacheSetting() {
const handleRefreshCache = async () => {
try {
setCacheRefreshBusy(true)
await refreshAppBrowserCache({
pubkey,
relayList,
requestAccountNetworkHydrate
})
await indexedDb.forceDatabaseUpgrade()
if (pubkey) {
await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, relayList)
}
toast.success(t('Cache refreshed successfully'))
} catch (error) {
logger.error('Failed to refresh cache', { error })
@ -116,7 +113,65 @@ export default function InBrowserCacheSetting() { @@ -116,7 +113,65 @@ export default function InBrowserCacheSetting() {
}
try {
const { unregisteredCount, cacheClearedCount } = await clearAppServiceWorkerAndCaches()
const currentOrigin = window.location.origin
let unregisteredCount = 0
let cacheClearedCount = 0
if (window.isSecureContext && 'serviceWorker' in navigator) {
let registrations: readonly ServiceWorkerRegistration[] = []
try {
registrations = await navigator.serviceWorker.getRegistrations()
} catch (error) {
logger.warn('Failed to get service worker registrations', { error })
}
if (registrations.length > 0) {
const unregisterPromises = registrations.map(async (registration) => {
try {
const scope = registration.scope
if (scope.startsWith(currentOrigin)) {
const result = await registration.unregister()
if (result) unregisteredCount++
return result
}
return false
} catch (error) {
logger.warn('Failed to unregister a service worker', { error })
return false
}
})
await Promise.all(unregisterPromises)
}
}
if ('caches' in window) {
try {
const cacheNames = await caches.keys()
const appCacheNames = [
'nostr-images',
'satellite-images',
'external-images'
]
const appCaches = cacheNames.filter(name => {
if (appCacheNames.includes(name)) return true
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true
if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) return true
return false
})
await Promise.all(appCaches.map(name => {
cacheClearedCount++
return caches.delete(name).catch(error => {
logger.warn(`Failed to delete cache: ${name}`, { error })
cacheClearedCount--
})
}))
} catch (error) {
logger.warn('Failed to clear some caches', { error })
}
}
if (unregisteredCount > 0 || cacheClearedCount > 0) {
const message = unregisteredCount > 0 && cacheClearedCount > 0

290
src/components/Library/LibrarySearchBar.tsx

@ -1,32 +1,13 @@ @@ -1,32 +1,13 @@
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 { 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 { Loader2, Search, Wifi } from 'lucide-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,
@ -36,9 +17,6 @@ export default function LibrarySearchBar({ @@ -36,9 +17,6 @@ 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
@ -47,166 +25,22 @@ export default function LibrarySearchBar({ @@ -47,166 +25,22 @@ 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">
{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}
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={searchQuery}
onChange={(e) => {
setSearching(true)
setSelectedIndex(-1)
onSearchQueryChange(e.target.value)
}}
onPaste={() => setSearching(true)}
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
onBlur={() => setSearching(false)}
onChange={(e) => onSearchQueryChange(e.target.value)}
placeholder={t('Library search placeholder')}
className={cn('bg-surface-background pl-3', displayList && 'z-50')}
className="pl-9"
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"
@ -241,115 +75,3 @@ export default function LibrarySearchBar({ @@ -241,115 +75,3 @@ 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,13 +1,11 @@ @@ -1,13 +1,11 @@
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'
@ -23,7 +21,6 @@ function readVersionUpdateDismissed(): boolean { @@ -23,7 +21,6 @@ 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)
@ -68,18 +65,16 @@ export default function VersionUpdateBanner() { @@ -68,18 +65,16 @@ export default function VersionUpdateBanner() {
setIsDismissed(true)
setIsUpdating(true)
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 })
}
const reload = () => {
window.location.reload()
})()
}
const apply = getPwaApplyUpdate()
if (apply) {
void apply().catch(reload)
return
}
reload()
}
const handleDismiss = () => {

2
src/constants.ts

@ -536,6 +536,7 @@ export const FAST_READ_RELAY_URLS = [ @@ -536,6 +536,7 @@ 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'
@ -584,6 +585,7 @@ export const SEARCH_QUERY_DEBOUNCE_MS = 550 @@ -584,6 +585,7 @@ 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'

97
src/hooks/useLibraryPublications.ts

@ -10,7 +10,6 @@ import { @@ -10,7 +10,6 @@ import {
searchLibraryPublications,
searchLibraryPublicationsOnRelays,
type LibraryPublicationEntry,
type LibraryPublicationRelaySearchAxis,
type PublicationEngagementMaps,
type LibraryMineFilterOpts
} from '@/lib/library-publication-index'
@ -23,10 +22,8 @@ import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -23,10 +22,8 @@ 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(),
@ -54,7 +51,6 @@ const EMPTY_ENGAGEMENT: PublicationEngagementMaps = { @@ -54,7 +51,6 @@ 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[]>([])
@ -63,11 +59,10 @@ export function useLibraryPublications(isActive: boolean) { @@ -63,11 +59,10 @@ 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)
@ -135,31 +130,13 @@ export function useLibraryPublications(isActive: boolean) { @@ -135,31 +130,13 @@ export function useLibraryPublications(isActive: boolean) {
}, [pubkey])
useEffect(() => {
const t = window.setTimeout(() => setDebouncedSearch(committedSearch), SEARCH_DEBOUNCE_MS)
const t = window.setTimeout(() => setDebouncedSearch(searchQuery), 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, searchAxis])
}, [debouncedSearch, showOnlyMine])
const applyDefaultFeedSlice = useCallback(
(indexEventsSlice: Event[], engagementMaps: PublicationEngagementMaps, pageIndex: number) => {
@ -194,6 +171,7 @@ export function useLibraryPublications(isActive: boolean) { @@ -194,6 +171,7 @@ export function useLibraryPublications(isActive: boolean) {
let cancelled = false
indexesReadyRef.current = false
setLoading(true)
setEngagementLoading(false)
setError(null)
setFeedPageIndex(0)
const forceRefresh = forceRefreshNextLoadRef.current
@ -221,19 +199,12 @@ export function useLibraryPublications(isActive: boolean) { @@ -221,19 +199,12 @@ 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) {
@ -252,6 +223,7 @@ export function useLibraryPublications(isActive: boolean) { @@ -252,6 +223,7 @@ export function useLibraryPublications(isActive: boolean) {
} finally {
if (!cancelled) {
setLoading(false)
setEngagementLoading(false)
}
}
})()
@ -301,7 +273,7 @@ export function useLibraryPublications(isActive: boolean) { @@ -301,7 +273,7 @@ export function useLibraryPublications(isActive: boolean) {
return
}
const cached = peekLibrarySearchResults(q, { indexEvents, engagement }, searchAxis)
const cached = peekLibrarySearchResults(q, { indexEvents, engagement })
if (cached) {
setSearchResults(cached)
setSearchLoading(false)
@ -310,7 +282,7 @@ export function useLibraryPublications(isActive: boolean) { @@ -310,7 +282,7 @@ export function useLibraryPublications(isActive: boolean) {
let cancelled = false
setSearchLoading(true)
void searchLibraryPublications(q, { indexEvents, engagement }, searchAxis).then((results) => {
void searchLibraryPublications(q, { indexEvents, engagement }).then((results) => {
if (cancelled) return
setSearchResults(results)
setSearchLoading(false)
@ -319,39 +291,20 @@ export function useLibraryPublications(isActive: boolean) { @@ -319,39 +291,20 @@ export function useLibraryPublications(isActive: boolean) {
return () => {
cancelled = true
}
}, [debouncedSearch, indexEvents, engagement, searchAxis])
}, [debouncedSearch, indexEvents, engagement])
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 ?? [])
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 },
{ axis: searchAxis }
),
timeoutPromise
]))
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId)
}
const { events, mergedIndexEvents, fromCache } = await searchLibraryPublicationsOnRelays(
q,
relays,
{ indexEvents, engagement }
)
setIndexEvents(mergedIndexEvents)
setAllIndexCount(mergedIndexEvents.length)
setTopLevelCount(getTopLevelIndexEvents(mergedIndexEvents).length)
@ -372,26 +325,18 @@ export function useLibraryPublications(isActive: boolean) { @@ -372,26 +325,18 @@ 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'
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
)
}
setError(message)
if (import.meta.env.DEV) {
logger.warn('[Library] relay search failed', { message, localFallback: local.length })
logger.warn('[Library] relay search failed', { message })
}
} finally {
setRelaySearchLoading(false)
}
}, [searchQuery, searchAxis, pubkey, indexEvents, engagement, blockedRelays, t])
}, [searchQuery, pubkey, indexEvents, engagement, blockedRelays])
const mineFilterOpts = useMemo(
() => ({
@ -490,14 +435,12 @@ export function useLibraryPublications(isActive: boolean) { @@ -490,14 +435,12 @@ 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

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

9
src/i18n/locales/de.ts

@ -1650,15 +1650,6 @@ export default { @@ -1650,15 +1650,6 @@ 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,15 +1673,6 @@ export default { @@ -1673,15 +1673,6 @@ 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.',
@ -1717,7 +1708,7 @@ export default { @@ -1717,7 +1708,7 @@ export default {
'Read this book': 'Read this book',
'libraryIndexCache.sectionTitle': 'Library publication index',
'libraryIndexCache.sectionBlurb':
'Kind-30040 catalog rows in your publication cache (same store as opened books). Clearing removes relay-discovered titles only—publications you have opened stay cached with their sections.',
'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.',
'libraryIndexCache.defaultsMobile':
'Default on mobile web: up to {{entries}} indexes, ~{{mb}} MB.',
'libraryIndexCache.defaultsElectron':
@ -1729,7 +1720,7 @@ export default { @@ -1729,7 +1720,7 @@ export default {
'libraryIndexCache.clear': 'Clear library index cache',
'libraryIndexCache.clearing': 'Clearing…',
'libraryIndexCache.clearConfirm':
'Clear relay-discovered library catalog entries? Opened publications and their sections stay in the publication cache.',
'Clear the Library index cache? The Library tab will reload indexes from relays on next visit. Opened publications stay in your publication reading cache.',
'libraryIndexCache.clearedToast': 'Library index cache cleared.',
'libraryIndexCache.clearFailed': 'Failed to clear library index cache.',
'Search page clear': 'Clear',

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

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

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

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

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

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

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

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

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

@ -8,10 +8,11 @@ import { @@ -8,10 +8,11 @@ import {
import { normalizeToDTag, parseAdvancedSearch } from '@/lib/search-parser'
import logger from '@/lib/logger'
import { extractNip32LabelValues, isBooklistNip32Label } from '@/lib/nip32-label'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationMetadataSearch } from '@/lib/index-relay-http'
import { queryIndexRelay, queryIndexRelayForLibrary, queryIndexRelayPublicationSearch } from '@/lib/index-relay-http'
import {
buildIndexByAddress,
buildStructuralPublicationIndexMap,
collectPublicationIndexEventIds,
collectReachableAddressesCached,
eventTagAddress,
filterValidIndexEvents,
@ -54,11 +55,7 @@ const INDEX_MAX_PAGES_PER_RELAY = 100 @@ -54,11 +55,7 @@ const INDEX_MAX_PAGES_PER_RELAY = 100
const INDEX_VERIFY_CHUNK = 80
const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44
/** Cap engagement relay queries to the first slice of the catalog (not the full index corpus). */
const MAX_TARGET_ADDRESSES = 120
const MAX_TARGET_EVENT_IDS = 160
const MAX_ENGAGEMENT_HTTP_CHUNKS = 6
const ENGAGEMENT_FETCH_TIMEOUT_MS = 25_000
const MAX_TARGET_ADDRESSES = 480
const HYDRATE_MISSING_CAP = 64
export const LIBRARY_PAGE_SIZE = 120
/** @deprecated Use {@link LIBRARY_PAGE_SIZE} */
@ -66,8 +63,6 @@ export const LIBRARY_RECENT_FALLBACK_LIMIT = LIBRARY_PAGE_SIZE @@ -66,8 +63,6 @@ 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. */
@ -203,13 +198,8 @@ type LibrarySearchSessionRow = { @@ -203,13 +198,8 @@ type LibrarySearchSessionRow = {
const librarySearchSessionCache = new Map<string, LibrarySearchSessionRow>()
function librarySearchQueryKey(
query: string,
axis?: LibraryPublicationRelaySearchAxis | null
): string {
const base = normalizeGeneralSearchQuery(query).toLowerCase()
if (!axis) return base
return `${axis}:${base}`
function librarySearchQueryKey(query: string): string {
return normalizeGeneralSearchQuery(query).toLowerCase()
}
function librarySearchFingerprint(context: LibrarySearchContext): string {
@ -240,9 +230,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string { @@ -240,9 +230,9 @@ function librarySearchFingerprint(context: LibrarySearchContext): string {
function getLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
opts?: { requireRelaySearch?: boolean; axis?: LibraryPublicationRelaySearchAxis | null }
opts?: { requireRelaySearch?: boolean }
): LibrarySearchSessionRow | null {
const key = librarySearchQueryKey(query, opts?.axis)
const key = librarySearchQueryKey(query)
if (!key) return null
const row = librarySearchSessionCache.get(key)
if (!row) return null
@ -254,10 +244,9 @@ function getLibrarySearchSessionRow( @@ -254,10 +244,9 @@ function getLibrarySearchSessionRow(
function putLibrarySearchSessionRow(
query: string,
context: LibrarySearchContext,
row: Omit<LibrarySearchSessionRow, 'fingerprint'>,
axis?: LibraryPublicationRelaySearchAxis | null
row: Omit<LibrarySearchSessionRow, 'fingerprint'>
): void {
const key = librarySearchQueryKey(query, axis)
const key = librarySearchQueryKey(query)
if (!key) return
librarySearchSessionCache.set(key, {
...row,
@ -268,10 +257,9 @@ function putLibrarySearchSessionRow( @@ -268,10 +257,9 @@ function putLibrarySearchSessionRow(
/** Sync read of cached search hits for the current index + engagement snapshot. */
export function peekLibrarySearchResults(
query: string,
context: LibrarySearchContext,
axis?: LibraryPublicationRelaySearchAxis | null
context: LibrarySearchContext
): LibraryPublicationEntry[] | null {
return getLibrarySearchSessionRow(query, context, { axis })?.entries ?? null
return getLibrarySearchSessionRow(query, context)?.entries ?? null
}
export function clearLibrarySearchSessionCache(): void {
@ -921,21 +909,8 @@ export async function fetchPublicationEngagementMaps( @@ -921,21 +909,8 @@ export async function fetchPublicationEngagementMaps(
return emptyPublicationEngagementMaps()
}
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 addressChunks = chunkArray([...targetAddresses], ENGAGEMENT_ADDRESS_CHUNK)
const eventIdChunks = chunkArray([...targetEventIds], ENGAGEMENT_EVENT_ID_CHUNK)
const { wsRelays, httpRelays } = splitWsAndHttpRelays(relayUrls)
const useWsEngagement = wsRelays.length > 0
if (import.meta.env.DEV) {
@ -1588,10 +1563,8 @@ export async function refreshLibraryEngagement( @@ -1588,10 +1563,8 @@ export async function refreshLibraryEngagement(
viewerPubkey?: string | null
): Promise<{ engagement: PublicationEngagementMaps; engaged: LibraryPublicationEntry[] }> {
const indexByAddress = buildIndexByAddress(indexEvents)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
indexByAddress
)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
indexRelayUrls,
@ -1616,24 +1589,23 @@ export async function refreshLibraryEngagement( @@ -1616,24 +1589,23 @@ export async function refreshLibraryEngagement(
}
}
const LIBRARY_SEARCH_BATCH_SIZE = 80
function collectLibraryPublicationIndexSearchRoots(
/** Search all cached kind-30040 indexes (library index store), mapping nested hits to top-level roots. */
export function searchLibraryPublicationIndex(
query: string,
indexEvents: Event[],
topLevelIds: Set<string>,
addressToRoot: Map<string, Event>,
axis: LibraryPublicationRelaySearchAxis | null | undefined,
roots: Map<string, Event>,
start: number,
end: number
): void {
indexByAddress: Map<string, Event>
): Event[] {
const q = query.trim()
if (!q || indexEvents.length === 0) return []
for (let i = start; i < end; i++) {
const ev = indexEvents[i]
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) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (topLevelIds.has(ev.id)) {
roots.set(ev.id, ev)
@ -1644,78 +1616,10 @@ function collectLibraryPublicationIndexSearchRoots( @@ -1644,78 +1616,10 @@ function collectLibraryPublicationIndexSearchRoots(
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
@ -1727,20 +1631,15 @@ export type LibrarySearchContext = { @@ -1727,20 +1631,15 @@ export type LibrarySearchContext = {
*/
export async function searchLibraryPublications(
query: string,
context: LibrarySearchContext,
axis?: LibraryPublicationRelaySearchAxis | null
context: LibrarySearchContext
): Promise<LibraryPublicationEntry[]> {
const q = query.trim()
if (!q) return []
const cached = getLibrarySearchSessionRow(q, context, { axis })
const cached = getLibrarySearchSessionRow(q, context)
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] search cache hit', {
query: q,
axis: axis ?? 'all',
relaySearched: cached.relaySearched
})
logger.info('[Library] search cache hit', { query: q, relaySearched: cached.relaySearched })
}
return cached.entries
}
@ -1752,7 +1651,7 @@ export async function searchLibraryPublications( @@ -1752,7 +1651,7 @@ export async function searchLibraryPublications(
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const indexByAddress = buildIndexByAddress(indexEvents)
const fromIndex = await searchLibraryPublicationIndexAsync(q, indexEvents, indexByAddress, axis)
const fromIndex = searchLibraryPublicationIndex(q, indexEvents, indexByAddress)
const rootMap = new Map<string, Event>()
for (const root of fromIndex) rootMap.set(root.id, root)
@ -1768,7 +1667,7 @@ export async function searchLibraryPublications( @@ -1768,7 +1667,7 @@ export async function searchLibraryPublications(
)
for (const ev of fromReadingCache) {
if (ev.kind !== ExtendedKind.PUBLICATION) continue
if (!publicationIndexMatchesSearchQueryWithAxis(ev, q, axis)) continue
if (!publicationIndexMatchesSearchQuery(ev, q)) continue
if (rootMap.has(ev.id)) continue
const addr = eventTagAddress(ev)
@ -1795,17 +1694,12 @@ export async function searchLibraryPublications( @@ -1795,17 +1694,12 @@ export async function searchLibraryPublications(
const entries = sortLibraryPublications(libraryEntriesFromRoots(roots, indexByAddress, engagement))
const searchContext: LibrarySearchContext = { indexEvents, engagement }
const prev = getLibrarySearchSessionRow(q, searchContext, { axis })
putLibrarySearchSessionRow(
q,
searchContext,
{
entries,
mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
relaySearched: prev?.relaySearched ?? false
},
axis
)
const prev = getLibrarySearchSessionRow(q, searchContext)
putLibrarySearchSessionRow(q, searchContext, {
entries,
mergedIndexEvents: prev?.mergedIndexEvents ?? indexEvents,
relaySearched: prev?.relaySearched ?? false
})
return entries
}
@ -1833,15 +1727,6 @@ function normalizePublicationDTag(term: string): string { @@ -1833,15 +1727,6 @@ 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()
@ -1857,106 +1742,14 @@ export function publicationQueryDTagVariants(query: string): string[] { @@ -1857,106 +1742,14 @@ export function publicationQueryDTagVariants(query: string): string[] {
return [...seen]
}
/** Normalized needles for publication metadata tag match (d / title / author). */
export function publicationQueryNeedles(query: string): string[] {
const raw = normalizeGeneralSearchQuery(query.trim())
if (!raw) return []
const lower = raw.toLowerCase()
const normalized = lower.replace(/\s+/g, ' ').trim()
const hyphen = lower
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return [...new Set([lower, normalized, hyphen].filter(Boolean))]
}
function publicationTagValueMatchesNeedles(
tagValue: string,
needles: string[],
exactOnly: boolean
): boolean {
const val = tagValue.trim().toLowerCase()
const valSpaced = val.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
for (const needle of needles) {
if (!needle) continue
const needleSpaced = needle.replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
if (val === needle || valSpaced === needleSpaced) return true
if (exactOnly || needle.length < 2) continue
if (val.includes(needle) || valSpaced.includes(needleSpaced)) return true
}
return false
}
export function publicationMetadataTagMatchesQuery(
event: Event,
tagName: 'd' | 'title' | 'author',
query: string
): boolean {
const needles = publicationQueryNeedles(query)
if (needles.length === 0) return false
const exactOnly = tagName === 'd'
for (const tag of event.tags ?? []) {
if ((tag[0] || '').toLowerCase() !== tagName) continue
const value = tag[1]?.trim()
if (value && publicationTagValueMatchesNeedles(value, needles, exactOnly)) return true
}
return false
}
function publicationRelaySearchSourceTerms(query: string): string[] {
const raw = query.trim()
if (!raw) return []
const terms = new Set<string>([raw])
const adv = parseAdvancedSearch(raw)
if (adv.title) {
for (const title of Array.isArray(adv.title) ? adv.title : [adv.title]) {
const t = title.trim()
if (t) terms.add(t)
}
}
return [...terms]
}
function publicationRelaySearchTermsForAxis(
axis: LibraryPublicationRelaySearchAxis,
/**
* 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: {
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[] {
limit?: number
}): Filter[] {
const searchRaw = opts.query.trim()
if (!searchRaw) return []
@ -1964,162 +1757,67 @@ export function buildLibraryPublicationRelaySearchFiltersForAxis( @@ -1964,162 +1757,67 @@ export function buildLibraryPublicationRelaySearchFiltersForAxis(
const kind = ExtendedKind.PUBLICATION
const seen = new Set<string>()
const out: Filter[] = []
if (axis === 'author') {
const npub = tryNpubFromQuery(searchRaw)
if (npub) {
addPublicationKindFilter(out, seen, { kinds: [kind], authors: [npub], limit })
return out
}
return out
}
if (axis === 'title') {
return out
const add = (filter: Filter) => {
const key = JSON.stringify(filter)
if (seen.has(key)) return
seen.add(key)
out.push(filter)
}
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 })
const npub = tryNpubFromQuery(searchRaw)
if (npub) {
add({ kinds: [kind], authors: [npub], 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)) continue
seen.add(key)
out.push(filter)
}
const dTags = publicationQueryDTagVariants(searchRaw)
if (dTags.length > 0) {
add({ kinds: [kind], '#d': dTags, limit })
}
return out
}
export function filterEventsForPublicationRelaySearchAxis(
events: Event[],
axis: LibraryPublicationRelaySearchAxis,
query: string
): Event[] {
const terms = publicationRelaySearchTermsForAxis(axis, query)
if (terms.length === 0) return []
return events.filter((event) => {
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))
})
}
export function publicationIndexMatchesSearchQueryWithAxis(
event: Event,
query: string,
axis?: LibraryPublicationRelaySearchAxis | null
): boolean {
if (!axis) return publicationIndexMatchesSearchQuery(event, query)
return filterEventsForPublicationRelaySearchAxis([event], axis, query).length > 0
}
async function scanHttpIndexRelayForPublicationAxis(
httpRelay: string,
axis: LibraryPublicationRelaySearchAxis,
term: string
): Promise<Event[]> {
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_HTTP_PAGE_LIMIT }
const matched: Event[] = []
const seen = new Set<string>()
const collect = (batch: Event[]) => {
for (const ev of filterEventsForPublicationRelaySearchAxis(batch, axis, term)) {
if (!seen.has(ev.id)) {
seen.add(ev.id)
matched.push(ev)
}
}
const searchNorm = normalizeGeneralSearchQuery(searchRaw)
add({ kinds: [kind], search: searchRaw, limit })
if (searchNorm !== searchRaw) {
add({ kinds: [kind], search: searchNorm, limit })
}
let firstPage: Event[]
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)
})
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 })
}
return []
}
collect(firstPage)
if (matched.length >= LIBRARY_RELAY_SEARCH_LIMIT || firstPage.length === 0) {
return matched.slice(0, LIBRARY_RELAY_SEARCH_LIMIT)
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 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
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 })
}
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)
return out
}
/** Query document relays for kind-30040 indexes matching {@link buildLibraryPublicationRelaySearchFilters}. */
@ -2127,7 +1825,7 @@ export async function searchLibraryPublicationsOnRelays( @@ -2127,7 +1825,7 @@ export async function searchLibraryPublicationsOnRelays(
query: string,
relayUrls: string[],
context: LibrarySearchContext,
options?: { forceRefresh?: boolean; axis?: LibraryPublicationRelaySearchAxis | null }
options?: { forceRefresh?: boolean }
): Promise<{
events: Event[]
entries: LibraryPublicationEntry[]
@ -2140,10 +1838,7 @@ export async function searchLibraryPublicationsOnRelays( @@ -2140,10 +1838,7 @@ export async function searchLibraryPublicationsOnRelays(
}
if (!options?.forceRefresh) {
const cached = getLibrarySearchSessionRow(q, context, {
requireRelaySearch: true,
axis: options?.axis
})
const cached = getLibrarySearchSessionRow(q, context, { requireRelaySearch: true })
if (cached) {
if (import.meta.env.DEV) {
logger.info('[Library] relay search cache hit', { query: q })
@ -2157,88 +1852,52 @@ export async function searchLibraryPublicationsOnRelays( @@ -2157,88 +1852,52 @@ 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) {
batches.push(
queryService
.fetchEvents(wsRelays, filters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000,
firstRelayResultGraceMs: false
})
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', {
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
if (wsRelays.length > 0 && hasNip01Filters) {
for (const httpRelay of httpRelays) {
for (const filter of filters) {
batches.push(
queryService
.fetchEvents(wsRelays, axisFilters, {
globalTimeout: LIBRARY_RELAY_SEARCH_TIMEOUT_MS,
eoseTimeout: 8_000,
firstRelayResultGraceMs: false
})
.then((events) => filterEventsForPublicationRelaySearchAxis(events, axis, q))
queryIndexRelayPublicationSearch(httpRelay, filter)
.then((page) => page.events as Event[])
.catch((e) => {
if (import.meta.env.DEV) {
logger.warn('[Library] WS publication search failed', {
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
for (const httpRelay of httpRelays) {
if (hasNip01Filters) {
for (const filter of axisFilters) {
batches.push(
queryIndexRelayForLibrary(httpRelay, filter)
.then((page) => filterEventsForPublicationRelaySearchAxis(page.events as Event[], axis, q))
.catch((e) => {
if (import.meta.env.DEV) {
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', {
logger.warn('[Library] HTTP publication search failed', {
relay: httpRelay,
axis,
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
)
}
}
if (batches.length === 0) {
return { events: [], entries: [], mergedIndexEvents: context.indexEvents ?? [], fromCache: false }
}
const settled = await Promise.all(batches)
const networkEvents = dedupeEventsById(settled.flat())
const valid = filterValidIndexEvents(networkEvents)
@ -2249,7 +1908,7 @@ export async function searchLibraryPublicationsOnRelays( @@ -2249,7 +1908,7 @@ export async function searchLibraryPublicationsOnRelays(
void persistLibraryIndexCacheEvents(mergedIndex)
}
const indexByAddress = buildIndexByAddress(mergedIndex)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress, options?.axis)
const roots = searchLibraryPublicationIndex(q, mergedIndex, indexByAddress)
const engagement = context.engagement ?? EMPTY_ENGAGEMENT
const entries = sortLibraryPublications(
libraryEntriesFromRoots(roots, indexByAddress, engagement)
@ -2259,22 +1918,15 @@ export async function searchLibraryPublicationsOnRelays( @@ -2259,22 +1918,15 @@ export async function searchLibraryPublicationsOnRelays(
indexEvents: mergedIndex,
engagement
}
putLibrarySearchSessionRow(
q,
searchContext,
{
entries,
mergedIndexEvents: mergedIndex,
relaySearched: true
},
options?.axis
)
putLibrarySearchSessionRow(q, searchContext, {
entries,
mergedIndexEvents: mergedIndex,
relaySearched: true
})
if (import.meta.env.DEV) {
logger.info('[Library] relay search done', {
axes: LIBRARY_PUBLICATION_RELAY_SEARCH_AXES.length,
filters: filterCount,
batches: batches.length,
filters: filters.length,
network: networkEvents.length,
valid: valid.length,
roots: roots.length
@ -2325,66 +1977,19 @@ function collectTargetAddressesFromIndexes( @@ -2325,66 +1977,19 @@ 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)
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
}
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
}
const rootAddr = eventTagAddress(root)
if (rootAddr) {
addresses.add(rootAddr)
if (addresses.size >= MAX_TARGET_ADDRESSES || eventIds.size >= MAX_TARGET_EVENT_IDS) {
break outer
}
if (addresses.size >= MAX_TARGET_ADDRESSES) break outer
}
}
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)
}
return addresses
}
async function buildEngagedFromCache(
@ -2397,10 +2002,8 @@ async function buildEngagedFromCache( @@ -2397,10 +2002,8 @@ async function buildEngagedFromCache(
const topLevel = getTopLevelIndexEvents(indexEvents)
let maps = engagement
if (!maps) {
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
indexByAddress
)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
indexRelayUrls,
@ -2477,10 +2080,11 @@ async function runLibraryPublicationIndexLoad( @@ -2477,10 +2080,11 @@ async function runLibraryPublicationIndexLoad(
if (!options?.forceRefresh && sessionCache?.relayKey === key) {
const cachedIndexEvents = indexEventsFromCache(sessionCache)
if (sessionCache.viewerPubkey !== viewerPubkey) {
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
const targetAddresses = collectTargetAddressesFromIndexes(
cachedIndexEvents,
sessionCache.indexByAddress
)
const targetEventIds = collectPublicationIndexEventIds(cachedIndexEvents)
const engagementRelayUrls = await buildLibraryEngagementRelayUrls(
viewerPubkey ?? undefined,
relayUrls,
@ -2544,10 +2148,8 @@ async function runLibraryPublicationIndexLoad( @@ -2544,10 +2148,8 @@ async function runLibraryPublicationIndexLoad(
}
topLevel = getTopLevelIndexEventsFromMap(indexByAddress)
const { addresses: targetAddresses, eventIds: targetEventIds } = collectEngagementTargets(
indexEvents,
buildIndexByAddress(indexEvents)
)
const targetAddresses = collectTargetAddressesFromIndexes(indexEvents, indexByAddress)
const targetEventIds = collectPublicationIndexEventIds(indexEvents)
if (import.meta.env.DEV) {
logger.info('[Library] fetching engagement', {
targetAddresses: targetAddresses.size,
@ -2608,7 +2210,7 @@ export function clearLibraryPublicationIndexCache(): void { @@ -2608,7 +2210,7 @@ export function clearLibraryPublicationIndexCache(): void {
clearLibrarySearchSessionCache()
}
/** Clears Library tab session cache and relay-discovered catalog masters (opened publications stay). */
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
export async function clearAllLibraryIndexCaches(): Promise<void> {
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, PROFILE_RELAY_URLS } from '@/constants'
import { DOCUMENT_RELAY_URLS, FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants'
import { describe, expect, it } from 'vitest'
import {
isMetadataPolicyActiveReadGrantRelay,
@ -15,14 +15,13 @@ describe('metadata-policy-curated-relays', () => { @@ -15,14 +15,13 @@ 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('wss://nostr21.com/')).toBe(false)
expect(isMetadataPolicyOperationScopedRelay(FAST_READ_RELAY_URLS[0]!)).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('wss://nostr21.com/')).toBe(true)
expect(isMetadataPolicyActiveReadGrantRelay('wss://relay.primal.net/')).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay(FAST_READ_RELAY_URLS[0]!)).toBe(false)
expect(isMetadataPolicyActiveReadGrantRelay('wss://nostr.wirednet.jp/')).toBe(false)
})
})

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

@ -65,6 +65,7 @@ function prioritizeNewUserTemplateRelays(urls: string[]): string[] { @@ -65,6 +65,7 @@ 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,7 +14,6 @@ import { @@ -14,7 +14,6 @@ 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/'
@ -76,7 +75,7 @@ export function buildNewUserProfileDraft(pubkey: string): TDraftEvent { @@ -76,7 +75,7 @@ export function buildNewUserProfileDraft(pubkey: string): TDraftEvent {
export function buildNewUserFavoriteRelaysDraft(): TDraftEvent {
return createFavoriteRelaysDraftEvent(
dedupeNormalizeRelayUrlsOrdered([...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL]),
[...DEFAULT_FAVORITE_RELAYS, NEW_USER_TRENDING_RELAY_URL],
[]
)
}

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

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

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

@ -1,11 +1,6 @@ @@ -1,11 +1,6 @@
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()
@ -27,7 +22,12 @@ export class Nip07Signer implements ISigner { @@ -27,7 +22,12 @@ export class Nip07Signer implements ISigner {
private pubkey: string | null = null
async init() {
for (let attempt = 0; attempt < NIP07_INJECT_MAX_ATTEMPTS; attempt++) {
const checkInterval = 100
// Some browser extensions inject `window.nostr` a bit later during startup/reload.
// Keep waiting longer to avoid false "no signer extension" failures on session restore.
const maxAttempts = 120
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (window.nostr) {
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, NIP07_INJECT_CHECK_INTERVAL_MS))
await new Promise((resolve) => setTimeout(resolve, checkInterval))
}
throw new Error(

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

@ -1,11 +1,7 @@ @@ -1,11 +1,7 @@
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',
'libraryPublicationIndex'
] as const
const LEGACY_DELETED_OBJECT_STORES = ['relayInfoEvents', 'spellListSourceEvents'] as const
import {
publicationCoordinateLookupKeys,
splitPublicationCoordinate
@ -32,7 +28,9 @@ import logger from '@/lib/logger' @@ -32,7 +28,9 @@ 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'
@ -73,12 +71,6 @@ type TValue<T = any> = { @@ -73,12 +71,6 @@ 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}. */
@ -158,6 +150,7 @@ export const StoreNames = { @@ -158,6 +150,7 @@ 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). */
@ -189,6 +182,7 @@ export type TCalendarRsvpCacheRow = { @@ -189,6 +182,7 @@ 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,
@ -243,7 +237,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet<string> = new Set([ @@ -243,7 +237,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 = 43
const DB_VERSION = 41
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@ -259,7 +253,7 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]): @@ -259,7 +253,7 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]):
return new Error(message)
}
type TLegacyLibraryPublicationIndexRow = {
type TLibraryPublicationIndexCacheRow = {
key: string
value: Event
addedAt: number
@ -267,7 +261,7 @@ type TLegacyLibraryPublicationIndexRow = { @@ -267,7 +261,7 @@ type TLegacyLibraryPublicationIndexRow = {
approxBytes: number
}
function approxPublicationCatalogMasterBytes(ev: Event): number {
function approxLibraryPublicationIndexRowBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
@ -275,125 +269,53 @@ function approxPublicationCatalogMasterBytes(ev: Event): number { @@ -275,125 +269,53 @@ function approxPublicationCatalogMasterBytes(ev: Event): number {
}
}
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}`
}
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
}
/** 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 storeRowIsPublicationEvent(event: Event): boolean {
return (
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.NOSTR_SPECIFICATION ||
event.kind === kinds.LongFormArticle
)
}
function ensurePublicationEventsCatalogIndexes(store: IDBObjectStore): void {
if (!store.indexNames.contains('catalogMaster')) {
store.createIndex('catalogMaster', 'catalogMaster', { unique: false })
}
}
function backfillPublicationCatalogMetadata(store: IDBObjectStore): void {
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) return
const row = cursor.value as TValue<Event>
const event = row?.value
if (!event || !storeRowIsPublicationEvent(event)) {
cursor.continue()
return
}
const isCatalogMaster = event.kind === ExtendedKind.PUBLICATION && !row.masterPublicationKey
const next: TValue<Event> = { ...row }
if (isCatalogMaster) {
next.catalogMaster = 1
next.lastAccessAt = row.lastAccessAt ?? row.addedAt ?? Date.now()
next.catalogBytes = row.catalogBytes ?? approxPublicationCatalogMasterBytes(event)
} else {
next.catalogMaster = 0
}
if (
next.catalogMaster !== row.catalogMaster ||
next.lastAccessAt !== row.lastAccessAt ||
next.catalogBytes !== row.catalogBytes
) {
const updateReq = cursor.update(next)
updateReq.onsuccess = () => cursor.continue()
updateReq.onerror = () => cursor.continue()
} else {
cursor.continue()
}
}
}
/** v43: merge legacy libraryPublicationIndex into publicationEvents; catalogMaster index. */
function migrateLegacyLibraryPublicationIndexStore(transaction: IDBTransaction, db: IDBDatabase): void {
const legacyName = 'libraryPublicationIndex'
if (!db.objectStoreNames.contains(legacyName) || !db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
return
}
const legacyStore = transaction.objectStore(legacyName)
const pubStore = transaction.objectStore(StoreNames.PUBLICATION_EVENTS)
const legacyRows: TLegacyLibraryPublicationIndexRow[] = []
const readReq = legacyStore.openCursor()
const readReq = store.openCursor()
readReq.onsuccess = () => {
const cursor = readReq.result as IDBCursorWithValue | null
const cursor = readReq.result
if (cursor) {
legacyRows.push(cursor.value as TLegacyLibraryPublicationIndexRow)
rows.push(cursor.value as TLibraryPublicationIndexCacheRow)
cursor.continue()
return
}
for (const row of legacyRows) {
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 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)
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
}
const winner = pickNewerPublicationIndexEvent(existing.value, ev)
byAddress.set(addr, {
key: addr,
value: winner,
addedAt: Math.min(existing.addedAt, row.addedAt ?? existing.addedAt),
lastAccessAt: Math.max(existing.lastAccessAt, row.lastAccessAt ?? row.addedAt ?? 0),
approxBytes: approxLibraryPublicationIndexRowBytes(winner)
})
}
const clearReq = store.clear()
clearReq.onsuccess = () => {
for (const row of byAddress.values()) {
store.put(row)
}
}
}
@ -425,11 +347,9 @@ function ensureMissingObjectStores(db: IDBDatabase): void { @@ -425,11 +347,9 @@ 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.PUBLICATION_EVENTS) {
const store = db.createObjectStore(storeName, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(store)
} else if (storeName === 'libraryPublicationIndex') {
/* dropped in v43 — do not recreate */
} else if (storeName === StoreNames.LIBRARY_PUBLICATION_INDEX) {
const lib = db.createObjectStore(storeName, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
} else {
db.createObjectStore(storeName, { keyPath: 'key' })
}
@ -594,8 +514,7 @@ class IndexedDbService { @@ -594,8 +514,7 @@ class IndexedDbService {
db.createObjectStore(StoreNames.RELAY_INFOS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) {
const pub = db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
ensurePublicationEventsCatalogIndexes(pub)
db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(StoreNames.PUBLIC_LIVELY_RELAYS)) {
db.createObjectStore(StoreNames.PUBLIC_LIVELY_RELAYS, { keyPath: 'key' })
@ -672,29 +591,15 @@ class IndexedDbService { @@ -672,29 +591,15 @@ class IndexedDbService {
}
}
if (event.oldVersion < 40) {
if (!db.objectStoreNames.contains('libraryPublicationIndex')) {
const lib = db.createObjectStore('libraryPublicationIndex', { keyPath: 'key' })
if (!db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
const lib = db.createObjectStore(StoreNames.LIBRARY_PUBLICATION_INDEX, { keyPath: 'key' })
lib.createIndex('lastAccessAt', 'lastAccessAt', { unique: false })
}
}
if (event.oldVersion < 41) {
const tx = (event.target as IDBOpenDBRequest).transaction
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')
if (tx && db.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
migrateLibraryPublicationIndexCacheToAddressKeys(tx)
}
}
ensureMissingObjectStores(db)
@ -785,11 +690,7 @@ class IndexedDbService { @@ -785,11 +690,7 @@ class IndexedDbService {
return resolve(oldValue.value)
}
const putRequest = store.put(
storeName === StoreNames.PUBLICATION_EVENTS
? buildPublicationStoreRow(key, cleanEvent, oldValue)
: this.formatValue(key, cleanEvent)
)
const putRequest = store.put(this.formatValue(key, cleanEvent))
putRequest.onsuccess = () => {
transaction.commit()
resolve(cleanEvent)
@ -1484,14 +1385,16 @@ class IndexedDbService { @@ -1484,14 +1385,16 @@ 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 = buildPublicationStoreRow(key, oldValue.value, oldValue, masterKey)
const value = this.formatValue(key, oldValue.value)
value.masterPublicationKey = masterKey
store.put(value)
}
transaction.commit()
return resolve(oldValue.value)
}
// Store with master key link
const value = buildPublicationStoreRow(key, cleanEvent, oldValue, masterKey)
const value = this.formatValue(key, cleanEvent)
value.masterPublicationKey = masterKey
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
@ -1539,7 +1442,8 @@ class IndexedDbService { @@ -1539,7 +1442,8 @@ 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 = buildPublicationStoreRow(key, event, undefined, masterKey)
const value = this.formatValue(key, event)
value.masterPublicationKey = masterKey
const putRequest = store.put(value)
putRequest.onsuccess = () => {
transaction.commit()
@ -2176,7 +2080,7 @@ class IndexedDbService { @@ -2176,7 +2080,7 @@ class IndexedDbService {
storeInfo[storeName] = req.result
pending--
if (pending === 0) {
void this.normalizePublicationStoreInfoCount(storeInfo).then(resolve).catch(reject)
resolve(storeInfo)
}
}
req.onerror = (ev) => {
@ -2186,116 +2090,6 @@ class IndexedDbService { @@ -2186,116 +2090,6 @@ class IndexedDbService {
})
}
/** Master kind-30040 catalog rows in {@link StoreNames.PUBLICATION_EVENTS}. */
async countPublicationStoreMasterEvents(): Promise<number> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return 0
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
if (store.indexNames.contains('catalogMaster')) {
const req = store.index('catalogMaster').count(IDBKeyRange.only(1))
req.onsuccess = () => {
tx.commit()
resolve(req.result)
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
return
}
const req = store.openCursor()
let count = 0
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(count)
return
}
const item = cursor.value as TValue<Event> | undefined
if (item?.catalogMaster === 1 || (item?.value?.kind === ExtendedKind.PUBLICATION && !item.masterPublicationKey)) {
count += 1
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
/** Kind-30040 library catalog masters in {@link StoreNames.PUBLICATION_EVENTS}. */
async getPublicationCatalogIndexEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
const out: Event[] = []
const collectRow = (row: TValue<Event> | undefined) => {
const event = row?.value
if (!event || event.kind !== ExtendedKind.PUBLICATION) return
if (row?.catalogMaster === 1 || !row.masterPublicationKey) out.push(event)
}
if (store.indexNames.contains('catalogMaster')) {
const req = store.index('catalogMaster').openCursor(IDBKeyRange.only(1))
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
collectRow(cursor.value as TValue<Event>)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
return
}
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
collectRow(cursor.value as TValue<Event>)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
/** @deprecated Use {@link getPublicationCatalogIndexEvents}. */
async getMasterPublicationIndexEventsFromReadingCache(): Promise<Event[]> {
return this.getPublicationCatalogIndexEvents()
}
private async normalizePublicationStoreInfoCount(storeInfo: Record<string, number>): Promise<Record<string, number>> {
if (!storeInfo[StoreNames.PUBLICATION_EVENTS]) return storeInfo
try {
storeInfo[StoreNames.PUBLICATION_EVENTS] = await this.countPublicationStoreMasterEvents()
} catch (e) {
logger.warn('[indexedDb] countPublicationStoreMasterEvents failed', { e })
}
return storeInfo
}
async getStoreItems(storeName: string): Promise<TValue<any>[]> {
await this.initPromise
if (!this.db || !this.db.objectStoreNames.contains(storeName)) {
@ -2493,27 +2287,13 @@ class IndexedDbService { @@ -2493,27 +2287,13 @@ 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(new Error('Not a replaceable event store'))
return Promise.reject('Not a replaceable event store')
}
return this.cleanupDuplicateEventsByDedupeKey(storeName, (event) =>
this.getReplaceableEventKeyFromEvent(event)
)
}
private async cleanupDuplicateEventsByDedupeKey(
storeName: string,
dedupeKeyForEvent: (event: Event) => string
): Promise<{ deleted: number; kept: number }> {
// First pass: identify duplicates
const allItems = await this.getStoreItems(storeName)
const eventMap = new Map<string, { key: string; event: Event; addedAt: number }>()
const keysToDelete: string[] = []
@ -2525,41 +2305,47 @@ class IndexedDbService { @@ -2525,41 +2305,47 @@ 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 dedupeKey = dedupeKeyForEvent(item.value)
const existing = eventMap.get(dedupeKey)
if (
!existing ||
item.value.created_at > existing.event.created_at ||
(item.value.created_at === existing.event.created_at && item.addedAt > existing.addedAt)
) {
const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value)
const existing = eventMap.get(replaceableKey)
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
if (existing) {
keysToDelete.push(existing.key)
}
eventMap.set(dedupeKey, {
eventMap.set(replaceableKey, {
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) {
logger.warn('Failed to get dedupe key for item', { key: item.key, error })
// If we can't generate a replaceable key, skip this item
logger.warn('Failed to get replaceable key for item', { key: item.key, error })
invalidItemsCount++
continue
}
}
// Second pass: delete duplicates
const totalProcessed = eventMap.size + keysToDelete.length
const actualKept = eventMap.size
if (keysToDelete.length === 0) {
const totalProcessed = eventMap.size + keysToDelete.length
// No duplicates found, but verify counts match
if (totalProcessed + invalidItemsCount !== allItems.length) {
logger.warn('Count mismatch while cleaning up replaceable events', {
totalItems: allItems.length,
@ -2567,7 +2353,7 @@ class IndexedDbService { @@ -2567,7 +2353,7 @@ class IndexedDbService {
invalid: invalidItemsCount
})
}
return { deleted: 0, kept: actualKept }
return Promise.resolve({ deleted: 0, kept: actualKept })
}
return new Promise((resolve) => {
@ -2577,13 +2363,14 @@ class IndexedDbService { @@ -2577,13 +2363,14 @@ 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', {
@ -2600,6 +2387,7 @@ class IndexedDbService { @@ -2600,6 +2387,7 @@ class IndexedDbService {
completedCount++
if (completedCount === keysToDelete.length) {
transaction.commit()
const actualKept = eventMap.size
resolve({ deleted: deletedCount, kept: actualKept })
}
}
@ -3968,19 +3756,22 @@ class IndexedDbService { @@ -3968,19 +3756,22 @@ class IndexedDbService {
}
}
private async listPublicationCatalogMasterRows(): Promise<
Array<{ key: string; lastAccessAt: number; bytes: number; hasNested: boolean }>
> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) return []
private approxLibraryPublicationIndexBytes(ev: Event): number {
try {
return new Blob([JSON.stringify(ev)]).size
} catch {
return 2048
}
}
const nestedMasterKeys = new Set<string>()
const masters: Array<{ key: string; lastAccessAt: number; bytes: number }> = []
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return 0
const toDelete: string[] = []
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly')
const store = tx.objectStore(StoreNames.PUBLICATION_EVENTS)
const req = store.openCursor()
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) {
@ -3988,19 +3779,53 @@ class IndexedDbService { @@ -3988,19 +3779,53 @@ class IndexedDbService {
resolve()
return
}
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)
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
) {
masters.push({
key,
lastAccessAt: row.lastAccessAt ?? row.addedAt,
bytes: row.catalogBytes ?? approxPublicationCatalogMasterBytes(row.value as Event)
})
tx.commit()
resolve(true)
return
}
cursor.continue()
}
@ -4009,94 +3834,241 @@ class IndexedDbService { @@ -4009,94 +3834,241 @@ class IndexedDbService {
reject(idbEventToError(e))
}
})
}
return masters.map((row) => ({ ...row, hasNested: nestedMasterKeys.has(row.key) }))
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
const out: Event[] = []
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve(out)
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
if (row?.value?.kind === ExtendedKind.PUBLICATION) out.push(row.value)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async 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 getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) {
return { count: 0, bytes: 0 }
}
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
let count = 0
let bytes = 0
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve({ count, bytes })
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
count += 1
bytes += row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async mergePublicationCatalogIndexEvents(
async mergeLibraryPublicationIndexCacheEvents(
events: Event[],
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX) || events.length === 0) {
return
}
const now = Date.now()
const storeName = StoreNames.LIBRARY_PUBLICATION_INDEX
const rowsToWrite: Array<{ key: string; event: Event }> = []
for (const ev of events) {
if (ev.kind !== ExtendedKind.PUBLICATION || !isStructuralPublicationIndex(ev)) continue
await this.putReplaceableEvent(ev)
const key = eventTagAddress(ev)
if (!key) continue
const existing = rowsToWrite.find((row) => row.key === key)
if (!existing) {
rowsToWrite.push({ key, event: ev })
continue
}
existing.event = pickNewerPublicationIndexEvent(existing.event, ev)
}
await this.prunePublicationCatalogMasters(opts.maxEntries, opts.maxBytes)
}
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>()
if (rowsToWrite.length === 0) return
for (const victim of candidates) {
if (totalCount <= maxEntries && totalBytes <= maxBytes) break
toDelete.add(victim.key)
totalBytes -= victim.bytes
totalCount -= 1
}
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(storeName, 'readwrite')
const store = tx.objectStore(storeName)
let pending = rowsToWrite.length
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.PUBLICATION_EVENTS, key)
}
}
const finishOne = () => {
pending -= 1
if (pending === 0) {
tx.commit()
resolve()
}
}
/** 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)
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)
}
const put = store.put(row)
put.onsuccess = () => finishOne()
put.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
}
get.onerror = (e) => {
finishOne()
if (pending === 0) reject(idbEventToError(e))
}
}
}
}
})
/** @deprecated Use {@link getPublicationCatalogFootprint}. */
async getLibraryPublicationIndexCacheFootprint(): Promise<{ count: number; bytes: number }> {
return this.getPublicationCatalogFootprint()
await this.pruneLibraryPublicationIndexCache(opts.maxEntries, opts.maxBytes)
}
/** @deprecated Use {@link getPublicationCatalogIndexEvents}. */
async getLibraryPublicationIndexCacheEvents(): Promise<Event[]> {
return this.getPublicationCatalogIndexEvents()
}
/** 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
/** @deprecated Use {@link mergePublicationCatalogIndexEvents}. */
async mergeLibraryPublicationIndexCacheEvents(
events: Event[],
opts: { maxEntries: number; maxBytes: number }
): Promise<void> {
return this.mergePublicationCatalogIndexEvents(events, opts)
}
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 clearPublicationCatalogDiscoveryOnly}. */
async clearLibraryPublicationIndexCacheStore(): Promise<void> {
return this.clearPublicationCatalogDiscoveryOnly()
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
}
/** @deprecated No-op after v43 consolidation. */
async reconcileLibraryPublicationIndexCache(_canonical: PublicationIndexMap): Promise<void> {}
async pruneLibraryPublicationIndexCache(maxEntries: number, maxBytes: number): Promise<void> {
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.LIBRARY_PUBLICATION_INDEX)) return
/** @deprecated No-op after v43 consolidation. */
async libraryPublicationIndexCacheHasLegacyKeys(): Promise<boolean> {
return false
const rows: Array<{ key: string; lastAccessAt: number; bytes: number }> = []
await new Promise<void>((resolve, reject) => {
const tx = this.db!.transaction(StoreNames.LIBRARY_PUBLICATION_INDEX, 'readonly')
const req = tx.objectStore(StoreNames.LIBRARY_PUBLICATION_INDEX).openCursor()
req.onsuccess = () => {
const cursor = req.result as IDBCursorWithValue | null
if (!cursor) {
tx.commit()
resolve()
return
}
const row = cursor.value as TLibraryPublicationIndexCacheRow
rows.push({
key: cursor.key as string,
lastAccessAt: row.lastAccessAt ?? row.addedAt,
bytes: row.approxBytes ?? this.approxLibraryPublicationIndexBytes(row.value)
})
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
rows.sort((a, b) => a.lastAccessAt - b.lastAccessAt)
const toDelete = new Set<string>()
let totalBytes = rows.reduce((s, r) => s + r.bytes, 0)
let totalCount = rows.length
while (totalCount > maxEntries || totalBytes > maxBytes) {
const victim = rows.shift()
if (!victim) break
toDelete.add(victim.key)
totalBytes -= victim.bytes
totalCount -= 1
}
for (const key of toDelete) {
await this.deleteStoreItem(StoreNames.LIBRARY_PUBLICATION_INDEX, key)
}
}
/** @deprecated No-op after v43 consolidation. */
async pruneUnverifiedLibraryPublicationIndexCacheEvents(): Promise<number> {
return 0
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))
}
})
}
/**

2
src/services/nip89.service.ts

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

Loading…
Cancel
Save