Files
grimoire/src/components/ConnectWalletDialog.tsx
Alejandro f3cc7779e3 feat: make NWC connections more robust and wallet state reactive (#227)
* fix(nwc): improve connection reliability and add health tracking

- Add connection status observable (disconnected/connecting/connected/error)
- Validate wallet connection on restore using support$ observable with 10s timeout
- Add notification subscription error recovery with exponential backoff (5 retries)
- Add retry logic for balance refresh (3 retries with backoff)
- Use library's support$ observable for wallet capabilities (cached by applesauce)
- Replace manual getInfo() calls with reactive support$ subscription
- Add visual connection status indicator in WalletViewer header
- Add reconnect button when connection is in error state
- Store network info in cached connection for display

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* feat(wallet): add copy NWC connection string button to header

Adds a copy button (Copy/Check icons) in the wallet header before the
refresh button that copies the NWC connection string to clipboard for
easy sharing or backup.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): use CopyCheck icon and fix transaction loading

- Change Check to CopyCheck icon for copy NWC string button
- Add walletMethods computed value that combines support$ observable
  with cached info fallback from initial connection
- Fix transaction history not loading because support$ waits for
  kind 13194 events which many NWC wallets don't publish
- The cached info from getInfo() RPC call is now used as fallback

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(nwc): simplify with derived state from observables

Production-ready refactor of NWC implementation:

nwc.ts:
- Add wallet$ observable for reactive wallet instance access
- Remove redundant subscribeToSupport() - only needed for validation
- Cleaner code organization with clear sections

useWallet.ts:
- All state derived from observables (no useState for wallet)
- Move walletMethods computation to hook (reusable)
- isConnected derived from connectionStatus
- Simplified from 240 to 170 lines

WalletViewer.tsx:
- Use walletMethods from hook instead of local useMemo
- Simpler connection state tracking via connectionStatus
- Remove redundant wallet variable from destructuring
- No color change on copy NWC string (per feedback)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): use useCopy hook for clipboard operations

Replace manual clipboard state management with useCopy hook:
- copyInvoice/invoiceCopied for generated invoice
- copyRawTx/rawTxCopied for transaction JSON
- copyNwc/nwcCopied for NWC connection string

Benefits:
- Cleaner code (removed manual setTimeout calls)
- Automatic timeout cleanup on unmount
- Consistent copy behavior across all clipboard operations

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* feat(wallet): implement lazy-loaded transactions observable

- Add shared wallet types (Transaction, TransactionsState) in src/types/wallet.ts
- Add transactionsState$ observable to NWC service for shared tx state
- Implement loadTransactions, loadMoreTransactions, and retryLoadTransactions
- Auto-refresh transactions on payment notifications
- Simplify WalletViewer to use observable state instead of local state
- Remove manual transaction loading logic from component

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): update balance observable on initial connect

- Call refreshBalance() in createWalletFromURI to fetch initial balance
- Update balance$ directly when ConnectWalletDialog gets balance
- Fixes issue where WalletViewer showed "-" after connecting while
  user menu showed correct balance (different data sources)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): use single data source for balance across UI

Remove fallback to nwcConnection.balance in user-menu - now both
WalletViewer and user-menu use balance$ observable as the single
source of truth for wallet balance.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): address code review issues and simplify user menu

- Fix memory leak: track retry timeout and clear on disconnect
- Add explicit WalletSupport type for support observable
- Add comments explaining balance refresh error handling behavior
- Add comment about restoreWallet not being awaited intentionally
- User menu now uses connectionStatus observable (shows connecting/error states)
- Remove wallet name display from user menu (simplifies UI)
- Remove unused walletServiceProfile hook and getWalletName function

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): extract WalletConnectionStatus component

- Create reusable WalletConnectionStatus component for connection indicator
- Remove rounded borders from indicator (now square)
- Export getConnectionStatusColor helper for custom usage
- Use component in both user-menu and WalletViewer
- Supports size (sm/md), showLabel, and className props

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 18:02:40 +01:00

200 lines
5.9 KiB
TypeScript

import { useState, useEffect } from "react";
import { toast } from "sonner";
import { Loader2, Wallet, AlertCircle, AlertTriangle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useGrimoire } from "@/core/state";
import { createWalletFromURI, balance$ } from "@/services/nwc";
interface ConnectWalletDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConnected?: () => void;
}
export default function ConnectWalletDialog({
open,
onOpenChange,
onConnected,
}: ConnectWalletDialogProps) {
const [connectionString, setConnectionString] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { setNWCConnection, updateNWCBalance, updateNWCInfo } = useGrimoire();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setConnectionString("");
setLoading(false);
setError(null);
}
}, [open]);
async function handleConnect() {
if (!connectionString.trim()) {
setError("Please enter a connection string");
return;
}
if (!connectionString.startsWith("nostr+walletconnect://")) {
setError(
"Invalid connection string. Must start with nostr+walletconnect://",
);
return;
}
setLoading(true);
setError(null);
try {
// Create wallet instance from connection string
const wallet = createWalletFromURI(connectionString);
// Test the connection by getting wallet info
const info = await wallet.getInfo();
// Get initial balance
let balance: number | undefined;
try {
const balanceResult = await wallet.getBalance();
balance = balanceResult.balance;
// Update the observable immediately so WalletViewer shows correct balance
balance$.next(balance);
} catch (err) {
console.warn("[NWC] Failed to get balance:", err);
// Balance is optional, continue anyway
}
// Get connection details from the wallet instance
const serialized = wallet.toJSON();
// Save connection to state
setNWCConnection({
service: serialized.service,
relays: serialized.relays,
secret: serialized.secret,
lud16: serialized.lud16,
balance,
info: {
alias: info.alias,
network: info.network,
methods: info.methods,
notifications: info.notifications,
},
});
// Update balance if we got it
if (balance !== undefined) {
updateNWCBalance(balance);
}
// Update info
updateNWCInfo({
alias: info.alias,
network: info.network,
methods: info.methods,
notifications: info.notifications,
});
// Show success toast
toast.success("Wallet Connected");
// Close dialog
onOpenChange(false);
// Call onConnected callback
onConnected?.();
} catch (err) {
console.error("Wallet connection error:", err);
setError(err instanceof Error ? err.message : "Failed to connect wallet");
toast.error("Failed to connect wallet");
} finally {
setLoading(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect Wallet</DialogTitle>
<DialogDescription>
Connect to a Nostr Wallet Connect (NWC) enabled Lightning wallet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Enter your wallet connection string. You can get this from your NWC
wallet provider.
</p>
{/* Security warning */}
<div className="flex items-start gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 p-3 text-sm">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-yellow-600 dark:text-yellow-500" />
<div className="space-y-1">
<p className="font-medium text-yellow-900 dark:text-yellow-200">
Security Notice
</p>
<p className="text-yellow-800 dark:text-yellow-300">
Your wallet connection will be stored in browser storage. Only
connect on trusted devices.
</p>
</div>
</div>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<label
htmlFor="connection-string"
className="text-sm font-medium leading-none"
>
Connection String
</label>
<Input
id="connection-string"
placeholder="nostr+walletconnect://..."
value={connectionString}
onChange={(e) => setConnectionString(e.target.value)}
disabled={loading}
autoComplete="off"
/>
</div>
<Button
onClick={handleConnect}
disabled={loading || !connectionString.trim()}
className="w-full"
>
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Connecting...
</>
) : (
<>
<Wallet className="mr-2 size-4" />
Connect Wallet
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}