-
- {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({