From eef82e7871ccdb2c138686b2d8bdad5b10906751 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 10:42:12 +0000 Subject: [PATCH] feat: compact inbox UI with batch decrypt and improved UX Auto-Decrypt OFF by Default: - Changed autoDecrypt default from true to false (logic.ts, InboxViewer.tsx) - Users must explicitly enable auto-decrypt in settings - Reduces unexpected signer prompts and background activity Batch Decrypt Feature: - Added batchDecryptPending() method to GiftWrapManager - Processes all pending gift wraps in one operation - Shows in settings dropdown when auto-decrypt is off and pending > 0 - "Decrypt N Pending" button with loading spinner Compact Header (Like req viewer): - Single-line header with font-mono text-xs - Left: Status indicator (SYNC/OFF with shield icon, muted colors) - Right: Compact stats (just numbers with tooltips), relay dropdown, settings dropdown - No verbose labels, no large text - Muted icon colors (/70 opacity for stats) Relay Dropdown Improvements: - No chevron (cleaner look) - Shows relay count with Radio icon - Opens to full RelayLink components with icons and hover cards - Future: Can add auth/connected status here Settings Dropdown: - No chevron (cleaner look) - Compact checkboxes for sync and auto-decrypt - "Load Older" action item - Conditional "Decrypt N Pending" when auto-decrypt is off Conversation List Improvements: - Removed avatar placeholder (no longer needed) - Ultra-compact: font-mono text-xs, py-1.5 padding - Layout: Name (fixed width) | Message preview (flex) | Timestamp - Muted colors for clean, minimal look - Border-bottom separators - Clicking opens "chat npub..." with NIP-17 adapter NIP-17 Adapter: - Already implemented and fully functional - Read-only support for decrypted messages - Observable pattern for real-time updates - Supports threading via e-tag replies - Works with ChatViewer component User Experience: - Clean, compact UI focused on conversations - All controls accessible but not intrusive - Tooltips explain everything - Batch operations available when needed - Gift wrap stats accurate and real-time --- src/components/InboxViewer.tsx | 318 ++++++++++++++++++++------------- src/core/logic.ts | 2 +- src/services/gift-wrap.ts | 69 +++++++ 3 files changed, 264 insertions(+), 125 deletions(-) diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx index 0be0fef..5e7a46c 100644 --- a/src/components/InboxViewer.tsx +++ b/src/components/InboxViewer.tsx @@ -20,15 +20,30 @@ import { import { useProfile } from "@/hooks/useProfile"; import eventStore from "@/services/event-store"; import { getDisplayName } from "@/lib/nostr-utils"; -import { Settings, MessageSquare, ChevronDown, Radio } from "lucide-react"; +import { + Settings, + MessageSquare, + Radio, + ShieldCheck, + ShieldAlert, + Loader2, +} from "lucide-react"; import { toast } from "sonner"; import giftWrapManager from "@/services/gift-wrap"; import { RelayLink } from "@/components/nostr/RelayLink"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuLabel, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; type InboxViewerProps = Record; @@ -39,13 +54,11 @@ export function InboxViewer(_props: InboxViewerProps) { const { pubkey } = useAccount(); const stats = useGiftWrapStats(); const conversations = useGiftWrapConversations(); - const [showSettings, setShowSettings] = useState(false); const [conversationsPage, setConversationsPage] = useState(1); const [isLoadingOlder, setIsLoadingOlder] = useState(false); - const [relaysOpen, setRelaysOpen] = useState(false); const syncEnabled = state.giftWrapSettings?.syncEnabled ?? false; - const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? true; + const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? false; // Get DM relays (kind 10050) const dmRelayEvent = use$(() => { @@ -147,6 +160,25 @@ export function InboxViewer(_props: InboxViewerProps) { } }; + const [isBatchDecrypting, setIsBatchDecrypting] = useState(false); + + const handleBatchDecrypt = async () => { + setIsBatchDecrypting(true); + try { + const count = await giftWrapManager.batchDecryptPending(); + if (count > 0) { + toast.success(`Decrypted ${count} gift wraps`); + } else { + toast.info("No pending gift wraps to decrypt"); + } + } catch (error) { + console.error("[Inbox] Error batch decrypting:", error); + toast.error("Failed to batch decrypt"); + } finally { + setIsBatchDecrypting(false); + } + }; + if (!pubkey) { return (
@@ -161,125 +193,166 @@ export function InboxViewer(_props: InboxViewerProps) { return (
- {/* Header - Single Row with Heading, Stats, Relays, Settings */} -
-
- {/* Left: Heading */} -

Inbox

+ {/* Compact Header - Like req viewer */} +
+ {/* Left: Status */} +
+ + +
+ {syncEnabled ? ( + + ) : ( + + )} + + {syncEnabled ? "SYNC" : "OFF"} + +
+
+ +

+ {syncEnabled + ? "Gift wrap sync enabled" + : "Gift wrap sync disabled"} +

+
+
+
- {/* Center: Gift Wrap Stats */} -
-
-
{stats.totalGiftWraps}
-
Total
-
-
-
+ {/* Right: Stats + Controls */} +
+ {/* Stats - Compact numbers only */} + + + + {stats.totalGiftWraps} + + + +

Total gift wraps

+
+
+ + + + {stats.successfulDecryptions} -
-
Success
-
-
-
- {stats.failedDecryptions} -
-
Failed
-
-
-
+ + + +

Successfully decrypted

+
+ + + + + {stats.failedDecryptions} + + +

Failed decryptions

+
+
+ + + + {stats.pendingDecryptions} + + + +

Pending decryptions

+
+
+ + {/* Relay Dropdown (no chevron) */} + + + + + + DM Relays + +
+ {dmRelays.length > 0 ? ( + dmRelays.map((relay) => ( + + )) + ) : ( +

+ No DM relays configured. Using general relays from kind + 10002 or kind 3. +

+ )}
-
Pending
-
-
+ + - {/* Right: Relay dropdown + Settings */} -
- {/* Relay Icon + Count Dropdown */} - - - + + + Settings + +
+ + +
+ + + {isLoadingOlder ? "Loading..." : "Load Older"} + + {!autoDecrypt && stats.pendingDecryptions > 0 && ( + + {isBatchDecrypting ? ( + + + Decrypting... + + ) : ( + `Decrypt ${stats.pendingDecryptions} Pending` + )} + )} -
- - {/* Settings Icon */} - -
+ +
- {/* Settings Panel (Collapsible) */} - {showSettings && ( -
-
- - - -
-
- )} - {/* Conversations List */}
{conversationsList.length === 0 ? ( @@ -402,24 +475,21 @@ function ConversationRow({ return (
- {/* Avatar placeholder */} -
- {/* Name */} - + {displayName} {/* Message preview */} - + {preview} {truncated && "..."} {/* Timestamp */} - {timeStr} + {timeStr}
); } diff --git a/src/core/logic.ts b/src/core/logic.ts index d302f01..cdc5a4e 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -616,7 +616,7 @@ export const updateGiftWrapSettings = ( syncEnabled: settings.syncEnabled ?? state.giftWrapSettings?.syncEnabled ?? false, autoDecrypt: - settings.autoDecrypt ?? state.giftWrapSettings?.autoDecrypt ?? true, + settings.autoDecrypt ?? state.giftWrapSettings?.autoDecrypt ?? false, }, }; }; diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts index ef048c7..12047f8 100644 --- a/src/services/gift-wrap.ts +++ b/src/services/gift-wrap.ts @@ -351,6 +351,75 @@ class GiftWrapManager { return count; } + /** + * Batch decrypt all pending gift wraps + * Returns the number of successfully decrypted gift wraps + */ + async batchDecryptPending(): Promise { + const account = accountManager.active$.value; + if (!account) { + console.log("[GiftWrap] No active account"); + return 0; + } + + const { pubkey } = account; + + // Get all pending decryptions + const pending = await db.giftWrapDecryptions + .where("decryptionState") + .equals("pending") + .and((d) => d.recipientPubkey === pubkey) + .toArray(); + + if (pending.length === 0) { + console.log("[GiftWrap] No pending decryptions"); + return 0; + } + + console.log( + `[GiftWrap] Batch decrypting ${pending.length} pending gift wraps`, + ); + + let successCount = 0; + + // Process each pending gift wrap + for (const decryption of pending) { + try { + // Get the gift wrap event from event store + const giftWrap = eventStore.getEvent(decryption.giftWrapId); + if (!giftWrap) { + console.warn( + `[GiftWrap] Gift wrap ${decryption.giftWrapId} not found in event store`, + ); + continue; + } + + // Attempt to decrypt + await this.processGiftWrap(giftWrap, pubkey); + + // Check if it succeeded + const updated = await db.giftWrapDecryptions.get(decryption.giftWrapId); + if (updated?.decryptionState === "success") { + successCount++; + } + } catch (error) { + console.error( + `[GiftWrap] Error batch decrypting ${decryption.giftWrapId.slice(0, 8)}:`, + error, + ); + } + } + + console.log( + `[GiftWrap] Batch decrypt completed: ${successCount}/${pending.length} succeeded`, + ); + + // Update stats + await this.updateStats(); + + return successCount; + } + /** * Clean up old gift wraps to prevent storage bloat * Removes decryption records older than MAX_STORAGE_DAYS