diff --git a/src/PageManager.tsx b/src/PageManager.tsx index ab4363f..a44d2f6 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -266,7 +266,7 @@ export function useSmartSettingsNavigation() { if (url === '/settings') { window.history.pushState(null, '', url) setPrimaryNoteView(, 'settings') - } else if (url === '/settings/relays') { + } else if (url.startsWith('/settings/relays')) { window.history.pushState(null, '', url) setPrimaryNoteView(, 'settings-sub') } else if (url === '/settings/wallet') { diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index 4ab23ad..9887e6a 100644 --- a/src/components/CacheRelaysSetting/index.tsx +++ b/src/components/CacheRelaysSetting/index.tsx @@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button' import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay, TMailboxRelayScope } from '@/types' -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { DndContext, @@ -28,14 +28,30 @@ 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, Loader } from 'lucide-react' +import { CloudUpload, Loader, Trash2, RefreshCw, Database, WrapText, Search, X } from 'lucide-react' +import { Input } from '@/components/ui/input' +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' export default function CacheRelaysSetting() { const { t } = useTranslation() + const { isSmallScreen } = useScreenSize() const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent } = useNostr() const [relays, setRelays] = useState([]) const [hasChange, setHasChange] = useState(false) const [pushing, setPushing] = useState(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 sensors = useSensors( useSensor(PointerSensor, { @@ -134,6 +150,218 @@ export default function CacheRelaysSetting() { } } + useEffect(() => { + // Load cache info on mount + loadCacheInfo() + }, []) + + const loadCacheInfo = async () => { + try { + const info = await indexedDb.getStoreInfo() + setCacheInfo(info) + } catch (error) { + console.error('Failed to load cache info:', error) + } + } + + 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 + await indexedDb.clearAllCache() + + // Clear localStorage (but keep essential settings like theme, accounts, etc.) + // We'll only clear Jumble-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) { + console.warn(`Failed to remove ${key} from localStorage:`, e) + } + }) + + // Clear service worker caches + if ('caches' in window) { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter(name => name.includes('nostr') || name.includes('satellite') || name.includes('external')) + .map(name => caches.delete(name)) + ) + } + + // Clear post editor cache + postEditorCache.clearPostCache({}) + + // Reload cache info + await loadCacheInfo() + + toast.success(t('Cache cleared successfully')) + } catch (error) { + console.error('Failed to clear cache:', error) + toast.error(t('Failed to clear cache')) + } + } + + const handleRefreshCache = async () => { + try { + // Force database upgrade to update structure + await indexedDb.forceDatabaseUpgrade() + + // Reload cache info + await loadCacheInfo() + + toast.success(t('Cache refreshed successfully')) + } catch (error) { + console.error('Failed to refresh cache:', error) + toast.error(t('Failed to refresh cache')) + } + } + + const handleBrowseCache = () => { + setBrowsingCache(true) + setSelectedStore(null) + setStoreItems([]) + setSearchQuery('') + loadCacheInfo() + } + + 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) { + console.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) { + console.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) { + console.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() + toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept })) + } catch (error) { + console.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 save = async () => { if (!pubkey) return @@ -180,36 +408,338 @@ export default function CacheRelaysSetting() { } return ( -
-
-
{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}
+
+ {/* 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) => ( + + ))} +
+
+
+
- - - - - r.url)} strategy={verticalListSortingStrategy}> -
- {relays.map((relay) => ( - + + {/* 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.')}
+
+
+ + + +
+ {Object.keys(cacheInfo).length > 0 && ( +
+
{t('Cache Statistics:')}
+ {Object.entries(cacheInfo).map(([storeName, count]) => ( +
+ {storeName}: {count} {t('items')} +
))}
- - - + )} +
+ + {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 ? ( +
+ +
+ ) : storeItems.length === 0 ? ( +
{t('No items in this store.')}
+ ) : ( +
+
+ {storeItems.length} {t('items')} +
+ {storeItems.map((item, index) => { + const nestedCount = (item as any).nestedCount + return ( +
+
+ {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 ? ( +
+ +
+ ) : ( + <> +
+ + 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 + return ( +
+ +
+ {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)}
+                                  
+
+ ) + }) + )} +
+ )} + + )} + + )} +
+
+
+ )}
) } diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index b1054a5..bb50fb5 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -10,15 +10,17 @@ import logger from '@/lib/logger' import { Button } from '@/components/ui/button' import { MoreVertical } from 'lucide-react' import indexedDb from '@/services/indexed-db.service' +import { isReplaceableEvent } from '@/lib/event' interface PublicationReference { - coordinate: string + coordinate?: string + eventId?: string event?: Event - kind: number - pubkey: string - identifier: string + kind?: number + pubkey?: string + identifier?: string relay?: string - eventId?: string + type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID } interface ToCItem { @@ -93,16 +95,16 @@ export default function PublicationIndex({ const tocItem: ToCItem = { title, - coordinate: ref.coordinate, + coordinate: ref.coordinate || ref.eventId || '', event: ref.event, - kind: ref.kind + kind: ref.kind || ref.event?.kind || 0 } // For nested 30040 publications, recursively get their ToC - if (ref.kind === ExtendedKind.PUBLICATION && ref.event) { + if ((ref.kind === ExtendedKind.PUBLICATION || ref.event?.kind === ExtendedKind.PUBLICATION) && ref.event) { const nestedRefs: ToCItem[] = [] - // Parse nested references from this publication + // Parse nested references from this publication (both 'a' and 'e' tags) for (const tag of ref.event.tags) { if (tag[0] === 'a' && tag[1]) { const [kindStr, , identifier] = tag[1].split(':') @@ -121,6 +123,16 @@ export default function PublicationIndex({ kind }) } + } else if (tag[0] === 'e' && tag[1]) { + // For 'e' tags, we can't extract title from the tag alone + // The title will come from the fetched event if available + const nestedTitle = ref.event?.tags.find(t => t[0] === 'title')?.[1] || 'Untitled' + + nestedRefs.push({ + title: nestedTitle, + coordinate: tag[1], // Use event ID as coordinate + kind: ref.event?.kind + }) } } @@ -181,15 +193,17 @@ export default function PublicationIndex({ } } - // Extract references from 'a' tags + // Extract references from 'a' tags (addressable events) and 'e' tags (event IDs) const referencesData = useMemo(() => { const refs: PublicationReference[] = [] for (const tag of event.tags) { if (tag[0] === 'a' && tag[1]) { + // Addressable event (kind:pubkey:identifier) const [kindStr, pubkey, identifier] = tag[1].split(':') const kind = parseInt(kindStr) if (!isNaN(kind)) { refs.push({ + type: 'a', coordinate: tag[1], kind, pubkey, @@ -198,6 +212,13 @@ export default function PublicationIndex({ eventId: tag[3] // Optional event ID for version tracking }) } + } else if (tag[0] === 'e' && tag[1]) { + // Event ID reference + refs.push({ + type: 'e', + eventId: tag[1], + relay: tag[2] + }) } } return refs @@ -212,8 +233,8 @@ export default function PublicationIndex({ useEffect(() => { setVisitedIndices(prev => new Set([...prev, currentCoordinate])) - // Cache the current publication index event using its actual event ID - indexedDb.putPublicationEvent(event).catch(err => { + // Cache the current publication index event as replaceable event + indexedDb.putReplaceableEvent(event).catch(err => { logger.error('[PublicationIndex] Error caching publication event:', err) }) }, [currentCoordinate, event]) @@ -242,7 +263,7 @@ export default function PublicationIndex({ if (!isMounted) break // Skip if this is a 30040 event we've already visited (prevent circular references) - if (ref.kind === ExtendedKind.PUBLICATION) { + if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) { if (currentVisited.has(ref.coordinate)) { logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) fetchedRefs.push({ ...ref, event: undefined }) @@ -251,39 +272,57 @@ export default function PublicationIndex({ } try { - // Generate bech32 ID from the 'a' tag - const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] - const bech32Id = generateBech32IdFromATag(aTag) + let fetchedEvent: Event | undefined = undefined - if (bech32Id) { - // First, check if we have this event by its eventId in the ref - let fetchedEvent: Event | undefined = undefined - - if (ref.eventId) { - // Try to get by event ID first - fetchedEvent = await indexedDb.getPublicationEvent(ref.eventId) - } + if (ref.type === 'a' && ref.coordinate) { + // Handle addressable event (a tag) + const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] + const bech32Id = generateBech32IdFromATag(aTag) - // If not found by event ID, try to fetch from relay - if (!fetchedEvent) { - fetchedEvent = await client.fetchEvent(bech32Id) - // Save to cache using the fetched event's ID as the key - if (fetchedEvent) { - await indexedDb.putPublicationEvent(fetchedEvent) - logger.debug('[PublicationIndex] Cached event with ID:', fetchedEvent.id) + if (bech32Id) { + // Try to get by coordinate (replaceable event) + fetchedEvent = await indexedDb.getPublicationEvent(ref.coordinate) + + // If not found, try to fetch from relay + if (!fetchedEvent) { + fetchedEvent = await client.fetchEvent(bech32Id) + // Save to cache as replaceable event + if (fetchedEvent) { + await indexedDb.putReplaceableEvent(fetchedEvent) + logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate) + } + } else { + logger.debug('[PublicationIndex] Loaded from cache by coordinate:', ref.coordinate) } } else { - logger.debug('[PublicationIndex] Loaded from cache by event ID:', ref.eventId) + logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) } + } else if (ref.type === 'e' && ref.eventId) { + // Handle event ID reference (e tag) + // Try to fetch by event ID first + fetchedEvent = await client.fetchEvent(ref.eventId) - if (fetchedEvent && isMounted) { - fetchedRefs.push({ ...ref, event: fetchedEvent }) - } else if (isMounted) { - logger.warn('[PublicationIndex] Could not fetch event for:', ref.coordinate) - fetchedRefs.push({ ...ref, event: undefined }) + if (fetchedEvent) { + // Check if this is a replaceable event kind + if (isReplaceableEvent(fetchedEvent.kind)) { + // Save to cache as replaceable event (will be linked to master via putPublicationWithNestedEvents) + await indexedDb.putReplaceableEvent(fetchedEvent) + logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) + } else { + // For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents + // Just cache them for now without master link - they'll be properly linked when we call putPublicationWithNestedEvents + logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId) + } + } else { + logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) } + } + + if (fetchedEvent && isMounted) { + fetchedRefs.push({ ...ref, event: fetchedEvent }) } else if (isMounted) { - logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) + const identifier = ref.type === 'a' ? ref.coordinate : ref.eventId + logger.warn('[PublicationIndex] Could not fetch event for:', identifier || 'unknown') fetchedRefs.push({ ...ref, event: undefined }) } } catch (error) { @@ -297,6 +336,14 @@ export default function PublicationIndex({ if (isMounted) { setReferences(fetchedRefs) setIsLoading(false) + + // Store master publication with all nested events + const nestedEvents = fetchedRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) + if (nestedEvents.length > 0) { + indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => { + logger.error('[PublicationIndex] Error caching publication with nested events:', err) + }) + } } } finally { clearTimeout(timeout) @@ -395,30 +442,32 @@ export default function PublicationIndex({ return (
- Reference {index + 1}: Unable to load event from coordinate {ref.coordinate} + Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'}
) } // Render based on event kind - const sectionId = `section-${ref.coordinate.replace(/:/g, '-')}` + const coordinate = ref.coordinate || ref.eventId || '' + const sectionId = `section-${coordinate.replace(/:/g, '-')}` + const eventKind = ref.kind || ref.event.kind - if (ref.kind === ExtendedKind.PUBLICATION) { + if (eventKind === ExtendedKind.PUBLICATION) { // Recursively render nested 30040 publication index return (
) - } else if (ref.kind === ExtendedKind.PUBLICATION_CONTENT || ref.kind === ExtendedKind.WIKI_ARTICLE) { + } else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { // Render 30041 or 30818 content as AsciidocArticle return (
) - } else if (ref.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { + } else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { // Render 30817 content as MarkdownArticle return (
@@ -430,7 +479,7 @@ export default function PublicationIndex({ return (
- Reference {index + 1}: Unsupported kind {ref.kind} + Reference {index + 1}: Unsupported kind {eventKind}
) diff --git a/src/components/NoteOptions/RawEventDialog.tsx b/src/components/NoteOptions/RawEventDialog.tsx index 07f7d8a..6155d26 100644 --- a/src/components/NoteOptions/RawEventDialog.tsx +++ b/src/components/NoteOptions/RawEventDialog.tsx @@ -6,7 +6,11 @@ import { DialogTitle } from '@/components/ui/dialog' import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area' +import { Button } from '@/components/ui/button' import { Event } from 'nostr-tools' +import { WrapText } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' export default function RawEventDialog({ event, @@ -17,19 +21,51 @@ export default function RawEventDialog({ isOpen: boolean onClose: () => void }) { + const { t } = useTranslation() + const [wordWrapEnabled, setWordWrapEnabled] = useState(true) + return ( - - - Raw Event - View the raw event data + + +
+
+ Raw Event + View the raw event data +
+ +
- -
-            {JSON.stringify(event, null, 2)}
-          
- -
+
+ +
+
+                {JSON.stringify(event, null, 2)}
+              
+
+ +
+
) diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index bf71de4..1e40a9c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -5,7 +5,7 @@ export default { 'New Note': 'New Note', Post: 'Post', Home: 'Home', - 'Relay settings': 'Relay settings', + 'Relay settings': 'Relays and Storage Settings', Settings: 'Settings', SidebarRelays: 'Relays', Refresh: 'Refresh', @@ -57,7 +57,8 @@ export default { "username's muted": "{{username}}'s muted", Login: 'Login', 'Follows you': 'Follows you', - 'Relay Settings': 'Relay Settings', + 'Relay Settings': 'Relays and Storage Settings', + 'Relays and Storage Settings': 'Relays and Storage Settings', 'Relay set name': 'Relay set name', 'Add a new relay set': 'Add a new relay set', Add: 'Add', @@ -464,6 +465,7 @@ export default { 'Paste your one-time code here': 'Paste your one-time code here', Connect: 'Connect', 'Set up your wallet to send and receive sats!': 'Set up your wallet to send and receive sats!', - 'Set up': 'Set up' + 'Set up': 'Set up', + 'nested events': 'nested events' } } diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 576f2ac..d2c5051 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -25,12 +25,12 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: }, []) return ( - + {t('Favorite Relays')} {t('Read & Write Relays')} - {t('Cache Relays')} + {t('Cache')} diff --git a/src/pages/secondary/SettingsPage/index.tsx b/src/pages/secondary/SettingsPage/index.tsx index 488091f..d5bb677 100644 --- a/src/pages/secondary/SettingsPage/index.tsx +++ b/src/pages/secondary/SettingsPage/index.tsx @@ -44,7 +44,7 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb navigateToSettings(toRelaySettings())}>
-
{t('Relays')}
+
{t('Relays and Storage Settings')}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 688f3c7..4ba814e 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -2,11 +2,13 @@ import { ExtendedKind } from '@/constants' import { tagNameEquals } from '@/lib/tag' import { TRelayInfo } from '@/types' import { Event, kinds } from 'nostr-tools' +import { isReplaceableEvent } from '@/lib/event' type TValue = { key: string value: T | null addedAt: number + masterPublicationKey?: string // For nested publication events, link to master publication } const StoreNames = { @@ -47,7 +49,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 13) + const request = window.indexedDB.open('jumble', 14) request.onerror = (event) => { reject(event) @@ -465,11 +467,12 @@ class IndexedDbService { private getReplaceableEventKeyFromEvent(event: Event): string { if ( [kinds.Metadata, kinds.Contacts].includes(event.kind) || - (event.kind >= 10000 && event.kind < 20000) + (event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.WIKI_ARTICLE_MARKDOWN && event.kind !== kinds.LongFormArticle) ) { return this.getReplaceableEventKey(event.pubkey) } + // Publications and their nested content are replaceable by pubkey + d-tag const [, d] = event.tags.find(tagNameEquals('d')) ?? [] return this.getReplaceableEventKey(event.pubkey, d) } @@ -518,18 +521,119 @@ class IndexedDbService { } } - async putPublicationEvent(event: Event): Promise { + async putPublicationWithNestedEvents(masterEvent: Event, nestedEvents: Event[]): Promise { + // Store master publication as replaceable event + const masterKey = this.getReplaceableEventKeyFromEvent(masterEvent) + await this.putReplaceableEvent(masterEvent) + + // Store nested events, linking them to the master + for (const nestedEvent of nestedEvents) { + // Check if this is a replaceable event kind + if (isReplaceableEvent(nestedEvent.kind)) { + await this.putReplaceableEventWithMaster(nestedEvent, masterKey) + } else { + // For non-replaceable events, store by event ID with master link + await this.putNonReplaceableEventWithMaster(nestedEvent, masterKey) + } + } + + return masterEvent + } + + private async putReplaceableEventWithMaster(event: Event, masterKey: string): Promise { + const storeName = this.getStoreNameByKind(event.kind) + if (!storeName) { + return Promise.reject('store name not found') + } + await this.initPromise + + // Wait a bit for database upgrade to complete if store doesn't exist + if (this.db && !this.db.objectStoreNames.contains(storeName)) { + let retries = 20 + while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) { + await new Promise(resolve => setTimeout(resolve, 100)) + retries-- + } + } + + return new Promise((resolve, reject) => { + if (!this.db) { + return reject('database not initialized') + } + if (!this.db.objectStoreNames.contains(storeName)) { + console.warn(`Store ${storeName} not found in database. Cannot save event.`) + return resolve(event) + } + const transaction = this.db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + + const key = this.getReplaceableEventKeyFromEvent(event) + const getRequest = store.get(key) + getRequest.onsuccess = () => { + const oldValue = getRequest.result as TValue | undefined + if (oldValue?.value && oldValue.value.created_at >= event.created_at) { + // Update master key link even if event is not newer + if (oldValue.masterPublicationKey !== masterKey) { + const value = this.formatValue(key, oldValue.value) + value.masterPublicationKey = masterKey + store.put(value) + } + transaction.commit() + return resolve(oldValue.value) + } + // Store with master key link + const value = this.formatValue(key, event) + value.masterPublicationKey = masterKey + const putRequest = store.put(value) + putRequest.onsuccess = () => { + transaction.commit() + resolve(event) + } + + putRequest.onerror = (event) => { + transaction.commit() + reject(event) + } + } + + getRequest.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + + private async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise { + // For non-replaceable events, store by event ID in publication events store + const storeName = StoreNames.PUBLICATION_EVENTS await this.initPromise + + // Wait a bit for database upgrade to complete if store doesn't exist + if (this.db && !this.db.objectStoreNames.contains(storeName)) { + let retries = 20 + while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) { + await new Promise(resolve => setTimeout(resolve, 100)) + retries-- + } + } + return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } - const transaction = this.db.transaction(StoreNames.PUBLICATION_EVENTS, 'readwrite') - const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) + if (!this.db.objectStoreNames.contains(storeName)) { + console.warn(`Store ${storeName} not found in database. Cannot save event.`) + return resolve(event) + } + const transaction = this.db.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + // For non-replaceable events, use event ID as key const key = event.id - // Always update, as these are not replaceable events - const putRequest = store.put(this.formatValue(key, event)) + // For non-replaceable events, always update with master key link + const value = this.formatValue(key, event) + value.masterPublicationKey = masterKey + const putRequest = store.put(value) putRequest.onsuccess = () => { transaction.commit() resolve(event) @@ -542,20 +646,139 @@ class IndexedDbService { }) } - async getPublicationEvent(eventId: string): Promise { + async getPublicationEvent(coordinate: string): Promise { + // Parse coordinate (format: kind:pubkey:d-tag) + const coordinateParts = coordinate.split(':') + if (coordinateParts.length >= 2) { + const kind = parseInt(coordinateParts[0]) + if (!isNaN(kind)) { + const pubkey = coordinateParts[1] + const d = coordinateParts[2] || undefined + const event = await this.getReplaceableEvent(pubkey, kind, d) + return event || undefined + } + } + return Promise.resolve(undefined) + } + + async getPublicationStoreItems(storeName: string): Promise> { + // For publication stores, only return master events with nested counts await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return [] + } + return new Promise((resolve, reject) => { - if (!this.db) { - return reject('database not initialized') - } - const transaction = this.db.transaction(StoreNames.PUBLICATION_EVENTS, 'readonly') - const store = transaction.objectStore(StoreNames.PUBLICATION_EVENTS) - const request = store.get(eventId) + const transaction = this.db!.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.openCursor() + + const masterEvents = new Map() + const nestedEvents: Array<{ key: string; masterKey?: string }> = [] request.onsuccess = () => { + const cursor = (request as any).result + if (cursor) { + const item = cursor.value as TValue + const key = cursor.key as string + + if (item?.value) { + const event = item.value as Event + // Check if this is a master publication (kind 30040) or a nested event + if (event.kind === ExtendedKind.PUBLICATION && !item.masterPublicationKey) { + // This is a master publication + masterEvents.set(key, { + key, + value: event, + addedAt: item.addedAt, + nestedCount: 0 + }) + } else if (item.masterPublicationKey) { + // This is a nested event - track it for counting + nestedEvents.push({ + key, + masterKey: item.masterPublicationKey + }) + } + } + cursor.continue() + } else { + // Count nested events for each master + nestedEvents.forEach(nested => { + if (nested.masterKey && masterEvents.has(nested.masterKey)) { + const master = masterEvents.get(nested.masterKey)! + master.nestedCount++ + } + }) + + transaction.commit() + resolve(Array.from(masterEvents.values())) + } + } + + request.onerror = (event) => { transaction.commit() - const cachedValue = (request.result as TValue)?.value - resolve(cachedValue || undefined) + reject(event) + } + }) + } + + async deletePublicationAndNestedEvents(pubkey: string, d?: string): Promise<{ deleted: number }> { + const masterKey = this.getReplaceableEventKey(pubkey, d) + const storeName = StoreNames.PUBLICATION_EVENTS + + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return Promise.resolve({ deleted: 0 }) + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.openCursor() + + const keysToDelete: string[] = [] + + request.onsuccess = (event) => { + const cursor = (event.target as IDBRequest).result + if (cursor) { + const value = cursor.value as TValue + const key = cursor.key as string + + // Delete if it's the master (matches masterKey) or linked to the master (has masterPublicationKey) + if (key === masterKey || value?.masterPublicationKey === masterKey) { + keysToDelete.push(key) + } + cursor.continue() + } else { + // Delete all identified keys + let deletedCount = 0 + let completedCount = 0 + + if (keysToDelete.length === 0) { + transaction.commit() + return resolve({ deleted: 0 }) + } + + keysToDelete.forEach(key => { + const deleteRequest = store.delete(key) + deleteRequest.onsuccess = () => { + deletedCount++ + completedCount++ + if (completedCount === keysToDelete.length) { + transaction.commit() + resolve({ deleted: deletedCount }) + } + } + deleteRequest.onerror = () => { + completedCount++ + if (completedCount === keysToDelete.length) { + transaction.commit() + resolve({ deleted: deletedCount }) + } + } + }) + } } request.onerror = (event) => { @@ -573,6 +796,286 @@ class IndexedDbService { } } + async clearAllCache(): Promise { + await this.initPromise + if (!this.db) { + return + } + + const allStoreNames = Array.from(this.db.objectStoreNames) + const transaction = this.db.transaction(allStoreNames, 'readwrite') + + await Promise.allSettled( + allStoreNames.map(storeName => { + return new Promise((resolve, reject) => { + const store = transaction.objectStore(storeName) + const request = store.clear() + request.onsuccess = () => resolve() + request.onerror = (event) => reject(event) + }) + }) + ) + } + + async getStoreInfo(): Promise> { + await this.initPromise + if (!this.db) { + return {} + } + + const storeInfo: Record = {} + const allStoreNames = Array.from(this.db.objectStoreNames) + + await Promise.allSettled( + allStoreNames.map(storeName => { + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.count() + request.onsuccess = () => { + storeInfo[storeName] = request.result + resolve() + } + request.onerror = (event) => reject(event) + }) + }) + ) + + return storeInfo + } + + async getStoreItems(storeName: string): Promise[]> { + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return [] + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(storeName, 'readonly') + const store = transaction.objectStore(storeName) + const request = store.getAll() + + request.onsuccess = () => { + transaction.commit() + resolve(request.result as TValue[]) + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + + async deleteStoreItem(storeName: string, key: string): Promise { + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return Promise.reject('Store not found') + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.delete(key) + + request.onsuccess = () => { + transaction.commit() + resolve() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + + async clearStore(storeName: string): Promise { + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return Promise.reject('Store not found') + } + + return new Promise((resolve, reject) => { + const transaction = this.db!.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + const request = store.clear() + + request.onsuccess = () => { + transaction.commit() + resolve() + } + + request.onerror = (event) => { + transaction.commit() + reject(event) + } + }) + } + + async cleanupDuplicateReplaceableEvents(storeName: string): Promise<{ deleted: number; kept: number }> { + await this.initPromise + if (!this.db || !this.db.objectStoreNames.contains(storeName)) { + return Promise.reject('Store not found') + } + + // Get the kind for this store - only clean up replaceable event stores + const kind = this.getKindByStoreName(storeName) + if (!kind || !this.isReplaceableEventKind(kind)) { + return Promise.reject('Not a replaceable event store') + } + + // First pass: identify duplicates + const allItems = await this.getStoreItems(storeName) + const eventMap = new Map() + const keysToDelete: string[] = [] + + for (const item of allItems) { + if (!item || !item.value) continue + + const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value) + const existing = eventMap.get(replaceableKey) + + if (!existing || + item.value.created_at > existing.event.created_at || + (item.value.created_at === existing.event.created_at && + item.addedAt > existing.addedAt)) { + // This event is newer, mark the old one for deletion if it exists + if (existing) { + keysToDelete.push(existing.key) + } + eventMap.set(replaceableKey, { + key: item.key, + event: item.value, + addedAt: item.addedAt + }) + } else { + // This event is older or same, mark it for deletion + keysToDelete.push(item.key) + } + } + + // Second pass: delete duplicates + if (keysToDelete.length === 0) { + return Promise.resolve({ deleted: 0, kept: eventMap.size }) + } + + return new Promise((resolve) => { + const transaction = this.db!.transaction(storeName, 'readwrite') + const store = transaction.objectStore(storeName) + + let deletedCount = 0 + let completedCount = 0 + + keysToDelete.forEach(key => { + const deleteRequest = store.delete(key) + deleteRequest.onsuccess = () => { + deletedCount++ + completedCount++ + if (completedCount === keysToDelete.length) { + transaction.commit() + resolve({ deleted: deletedCount, kept: eventMap.size }) + } + } + deleteRequest.onerror = () => { + completedCount++ + if (completedCount === keysToDelete.length) { + transaction.commit() + resolve({ deleted: deletedCount, kept: eventMap.size }) + } + } + }) + }) + } + + private getKindByStoreName(storeName: string): number | undefined { + // Reverse lookup of getStoreNameByKind + if (storeName === StoreNames.PROFILE_EVENTS) return kinds.Metadata + if (storeName === StoreNames.RELAY_LIST_EVENTS) return kinds.RelayList + if (storeName === StoreNames.FOLLOW_LIST_EVENTS) return kinds.Contacts + if (storeName === StoreNames.MUTE_LIST_EVENTS) return kinds.Mutelist + if (storeName === StoreNames.BOOKMARK_LIST_EVENTS) return kinds.BookmarkList + if (storeName === StoreNames.PIN_LIST_EVENTS) return 10001 + if (storeName === StoreNames.INTEREST_LIST_EVENTS) return 10015 + if (storeName === StoreNames.BLOSSOM_SERVER_LIST_EVENTS) return ExtendedKind.BLOSSOM_SERVER_LIST + if (storeName === StoreNames.RELAY_SETS) return kinds.Relaysets + if (storeName === StoreNames.FAVORITE_RELAYS) return ExtendedKind.FAVORITE_RELAYS + if (storeName === StoreNames.BLOCKED_RELAYS_EVENTS) return ExtendedKind.BLOCKED_RELAYS + if (storeName === StoreNames.CACHE_RELAYS_EVENTS) return ExtendedKind.CACHE_RELAYS + if (storeName === StoreNames.USER_EMOJI_LIST_EVENTS) return kinds.UserEmojiList + if (storeName === StoreNames.EMOJI_SET_EVENTS) return kinds.Emojisets + // PUBLICATION_EVENTS is not replaceable, so we don't handle it here + return undefined + } + + private isReplaceableEventKind(kind: number): boolean { + // Check if this is a replaceable event kind + return ( + kind === kinds.Metadata || + kind === kinds.Contacts || + kind === kinds.RelayList || + kind === kinds.Mutelist || + kind === kinds.BookmarkList || + (kind >= 10000 && kind < 20000) || + kind === ExtendedKind.FAVORITE_RELAYS || + kind === ExtendedKind.BLOCKED_RELAYS || + kind === ExtendedKind.CACHE_RELAYS || + kind === ExtendedKind.BLOSSOM_SERVER_LIST + ) + } + + async forceDatabaseUpgrade(): Promise { + // Close the database first + if (this.db) { + this.db.close() + this.db = null + this.initPromise = null + } + + // Check current version + const checkRequest = window.indexedDB.open('jumble') + let currentVersion = 14 + checkRequest.onsuccess = () => { + const db = checkRequest.result + currentVersion = db.version + db.close() + } + checkRequest.onerror = () => { + // If we can't check, start fresh + currentVersion = 14 + } + await new Promise(resolve => setTimeout(resolve, 100)) // Wait for version check + + const newVersion = currentVersion + 1 + + // Open with new version to trigger upgrade + return new Promise((resolve, reject) => { + const request = window.indexedDB.open('jumble', newVersion) + + request.onerror = (event) => { + reject(event) + } + + request.onsuccess = () => { + const db = request.result + // Don't close - keep it open for the service to use + this.db = db + this.initPromise = Promise.resolve() + resolve() + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + // Create any missing stores + Object.values(StoreNames).forEach(storeName => { + if (!db.objectStoreNames.contains(storeName)) { + db.createObjectStore(storeName, { keyPath: 'key' }) + } + }) + } + }) + } + private async cleanUp() { await this.initPromise if (!this.db) { diff --git a/src/services/navigation.service.ts b/src/services/navigation.service.ts index f1b0875..1e4d29a 100644 --- a/src/services/navigation.service.ts +++ b/src/services/navigation.service.ts @@ -215,7 +215,7 @@ export class NavigationService { if (viewType === 'settings') return 'Settings' if (viewType === 'settings-sub') { if (pathname.includes('/general')) return 'General Settings' - if (pathname.includes('/relays')) return 'Relay Settings' + if (pathname.includes('/relays')) return 'Relays and Storage Settings' if (pathname.includes('/wallet')) return 'Wallet Settings' if (pathname.includes('/posts')) return 'Post Settings' if (pathname.includes('/translation')) return 'Translation Settings' @@ -223,7 +223,7 @@ export class NavigationService { } if (viewType === 'profile') { if (pathname.includes('/following')) return 'Following' - if (pathname.includes('/relays')) return 'Relay Settings' + if (pathname.includes('/relays')) return 'Relays and Storage Settings' return 'Profile' } if (viewType === 'hashtag') return 'Hashtag' @@ -240,7 +240,7 @@ export class NavigationService { } if (viewType === 'following') return 'Following' if (viewType === 'mute') return 'Muted Users' - if (viewType === 'others-relay-settings') return 'Relay Settings' + if (viewType === 'others-relay-settings') return 'Relays and Storage Settings' return 'Page' }