mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-16 17:48:34 +02:00
feat: add NIP-17 inbox viewer with gift wrap settings control
Implements comprehensive inbox dashboard for NIP-17 encrypted DMs with full control over gift wrap sync and decryption behavior. ## Features **Gift Wrap Settings** (GrimoireState): - `syncEnabled`: Toggle gift wrap sync on/off (default: true) - `autoDecrypt`: Toggle automatic decryption (default: true) - Persisted to localStorage via Jotai atomWithStorage - Settings accessible via useGrimoire hook **InboxViewer Component** (src/components/InboxViewer.tsx): - **Settings Panel**: Toggle sync and auto-decrypt with immediate feedback - **Statistics Panel**: Real-time gift wrap stats (total, success, failed, pending) - **DM Relays Panel**: Shows kind 10050 relay list, falls back message if none - **Conversations List**: Compact one-row-per-conversation view with: - Profile display names (via useProfile hook) - Latest message preview (truncated to 60 chars) - Timestamp in localized format - Click to open chat window - Copy pubkey button per conversation **Hooks** (src/hooks/useGiftWrap.ts): - `useGiftWrapStats()`: Real-time observable stats from gift wrap manager - `useGiftWrapConversations()`: All conversations for active account - `useConversationMessages(key)`: Messages for specific conversation - Auto-refresh with polling (5s for conversations, 3s for messages) **Integration**: - Updated useAccountSync to respect `syncEnabled` setting - Stops gift wrap sync when disabled or no account active - Added "inbox" to AppId type union - Added inbox command to man pages - Wired InboxViewer into WindowRenderer **Usage**: ```bash inbox # Open DM inbox dashboard ``` **Inbox shows at a glance**: 1. Sync settings (enable/disable toggle) 2. DM relay status (kind 10050 relay list) 3. Gift wrap statistics (success/failure rates) 4. All conversations sorted by latest message 5. One-click access to open any conversation **Future improvements**: - Replace polling with reactive subscriptions when gift wrap manager emits updates - Add conversation search/filter - Add conversation archiving - Add read/unread indicators - Add notification badges
This commit is contained in:
308
src/components/InboxViewer.tsx
Normal file
308
src/components/InboxViewer.tsx
Normal file
@@ -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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">
|
||||
Please log in to view your inbox
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">NIP-17 DM Inbox</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Encrypted direct messages with gift wrap privacy
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="rounded p-2 hover:bg-muted"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<div className="border-b bg-muted/50 px-4 py-3">
|
||||
<h3 className="mb-2 text-sm font-semibold">Settings</h3>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Panel */}
|
||||
<div className="border-b bg-muted/30 px-4 py-3">
|
||||
<h3 className="mb-2 text-sm font-semibold">Gift Wrap Statistics</h3>
|
||||
<div className="grid grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold">{stats.totalGiftWraps}</div>
|
||||
<div className="text-xs text-muted-foreground">Total</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stats.successfulDecryptions}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Success</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{stats.failedDecryptions}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Failed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-yellow-600">
|
||||
{stats.pendingDecryptions}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DM Relays Panel */}
|
||||
<div className="border-b bg-muted/20 px-4 py-3">
|
||||
<h3 className="mb-2 text-sm font-semibold">DM Relays (Kind 10050)</h3>
|
||||
{dmRelays.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dmRelays.map((relay) => (
|
||||
<span
|
||||
key={relay}
|
||||
className="rounded bg-muted px-2 py-1 text-xs font-mono"
|
||||
>
|
||||
{relay}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No DM relays configured (using general relays)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations List */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="px-4 py-3">
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
Conversations ({conversationsList.length})
|
||||
</h3>
|
||||
{conversationsList.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
<MessageSquare className="mx-auto mb-2 h-12 w-12 opacity-50" />
|
||||
<p>No conversations yet</p>
|
||||
<p className="text-xs">
|
||||
Start a chat using: <code>chat npub...</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{conversationsList.map(({ key, latestMessage, otherPubkey }) => (
|
||||
<ConversationRow
|
||||
key={key}
|
||||
conversationKey={key}
|
||||
otherPubkey={otherPubkey}
|
||||
latestMessage={latestMessage}
|
||||
onClick={() => handleOpenConversation(key, otherPubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="flex cursor-pointer items-center gap-3 rounded border p-3 hover:bg-muted/50"
|
||||
>
|
||||
{/* Avatar placeholder */}
|
||||
<div className="h-10 w-10 shrink-0 rounded-full bg-primary/20" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate font-semibold">{displayName}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{timeStr}
|
||||
</span>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{preview}
|
||||
{truncated && "..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={handleCopyPubkey}
|
||||
className="shrink-0 rounded p-1 hover:bg-muted"
|
||||
title="Copy pubkey"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <InboxViewer />;
|
||||
break;
|
||||
case "spells":
|
||||
content = <SpellsViewer />;
|
||||
break;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
108
src/hooks/useGiftWrap.ts
Normal file
108
src/hooks/useGiftWrap.ts
Normal file
@@ -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<GiftWrapStats>({
|
||||
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<string, UnsealedDM> | null {
|
||||
const [conversations, setConversations] = useState<Map<
|
||||
string,
|
||||
UnsealedDM
|
||||
> | null>(null);
|
||||
const activeAccount = use$(accountManager.active$);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAccount?.pubkey) {
|
||||
setConversations(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load conversations from storage
|
||||
giftWrapManager
|
||||
.getConversations(activeAccount.pubkey)
|
||||
.then(setConversations)
|
||||
.catch((error) => {
|
||||
console.error("[useGiftWrapConversations] Failed to load:", error);
|
||||
setConversations(new Map());
|
||||
});
|
||||
|
||||
// Poll for updates every 5 seconds
|
||||
// TODO: Replace with proper reactive subscription when gift wrap manager emits updates
|
||||
const interval = setInterval(() => {
|
||||
giftWrapManager
|
||||
.getConversations(activeAccount.pubkey)
|
||||
.then(setConversations)
|
||||
.catch(console.error);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [activeAccount?.pubkey]);
|
||||
|
||||
return conversations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get messages for a specific conversation
|
||||
*/
|
||||
export function useConversationMessages(
|
||||
conversationKey: string | null,
|
||||
): UnsealedDM[] | null {
|
||||
const [messages, setMessages] = useState<UnsealedDM[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationKey) {
|
||||
setMessages(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load messages from storage
|
||||
giftWrapManager
|
||||
.getConversationMessages(conversationKey)
|
||||
.then(setMessages)
|
||||
.catch((error) => {
|
||||
console.error("[useConversationMessages] Failed to load:", error);
|
||||
setMessages([]);
|
||||
});
|
||||
|
||||
// Poll for updates every 3 seconds
|
||||
// TODO: Replace with proper reactive subscription
|
||||
const interval = setInterval(() => {
|
||||
giftWrapManager
|
||||
.getConversationMessages(conversationKey)
|
||||
.then(setMessages)
|
||||
.catch(console.error);
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [conversationKey]);
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export type AppId =
|
||||
| "debug"
|
||||
| "conn"
|
||||
| "chat"
|
||||
| "inbox"
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
@@ -138,4 +139,8 @@ export interface GrimoireState {
|
||||
};
|
||||
nwcConnection?: NWCConnection;
|
||||
walletBalancesBlurred?: boolean; // Privacy: blur balances and transaction amounts
|
||||
giftWrapSettings?: {
|
||||
syncEnabled: boolean; // Enable/disable gift wrap sync (NIP-17 DMs)
|
||||
autoDecrypt: boolean; // Automatically decrypt received gift wraps
|
||||
};
|
||||
}
|
||||
|
||||
@@ -587,6 +587,21 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
};
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
name: "inbox",
|
||||
section: "1",
|
||||
synopsis: "inbox",
|
||||
description:
|
||||
"View NIP-17 direct message inbox dashboard. Shows gift wrap decryption statistics, DM relay status (kind 10050), and a compact list of all conversations. Allows toggling gift wrap sync on/off and auto-decryption settings. Click any conversation to open a chat window.",
|
||||
options: [],
|
||||
examples: ["inbox Open DM inbox dashboard"],
|
||||
seeAlso: ["chat"],
|
||||
appId: "inbox",
|
||||
category: "Nostr",
|
||||
argParser: () => {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
name: "profile",
|
||||
section: "1",
|
||||
|
||||
Reference in New Issue
Block a user