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.
This commit is contained in:
Claude
2026-01-28 10:05:02 +00:00
parent 497ec07bd8
commit f26f49af11
5 changed files with 451 additions and 57 deletions

View File

@@ -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}
</span>
<span className="font-mono font-medium">
{blurred ? "✦✦✦" : amount.toLocaleString()} sats
{blurred ? "✦✦✦" : amount.toLocaleString()}
</span>
</div>
))}
@@ -71,6 +94,99 @@ function MintBalanceBreakdown({
);
}
/**
* Discovering state view - searching for wallet
*/
function DiscoveringView() {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Search className="size-8 text-muted-foreground animate-pulse" />
</div>
<h2 className="text-lg font-semibold mb-2">Searching for Wallet</h2>
<p className="text-sm text-muted-foreground max-w-sm">
Looking for your NIP-60 Cashu wallet on the Nostr network...
</p>
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Querying relays
</div>
</div>
);
}
/**
* Missing wallet state view
*/
function MissingWalletView() {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Wallet className="size-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-2">No Cashu Wallet Found</h2>
<p className="text-sm text-muted-foreground max-w-sm">
No NIP-60 Cashu wallet was found for your account. Wallet creation is
not yet supported in Grimoire.
</p>
</div>
);
}
/**
* Locked wallet state view
*/
function LockedWalletView({
onUnlock,
unlocking,
}: {
onUnlock: () => void;
unlocking: boolean;
}) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Lock className="size-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-2">Wallet Locked</h2>
<p className="text-sm text-muted-foreground max-w-sm mb-6">
Your Cashu wallet is encrypted. Unlock it to view your balance and
transaction history.
</p>
<Button onClick={onUnlock} disabled={unlocking}>
{unlocking ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Unlocking...
</>
) : (
<>
<Lock className="mr-2 size-4" />
Unlock Wallet
</>
)}
</Button>
</div>
);
}
/**
* Not logged in view
*/
function NotLoggedInView() {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
<Wallet className="size-8 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold mb-2">Not Logged In</h2>
<p className="text-sm text-muted-foreground max-w-sm">
Log in with a Nostr account to access your Cashu wallet.
</p>
</div>
);
}
/**
* 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<WalletHistory | null>(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={
<span className="text-sm text-muted-foreground">Locked</span>
}
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 (
<TransactionRow
key={entry.id}
direction="in" // TODO: Get from meta$
direction="in" // TODO: Determine from meta$
amount={0} // TODO: Get from meta$
blurred={state.walletBalancesBlurred ?? false}
blurred={blurred}
label={
<span className="text-sm">
{entry.event.created_at
? new Date(entry.event.created_at * 1000).toLocaleTimeString()
: "Transaction"}
{formatTimestamp(entry.event.created_at, "datetime")}
</span>
}
onClick={() => handleTransactionClick(entry)}
/>
);
},
[state.walletBalancesBlurred],
[blurred, handleTransactionClick],
);
// Not logged in
if (!isLoggedIn) {
return (
<NoWalletView
title="Not Logged In"
message="Log in with a Nostr account to access your Cashu wallet."
/>
<div className="h-full w-full flex flex-col bg-background text-foreground">
<NotLoggedInView />
</div>
);
}
// No wallet found
if (!hasWallet) {
// Discovering
if (isDiscovering) {
return (
<NoWalletView
title="No Cashu Wallet"
message="No NIP-60 Cashu wallet found for your account. Wallet creation is not yet supported in Grimoire."
/>
<div className="h-full w-full flex flex-col bg-background text-foreground">
<DiscoveringView />
</div>
);
}
// Wallet locked
if (!isUnlocked) {
// Missing wallet
if (isMissing) {
return (
<WalletLockedView
message="Your Cashu wallet is encrypted. Unlock it to view your balance and history."
loading={unlocking}
onUnlock={unlock}
/>
<div className="h-full w-full flex flex-col bg-background text-foreground">
<MissingWalletView />
</div>
);
}
// Locked wallet
if (isLocked) {
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
<LockedWalletView onUnlock={unlock} unlocking={unlocking} />
</div>
);
}
@@ -230,14 +364,14 @@ export default function Nip61WalletViewer() {
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors opacity-50 cursor-not-allowed"
aria-label="Settings (coming soon)"
disabled
onClick={() => setSettingsOpen(true)}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Settings"
>
<Settings className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Settings (coming soon)</TooltipContent>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
}
@@ -253,16 +387,12 @@ export default function Nip61WalletViewer() {
{/* Balance */}
<WalletBalance
balance={totalBalance}
blurred={state.walletBalancesBlurred ?? false}
blurred={blurred}
onToggleBlur={toggleWalletBalancesBlur}
label="sats"
/>
{/* Balance by mint */}
<MintBalanceBreakdown
balance={balance}
blurred={state.walletBalancesBlurred ?? false}
/>
<MintBalanceBreakdown balance={balance} blurred={blurred} />
{/* Send / Receive Buttons (Placeholders) */}
<div className="px-4 pb-3">
@@ -302,6 +432,170 @@ export default function Nip61WalletViewer() {
/>
</div>
</div>
{/* Transaction Detail Dialog */}
<Dialog
open={detailDialogOpen}
onOpenChange={(open) => {
setDetailDialogOpen(open);
if (!open) {
setShowRawTransaction(false);
setCopiedRawTx(false);
}
}}
>
<DialogContent className="max-w-md max-h-[70vh] flex flex-col">
<DialogHeader>
<DialogTitle>Transaction Details</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[calc(70vh-8rem)] pr-2">
{selectedTransaction && (
<div className="space-y-4">
<div className="flex items-center gap-3">
<ArrowDownLeft className="size-6 text-green-500" />
<div>
<p className="text-lg font-semibold">Transaction</p>
<p className="text-sm text-muted-foreground">
{formatTimestamp(
selectedTransaction.event.created_at,
"datetime",
)}
</p>
</div>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-muted-foreground">
Event ID
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.event.id}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Status
</Label>
<p className="text-sm">
{selectedTransaction.unlocked ? "Unlocked" : "Locked"}
</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Created At
</Label>
<p className="text-sm font-mono">
{formatTimestamp(
selectedTransaction.event.created_at,
"absolute",
)}
</p>
</div>
</div>
{/* Raw Transaction (expandable) */}
<div className="border-t border-border pt-4 mt-4">
<button
onClick={() => setShowRawTransaction(!showRawTransaction)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full"
>
{showRawTransaction ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
<span>Show Raw Event</span>
</button>
{showRawTransaction && (
<div className="mt-3 space-y-2">
<div className="relative">
<pre className="text-xs font-mono bg-muted p-3 rounded overflow-x-auto max-h-60 overflow-y-auto">
{JSON.stringify(selectedTransaction.event, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRawTx}
onCopy={() => {
navigator.clipboard.writeText(
JSON.stringify(
selectedTransaction.event,
null,
2,
),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}}
label="Copy event JSON"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDetailDialogOpen(false);
setShowRawTransaction(false);
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Settings Dialog */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Wallet Settings</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Keep Wallet Synced</Label>
<p className="text-xs text-muted-foreground">
Keep wallet unlocked and sync history automatically
</p>
</div>
<Switch
checked={syncEnabled}
onCheckedChange={toggleSyncEnabled}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Blur Balances</Label>
<p className="text-xs text-muted-foreground">
Hide balance amounts for privacy
</p>
</div>
<Switch
checked={blurred}
onCheckedChange={toggleWalletBalancesBlur}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -602,3 +602,13 @@ export const toggleWalletBalancesBlur = (
walletBalancesBlurred: !state.walletBalancesBlurred,
};
};
export const setCashuWalletSyncEnabled = (
state: GrimoireState,
enabled: boolean,
): GrimoireState => {
return {
...state,
cashuWalletSyncEnabled: enabled,
};
};

View File

@@ -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,
};
};

View File

@@ -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<string | null>(null);
const [discoveryComplete, setDiscoveryComplete] = useState(false);
const subscriptionRef = useRef<Subscription | null>(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,

View File

@@ -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
}