From 4384ba69e5a0c4d80daa40b25ac81c09636234f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 12:04:27 +0000 Subject: [PATCH] refactor: redesign wallet UI to single-view layout with virtualized transactions Converts the tabbed wallet interface to a conventional single-view layout with improved UX and performance optimizations. **Layout Changes:** - Removed tabs in favor of single-page layout - Balance header at top with wallet name and refresh button - Side-by-side Send/Receive cards for quick access - Transaction history below with virtualized scrolling - Disconnect button at bottom of page **New Features:** - Connect Wallet button when no wallet is connected (opens dialog in-app) - Wallet capabilities shown in tooltip on info icon - Virtualized transaction list using react-virtuoso - Batched transaction loading (20 per batch) - Automatic "load more" when scrolling to bottom - Loading states for initial load and pagination - "No more transactions" message when exhausted **Performance Improvements:** - Virtualized list rendering for smooth scrolling with many transactions - Only renders visible transactions in viewport - Lazy loads additional batches on demand - Reduced initial load to 20 transactions instead of 50 **UX Improvements:** - More conventional wallet UI pattern - Send/Receive always visible (no tab switching) - QR code and invoice appear inline when generated - Info icon with tooltip for capabilities (cleaner than full card) - Disconnect option always accessible at bottom **Technical Details:** - Fixed transaction loading race condition with separate useEffect - Proper dependency tracking for loadMoreTransactions callback - Footer component in Virtuoso for loading/end states - Responsive grid layout for Send/Receive cards All tests passing. Build verified. --- src/components/WalletViewer.tsx | 693 ++++++++++++++++---------------- 1 file changed, 352 insertions(+), 341 deletions(-) diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index 065c4a8..1e2104c 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -2,17 +2,10 @@ * WalletViewer Component * * Displays NWC wallet information and provides UI for wallet operations. - * Dynamically shows features based on wallet capabilities (methods). - * - * Features: - * - Balance display with real-time updates - * - Transaction history (if list_transactions supported) - * - Send/Receive Lightning payments - * - Budget information (if get_budget supported) - * - Wallet info and capabilities + * Single-view layout with balance, send/receive, and transaction history. */ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { toast } from "sonner"; import { Wallet, @@ -20,20 +13,25 @@ import { Send, Download, Info, - AlertCircle, Copy, Check, - Zap, ArrowUpRight, ArrowDownLeft, + LogOut, } from "lucide-react"; +import { Virtuoso } from "react-virtuoso"; import { useWallet } from "@/hooks/useWallet"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ScrollArea } from "@/components/ui/scroll-area"; import QRCode from "qrcode"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import ConnectWalletDialog from "./ConnectWalletDialog"; interface Transaction { type: "incoming" | "outgoing"; @@ -61,6 +59,8 @@ interface WalletInfo { notifications?: string[]; } +const BATCH_SIZE = 20; + export default function WalletViewer() { const { wallet, @@ -71,19 +71,22 @@ export default function WalletViewer() { listTransactions, makeInvoice, payInvoice, + disconnect, } = useWallet(); const [walletInfo, setWalletInfo] = useState(null); const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(false); - const [activeTab, setActiveTab] = useState("overview"); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const [connectDialogOpen, setConnectDialogOpen] = useState(false); - // Send tab state + // Send state const [sendInvoice, setSendInvoice] = useState(""); const [sendAmount, setSendAmount] = useState(""); const [sending, setSending] = useState(false); - // Receive tab state + // Receive state const [receiveAmount, setReceiveAmount] = useState(""); const [receiveDescription, setReceiveDescription] = useState(""); const [generatedInvoice, setGeneratedInvoice] = useState(""); @@ -98,15 +101,12 @@ export default function WalletViewer() { } }, [isConnected]); - // Load transactions when switching to that tab + // Load transactions when wallet info is available useEffect(() => { - if ( - activeTab === "transactions" && - walletInfo?.methods.includes("list_transactions") - ) { - loadTransactions(); + if (walletInfo?.methods.includes("list_transactions")) { + loadInitialTransactions(); } - }, [activeTab, walletInfo]); + }, [walletInfo]); async function loadWalletInfo() { try { @@ -118,13 +118,16 @@ export default function WalletViewer() { } } - async function loadTransactions() { - if (!walletInfo?.methods.includes("list_transactions")) return; - + async function loadInitialTransactions() { setLoading(true); try { - const result = await listTransactions({ limit: 50 }); - setTransactions(result.transactions || []); + const result = await listTransactions({ + limit: BATCH_SIZE, + offset: 0, + }); + const txs = result.transactions || []; + setTransactions(txs); + setHasMore(txs.length === BATCH_SIZE); } catch (error) { console.error("Failed to load transactions:", error); toast.error("Failed to load transactions"); @@ -133,6 +136,32 @@ export default function WalletViewer() { } } + const loadMoreTransactions = useCallback(async () => { + if ( + !walletInfo?.methods.includes("list_transactions") || + !hasMore || + loadingMore + ) { + return; + } + + setLoadingMore(true); + try { + const result = await listTransactions({ + limit: BATCH_SIZE, + offset: transactions.length, + }); + const newTxs = result.transactions || []; + setTransactions((prev) => [...prev, ...newTxs]); + setHasMore(newTxs.length === BATCH_SIZE); + } catch (error) { + console.error("Failed to load more transactions:", error); + toast.error("Failed to load more transactions"); + } finally { + setLoadingMore(false); + } + }, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]); + async function handleRefreshBalance() { setLoading(true); try { @@ -159,6 +188,8 @@ export default function WalletViewer() { toast.success("Payment sent successfully"); setSendInvoice(""); setSendAmount(""); + // Reload transactions + loadInitialTransactions(); } catch (error) { console.error("Payment failed:", error); toast.error(error instanceof Error ? error.message : "Payment failed"); @@ -188,7 +219,7 @@ export default function WalletViewer() { // Generate QR code const qrDataUrl = await QRCode.toDataURL(result.invoice.toUpperCase(), { - width: 300, + width: 256, margin: 2, color: { dark: "#000000", @@ -215,6 +246,11 @@ export default function WalletViewer() { setTimeout(() => setCopied(false), 2000); } + function handleDisconnect() { + disconnect(); + toast.success("Wallet disconnected"); + } + function formatSats(millisats: number | undefined): string { if (millisats === undefined) return "—"; return Math.floor(millisats / 1000).toLocaleString(); @@ -230,31 +266,70 @@ export default function WalletViewer() { - + No Wallet Connected - +

Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to - use this feature. + send and receive payments.

+
+ ); } return (
+ {/* Header with Balance */}
-

- {walletInfo?.alias || "Lightning Wallet"} -

+
+

+ {walletInfo?.alias || "Lightning Wallet"} +

+ {walletInfo && ( + + + + + + +
+
Capabilities:
+
+ {walletInfo.methods.map((method) => ( +
+ + {method} +
+ ))} +
+
+
+
+
+ )} +

Balance: {formatSats(balance)} sats

@@ -274,316 +349,252 @@ export default function WalletViewer() {
- -
- - - - Overview - - - - Send - - - - Receive - - {walletInfo?.methods.includes("list_transactions") && ( - - - Transactions - - )} - -
+ {/* Main Content */} +
+
+ {/* Send and Receive Row */} +
+ {/* Send Card */} + + + + + Send Payment + + + +
+ + setSendInvoice(e.target.value)} + disabled={sending} + className="font-mono text-xs" + /> +
- -
- - - - Wallet Information - - - {walletInfo?.alias && ( -
- - Alias - - - {walletInfo.alias} - -
+
+ + setSendAmount(e.target.value)} + disabled={sending} + /> +
+ + +
+
+ + {/* Receive Card */} + + + + + Receive Payment + + + +
+ + setReceiveAmount(e.target.value)} + disabled={generating} + /> +
+ +
+ + setReceiveDescription(e.target.value)} + disabled={generating} + /> +
+ + -
-
-
- - - - - Receive Payment - - -
- - setReceiveAmount(e.target.value)} - disabled={generating} - /> -
- -
- - setReceiveDescription(e.target.value)} - disabled={generating} - /> -
- - - - {generatedInvoice && ( -
-
- {invoiceQR && ( - Invoice QR Code - )} -
- -
-
- - -
-
- {generatedInvoice} -
-
-
- )} -
-
-
- - {walletInfo?.methods.includes("list_transactions") && ( - - - - Transaction History - - - {loading ? ( -
- -
- ) : transactions.length === 0 ? ( -

- No transactions found -

- ) : ( -
- {transactions.map((tx, index) => ( -
-
- {tx.type === "incoming" ? ( - - ) : ( - - )} -
-

- {tx.type === "incoming" ? "Received" : "Sent"} -

- {tx.description && ( -

- {tx.description} -

- )} -

- {formatDate(tx.created_at)} -

-
-
-
-

- {tx.type === "incoming" ? "+" : "-"} - {formatSats(tx.amount)} sats -

- {tx.fees_paid !== undefined && - tx.fees_paid > 0 && ( -

- Fee: {formatSats(tx.fees_paid)} sats -

- )} -
-
- ))} -
- )} -
-
-
- )} + + +
-
- + + {/* Generated Invoice Display */} + {generatedInvoice && ( + + +
+
+ {invoiceQR && ( + Invoice QR Code + )} +
+ +
+
+ + +
+
+ {generatedInvoice} +
+
+
+
+
+ )} + + {/* Transactions List */} + {walletInfo?.methods.includes("list_transactions") && ( + + + Transaction History + + + {loading ? ( +
+ +
+ ) : transactions.length === 0 ? ( +

+ No transactions found +

+ ) : ( +
+ ( +
+
+ {tx.type === "incoming" ? ( + + ) : ( + + )} +
+

+ {tx.type === "incoming" ? "Received" : "Sent"} +

+ {tx.description && ( +

+ {tx.description} +

+ )} +

+ {formatDate(tx.created_at)} +

+
+
+
+

+ {tx.type === "incoming" ? "+" : "-"} + {formatSats(tx.amount)} sats +

+ {tx.fees_paid !== undefined && tx.fees_paid > 0 && ( +

+ Fee: {formatSats(tx.fees_paid)} sats +

+ )} +
+
+ )} + components={{ + Footer: () => + loadingMore ? ( +
+ +
+ ) : !hasMore && transactions.length > 0 ? ( +
+ No more transactions +
+ ) : null, + }} + /> +
+ )} +
+
+ )} + + {/* Disconnect Button */} +
+ +
+
+
); }