diff --git a/src/components/InboxViewer.tsx b/src/components/InboxViewer.tsx
new file mode 100644
index 0000000..3bdc0cf
--- /dev/null
+++ b/src/components/InboxViewer.tsx
@@ -0,0 +1,308 @@
+/**
+ * InboxViewer - NIP-17 DM Inbox Dashboard
+ *
+ * Shows:
+ * - Gift wrap sync settings (enable/disable, auto-decrypt)
+ * - DM relay status
+ * - Gift wrap statistics
+ * - Conversation list (compact view)
+ */
+
+import { useState, useMemo } from "react";
+import { use$ } from "applesauce-react/hooks";
+import { nip19 } from "nostr-tools";
+import { useGrimoire } from "@/core/state";
+import { useAccount } from "@/hooks/useAccount";
+import {
+ useGiftWrapStats,
+ useGiftWrapConversations,
+} from "@/hooks/useGiftWrap";
+import { useProfile } from "@/hooks/useProfile";
+import eventStore from "@/services/event-store";
+import accountManager from "@/services/accounts";
+import { getDisplayName } from "@/lib/nostr-utils";
+import { Copy, Settings, RefreshCw, MessageSquare } from "lucide-react";
+import { useCopy } from "@/hooks/useCopy";
+import { toast } from "sonner";
+
+interface InboxViewerProps {}
+
+export function InboxViewer(_props: InboxViewerProps) {
+ const { state, updateGiftWrapSettings } = useGrimoire();
+ const { pubkey } = useAccount();
+ const stats = useGiftWrapStats();
+ const conversations = useGiftWrapConversations();
+ const [showSettings, setShowSettings] = useState(false);
+
+ const syncEnabled = state.giftWrapSettings?.syncEnabled ?? true;
+ const autoDecrypt = state.giftWrapSettings?.autoDecrypt ?? true;
+
+ // Get DM relays (kind 10050)
+ const dmRelayEvent = use$(() => {
+ if (!pubkey) return null;
+ return eventStore
+ .getAll()
+ .filter((e) => e.kind === 10050 && e.pubkey === pubkey)
+ .sort((a, b) => b.created_at - a.created_at)[0];
+ }, [pubkey]);
+
+ const dmRelays = useMemo(() => {
+ if (!dmRelayEvent) return [];
+ return dmRelayEvent.tags
+ .filter((t) => t[0] === "relay" && t[1])
+ .map((t) => t[1]);
+ }, [dmRelayEvent]);
+
+ // Convert conversations map to sorted array
+ const conversationsList = useMemo(() => {
+ if (!conversations) return [];
+ return Array.from(conversations.entries())
+ .map(([key, latestMessage]) => ({
+ key,
+ latestMessage,
+ otherPubkey:
+ latestMessage.senderPubkey === pubkey
+ ? latestMessage.recipientPubkey
+ : latestMessage.senderPubkey,
+ }))
+ .sort((a, b) => b.latestMessage.createdAt - a.latestMessage.createdAt);
+ }, [conversations, pubkey]);
+
+ const handleToggleSync = () => {
+ updateGiftWrapSettings({ syncEnabled: !syncEnabled });
+ toast.success(
+ syncEnabled ? "Gift wrap sync disabled" : "Gift wrap sync enabled",
+ );
+ };
+
+ const handleToggleAutoDecrypt = () => {
+ updateGiftWrapSettings({ autoDecrypt: !autoDecrypt });
+ toast.success(
+ autoDecrypt ? "Auto-decrypt disabled" : "Auto-decrypt enabled",
+ );
+ };
+
+ const handleOpenConversation = (
+ conversationKey: string,
+ otherPubkey: string,
+ ) => {
+ // Open chat window with the other participant
+ const npub = nip19.npubEncode(otherPubkey);
+ window.dispatchEvent(
+ new CustomEvent("grimoire:execute-command", {
+ detail: `chat ${npub}`,
+ }),
+ );
+ };
+
+ if (!pubkey) {
+ return (
+
+
+
+ Please log in to view your inbox
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
NIP-17 DM Inbox
+
+ Encrypted direct messages with gift wrap privacy
+
+
+
+
+
+
+ {/* Settings Panel */}
+ {showSettings && (
+
+ )}
+
+ {/* Stats Panel */}
+
+
Gift Wrap Statistics
+
+
+
{stats.totalGiftWraps}
+
Total
+
+
+
+ {stats.successfulDecryptions}
+
+
Success
+
+
+
+ {stats.failedDecryptions}
+
+
Failed
+
+
+
+ {stats.pendingDecryptions}
+
+
Pending
+
+
+
+
+ {/* DM Relays Panel */}
+
+
DM Relays (Kind 10050)
+ {dmRelays.length > 0 ? (
+
+ {dmRelays.map((relay) => (
+
+ {relay}
+
+ ))}
+
+ ) : (
+
+ No DM relays configured (using general relays)
+
+ )}
+
+
+ {/* Conversations List */}
+
+
+
+ Conversations ({conversationsList.length})
+
+ {conversationsList.length === 0 ? (
+
+
+
No conversations yet
+
+ Start a chat using: chat npub...
+
+
+ ) : (
+
+ {conversationsList.map(({ key, latestMessage, otherPubkey }) => (
+ handleOpenConversation(key, otherPubkey)}
+ />
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+interface ConversationRowProps {
+ conversationKey: string;
+ otherPubkey: string;
+ latestMessage: any;
+ onClick: () => void;
+}
+
+function ConversationRow({
+ otherPubkey,
+ latestMessage,
+ onClick,
+}: ConversationRowProps) {
+ const profile = useProfile(otherPubkey);
+ const { copy } = useCopy();
+ const displayName = getDisplayName(otherPubkey, profile);
+
+ const handleCopyPubkey = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ copy(otherPubkey, "Pubkey copied");
+ };
+
+ // Format timestamp
+ const timestamp = new Date(latestMessage.createdAt * 1000);
+ const timeStr = timestamp.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+
+ // Truncate content preview
+ const preview = latestMessage.content.slice(0, 60);
+ const truncated = latestMessage.content.length > 60;
+
+ return (
+
+ {/* Avatar placeholder */}
+
+
+ {/* Content */}
+
+
+ {displayName}
+
+ {timeStr}
+
+
+
+ {preview}
+ {truncated && "..."}
+
+
+
+ {/* Actions */}
+
+
+ );
+}
diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx
index 0ad2c71..81bd49b 100644
--- a/src/components/WindowRenderer.tsx
+++ b/src/components/WindowRenderer.tsx
@@ -47,6 +47,9 @@ const ZapWindow = lazy(() =>
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
);
const CountViewer = lazy(() => import("./CountViewer"));
+const InboxViewer = lazy(() =>
+ import("./InboxViewer").then((m) => ({ default: m.InboxViewer })),
+);
// Loading fallback component
function ViewerLoading() {
@@ -208,6 +211,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
);
}
break;
+ case "inbox":
+ content = ;
+ break;
case "spells":
content = ;
break;
diff --git a/src/core/logic.ts b/src/core/logic.ts
index 07527c1..a84dc77 100644
--- a/src/core/logic.ts
+++ b/src/core/logic.ts
@@ -602,3 +602,21 @@ export const toggleWalletBalancesBlur = (
walletBalancesBlurred: !state.walletBalancesBlurred,
};
};
+
+/**
+ * Updates gift wrap settings (NIP-17 DM sync and decryption)
+ */
+export const updateGiftWrapSettings = (
+ state: GrimoireState,
+ settings: Partial<{ syncEnabled: boolean; autoDecrypt: boolean }>,
+): GrimoireState => {
+ return {
+ ...state,
+ giftWrapSettings: {
+ syncEnabled:
+ settings.syncEnabled ?? state.giftWrapSettings?.syncEnabled ?? true,
+ autoDecrypt:
+ settings.autoDecrypt ?? state.giftWrapSettings?.autoDecrypt ?? true,
+ },
+ };
+};
diff --git a/src/core/state.ts b/src/core/state.ts
index 98f8618..012a5fd 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -374,6 +374,13 @@ export const useGrimoire = () => {
setState((prev) => Logic.toggleWalletBalancesBlur(prev));
}, [setState]);
+ const updateGiftWrapSettings = useCallback(
+ (settings: Partial<{ syncEnabled: boolean; autoDecrypt: boolean }>) => {
+ setState((prev) => Logic.updateGiftWrapSettings(prev, settings));
+ },
+ [setState],
+ );
+
return {
state,
isTemporary,
@@ -405,5 +412,6 @@ export const useGrimoire = () => {
updateNWCInfo,
disconnectNWC,
toggleWalletBalancesBlur,
+ updateGiftWrapSettings,
};
};
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
index 30f4198..692a36d 100644
--- a/src/hooks/useAccountSync.ts
+++ b/src/hooks/useAccountSync.ts
@@ -12,11 +12,12 @@ import giftWrapManager from "@/services/gift-wrap";
* Hook that syncs active account with Grimoire state and fetches relay lists and blossom servers
*/
export function useAccountSync() {
+ const grimoire = useGrimoire();
const {
setActiveAccount,
setActiveAccountRelays,
setActiveAccountBlossomServers,
- } = useGrimoire();
+ } = grimoire;
const eventStore = useEventStore();
// Watch active account from accounts service
@@ -129,8 +130,10 @@ export function useAccountSync() {
// Start gift wrap sync (NIP-17) when account changes
useEffect(() => {
- if (!activeAccount?.pubkey) {
- // Stop sync when no account is active
+ const syncEnabled = grimoire.state.giftWrapSettings?.syncEnabled ?? true;
+
+ if (!activeAccount?.pubkey || !syncEnabled) {
+ // Stop sync when no account is active or sync is disabled
giftWrapManager.stopSync();
return;
}
@@ -144,5 +147,5 @@ export function useAccountSync() {
return () => {
giftWrapManager.stopSync();
};
- }, [activeAccount?.pubkey]);
+ }, [activeAccount?.pubkey, grimoire.state.giftWrapSettings?.syncEnabled]);
}
diff --git a/src/hooks/useGiftWrap.ts b/src/hooks/useGiftWrap.ts
new file mode 100644
index 0000000..4c7bcd7
--- /dev/null
+++ b/src/hooks/useGiftWrap.ts
@@ -0,0 +1,108 @@
+/**
+ * React hooks for accessing NIP-17 gift wrap data
+ */
+
+import { useState, useEffect } from "react";
+import { use$ } from "applesauce-react/hooks";
+import giftWrapManager, { type GiftWrapStats } from "@/services/gift-wrap";
+import type { UnsealedDM } from "@/services/db";
+import accountManager from "@/services/accounts";
+
+/**
+ * Hook to access gift wrap statistics
+ * Returns real-time stats about decryption success/failure rates
+ */
+export function useGiftWrapStats(): GiftWrapStats {
+ const [stats, setStats] = useState({
+ totalGiftWraps: 0,
+ successfulDecryptions: 0,
+ failedDecryptions: 0,
+ pendingDecryptions: 0,
+ });
+
+ useEffect(() => {
+ const subscription = giftWrapManager.getStats().subscribe(setStats);
+ return () => subscription.unsubscribe();
+ }, []);
+
+ return stats;
+}
+
+/**
+ * Hook to get all conversations for the active account
+ * Returns a map of conversation keys to the latest message in each conversation
+ */
+export function useGiftWrapConversations(): Map | null {
+ const [conversations, setConversations] = useState