You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
472 lines
18 KiB
472 lines
18 KiB
import { Button } from '@/components/ui/button' |
|
import logger from '@/lib/logger' |
|
import { useNostr } from '@/providers/NostrProvider' |
|
import { useEffect, useState, useMemo, useRef } from 'react' |
|
import { useTranslation } from 'react-i18next' |
|
import { Trash2, RefreshCw, Database, X, Terminal, XCircle } from 'lucide-react' |
|
import { Input } from '@/components/ui/input' |
|
import client from '@/services/client.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 { 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() { |
|
const { t } = useTranslation() |
|
const { isSmallScreen } = useScreenSize() |
|
const { |
|
pubkey, |
|
relayList, |
|
requestAccountNetworkHydrate |
|
} = useNostr() |
|
const { openBrowseCache } = useCacheBrowser() |
|
const [consoleLogs, setConsoleLogs] = useState<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([]) |
|
const [showConsoleLogs, setShowConsoleLogs] = useState(false) |
|
const [consoleLogSearch, setConsoleLogSearch] = useState('') |
|
const [consoleLogLevel, setConsoleLogLevel] = useState<'errors-warnings' | 'all'>('all') |
|
const [cacheRefreshBusy, setCacheRefreshBusy] = useState(false) |
|
const consoleLogRef = useRef<Array<{ type: string; message: string; formattedParts?: Array<{ text: string; style?: string }>; timestamp: number }>>([]) |
|
|
|
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 |
|
} |
|
|
|
try { |
|
await indexedDb.clearAllCache() |
|
await indexedDb.clearPiperTtsCache() |
|
|
|
const cacheKeys = Object.values(StorageKey).filter(key => |
|
key.includes('CACHE') || key.includes('EVENT') || key.includes('FEED') || key.includes('NOTIFICATION') |
|
) |
|
cacheKeys.forEach(key => { |
|
try { |
|
window.localStorage.removeItem(key) |
|
} catch (e) { |
|
logger.warn(`Failed to remove ${key} from localStorage`, e as Error) |
|
} |
|
}) |
|
|
|
if ('caches' in window) { |
|
try { |
|
const cacheNames = await caches.keys() |
|
const currentOrigin = window.location.origin |
|
|
|
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 => caches.delete(name).catch(error => { |
|
logger.warn(`Failed to delete cache: ${name}`, { error }) |
|
}))) |
|
} catch (error) { |
|
logger.warn('Failed to clear some service worker caches', { error }) |
|
} |
|
} |
|
|
|
postEditorCache.clearAllPostCaches() |
|
client.clearInMemoryCaches() |
|
|
|
toast.success(t('Cache cleared successfully')) |
|
setTimeout(() => window.location.reload(), 1500) |
|
} catch (error) { |
|
logger.error('Failed to clear cache', { error }) |
|
toast.error(t('Failed to clear cache')) |
|
} |
|
} |
|
|
|
const handleRefreshCache = async () => { |
|
try { |
|
setCacheRefreshBusy(true) |
|
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 }) |
|
toast.error(t('Failed to refresh cache')) |
|
} finally { |
|
setCacheRefreshBusy(false) |
|
} |
|
} |
|
|
|
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 |
|
} |
|
|
|
try { |
|
const currentOrigin = window.location.origin |
|
let unregisteredCount = 0 |
|
let cacheClearedCount = 0 |
|
|
|
if (window.isSecureContext && 'serviceWorker' in navigator) { |
|
let registrations: readonly ServiceWorkerRegistration[] = [] |
|
try { |
|
registrations = await navigator.serviceWorker.getRegistrations() |
|
} catch (error) { |
|
logger.warn('Failed to get service worker registrations', { error }) |
|
} |
|
|
|
if (registrations.length > 0) { |
|
const unregisterPromises = registrations.map(async (registration) => { |
|
try { |
|
const scope = registration.scope |
|
if (scope.startsWith(currentOrigin)) { |
|
const result = await registration.unregister() |
|
if (result) unregisteredCount++ |
|
return result |
|
} |
|
return false |
|
} catch (error) { |
|
logger.warn('Failed to unregister a service worker', { error }) |
|
return false |
|
} |
|
}) |
|
await Promise.all(unregisterPromises) |
|
} |
|
} |
|
|
|
if ('caches' in window) { |
|
try { |
|
const cacheNames = await caches.keys() |
|
|
|
const appCacheNames = [ |
|
'nostr-images', |
|
'satellite-images', |
|
'external-images' |
|
] |
|
|
|
const appCaches = cacheNames.filter(name => { |
|
if (appCacheNames.includes(name)) return true |
|
if (name.startsWith('workbox-') || name.startsWith('precache-')) return true |
|
if (name.includes(currentOrigin.replace(/https?:\/\//, '').split('/')[0])) return true |
|
return false |
|
}) |
|
|
|
await Promise.all(appCaches.map(name => { |
|
cacheClearedCount++ |
|
return caches.delete(name).catch(error => { |
|
logger.warn(`Failed to delete cache: ${name}`, { error }) |
|
cacheClearedCount-- |
|
}) |
|
})) |
|
} catch (error) { |
|
logger.warn('Failed to clear some caches', { error }) |
|
} |
|
} |
|
|
|
if (unregisteredCount > 0 || cacheClearedCount > 0) { |
|
const message = unregisteredCount > 0 && cacheClearedCount > 0 |
|
? t('Service worker unregistered and caches cleared. Please reload the page.') |
|
: unregisteredCount > 0 |
|
? t('Service worker unregistered. Please reload the page.') |
|
: t('Service worker caches cleared. Please reload the page.') |
|
toast.success(message) |
|
setTimeout(() => window.location.reload(), 1000) |
|
} else { |
|
toast.info(t('No service workers or caches found for this app')) |
|
} |
|
} catch (error) { |
|
logger.error('Failed to unregister service worker', { error }) |
|
toast.error(t('Failed to unregister service worker: ') + (error instanceof Error ? error.message : String(error))) |
|
} |
|
} |
|
|
|
useEffect(() => { |
|
const originalLog = console.log |
|
const originalError = console.error |
|
const originalWarn = console.warn |
|
const originalInfo = console.info |
|
|
|
const captureLog = (type: string, ...args: any[]) => { |
|
let message = '' |
|
let formattedParts: Array<{ text: string; style?: string }> = [] |
|
|
|
if (args.length > 0 && typeof args[0] === 'string' && args[0].includes('%c')) { |
|
const formatString = args[0] |
|
const parts = formatString.split(/%c/g) |
|
formattedParts = [] |
|
|
|
for (let i = 0; i < parts.length; i++) { |
|
const text = parts[i] |
|
const style = i < args.length - 1 && typeof args[i + 1] === 'string' ? args[i + 1] : undefined |
|
formattedParts.push({ text, style }) |
|
} |
|
|
|
const remainingArgs = args.slice(parts.length) |
|
if (remainingArgs.length > 0) { |
|
const remainingText = remainingArgs.map(arg => { |
|
if (typeof arg === 'object') { |
|
try { return JSON.stringify(arg, null, 2) } catch { return String(arg) } |
|
} |
|
return String(arg) |
|
}).join(' ') |
|
if (formattedParts.length > 0) { |
|
formattedParts[formattedParts.length - 1].text += ' ' + remainingText |
|
} else { |
|
formattedParts.push({ text: remainingText }) |
|
} |
|
} |
|
|
|
message = formattedParts.map(p => p.text).join('') |
|
} else { |
|
message = args.map(arg => { |
|
if (typeof arg === 'object') { |
|
try { return JSON.stringify(arg, null, 2) } catch { return String(arg) } |
|
} |
|
return String(arg) |
|
}).join(' ') |
|
formattedParts = [{ text: message }] |
|
} |
|
|
|
const logEntry = { type, message, formattedParts, timestamp: Date.now() } |
|
consoleLogRef.current.push(logEntry) |
|
if (consoleLogRef.current.length > 1000) { |
|
consoleLogRef.current = consoleLogRef.current.slice(-1000) |
|
} |
|
if (showConsoleLogs) { |
|
setConsoleLogs([...consoleLogRef.current]) |
|
} |
|
} |
|
|
|
console.log = (...args: any[]) => { captureLog('log', ...args); originalLog.apply(console, args) } |
|
console.error = (...args: any[]) => { captureLog('error', ...args); originalError.apply(console, args) } |
|
console.warn = (...args: any[]) => { captureLog('warn', ...args); originalWarn.apply(console, args) } |
|
console.info = (...args: any[]) => { captureLog('info', ...args); originalInfo.apply(console, args) } |
|
|
|
return () => { |
|
console.log = originalLog |
|
console.error = originalError |
|
console.warn = originalWarn |
|
console.info = originalInfo |
|
} |
|
}, [showConsoleLogs]) |
|
|
|
const handleShowConsoleLogs = () => { |
|
setConsoleLogs([...consoleLogRef.current]) |
|
setShowConsoleLogs(true) |
|
setConsoleLogSearch('') |
|
setConsoleLogLevel('all') |
|
} |
|
|
|
const handleClearConsoleLogs = () => { |
|
consoleLogRef.current = [] |
|
setConsoleLogs([]) |
|
toast.success(t('Console logs cleared')) |
|
} |
|
|
|
const filteredConsoleLogs = useMemo(() => { |
|
let filtered = [...consoleLogs] |
|
if (consoleLogLevel === 'errors-warnings') { |
|
filtered = filtered.filter(log => log.type === 'error' || log.type === 'warn') |
|
} |
|
if (consoleLogSearch.trim()) { |
|
const query = consoleLogSearch.toLowerCase().trim() |
|
filtered = filtered.filter(log => |
|
log.message.toLowerCase().includes(query) || |
|
log.type.toLowerCase().includes(query) |
|
) |
|
} |
|
return filtered |
|
}, [consoleLogs, consoleLogSearch, consoleLogLevel]) |
|
|
|
const renderConsoleLogList = () => |
|
filteredConsoleLogs.length === 0 ? ( |
|
<div className="text-muted-foreground p-4 text-center"> |
|
{consoleLogs.length === 0 |
|
? t('No console logs captured yet') |
|
: t('No logs match the current filters') |
|
} |
|
</div> |
|
) : ( |
|
filteredConsoleLogs.map((log, index) => ( |
|
<div |
|
key={index} |
|
className={`p-2 rounded border ${ |
|
log.type === 'error' ? 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800' : |
|
log.type === 'warn' ? 'bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800' : |
|
'bg-background border-border' |
|
}`} |
|
> |
|
<div className="flex items-start gap-2"> |
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> |
|
{new Date(log.timestamp).toLocaleTimeString()} |
|
</span> |
|
<span className="text-muted-foreground text-[10px] whitespace-nowrap"> |
|
[{log.type}] |
|
</span> |
|
<pre className="flex-1 overflow-x-auto whitespace-pre-wrap break-words"> |
|
{log.formattedParts ? ( |
|
log.formattedParts.map((part, i) => { |
|
if (part.style) { |
|
const styleObj: Record<string, string> = {} |
|
part.style.split(';').forEach(rule => { |
|
const trimmed = rule.trim() |
|
if (trimmed) { |
|
const colonIndex = trimmed.indexOf(':') |
|
if (colonIndex > 0) { |
|
const key = trimmed.substring(0, colonIndex).trim() |
|
const value = trimmed.substring(colonIndex + 1).trim() |
|
const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase()) |
|
styleObj[camelKey] = value |
|
} |
|
} |
|
}) |
|
return <span key={i} style={styleObj}>{part.text}</span> |
|
} |
|
return <span key={i}>{part.text}</span> |
|
}) |
|
) : ( |
|
log.message |
|
)} |
|
</pre> |
|
</div> |
|
</div> |
|
)) |
|
) |
|
|
|
const consoleLogFilters = ( |
|
<div className="flex min-w-0 flex-wrap gap-2"> |
|
<Input |
|
placeholder={t('Search logs...')} |
|
value={consoleLogSearch} |
|
onChange={(e) => setConsoleLogSearch(e.target.value)} |
|
className="min-w-0 flex-1 basis-[min(100%,12rem)]" |
|
/> |
|
<div className="flex shrink-0 gap-1"> |
|
<Button |
|
type="button" |
|
variant={consoleLogLevel === 'errors-warnings' ? 'secondary' : 'outline'} |
|
size="sm" |
|
onClick={() => setConsoleLogLevel('errors-warnings')} |
|
> |
|
{t('Errors & warnings')} |
|
</Button> |
|
<Button |
|
type="button" |
|
variant={consoleLogLevel === 'all' ? 'secondary' : 'outline'} |
|
size="sm" |
|
onClick={() => setConsoleLogLevel('all')} |
|
> |
|
{t('All')} |
|
</Button> |
|
</div> |
|
</div> |
|
) |
|
|
|
return ( |
|
<div className="space-y-4"> |
|
<div className="text-xs text-muted-foreground space-y-1"> |
|
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div> |
|
<div> |
|
{t('refreshCacheButtonExplainer', { |
|
defaultValue: |
|
'Refresh Cache runs an IndexedDB upgrade check, re-fetches your relay lists and profile-related events from the network (same work as the automatic startup sync), syncs kind-5 deletions into tombstones and removes deleted items from the local cache, then refreshes the store counts below.' |
|
})} |
|
</div> |
|
</div> |
|
<div className="flex min-w-0 flex-wrap gap-2"> |
|
<Button variant="outline" className="shrink-0" onClick={handleClearCache}> |
|
<Trash2 className="mr-2 h-4 w-4" /> |
|
{t('Clear Cache')} |
|
</Button> |
|
<Button |
|
variant="outline" |
|
className="shrink-0" |
|
onClick={handleRefreshCache} |
|
disabled={cacheRefreshBusy} |
|
> |
|
<RefreshCw className={`mr-2 h-4 w-4 ${cacheRefreshBusy ? 'animate-spin' : ''}`} /> |
|
{t('Refresh Cache')} |
|
</Button> |
|
<Button variant="outline" className="shrink-0" onClick={openBrowseCache}> |
|
<Database className="mr-2 h-4 w-4" /> |
|
{t('Browse Cache')} |
|
</Button> |
|
<Button variant="outline" className="shrink-0" onClick={handleClearServiceWorker}> |
|
<XCircle className="mr-2 h-4 w-4" /> |
|
{t('Clear Service Worker')} |
|
</Button> |
|
<Button variant="outline" className="shrink-0" onClick={handleShowConsoleLogs}> |
|
<Terminal className="mr-2 h-4 w-4" /> |
|
{t('View Console Logs')} ({consoleLogRef.current.length}) |
|
</Button> |
|
</div> |
|
|
|
{isSmallScreen ? ( |
|
<Drawer open={showConsoleLogs} onOpenChange={setShowConsoleLogs}> |
|
<DrawerContent className="max-h-[90vh]"> |
|
<DrawerHeader> |
|
<div className="flex items-center justify-between"> |
|
<div className="flex-1"> |
|
<DrawerTitle>{t('Console Logs')}</DrawerTitle> |
|
<DrawerDescription> |
|
{t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) |
|
</DrawerDescription> |
|
</div> |
|
<div className="flex gap-2"> |
|
<Button variant="outline" size="sm" onClick={handleClearConsoleLogs}> |
|
<Trash2 className="h-4 w-4 mr-2" /> |
|
{t('Clear')} |
|
</Button> |
|
<Button variant="ghost" size="sm" onClick={() => setShowConsoleLogs(false)}> |
|
<X className="h-4 w-4" /> |
|
</Button> |
|
</div> |
|
</div> |
|
</DrawerHeader> |
|
<div className="space-y-2 px-4 pb-2">{consoleLogFilters}</div> |
|
<div className="flex-1 overflow-auto px-4 pb-4"> |
|
<div className="space-y-1 font-mono text-xs">{renderConsoleLogList()}</div> |
|
</div> |
|
</DrawerContent> |
|
</Drawer> |
|
) : ( |
|
<Dialog open={showConsoleLogs} onOpenChange={setShowConsoleLogs}> |
|
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col" withoutClose> |
|
<DialogHeader> |
|
<div className="flex items-center justify-between"> |
|
<div className="flex-1"> |
|
<DialogTitle>{t('Console Logs')}</DialogTitle> |
|
<DialogDescription> |
|
{t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) |
|
</DialogDescription> |
|
</div> |
|
<div className="flex gap-2"> |
|
<Button variant="outline" size="sm" onClick={handleClearConsoleLogs}> |
|
<Trash2 className="h-4 w-4 mr-2" /> |
|
{t('Clear')} |
|
</Button> |
|
<Button variant="ghost" size="sm" onClick={() => setShowConsoleLogs(false)}> |
|
<X className="h-4 w-4" /> |
|
</Button> |
|
</div> |
|
</div> |
|
</DialogHeader> |
|
<div className="space-y-2 px-6 pb-4">{consoleLogFilters}</div> |
|
<div className="flex-1 overflow-auto px-6 pb-4"> |
|
<div className="space-y-1 font-mono text-xs">{renderConsoleLogList()}</div> |
|
</div> |
|
</DialogContent> |
|
</Dialog> |
|
)} |
|
</div> |
|
) |
|
}
|
|
|