mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 08:27:27 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -602,3 +602,13 @@ export const toggleWalletBalancesBlur = (
|
||||
walletBalancesBlurred: !state.walletBalancesBlurred,
|
||||
};
|
||||
};
|
||||
|
||||
export const setCashuWalletSyncEnabled = (
|
||||
state: GrimoireState,
|
||||
enabled: boolean,
|
||||
): GrimoireState => {
|
||||
return {
|
||||
...state,
|
||||
cashuWalletSyncEnabled: enabled,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user