From 35e1f9fe1a20f57c06fbe0fa219cd1ba051fde69 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Jan 2026 09:34:47 +0000 Subject: [PATCH] fix: Improve inbox gift wrap handling and UI Fixes several issues with the inbox feature: 1. Load persisted decrypted content on init: - Load stored encrypted content IDs from Dexie on service init - Subscribe to eventStore.update$ to detect cache restoration - Automatically update conversations when restored content is available 2. Mark already-decrypted gift wraps correctly: - Check both in-memory unlock state AND persisted IDs - Prevents showing decrypted messages as "pending" after reload 3. Hide manual decrypt UI when auto-decrypt is enabled: - Only show "Decrypt All" button when auto-decrypt is off - Show "Auto-decrypting..." status when auto-decrypt is on 4. Show pending count in user menu: - Add pendingCount$ observable to gift wrap service - Display badge on "Private Messages" menu item when there are undecrypted messages and auto-decrypt is disabled 5. Expose full rumor for future kind support: - Add decryptedRumors$ observable with all decrypted rumors - Full rumor event (id, pubkey, kind, tags, content) is preserved - Enables future support for any kind sent via gift wrap --- src/components/InboxViewer.tsx | 63 ++++++++++++--------- src/components/nostr/user-menu.tsx | 19 ++++++- src/services/db.ts | 11 +++- src/services/gift-wrap.ts | 90 ++++++++++++++++++++++++++++-- 4 files changed, 149 insertions(+), 34 deletions(-) diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 769cc21..614c7b7 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -239,32 +239,43 @@ function InboxViewer() { - {(counts.pending > 0 || counts.decrypting > 0) && ( -
- - {counts.pending + counts.decrypting} messages waiting to be - decrypted - - -
- )} + {/* Only show manual decrypt options when auto-decrypt is OFF */} + {!settings?.autoDecrypt && + (counts.pending > 0 || counts.decrypting > 0) && ( +
+ + {counts.pending + counts.decrypting} messages waiting to be + decrypted + + +
+ )} + + {/* Show auto-decrypt status when enabled and there are pending messages */} + {settings?.autoDecrypt && + (counts.pending > 0 || counts.decrypting > 0) && ( +
+ + Auto-decrypting messages... +
+ )} )} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index ccea41a..b0ea41c 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,10 +1,12 @@ import { User, HardDrive, Palette, Mail } from "lucide-react"; import accounts from "@/services/accounts"; +import giftWrapService from "@/services/gift-wrap"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; import { getDisplayName } from "@/lib/nostr-utils"; import { useGrimoire } from "@/core/state"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, @@ -63,6 +65,13 @@ export default function UserMenu() { const [showLogin, setShowLogin] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); + // Gift wrap service state for pending message count + const inboxSettings = use$(giftWrapService.settings$); + const pendingCount = use$(giftWrapService.pendingCount$); + // Show badge when enabled, not auto-decrypt, and has pending messages + const showPendingBadge = + inboxSettings?.enabled && !inboxSettings?.autoDecrypt && pendingCount > 0; + function openProfile() { if (!account?.pubkey) return; addWindow( @@ -165,7 +174,15 @@ export default function UserMenu() { }} > - Private Messages + Private Messages + {showPendingBadge && ( + + {pendingCount} + + )} diff --git a/src/services/db.ts b/src/services/db.ts index 5ef886c..249f643 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -63,10 +63,19 @@ export interface CachedBlossomServerList { export interface EncryptedContentEntry { id: string; // Event ID (gift wrap or seal) - plaintext: string; // Decrypted content + plaintext: string; // Decrypted content (JSON string for rumor/seal) savedAt: number; } +/** + * Get all stored encrypted content IDs + * Used to check which gift wraps have already been decrypted + */ +export async function getStoredEncryptedContentIds(): Promise> { + const entries = await db.encryptedContent.toArray(); + return new Set(entries.map((e) => e.id)); +} + export interface LocalSpell { id: string; // UUID for local-only spells, or event ID for published spells alias?: string; // Optional local-only quick name (e.g., "btc") diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index 12b3805..44d1f4f 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -14,7 +14,7 @@ import type { NostrEvent } from "@/types/nostr"; import type { ISigner } from "applesauce-signers"; import eventStore from "./event-store"; import pool from "./relay-pool"; -import { encryptedContentStorage } from "./db"; +import { encryptedContentStorage, getStoredEncryptedContentIds } from "./db"; /** Kind 10050: DM relay list (NIP-17) */ const DM_RELAY_LIST_KIND = 10050; @@ -88,9 +88,18 @@ class GiftWrapService { private giftWraps: NostrEvent[] = []; readonly giftWraps$ = new BehaviorSubject([]); - /** Conversations grouped by participants */ + /** Conversations grouped by participants (NIP-17 kind 14 messages only) */ readonly conversations$ = new BehaviorSubject([]); + /** + * All decrypted rumors (any kind, not just DMs) + * The full rumor event is preserved including: id, pubkey, created_at, kind, tags, content + * This allows future support for any kind sent via gift wrap (messages, files, etc.) + */ + readonly decryptedRumors$ = new BehaviorSubject< + Array<{ giftWrap: NostrEvent; rumor: Rumor }> + >([]); + /** Inbox relays (kind 10050) */ readonly inboxRelays$ = new BehaviorSubject([]); @@ -110,9 +119,14 @@ class GiftWrapService { error?: string; }>(); + /** Pending count observable for UI display */ + readonly pendingCount$ = new BehaviorSubject(0); + private subscriptions: Subscription[] = []; private relaySubscription: Subscription | null = null; private persistenceCleanup: (() => void) | null = null; + /** IDs of gift wraps that have persisted decrypted content */ + private persistedIds = new Set(); constructor() { // Start encrypted content persistence @@ -123,16 +137,44 @@ class GiftWrapService { } /** Initialize the service with user pubkey and signer */ - init(pubkey: string, signer: ISigner | null) { + async init(pubkey: string, signer: ISigner | null) { this.cleanup(); this.userPubkey = pubkey; this.signer = signer; this.decryptStates.clear(); this.decryptStates$.next(new Map()); + this.pendingCount$.next(0); + + // Load persisted encrypted content IDs to know which gift wraps are already decrypted + this.persistedIds = await getStoredEncryptedContentIds(); // Load inbox relays (kind 10050) this.loadInboxRelays(); + // Subscribe to event updates to detect when cache restoration completes + const updateSub = eventStore.update$.subscribe((event) => { + if ( + event.kind === kinds.GiftWrap && + this.giftWraps.some((g) => g.id === event.id) + ) { + // A gift wrap was updated (possibly restored from cache) + // Check if it's now unlocked and update state accordingly + if (isGiftWrapUnlocked(event)) { + const currentState = this.decryptStates.get(event.id); + if (currentState?.status === "pending") { + this.decryptStates.set(event.id, { + status: "success", + decryptedAt: Date.now(), + }); + this.decryptStates$.next(new Map(this.decryptStates)); + this.updatePendingCount(); + this.updateConversations(); + } + } + } + }); + this.subscriptions.push(updateSub); + // If enabled, start syncing if (this.settings$.value.enabled) { this.startSync(); @@ -232,7 +274,10 @@ class GiftWrapService { // Update decrypt states for new gift wraps for (const gw of giftWraps) { if (!this.decryptStates.has(gw.id)) { - const isUnlocked = isGiftWrapUnlocked(gw); + // Check both in-memory unlock state and persisted IDs + // Persisted IDs indicate content was decrypted in a previous session + const isUnlocked = + isGiftWrapUnlocked(gw) || this.persistedIds.has(gw.id); this.decryptStates.set(gw.id, { status: isUnlocked ? "success" : "pending", decryptedAt: isUnlocked ? Date.now() : undefined, @@ -240,6 +285,7 @@ class GiftWrapService { } } this.decryptStates$.next(new Map(this.decryptStates)); + this.updatePendingCount(); // Update conversations this.updateConversations(); @@ -267,15 +313,37 @@ class GiftWrapService { } } - /** Update conversations from decrypted gift wraps */ + /** Update pending count for UI display */ + private updatePendingCount() { + let count = 0; + for (const state of this.decryptStates.values()) { + if (state.status === "pending" || state.status === "decrypting") { + count++; + } + } + this.pendingCount$.next(count); + } + + /** + * Update conversations and decrypted rumors from gift wraps. + * Applesauce persistence stores the full JSON representation of rumors, + * preserving all fields (id, pubkey, created_at, kind, tags, content). + */ private updateConversations() { const conversationMap = new Map(); + const allRumors: Array<{ giftWrap: NostrEvent; rumor: Rumor }> = []; for (const gw of this.giftWraps) { if (!isGiftWrapUnlocked(gw)) continue; const rumor = getGiftWrapRumor(gw); - if (!rumor || rumor.kind !== PRIVATE_DM_KIND) continue; + if (!rumor) continue; + + // Collect all decrypted rumors (any kind) for future use + allRumors.push({ giftWrap: gw, rumor }); + + // Only group NIP-17 DMs (kind 14) into conversations + if (rumor.kind !== PRIVATE_DM_KIND) continue; const convId = getConversationIdentifierFromMessage(rumor); const existing = conversationMap.get(convId); @@ -293,6 +361,10 @@ class GiftWrapService { } } + // Sort rumors by created_at descending + allRumors.sort((a, b) => b.rumor.created_at - a.rumor.created_at); + this.decryptedRumors$.next(allRumors); + const conversations = Array.from(conversationMap.values()).sort( (a, b) => (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0), @@ -320,16 +392,21 @@ class GiftWrapService { // Update state to decrypting this.decryptStates.set(giftWrapId, { status: "decrypting" }); this.decryptStates$.next(new Map(this.decryptStates)); + this.updatePendingCount(); try { const rumor = await unlockGiftWrap(gw, this.signer); + // Add to persisted IDs so it's recognized on next reload + this.persistedIds.add(giftWrapId); + // Update state to success this.decryptStates.set(giftWrapId, { status: "success", decryptedAt: Date.now(), }); this.decryptStates$.next(new Map(this.decryptStates)); + this.updatePendingCount(); // Emit decrypt event this.decryptEvent$.next({ @@ -348,6 +425,7 @@ class GiftWrapService { // Update state to error this.decryptStates.set(giftWrapId, { status: "error", error }); this.decryptStates$.next(new Map(this.decryptStates)); + this.updatePendingCount(); // Emit decrypt event this.decryptEvent$.next({