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.
This commit is contained in:
Claude
2026-01-18 12:04:27 +00:00
parent 40585ea0f2
commit 4384ba69e5

View File

@@ -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<WalletInfo | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
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() {
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="size-5" />
<Wallet className="size-5" />
No Wallet Connected
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to
use this feature.
send and receive payments.
</p>
<Button
onClick={() => setConnectDialogOpen(true)}
className="w-full"
>
<Wallet className="mr-2 size-4" />
Connect Wallet
</Button>
</CardContent>
</Card>
<ConnectWalletDialog
open={connectDialogOpen}
onOpenChange={setConnectDialogOpen}
/>
</div>
);
}
return (
<div className="flex h-full flex-col">
{/* Header with Balance */}
<div className="border-b border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Wallet className="size-6 text-primary" />
<div>
<h2 className="text-lg font-semibold">
{walletInfo?.alias || "Lightning Wallet"}
</h2>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">
{walletInfo?.alias || "Lightning Wallet"}
</h2>
{walletInfo && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="space-y-2">
<div className="font-semibold">Capabilities:</div>
<div className="space-y-1">
{walletInfo.methods.map((method) => (
<div
key={method}
className="flex items-center gap-2 text-xs"
>
<Check className="size-3 text-green-500" />
<span className="font-mono">{method}</span>
</div>
))}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<p className="text-sm text-muted-foreground">
Balance: {formatSats(balance)} sats
</p>
@@ -274,316 +349,252 @@ export default function WalletViewer() {
</div>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1">
<div className="border-b border-border bg-card px-4">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">
<Info className="mr-2 size-4" />
Overview
</TabsTrigger>
<TabsTrigger value="send">
<Send className="mr-2 size-4" />
Send
</TabsTrigger>
<TabsTrigger value="receive">
<Download className="mr-2 size-4" />
Receive
</TabsTrigger>
{walletInfo?.methods.includes("list_transactions") && (
<TabsTrigger value="transactions">
<Zap className="mr-2 size-4" />
Transactions
</TabsTrigger>
)}
</TabsList>
</div>
{/* Main Content */}
<div className="flex-1 overflow-auto p-4">
<div className="mx-auto max-w-4xl space-y-4">
{/* Send and Receive Row */}
<div className="grid gap-4 md:grid-cols-2">
{/* Send Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Send className="size-4" />
Send Payment
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium">Invoice</label>
<Input
placeholder="lnbc..."
value={sendInvoice}
onChange={(e) => setSendInvoice(e.target.value)}
disabled={sending}
className="font-mono text-xs"
/>
</div>
<ScrollArea className="flex-1">
<div className="p-4">
<TabsContent value="overview" className="mt-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>Wallet Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{walletInfo?.alias && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Alias
</span>
<span className="text-sm font-medium">
{walletInfo.alias}
</span>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Amount (optional, millisats)
</label>
<Input
type="number"
placeholder="Leave empty for invoice amount"
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
disabled={sending}
/>
</div>
<Button
onClick={handleSendPayment}
disabled={sending || !sendInvoice.trim()}
className="w-full"
>
{sending ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 size-4" />
Send
</>
)}
{walletInfo?.network && (
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Network
</span>
<span className="text-sm font-medium capitalize">
{walletInfo.network}
</span>
</div>
</Button>
</CardContent>
</Card>
{/* Receive Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Download className="size-4" />
Receive Payment
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium">Amount (sats)</label>
<Input
type="number"
placeholder="1000"
value={receiveAmount}
onChange={(e) => setReceiveAmount(e.target.value)}
disabled={generating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Description (optional)
</label>
<Input
placeholder="What's this for?"
value={receiveDescription}
onChange={(e) => setReceiveDescription(e.target.value)}
disabled={generating}
/>
</div>
<Button
onClick={handleGenerateInvoice}
disabled={generating || !receiveAmount}
className="w-full"
>
{generating ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Generating...
</>
) : (
<>
<Download className="mr-2 size-4" />
Generate Invoice
</>
)}
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">
Balance
</span>
<span className="text-sm font-medium">
{formatSats(balance)} sats
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Capabilities</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{walletInfo?.methods.map((method) => (
<div
key={method}
className="flex items-center gap-2 text-sm"
>
<Check className="size-4 text-green-500" />
<span className="font-mono">{method}</span>
</div>
))}
</div>
</CardContent>
</Card>
{walletInfo?.notifications &&
walletInfo.notifications.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{walletInfo.notifications.map((notification) => (
<div
key={notification}
className="flex items-center gap-2 text-sm"
>
<Check className="size-4 text-green-500" />
<span className="font-mono">{notification}</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="send" className="mt-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>Send Payment</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Invoice</label>
<Input
placeholder="lnbc..."
value={sendInvoice}
onChange={(e) => setSendInvoice(e.target.value)}
disabled={sending}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Amount (optional, in millisats)
</label>
<Input
type="number"
placeholder="Leave empty for invoice amount"
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
disabled={sending}
/>
</div>
<Button
onClick={handleSendPayment}
disabled={sending || !sendInvoice.trim()}
className="w-full"
>
{sending ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 size-4" />
Send Payment
</>
)}
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="receive" className="mt-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>Receive Payment</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Amount (sats)</label>
<Input
type="number"
placeholder="1000"
value={receiveAmount}
onChange={(e) => setReceiveAmount(e.target.value)}
disabled={generating}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Description (optional)
</label>
<Input
placeholder="What's this for?"
value={receiveDescription}
onChange={(e) => setReceiveDescription(e.target.value)}
disabled={generating}
/>
</div>
<Button
onClick={handleGenerateInvoice}
disabled={generating || !receiveAmount}
className="w-full"
>
{generating ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Generating...
</>
) : (
<>
<Download className="mr-2 size-4" />
Generate Invoice
</>
)}
</Button>
{generatedInvoice && (
<div className="space-y-4 rounded-lg border border-border bg-muted/50 p-4">
<div className="flex justify-center">
{invoiceQR && (
<img
src={invoiceQR}
alt="Invoice QR Code"
className="size-64"
/>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Invoice</label>
<Button
variant="ghost"
size="sm"
onClick={handleCopyInvoice}
>
{copied ? (
<>
<Check className="mr-2 size-4" />
Copied
</>
) : (
<>
<Copy className="mr-2 size-4" />
Copy
</>
)}
</Button>
</div>
<div className="break-all rounded bg-background p-3 font-mono text-xs">
{generatedInvoice}
</div>
</div>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{walletInfo?.methods.includes("list_transactions") && (
<TabsContent value="transactions" className="mt-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>Transaction History</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
) : transactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No transactions found
</p>
) : (
<div className="space-y-3">
{transactions.map((tx, index) => (
<div
key={tx.payment_hash || index}
className="flex items-start justify-between rounded-lg border border-border p-3"
>
<div className="flex items-start gap-3">
{tx.type === "incoming" ? (
<ArrowDownLeft className="mt-0.5 size-5 text-green-500" />
) : (
<ArrowUpRight className="mt-0.5 size-5 text-red-500" />
)}
<div className="space-y-1">
<p className="text-sm font-medium">
{tx.type === "incoming" ? "Received" : "Sent"}
</p>
{tx.description && (
<p className="text-xs text-muted-foreground">
{tx.description}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatDate(tx.created_at)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold">
{tx.type === "incoming" ? "+" : "-"}
{formatSats(tx.amount)} sats
</p>
{tx.fees_paid !== undefined &&
tx.fees_paid > 0 && (
<p className="text-xs text-muted-foreground">
Fee: {formatSats(tx.fees_paid)} sats
</p>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
)}
</Button>
</CardContent>
</Card>
</div>
</ScrollArea>
</Tabs>
{/* Generated Invoice Display */}
{generatedInvoice && (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex justify-center">
{invoiceQR && (
<img
src={invoiceQR}
alt="Invoice QR Code"
className="size-64 rounded-lg border border-border"
/>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Invoice</label>
<Button
variant="ghost"
size="sm"
onClick={handleCopyInvoice}
>
{copied ? (
<>
<Check className="mr-2 size-4" />
Copied
</>
) : (
<>
<Copy className="mr-2 size-4" />
Copy
</>
)}
</Button>
</div>
<div className="break-all rounded bg-muted p-3 font-mono text-xs">
{generatedInvoice}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Transactions List */}
{walletInfo?.methods.includes("list_transactions") && (
<Card>
<CardHeader>
<CardTitle className="text-base">Transaction History</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex justify-center py-8">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
) : transactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No transactions found
</p>
) : (
<div className="h-96">
<Virtuoso
data={transactions}
endReached={loadMoreTransactions}
itemContent={(index, tx) => (
<div
key={tx.payment_hash || index}
className="flex items-start justify-between border-b border-border py-3 last:border-0"
>
<div className="flex items-start gap-3">
{tx.type === "incoming" ? (
<ArrowDownLeft className="mt-0.5 size-5 text-green-500" />
) : (
<ArrowUpRight className="mt-0.5 size-5 text-red-500" />
)}
<div className="space-y-1">
<p className="text-sm font-medium">
{tx.type === "incoming" ? "Received" : "Sent"}
</p>
{tx.description && (
<p className="text-xs text-muted-foreground">
{tx.description}
</p>
)}
<p className="text-xs text-muted-foreground">
{formatDate(tx.created_at)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold">
{tx.type === "incoming" ? "+" : "-"}
{formatSats(tx.amount)} sats
</p>
{tx.fees_paid !== undefined && tx.fees_paid > 0 && (
<p className="text-xs text-muted-foreground">
Fee: {formatSats(tx.fees_paid)} sats
</p>
)}
</div>
</div>
)}
components={{
Footer: () =>
loadingMore ? (
<div className="flex justify-center py-4">
<RefreshCw className="size-5 animate-spin text-muted-foreground" />
</div>
) : !hasMore && transactions.length > 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
No more transactions
</div>
) : null,
}}
/>
</div>
)}
</CardContent>
</Card>
)}
{/* Disconnect Button */}
<div className="flex justify-center pb-4">
<Button
variant="outline"
onClick={handleDisconnect}
className="w-full max-w-xs"
>
<LogOut className="mr-2 size-4" />
Disconnect Wallet
</Button>
</div>
</div>
</div>
</div>
);
}