Browse Source

bug-fixed, adjusted the display, added search, cleanup, and delete to cache viewers

fixed publication caching to replaceable state with a master index, to make updates and deletion simpler, and to prevent duplicates. handle both e and a tags
imwald
Silberengel 5 months ago
parent
commit
18d6e8513c
  1. 2
      src/PageManager.tsx
  2. 588
      src/components/CacheRelaysSetting/index.tsx
  3. 137
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  4. 56
      src/components/NoteOptions/RawEventDialog.tsx
  5. 8
      src/i18n/locales/en.ts
  6. 4
      src/pages/secondary/RelaySettingsPage/index.tsx
  7. 2
      src/pages/secondary/SettingsPage/index.tsx
  8. 535
      src/services/indexed-db.service.ts
  9. 6
      src/services/navigation.service.ts

2
src/PageManager.tsx

@ -266,7 +266,7 @@ export function useSmartSettingsNavigation() { @@ -266,7 +266,7 @@ export function useSmartSettingsNavigation() {
if (url === '/settings') {
window.history.pushState(null, '', url)
setPrimaryNoteView(<SettingsPage index={0} hideTitlebar={true} />, 'settings')
} else if (url === '/settings/relays') {
} else if (url.startsWith('/settings/relays')) {
window.history.pushState(null, '', url)
setPrimaryNoteView(<RelaySettingsPage index={0} hideTitlebar={true} />, 'settings-sub')
} else if (url === '/settings/wallet') {

588
src/components/CacheRelaysSetting/index.tsx

@ -2,7 +2,7 @@ import { Button } from '@/components/ui/button' @@ -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' @@ -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<TMailboxRelay[]>([])
const [hasChange, setHasChange] = useState(false)
const [pushing, setPushing] = useState(false)
const [cacheInfo, setCacheInfo] = useState<Record<string, number>>({})
const [browsingCache, setBrowsingCache] = useState(false)
const [selectedStore, setSelectedStore] = useState<string | null>(null)
const [storeItems, setStoreItems] = useState<any[]>([])
const [loadingItems, setLoadingItems] = useState(false)
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
const [searchQuery, setSearchQuery] = useState('')
const sensors = useSensors(
useSensor(PointerSensor, {
@ -134,6 +150,218 @@ export default function CacheRelaysSetting() { @@ -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() { @@ -180,36 +408,338 @@ export default function CacheRelaysSetting() {
}
return (
<div className="space-y-4">
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}</div>
<div className="space-y-6">
{/* Cache Relays Section */}
<div className="space-y-4">
<h3 className="text-sm font-semibold">{t('Cache Relays')}</h3>
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}</div>
</div>
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} />
<RelayCountWarning relays={relays} />
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />}
{t('Save')}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={relays.map((r) => r.url)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{relays.map((relay) => (
<MailboxRelay
key={relay.url}
mailboxRelay={relay}
changeMailboxRelayScope={changeCacheRelayScope}
removeMailboxRelay={removeCacheRelay}
/>
))}
</div>
</SortableContext>
</DndContext>
<NewMailboxRelayInput saveNewMailboxRelay={saveNewCacheRelay} />
</div>
<DiscoveredRelays onAdd={handleAddDiscoveredRelays} localOnly={true} />
<RelayCountWarning relays={relays} />
<Button className="w-full" disabled={!pubkey || pushing || !hasChange} onClick={save}>
{pushing ? <Loader className="animate-spin" /> : <CloudUpload />}
{t('Save')}
</Button>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={relays.map((r) => r.url)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{relays.map((relay) => (
<MailboxRelay
key={relay.url}
mailboxRelay={relay}
changeMailboxRelayScope={changeCacheRelayScope}
removeMailboxRelay={removeCacheRelay}
/>
{/* In-Browser Cache Section */}
<div className="space-y-4 border-t pt-4">
<h3 className="text-sm font-semibold">{t('In-Browser Cache')}</h3>
<div className="text-xs text-muted-foreground space-y-1">
<div>{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}</div>
</div>
<div className="flex flex-row gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleClearCache}
>
<Trash2 className="h-4 w-4 mr-2" />
{t('Clear Cache')}
</Button>
<Button
variant="outline"
className="flex-1"
onClick={handleRefreshCache}
>
<RefreshCw className="h-4 w-4 mr-2" />
{t('Refresh Cache')}
</Button>
<Button
variant="outline"
className="flex-1"
onClick={handleBrowseCache}
>
<Database className="h-4 w-4 mr-2" />
{t('Browse Cache')}
</Button>
</div>
{Object.keys(cacheInfo).length > 0 && (
<div className="text-xs text-muted-foreground space-y-1 mt-2">
<div className="font-semibold">{t('Cache Statistics:')}</div>
{Object.entries(cacheInfo).map(([storeName, count]) => (
<div key={storeName}>
{storeName}: {count} {t('items')}
</div>
))}
</div>
</SortableContext>
</DndContext>
<NewMailboxRelayInput saveNewMailboxRelay={saveNewCacheRelay} />
)}
</div>
{isSmallScreen ? (
<Drawer open={browsingCache} onOpenChange={setBrowsingCache}>
<DrawerContent className="max-h-[90vh]">
<DrawerHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<DrawerTitle>
{selectedStore ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStore(null)
setStoreItems([])
}}
>
{t('Back')}
</Button>
{selectedStore}
</div>
) : (
t('Browse Cache')
)}
</DrawerTitle>
<DrawerDescription>
{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.')}
</DrawerDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
</DrawerHeader>
<div className={`px-4 pb-4 space-y-4 overflow-y-auto ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? (
// Store list view
Object.keys(cacheInfo).length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No cached data found.')}</div>
) : (
Object.entries(cacheInfo).map(([storeName, count]) => (
<div
key={storeName}
className="border rounded-lg p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleStoreClick(storeName)}
>
<div className="font-semibold text-sm break-words">{storeName}</div>
<div className="text-xs text-muted-foreground mt-1">
{count} {t('items')}
</div>
</div>
))
)
) : (
// Store items view
loadingItems ? (
<div className="flex items-center justify-center py-8">
<Loader className="animate-spin h-6 w-6" />
</div>
) : storeItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div>
) : (
<div className="space-y-2">
<div className="text-xs text-muted-foreground mb-2">
{storeItems.length} {t('items')}
</div>
{storeItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words">
<div className="font-semibold text-xs mb-2 break-all">
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground">
({nestedCount} {t('nested events')})
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
</div>
<pre className={`text-xs bg-muted p-2 rounded overflow-auto max-h-96 select-text ${wordWrapEnabled ? 'overflow-x-hidden whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'}`}>
{JSON.stringify(item.value, null, 2)}
</pre>
</div>
)
})}
</div>
)
)}
</div>
</DrawerContent>
</Drawer>
) : (
<Dialog open={browsingCache} onOpenChange={setBrowsingCache}>
<DialogContent className="max-w-[1000px] max-h-[1000px] overflow-y-auto overflow-x-hidden">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex-1">
<DialogTitle>
{selectedStore ? (
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedStore(null)
setStoreItems([])
}}
>
{t('Back')}
</Button>
{selectedStore}
</div>
) : (
t('Browse Cache')
)}
</DialogTitle>
<DialogDescription>
{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.')}
</DialogDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
</DialogHeader>
<div className={`space-y-4 ${wordWrapEnabled ? 'overflow-x-hidden break-words' : 'overflow-x-auto'}`}>
{!selectedStore ? (
// Store list view
Object.keys(cacheInfo).length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No cached data found.')}</div>
) : (
Object.entries(cacheInfo).map(([storeName, count]) => (
<div
key={storeName}
className="border rounded-lg p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleStoreClick(storeName)}
>
<div className="font-semibold text-sm break-words">{storeName}</div>
<div className="text-xs text-muted-foreground mt-1">
{count} {t('items')}
</div>
</div>
))
)
) : (
// Store items view
<>
{loadingItems ? (
<div className="flex items-center justify-center py-8">
<Loader className="animate-spin h-6 w-6" />
</div>
) : (
<>
<div className="relative py-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={t('Search items...')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8"
/>
</div>
{storeItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items in this store.')}</div>
) : (
<div className="space-y-2">
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-muted-foreground">
{filteredStoreItems.length} {t('of')} {storeItems.length} {t('items')}
{searchQuery.trim() && ` ${t('matching')} "${searchQuery}"`}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleCleanupDuplicates}
className="h-7 text-xs"
>
<RefreshCw className="h-3 w-3 mr-1" />
{t('Cleanup Duplicates')}
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteAllItems}
className="h-7 text-xs"
>
<Trash2 className="h-3 w-3 mr-1" />
{t('Delete All')}
</Button>
</div>
</div>
{filteredStoreItems.length === 0 ? (
<div className="text-sm text-muted-foreground">{t('No items match your search.')}</div>
) : (
filteredStoreItems.map((item, index) => {
const nestedCount = (item as any).nestedCount
return (
<div key={item.key || index} className="border rounded-lg p-3 break-words relative">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteItem(item.key)}
className="absolute top-2 right-2 h-6 w-6 p-0"
title={t('Delete item')}
>
<X className="h-3 w-3" />
</Button>
<div className="font-semibold text-xs mb-2 break-all pr-8">
{item.key}
{typeof nestedCount === 'number' && nestedCount > 0 && (
<span className="ml-2 text-muted-foreground">
({nestedCount} {t('nested events')})
</span>
)}
</div>
<div className="text-xs text-muted-foreground mb-2">
{t('Added at')}: {new Date(item.addedAt).toLocaleString()}
</div>
<pre className={`text-xs bg-muted p-2 rounded overflow-auto max-h-96 select-text ${wordWrapEnabled ? 'overflow-x-hidden whitespace-pre-wrap break-words' : 'overflow-x-auto whitespace-pre'}`}>
{JSON.stringify(item.value, null, 2)}
</pre>
</div>
)
})
)}
</div>
)}
</>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
)}
</div>
)
}

137
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -10,15 +10,17 @@ import logger from '@/lib/logger' @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -395,30 +442,32 @@ export default function PublicationIndex({
return (
<div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="text-sm text-muted-foreground">
Reference {index + 1}: Unable to load event from coordinate {ref.coordinate}
Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'}
</div>
</div>
)
}
// 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 (
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-4">
<PublicationIndex event={ref.event} />
</div>
)
} 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 (
<div key={index} id={sectionId} className="scroll-mt-4">
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} />
</div>
)
} else if (ref.kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) {
// Render 30817 content as MarkdownArticle
return (
<div key={index} id={sectionId} className="scroll-mt-4">
@ -430,7 +479,7 @@ export default function PublicationIndex({ @@ -430,7 +479,7 @@ export default function PublicationIndex({
return (
<div key={index} className="p-4 border rounded-lg">
<div className="text-sm text-muted-foreground">
Reference {index + 1}: Unsupported kind {ref.kind}
Reference {index + 1}: Unsupported kind {eventKind}
</div>
</div>
)

56
src/components/NoteOptions/RawEventDialog.tsx

@ -6,7 +6,11 @@ import { @@ -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({ @@ -17,19 +21,51 @@ export default function RawEventDialog({
isOpen: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const [wordWrapEnabled, setWordWrapEnabled] = useState(true)
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px]">
<DialogHeader>
<DialogTitle>Raw Event</DialogTitle>
<DialogDescription className="sr-only">View the raw event data</DialogDescription>
<DialogContent className="h-[60vh] w-[95vw] max-w-[400px] sm:w-[90vw] sm:max-w-[600px] md:w-[85vw] md:max-w-[800px] lg:w-[80vw] lg:max-w-[1000px] xl:w-[75vw] xl:max-w-[1200px] 2xl:w-[70vw] 2xl:max-w-[1400px] flex flex-col overflow-hidden">
<DialogHeader className="shrink-0 pr-8">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<DialogTitle>Raw Event</DialogTitle>
<DialogDescription className="sr-only">View the raw event data</DialogDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setWordWrapEnabled(!wordWrapEnabled)}
title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
className="shrink-0"
>
<WrapText className={`h-4 w-4 ${wordWrapEnabled ? '' : 'opacity-50'}`} />
</Button>
</div>
</DialogHeader>
<ScrollArea className="h-full">
<pre className="text-sm text-muted-foreground select-text whitespace-pre-wrap break-words">
{JSON.stringify(event, null, 2)}
</pre>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<div className="flex-1 min-h-0 min-w-0 overflow-hidden">
<ScrollArea className="h-full w-full">
<div className="w-full min-w-0 max-w-full pr-4">
<pre
className={`text-sm text-muted-foreground select-text min-w-0 ${wordWrapEnabled ? 'whitespace-pre-wrap overflow-x-hidden' : 'whitespace-pre overflow-x-auto'}`}
style={{
wordBreak: wordWrapEnabled ? 'break-all' : 'normal',
overflowWrap: wordWrapEnabled ? 'anywhere' : 'normal',
maxWidth: '100%',
width: '100%',
boxSizing: 'border-box'
}}
>
{JSON.stringify(event, null, 2)}
</pre>
</div>
<ScrollBar
orientation="horizontal"
className={wordWrapEnabled ? 'opacity-0 pointer-events-none' : ''}
/>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
)

8
src/i18n/locales/en.ts

@ -5,7 +5,7 @@ export default { @@ -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 { @@ -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 { @@ -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'
}
}

4
src/pages/secondary/RelaySettingsPage/index.tsx

@ -25,12 +25,12 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: @@ -25,12 +25,12 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?:
}, [])
return (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Relay settings')}>
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Relays and Storage Settings')}>
<Tabs value={tabValue} onValueChange={setTabValue} className="px-4 py-3 space-y-4">
<TabsList>
<TabsTrigger value="favorite-relays">{t('Favorite Relays')}</TabsTrigger>
<TabsTrigger value="mailbox">{t('Read & Write Relays')}</TabsTrigger>
<TabsTrigger value="cache-relays">{t('Cache Relays')}</TabsTrigger>
<TabsTrigger value="cache-relays">{t('Cache')}</TabsTrigger>
</TabsList>
<TabsContent value="favorite-relays">
<FavoriteRelaysSetting />

2
src/pages/secondary/SettingsPage/index.tsx

@ -44,7 +44,7 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb @@ -44,7 +44,7 @@ const SettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: numb
<SettingItem className="clickable" onClick={() => navigateToSettings(toRelaySettings())}>
<div className="flex items-center gap-4">
<Server />
<div>{t('Relays')}</div>
<div>{t('Relays and Storage Settings')}</div>
</div>
<ChevronRight />
</SettingItem>

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

@ -2,11 +2,13 @@ import { ExtendedKind } from '@/constants' @@ -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<T = any> = {
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 { @@ -47,7 +49,7 @@ class IndexedDbService {
init(): Promise<void> {
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 { @@ -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 { @@ -518,18 +521,119 @@ class IndexedDbService {
}
}
async putPublicationEvent(event: Event): Promise<Event> {
async putPublicationWithNestedEvents(masterEvent: Event, nestedEvents: Event[]): Promise<Event> {
// 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<Event> {
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<Event> | 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<Event> {
// 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 { @@ -542,20 +646,139 @@ class IndexedDbService {
})
}
async getPublicationEvent(eventId: string): Promise<Event | undefined> {
async getPublicationEvent(coordinate: string): Promise<Event | undefined> {
// 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<Array<{ key: string; value: any; addedAt: number; nestedCount?: number }>> {
// 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<string, { key: string; value: any; addedAt: number; nestedCount: number }>()
const nestedEvents: Array<{ key: string; masterKey?: string }> = []
request.onsuccess = () => {
const cursor = (request as any).result
if (cursor) {
const item = cursor.value as TValue<Event>
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<Event>)?.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<Event>
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 { @@ -573,6 +796,286 @@ class IndexedDbService {
}
}
async clearAllCache(): Promise<void> {
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<void>((resolve, reject) => {
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = (event) => reject(event)
})
})
)
}
async getStoreInfo(): Promise<Record<string, number>> {
await this.initPromise
if (!this.db) {
return {}
}
const storeInfo: Record<string, number> = {}
const allStoreNames = Array.from(this.db.objectStoreNames)
await Promise.allSettled(
allStoreNames.map(storeName => {
return new Promise<void>((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<TValue<any>[]> {
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<any>[])
}
request.onerror = (event) => {
transaction.commit()
reject(event)
}
})
}
async deleteStoreItem(storeName: string, key: string): Promise<void> {
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<void> {
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<string, { key: string; event: Event; addedAt: number }>()
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<void> {
// 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) {

6
src/services/navigation.service.ts

@ -215,7 +215,7 @@ export class NavigationService { @@ -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 { @@ -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 { @@ -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'
}

Loading…
Cancel
Save