- {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.')}
+
+
+
+
+ {t('Clear Cache')}
+
+
+
+ {t('Refresh Cache')}
+
+
+
+ {t('Browse Cache')}
+
+
+ {Object.keys(cacheInfo).length > 0 && (
+
+
{t('Cache Statistics:')}
+ {Object.entries(cacheInfo).map(([storeName, count]) => (
+
+ {storeName}: {count} {t('items')}
+
))}
-
-
-
+ )}
+
+
+ {isSmallScreen ? (
+
+
+
+
+
+
+ {selectedStore ? (
+
+ {
+ setSelectedStore(null)
+ setStoreItems([])
+ }}
+ >
+ ← {t('Back')}
+
+ {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.')}
+
+
+
setWordWrapEnabled(!wordWrapEnabled)}
+ title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
+ >
+
+
+
+
+
+ {!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 ? (
+
+ {
+ setSelectedStore(null)
+ setStoreItems([])
+ }}
+ >
+ ← {t('Back')}
+
+ {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.')}
+
+
+
setWordWrapEnabled(!wordWrapEnabled)}
+ title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
+ >
+
+
+
+
+
+ {!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}"`}
+
+
+
+
+ {t('Cleanup Duplicates')}
+
+
+
+ {t('Delete All')}
+
+
+
+ {filteredStoreItems.length === 0 ? (
+
{t('No items match your search.')}
+ ) : (
+ filteredStoreItems.map((item, index) => {
+ const nestedCount = (item as any).nestedCount
+ return (
+
+
handleDeleteItem(item.key)}
+ className="absolute top-2 right-2 h-6 w-6 p-0"
+ title={t('Delete item')}
+ >
+
+
+
+ {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 (
@@ -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
+
+
setWordWrapEnabled(!wordWrapEnabled)}
+ title={wordWrapEnabled ? t('Disable word wrap') : t('Enable word wrap')}
+ className="shrink-0"
+ >
+
+
+
-
-
- {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'
}