From 5bf6f2a1fb328ba0079bd7a949951b67f08933f0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 8 Apr 2026 15:21:45 +0200 Subject: [PATCH] bug-fixes --- src/components/CacheRelaysSetting/index.tsx | 1358 +---------------- .../FavoriteRelaysFeedPicker/index.tsx | 88 +- .../InBrowserCacheSetting/index.tsx | 817 ++++++++++ .../Note/SelectionHighlightTrigger.tsx | 79 +- .../primary/SpellsPage/fauxSpellFeeds.ts | 8 +- .../secondary/CacheSettingsPage/index.tsx | 6 +- .../secondary/RelaySettingsPage/index.tsx | 8 + 7 files changed, 1009 insertions(+), 1355 deletions(-) create mode 100644 src/components/InBrowserCacheSetting/index.tsx diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index d81e3e85..c1e8ebfc 100644 --- a/src/components/CacheRelaysSetting/index.tsx +++ b/src/components/CacheRelaysSetting/index.tsx @@ -3,7 +3,7 @@ import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import logger from '@/lib/logger' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay, TMailboxRelayScope } from '@/types' -import { useEffect, useState, useMemo, useRef, useCallback } from 'react' +import { useEffect, useState, useRef } from 'react' import { useTranslation } from 'react-i18next' import { DndContext, @@ -29,50 +29,22 @@ import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' import { createCacheRelaysDraftEvent } from '@/lib/draft-event' import { getRelayListFromEvent } from '@/lib/event-metadata' import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' -import { CloudUpload, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert, Terminal, XCircle } from 'lucide-react' -import { Input } from '@/components/ui/input' +import { CloudUpload } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' -import client from '@/services/client.service' -import indexedDb, { StoreNames } 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' export default function CacheRelaysSetting() { const { t } = useTranslation() - const { isSmallScreen } = useScreenSize() const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent, - relayList, - requestAccountNetworkHydrate } = useNostr() const [relays, setRelays] = useState([]) const [hasChange, setHasChange] = useState(false) const [pushing, setPushing] = useState(false) const justSavedRef = useRef(false) - 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 [consoleLogs, setConsoleLogs] = useState; 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; timestamp: number }>>([]) const sensors = useSensors( useSensor(PointerSensor, { @@ -114,29 +86,23 @@ export default function CacheRelaysSetting() { const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent) const newRelays = cacheRelayList.originalRelays - - // Use functional update to compare with current state + setRelays((currentRelays) => { - // Check if relays are actually different (deep comparison) - const areRelaysEqual = + const areRelaysEqual = newRelays.length === currentRelays.length && - newRelays.every((relay, index) => - relay.url === currentRelays[index]?.url && + newRelays.every((relay, index) => + relay.url === currentRelays[index]?.url && relay.scope === currentRelays[index]?.scope ) - - // Only update and reset hasChange if relays actually changed AND we just saved - // This prevents resetting hasChange when user is actively making changes + if (!areRelaysEqual) { if (justSavedRef.current) { - // We just saved, so this update is expected - reset hasChange justSavedRef.current = false setHasChange(false) } return newRelays } - - // If relays are equal, don't update state (prevents unnecessary re-render) + return currentRelays }) }, [cacheRelayListEvent]) @@ -171,7 +137,6 @@ export default function CacheRelaysSetting() { if (!normalizedUrl) { return t('Invalid relay URL') } - // Cache relays must be local network URLs only if (!isLocalNetworkUrl(normalizedUrl)) { return t('Cache relays must be local network URLs only (e.g., ws://localhost:4869 or ws://127.0.0.1:4869)') } @@ -184,7 +149,6 @@ export default function CacheRelaysSetting() { } const handleAddDiscoveredRelays = (newRelays: TMailboxRelay[]) => { - // Filter to only local network URLs for cache relays const localRelays = newRelays.filter(newRelay => isLocalNetworkUrl(newRelay.url)) const relaysToAdd = localRelays.filter( newRelay => !relays.some(r => r.url === newRelay.url) @@ -195,589 +159,6 @@ export default function CacheRelaysSetting() { } } - useEffect(() => { - // Load cache info on mount - 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 - } - - try { - // Clear IndexedDB (all stores, including Piper read-aloud blobs) - await indexedDb.clearAllCache() - await indexedDb.clearPiperTtsCache() - - // Clear localStorage (but keep essential settings like theme, accounts, etc.) - // We'll only clear Imwald-specific cache keys, not all localStorage - 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) - } - }) - - // Clear only this app's service worker caches - if ('caches' in window) { - try { - const cacheNames = await caches.keys() - const currentOrigin = window.location.origin - - // App-specific cache names (from vite.config.ts) - const appCacheNames = [ - 'nostr-images', - 'satellite-images', - 'external-images' - ] - - // Workbox precache caches (typically start with 'workbox-' or 'precache-') - // and any cache that might be from this app - const appCaches = cacheNames.filter(name => { - // Check if it's one of our named caches - if (appCacheNames.includes(name)) { - return true - } - // Check if it's a workbox precache cache - if (name.startsWith('workbox-') || name.startsWith('precache-')) { - return true - } - // Check if it's a workbox runtime cache (might have our origin in the name) - 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 }) - } - } - - // Clear post editor cache - postEditorCache.clearAllPostCaches() - - // Clear in-memory caches so profile pics and reactions work after clear - client.clearInMemoryCaches() - - // Reload cache info - await loadCacheInfo() - - toast.success(t('Cache cleared successfully')) - // Reload the app so it re-fetches profiles and relay lists from the network. - // Without this, missing IndexedDB + stale in-memory state can break reactions and avatars. - 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() - await loadCacheInfo() - 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 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 - } - - try { - const currentOrigin = window.location.origin - let unregisteredCount = 0 - let cacheClearedCount = 0 - - // Check for service worker support and secure context (SW API throws in insecure contexts) - if (window.isSecureContext && 'serviceWorker' in navigator) { - // Get all service worker registrations - let registrations: readonly ServiceWorkerRegistration[] = [] - try { - registrations = await navigator.serviceWorker.getRegistrations() - } catch (error) { - logger.warn('Failed to get service worker registrations', { error }) - } - - // Only unregister service workers for this origin/app - if (registrations.length > 0) { - const unregisterPromises = registrations.map(async (registration) => { - try { - // Check if this service worker is for this origin - 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) - } - } - - // Clear only this app's caches - if ('caches' in window) { - try { - const cacheNames = await caches.keys() - - // App-specific cache names (from vite.config.ts) - const appCacheNames = [ - 'nostr-images', - 'satellite-images', - 'external-images' - ] - - // Workbox precache caches (typically start with 'workbox-' or 'precache-') - // and any cache that might be from this app - const appCaches = cacheNames.filter(name => { - // Check if it's one of our named caches - if (appCacheNames.includes(name)) { - return true - } - // Check if it's a workbox precache cache - if (name.startsWith('workbox-') || name.startsWith('precache-')) { - return true - } - // Check if it's a workbox runtime cache (might have our origin in the name) - 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) - - // Reload after a short delay - 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))) - } - } - - // Capture console logs and logger output - start capturing immediately when component mounts - // Note: The logger uses console.log/error/warn/info internally, so intercepting console methods - // will automatically capture all logger output (debug, info, warn, error, perf, component, etc.) - useEffect(() => { - const originalLog = console.log - const originalError = console.error - const originalWarn = console.warn - const originalInfo = console.info - - const captureLog = (type: string, ...args: any[]) => { - // Handle console formatting with %c placeholders for CSS styling - // Console.log supports %c for CSS styling: console.log('%cText', 'color: red') - let message = '' - let formattedParts: Array<{ text: string; style?: string }> = [] - - if (args.length > 0 && typeof args[0] === 'string' && args[0].includes('%c')) { - // Handle %c formatting - 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 }) - } - - // Also include remaining args - 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 }) - } - } - - // Create a plain text version for search/filtering - message = formattedParts.map(p => p.text).join('') - } else { - // Normal formatting - convert all args to strings - 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) - // Keep only last 1000 logs - if (consoleLogRef.current.length > 1000) { - consoleLogRef.current = consoleLogRef.current.slice(-1000) - } - - // Update state if dialog is open - if (showConsoleLogs) { - setConsoleLogs([...consoleLogRef.current]) - } - } - - // Intercept console methods - this will capture all logger output since logger uses console internally - 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) - // Reset filters when opening – default to 'all' so user sees every entry (errors + warnings + info) - setConsoleLogSearch('') - setConsoleLogLevel('all') - } - - const handleClearConsoleLogs = () => { - consoleLogRef.current = [] - setConsoleLogs([]) - toast.success(t('Console logs cleared')) - } - - // Filter console logs based on search query and log level - const filteredConsoleLogs = useMemo(() => { - let filtered = [...consoleLogs] - - // Filter by log level: errors-warnings = error + warn only, all = everything - if (consoleLogLevel === 'errors-warnings') { - filtered = filtered.filter(log => log.type === 'error' || log.type === 'warn') - } - - // Filter by search query - 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 handleStoreClick = async (storeName: string) => { - setSelectedStore(storeName) - setSearchQuery('') - setLoadingItems(true) - try { - // For publication stores, use special method that only shows masters - 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 => { - // Search in key - if (item.key?.toLowerCase().includes(query)) { - return true - } - - // Search in JSON content - try { - const jsonString = JSON.stringify(item.value) - if (jsonString.toLowerCase().includes(query)) { - return true - } - } catch (e) { - // If JSON.stringify fails, skip - } - - // Search in addedAt timestamp - const dateString = new Date(item.addedAt).toLocaleString().toLowerCase() - if (dateString.includes(query)) { - return true - } - - return false - }) - }, [storeItems, searchQuery]) - - const handleDeleteItem = async (key: string) => { - if (!selectedStore) return - - try { - // For publication stores, parse the key to get pubkey and d-tag - if (selectedStore === 'publicationEvents') { - // Key format is "pubkey" or "pubkey:d-tag" - 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')) - } - - // Reload items - const items = selectedStore === 'publicationEvents' - ? await indexedDb.getPublicationStoreItems(selectedStore) - : await indexedDb.getStoreItems(selectedStore) - setStoreItems(items) - // Update cache info - 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([]) - // Update cache info - 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) - // Reload items - const items = await indexedDb.getStoreItems(selectedStore) - setStoreItems(items) - // Reset search query to show all items - setSearchQuery('') - // Update cache info - loadCacheInfo() - // Reload items to get accurate count after cleanup - const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore) - const actualCount = itemsAfterCleanup.length - - // Show message with actual count - 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) - } - } - - // Check if an event is invalid - const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }, storeName?: string | null): boolean => { - if (!item) return true - - // RSS feed items are not Nostr events, so skip validation for that store - // Handle both old format (with item property) and new format (with value property) - if (storeName === 'rssFeedItems') { - // Old format has item property, new format has value property - both are valid for RSS items - if (item.value || (item as any).item) { - return false - } - // If neither exists, it's invalid - return true - } - - 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) - } - - // For other stores, check if value exists - if (!item.value) return true - - const event = item.value as Event - // Check for required Nostr event fields - if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') { - return true - } - - // Check for tags array (required for Nostr events) - if (!event.tags || !Array.isArray(event.tags)) { - return true - } - - // Check for id and sig (these should be present in valid events) - if (!event.id || !event.sig) { - return true - } - - return false - }, []) - - // Get explanation for why an event is invalid - 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 save = async () => { if (!pubkey) return @@ -785,11 +166,9 @@ export default function CacheRelaysSetting() { try { const event = createCacheRelaysDraftEvent(relays) const result = await publish(event) - // Set flag before updating so useEffect knows to reset hasChange justSavedRef.current = true await updateCacheRelayListEvent(result) - - // Show publishing feedback + if ((result as any).relayStatuses) { showPublishingFeedback({ success: true, @@ -804,10 +183,8 @@ export default function CacheRelaysSetting() { showSimplePublishSuccess(t('Cache relays saved')) } } catch (error) { - // Reset flag on error justSavedRef.current = false logger.error('Failed to save cache relays', { error }) - // Show error feedback if (error instanceof Error && (error as any).relayStatuses) { showPublishingFeedback({ success: false, @@ -827,697 +204,36 @@ export default function CacheRelaysSetting() { } return ( -
- {/* Cache Relays Section */} -
-

{t('Cache Relays')}

-
-
{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}
-
- - - - - r.url)} strategy={verticalListSortingStrategy}> -
- {relays.map((relay) => ( - - ))} -
-
-
- +
+
+
{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}
- - {/* In-Browser Cache Section */} -
-

{t('In-Browser Cache')}

-
-
{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}
-
- {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.' - })} + + + + + r.url)} strategy={verticalListSortingStrategy}> +
+ {relays.map((relay) => ( + + ))}
-
-
- - - - - -
-
- - {isSmallScreen ? ( - - - -
-
- - {selectedStore ? ( -
- - {selectedStore} -
- ) : ( - t('Browse Cache') - )} -
- - {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.')} - -
- -
-
-
- {!selectedStore ? ( - // Store list view - Object.keys(cacheInfo).length === 0 ? ( -
{t('No cached data found.')}
- ) : ( - Object.entries(cacheInfo).map(([storeName, count]) => ( -
handleStoreClick(storeName)} - > -
{storeName}
-
- {count} {t('items')} -
-
- )) - ) - ) : ( - // Store items view - 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)}
-                                
-
- ) - }) - )} -
- )} - - ) - )} -
-
-
- ) : ( - - - -
-
- - {selectedStore ? ( -
- - {selectedStore} -
- ) : ( - t('Browse Cache') - )} -
- - {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.')} - -
- -
-
-
- {!selectedStore ? ( - // Store list view - Object.keys(cacheInfo).length === 0 ? ( -
{t('No cached data found.')}
- ) : ( - Object.entries(cacheInfo).map(([storeName, count]) => ( -
handleStoreClick(storeName)} - > -
{storeName}
-
- {count} {t('items')} -
-
- )) - ) - ) : ( - // Store items view - <> - {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)}
-                                  
-
- ) - }) - )} -
- )} - - )} - - )} -
-
-
- )} - - {/* Console Logs Dialog */} - {isSmallScreen ? ( - - - -
-
- {t('Console Logs')} - - {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) - -
-
- - -
-
-
-
-
- setConsoleLogSearch(e.target.value)} - className="min-w-0 flex-1 basis-[min(100%,12rem)]" - /> -
- - -
-
-
-
-
- {filteredConsoleLogs.length === 0 ? ( -
- {consoleLogs.length === 0 - ? t('No console logs captured yet') - : t('No logs match the current filters') - } -
- ) : ( - filteredConsoleLogs.map((log, index) => ( -
-
- - {new Date(log.timestamp).toLocaleTimeString()} - - - [{log.type}] - -
-                          {log.formattedParts ? (
-                            log.formattedParts.map((part, i) => {
-                              if (part.style) {
-                                // Parse CSS string like "color:#f1b912" or "color: #f1b912; font-weight: bold"
-                                const styleObj: Record = {}
-                                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()
-                                      // Convert kebab-case to camelCase
-                                      const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
-                                      styleObj[camelKey] = value
-                                    }
-                                  }
-                                })
-                                return (
-                                  
-                                    {part.text}
-                                  
-                                )
-                              }
-                              return {part.text}
-                            })
-                          ) : (
-                            log.message
-                          )}
-                        
-
-
- )) - )} -
-
-
-
- ) : ( - - - -
-
- {t('Console Logs')} - - {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) - -
-
- - -
-
-
-
-
- setConsoleLogSearch(e.target.value)} - className="min-w-0 flex-1 basis-[min(100%,12rem)]" - /> -
- - -
-
-
-
-
- {filteredConsoleLogs.length === 0 ? ( -
- {consoleLogs.length === 0 - ? t('No console logs captured yet') - : t('No logs match the current filters') - } -
- ) : ( - filteredConsoleLogs.map((log, index) => ( -
-
- - {new Date(log.timestamp).toLocaleTimeString()} - - - [{log.type}] - -
-                          {log.formattedParts ? (
-                            log.formattedParts.map((part, i) => {
-                              if (part.style) {
-                                // Parse CSS string like "color:#f1b912" or "color: #f1b912; font-weight: bold"
-                                const styleObj: Record = {}
-                                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()
-                                      // Convert kebab-case to camelCase
-                                      const camelKey = key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
-                                      styleObj[camelKey] = value
-                                    }
-                                  }
-                                })
-                                return (
-                                  
-                                    {part.text}
-                                  
-                                )
-                              }
-                              return {part.text}
-                            })
-                          ) : (
-                            log.message
-                          )}
-                        
-
-
- )) - )} -
-
-
-
- )} + + +
) } - diff --git a/src/components/FavoriteRelaysFeedPicker/index.tsx b/src/components/FavoriteRelaysFeedPicker/index.tsx index 4afbdb76..ded2b6fa 100644 --- a/src/components/FavoriteRelaysFeedPicker/index.tsx +++ b/src/components/FavoriteRelaysFeedPicker/index.tsx @@ -10,14 +10,16 @@ import { SelectValue } from '@/components/ui/select' import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays' +import { getHttpRelayListFromEvent } from '@/lib/event-metadata' import { toRelaySettings } from '@/lib/link' -import { normalizeUrl, simplifyUrl } from '@/lib/url' +import { normalizeAnyRelayUrl, normalizeUrl, simplifyUrl } from '@/lib/url' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' import { cn } from '@/lib/utils' import { useContainerWidth } from '@/hooks/useContainerWidth' import { useSecondaryPage } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFeed } from '@/providers/FeedProvider' +import { useNostr } from '@/providers/NostrProvider' import { SquarePen } from 'lucide-react' import { useMemo, useRef } from 'react' import { useTranslation } from 'react-i18next' @@ -36,7 +38,7 @@ function selectValueToRelaySetId(v: string) { return decodeURIComponent(v.slice(3)) } -/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, then single relays. */ +/** Top-of-feed control: all favorites, Wisp trending (nostrarchives), relay sets, single relays, HTTP index relays. */ export default function FavoriteRelaysFeedPicker() { const { t } = useTranslation() const containerRef = useRef(null) @@ -47,6 +49,7 @@ export default function FavoriteRelaysFeedPicker() { const { push } = useSecondaryPage() const { favoriteRelays, blockedRelays, relaySets } = useFavoriteRelays() const { feedInfo, switchFeed } = useFeed() + const { httpRelayListEvent } = useNostr() const openFavoriteRelaySettings = () => { push(toRelaySettings('favorite-relays')) @@ -59,6 +62,22 @@ export default function FavoriteRelaysFeedPicker() { [favoriteRelays, blockedRelays] ) + /** HTTP index relay URLs from kind 10243, deduped, excluding any already in favorites. */ + const httpRelayUrls = useMemo(() => { + if (!httpRelayListEvent) return [] + const list = getHttpRelayListFromEvent(httpRelayListEvent) + const favKeys = new Set(urls.map((u) => normalizeAnyRelayUrl(u) || u)) + const seen = new Set() + const out: string[] = [] + for (const u of [...list.httpRead, ...list.httpWrite]) { + const k = normalizeAnyRelayUrl(u) || u + if (!k || seen.has(k) || favKeys.has(k)) continue + seen.add(k) + out.push(k) + } + return out + }, [httpRelayListEvent, urls]) + const wispTrendingRelayUrl = useMemo(() => buildWispTrendingNotesRelayUrl(), []) const wispTrendingRelayKey = useMemo( () => normalizeUrl(wispTrendingRelayUrl) || wispTrendingRelayUrl, @@ -69,8 +88,11 @@ export default function FavoriteRelaysFeedPicker() { [urls, wispTrendingRelayKey] ) + // Use normalizeAnyRelayUrl so HTTP relay IDs are matched correctly (normalizeUrl converts http→ws). const currentRelayKey = - feedInfo.feedType === 'relay' && feedInfo.id ? normalizeUrl(feedInfo.id) || feedInfo.id : null + feedInfo.feedType === 'relay' && feedInfo.id + ? normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id + : null const allActive = feedInfo.feedType === 'all-favorites' @@ -103,7 +125,10 @@ export default function FavoriteRelaysFeedPicker() { items.push({ value: relaySetToSelectValue(orphanRelaySetId) }) } for (const url of urls) { - items.push({ value: normalizeUrl(url) || url }) + items.push({ value: normalizeAnyRelayUrl(url) || url }) + } + for (const url of httpRelayUrls) { + items.push({ value: normalizeAnyRelayUrl(url) || url }) } if ( !allActive && @@ -111,11 +136,12 @@ export default function FavoriteRelaysFeedPicker() { feedInfo.id && !items.some((i) => i.value === currentRelayKey) ) { - items.push({ value: normalizeUrl(feedInfo.id) || feedInfo.id }) + items.push({ value: normalizeAnyRelayUrl(feedInfo.id) || feedInfo.id }) } return items }, [ urls, + httpRelayUrls, allActive, feedInfo.feedType, feedInfo.id, @@ -132,8 +158,11 @@ export default function FavoriteRelaysFeedPicker() { const resolveRelayUrl = (value: string) => { if (value === ALL_FAVORITES_VALUE) return null - const fromList = urls.find((u) => (normalizeUrl(u) || u) === value) - return fromList ?? value + const fromFav = urls.find((u) => (normalizeAnyRelayUrl(u) || u) === value) + if (fromFav) return fromFav + const fromHttp = httpRelayUrls.find((u) => (normalizeAnyRelayUrl(u) || u) === value) + if (fromHttp) return fromHttp + return value } const onPickValue = (v: string) => { @@ -154,7 +183,7 @@ export default function FavoriteRelaysFeedPicker() { if (relay) void switchFeed('relay', { relay }) } - if (urls.length === 0 && relaySets.length === 0) return null + if (urls.length === 0 && httpRelayUrls.length === 0 && relaySets.length === 0) return null const editSettingsButton = (
@@ -314,7 +359,30 @@ export default function FavoriteRelaysFeedPicker() {
)} {urls.map((url) => { - const key = normalizeUrl(url) || url + const key = normalizeAnyRelayUrl(url) || url + const active = feedInfo.feedType === 'relay' && currentRelayKey === key + return ( + + ) + })} + {httpRelayUrls.length > 0 && ( +
+ )} + {httpRelayUrls.map((url) => { + const key = normalizeAnyRelayUrl(url) || url const active = feedInfo.feedType === 'relay' && currentRelayKey === key return ( + +
+
+ {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 ? ( +
+ {consoleLogs.length === 0 + ? t('No console logs captured yet') + : t('No logs match the current filters') + } +
+ ) : ( + filteredConsoleLogs.map((log, index) => ( +
+
+ + {new Date(log.timestamp).toLocaleTimeString()} + + + [{log.type}] + +
+              {log.formattedParts ? (
+                log.formattedParts.map((part, i) => {
+                  if (part.style) {
+                    const styleObj: Record = {}
+                    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 {part.text}
+                  }
+                  return {part.text}
+                })
+              ) : (
+                log.message
+              )}
+            
+
+
+ )) + ) + + const consoleLogFilters = ( +
+ setConsoleLogSearch(e.target.value)} + className="min-w-0 flex-1 basis-[min(100%,12rem)]" + /> +
+ + +
+
+ ) + + 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 ( +
+
+
{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}
+
+ {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.' + })} +
+
+
+ + + + + +
+ + {isSmallScreen ? ( + + + + {browseCacheHeader} + {t('Browse Cache')} + {browseCacheDescription} + +
+ {!selectedStore ? renderStoreListView() : renderStoreItemsView()} +
+
+
+ ) : ( + + + + {browseCacheHeader} + {t('Browse Cache')} + {browseCacheDescription} + +
+ {!selectedStore ? renderStoreListView() : renderStoreItemsView()} +
+
+
+ )} + + {isSmallScreen ? ( + + + +
+
+ {t('Console Logs')} + + {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) + +
+
+ + +
+
+
+
{consoleLogFilters}
+
+
{renderConsoleLogList()}
+
+
+
+ ) : ( + + + +
+
+ {t('Console Logs')} + + {t('View recent console logs for debugging')} ({filteredConsoleLogs.length} / {consoleLogs.length} {t('entries')}) + +
+
+ + +
+
+
+
{consoleLogFilters}
+
+
{renderConsoleLogList()}
+
+
+
+ )} +
+ ) +} diff --git a/src/components/Note/SelectionHighlightTrigger.tsx b/src/components/Note/SelectionHighlightTrigger.tsx index c9197aa7..4641e097 100644 --- a/src/components/Note/SelectionHighlightTrigger.tsx +++ b/src/components/Note/SelectionHighlightTrigger.tsx @@ -2,7 +2,7 @@ import { buildHighlightDataFromEvent } from '@/lib/build-highlight-data' import { useCreateHighlight } from './CreateHighlightContext' import { Event } from 'nostr-tools' import { Highlighter } from 'lucide-react' -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' @@ -36,8 +36,11 @@ export default function SelectionHighlightTrigger({ top: number left: number } | null>(null) + const debounceRef = useRef | null>(null) + // True while a touch is physically in contact with the screen. + const isTouchActiveRef = useRef(false) - const handleMouseUp = useCallback(() => { + const evaluateSelection = useCallback(() => { if (!openHighlight || !containerRef.current) return const sel = window.getSelection() if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { @@ -54,15 +57,64 @@ export default function SelectionHighlightTrigger({ setToolbar(null) return } + const rect = range.getBoundingClientRect() - setToolbar({ - selectedText, - paragraphContext: getParagraphContextFromRange(range), - top: rect.top - 44, - left: rect.left + rect.width / 2 - 80 - }) + const toolbarHeight = 44 + const margin = 8 + // Prefer above the selection; fall back to below if too close to top of viewport. + const top = + rect.top - toolbarHeight < margin ? rect.bottom + margin : rect.top - toolbarHeight + const rawLeft = rect.left + rect.width / 2 - 80 + const left = Math.max(margin, Math.min(rawLeft, window.innerWidth - 176 - margin)) + + setToolbar({ selectedText, paragraphContext: getParagraphContextFromRange(range), top, left }) }, [openHighlight]) + // Desktop: mouseup fires reliably after text selection by mouse. + const handleMouseUp = useCallback(() => { + evaluateSelection() + }, [evaluateSelection]) + + useEffect(() => { + if (!openHighlight) return + + const schedule = (delayMs: number) => { + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(evaluateSelection, delayMs) + } + + // Mobile: finger touches screen — mark active so selectionchange is suppressed during + // the gesture itself (avoids positioning the toolbar mid-drag). + const onTouchStart = () => { + isTouchActiveRef.current = true + } + + // Mobile: finger lifts — wait for the browser to settle the selection, then evaluate. + // The 600 ms matches the delay used in RssFeedItem for the same reason. + const onTouchEnd = () => { + isTouchActiveRef.current = false + schedule(600) + } + + // Both: covers keyboard selection (Shift+Arrow) on desktop and selection-handle + // dragging on mobile (which may not generate touch events in our DOM). + const onSelectionChange = () => { + if (isTouchActiveRef.current) return + schedule(80) + } + + document.addEventListener('touchstart', onTouchStart, { passive: true }) + document.addEventListener('touchend', onTouchEnd, { passive: true }) + document.addEventListener('selectionchange', onSelectionChange) + + return () => { + document.removeEventListener('touchstart', onTouchStart) + document.removeEventListener('touchend', onTouchEnd) + document.removeEventListener('selectionchange', onSelectionChange) + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [openHighlight, evaluateSelection]) + const handleCreateHighlight = useCallback(() => { if (!toolbar || !openHighlight) return const highlightData = buildHighlightDataFromEvent(event, toolbar.paragraphContext) @@ -84,10 +136,7 @@ export default function SelectionHighlightTrigger({ <>
-
+
)}
diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 9787510e..12ee1d0e 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -19,7 +19,7 @@ import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { normalizeTopic } from '@/lib/discussion-topics' import { userIdToPubkey } from '@/lib/pubkey' -import { normalizeUrl } from '@/lib/url' +import { normalizeAnyRelayUrl } from '@/lib/url' import type { TFeedSubRequest } from '@/types' import { type Event, type Filter } from 'nostr-tools' @@ -78,17 +78,17 @@ const INTERESTS_MAX_TOPIC_TAG_VALUES = INTERESTS_MAX_TOPICS * 4 * filled the cap. */ export function appendCuratedReadOnlyRelays(curated: string[], blockedRelays: string[]): string[] { - const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) + const blocked = new Set(blockedRelays.map((b) => normalizeAnyRelayUrl(b) || b)) const seen = new Set() const out: string[] = [] for (const u of READ_ONLY_RELAY_URLS) { - const k = normalizeUrl(u) || u + const k = normalizeAnyRelayUrl(u) || u if (!k || blocked.has(k) || seen.has(k)) continue seen.add(k) out.push(k) } for (const u of curated) { - const k = normalizeUrl(u) || u + const k = normalizeAnyRelayUrl(u) || u if (!k || seen.has(k)) continue seen.add(k) out.push(k) diff --git a/src/pages/secondary/CacheSettingsPage/index.tsx b/src/pages/secondary/CacheSettingsPage/index.tsx index 90550075..cb362e58 100644 --- a/src/pages/secondary/CacheSettingsPage/index.tsx +++ b/src/pages/secondary/CacheSettingsPage/index.tsx @@ -1,4 +1,4 @@ -import CacheRelaysSetting from '@/components/CacheRelaysSetting' +import InBrowserCacheSetting from '@/components/InBrowserCacheSetting' import EventArchiveCacheSettings from '@/components/EventArchiveCacheSettings' import { RefreshButton } from '@/components/RefreshButton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' @@ -29,8 +29,8 @@ const CacheSettingsPage = forwardRef( title={hideTitlebar ? undefined : t('Cache & offline storage')} controls={hideTitlebar ? undefined : } > -
- +
+
diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 05efdaa7..366e5377 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,3 +1,4 @@ +import CacheRelaysSetting from '@/components/CacheRelaysSetting' import HttpRelaysSetting from '@/components/HttpRelaysSetting' import JsonViewDialog from '@/components/JsonViewDialog' import MailboxSetting from '@/components/MailboxSetting' @@ -70,6 +71,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: case '#favorite-relays': setTabValue('favorite-relays') break + case '#cache-relays': + setTabValue('cache-relays') + break } }, []) @@ -115,6 +119,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: {t('Read & Write Relays')} {t('HTTP relays')} {t('Session relays')} + {t('Cache Relays')} @@ -128,6 +133,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: + + + )