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
This commit is contained in:
Claude
2026-01-16 09:34:47 +00:00
parent 325ffa5aa8
commit 35e1f9fe1a
4 changed files with 149 additions and 34 deletions

View File

@@ -239,32 +239,43 @@ function InboxViewer() {
</div>
</div>
{(counts.pending > 0 || counts.decrypting > 0) && (
<div className="px-4 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{counts.pending + counts.decrypting} messages waiting to be
decrypted
</span>
<Button
size="sm"
variant="secondary"
onClick={handleDecryptAll}
disabled={isDecryptingAll || !account?.signer}
>
{isDecryptingAll ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Decrypting...
</>
) : (
<>
<Unlock className="size-4 mr-2" />
Decrypt All
</>
)}
</Button>
</div>
)}
{/* Only show manual decrypt options when auto-decrypt is OFF */}
{!settings?.autoDecrypt &&
(counts.pending > 0 || counts.decrypting > 0) && (
<div className="px-4 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{counts.pending + counts.decrypting} messages waiting to be
decrypted
</span>
<Button
size="sm"
variant="secondary"
onClick={handleDecryptAll}
disabled={isDecryptingAll || !account?.signer}
>
{isDecryptingAll ? (
<>
<Loader2 className="size-4 mr-2 animate-spin" />
Decrypting...
</>
) : (
<>
<Unlock className="size-4 mr-2" />
Decrypt All
</>
)}
</Button>
</div>
)}
{/* Show auto-decrypt status when enabled and there are pending messages */}
{settings?.autoDecrypt &&
(counts.pending > 0 || counts.decrypting > 0) && (
<div className="px-4 py-3 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Auto-decrypting messages...</span>
</div>
)}
</div>
)}

View File

@@ -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() {
}}
>
<Mail className="size-4 mr-2" />
Private Messages
<span className="flex-1">Private Messages</span>
{showPendingBadge && (
<Badge
variant="secondary"
className="ml-2 bg-yellow-500/10 text-yellow-500 border-yellow-500/20"
>
{pendingCount}
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-crosshair">

View File

@@ -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<Set<string>> {
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")

View File

@@ -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<NostrEvent[]>([]);
/** Conversations grouped by participants */
/** Conversations grouped by participants (NIP-17 kind 14 messages only) */
readonly conversations$ = new BehaviorSubject<Conversation[]>([]);
/**
* 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<string[]>([]);
@@ -110,9 +119,14 @@ class GiftWrapService {
error?: string;
}>();
/** Pending count observable for UI display */
readonly pendingCount$ = new BehaviorSubject<number>(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<string>();
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<string, Conversation>();
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({