diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx
new file mode 100644
index 0000000..769cc21
--- /dev/null
+++ b/src/components/InboxViewer.tsx
@@ -0,0 +1,529 @@
+import { useEffect, useState } from "react";
+import { use$ } from "applesauce-react/hooks";
+import {
+ Mail,
+ MailOpen,
+ Lock,
+ Unlock,
+ Loader2,
+ AlertCircle,
+ CheckCircle2,
+ Clock,
+ Radio,
+ RefreshCw,
+ Users,
+ MessageSquare,
+} from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { RelayLink } from "@/components/nostr/RelayLink";
+import { UserName } from "@/components/nostr/UserName";
+
+import giftWrapService from "@/services/gift-wrap";
+import accounts from "@/services/accounts";
+import { cn } from "@/lib/utils";
+import { formatTimestamp } from "@/hooks/useLocale";
+import type { DecryptStatus } from "@/services/gift-wrap";
+
+/**
+ * InboxViewer - Manage private messages (NIP-17/59 gift wraps)
+ */
+function InboxViewer() {
+ const account = use$(accounts.active$);
+ const settings = use$(giftWrapService.settings$);
+ const syncStatus = use$(giftWrapService.syncStatus$);
+ const giftWraps = use$(giftWrapService.giftWraps$);
+ const decryptStates = use$(giftWrapService.decryptStates$);
+ const conversations = use$(giftWrapService.conversations$);
+ const inboxRelays = use$(giftWrapService.inboxRelays$);
+
+ const [isDecryptingAll, setIsDecryptingAll] = useState(false);
+
+ // Initialize service when account changes
+ useEffect(() => {
+ if (account) {
+ giftWrapService.init(account.pubkey, account.signer ?? null);
+ }
+ }, [account]);
+
+ // Update signer when it changes
+ useEffect(() => {
+ if (account?.signer) {
+ giftWrapService.setSigner(account.signer);
+ }
+ }, [account?.signer]);
+
+ // Calculate counts
+ const counts = {
+ pending: 0,
+ decrypting: 0,
+ success: 0,
+ error: 0,
+ total: giftWraps?.length ?? 0,
+ };
+
+ if (decryptStates) {
+ for (const state of decryptStates.values()) {
+ switch (state.status) {
+ case "pending":
+ counts.pending++;
+ break;
+ case "decrypting":
+ counts.decrypting++;
+ break;
+ case "success":
+ counts.success++;
+ break;
+ case "error":
+ counts.error++;
+ break;
+ }
+ }
+ }
+
+ const handleToggleEnabled = (checked: boolean) => {
+ giftWrapService.updateSettings({ enabled: checked });
+ };
+
+ const handleToggleAutoDecrypt = (checked: boolean) => {
+ giftWrapService.updateSettings({ autoDecrypt: checked });
+ };
+
+ const handleDecryptAll = async () => {
+ if (!account?.signer) {
+ toast.error(
+ "No signer available. Please log in with a signer that supports encryption.",
+ );
+ return;
+ }
+
+ setIsDecryptingAll(true);
+ try {
+ const result = await giftWrapService.decryptAll();
+ if (result.success > 0) {
+ toast.success(`Decrypted ${result.success} messages`);
+ }
+ if (result.error > 0) {
+ toast.error(`Failed to decrypt ${result.error} messages`);
+ }
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Failed to decrypt messages",
+ );
+ } finally {
+ setIsDecryptingAll(false);
+ }
+ };
+
+ const handleRefresh = () => {
+ giftWrapService.startSync();
+ };
+
+ if (!account) {
+ return (
+
+
+
+
Log in to access private messages
+
+
+ );
+ }
+
+ return (
+
+ {/* Settings Section */}
+
+
+
+
+ Private Messages
+
+
+ {syncStatus === "syncing" && (
+
+ )}
+
+
+
+
+
+
+ {/* Enable/Disable Toggle */}
+
+
+
+ Enable gift wrap sync
+
+
+
+ {/* Auto-decrypt Toggle */}
+
+
+
+ Auto-decrypt messages
+
+
+
+
+ {/* Inbox Relays Section */}
+ {inboxRelays && inboxRelays.length > 0 && (
+
+
+
+ DM Inbox Relays (kind 10050)
+
+
+ {inboxRelays.map((relay) => (
+
+ ))}
+
+
+ )}
+
+ {inboxRelays && inboxRelays.length === 0 && settings?.enabled && (
+
+
+
+
No DM inbox relays configured (kind 10050)
+
+
+ )}
+
+ {/* Decrypt Status Section */}
+ {settings?.enabled && counts.total > 0 && (
+
+
+
Gift Wraps ({counts.total})
+
+
+
+
+
+
+
+ {(counts.pending > 0 || counts.decrypting > 0) && (
+
+
+ {counts.pending + counts.decrypting} messages waiting to be
+ decrypted
+
+
+ {isDecryptingAll ? (
+ <>
+
+ Decrypting...
+ >
+ ) : (
+ <>
+
+ Decrypt All
+ >
+ )}
+
+
+ )}
+
+ )}
+
+ {/* Conversations Section */}
+
+ {settings?.enabled && conversations && conversations.length > 0 && (
+ <>
+
+
+ Recent Conversations ({conversations.length})
+
+ {conversations.map((conv) => (
+
{
+ // Open chat window - for now just show a toast
+ // In future, this would open the conversation in a chat viewer
+ toast.info("Chat viewer coming soon");
+ }}
+ />
+ ))}
+ >
+ )}
+
+ {settings?.enabled &&
+ (!conversations || conversations.length === 0) &&
+ counts.success === 0 && (
+
+
+
No conversations yet
+ {counts.pending > 0 && (
+
+ Decrypt pending messages to see conversations
+
+ )}
+
+ )}
+
+ {!settings?.enabled && (
+
+
+
Enable gift wrap sync to receive private messages
+
+ )}
+
+
+ {/* Pending Gift Wraps List (for manual decrypt) */}
+ {settings?.enabled && !settings.autoDecrypt && counts.pending > 0 && (
+
{
+ try {
+ await giftWrapService.decrypt(id);
+ toast.success("Message decrypted");
+ } catch (err) {
+ toast.error(
+ err instanceof Error ? err.message : "Decryption failed",
+ );
+ }
+ }}
+ />
+ )}
+
+ );
+}
+
+interface StatusBadgeProps {
+ status: "success" | "pending" | "error";
+ count: number;
+}
+
+function StatusBadge({ status, count }: StatusBadgeProps) {
+ if (count === 0) return null;
+
+ const config = {
+ success: {
+ icon: CheckCircle2,
+ className: "bg-green-500/10 text-green-500 border-green-500/20",
+ },
+ pending: {
+ icon: Clock,
+ className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
+ },
+ error: {
+ icon: AlertCircle,
+ className: "bg-red-500/10 text-red-500 border-red-500/20",
+ },
+ };
+
+ const { icon: Icon, className } = config[status];
+
+ return (
+
+
+ {count}
+
+ );
+}
+
+interface ConversationRowProps {
+ conversation: {
+ id: string;
+ participants: string[];
+ lastMessage?: { content: string; created_at: number; pubkey: string };
+ };
+ currentUserPubkey: string;
+ onClick: () => void;
+}
+
+function ConversationRow({
+ conversation,
+ currentUserPubkey,
+ onClick,
+}: ConversationRowProps) {
+ // Filter out current user from participants for display
+ const otherParticipants = conversation.participants.filter(
+ (p) => p !== currentUserPubkey,
+ );
+
+ return (
+
+
+
+ {otherParticipants.length === 1 ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ {otherParticipants.slice(0, 3).map((pubkey, i) => (
+
+ {i > 0 && , }
+
+
+ ))}
+ {otherParticipants.length > 3 && (
+
+ +{otherParticipants.length - 3} more
+
+ )}
+
+ {conversation.lastMessage && (
+
+ {conversation.lastMessage.content}
+
+ )}
+
+ {conversation.lastMessage && (
+
+ {formatTimestamp(conversation.lastMessage.created_at)}
+
+ )}
+
+
+ );
+}
+
+interface PendingGiftWrapsListProps {
+ decryptStates:
+ | Map
+ | undefined;
+ giftWraps: { id: string; created_at: number }[];
+ onDecrypt: (id: string) => Promise;
+}
+
+function PendingGiftWrapsList({
+ decryptStates,
+ giftWraps,
+ onDecrypt,
+}: PendingGiftWrapsListProps) {
+ const [decryptingIds, setDecryptingIds] = useState>(new Set());
+
+ const pendingWraps = giftWraps.filter((gw) => {
+ const state = decryptStates?.get(gw.id);
+ return state?.status === "pending" || state?.status === "error";
+ });
+
+ if (pendingWraps.length === 0) return null;
+
+ return (
+
+
+ Pending Decryption
+
+ {pendingWraps.slice(0, 10).map((gw) => {
+ const state = decryptStates?.get(gw.id);
+ const isDecrypting = decryptingIds.has(gw.id);
+
+ return (
+
+
+ {state?.status === "error" ? (
+
+
+
+
+
+ {state.error || "Decryption failed"}
+
+
+ ) : (
+
+ )}
+
+ {gw.id.slice(0, 16)}...
+
+
+ {formatTimestamp(gw.created_at)}
+
+
+
{
+ setDecryptingIds((prev) => new Set([...prev, gw.id]));
+ try {
+ await onDecrypt(gw.id);
+ } finally {
+ setDecryptingIds((prev) => {
+ const next = new Set(prev);
+ next.delete(gw.id);
+ return next;
+ });
+ }
+ }}
+ >
+ {isDecrypting ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+ {pendingWraps.length > 10 && (
+
+ And {pendingWraps.length - 10} more...
+
+ )}
+
+ );
+}
+
+export default InboxViewer;
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index e43055e..721cc3b 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -43,6 +43,7 @@ const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const CountViewer = lazy(() => import("./CountViewer"));
+const InboxViewer = lazy(() => import("./InboxViewer"));
// Loading fallback component
function ViewerLoading() {
@@ -220,6 +221,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
+ case "inbox":
+ content = ;
+ break;
default:
content = (
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx
index 111a2ea..ccea41a 100644
--- a/src/components/nostr/user-menu.tsx
+++ b/src/components/nostr/user-menu.tsx
@@ -1,4 +1,4 @@
-import { User, HardDrive, Palette } from "lucide-react";
+import { User, HardDrive, Palette, Mail } from "lucide-react";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -158,6 +158,15 @@ export default function UserMenu() {
)}
+
{
+ addWindow("inbox", {}, "Inbox");
+ }}
+ >
+
+ Private Messages
+
diff --git a/src/services/db.ts b/src/services/db.ts
index 6e9ee79..5ef886c 100644
--- a/src/services/db.ts
+++ b/src/services/db.ts
@@ -61,6 +61,12 @@ export interface CachedBlossomServerList {
updatedAt: number;
}
+export interface EncryptedContentEntry {
+ id: string; // Event ID (gift wrap or seal)
+ plaintext: string; // Decrypted content
+ savedAt: number;
+}
+
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")
@@ -98,6 +104,7 @@ class GrimoireDb extends Dexie {
blossomServers!: Table;
spells!: Table;
spellbooks!: Table;
+ encryptedContent!: Table;
constructor(name: string) {
super(name);
@@ -333,6 +340,21 @@ class GrimoireDb extends Dexie {
spells: "&id, alias, createdAt, isPublished, deletedAt",
spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
});
+
+ // Version 16: Add encrypted content cache for gift wraps (NIP-17/59)
+ this.version(16).stores({
+ profiles: "&pubkey",
+ nip05: "&nip05",
+ nips: "&id",
+ relayInfo: "&url",
+ relayAuthPreferences: "&url",
+ relayLists: "&pubkey, updatedAt",
+ relayLiveness: "&url",
+ blossomServers: "&pubkey, updatedAt",
+ spells: "&id, alias, createdAt, isPublished, deletedAt",
+ spellbooks: "&id, slug, title, createdAt, isPublished, deletedAt",
+ encryptedContent: "&id, savedAt",
+ });
}
}
@@ -360,4 +382,23 @@ export const relayLivenessStorage = {
},
};
+/**
+ * Dexie storage adapter for encrypted content persistence (NIP-17/59 gift wraps)
+ * Implements the EncryptedContentCache interface expected by applesauce-common
+ */
+export const encryptedContentStorage = {
+ async getItem(id: string): Promise {
+ const entry = await db.encryptedContent.get(id);
+ return entry?.plaintext ?? null;
+ },
+
+ async setItem(id: string, plaintext: string): Promise {
+ await db.encryptedContent.put({
+ id,
+ plaintext,
+ savedAt: Date.now(),
+ });
+ },
+};
+
export default db;
diff --git a/src/services/gift-wrap.ts b/src/services/gift-wrap.ts
new file mode 100644
index 0000000..12b3805
--- /dev/null
+++ b/src/services/gift-wrap.ts
@@ -0,0 +1,470 @@
+import { BehaviorSubject, Subject, Subscription, filter, map } from "rxjs";
+import { kinds } from "applesauce-core/helpers/event";
+import {
+ isGiftWrapUnlocked,
+ getGiftWrapRumor,
+ unlockGiftWrap,
+} from "applesauce-common/helpers/gift-wrap";
+import {
+ getConversationIdentifierFromMessage,
+ getConversationParticipants,
+} from "applesauce-common/helpers/messages";
+import { persistEncryptedContent } from "applesauce-common/helpers/encrypted-content-cache";
+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";
+
+/** Kind 10050: DM relay list (NIP-17) */
+const DM_RELAY_LIST_KIND = 10050;
+
+/** Kind 14: Private direct message (NIP-17) */
+const PRIVATE_DM_KIND = 14;
+
+/** Rumor is an unsigned event - used for gift wrap contents */
+interface Rumor {
+ id: string;
+ pubkey: string;
+ created_at: number;
+ kind: number;
+ tags: string[][];
+ content: string;
+}
+
+/** Status of a gift wrap decryption */
+export type DecryptStatus = "pending" | "decrypting" | "success" | "error";
+
+export interface DecryptState {
+ status: DecryptStatus;
+ error?: string;
+ decryptedAt?: number;
+}
+
+export interface Conversation {
+ id: string;
+ participants: string[];
+ lastMessage?: Rumor;
+ lastGiftWrap?: NostrEvent;
+ unreadCount?: number;
+}
+
+/** Settings for the inbox service */
+export interface InboxSettings {
+ enabled: boolean;
+ autoDecrypt: boolean;
+}
+
+const SETTINGS_KEY = "grimoire-inbox-settings";
+
+function loadSettings(): InboxSettings {
+ try {
+ const saved = localStorage.getItem(SETTINGS_KEY);
+ if (saved) return JSON.parse(saved);
+ } catch {
+ // ignore
+ }
+ return { enabled: false, autoDecrypt: false };
+}
+
+function saveSettings(settings: InboxSettings) {
+ localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
+}
+
+class GiftWrapService {
+ /** Current user's pubkey */
+ private userPubkey: string | null = null;
+ /** Current signer for decryption */
+ private signer: ISigner | null = null;
+
+ /** Map of gift wrap ID -> decrypt state */
+ private decryptStates = new Map();
+ /** Observable for decrypt state changes */
+ readonly decryptStates$ = new BehaviorSubject>(
+ new Map(),
+ );
+
+ /** All gift wraps for the current user */
+ private giftWraps: NostrEvent[] = [];
+ readonly giftWraps$ = new BehaviorSubject([]);
+
+ /** Conversations grouped by participants */
+ readonly conversations$ = new BehaviorSubject([]);
+
+ /** Inbox relays (kind 10050) */
+ readonly inboxRelays$ = new BehaviorSubject([]);
+
+ /** Settings */
+ readonly settings$ = new BehaviorSubject(loadSettings());
+
+ /** Sync status */
+ readonly syncStatus$ = new BehaviorSubject<
+ "idle" | "syncing" | "error" | "disabled"
+ >("idle");
+
+ /** Event emitter for decrypt events */
+ readonly decryptEvent$ = new Subject<{
+ giftWrapId: string;
+ status: DecryptStatus;
+ rumor?: Rumor;
+ error?: string;
+ }>();
+
+ private subscriptions: Subscription[] = [];
+ private relaySubscription: Subscription | null = null;
+ private persistenceCleanup: (() => void) | null = null;
+
+ constructor() {
+ // Start encrypted content persistence
+ this.persistenceCleanup = persistEncryptedContent(
+ eventStore,
+ encryptedContentStorage,
+ );
+ }
+
+ /** Initialize the service with user pubkey and signer */
+ init(pubkey: string, signer: ISigner | null) {
+ this.cleanup();
+ this.userPubkey = pubkey;
+ this.signer = signer;
+ this.decryptStates.clear();
+ this.decryptStates$.next(new Map());
+
+ // Load inbox relays (kind 10050)
+ this.loadInboxRelays();
+
+ // If enabled, start syncing
+ if (this.settings$.value.enabled) {
+ this.startSync();
+ }
+ }
+
+ /** Update settings */
+ updateSettings(settings: Partial) {
+ const newSettings = { ...this.settings$.value, ...settings };
+ this.settings$.next(newSettings);
+ saveSettings(newSettings);
+
+ // Handle enabled state change
+ if (settings.enabled !== undefined) {
+ if (settings.enabled) {
+ this.startSync();
+ } else {
+ this.stopSync();
+ }
+ }
+
+ // Handle auto-decrypt change
+ if (settings.autoDecrypt && this.signer) {
+ this.autoDecryptPending();
+ }
+ }
+
+ /** Load inbox relays from kind 10050 event */
+ private async loadInboxRelays() {
+ if (!this.userPubkey) return;
+
+ const sub = eventStore
+ .replaceable(DM_RELAY_LIST_KIND, this.userPubkey)
+ .pipe(
+ filter((e) => e !== undefined),
+ map((event) => {
+ if (!event) return [];
+ // Extract relay URLs from tags
+ return event.tags
+ .filter((tag) => tag[0] === "relay")
+ .map((tag) => tag[1])
+ .filter(Boolean);
+ }),
+ )
+ .subscribe((relays) => {
+ this.inboxRelays$.next(relays);
+ });
+
+ this.subscriptions.push(sub);
+ }
+
+ /** Start syncing gift wraps from inbox relays */
+ startSync() {
+ if (!this.userPubkey) {
+ this.syncStatus$.next("disabled");
+ return;
+ }
+
+ const relays = this.inboxRelays$.value;
+ if (relays.length === 0) {
+ // Use default relays if no inbox relays set
+ this.syncStatus$.next("syncing");
+ this.subscribeToGiftWraps([]);
+ } else {
+ this.syncStatus$.next("syncing");
+ this.subscribeToGiftWraps(relays);
+ }
+ }
+
+ /** Stop syncing */
+ stopSync() {
+ this.syncStatus$.next("disabled");
+ if (this.relaySubscription) {
+ this.relaySubscription.unsubscribe();
+ this.relaySubscription = null;
+ }
+ }
+
+ /** Subscribe to gift wraps for current user */
+ private subscribeToGiftWraps(relays: string[]) {
+ if (!this.userPubkey) return;
+
+ // Subscribe to gift wraps addressed to this user
+ const reqFilter = {
+ kinds: [kinds.GiftWrap],
+ "#p": [this.userPubkey],
+ };
+
+ // Use timeline observable for reactive updates
+ const sub = eventStore
+ .timeline(reqFilter)
+ .pipe(map((events) => events.sort((a, b) => b.created_at - a.created_at)))
+ .subscribe((giftWraps) => {
+ this.giftWraps = giftWraps;
+ this.giftWraps$.next(giftWraps);
+
+ // Update decrypt states for new gift wraps
+ for (const gw of giftWraps) {
+ if (!this.decryptStates.has(gw.id)) {
+ const isUnlocked = isGiftWrapUnlocked(gw);
+ this.decryptStates.set(gw.id, {
+ status: isUnlocked ? "success" : "pending",
+ decryptedAt: isUnlocked ? Date.now() : undefined,
+ });
+ }
+ }
+ this.decryptStates$.next(new Map(this.decryptStates));
+
+ // Update conversations
+ this.updateConversations();
+
+ // Auto-decrypt if enabled
+ if (this.settings$.value.autoDecrypt && this.signer) {
+ this.autoDecryptPending();
+ }
+
+ this.syncStatus$.next("idle");
+ });
+
+ this.relaySubscription = sub;
+
+ // Also request from relays
+ if (relays.length > 0) {
+ pool.request(relays, [reqFilter], { eventStore }).subscribe({
+ next: () => {
+ // Events are automatically added to eventStore via the options
+ },
+ error: (err) => {
+ console.warn(`[GiftWrap] Error fetching from relays:`, err);
+ },
+ });
+ }
+ }
+
+ /** Update conversations from decrypted gift wraps */
+ private updateConversations() {
+ const conversationMap = new Map();
+
+ for (const gw of this.giftWraps) {
+ if (!isGiftWrapUnlocked(gw)) continue;
+
+ const rumor = getGiftWrapRumor(gw);
+ if (!rumor || rumor.kind !== PRIVATE_DM_KIND) continue;
+
+ const convId = getConversationIdentifierFromMessage(rumor);
+ const existing = conversationMap.get(convId);
+
+ if (
+ !existing ||
+ rumor.created_at > (existing.lastMessage?.created_at ?? 0)
+ ) {
+ conversationMap.set(convId, {
+ id: convId,
+ participants: getConversationParticipants(rumor),
+ lastMessage: rumor,
+ lastGiftWrap: gw,
+ });
+ }
+ }
+
+ const conversations = Array.from(conversationMap.values()).sort(
+ (a, b) =>
+ (b.lastMessage?.created_at ?? 0) - (a.lastMessage?.created_at ?? 0),
+ );
+
+ this.conversations$.next(conversations);
+ }
+
+ /** Decrypt a single gift wrap */
+ async decrypt(giftWrapId: string): Promise {
+ if (!this.signer) {
+ throw new Error("No signer available");
+ }
+
+ const gw = this.giftWraps.find((g) => g.id === giftWrapId);
+ if (!gw) {
+ throw new Error("Gift wrap not found");
+ }
+
+ // Check if already decrypted
+ if (isGiftWrapUnlocked(gw)) {
+ return getGiftWrapRumor(gw) ?? null;
+ }
+
+ // Update state to decrypting
+ this.decryptStates.set(giftWrapId, { status: "decrypting" });
+ this.decryptStates$.next(new Map(this.decryptStates));
+
+ try {
+ const rumor = await unlockGiftWrap(gw, this.signer);
+
+ // Update state to success
+ this.decryptStates.set(giftWrapId, {
+ status: "success",
+ decryptedAt: Date.now(),
+ });
+ this.decryptStates$.next(new Map(this.decryptStates));
+
+ // Emit decrypt event
+ this.decryptEvent$.next({
+ giftWrapId,
+ status: "success",
+ rumor,
+ });
+
+ // Update conversations
+ this.updateConversations();
+
+ return rumor;
+ } catch (err) {
+ const error = err instanceof Error ? err.message : "Unknown error";
+
+ // Update state to error
+ this.decryptStates.set(giftWrapId, { status: "error", error });
+ this.decryptStates$.next(new Map(this.decryptStates));
+
+ // Emit decrypt event
+ this.decryptEvent$.next({
+ giftWrapId,
+ status: "error",
+ error,
+ });
+
+ return null;
+ }
+ }
+
+ /** Decrypt all pending gift wraps */
+ async decryptAll(): Promise<{ success: number; error: number }> {
+ if (!this.signer) {
+ throw new Error("No signer available");
+ }
+
+ let success = 0;
+ let error = 0;
+
+ const pending = this.giftWraps.filter(
+ (gw) =>
+ !isGiftWrapUnlocked(gw) &&
+ this.decryptStates.get(gw.id)?.status !== "decrypting",
+ );
+
+ for (const gw of pending) {
+ try {
+ await this.decrypt(gw.id);
+ success++;
+ } catch {
+ error++;
+ }
+ }
+
+ return { success, error };
+ }
+
+ /** Auto-decrypt pending gift wraps (called when auto-decrypt is enabled) */
+ private async autoDecryptPending() {
+ if (!this.signer || !this.settings$.value.autoDecrypt) return;
+
+ const pending = this.giftWraps.filter((gw) => {
+ const state = this.decryptStates.get(gw.id);
+ return state?.status === "pending";
+ });
+
+ for (const gw of pending) {
+ try {
+ await this.decrypt(gw.id);
+ } catch {
+ // Errors are already tracked in decryptStates
+ }
+ }
+ }
+
+ /** Get counts by status */
+ getCounts(): {
+ pending: number;
+ success: number;
+ error: number;
+ total: number;
+ } {
+ let pending = 0;
+ let success = 0;
+ let error = 0;
+
+ for (const state of this.decryptStates.values()) {
+ switch (state.status) {
+ case "pending":
+ case "decrypting":
+ pending++;
+ break;
+ case "success":
+ success++;
+ break;
+ case "error":
+ error++;
+ break;
+ }
+ }
+
+ return { pending, success, error, total: this.giftWraps.length };
+ }
+
+ /** Update signer (when user logs in/out or changes) */
+ setSigner(signer: ISigner | null) {
+ this.signer = signer;
+
+ // Auto-decrypt if enabled and signer is available
+ if (signer && this.settings$.value.autoDecrypt) {
+ this.autoDecryptPending();
+ }
+ }
+
+ /** Cleanup subscriptions */
+ cleanup() {
+ this.subscriptions.forEach((s) => s.unsubscribe());
+ this.subscriptions = [];
+ if (this.relaySubscription) {
+ this.relaySubscription.unsubscribe();
+ this.relaySubscription = null;
+ }
+ }
+
+ /** Full destroy (call when app unmounts) */
+ destroy() {
+ this.cleanup();
+ if (this.persistenceCleanup) {
+ this.persistenceCleanup();
+ this.persistenceCleanup = null;
+ }
+ }
+}
+
+// Singleton instance
+const giftWrapService = new GiftWrapService();
+
+export default giftWrapService;
diff --git a/src/types/app.ts b/src/types/app.ts
index 09a1148..e581461 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -21,6 +21,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
+ | "inbox"
| "win";
export interface WindowInstance {
diff --git a/src/types/man.ts b/src/types/man.ts
index c566eed..4cf7f7d 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -641,6 +641,18 @@ export const manPages: Record = {
category: "Nostr",
defaultProps: {},
},
+ inbox: {
+ name: "inbox",
+ section: "1",
+ synopsis: "inbox",
+ description:
+ "Manage private messages using NIP-17/NIP-59 gift wraps. View and configure your DM inbox relays (kind 10050), enable/disable gift wrap sync, track decryption status, and browse recent conversations. Supports auto-decrypt mode for hands-free message decryption.",
+ examples: ["inbox Open the private message inbox manager"],
+ seeAlso: ["chat", "profile", "conn"],
+ appId: "inbox",
+ category: "Nostr",
+ defaultProps: {},
+ },
blossom: {
name: "blossom",
section: "1",