diff --git a/package.json b/package.json index f687e4e..fde297f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "11.1", + "version": "11.2", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx index 9c11e28..840fff1 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, useMemo, useRef } from 'react' +import { useEffect, useState, useMemo, useRef, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { DndContext, @@ -28,15 +28,17 @@ 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, Trash2, RefreshCw, Database, WrapText, Search, X } from 'lucide-react' +import { CloudUpload, Loader, Trash2, RefreshCw, Database, WrapText, Search, X, TriangleAlert } 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 { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { toast } from 'sonner' +import { Event } from 'nostr-tools' export default function CacheRelaysSetting() { const { t } = useTranslation() @@ -374,7 +376,20 @@ export default function CacheRelaysSetting() { setSearchQuery('') // Update cache info loadCacheInfo() - toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept })) + // Reload items to get accurate count after cleanup + const itemsAfterCleanup = await indexedDb.getStoreItems(selectedStore) + const actualCount = itemsAfterCleanup.length + + // Show message with actual count + if (actualCount !== result.kept) { + toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}} (total items after cleanup: {{total}})', { + deleted: result.deleted, + kept: result.kept, + total: actualCount + })) + } else { + toast.success(t('Cleaned up {{deleted}} duplicate entries, kept {{kept}}', { deleted: result.deleted, kept: result.kept })) + } } catch (error) { console.error('Failed to cleanup duplicates:', error) if (error instanceof Error && error.message === 'Not a replaceable event store') { @@ -387,6 +402,52 @@ export default function CacheRelaysSetting() { } } + // Check if an event is invalid + const isInvalidEvent = useCallback((item: { key: string; value: any; addedAt: number }): boolean => { + if (!item || !item.value) return true + + const event = item.value as Event + // Check for required Nostr event fields + if (!event.pubkey || !event.kind || typeof event.created_at !== 'number') { + return true + } + + // Check for tags array (required for Nostr events) + if (!event.tags || !Array.isArray(event.tags)) { + return true + } + + // Check for id and sig (these should be present in valid events) + if (!event.id || !event.sig) { + return true + } + + return false + }, []) + + // Get explanation for why an event is invalid + const getInvalidEventExplanation = useCallback((item: { key: string; value: any; addedAt: number }): string => { + if (!item || !item.value) { + return t('Event has no value data') + } + + const event = item.value as Event + const missing: string[] = [] + + if (!event.pubkey) missing.push(t('pubkey')) + if (!event.kind) missing.push(t('kind')) + if (typeof event.created_at !== 'number') missing.push(t('created_at')) + if (!event.tags || !Array.isArray(event.tags)) missing.push(t('tags')) + if (!event.id) missing.push(t('id')) + if (!event.sig) missing.push(t('sig')) + + if (missing.length > 0) { + return t('Event is missing required fields: {{fields}}', { fields: missing.join(', ') }) + } + + return t('Event appears to be invalid or corrupted') + }, [t]) + const save = async () => { if (!pubkey) return @@ -477,10 +538,10 @@ export default function CacheRelaysSetting() {
{t('Clear cached data stored in your browser, including IndexedDB events, localStorage settings, and service worker caches.')}
-
+
+
-
-                            {JSON.stringify(item.value, null, 2)}
-                          
- ) - })} - + {filteredStoreItems.length === 0 ? ( +
{t('No items match your search.')}
+ ) : ( + filteredStoreItems.map((item, index) => { + const nestedCount = (item as any).nestedCount + const invalid = isInvalidEvent(item) + const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' + return ( +
+
+ {invalid && ( + + + + + +
+
+ + {t('Invalid Event')} +
+
+ {invalidExplanation} +
+
+
+
+ )} + +
+
+ {item.key} + {typeof nestedCount === 'number' && nestedCount > 0 && ( + + ({nestedCount} {t('nested events')}) + + )} +
+
+ {t('Added at')}: {new Date(item.addedAt).toLocaleString()} +
+
+                                  {JSON.stringify(item.value, null, 2)}
+                                
+
+ ) + }) + )} + + )} + ) )} @@ -729,18 +869,47 @@ export default function CacheRelaysSetting() { ) : ( filteredStoreItems.map((item, index) => { const nestedCount = (item as any).nestedCount + const invalid = isInvalidEvent(item) + const invalidExplanation = invalid ? getInvalidEventExplanation(item) : '' return (
- -
+
+ {invalid && ( + + + + + +
+
+ + {t('Invalid Event')} +
+
+ {invalidExplanation} +
+
+
+
+ )} + +
+
{item.key} {typeof nestedCount === 'number' && nestedCount > 0 && ( diff --git a/src/components/MailboxSetting/NewMailboxRelayInput.tsx b/src/components/MailboxSetting/NewMailboxRelayInput.tsx index 64ee56e..2f8ca4e 100644 --- a/src/components/MailboxSetting/NewMailboxRelayInput.tsx +++ b/src/components/MailboxSetting/NewMailboxRelayInput.tsx @@ -35,7 +35,7 @@ export default function NewMailboxRelayInput({ return (
-
+
- +
{newRelayUrlError &&
{newRelayUrlError}
}
diff --git a/src/components/MailboxSetting/SaveButton.tsx b/src/components/MailboxSetting/SaveButton.tsx index 4667b18..b1536d3 100644 --- a/src/components/MailboxSetting/SaveButton.tsx +++ b/src/components/MailboxSetting/SaveButton.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button' import { createRelayListDraftEvent } from '@/lib/draft-event' -import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' +import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay } from '@/types' import { CloudUpload, Loader } from 'lucide-react' @@ -27,16 +27,20 @@ export default function SaveButton({ try { const event = createRelayListDraftEvent(mailboxRelays) const result = await publish(event) + + // Read relayStatuses immediately before it might be deleted + const relayStatuses = (result as any).relayStatuses + await updateRelayListEvent(result) setHasChange(false) // Show publishing feedback - if ((result as any).relayStatuses) { + if (relayStatuses && relayStatuses.length > 0) { showPublishingFeedback({ success: true, - relayStatuses: (result as any).relayStatuses, - successCount: (result as any).relayStatuses.filter((s: any) => s.success).length, - totalCount: (result as any).relayStatuses.length + relayStatuses: relayStatuses, + successCount: relayStatuses.filter((s: any) => s.success).length, + totalCount: relayStatuses.length }, { message: t('Mailbox relays saved'), duration: 6000 @@ -44,6 +48,23 @@ export default function SaveButton({ } else { showSimplePublishSuccess(t('Mailbox relays saved')) } + } catch (error) { + console.error('Failed to save relay list:', error) + // Show error feedback with relay statuses if available + if (error instanceof Error && (error as any).relayStatuses) { + const errorRelayStatuses = (error as any).relayStatuses + showPublishingFeedback({ + success: false, + relayStatuses: errorRelayStatuses, + successCount: errorRelayStatuses.filter((s: any) => s.success).length, + totalCount: errorRelayStatuses.length + }, { + message: error.message || t('Failed to save relay list'), + duration: 6000 + }) + } else { + showPublishingError(error instanceof Error ? error : new Error(t('Failed to save relay list'))) + } } finally { setPushing(false) } diff --git a/src/components/MailboxSetting/index.tsx b/src/components/MailboxSetting/index.tsx index 74783ab..c5051ec 100644 --- a/src/components/MailboxSetting/index.tsx +++ b/src/components/MailboxSetting/index.tsx @@ -1,5 +1,5 @@ import { Button } from '@/components/ui/button' -import { normalizeUrl } from '@/lib/url' +import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay, TMailboxRelayScope } from '@/types' import { useEffect, useState } from 'react' @@ -67,7 +67,9 @@ export default function MailboxSetting() { useEffect(() => { if (!relayList) return - setRelays(relayList.originalRelays) + // Filter out cache relays (local network URLs) - they belong in kind 10432, not kind 10002 + const mailboxRelays = relayList.originalRelays.filter(relay => !isLocalNetworkUrl(relay.url)) + setRelays(mailboxRelays) }, [relayList]) if (!pubkey) { diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 6d1a833..f306963 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -848,13 +848,14 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { // Attach relayStatuses only temporarily for UI feedback, then remove it // This prevents it from being included in the event when serialized + // Use a longer delay to ensure UI components can read it before deletion if (relayStatuses) { (event as any).relayStatuses = relayStatuses - // Remove it immediately after return so it's not persisted - // The components that need it will read it synchronously + // Remove it after a delay to allow UI components to read it + // Components should read it immediately after publish() returns setTimeout(() => { delete (event as any).relayStatuses - }, 0) + }, 100) } return event @@ -946,17 +947,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const updateRelayListEvent = async (relayListEvent: Event) => { await indexedDb.putReplaceableEvent(relayListEvent) + // Clear the relay list cache to force a fresh fetch + if (account?.pubkey) { + client.clearRelayListCache(account.pubkey) + } // Fetch updated relay list (which merges both 10002 and 10432) const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') setRelayList(mergedRelayList) } const updateCacheRelayListEvent = async (cacheRelayListEvent: Event) => { - const newCacheRelayList = await indexedDb.putReplaceableEvent(cacheRelayListEvent) - setCacheRelayListEvent(newCacheRelayList) - // Fetch updated relay list (which merges both 10002 and 10432) - const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') - setRelayList(mergedRelayList) + await indexedDb.putReplaceableEvent(cacheRelayListEvent) + // Clear the relay list cache to ensure fresh fetches use the updated event + if (account?.pubkey) { + client.clearRelayListCache(account.pubkey) + } + // Set local state immediately with the event we just saved + // This will trigger the component's useEffect to update the UI immediately + setCacheRelayListEvent(cacheRelayListEvent) + // Don't update relayList here - it's a computed merge of kind 10002 + 10432 + // The merged list will be computed on-the-fly when needed via fetchRelayList() + // This ensures kind 10002 and 10432 remain separate and are only merged when publishing/using } const updateProfileEvent = async (profileEvent: Event) => { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index c1503b2..de09419 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1285,6 +1285,10 @@ class ClientService extends EventTarget { return relayEvent ?? null } + clearRelayListCache(pubkey: string) { + this.relayListRequestCache.delete(pubkey) + } + async fetchRelayList(pubkey: string): Promise { // Deduplicate concurrent requests for the same pubkey's relay list const existingRequest = this.relayListRequestCache.get(pubkey) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 1823bec..96ea02b 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -965,35 +965,59 @@ class IndexedDbService { const allItems = await this.getStoreItems(storeName) const eventMap = new Map() const keysToDelete: string[] = [] + let invalidItemsCount = 0 for (const item of allItems) { - if (!item || !item.value) continue + if (!item || !item.value) { + invalidItemsCount++ + continue + } - const replaceableKey = this.getReplaceableEventKeyFromEvent(item.value) - const existing = eventMap.get(replaceableKey) + // Skip if event doesn't have required fields + if (!item.value.pubkey || !item.value.kind || !item.value.created_at) { + invalidItemsCount++ + continue + } - 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) + try { + 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) } - 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) + } catch (error) { + // If we can't generate a replaceable key, skip this item + console.warn('Failed to get replaceable key for item:', item.key, error) + invalidItemsCount++ + continue } } // Second pass: delete duplicates + const totalProcessed = eventMap.size + keysToDelete.length + const actualKept = eventMap.size + if (keysToDelete.length === 0) { - return Promise.resolve({ deleted: 0, kept: eventMap.size }) + // No duplicates found, but verify counts match + if (totalProcessed + invalidItemsCount !== allItems.length) { + console.warn(`Count mismatch: total items=${allItems.length}, processed=${totalProcessed}, invalid=${invalidItemsCount}`) + } + return Promise.resolve({ deleted: 0, kept: actualKept }) } return new Promise((resolve) => { @@ -1010,14 +1034,20 @@ class IndexedDbService { completedCount++ if (completedCount === keysToDelete.length) { transaction.commit() - resolve({ deleted: deletedCount, kept: eventMap.size }) + const actualKept = eventMap.size + const totalProcessed = actualKept + deletedCount + if (totalProcessed + invalidItemsCount !== allItems.length) { + console.warn(`Count mismatch after deletion: total items=${allItems.length}, kept=${actualKept}, deleted=${deletedCount}, invalid=${invalidItemsCount}`) + } + resolve({ deleted: deletedCount, kept: actualKept }) } } deleteRequest.onerror = () => { completedCount++ if (completedCount === keysToDelete.length) { transaction.commit() - resolve({ deleted: deletedCount, kept: eventMap.size }) + const actualKept = eventMap.size + resolve({ deleted: deletedCount, kept: actualKept }) } } })