From fdbf8022d4f5f5d2b53ac2991dbc286edc0c6bc9 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 9 Apr 2026 10:41:42 +0200 Subject: [PATCH] make cache more accessible --- jsconfig.json | 16 + src/App.tsx | 5 +- .../CacheBrowser/CacheBrowserDialog.tsx | 577 ++++++++++++++++++ src/components/HelpAndAccountMenu.tsx | 12 +- .../InBrowserCacheSetting/index.tsx | 357 +---------- src/contexts/cache-browser-context.tsx | 29 + src/i18n/locales/en.ts | 9 +- src/services/indexed-db.service.ts | 159 +++++ 8 files changed, 810 insertions(+), 354 deletions(-) create mode 100644 jsconfig.json create mode 100644 src/components/CacheBrowser/CacheBrowserDialog.tsx create mode 100644 src/contexts/cache-browser-context.tsx diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..4a87831e --- /dev/null +++ b/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"] +} diff --git a/src/App.tsx b/src/App.tsx index 045d2390..550e53af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,6 +28,7 @@ import { ZapProvider } from '@/providers/ZapProvider' import SlowConnectionHint from '@/components/SlowConnectionHint' import StartupSessionBanner from '@/components/StartupSessionBanner' import VersionUpdateBanner from '@/components/VersionUpdateBanner' +import { CacheBrowserProvider } from './contexts/cache-browser-context' import { PageManager } from './PageManager' export default function App(): JSX.Element { @@ -58,7 +59,9 @@ export default function App(): JSX.Element { - + + + diff --git a/src/components/CacheBrowser/CacheBrowserDialog.tsx b/src/components/CacheBrowser/CacheBrowserDialog.tsx new file mode 100644 index 00000000..ceb7e24b --- /dev/null +++ b/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>({}) + const [selectedStore, setSelectedStore] = useState(null) + const [storeItems, setStoreItems] = useState([]) + const [loadingItems, setLoadingItems] = useState(false) + const [wordWrapEnabled, setWordWrapEnabled] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + const [cachedEventsSearch, setCachedEventsSearch] = useState('') + const [globalSearchHits, setGlobalSearchHits] = useState([]) + 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
{t('No cached data found.')}
+ } + const q = cachedEventsSearch.trim() + if (q) { + if (globalSearchLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) + } + if (globalSearchHits.length === 0) { + return ( +
{t('No cached events match your search.')}
+ ) + } + return ( +
+ {globalSearchTruncated && ( +

+ {t('Showing first {{count}} cached event matches.', { count: GLOBAL_CACHED_EVENTS_SEARCH_LIMIT })} +

+ )} + {globalSearchHits.map((hit) => ( +
+
+ {hit.storeName} + · + {t('Event kind label', { kind: hit.value.kind })} +
+
{hit.value.content || '\u00a0'}
+
+ + +
+
+ ))} +
+ ) + } + return Object.entries(cacheInfo) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([storeName, count]) => ( +
handleStoreClick(storeName)} + > +
{storeName}
+
+ {count} {t('items')} +
+
+ )) + } + + const storeItemsSearch = + selectedStore && !loadingItems ? ( +
+ + setSearchQuery(e.target.value)} + className="w-full min-w-0 pl-8" + /> +
+ ) : null + + const renderStoreItemsInner = () => + loadingItems ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + <> + {storeItems.length === 0 ? ( +
{t('No items in this store.')}
+ ) : ( +
+
+
+ {filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} + {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`} +
+
+ + +
+
+ {filteredStoreItems.length === 0 ? ( +
{t('No items match your search.')}
+ ) : ( + filteredStoreItems.map((item, index) => { + const nestedCount = (item as any).nestedCount + const invalid = isInvalidEvent(item, selectedStore) + const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' + return ( +
+
+ {invalid && ( + + + + + +
+
+ + {t('Invalid Event')} +
+
{invalidExplanation}
+
+
+
+ )} + + +
+
+ {item.key} + {typeof nestedCount === 'number' && nestedCount > 0 && ( + + ({nestedCount} {t('nested events')}) + + )} +
+
+ {t('Added at')}: {new Date(item.addedAt).toLocaleString()} +
+
+                      {JSON.stringify(item.value, null, 2)}
+                    
+
+ ) + }) + )} +
+ )} + + ) + + const browseCacheHeader = ( +
+
+ {selectedStore ? ( + <> + + {selectedStore} + + ) : ( + + + {t('Browse Cache')} + + )} +
+ +
+ ) + + 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 && ( +
+ + setCachedEventsSearch(e.target.value)} + className="w-full min-w-0 pl-8" + autoComplete="off" + /> +
+ ) + + /** Scrollable region only — search fields are siblings above this so focus rings are not clipped. */ + const scrollableListBody = ( +
+ {!selectedStore ? renderStoreListView() : renderStoreItemsInner()} +
+ ) + + return isSmallScreen ? ( + + + + {browseCacheHeader} + {t('Browse Cache')} + {browseCacheDescription} + +
+ {storeListSearch} + {storeItemsSearch} +
+ {scrollableListBody} +
+
+
+
+ ) : ( + + + + {browseCacheHeader} + {t('Browse Cache')} + {browseCacheDescription} + + {storeListSearch} + {storeItemsSearch} +
+ {scrollableListBody} +
+
+
+ ) +} diff --git a/src/components/HelpAndAccountMenu.tsx b/src/components/HelpAndAccountMenu.tsx index 8b6f1f9e..c0c48b90 100644 --- a/src/components/HelpAndAccountMenu.tsx +++ b/src/components/HelpAndAccountMenu.tsx @@ -16,10 +16,11 @@ import { Skeleton } from '@/components/ui/skeleton' import { formatPubkey, formatNpub, generateImageByPubkey, pubkeyToNpub } from '@/lib/pubkey' import { isVideo } from '@/lib/url' import { cn } from '@/lib/utils' +import { useCacheBrowser } from '../contexts/cache-browser-context' import { usePrimaryPage } from '@/contexts/primary-page-context' import { useFetchProfile } from '@/hooks/useFetchProfile' 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 { useTranslation } from 'react-i18next' @@ -34,6 +35,7 @@ function AccountDropdownItems({ }) { const { t } = useTranslation() const { navigate } = usePrimaryPage() + const { openBrowseCache } = useCacheBrowser() return ( <> @@ -45,6 +47,14 @@ function AccountDropdownItems({ {t('Settings')} + { + openBrowseCache() + }} + > + + {t('Browse Cache')} + diff --git a/src/components/InBrowserCacheSetting/index.tsx b/src/components/InBrowserCacheSetting/index.tsx index 7525c949..6c5d29d9 100644 --- a/src/components/InBrowserCacheSetting/index.tsx +++ b/src/components/InBrowserCacheSetting/index.tsx @@ -1,22 +1,20 @@ import { Button } from '@/components/ui/button' import logger from '@/lib/logger' 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 { 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 { Skeleton } from '@/components/ui/skeleton' 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 { StorageKey } from '@/constants' 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 { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' -import { Event } from 'nostr-tools' +import { useCacheBrowser } from '../../contexts/cache-browser-context' export default function InBrowserCacheSetting() { const { t } = useTranslation() @@ -26,13 +24,7 @@ export default function InBrowserCacheSetting() { relayList, requestAccountNetworkHydrate } = useNostr() - const [cacheInfo, setCacheInfo] = useState>({}) - const [browsingCache, setBrowsingCache] = useState(false) - const [selectedStore, setSelectedStore] = useState(null) - const [storeItems, setStoreItems] = useState([]) - const [loadingItems, setLoadingItems] = useState(false) - const [wordWrapEnabled, setWordWrapEnabled] = useState(true) - const [searchQuery, setSearchQuery] = useState('') + const { openBrowseCache } = useCacheBrowser() const [consoleLogs, setConsoleLogs] = useState; timestamp: number }>>([]) const [showConsoleLogs, setShowConsoleLogs] = useState(false) const [consoleLogSearch, setConsoleLogSearch] = useState('') @@ -40,20 +32,6 @@ export default function InBrowserCacheSetting() { const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false) const consoleLogRef = useRef; 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 () => { 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 @@ -102,7 +80,6 @@ export default function InBrowserCacheSetting() { postEditorCache.clearAllPostCaches() client.clearInMemoryCaches() - await loadCacheInfo() toast.success(t('Cache cleared successfully')) setTimeout(() => window.location.reload(), 1500) @@ -116,7 +93,6 @@ export default function InBrowserCacheSetting() { try { setCacheRefreshBusy(true) await indexedDb.forceDatabaseUpgrade() - await loadCacheInfo() if (pubkey) { await requestAccountNetworkHydrate() await syncUserDeletionTombstones(pubkey, relayList) @@ -130,14 +106,6 @@ export default function InBrowserCacheSetting() { } } - const handleBrowseCache = () => { - setBrowsingCache(true) - setSelectedStore(null) - setStoreItems([]) - setSearchQuery('') - loadCacheInfo() - } - 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.'))) { return @@ -319,258 +287,6 @@ export default function InBrowserCacheSetting() { return filtered }, [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 ? ( -
{t('No cached data found.')}
- ) : ( - Object.entries(cacheInfo).map(([storeName, count]) => ( -
handleStoreClick(storeName)} - > -
{storeName}
-
{count} {t('items')}
-
- )) - ) - - const renderStoreItemsView = () => - loadingItems ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- ) : ( - <> -
- - setSearchQuery(e.target.value)} - className="pl-8" - /> -
- {storeItems.length === 0 ? ( -
{t('No items in this store.')}
- ) : ( -
-
-
- {filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')} - {searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`} -
-
- - -
-
- {filteredStoreItems.length === 0 ? ( -
{t('No items match your search.')}
- ) : ( - filteredStoreItems.map((item, index) => { - const nestedCount = (item as any).nestedCount - const invalid = isInvalidEvent(item, selectedStore) - const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' - return ( -
-
- {invalid && ( - - - - - -
-
- - {t('Invalid Event')} -
-
{invalidExplanation}
-
-
-
- )} - -
-
- {item.key} - {typeof nestedCount === 'number' && nestedCount > 0 && ( - - ({nestedCount} {t('nested events')}) - - )} -
-
- {t('Added at')}: {new Date(item.addedAt).toLocaleString()} -
-
-                      {JSON.stringify(item.value, null, 2)}
-                    
-
- ) - }) - )} -
- )} - - ) - const renderConsoleLogList = () => filteredConsoleLogs.length === 0 ? (
@@ -655,39 +371,6 @@ export default function InBrowserCacheSetting() {
) - const browseCacheHeader = ( -
-
- {selectedStore ? ( -
- - {selectedStore} -
- ) : ( - t('Browse Cache') - )} -
- -
- ) - - 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 (
@@ -713,7 +396,7 @@ export default function InBrowserCacheSetting() { {t('Refresh Cache')} - @@ -727,34 +410,6 @@ export default function InBrowserCacheSetting() {
- {isSmallScreen ? ( - - - - {browseCacheHeader} - {t('Browse Cache')} - {browseCacheDescription} - -
- {!selectedStore ? renderStoreListView() : renderStoreItemsView()} -
-
-
- ) : ( - - - - {browseCacheHeader} - {t('Browse Cache')} - {browseCacheDescription} - -
- {!selectedStore ? renderStoreListView() : renderStoreItemsView()} -
-
-
- )} - {isSmallScreen ? ( diff --git a/src/contexts/cache-browser-context.tsx b/src/contexts/cache-browser-context.tsx new file mode 100644 index 00000000..db0854b1 --- /dev/null +++ b/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(undefined) + +export function CacheBrowserProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + const openBrowseCache = useCallback(() => setOpen(true), []) + const value = useMemo(() => ({ openBrowseCache }), [openBrowseCache]) + + return ( + + {children} + + + ) +} + +export function useCacheBrowser(): CacheBrowserContextValue { + const ctx = useContext(CacheBrowserContext) + if (!ctx) { + throw new Error('useCacheBrowser must be used within CacheBrowserProvider') + } + return ctx +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index d63e3900..65864225 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1264,6 +1264,13 @@ export default { 'Brief summary (optional)': 'Brief summary (optional)', 'Brief summary of the article (optional)': 'Brief summary of the article (optional)', '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', 'Cache Relays': 'Cache Relays', '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 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 IndexedDB stores, or search all cached Nostr-like events (content, tags, id, pubkey, kind) across stores.', 'View on Alexandria': 'View on Alexandria', 'View on DecentNewsroom': 'View on DecentNewsroom', 'View on Wikistr': 'View on Wikistr', diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 72637daf..a401a58e 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -39,6 +39,43 @@ type TValue = { 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 + 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 = { PROFILE_EVENTS: 'profileEvents', RELAY_LIST_EVENTS: 'relayListEvents', @@ -89,6 +126,22 @@ export const StoreNames = { 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 = 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. */ 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 { + 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() + + for (const storeName of storeNames) { + if (results.length >= limit) break + + try { + await new Promise((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. */ async invalidateReplaceableEvent(pubkey: string, kind: number, d?: string): Promise { const storeName = this.getStoreNameByKind(kind)