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
This commit is contained in:
Claude
2026-01-20 10:42:12 +00:00
parent 33f82083b9
commit eef82e7871
3 changed files with 264 additions and 125 deletions

View File

@@ -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<string, never>;
@@ -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 (
<div className="flex h-full items-center justify-center">
@@ -161,125 +193,166 @@ export function InboxViewer(_props: InboxViewerProps) {
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header - Single Row with Heading, Stats, Relays, Settings */}
<div className="border-b px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Left: Heading */}
<h2 className="text-lg font-semibold">Inbox</h2>
{/* Compact Header - Like req viewer */}
<div className="border-b px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Status */}
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-help">
{syncEnabled ? (
<ShieldCheck className="size-3 text-green-600/70" />
) : (
<ShieldAlert className="size-3 text-muted-foreground/50" />
)}
<span className="text-muted-foreground">
{syncEnabled ? "SYNC" : "OFF"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
{syncEnabled
? "Gift wrap sync enabled"
: "Gift wrap sync disabled"}
</p>
</TooltipContent>
</Tooltip>
</div>
{/* Center: Gift Wrap Stats */}
<div className="flex flex-1 items-center justify-center gap-6">
<div className="text-center">
<div className="text-lg font-bold">{stats.totalGiftWraps}</div>
<div className="text-xs text-muted-foreground">Total</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-green-600">
{/* Right: Stats + Controls */}
<div className="flex items-center gap-3">
{/* Stats - Compact numbers only */}
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground/80">
{stats.totalGiftWraps}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Total gift wraps</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-green-600/70">
{stats.successfulDecryptions}
</div>
<div className="text-xs text-muted-foreground">Success</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-red-600">
{stats.failedDecryptions}
</div>
<div className="text-xs text-muted-foreground">Failed</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-yellow-600">
</span>
</TooltipTrigger>
<TooltipContent>
<p>Successfully decrypted</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-red-600/70">{stats.failedDecryptions}</span>
</TooltipTrigger>
<TooltipContent>
<p>Failed decryptions</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-yellow-600/70">
{stats.pendingDecryptions}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Pending decryptions</p>
</TooltipContent>
</Tooltip>
{/* Relay Dropdown (no chevron) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-1 text-muted-foreground/80 hover:text-foreground transition-colors">
<Radio className="size-3" />
<span>{dmRelays.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>DM Relays</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-64 overflow-y-auto space-y-1 p-2">
{dmRelays.length > 0 ? (
dmRelays.map((relay) => (
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
/>
))
) : (
<p className="text-xs text-muted-foreground p-2">
No DM relays configured. Using general relays from kind
10002 or kind 3.
</p>
)}
</div>
<div className="text-xs text-muted-foreground">Pending</div>
</div>
</div>
</DropdownMenuContent>
</DropdownMenu>
{/* Right: Relay dropdown + Settings */}
<div className="flex items-center gap-2">
{/* Relay Icon + Count Dropdown */}
<Collapsible open={relaysOpen} onOpenChange={setRelaysOpen}>
<CollapsibleTrigger asChild>
<button
className="flex items-center gap-1.5 rounded px-2 py-1 hover:bg-muted"
title="DM Relays"
>
<Radio className="h-4 w-4" />
<span className="text-sm font-medium">{dmRelays.length}</span>
<ChevronDown
className={`h-3 w-3 transition-transform ${relaysOpen ? "rotate-180" : ""}`}
{/* Settings Dropdown (no chevron) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="text-muted-foreground/80 hover:text-foreground transition-colors">
<Settings className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Settings</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="p-2 space-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={syncEnabled}
onChange={handleToggleSync}
className="h-3.5 w-3.5"
/>
</button>
</CollapsibleTrigger>
{relaysOpen && (
<div className="absolute right-16 top-14 z-10 w-72 rounded-md border bg-popover p-3 shadow-lg">
<CollapsibleContent className="space-y-1.5">
<div className="mb-2 text-xs font-semibold text-muted-foreground">
DM RELAYS
</div>
{dmRelays.length > 0 ? (
dmRelays.map((relay) => (
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
/>
))
) : (
<p className="text-xs text-muted-foreground">
No DM relays configured. Using general relays from kind
10002 or kind 3.
</p>
)}
</CollapsibleContent>
</div>
<span>Enable sync</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={autoDecrypt}
onChange={handleToggleAutoDecrypt}
className="h-3.5 w-3.5"
disabled={!syncEnabled}
/>
<span>Auto-decrypt</span>
</label>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLoadOlderGiftWraps}
disabled={isLoadingOlder}
>
{isLoadingOlder ? "Loading..." : "Load Older"}
</DropdownMenuItem>
{!autoDecrypt && stats.pendingDecryptions > 0 && (
<DropdownMenuItem
onClick={handleBatchDecrypt}
disabled={isBatchDecrypting}
>
{isBatchDecrypting ? (
<span className="flex items-center gap-2">
<Loader2 className="size-3 animate-spin" />
Decrypting...
</span>
) : (
`Decrypt ${stats.pendingDecryptions} Pending`
)}
</DropdownMenuItem>
)}
</Collapsible>
{/* Settings Icon */}
<button
onClick={() => setShowSettings(!showSettings)}
className="rounded p-2 hover:bg-muted"
title="Settings"
>
<Settings className="h-4 w-4" />
</button>
</div>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Settings Panel (Collapsible) */}
{showSettings && (
<div className="border-b bg-muted/50 px-4 py-3">
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={syncEnabled}
onChange={handleToggleSync}
className="h-4 w-4"
/>
<span className="text-sm">Enable gift wrap sync</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={autoDecrypt}
onChange={handleToggleAutoDecrypt}
className="h-4 w-4"
disabled={!syncEnabled}
/>
<span className="text-sm">Auto-decrypt received gift wraps</span>
</label>
<button
onClick={handleLoadOlderGiftWraps}
disabled={isLoadingOlder}
className="mt-2 w-full rounded px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-50"
>
{isLoadingOlder ? "Loading..." : "Load Older Gift Wraps"}
</button>
</div>
</div>
)}
{/* Conversations List */}
<div className="flex-1 overflow-auto">
{conversationsList.length === 0 ? (
@@ -402,24 +475,21 @@ function ConversationRow({
return (
<div
onClick={onClick}
className="flex cursor-pointer items-center gap-3 border-b px-4 py-2 hover:bg-muted/50 last:border-b-0"
className="flex cursor-pointer items-center gap-2 border-b px-3 py-1.5 hover:bg-muted/30 last:border-b-0 font-mono text-xs"
>
{/* Avatar placeholder */}
<div className="h-8 w-8 shrink-0 rounded-full bg-primary/20" />
{/* Name */}
<span className="w-32 shrink-0 truncate font-medium text-sm">
<span className="w-28 shrink-0 truncate font-medium text-muted-foreground">
{displayName}
</span>
{/* Message preview */}
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
<span className="min-w-0 flex-1 truncate text-muted-foreground/70">
{preview}
{truncated && "..."}
</span>
{/* Timestamp */}
<span className="shrink-0 text-xs text-muted-foreground">{timeStr}</span>
<span className="shrink-0 text-muted-foreground/50">{timeStr}</span>
</div>
);
}

View File

@@ -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,
},
};
};

View File

@@ -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<number> {
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