Browse Source

make cache more accessible

imwald
Silberengel 3 weeks ago
parent
commit
fdbf8022d4
  1. 16
      jsconfig.json
  2. 5
      src/App.tsx
  3. 577
      src/components/CacheBrowser/CacheBrowserDialog.tsx
  4. 12
      src/components/HelpAndAccountMenu.tsx
  5. 357
      src/components/InBrowserCacheSetting/index.tsx
  6. 29
      src/contexts/cache-browser-context.tsx
  7. 9
      src/i18n/locales/en.ts
  8. 159
      src/services/indexed-db.service.ts

16
jsconfig.json

@ -0,0 +1,16 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true
},
"include": ["src"]
}

5
src/App.tsx

@ -28,6 +28,7 @@ import { ZapProvider } from '@/providers/ZapProvider'
import SlowConnectionHint from '@/components/SlowConnectionHint' import SlowConnectionHint from '@/components/SlowConnectionHint'
import StartupSessionBanner from '@/components/StartupSessionBanner' import StartupSessionBanner from '@/components/StartupSessionBanner'
import VersionUpdateBanner from '@/components/VersionUpdateBanner' import VersionUpdateBanner from '@/components/VersionUpdateBanner'
import { CacheBrowserProvider } from './contexts/cache-browser-context'
import { PageManager } from './PageManager' import { PageManager } from './PageManager'
export default function App(): JSX.Element { export default function App(): JSX.Element {
@ -58,7 +59,9 @@ export default function App(): JSX.Element {
<KindFilterProvider> <KindFilterProvider>
<UserPreferencesProvider> <UserPreferencesProvider>
<LiveActivitiesProvider> <LiveActivitiesProvider>
<PageManager /> <CacheBrowserProvider>
<PageManager />
</CacheBrowserProvider>
</LiveActivitiesProvider> </LiveActivitiesProvider>
<ReadAloudPlayerModal /> <ReadAloudPlayerModal />
<PublishSuccessSubtleIndicator /> <PublishSuccessSubtleIndicator />

577
src/components/CacheBrowser/CacheBrowserDialog.tsx

@ -0,0 +1,577 @@
import { Button } from '@/components/ui/button'
import logger from '@/lib/logger'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Copy } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import indexedDb, { StoreNames, type TCachedEventSearchHit } from '@/services/indexed-db.service'
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'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner'
import { Event } from 'nostr-tools'
import { cn } from '@/lib/utils'
const GLOBAL_CACHED_EVENTS_SEARCH_LIMIT = 400
export default function CacheBrowserDialog({
open,
onOpenChange
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({})
const [selectedStore, setSelectedStore] = useState<string | null>(null)
const [storeItems, setStoreItems] = useState<any[]>([])
const [loadingItems, setLoadingItems] = useState(false)
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [cachedEventsSearch, setCachedEventsSearch] = useState('')
const [globalSearchHits, setGlobalSearchHits] = useState<TCachedEventSearchHit[]>([])
const [globalSearchLoading, setGlobalSearchLoading] = useState(false)
const [globalSearchTruncated, setGlobalSearchTruncated] = useState(false)
const [publicationListFull, setPublicationListFull] = useState(false)
const globalSearchRequestId = useRef(0)
const loadCacheInfo = async () => {
try {
const info = await indexedDb.getStoreInfo()
setCacheInfo(info)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Failed to load cache info', { error: message })
}
}
useEffect(() => {
if (!open) return
setSelectedStore(null)
setStoreItems([])
setSearchQuery('')
setCachedEventsSearch('')
setGlobalSearchHits([])
setGlobalSearchTruncated(false)
setPublicationListFull(false)
void loadCacheInfo()
}, [open])
useEffect(() => {
if (!open || selectedStore) return
const q = cachedEventsSearch.trim()
if (!q) {
setGlobalSearchHits([])
setGlobalSearchLoading(false)
setGlobalSearchTruncated(false)
return
}
const reqId = ++globalSearchRequestId.current
setGlobalSearchLoading(true)
const timer = window.setTimeout(() => {
void indexedDb
.searchAllCachedEventsFullText(q, { limit: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })
.then((hits) => {
if (globalSearchRequestId.current !== reqId) return
setGlobalSearchHits(hits)
setGlobalSearchTruncated(hits.length >= GLOBAL_CACHED_EVENTS_SEARCH_LIMIT)
setGlobalSearchLoading(false)
})
.catch((e) => {
logger.error('Cached events search failed', { e })
if (globalSearchRequestId.current !== reqId) return
setGlobalSearchHits([])
setGlobalSearchTruncated(false)
setGlobalSearchLoading(false)
})
}, 350)
return () => window.clearTimeout(timer)
}, [cachedEventsSearch, open, selectedStore])
const handleStoreClick = async (storeName: string) => {
setSelectedStore(storeName)
setSearchQuery('')
setPublicationListFull(false)
setLoadingItems(true)
try {
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'))
setStoreItems([])
} finally {
setLoadingItems(false)
}
}
const handleOpenSearchHit = async (hit: TCachedEventSearchHit) => {
setSelectedStore(hit.storeName)
setSearchQuery(hit.value.id)
setPublicationListFull(hit.storeName === 'publicationEvents')
setLoadingItems(true)
try {
const items = await indexedDb.getStoreItems(hit.storeName)
setStoreItems(items)
} catch (error) {
logger.error('Failed to load store items', { error })
toast.error(t('Failed to load store items'))
setStoreItems([])
} finally {
setLoadingItems(false)
}
}
const filteredStoreItems = useMemo(() => {
if (!searchQuery.trim()) return storeItems
const query = searchQuery.toLowerCase().trim()
return storeItems.filter((item) => {
if (item.key?.toLowerCase().includes(query)) return true
try {
if (JSON.stringify(item.value).toLowerCase().includes(query)) return true
} catch {
/* skip */
}
if (new Date(item.addedAt).toLocaleString().toLowerCase().includes(query)) return true
return false
})
}, [storeItems, searchQuery])
const handleDeleteItem = async (key: string) => {
if (!selectedStore) return
try {
if (selectedStore === 'publicationEvents') {
const parts = key.split(':')
const pubkey = parts[0]
const d = parts[1] || undefined
const result = await indexedDb.deletePublicationAndNestedEvents(pubkey, d)
toast.success(t('Deleted {{count}} event(s)', { count: result.deleted }))
} else {
await indexedDb.deleteStoreItem(selectedStore, key)
toast.success(t('Item deleted successfully'))
}
const items =
selectedStore === 'publicationEvents' && !publicationListFull
? await indexedDb.getPublicationStoreItems(selectedStore)
: await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
void loadCacheInfo()
} catch (error) {
logger.error('Failed to delete item', { error })
toast.error(t('Failed to delete item'))
}
}
const handleDeleteAllItems = async () => {
if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try {
await indexedDb.clearStore(selectedStore)
setStoreItems([])
void loadCacheInfo()
toast.success(t('All items deleted successfully'))
} catch (error) {
logger.error('Failed to delete all items', { error })
toast.error(t('Failed to delete all items'))
}
}
const handleCleanupDuplicates = async () => {
if (!selectedStore) return
if (!confirm(t('Clean up duplicate replaceable events? This will keep only the newest version of each event.')))
return
setLoadingItems(true)
try {
const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore)
const items = await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
setSearchQuery('')
void loadCacheInfo()
const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore)
const actualCount = itemsAfterCleanup.length
if (actualCount !== result.kept) {
toast.success(
t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', {
deleted: result.deleted,
kept: result.kept,
total: actualCount
})
)
} else {
toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept }))
}
} catch (error) {
logger.error('Failed to cleanup duplicates', { error })
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'))
}
} finally {
setLoadingItems(false)
}
}
const handleCopyItemJson = (item: { value: unknown }) => {
try {
const text = JSON.stringify(item.value, null, 2)
void navigator.clipboard.writeText(text)
toast.success(t('Copied to clipboard'))
} catch {
toast.error(t('Failed to copy'))
}
}
const isInvalidEvent = useCallback(
(item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true
if (storeName === 'rssFeedItems') {
return !(item.value || (item as any).item)
}
if (storeName === StoreNames.PIPER_TTS_CACHE) {
const v = item.value as { blob?: unknown; mimeType?: string } | null
return !(v && typeof v.mimeType === 'string' && v.blob instanceof Blob)
}
if (!item.value) return true
const event = item.value as Event
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true
if (!event.tags || !Array.isArray(event.tags)) return true
if (!event.id || !event.sig) return true
return false
},
[]
)
const getInvalidEventExplanation = useCallback(
(item: { key: string; value: any; addedAt: number }): string => {
if (!item || !item.value) return t('Event has no value data')
const event = item.value as Event
const missing: string[] = []
if (!event.pubkey) missing.push(t('pubkey'))
if (!event.kind) missing.push(t('kind'))
if (typeof event.created_at !== 'number') missing.push(t('created_at'))
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags'))
if (!event.id) missing.push(t('id'))
if (!event.sig) missing.push(t('sig'))
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') })
return t('Event appears to be invalid or corrupted')
},
[t]
)
const renderStoreListView = () => {
if (Object.keys(cacheInfo).length === 0) {
return <div className="text-sm text-muted-foreground">{t('No cached data found.')}</div>
}
const q = cachedEventsSearch.trim()
if (q) {
if (globalSearchLoading) {
return (
<div className="space-y-2 py-2" role="status" aria-busy="true" aria-label={t('Searching…')}>
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-md" />
))}
</div>
)
}
if (globalSearchHits.length === 0) {
return (
<div className="text-sm text-muted-foreground">{t('No cached events match your search.')}</div>
)
}
return (
<div className="space-y-2">
{globalSearchTruncated && (
<p className="text-xs text-muted-foreground">
{t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })}
</p>
)}
{globalSearchHits.map((hit) => (
<div
key={`${hit.storeName}:${hit.key}:${hit.value.id}`}
className="space-y-2 rounded-lg border p-3 transition-colors hover:bg-muted/50"
>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="break-all font-medium text-foreground">{hit.storeName}</span>
<span aria-hidden>·</span>
<span>{t('Event kind label', { kind: hit.value.kind })}</span>
</div>
<div className="line-clamp-2 break-words text-xs">{hit.value.content || '\u00a0'}</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
type="button"
onClick={() => void handleOpenSearchHit(hit)}
>
{t('Open in store')}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
type="button"
onClick={() => handleCopyItemJson({ value: hit.value })}
title={t('Copy event JSON')}
>
<Copy className="mr-1 h-3 w-3" />
{t('Copy event JSON')}
</Button>
</div>
</div>
))}
</div>
)
}
return Object.entries(cacheInfo)
.sort(([a], [b]) => a.localeCompare(b))
.map(([storeName, count]) => (
<div
key={storeName}
className="cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50"
onClick={() => handleStoreClick(storeName)}
>
<div className="break-words text-sm font-semibold">{storeName}</div>
<div className="mt-1 text-xs text-muted-foreground">
{count} {t('items')}
</div>
</div>
))
}
const storeItemsSearch =
selectedStore && !loadingItems ? (
<div className="relative min-w-0 max-w-full shrink-0 px-1.5 py-1">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search items...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full min-w-0 pl-8"
/>
</div>
) : null
const renderStoreItemsInner = () =>
loadingItems ? (
<div className="space-y-2 py-6" role="status" aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : (
<>
{storeItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div>
) : (
<div className="min-w-0 max-w-full space-y-2">
<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')}
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
</div>
<div className="flex min-w-0 flex-shrink-0 flex-wrap gap-2 sm:justify-end">
<Button variant="outline" size="sm" onClick={handleCleanupDuplicates} className="h-7 text-xs">
<RefreshCw className="h-3 w-3 mr-1" />
{t('Cleanup Duplicates')}
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteAllItems} className="h-7 text-xs">
<Trash2 className="h-3 w-3 mr-1" />
{t('Delete All')}
</Button>
</div>
</div>
{filteredStoreItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items match your search.')}</div>
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
return (
<div
key={item.key || index}
className="relative min-w-0 max-w-full break-words rounded-lg border p-3"
>
<div className="absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-md bg-background/90 pl-1 backdrop-blur-sm">
{invalid && (
<HoverCard>
<HoverCardTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400"
title={invalidExplanation}
>
<TriangleAlert className="h-3 w-3" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-2">
<div className="font-semibold text-sm flex items-center gap-2">
<TriangleAlert className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Invalid Event')}
</div>
<div className="text-sm text-muted-foreground">{invalidExplanation}</div>
</div>
</HoverCardContent>
</HoverCard>
)}
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyItemJson(item)}
className="h-6 w-6 p-0"
title={t('Copy event JSON')}
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteItem(item.key)}
className="h-6 w-6 p-0"
title={t('Delete item')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-24' : 'pr-20'}`}>
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground">
({nestedCount} {t('nested events')})
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
</div>
<pre
className={cn(
'max-h-96 min-w-0 max-w-full select-text overflow-auto rounded bg-muted p-2 text-xs',
wordWrapEnabled
? 'break-words whitespace-pre-wrap [overflow-wrap:anywhere]'
: 'overflow-x-auto whitespace-pre'
)}
>
{JSON.stringify(item.value, null, 2)}
</pre>
</div>
)
})
)}
</div>
)}
</>
)
const browseCacheHeader = (
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{selectedStore ? (
<>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStore(null)
setStoreItems([])
}}
>
{t('Back')}
</Button>
<span className="truncate text-sm font-medium">{selectedStore}</span>
</>
) : (
<span className="flex items-center gap-2 text-sm font-medium">
<Database className="h-4 w-4 shrink-0" />
{t('Browse Cache')}
</span>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
)
const browseCacheDescription = selectedStore
? t('View cached items in this store.')
: t('Browse cache root description')
/** Keep outside overflow-x-hidden ancestors so focus rings are not clipped (Firefox). */
const storeListSearch = !selectedStore && Object.keys(cacheInfo).length > 0 && (
<div className="relative min-w-0 max-w-full px-1.5 py-1">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={t('Search cached events...')}
value={cachedEventsSearch}
onChange={(e) => setCachedEventsSearch(e.target.value)}
className="w-full min-w-0 pl-8"
autoComplete="off"
/>
</div>
)
/** Scrollable region only — search fields are siblings above this so focus rings are not clipped. */
const scrollableListBody = (
<div
className={cn(
'min-w-0 max-w-full space-y-4 pr-3 sm:pr-5',
wordWrapEnabled && 'break-words'
)}
>
{!selectedStore ? renderStoreListView() : renderStoreItemsInner()}
</div>
)
return isSmallScreen ? (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="flex max-h-[90vh] flex-col">
<DrawerHeader className="min-w-0 shrink-0">
{browseCacheHeader}
<DrawerTitle className="sr-only">{t('Browse Cache')}</DrawerTitle>
<DrawerDescription>{browseCacheDescription}</DrawerDescription>
</DrawerHeader>
<div className="flex min-h-0 min-w-0 flex-1 flex-col space-y-4 px-4 pb-4">
{storeListSearch}
{storeItemsSearch}
<div className="max-h-[min(70vh,32rem)] min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
{scrollableListBody}
</div>
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[min(1000px,90vh)] w-[calc(100vw-1.5rem)] max-w-[min(1000px,calc(100vw-1.5rem))] min-w-0 flex-col gap-4 overflow-x-visible overflow-y-hidden sm:w-full sm:max-w-[1000px]">
<DialogHeader className="min-w-0 shrink-0">
{browseCacheHeader}
<DialogTitle className="sr-only">{t('Browse Cache')}</DialogTitle>
<DialogDescription>{browseCacheDescription}</DialogDescription>
</DialogHeader>
{storeListSearch}
{storeItemsSearch}
<div className="min-h-0 min-w-0 w-full flex-1 overflow-x-hidden overflow-y-auto">
{scrollableListBody}
</div>
</DialogContent>
</Dialog>
)
}

12
src/components/HelpAndAccountMenu.tsx

@ -16,10 +16,11 @@ import { Skeleton } from '@/components/ui/skeleton'
import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { isVideo } from '@/lib/url' import { isVideo } from '@/lib/url'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCacheBrowser } from '../contexts/cache-browser-context'
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { useFetchProfile } from '@/hooks/useFetchProfile' import { useFetchProfile } from '@/hooks/useFetchProfile'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { ArrowDownUp, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react' import { ArrowDownUp, Database, LogIn, LogOut, Settings, User, UserRound } from 'lucide-react'
import { useMemo, useState, type ReactNode } from 'react' import { useMemo, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -34,6 +35,7 @@ function AccountDropdownItems({
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigate } = usePrimaryPage() const { navigate } = usePrimaryPage()
const { openBrowseCache } = useCacheBrowser()
return ( return (
<> <>
@ -45,6 +47,14 @@ function AccountDropdownItems({
<Settings className="size-4" /> <Settings className="size-4" />
{t('Settings')} {t('Settings')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
openBrowseCache()
}}
>
<Database className="size-4" />
{t('Browse Cache')}
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={onSwitchAccount}> <DropdownMenuItem onClick={onSwitchAccount}>
<ArrowDownUp className="size-4" /> <ArrowDownUp className="size-4" />

357
src/components/InBrowserCacheSetting/index.tsx

@ -1,22 +1,20 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react' import { Trash2, RefreshCw, Database, X, Terminal, XCircle } from 'lucide-react'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import client from '@/services/client.service' import client from '@/services/client.service'
import indexedDb, { StoreNames } from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import postEditorCache from '@/services/post-editor-cache.service' import postEditorCache from '@/services/post-editor-cache.service'
import { StorageKey } from '@/constants' import { StorageKey } from '@/constants'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription } from '@/components/ui/drawer'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { toast } from 'sonner' import { toast } from 'sonner'
import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions'
import { Event } from 'nostr-tools' import { useCacheBrowser } from '../../contexts/cache-browser-context'
export default function InBrowserCacheSetting() { export default function InBrowserCacheSetting() {
const { t } = useTranslation() const { t } = useTranslation()
@ -26,13 +24,7 @@ export default function InBrowserCacheSetting() {
relayList, relayList,
requestAccountNetworkHydrate requestAccountNetworkHydrate
} = useNostr() } = useNostr()
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({}) const { openBrowseCache } = useCacheBrowser()
const [browsingCache, setBrowsingCache] = useState(false)
const [selectedStore, setSelectedStore] = useState<string | null>(null)
const [storeItems, setStoreItems] = useState<any[]>([])
const [loadingItems, setLoadingItems] = useState(false)
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const [consoleLogs, setConsoleLogs] = useState<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([]) const [consoleLogs, setConsoleLogs] = useState<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([])
const [showConsoleLogs, setShowConsoleLogs] = useState(false) const [showConsoleLogs, setShowConsoleLogs] = useState(false)
const [consoleLogSearch, setConsoleLogSearch] = useState('') const [consoleLogSearch, setConsoleLogSearch] = useState('')
@ -40,20 +32,6 @@ export default function InBrowserCacheSetting() {
const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false) const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false)
const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([]) const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([])
useEffect(() => {
loadCacheInfo()
}, [])
const loadCacheInfo = async () => {
try {
const info = await indexedDb.getStoreInfo()
setCacheInfo(info)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error('Failed to load cache info', { error: message })
}
}
const handleClearCache = async () => { const handleClearCache = async () => {
if (!confirm(t('Are you sure you want to clear all cached data? This will delete all stored events and settings from your browser.'))) { if (!confirm(t('Are you sure you want to clear all cached data? This will delete all stored events and settings from your browser.'))) {
return return
@ -102,7 +80,6 @@ export default function InBrowserCacheSetting() {
postEditorCache.clearAllPostCaches() postEditorCache.clearAllPostCaches()
client.clearInMemoryCaches() client.clearInMemoryCaches()
await loadCacheInfo()
toast.success(t('Cache cleared successfully')) toast.success(t('Cache cleared successfully'))
setTimeout(() => window.location.reload(), 1500) setTimeout(() => window.location.reload(), 1500)
@ -116,7 +93,6 @@ export default function InBrowserCacheSetting() {
try { try {
setCacheRefreshBusy(true) setCacheRefreshBusy(true)
await indexedDb.forceDatabaseUpgrade() await indexedDb.forceDatabaseUpgrade()
await loadCacheInfo()
if (pubkey) { if (pubkey) {
await requestAccountNetworkHydrate() await requestAccountNetworkHydrate()
await syncUserDeletionTombstones(pubkey, relayList) await syncUserDeletionTombstones(pubkey, relayList)
@ -130,14 +106,6 @@ export default function InBrowserCacheSetting() {
} }
} }
const handleBrowseCache = () => {
setBrowsingCache(true)
setSelectedStore(null)
setStoreItems([])
setSearchQuery('')
loadCacheInfo()
}
const handleClearServiceWorker = async () => { const handleClearServiceWorker = async () => {
if (!confirm(t('Are you sure you want to unregister the service worker? This will clear this app\'s service worker caches and you will need to reload the page.'))) { if (!confirm(t('Are you sure you want to unregister the service worker? This will clear this app\'s service worker caches and you will need to reload the page.'))) {
return return
@ -319,258 +287,6 @@ export default function InBrowserCacheSetting() {
return filtered return filtered
}, [consoleLogs, consoleLogSearch, consoleLogLevel]) }, [consoleLogs, consoleLogSearch, consoleLogLevel])
const handleStoreClick = async (storeName: string) => {
setSelectedStore(storeName)
setSearchQuery('')
setLoadingItems(true)
try {
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'))
setStoreItems([])
} finally {
setLoadingItems(false)
}
}
const filteredStoreItems = useMemo(() => {
if (!searchQuery.trim()) return storeItems
const query = searchQuery.toLowerCase().trim()
return storeItems.filter(item => {
if (item.key?.toLowerCase().includes(query)) return true
try {
if (JSON.stringify(item.value).toLowerCase().includes(query)) return true
} catch (e) { /* skip */ }
if (new Date(item.addedAt).toLocaleString().toLowerCase().includes(query)) return true
return false
})
}, [storeItems, searchQuery])
const handleDeleteItem = async (key: string) => {
if (!selectedStore) return
try {
if (selectedStore === 'publicationEvents') {
const parts = key.split(':')
const pubkey = parts[0]
const d = parts[1] || undefined
const result = await indexedDb.deletePublicationAndNestedEvents(pubkey, d)
toast.success(t('Deleted {{count}} event(s)', { count: result.deleted }))
} else {
await indexedDb.deleteStoreItem(selectedStore, key)
toast.success(t('Item deleted successfully'))
}
const items = selectedStore === 'publicationEvents'
? await indexedDb.getPublicationStoreItems(selectedStore)
: await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
loadCacheInfo()
} catch (error) {
logger.error('Failed to delete item', { error })
toast.error(t('Failed to delete item'))
}
}
const handleDeleteAllItems = async () => {
if (!selectedStore) return
if (!confirm(t('Are you sure you want to delete all items from this store?'))) return
try {
await indexedDb.clearStore(selectedStore)
setStoreItems([])
loadCacheInfo()
toast.success(t('All items deleted successfully'))
} catch (error) {
logger.error('Failed to delete all items', { error })
toast.error(t('Failed to delete all items'))
}
}
const handleCleanupDuplicates = async () => {
if (!selectedStore) return
if (!confirm(t('Clean up duplicate replaceable events? This will keep only the newest version of each event.'))) return
setLoadingItems(true)
try {
const result = await indexedDb.cleanupDuplicateReplaceableEvents(selectedStore)
const items = await indexedDb.getStoreItems(selectedStore)
setStoreItems(items)
setSearchQuery('')
loadCacheInfo()
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}})', {
deleted: result.deleted, kept: result.kept, total: actualCount
}))
} else {
toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept }))
}
} catch (error) {
logger.error('Failed to cleanup duplicates', { error })
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'))
}
} finally {
setLoadingItems(false)
}
}
const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => {
if (!item) return true
if (storeName === 'rssFeedItems') {
return !(item.value || (item as any).item)
}
if (storeName === StoreNames.PIPER_TTS_CACHE) {
const v = item.value as { blob?: unknown; mimeType?: string } | null
return !(v && typeof v.mimeType === 'string' && v.blob instanceof Blob)
}
if (!item.value) return true
const event = item.value as Event
if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') return true
if (!event.tags || !Array.isArray(event.tags)) return true
if (!event.id || !event.sig) return true
return false
}, [])
const getInvalidEventExplanation = useCallback((item: { key: string; value: any; addedAt: number }): string => {
if (!item || !item.value) return t('Event has no value data')
const event = item.value as Event
const missing: string[] = []
if (!event.pubkey) missing.push(t('pubkey'))
if (!event.kind) missing.push(t('kind'))
if (typeof event.created_at !== 'number') missing.push(t('created_at'))
if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags'))
if (!event.id) missing.push(t('id'))
if (!event.sig) missing.push(t('sig'))
if (missing.length > 0) return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') })
return t('Event appears to be invalid or corrupted')
}, [t])
const renderStoreListView = () =>
Object.keys(cacheInfo).length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No cached data found.')}</div>
) : (
Object.entries(cacheInfo).map(([storeName, count]) => (
<div
key={storeName}
className="border rounded-lg p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleStoreClick(storeName)}
>
<div className="font-semibold text-sm break-words">{storeName}</div>
<div className="text-xs text-muted-foreground mt-1">{count} {t('items')}</div>
</div>
))
)
const renderStoreItemsView = () =>
loadingItems ? (
<div className="space-y-2 py-6" role="status" aria-busy="true">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-md" />
))}
</div>
) : (
<>
<div className="relative py-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search items...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
{storeItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-muted-foreground">
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')}
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleCleanupDuplicates} className="h-7 text-xs">
<RefreshCw className="h-3 w-3 mr-1" />
{t('Cleanup Duplicates')}
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteAllItems} className="h-7 text-xs">
<Trash2 className="h-3 w-3 mr-1" />
{t('Delete All')}
</Button>
</div>
</div>
{filteredStoreItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items match your search.')}</div>
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
const invalid = isInvalidEvent(item, selectedStore)
const invalidExplanation = invalid ? getInvalidEventExplanation(item) : ''
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words relative">
<div className="absolute top-2 right-2 flex items-center gap-1">
{invalid && (
<HoverCard>
<HoverCardTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400"
title={invalidExplanation}
>
<TriangleAlert className="h-3 w-3" />
</Button>
</HoverCardTrigger>
<HoverCardContent className="w-80">
<div className="space-y-2">
<div className="font-semibold text-sm flex items-center gap-2">
<TriangleAlert className="h-4 w-4 text-amber-600 dark:text-amber-500" />
{t('Invalid Event')}
</div>
<div className="text-sm text-muted-foreground">{invalidExplanation}</div>
</div>
</HoverCardContent>
</HoverCard>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteItem(item.key)}
className="h-6 w-6 p-0"
title={t('Delete item')}
>
<X className="h-3 w-3" />
</Button>
</div>
<div className={`font-semibold text-xs mb-2 break-all ${invalid ? 'pr-16' : 'pr-8'}`}>
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground">
({nestedCount} {t('nested events')})
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
</div>
<pre className={`text-xs bg-muted p-2 rounded overflow-auto max-h-96 select-text ${wordWrapEnabled ? 'overflow-x-hidden whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'}`}>
{JSON.stringify(item.value, null, 2)}
</pre>
</div>
)
})
)}
</div>
)}
</>
)
const renderConsoleLogList = () => const renderConsoleLogList = () =>
filteredConsoleLogs.length === 0 ? ( filteredConsoleLogs.length === 0 ? (
<div className="text-muted-foreground p-4 text-center"> <div className="text-muted-foreground p-4 text-center">
@ -655,39 +371,6 @@ export default function InBrowserCacheSetting() {
</div> </div>
) )
const browseCacheHeader = (
<div className="flex items-center justify-between">
<div className="flex-1">
{selectedStore ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedStore(null); setStoreItems([]) }}
>
{t('Back')}
</Button>
{selectedStore}
</div>
) : (
t('Browse Cache')
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
)
const browseCacheDescription = selectedStore
? t('View cached items in this store.')
: t('View details about cached data in IndexedDB stores. Click on a store to view its items.')
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
@ -713,7 +396,7 @@ export default function InBrowserCacheSetting() {
<RefreshCw className={`mr-2 h-4 w-4 ${cacheRefreshBusy ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${cacheRefreshBusy ? 'animate-spin' : ''}`} />
{t('Refresh Cache')} {t('Refresh Cache')}
</Button> </Button>
<Button variant="outline" className="shrink-0" onClick={handleBrowseCache}> <Button variant="outline" className="shrink-0" onClick={openBrowseCache}>
<Database className="mr-2 h-4 w-4" /> <Database className="mr-2 h-4 w-4" />
{t('Browse Cache')} {t('Browse Cache')}
</Button> </Button>
@ -727,34 +410,6 @@ export default function InBrowserCacheSetting() {
</Button> </Button>
</div> </div>
{isSmallScreen ? (
<Drawer open={browsingCache} onOpenChange={setBrowsingCache}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
{browseCacheHeader}
<DrawerTitle className="sr-only">{t('Browse Cache')}</DrawerTitle>
<DrawerDescription>{browseCacheDescription}</DrawerDescription>
</DrawerHeader>
<div className={`px-4 pb-4 space-y-4 overflow-y-auto ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? renderStoreListView() : renderStoreItemsView()}
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={browsingCache} onOpenChange={setBrowsingCache}>
<DialogContent className="max-w-[1000px] max-h-[1000px] overflow-y-auto overflow-x-hidden">
<DialogHeader>
{browseCacheHeader}
<DialogTitle className="sr-only">{t('Browse Cache')}</DialogTitle>
<DialogDescription>{browseCacheDescription}</DialogDescription>
</DialogHeader>
<div className={`space-y-4 ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? renderStoreListView() : renderStoreItemsView()}
</div>
</DialogContent>
</Dialog>
)}
{isSmallScreen ? ( {isSmallScreen ? (
<Drawer open={showConsoleLogs} onOpenChange={setShowConsoleLogs}> <Drawer open={showConsoleLogs} onOpenChange={setShowConsoleLogs}>
<DrawerContent className="max-h-[90vh]"> <DrawerContent className="max-h-[90vh]">

29
src/contexts/cache-browser-context.tsx

@ -0,0 +1,29 @@
import CacheBrowserDialog from '../components/CacheBrowser/CacheBrowserDialog'
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
type CacheBrowserContextValue = {
openBrowseCache: () => void
}
const CacheBrowserContext = createContext<CacheBrowserContextValue | undefined>(undefined)
export function CacheBrowserProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
const openBrowseCache = useCallback(() => setOpen(true), [])
const value = useMemo(() => ({ openBrowseCache }), [openBrowseCache])
return (
<CacheBrowserContext.Provider value={value}>
{children}
<CacheBrowserDialog open={open} onOpenChange={setOpen} />
</CacheBrowserContext.Provider>
)
}
export function useCacheBrowser(): CacheBrowserContextValue {
const ctx = useContext(CacheBrowserContext)
if (!ctx) {
throw new Error('useCacheBrowser must be used within CacheBrowserProvider')
}
return ctx
}

9
src/i18n/locales/en.ts

@ -1264,6 +1264,13 @@ export default {
'Brief summary (optional)': 'Brief summary (optional)', 'Brief summary (optional)': 'Brief summary (optional)',
'Brief summary of the article (optional)': 'Brief summary of the article (optional)', 'Brief summary of the article (optional)': 'Brief summary of the article (optional)',
'Browse Cache': 'Browse Cache', 'Browse Cache': 'Browse Cache',
'Search cached events...': 'Search cached events...',
'No cached events match your search.': 'No cached events match your search.',
'Showing first {{count}} cached event matches.': 'Showing first {{count}} cached event matches.',
'Open in store': 'Open in store',
'Browse cache root description':
'View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.',
'Copy event JSON': 'Copy event JSON',
'C-Tag': 'C-Tag', 'C-Tag': 'C-Tag',
'Cache Relays': 'Cache Relays', 'Cache Relays': 'Cache Relays',
'Cache cleared successfully': 'Cache cleared successfully', 'Cache cleared successfully': 'Cache cleared successfully',
@ -1847,7 +1854,7 @@ export default {
'View cached items in this store.': 'View cached items in this store.', 'View cached items in this store.': 'View cached items in this store.',
'View definition': 'View definition', 'View definition': 'View definition',
'View details about cached data in IndexedDB stores. Click on a store to view its items.': 'View details about cached data in IndexedDB stores. Click on a store to view its items.':
'View details about cached data in IndexedDB stores. Click on a store to view its items.', 'View IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.',
'View on Alexandria': 'View on Alexandria', 'View on Alexandria': 'View on Alexandria',
'View on DecentNewsroom': 'View on DecentNewsroom', 'View on DecentNewsroom': 'View on DecentNewsroom',
'View on Wikistr': 'View on Wikistr', 'View on Wikistr': 'View on Wikistr',

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

@ -39,6 +39,43 @@ type TValue<T = any> = {
masterPublicationKey?: string // For nested publication events, link to master publication masterPublicationKey?: string // For nested publication events, link to master publication
} }
/** One matching row from {@link IndexedDbService.searchAllCachedEventsFullText}. */
export type TCachedEventSearchHit = {
storeName: string
key: string
value: Event
addedAt: number
}
function isLikelyCachedNostrEvent(v: unknown): v is Event {
if (!v || typeof v !== 'object') return false
const o = v as Record<string, unknown>
return (
typeof o.id === 'string' &&
o.id.length > 0 &&
typeof o.pubkey === 'string' &&
o.pubkey.length > 0 &&
typeof o.kind === 'number' &&
typeof o.content === 'string' &&
Array.isArray(o.tags)
)
}
function cachedEventMatchesFullTextQuery(ev: Event, qLower: string): boolean {
if (!qLower) return false
if (ev.id.toLowerCase().includes(qLower)) return true
if (ev.pubkey.toLowerCase().includes(qLower)) return true
if (String(ev.kind).includes(qLower)) return true
if ((ev.content ?? '').toLowerCase().includes(qLower)) return true
for (const tag of ev.tags ?? []) {
if (!Array.isArray(tag)) continue
for (const cell of tag) {
if (String(cell).toLowerCase().includes(qLower)) return true
}
}
return false
}
export const StoreNames = { export const StoreNames = {
PROFILE_EVENTS: 'profileEvents', PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents', RELAY_LIST_EVENTS: 'relayListEvents',
@ -89,6 +126,22 @@ export const StoreNames = {
PIPER_TTS_CACHE: 'piperTtsCache' PIPER_TTS_CACHE: 'piperTtsCache'
} }
/** Object stores skipped by full-text cache search (blobs, settings, relay metadata, etc.). */
const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet<string> = new Set([
StoreNames.SETTINGS,
StoreNames.PIPER_TTS_CACHE,
StoreNames.RELAY_INFOS,
StoreNames.NIP66_DISCOVERY,
StoreNames.GIF_CACHE,
StoreNames.TIMELINE_STATE,
StoreNames.PUBLIC_LIVELY_RELAYS,
StoreNames.RSS_FEED_ITEMS,
StoreNames.FOLLOWING_FAVORITE_RELAYS,
StoreNames.RELAY_SETS,
StoreNames.MUTE_DECRYPTED_TAGS,
StoreNames.FAVORITE_RELAYS
])
/** Schema version we expect. When adding stores or migrations, bump this. */ /** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 34 const DB_VERSION = 34
@ -1465,6 +1518,112 @@ class IndexedDbService {
}) })
} }
/**
* Scan object stores (excluding blobs, settings, and relay-only metadata) for rows that look like
* Nostr events. Case-insensitive match on id, pubkey, kind, content, and every tag cell.
*/
async searchAllCachedEventsFullText(
query: string,
options?: { limit?: number }
): Promise<TCachedEventSearchHit[]> {
await this.initPromise
const qLower = query.trim().toLowerCase()
const limit = Math.min(Math.max(options?.limit ?? 400, 1), 2000)
if (!qLower || !this.db) {
return []
}
const storeNames = Array.from(this.db.objectStoreNames).filter(
(name) => !CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES.has(name)
)
const results: TCachedEventSearchHit[] = []
const seen = new Set<string>()
for (const storeName of storeNames) {
if (results.length >= limit) break
try {
await new Promise<void>((resolve, reject) => {
if (!this.db!.objectStoreNames.contains(storeName)) {
resolve()
return
}
const transaction = this.db!.transaction(storeName, 'readonly')
const store = transaction.objectStore(storeName)
const cursorReq = store.openCursor()
cursorReq.onsuccess = () => {
const cursor = cursorReq.result as IDBCursorWithValue | null
if (!cursor) {
transaction.commit()
resolve()
return
}
if (results.length >= limit) {
transaction.commit()
resolve()
return
}
const raw = cursor.value
if (storeName === StoreNames.EVENT_ARCHIVE) {
const row = raw as TArchivedEventRow
if (row?.value && isLikelyCachedNostrEvent(row.value)) {
const ev = row.value
if (cachedEventMatchesFullTextQuery(ev, qLower)) {
const dedupeKey = `${storeName}:${row.key}`
if (!seen.has(dedupeKey)) {
seen.add(dedupeKey)
results.push({
storeName,
key: row.key,
value: ev,
addedAt: row.addedAt
})
}
}
}
} else {
const item = raw as TValue
if (
item?.value != null &&
typeof item.key === 'string' &&
isLikelyCachedNostrEvent(item.value)
) {
const ev = item.value
if (cachedEventMatchesFullTextQuery(ev, qLower)) {
const dedupeKey = `${storeName}:${item.key}`
if (!seen.has(dedupeKey)) {
seen.add(dedupeKey)
results.push({
storeName,
key: item.key,
value: ev,
addedAt: item.addedAt ?? 0
})
}
}
}
}
cursor.continue()
}
cursorReq.onerror = (ev) => {
transaction.commit()
reject(idbEventToError(ev))
}
})
} catch (e) {
logger.warn('[IndexedDB] searchAllCachedEventsFullText store failed', { storeName, e })
}
}
results.sort((a, b) => b.addedAt - a.addedAt)
return results
}
/** Remove a replaceable event from cache so the next fetch will load from relays. */ /** Remove a replaceable event from cache so the next fetch will load from relays. */
async invalidateReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<void> { async invalidateReplaceableEvent(pubkey: string, kind: number, d?: string): Promise<void> {
const storeName = this.getStoreNameByKind(kind) const storeName = this.getStoreNameByKind(kind)

Loading…
Cancel
Save