From f26f49af1172d6296f8752c44fec31f0b5c0509e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Jan 2026 10:05:02 +0000 Subject: [PATCH] feat: improve NIP-60 Cashu wallet UX and state machine Redesign wallet state machine with proper states: - discovering: Querying network to find wallet - missing: No wallet found after search - locked: Wallet exists but encrypted - unlocked: Wallet decrypted and ready UX improvements: - Add transaction detail dialog with raw JSON viewer - Add wallet settings dialog with sync and blur toggles - Remove "sats" suffix from balance displays - Sort mint balances by amount (highest first) - Use formatTimestamp for consistent date formatting - Add proper loading/searching UI states Add cashuWalletSyncEnabled setting to app state for future auto-sync functionality. --- src/components/Nip61WalletViewer.tsx | 388 +++++++++++++++++++++++---- src/core/logic.ts | 10 + src/core/state.ts | 8 + src/hooks/useNip61Wallet.ts | 101 ++++++- src/types/app.ts | 1 + 5 files changed, 451 insertions(+), 57 deletions(-) diff --git a/src/components/Nip61WalletViewer.tsx b/src/components/Nip61WalletViewer.tsx index 2e17a31..0b71257 100644 --- a/src/components/Nip61WalletViewer.tsx +++ b/src/components/Nip61WalletViewer.tsx @@ -1,14 +1,28 @@ /** * Nip61WalletViewer Component * - * Displays NIP-60 Cashu wallet information: - * - Wallet balance (total and per-mint) - * - Transaction history - * - Placeholder send/receive buttons + * Displays NIP-60 Cashu wallet with proper state machine: + * - discovering: Searching for wallet on network + * - missing: No wallet found + * - locked: Wallet found but encrypted + * - unlocked: Wallet decrypted, showing balance and history */ import { useState, useMemo, useCallback } from "react"; -import { Send, Download, RefreshCw, Settings, Coins } from "lucide-react"; +import { + Send, + Download, + RefreshCw, + Settings, + Coins, + Loader2, + Wallet, + Lock, + ChevronRight, + ChevronDown, + ArrowDownLeft, + Search, +} from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -16,18 +30,27 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { WalletBalance, WalletHeader, WalletHistoryList, TransactionRow, - NoWalletView, - WalletLockedView, type HistoryItem, } from "@/components/wallet"; +import { CodeCopyButton } from "@/components/CodeCopyButton"; import { useNip61Wallet } from "@/hooks/useNip61Wallet"; import { useGrimoire } from "@/core/state"; import { useAccount } from "@/hooks/useAccount"; +import { formatTimestamp } from "@/hooks/useLocale"; import type { WalletHistory } from "applesauce-wallet/casts"; /** @@ -61,7 +84,7 @@ function MintBalanceBreakdown({ {new URL(mint).hostname} - {blurred ? "✦✦✦" : amount.toLocaleString()} sats + {blurred ? "✦✦✦" : amount.toLocaleString()} ))} @@ -71,6 +94,99 @@ function MintBalanceBreakdown({ ); } +/** + * Discovering state view - searching for wallet + */ +function DiscoveringView() { + return ( +
+
+ +
+

Searching for Wallet

+

+ Looking for your NIP-60 Cashu wallet on the Nostr network... +

+
+ + Querying relays +
+
+ ); +} + +/** + * Missing wallet state view + */ +function MissingWalletView() { + return ( +
+
+ +
+

No Cashu Wallet Found

+

+ No NIP-60 Cashu wallet was found for your account. Wallet creation is + not yet supported in Grimoire. +

+
+ ); +} + +/** + * Locked wallet state view + */ +function LockedWalletView({ + onUnlock, + unlocking, +}: { + onUnlock: () => void; + unlocking: boolean; +}) { + return ( +
+
+ +
+

Wallet Locked

+

+ Your Cashu wallet is encrypted. Unlock it to view your balance and + transaction history. +

+ +
+ ); +} + +/** + * Not logged in view + */ +function NotLoggedInView() { + return ( +
+
+ +
+

Not Logged In

+

+ Log in with a Nostr account to access your Cashu wallet. +

+
+ ); +} + /** * Transform WalletHistory into HistoryItem for the list */ @@ -86,8 +202,9 @@ export default function Nip61WalletViewer() { const { state, toggleWalletBalancesBlur } = useGrimoire(); const { isLoggedIn } = useAccount(); const { - hasWallet, - isUnlocked, + isDiscovering, + isMissing, + isLocked, balance, totalBalance, history, @@ -95,9 +212,19 @@ export default function Nip61WalletViewer() { unlock, unlocking, error, + syncEnabled, + toggleSyncEnabled, } = useNip61Wallet(); const [refreshing, setRefreshing] = useState(false); + const [selectedTransaction, setSelectedTransaction] = + useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [showRawTransaction, setShowRawTransaction] = useState(false); + const [copiedRawTx, setCopiedRawTx] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const blurred = state.walletBalancesBlurred ?? false; // Transform history for the list component const historyItems = useMemo(() => { @@ -109,7 +236,6 @@ export default function Nip61WalletViewer() { const handleRefresh = useCallback(async () => { setRefreshing(true); try { - // Re-unlock to refresh encrypted content await unlock(); toast.success("Wallet refreshed"); } catch (err) { @@ -120,6 +246,12 @@ export default function Nip61WalletViewer() { } }, [unlock]); + // Open transaction detail + const handleTransactionClick = useCallback((entry: WalletHistory) => { + setSelectedTransaction(entry); + setDetailDialogOpen(true); + }, []); + // Render history entry const renderHistoryEntry = useCallback( (item: HistoryItem) => { @@ -136,60 +268,62 @@ export default function Nip61WalletViewer() { label={ Locked } + onClick={() => handleTransactionClick(entry)} /> ); } - // Get meta synchronously if available - // Note: In a real implementation, we'd need to handle the observable - // For now, we'll show a simplified view return ( - {entry.event.created_at - ? new Date(entry.event.created_at * 1000).toLocaleTimeString() - : "Transaction"} + {formatTimestamp(entry.event.created_at, "datetime")} } + onClick={() => handleTransactionClick(entry)} /> ); }, - [state.walletBalancesBlurred], + [blurred, handleTransactionClick], ); // Not logged in if (!isLoggedIn) { return ( - +
+ +
); } - // No wallet found - if (!hasWallet) { + // Discovering + if (isDiscovering) { return ( - +
+ +
); } - // Wallet locked - if (!isUnlocked) { + // Missing wallet + if (isMissing) { return ( - +
+ +
+ ); + } + + // Locked wallet + if (isLocked) { + return ( +
+ +
); } @@ -230,14 +364,14 @@ export default function Nip61WalletViewer() { - Settings (coming soon) + Settings } @@ -253,16 +387,12 @@ export default function Nip61WalletViewer() { {/* Balance */} {/* Balance by mint */} - + {/* Send / Receive Buttons (Placeholders) */}
@@ -302,6 +432,170 @@ export default function Nip61WalletViewer() { />
+ + {/* Transaction Detail Dialog */} + { + setDetailDialogOpen(open); + if (!open) { + setShowRawTransaction(false); + setCopiedRawTx(false); + } + }} + > + + + Transaction Details + + +
+ {selectedTransaction && ( +
+
+ +
+

Transaction

+

+ {formatTimestamp( + selectedTransaction.event.created_at, + "datetime", + )} +

+
+
+ +
+
+ +

+ {selectedTransaction.event.id} +

+
+ +
+ +

+ {selectedTransaction.unlocked ? "Unlocked" : "Locked"} +

+
+ +
+ +

+ {formatTimestamp( + selectedTransaction.event.created_at, + "absolute", + )} +

+
+
+ + {/* Raw Transaction (expandable) */} +
+ + + {showRawTransaction && ( +
+
+
+                          {JSON.stringify(selectedTransaction.event, null, 2)}
+                        
+ { + navigator.clipboard.writeText( + JSON.stringify( + selectedTransaction.event, + null, + 2, + ), + ); + setCopiedRawTx(true); + setTimeout(() => setCopiedRawTx(false), 2000); + }} + label="Copy event JSON" + /> +
+
+ )} +
+
+ )} +
+ + + + +
+
+ + {/* Settings Dialog */} + + + + Wallet Settings + + +
+
+
+ +

+ Keep wallet unlocked and sync history automatically +

+
+ +
+ +
+
+ +

+ Hide balance amounts for privacy +

+
+ +
+
+ + + + +
+
); } diff --git a/src/core/logic.ts b/src/core/logic.ts index 07527c1..756b204 100644 --- a/src/core/logic.ts +++ b/src/core/logic.ts @@ -602,3 +602,13 @@ export const toggleWalletBalancesBlur = ( walletBalancesBlurred: !state.walletBalancesBlurred, }; }; + +export const setCashuWalletSyncEnabled = ( + state: GrimoireState, + enabled: boolean, +): GrimoireState => { + return { + ...state, + cashuWalletSyncEnabled: enabled, + }; +}; diff --git a/src/core/state.ts b/src/core/state.ts index 98f8618..7fe5efa 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 setCashuWalletSyncEnabled = useCallback( + (enabled: boolean) => { + setState((prev) => Logic.setCashuWalletSyncEnabled(prev, enabled)); + }, + [setState], + ); + return { state, isTemporary, @@ -405,5 +412,6 @@ export const useGrimoire = () => { updateNWCInfo, disconnectNWC, toggleWalletBalancesBlur, + setCashuWalletSyncEnabled, }; }; diff --git a/src/hooks/useNip61Wallet.ts b/src/hooks/useNip61Wallet.ts index 479bc86..a1af725 100644 --- a/src/hooks/useNip61Wallet.ts +++ b/src/hooks/useNip61Wallet.ts @@ -1,8 +1,11 @@ /** * useNip61Wallet Hook * - * Provides access to the user's NIP-60 Cashu wallet state. - * Uses applesauce-wallet casts for reactive wallet data. + * Provides access to the user's NIP-60 Cashu wallet with proper state machine: + * - discovering: Querying the network to find if wallet exists + * - missing: No wallet found after search completed + * - locked: Wallet exists but encrypted + * - unlocked: Wallet decrypted and ready to use */ import { useMemo, useCallback, useState, useEffect, useRef } from "react"; @@ -21,10 +24,19 @@ import { WALLET_HISTORY_KIND, } from "@/services/nip61-wallet"; import { kinds, relaySet } from "applesauce-core/helpers"; +import { useGrimoire } from "@/core/state"; // Import casts to enable user.wallet$ property import "applesauce-wallet/casts"; +/** Wallet state machine states */ +export type WalletState = + | "discovering" + | "missing" + | "locked" + | "unlocked" + | "error"; + /** * Hook to access the user's NIP-60 Cashu wallet * @@ -32,8 +44,10 @@ import "applesauce-wallet/casts"; */ export function useNip61Wallet() { const { pubkey, canSign } = useAccount(); + const { state: appState, setCashuWalletSyncEnabled } = useGrimoire(); const [unlocking, setUnlocking] = useState(false); const [error, setError] = useState(null); + const [discoveryComplete, setDiscoveryComplete] = useState(false); const subscriptionRef = useRef(null); // Create User cast for the active pubkey @@ -56,20 +70,46 @@ export function useNip61Wallet() { // Get user's outbox relays for subscriptions const outboxes = use$(() => user?.outboxes$, [user]); + // Determine current wallet state + const walletState: WalletState = useMemo(() => { + if (!pubkey) return "missing"; + if (error) return "error"; + if (!discoveryComplete) return "discovering"; + if (!wallet) return "missing"; + if (wallet.unlocked) return "unlocked"; + return "locked"; + }, [pubkey, error, discoveryComplete, wallet]); + // Subscribe to wallet events when pubkey is available useEffect(() => { - if (!pubkey) return; + if (!pubkey) { + setDiscoveryComplete(true); + return; + } + + // Reset discovery state when pubkey changes + setDiscoveryComplete(false); + setError(null); // Get all relevant relays const walletRelays = relays || []; const userOutboxes = outboxes || []; const allRelays = relaySet(walletRelays, userOutboxes); - if (allRelays.length === 0) return; + // If no relays, use some defaults for discovery + const queryRelays = + allRelays.length > 0 + ? allRelays + : [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nos.lol", + "wss://relay.nostr.band", + ]; // Subscribe to wallet-related events const observable = pool.subscription( - allRelays, + queryRelays, [ // Wallet events { @@ -86,10 +126,29 @@ export function useNip61Wallet() { { eventStore }, ); - const subscription = observable.subscribe(); + const subscription = observable.subscribe({ + next: (response) => { + // When we receive EOSE, discovery is complete + if (typeof response === "string" && response === "EOSE") { + setDiscoveryComplete(true); + } + }, + error: (err) => { + console.error("Wallet subscription error:", err); + setError(err instanceof Error ? err.message : "Subscription failed"); + setDiscoveryComplete(true); + }, + }); + subscriptionRef.current = subscription; + // Set a timeout for discovery in case no EOSE is received + const timeout = setTimeout(() => { + setDiscoveryComplete(true); + }, 10000); // 10 second timeout + return () => { + clearTimeout(timeout); subscription.unsubscribe(); subscriptionRef.current = null; }; @@ -123,14 +182,32 @@ export function useNip61Wallet() { return Object.values(balance).reduce((sum, amount) => sum + amount, 0); }, [balance]); + // Sort balances by amount (descending) + const sortedBalance = useMemo(() => { + if (!balance) return undefined; + const entries = Object.entries(balance); + entries.sort((a, b) => b[1] - a[1]); + return Object.fromEntries(entries); + }, [balance]); + + // Toggle sync setting + const toggleSyncEnabled = useCallback(() => { + setCashuWalletSyncEnabled(!appState.cashuWalletSyncEnabled); + }, [appState.cashuWalletSyncEnabled, setCashuWalletSyncEnabled]); + return { - // Wallet state + // State machine + walletState, + isDiscovering: walletState === "discovering", + isMissing: walletState === "missing", + isLocked: walletState === "locked", + isUnlocked: walletState === "unlocked", + + // Wallet instance wallet, - hasWallet: wallet !== undefined, - isUnlocked: wallet?.unlocked ?? false, // Balances - balance, // { [mint: string]: number } + balance: sortedBalance, // { [mint: string]: number } sorted by amount totalBalance, // Total across all mints // Data @@ -144,6 +221,10 @@ export function useNip61Wallet() { unlock, unlocking, + // Sync setting + syncEnabled: appState.cashuWalletSyncEnabled ?? false, + toggleSyncEnabled, + // Error state error, diff --git a/src/types/app.ts b/src/types/app.ts index 5b114e5..f00f2a4 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -140,4 +140,5 @@ export interface GrimoireState { }; nwcConnection?: NWCConnection; walletBalancesBlurred?: boolean; // Privacy: blur balances and transaction amounts + cashuWalletSyncEnabled?: boolean; // Keep NIP-60 Cashu wallet unlocked and synced }