Files
grimoire/src/components/WalletViewer.tsx
Alejandro 9f6e524ea9 feat: add tap-to-blur privacy feature for wallet balances (#143)
* feat: add tap-to-blur privacy feature for wallet balances

Implement privacy toggle for wallet balances and transaction amounts.
Tapping any balance display toggles a global blur effect across all
wallet UIs. Persisted to localStorage for consistent privacy.

- Add walletBalancesBlurred state to GrimoireState
- Add toggleWalletBalancesBlur pure function in core/logic
- Make big balance in WalletViewer clickable with eye icon indicator
- Apply blur to all transaction amounts in list and detail views
- Add blur to send/receive dialog amounts
- Make balance in user menu wallet info clickable with eye icon
- Apply blur to balance in dropdown menu item

UX matches common financial app pattern: tap balance → blur on/off

* refactor: replace blur with fixed-width placeholders for privacy

Prevent balance size information leakage by using fixed-width
placeholder characters instead of blur effect. A blurred "1000000"
would still reveal it's a large balance vs "100" even when blurred.

Changes:
- Replace blur-sm class with conditional placeholder text
- Use "••••••" for main balances
- Use "••••" for transaction amounts in lists
- Use "•••••• sats" for detailed amounts with unit
- Use "•••• sats" for smaller amounts like fees

Security improvement: No information about balance size is leaked
when privacy mode is enabled. All hidden amounts appear identical.

* refactor: improve privacy UX with stars and clearer send flow

Three UX improvements to the wallet privacy feature:

1. Don't hide amounts in send confirmation dialog
   - Users need to verify invoice amounts before sending
   - Privacy mode now only affects viewing, not sending

2. Replace bullet placeholders (••••) with stars (✦✦✦✦)
   - More visually distinct and recognizable as privacy indicator
   - Unicode BLACK FOUR POINTED STAR (U+2726)
   - Better matches common "redacted" aesthetic

3. Reduce eye icon sizes for subtler presence
   - Main balance: size-6 → size-5
   - Wallet info dialog: size-3.5 → size-3
   - Smaller icons feel less intrusive

Result: Clearer privacy state, safer payment flow, better aesthetics.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-18 23:22:00 +01:00

1637 lines
53 KiB
TypeScript

/**
* WalletViewer Component
*
* Displays NWC wallet information and provides UI for wallet operations.
* Layout: Header → Big centered balance → Send/Receive buttons → Transaction list
*/
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { toast } from "sonner";
import {
Wallet,
RefreshCw,
Send,
Download,
Info,
Copy,
Check,
ArrowUpRight,
ArrowDownLeft,
LogOut,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useWallet } from "@/hooks/useWallet";
import { useGrimoire } from "@/core/state";
import { decode as decodeBolt11 } from "light-bolt11-decoder";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Label } from "@/components/ui/label";
import QRCode from "qrcode";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import ConnectWalletDialog from "./ConnectWalletDialog";
import { RelayLink } from "@/components/nostr/RelayLink";
import { parseZapRequest } from "@/lib/wallet-utils";
import { Zap } from "lucide-react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { KindRenderer } from "./nostr/kinds";
import { RichText } from "./nostr/RichText";
import { UserName } from "./nostr/UserName";
import { CodeCopyButton } from "./CodeCopyButton";
interface Transaction {
type: "incoming" | "outgoing";
invoice?: string;
description?: string;
description_hash?: string;
preimage?: string;
payment_hash?: string;
amount: number;
fees_paid?: number;
created_at: number;
expires_at?: number;
settled_at?: number;
metadata?: Record<string, any>;
}
interface WalletInfo {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
block_height?: number;
block_hash?: string;
methods: string[];
notifications?: string[];
}
interface InvoiceDetails {
amount?: number;
description?: string;
timestamp?: number;
expiry?: number;
}
const BATCH_SIZE = 20;
const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds
/**
* Helper: Format timestamp as a readable day marker
*/
function formatDayMarker(timestamp: number): string {
const date = new Date(timestamp * 1000);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// Reset time parts for comparison
const dateOnly = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const todayOnly = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const yesterdayOnly = new Date(
yesterday.getFullYear(),
yesterday.getMonth(),
yesterday.getDate(),
);
if (dateOnly.getTime() === todayOnly.getTime()) {
return "Today";
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
return "Yesterday";
} else {
// Format as "Jan 15" (short month, no year, respects locale)
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
}
/**
* Helper: Check if two timestamps are on different days
*/
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
const date1 = new Date(timestamp1 * 1000);
const date2 = new Date(timestamp2 * 1000);
return (
date1.getFullYear() !== date2.getFullYear() ||
date1.getMonth() !== date2.getMonth() ||
date1.getDate() !== date2.getDate()
);
}
/**
* Parse a BOLT11 invoice to extract details with security validations
*/
function parseInvoice(invoice: string): InvoiceDetails | null {
try {
// Validate format
if (!invoice.toLowerCase().startsWith("ln")) {
throw new Error("Invalid invoice format");
}
const decoded = decodeBolt11(invoice);
// Extract amount (in millisats)
const amountSection = decoded.sections.find((s) => s.name === "amount");
const amount =
amountSection && "value" in amountSection
? Number(amountSection.value) / 1000 // Convert to sats
: undefined;
// Validate amount is reasonable (< 21M BTC in sats = 2.1 quadrillion msats)
if (amount && amount > 2100000000000000) {
throw new Error("Amount exceeds maximum possible value");
}
// Extract description
const descSection = decoded.sections.find((s) => s.name === "description");
const description =
descSection && "value" in descSection
? String(descSection.value)
: undefined;
// Extract timestamp
const timestampSection = decoded.sections.find(
(s) => s.name === "timestamp",
);
const timestamp =
timestampSection && "value" in timestampSection
? Number(timestampSection.value)
: undefined;
// Extract expiry
const expiry = decoded.expiry;
// Check if invoice is expired
if (timestamp && expiry) {
const expiresAt = timestamp + expiry;
const nowSeconds = Math.floor(Date.now() / 1000);
if (expiresAt < nowSeconds) {
throw new Error("Invoice has expired");
}
}
return {
amount,
description,
timestamp,
expiry,
};
} catch (error) {
console.error("Failed to parse invoice:", error);
const message =
error instanceof Error ? error.message : "Invalid invoice format";
toast.error(`Invalid invoice: ${message}`);
return null;
}
}
/**
* Helper to parse coordinate string (kind:pubkey:identifier)
*/
function parseAddressCoordinate(
coordinate: string,
): { kind: number; pubkey: string; identifier: string } | null {
const parts = coordinate.split(":");
if (parts.length !== 3) return null;
const kind = parseInt(parts[0], 10);
if (isNaN(kind)) return null;
return {
kind,
pubkey: parts[1],
identifier: parts[2],
};
}
/**
* Component to render zap details in the transaction detail dialog
*/
function ZapTransactionDetail({ transaction }: { transaction: Transaction }) {
const zapInfo = parseZapRequest(transaction);
// Parse address coordinate if present (format: kind:pubkey:identifier)
const addressPointer = zapInfo?.zappedEventAddress
? parseAddressCoordinate(zapInfo.zappedEventAddress)
: null;
// Call hooks unconditionally (before early return)
const zappedEvent = useNostrEvent(
zapInfo?.zappedEventId
? { id: zapInfo.zappedEventId }
: addressPointer || undefined,
);
// Early return after hooks
if (!zapInfo) return null;
return (
<div className="space-y-4 pt-4 border-t border-border">
{/* Zap sender */}
<div>
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<Zap className="size-3 fill-zap text-zap" />
Zap From
</Label>
<div className="mt-1">
<UserName pubkey={zapInfo.sender} />
</div>
</div>
{/* Zap message */}
{zapInfo.message && (
<div>
<Label className="text-xs text-muted-foreground">Zap Message</Label>
<div className="mt-1 text-sm">
<RichText
content={zapInfo.message}
event={zapInfo.zapRequestEvent}
/>
</div>
</div>
)}
{/* Zapped event */}
{zappedEvent && (
<div>
<Label className="text-xs text-muted-foreground">Zapped Post</Label>
<div className="mt-1 border border-muted rounded-md overflow-hidden">
<KindRenderer event={zappedEvent} />
</div>
</div>
)}
{/* Loading state for zapped event */}
{(zapInfo.zappedEventId || zapInfo.zappedEventAddress) &&
!zappedEvent && (
<div>
<Label className="text-xs text-muted-foreground">Zapped Post</Label>
<div className="mt-1 text-xs text-muted-foreground">
Loading event...
</div>
</div>
)}
</div>
);
}
/**
* Component to render a transaction row with zap detection
*/
function TransactionLabel({ transaction }: { transaction: Transaction }) {
const zapInfo = parseZapRequest(transaction);
// Not a zap - use original description or default label
if (!zapInfo) {
return (
<span className="text-sm truncate">
{transaction.description ||
(transaction.type === "incoming" ? "Received" : "Payment")}
</span>
);
}
// It's a zap! Show username + message on one line
return (
<div className="flex items-center gap-2 min-w-0">
<Zap className="size-3.5 flex-shrink-0 fill-zap text-zap" />
<div className="text-sm min-w-0 flex items-center gap-2">
<UserName pubkey={zapInfo.sender} className="flex-shrink-0" />
{zapInfo.message && (
<span className="line-clamp-1 min-w-0">
<RichText
content={zapInfo.message}
event={zapInfo.zapRequestEvent}
/>
</span>
)}
</div>
</div>
);
}
export default function WalletViewer() {
const {
state,
disconnectNWC: disconnectNWCFromState,
toggleWalletBalancesBlur,
} = useGrimoire();
const {
wallet,
balance,
isConnected,
getInfo,
refreshBalance,
listTransactions,
makeInvoice,
payInvoice,
lookupInvoice,
disconnect,
} = useWallet();
const [walletInfo, setWalletInfo] = useState<WalletInfo | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false);
const [txLoadAttempted, setTxLoadAttempted] = useState(false);
const [txLoadFailed, setTxLoadFailed] = useState(false);
// Use refs to track loading attempts without causing re-renders
const walletInfoLoadedRef = useRef(false);
const lastConnectionStateRef = useRef(isConnected);
const lastBalanceRefreshRef = useRef(0);
const lastTxLoadRef = useRef(0);
// Send dialog state
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [sendInvoice, setSendInvoice] = useState("");
const [sendAmount, setSendAmount] = useState("");
const [sendStep, setSendStep] = useState<"input" | "confirm">("input");
const [invoiceDetails, setInvoiceDetails] = useState<InvoiceDetails | null>(
null,
);
const [sending, setSending] = useState(false);
// Receive dialog state
const [receiveDialogOpen, setReceiveDialogOpen] = useState(false);
const [receiveAmount, setReceiveAmount] = useState("");
const [receiveDescription, setReceiveDescription] = useState("");
const [generatedInvoice, setGeneratedInvoice] = useState("");
const [generatedPaymentHash, setGeneratedPaymentHash] = useState("");
const [invoiceQR, setInvoiceQR] = useState("");
const [generating, setGenerating] = useState(false);
const [copied, setCopied] = useState(false);
const [checkingPayment, setCheckingPayment] = useState(false);
// Transaction detail dialog state
const [selectedTransaction, setSelectedTransaction] =
useState<Transaction | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [showRawTransaction, setShowRawTransaction] = useState(false);
const [copiedRawTx, setCopiedRawTx] = useState(false);
// Load wallet info when connected
useEffect(() => {
// Detect connection state changes
if (isConnected !== lastConnectionStateRef.current) {
lastConnectionStateRef.current = isConnected;
walletInfoLoadedRef.current = false;
if (isConnected) {
// Reset transaction loading flags when wallet connects
setTxLoadAttempted(false);
setTxLoadFailed(false);
setTransactions([]);
setWalletInfo(null);
} else {
// Clear all state when wallet disconnects
setTxLoadAttempted(false);
setTxLoadFailed(false);
setTransactions([]);
setWalletInfo(null);
setLoading(false);
setLoadingMore(false);
setHasMore(true);
}
}
// Load wallet info if connected and not yet loaded
if (isConnected && !walletInfoLoadedRef.current) {
walletInfoLoadedRef.current = true;
getInfo()
.then((info) => setWalletInfo(info))
.catch((error) => {
console.error("Failed to load wallet info:", error);
toast.error("Failed to load wallet info");
walletInfoLoadedRef.current = false; // Allow retry
});
}
}, [isConnected, getInfo]);
// Load transactions when wallet info is available (only once)
useEffect(() => {
if (
walletInfo?.methods.includes("list_transactions") &&
!txLoadAttempted &&
!loading
) {
setLoading(true);
setTxLoadAttempted(true);
listTransactions({
limit: BATCH_SIZE,
offset: 0,
})
.then((result) => {
const txs = result.transactions || [];
setTransactions(txs);
setHasMore(txs.length === BATCH_SIZE);
setTxLoadFailed(false);
})
.catch((error) => {
console.error("Failed to load transactions:", error);
setTxLoadFailed(true);
})
.finally(() => {
setLoading(false);
});
}
}, [walletInfo, txLoadAttempted, loading, listTransactions]);
// Helper to reload transactions (resets flags to trigger reload)
const reloadTransactions = useCallback(() => {
// Rate limiting: minimum 5 seconds between transaction reloads
const now = Date.now();
const timeSinceLastLoad = now - lastTxLoadRef.current;
if (timeSinceLastLoad < 5000) {
const waitTime = Math.ceil((5000 - timeSinceLastLoad) / 1000);
toast.warning(`Please wait ${waitTime}s before reloading transactions`);
return;
}
lastTxLoadRef.current = now;
setTxLoadAttempted(false);
setTxLoadFailed(false);
}, []);
useEffect(() => {
if (!generatedPaymentHash || !receiveDialogOpen) return;
const checkPayment = async () => {
if (!walletInfo?.methods.includes("lookup_invoice")) return;
setCheckingPayment(true);
try {
const result = await lookupInvoice(generatedPaymentHash);
// If invoice is settled, close dialog and refresh
if (result.settled_at) {
toast.success("Payment received!");
setReceiveDialogOpen(false);
resetReceiveDialog();
// Reload transactions
reloadTransactions();
}
} catch {
// Ignore errors, will retry
} finally {
setCheckingPayment(false);
}
};
const intervalId = setInterval(checkPayment, PAYMENT_CHECK_INTERVAL);
return () => clearInterval(intervalId);
}, [
generatedPaymentHash,
receiveDialogOpen,
walletInfo,
lookupInvoice,
reloadTransactions,
]);
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() {
// Rate limiting: minimum 2 seconds between refreshes
const now = Date.now();
const timeSinceLastRefresh = now - lastBalanceRefreshRef.current;
if (timeSinceLastRefresh < 2000) {
const waitTime = Math.ceil((2000 - timeSinceLastRefresh) / 1000);
toast.warning(`Please wait ${waitTime}s before refreshing again`);
return;
}
lastBalanceRefreshRef.current = now;
setLoading(true);
try {
await refreshBalance();
toast.success("Balance refreshed");
} catch (error) {
console.error("Failed to refresh balance:", error);
toast.error("Failed to refresh balance");
} finally {
setLoading(false);
}
}
async function handleConfirmSend() {
if (!sendInvoice.trim()) {
toast.error("Please enter an invoice or Lightning address");
return;
}
const input = sendInvoice.trim();
// Check if it's a Lightning address
if (input.includes("@") && !input.toLowerCase().startsWith("ln")) {
// Lightning address - requires amount
if (!sendAmount || parseInt(sendAmount) <= 0) {
toast.error("Please enter an amount for Lightning address payments");
return;
}
setSending(true);
try {
const amountSats = parseInt(sendAmount); // Amount is in sats
const invoice = await resolveLightningAddress(input, amountSats);
// Update the invoice field with the resolved invoice
setSendInvoice(invoice);
// Parse the resolved invoice
const details = parseInvoice(invoice);
if (!details) {
throw new Error("Failed to parse resolved invoice");
}
setInvoiceDetails(details);
setSendStep("confirm");
} catch (error) {
console.error("Failed to resolve Lightning address:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to resolve Lightning address",
);
} finally {
setSending(false);
}
return;
}
// Parse BOLT11 invoice
const details = parseInvoice(input);
if (!details) {
toast.error("Invalid Lightning invoice");
return;
}
setInvoiceDetails(details);
setSendStep("confirm");
}
// Auto-proceed to confirm when valid invoice with amount is entered
function handleInvoiceChange(value: string) {
setSendInvoice(value);
// If it looks like an invoice, try to parse it
if (value.toLowerCase().startsWith("ln")) {
const details = parseInvoice(value);
// Only auto-proceed if invoice has an amount
if (details && details.amount !== undefined) {
setInvoiceDetails(details);
setSendStep("confirm");
}
}
}
// Resolve Lightning address to invoice with security validations
async function resolveLightningAddress(address: string, amountSats: number) {
try {
const [username, domain] = address.split("@");
if (!username || !domain) {
throw new Error("Invalid Lightning address format");
}
// Security: Enforce HTTPS only
const lnurlUrl = `https://${domain}/.well-known/lnurlp/${username}`;
// Security: Add timeout for fetch requests (5 seconds)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(lnurlUrl, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(
`Failed to fetch Lightning address: ${response.statusText}`,
);
}
const data = await response.json();
if (data.status === "ERROR") {
throw new Error(data.reason || "Lightning address lookup failed");
}
// Validate callback URL uses HTTPS
if (!data.callback || !data.callback.startsWith("https://")) {
throw new Error("Invalid callback URL (must use HTTPS)");
}
// Check amount limits (amounts are in millisats)
const amountMsat = amountSats * 1000;
if (data.minSendable && amountMsat < data.minSendable) {
throw new Error(
`Amount too small. Minimum: ${data.minSendable / 1000} sats`,
);
}
if (data.maxSendable && amountMsat > data.maxSendable) {
throw new Error(
`Amount too large. Maximum: ${data.maxSendable / 1000} sats`,
);
}
// Fetch invoice from callback
const callbackUrl = new URL(data.callback);
callbackUrl.searchParams.set("amount", amountMsat.toString());
const invoiceController = new AbortController();
const invoiceTimeoutId = setTimeout(
() => invoiceController.abort(),
5000,
);
const invoiceResponse = await fetch(callbackUrl.toString(), {
signal: invoiceController.signal,
});
clearTimeout(invoiceTimeoutId);
if (!invoiceResponse.ok) {
throw new Error(
`Failed to get invoice: ${invoiceResponse.statusText}`,
);
}
const invoiceData = await invoiceResponse.json();
if (invoiceData.status === "ERROR") {
throw new Error(invoiceData.reason || "Failed to generate invoice");
}
return invoiceData.pr; // The BOLT11 invoice
} catch (fetchError) {
if (fetchError instanceof Error && fetchError.name === "AbortError") {
throw new Error("Request timeout (5 seconds)");
}
throw fetchError;
}
} catch (error) {
console.error("Lightning address resolution failed:", error);
throw error;
}
}
function handleBackToInput() {
setSendStep("input");
setInvoiceDetails(null);
}
async function handleSendPayment() {
setSending(true);
try {
// Convert sats to millisats for NWC protocol
const amount = sendAmount ? parseInt(sendAmount) * 1000 : undefined;
await payInvoice(sendInvoice, amount);
toast.success("Payment sent successfully");
resetSendDialog();
setSendDialogOpen(false);
// Reload transactions
reloadTransactions();
} catch (error) {
console.error("Payment failed:", error);
toast.error(error instanceof Error ? error.message : "Payment failed");
} finally {
setSending(false);
}
}
function resetSendDialog() {
setSendInvoice("");
setSendAmount("");
setSendStep("input");
setInvoiceDetails(null);
}
async function handleGenerateInvoice() {
const amountSats = parseInt(receiveAmount);
if (!amountSats || amountSats <= 0) {
toast.error("Please enter a valid amount");
return;
}
setGenerating(true);
try {
// Convert sats to millisats for NWC protocol
const amountMillisats = amountSats * 1000;
const result = await makeInvoice(amountMillisats, {
description: receiveDescription || undefined,
});
if (!result.invoice) {
throw new Error("No invoice returned from wallet");
}
setGeneratedInvoice(result.invoice);
// Extract payment hash if available
if (result.payment_hash) {
setGeneratedPaymentHash(result.payment_hash);
}
// Generate QR code
const qrDataUrl = await QRCode.toDataURL(result.invoice.toUpperCase(), {
width: 256,
margin: 2,
color: {
dark: "#000000",
light: "#ffffff",
},
});
setInvoiceQR(qrDataUrl);
toast.success("Invoice generated");
} catch (error) {
console.error("Failed to generate invoice:", error);
toast.error(
error instanceof Error ? error.message : "Failed to generate invoice",
);
} finally {
setGenerating(false);
}
}
async function handleCopyInvoice() {
try {
await navigator.clipboard.writeText(generatedInvoice);
setCopied(true);
toast.success("Invoice copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy invoice:", error);
toast.error("Failed to copy to clipboard");
}
}
function resetReceiveDialog() {
setGeneratedInvoice("");
setGeneratedPaymentHash("");
setInvoiceQR("");
setReceiveAmount("");
setReceiveDescription("");
setCopied(false);
}
function handleDisconnect() {
// Clear NWC connection from Grimoire state first
disconnectNWCFromState();
// Then clear the wallet service
disconnect();
setDisconnectDialogOpen(false);
toast.success("Wallet disconnected");
}
function handleTransactionClick(tx: Transaction) {
setSelectedTransaction(tx);
setDetailDialogOpen(true);
}
function formatSats(millisats: number | undefined): string {
if (millisats === undefined) return "—";
return Math.floor(millisats / 1000).toLocaleString();
}
function formatFullDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString();
}
// Process transactions to include day markers
const transactionsWithMarkers = useMemo(() => {
if (!transactions || transactions.length === 0) return [];
const items: Array<
| { type: "transaction"; data: Transaction }
| { type: "day-marker"; data: string; timestamp: number }
> = [];
transactions.forEach((transaction, index) => {
// Add day marker if this is the first transaction or if day changed
if (index === 0) {
items.push({
type: "day-marker",
data: formatDayMarker(transaction.created_at),
timestamp: transaction.created_at,
});
} else if (
isDifferentDay(
transactions[index - 1].created_at,
transaction.created_at,
)
) {
items.push({
type: "day-marker",
data: formatDayMarker(transaction.created_at),
timestamp: transaction.created_at,
});
}
items.push({ type: "transaction", data: transaction });
});
return items;
}, [transactions]);
if (!isConnected || !wallet) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="size-5" />
No Wallet Connected
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to
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="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Wallet Name + Status */}
<div className="flex items-center gap-2">
<span className="font-semibold">
{walletInfo?.alias || "Lightning Wallet"}
</span>
<div className="size-1.5 rounded-full bg-green-500" />
</div>
{/* Right: Info Dropdown, Refresh, Disconnect */}
<div className="flex items-center gap-2">
{walletInfo && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Wallet info"
>
<Info className="size-3" />
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="p-3 space-y-3">
<div className="space-y-2">
<div className="text-xs font-semibold">
Wallet Information
</div>
{walletInfo.network && (
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Network</span>
<span className="font-mono capitalize">
{walletInfo.network}
</span>
</div>
)}
{state.nwcConnection?.relays &&
state.nwcConnection.relays.length > 0 && (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">
Relay
</span>
<RelayLink
url={state.nwcConnection.relays[0]}
className="py-0"
urlClassname="text-xs"
iconClassname="size-3"
showInboxOutbox={false}
/>
</div>
)}
{state.nwcConnection?.lud16 && (
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">
Lightning Address
</span>
<span className="font-mono">
{state.nwcConnection.lud16}
</span>
</div>
)}
</div>
<div className="space-y-2">
<div className="text-xs font-semibold">Capabilities</div>
<div className="flex flex-wrap gap-1">
{walletInfo.methods.map((method) => (
<span
key={method}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
>
{method}
</span>
))}
</div>
</div>
{walletInfo.notifications &&
walletInfo.notifications.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold">
Notifications
</div>
<div className="flex flex-wrap gap-1">
{walletInfo.notifications.map((notification) => (
<span
key={notification}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
>
{notification}
</span>
))}
</div>
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRefreshBalance}
disabled={loading}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Refresh balance"
>
<RefreshCw
className={`size-3 ${loading ? "animate-spin" : ""}`}
/>
</button>
</TooltipTrigger>
<TooltipContent>Refresh Balance</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => setDisconnectDialogOpen(true)}
className="flex items-center gap-1 text-destructive hover:text-destructive/80 transition-colors"
aria-label="Disconnect wallet"
>
<LogOut className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Disconnect Wallet</TooltipContent>
</Tooltip>
</div>
</div>
{/* Big Centered Balance */}
<div className="py-4 flex flex-col items-center justify-center">
<button
onClick={toggleWalletBalancesBlur}
className="text-4xl font-bold font-mono hover:opacity-70 transition-opacity cursor-pointer flex items-center gap-3"
title="Click to toggle privacy blur"
>
<span>
{state.walletBalancesBlurred ? "✦✦✦✦✦✦" : formatSats(balance)}
</span>
{state.walletBalancesBlurred ? (
<EyeOff className="size-5 text-muted-foreground" />
) : (
<Eye className="size-5 text-muted-foreground" />
)}
</button>
</div>
{/* Send / Receive Buttons */}
{walletInfo &&
(walletInfo.methods.includes("pay_invoice") ||
walletInfo.methods.includes("make_invoice")) && (
<div className="px-4 pb-3">
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
{walletInfo.methods.includes("make_invoice") && (
<Button
onClick={() => setReceiveDialogOpen(true)}
variant="outline"
>
<Download className="mr-2 size-4" />
Receive
</Button>
)}
{walletInfo.methods.includes("pay_invoice") && (
<Button
onClick={() => setSendDialogOpen(true)}
variant="default"
>
<Send className="mr-2 size-4" />
Send
</Button>
)}
</div>
</div>
)}
{/* Transaction History */}
<div className="flex-1 overflow-hidden">
{walletInfo?.methods.includes("list_transactions") ? (
loading ? (
<div className="flex h-full items-center justify-center">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
) : txLoadFailed ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
<p className="text-sm text-muted-foreground text-center">
Failed to load transaction history
</p>
<Button variant="outline" size="sm" onClick={reloadTransactions}>
<RefreshCw className="mr-2 size-4" />
Retry
</Button>
</div>
) : transactionsWithMarkers.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
No transactions found
</p>
</div>
) : (
<Virtuoso
data={transactionsWithMarkers}
endReached={loadMoreTransactions}
itemContent={(index, item) => {
if (item.type === "day-marker") {
return (
<div
className="flex justify-center py-2"
key={`marker-${item.timestamp}`}
>
<Label className="text-[10px] text-muted-foreground">
{item.data}
</Label>
</div>
);
}
const tx = item.data;
return (
<div
key={tx.payment_hash || index}
className="flex items-center justify-between border-b border-border px-4 py-2.5 hover:bg-muted/50 transition-colors flex-shrink-0 cursor-pointer"
onClick={() => handleTransactionClick(tx)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{tx.type === "incoming" ? (
<ArrowDownLeft className="size-4 text-green-500 flex-shrink-0" />
) : (
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
)}
<TransactionLabel transaction={tx} />
</div>
<div className="flex-shrink-0 ml-4">
<p className="text-sm font-semibold font-mono">
{state.walletBalancesBlurred
? "✦✦✦✦"
: formatSats(tx.amount)}
</p>
</div>
</div>
);
}}
components={{
Footer: () =>
loadingMore ? (
<div className="flex justify-center py-4 border-b border-border">
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
</div>
) : !hasMore && transactions.length > 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
No more transactions
</div>
) : null,
}}
/>
)
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Transaction history not available
</p>
</div>
)}
</div>
{/* Disconnect Confirmation Dialog */}
<Dialog
open={disconnectDialogOpen}
onOpenChange={setDisconnectDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect Wallet?</DialogTitle>
<DialogDescription>
This will disconnect your Lightning wallet. You can reconnect at
any time.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={() => setDisconnectDialogOpen(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={handleDisconnect}>
Disconnect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Transaction Detail Dialog */}
<Dialog
open={detailDialogOpen}
onOpenChange={(open) => {
setDetailDialogOpen(open);
if (!open) {
setShowRawTransaction(false);
setCopiedRawTx(false);
}
}}
>
<DialogContent className="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">
{selectedTransaction.type === "incoming" ? (
<ArrowDownLeft className="size-6 text-green-500" />
) : (
<ArrowUpRight className="size-6 text-red-500" />
)}
<div>
<p className="text-lg font-semibold">
{selectedTransaction.type === "incoming"
? "Received"
: "Sent"}
</p>
<p className="text-2xl font-bold font-mono">
{state.walletBalancesBlurred
? "✦✦✦✦✦✦ sats"
: `${formatSats(selectedTransaction.amount)} sats`}
</p>
</div>
</div>
<div className="space-y-2">
{selectedTransaction.description &&
!parseZapRequest(selectedTransaction) && (
<div>
<Label className="text-xs text-muted-foreground">
Description
</Label>
<p className="text-sm">
{selectedTransaction.description}
</p>
</div>
)}
<div>
<Label className="text-xs text-muted-foreground">
Date
</Label>
<p className="text-sm font-mono">
{formatFullDate(selectedTransaction.created_at)}
</p>
</div>
{selectedTransaction.fees_paid !== undefined &&
selectedTransaction.fees_paid > 0 && (
<div>
<Label className="text-xs text-muted-foreground">
Fees Paid
</Label>
<p className="text-sm font-mono">
{state.walletBalancesBlurred
? "✦✦✦✦ sats"
: `${formatSats(selectedTransaction.fees_paid)} sats`}
</p>
</div>
)}
{selectedTransaction.payment_hash && (
<div>
<Label className="text-xs text-muted-foreground">
Payment Hash
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.payment_hash}
</p>
</div>
)}
{selectedTransaction.preimage && (
<div>
<Label className="text-xs text-muted-foreground">
Preimage
</Label>
<p className="text-xs font-mono break-all bg-muted p-2 rounded">
{selectedTransaction.preimage}
</p>
</div>
)}
</div>
{/* Zap Details (if this is a zap payment) */}
<ZapTransactionDetail transaction={selectedTransaction} />
{/* 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 Transaction</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, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRawTx}
onCopy={() => {
navigator.clipboard.writeText(
JSON.stringify(selectedTransaction, null, 2),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}}
label="Copy transaction JSON"
/>
</div>
</div>
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDetailDialogOpen(false);
setShowRawTransaction(false);
setCopiedRawTx(false);
}}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Send Dialog */}
<Dialog
open={sendDialogOpen}
onOpenChange={(open) => {
setSendDialogOpen(open);
if (!open) resetSendDialog();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Send Payment</DialogTitle>
<DialogDescription>
{sendStep === "input"
? "Pay a Lightning invoice or Lightning address. Amount can be overridden if the invoice allows it."
: "Confirm payment details before sending."}
</DialogDescription>
</DialogHeader>
{sendStep === "input" ? (
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Invoice or Lightning Address
</label>
<Input
placeholder="lnbc... or user@domain.com"
value={sendInvoice}
onChange={(e) => handleInvoiceChange(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Amount (sats, optional)
</label>
<Input
type="number"
placeholder="Required for Lightning addresses"
value={sendAmount}
onChange={(e) => setSendAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Leave empty for invoices with fixed amounts
</p>
</div>
<Button
onClick={handleConfirmSend}
disabled={!sendInvoice.trim() || sending}
className="w-full"
>
{sending ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Resolving...
</>
) : (
"Continue"
)}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="rounded-lg border border-border p-4">
<div className="space-y-3">
<p className="text-sm font-medium">Confirm Payment</p>
<div className="space-y-2 text-sm">
{invoiceDetails?.amount && !sendAmount && (
<div className="flex justify-between">
<span className="text-muted-foreground">Amount:</span>
<span className="font-semibold font-mono">
{Math.floor(invoiceDetails.amount).toLocaleString()}{" "}
sats
</span>
</div>
)}
{sendAmount && (
<div className="flex justify-between">
<span className="text-muted-foreground">Amount:</span>
<span className="font-semibold font-mono">
{parseInt(sendAmount).toLocaleString()} sats
</span>
</div>
)}
{invoiceDetails?.description && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Description:
</span>
<span className="truncate ml-2">
{invoiceDetails.description}
</span>
</div>
)}
</div>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={handleBackToInput}
disabled={sending}
variant="outline"
className="flex-1"
>
Back
</Button>
<Button
onClick={handleSendPayment}
disabled={sending}
className="flex-1"
>
{sending ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 size-4" />
Send Payment
</>
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
{/* Receive Dialog */}
<Dialog
open={receiveDialogOpen}
onOpenChange={(open) => {
setReceiveDialogOpen(open);
if (!open) resetReceiveDialog();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Receive Payment</DialogTitle>
<DialogDescription>
Generate a Lightning invoice to receive sats.
{checkingPayment && " Waiting for payment..."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{!generatedInvoice ? (
<>
<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>
</>
) : (
<>
<div className="flex justify-center">
{invoiceQR && (
<div className="relative">
<img
src={invoiceQR}
alt="Invoice QR Code"
className="size-64 rounded-lg border border-border"
/>
{checkingPayment && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 rounded-lg">
<RefreshCw className="size-8 animate-spin text-primary" />
</div>
)}
</div>
)}
</div>
<div className="space-y-3">
<Button
onClick={handleCopyInvoice}
variant="default"
className="w-full h-12"
>
{copied ? (
<>
<Check className="mr-2 size-5" />
Copied Invoice
</>
) : (
<>
<Copy className="mr-2 size-5" />
Copy Invoice
</>
)}
</Button>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">
Invoice (tap to view)
</label>
<div
className="rounded bg-muted p-3 font-mono text-xs cursor-pointer hover:bg-muted/80 transition-colors break-all line-clamp-2"
onClick={handleCopyInvoice}
>
{generatedInvoice}
</div>
</div>
<Button
onClick={resetReceiveDialog}
variant="outline"
className="w-full"
disabled={checkingPayment}
>
Generate Another
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}