mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user