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>
This commit is contained in:
Alejandro
2026-01-29 18:02:40 +01:00
committed by GitHub
parent 9b883f1173
commit f3cc7779e3
8 changed files with 751 additions and 463 deletions

View File

@@ -11,7 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useGrimoire } from "@/core/state";
import { createWalletFromURI } from "@/services/nwc";
import { createWalletFromURI, balance$ } from "@/services/nwc";
interface ConnectWalletDialogProps {
open: boolean;
@@ -66,6 +66,8 @@ export default function ConnectWalletDialog({
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
@@ -83,6 +85,7 @@ export default function ConnectWalletDialog({
balance,
info: {
alias: info.alias,
network: info.network,
methods: info.methods,
notifications: info.notifications,
},
@@ -96,6 +99,7 @@ export default function ConnectWalletDialog({
// Update info
updateNWCInfo({
alias: info.alias,
network: info.network,
methods: info.methods,
notifications: info.notifications,
});

View File

@@ -0,0 +1,65 @@
/**
* WalletConnectionStatus Component
*
* Displays a visual indicator for wallet connection status.
* Shows different colors and animations based on status:
* - connected: green
* - connecting: yellow (pulsing)
* - error: red
* - disconnected: gray
*/
import type { NWCConnectionStatus } from "@/services/nwc";
interface WalletConnectionStatusProps {
status: NWCConnectionStatus;
/** Size of the indicator */
size?: "sm" | "md";
/** Whether to show the status label */
showLabel?: boolean;
/** Additional class names */
className?: string;
}
const sizeClasses = {
sm: "size-1.5",
md: "size-2",
};
/**
* Get the color class for a connection status
*/
export function getConnectionStatusColor(status: NWCConnectionStatus): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500 animate-pulse";
case "error":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
export function WalletConnectionStatus({
status,
size = "sm",
showLabel = false,
className = "",
}: WalletConnectionStatusProps) {
const colorClass = getConnectionStatusColor(status);
if (!showLabel) {
return (
<span className={`${sizeClasses[size]} ${colorClass} ${className}`} />
);
}
return (
<div className={`flex items-center gap-1.5 ${className}`}>
<span className={`${sizeClasses[size]} ${colorClass}`} />
<span className="text-sm font-medium capitalize">{status}</span>
</div>
);
}

View File

@@ -5,7 +5,7 @@
* Layout: Header → Big centered balance → Send/Receive buttons → Transaction list
*/
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { toast } from "sonner";
import {
Wallet,
@@ -14,6 +14,7 @@ import {
Download,
Info,
Copy,
CopyCheck,
Check,
ArrowUpRight,
ArrowDownLeft,
@@ -26,6 +27,7 @@ import {
} from "lucide-react";
import { Virtuoso } from "react-virtuoso";
import { useWallet } from "@/hooks/useWallet";
import { useCopy } from "@/hooks/useCopy";
import { useGrimoire } from "@/core/state";
import { decode as decodeBolt11 } from "light-bolt11-decoder";
import { Button } from "@/components/ui/button";
@@ -60,32 +62,8 @@ 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[];
}
import { WalletConnectionStatus } from "./WalletConnectionStatus";
import type { Transaction } from "@/types/wallet";
interface InvoiceDetails {
amount?: number;
@@ -94,7 +72,6 @@ interface InvoiceDetails {
expiry?: number;
}
const BATCH_SIZE = 20;
const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds
/**
@@ -406,33 +383,30 @@ export default function WalletViewer() {
toggleWalletBalancesBlur,
} = useGrimoire();
const {
wallet,
balance,
isConnected,
getInfo,
connectionStatus,
lastError,
support,
walletMethods, // Combined support$ + cached info fallback
transactionsState,
refreshBalance,
listTransactions,
makeInvoice,
payInvoice,
lookupInvoice,
disconnect,
reconnect,
loadTransactions,
loadMoreTransactions,
retryLoadTransactions,
} = 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 [refreshingBalance, setRefreshingBalance] = useState(false);
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);
// Rate limiting ref
const lastBalanceRefreshRef = useRef(0);
const lastTxLoadRef = useRef(0);
// Send dialog state
const [sendDialogOpen, setSendDialogOpen] = useState(false);
@@ -452,7 +426,6 @@ export default function WalletViewer() {
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
@@ -460,107 +433,37 @@ export default function WalletViewer() {
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;
// Copy hooks for clipboard operations
const { copy: copyInvoice, copied: invoiceCopied } = useCopy(2000);
const { copy: copyRawTx, copied: rawTxCopied } = useCopy(2000);
const { copy: copyNwc, copied: nwcCopied } = useCopy(2000);
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)
// Trigger lazy load of transactions when wallet supports it
useEffect(() => {
if (
walletInfo?.methods.includes("list_transactions") &&
!txLoadAttempted &&
!loading
walletMethods.includes("list_transactions") &&
!transactionsState.initialized
) {
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);
});
loadTransactions();
}
}, [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);
}, []);
}, [walletMethods, transactionsState.initialized, loadTransactions]);
// Poll for payment status when waiting for invoice to be paid
useEffect(() => {
if (!generatedPaymentHash || !receiveDialogOpen) return;
const checkPayment = async () => {
if (!walletInfo?.methods.includes("lookup_invoice")) return;
if (!walletMethods.includes("lookup_invoice")) return;
setCheckingPayment(true);
try {
const result = await lookupInvoice(generatedPaymentHash);
// If invoice is settled, close dialog and refresh
// If invoice is settled, close dialog (notifications will refresh transactions)
if (result.settled_at) {
toast.success("Payment received!");
setReceiveDialogOpen(false);
resetReceiveDialog();
// Reload transactions
reloadTransactions();
}
} catch {
// Ignore errors, will retry
@@ -571,39 +474,7 @@ export default function WalletViewer() {
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]);
}, [generatedPaymentHash, receiveDialogOpen, walletMethods, lookupInvoice]);
async function handleRefreshBalance() {
// Rate limiting: minimum 2 seconds between refreshes
@@ -616,7 +487,7 @@ export default function WalletViewer() {
}
lastBalanceRefreshRef.current = now;
setLoading(true);
setRefreshingBalance(true);
try {
await refreshBalance();
toast.success("Balance refreshed");
@@ -624,10 +495,24 @@ export default function WalletViewer() {
console.error("Failed to refresh balance:", error);
toast.error("Failed to refresh balance");
} finally {
setLoading(false);
setRefreshingBalance(false);
}
}
function handleCopyNwcString() {
if (!state.nwcConnection) return;
const { service, relays, secret, lud16 } = state.nwcConnection;
const params = new URLSearchParams();
relays.forEach((relay) => params.append("relay", relay));
params.append("secret", secret);
if (lud16) params.append("lud16", lud16);
const nwcString = `nostr+walletconnect://${service}?${params.toString()}`;
copyNwc(nwcString);
toast.success("Connection string copied");
}
async function handleConfirmSend() {
if (!sendInvoice.trim()) {
toast.error("Please enter an invoice or Lightning address");
@@ -804,8 +689,7 @@ export default function WalletViewer() {
toast.success("Payment sent successfully");
resetSendDialog();
setSendDialogOpen(false);
// Reload transactions
reloadTransactions();
// Notifications will automatically refresh transactions
} catch (error) {
console.error("Payment failed:", error);
toast.error(error instanceof Error ? error.message : "Payment failed");
@@ -868,16 +752,9 @@ export default function WalletViewer() {
}
}
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 handleCopyInvoice() {
copyInvoice(generatedInvoice);
toast.success("Invoice copied to clipboard");
}
function resetReceiveDialog() {
@@ -886,7 +763,6 @@ export default function WalletViewer() {
setInvoiceQR("");
setReceiveAmount("");
setReceiveDescription("");
setCopied(false);
}
function handleDisconnect() {
@@ -912,6 +788,13 @@ export default function WalletViewer() {
return new Date(timestamp * 1000).toLocaleString();
}
// Derive values from transactionsState for convenience
const transactions = transactionsState.items;
const txLoading = transactionsState.loading;
const txLoadingMore = transactionsState.loadingMore;
const txHasMore = transactionsState.hasMore;
const txError = transactionsState.error;
// Process transactions to include day markers
const transactionsWithMarkers = useMemo(() => {
if (!transactions || transactions.length === 0) return [];
@@ -948,7 +831,7 @@ export default function WalletViewer() {
return items;
}, [transactions]);
if (!isConnected || !wallet) {
if (!isConnected) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
@@ -984,17 +867,39 @@ export default function WalletViewer() {
<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 */}
{/* Left: Wallet Name + Connection Status */}
<div className="flex items-center gap-2">
<span className="font-semibold">
{walletInfo?.alias || "Lightning Wallet"}
{state.nwcConnection?.info?.alias || "Lightning Wallet"}
</span>
<div className="size-1.5 rounded-full bg-green-500" />
<Tooltip>
<TooltipTrigger asChild>
<WalletConnectionStatus status={connectionStatus} size="sm" />
</TooltipTrigger>
<TooltipContent>
{connectionStatus === "connected" && "Connected"}
{connectionStatus === "connecting" && "Connecting..."}
{connectionStatus === "error" && (
<span>Error: {lastError?.message || "Connection failed"}</span>
)}
{connectionStatus === "disconnected" && "Disconnected"}
</TooltipContent>
</Tooltip>
{connectionStatus === "error" && (
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs"
onClick={reconnect}
>
Retry
</Button>
)}
</div>
{/* Right: Info Dropdown, Refresh, Disconnect */}
<div className="flex items-center gap-3">
{walletInfo && (
{support && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -1011,11 +916,11 @@ export default function WalletViewer() {
<div className="text-xs font-semibold">
Wallet Information
</div>
{walletInfo.network && (
{state.nwcConnection?.info?.network && (
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Network</span>
<span className="font-mono capitalize">
{walletInfo.network}
{state.nwcConnection.info.network}
</span>
</div>
)}
@@ -1049,7 +954,7 @@ export default function WalletViewer() {
<div className="space-y-2">
<div className="text-xs font-semibold">Capabilities</div>
<div className="flex flex-wrap gap-1">
{walletInfo.methods.map((method) => (
{support.methods?.map((method) => (
<span
key={method}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
@@ -1060,14 +965,14 @@ export default function WalletViewer() {
</div>
</div>
{walletInfo.notifications &&
walletInfo.notifications.length > 0 && (
{support.notifications &&
support.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) => (
{support.notifications.map((notification) => (
<span
key={notification}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
@@ -1083,16 +988,33 @@ export default function WalletViewer() {
</DropdownMenu>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopyNwcString}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Copy connection string"
>
{nwcCopied ? (
<CopyCheck className="size-3" />
) : (
<Copy className="size-3" />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy Connection String</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRefreshBalance}
disabled={loading}
disabled={refreshingBalance}
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" : ""}`}
className={`size-3 ${refreshingBalance ? "animate-spin" : ""}`}
/>
</button>
</TooltipTrigger>
@@ -1133,42 +1055,38 @@ export default function WalletViewer() {
</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>
{(walletMethods.includes("pay_invoice") ||
walletMethods.includes("make_invoice")) && (
<div className="px-4 pb-3">
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
{walletMethods.includes("make_invoice") && (
<Button
onClick={() => setReceiveDialogOpen(true)}
variant="outline"
>
<Download className="mr-2 size-4" />
Receive
</Button>
)}
{walletMethods.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 flex justify-center">
<div className="w-full max-w-md">
{walletInfo?.methods.includes("list_transactions") ? (
loading ? (
{walletMethods.includes("list_transactions") ? (
txLoading ? (
<div className="flex h-full items-center justify-center">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
) : txLoadFailed ? (
) : txError ? (
<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
@@ -1176,7 +1094,7 @@ export default function WalletViewer() {
<Button
variant="outline"
size="sm"
onClick={reloadTransactions}
onClick={retryLoadTransactions}
>
<RefreshCw className="mr-2 size-4" />
Retry
@@ -1234,11 +1152,11 @@ export default function WalletViewer() {
}}
components={{
Footer: () =>
loadingMore ? (
txLoadingMore ? (
<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 ? (
) : !txHasMore && transactions.length > 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
No more transactions
</div>
@@ -1290,7 +1208,6 @@ export default function WalletViewer() {
setDetailDialogOpen(open);
if (!open) {
setShowRawTransaction(false);
setCopiedRawTx(false);
}
}}
>
@@ -1387,7 +1304,10 @@ export default function WalletViewer() {
{txid}
</p>
<a
href={getMempoolUrl(txid, walletInfo?.network)}
href={getMempoolUrl(
txid,
state.nwcConnection?.info?.network,
)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 transition-colors flex-shrink-0"
@@ -1453,13 +1373,11 @@ export default function WalletViewer() {
{JSON.stringify(selectedTransaction, null, 2)}
</pre>
<CodeCopyButton
copied={copiedRawTx}
copied={rawTxCopied}
onCopy={() => {
navigator.clipboard.writeText(
copyRawTx(
JSON.stringify(selectedTransaction, null, 2),
);
setCopiedRawTx(true);
setTimeout(() => setCopiedRawTx(false), 2000);
}}
label="Copy transaction JSON"
/>
@@ -1477,7 +1395,6 @@ export default function WalletViewer() {
onClick={() => {
setDetailDialogOpen(false);
setShowRawTransaction(false);
setCopiedRawTx(false);
}}
>
Close
@@ -1705,7 +1622,7 @@ export default function WalletViewer() {
variant="default"
className="w-full h-12"
>
{copied ? (
{invoiceCopied ? (
<>
<Check className="mr-2 size-5" />
Copied Invoice

View File

@@ -10,6 +10,7 @@ import {
LogOut,
Settings,
} from "lucide-react";
import { WalletConnectionStatus } from "@/components/WalletConnectionStatus";
import accounts from "@/services/accounts";
import { useProfile } from "@/hooks/useProfile";
import { use$ } from "applesauce-react/hooks";
@@ -118,18 +119,12 @@ export default function UserMenu() {
return sats.toString();
}
// Get wallet service profile for display name, using wallet relays as hints
const walletServiceProfile = useProfile(
nwcConnection?.service,
nwcConnection?.relays,
);
// Use wallet hook for real-time balance and methods
// Use wallet hook for real-time balance and connection status
const {
disconnect: disconnectWallet,
refreshBalance,
balance,
wallet,
connectionStatus,
} = useWallet();
function openProfile() {
@@ -185,26 +180,6 @@ export default function UserMenu() {
}
}
function getWalletName(): string {
if (!nwcConnection) return "";
// Use service pubkey profile name, fallback to alias, then pubkey slice
return (
getDisplayName(nwcConnection.service, walletServiceProfile) ||
nwcConnection.info?.alias ||
nwcConnection.service.slice(0, 8)
);
}
function openWalletServiceProfile() {
if (!nwcConnection?.service) return;
addWindow(
"profile",
{ pubkey: nwcConnection.service },
`Profile ${nwcConnection.service.slice(0, 8)}...`,
);
setShowWalletInfo(false);
}
return (
<>
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
@@ -228,8 +203,7 @@ export default function UserMenu() {
<div className="space-y-4">
{/* Balance */}
{(balance !== undefined ||
nwcConnection.balance !== undefined) && (
{balance !== undefined && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Balance:
@@ -243,7 +217,7 @@ export default function UserMenu() {
<span>
{state.walletBalancesBlurred
? "✦✦✦✦✦✦"
: formatBalance(balance ?? nwcConnection.balance)}
: formatBalance(balance)}
</span>
{state.walletBalancesBlurred ? (
<EyeOff className="size-3 text-muted-foreground" />
@@ -263,30 +237,14 @@ export default function UserMenu() {
</div>
)}
{/* Wallet Name */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Wallet:</span>
<button
onClick={openWalletServiceProfile}
className="text-sm font-medium hover:underline cursor-crosshair text-primary"
>
{getWalletName()}
</button>
</div>
{/* Connection Status */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<div className="flex items-center gap-2">
<span
className={`size-2 rounded-full ${
wallet ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm font-medium">
{wallet ? "Connected" : "Disconnected"}
</span>
</div>
<WalletConnectionStatus
status={connectionStatus}
size="md"
showLabel
/>
</div>
{/* Lightning Address */}
@@ -402,25 +360,15 @@ export default function UserMenu() {
>
<div className="flex items-center gap-2">
<Wallet className="size-4 text-muted-foreground" />
{balance !== undefined ||
nwcConnection.balance !== undefined ? (
{balance !== undefined && (
<span className="text-sm">
{state.walletBalancesBlurred
? "✦✦✦✦"
: formatBalance(balance ?? nwcConnection.balance)}
: formatBalance(balance)}
</span>
) : null}
</div>
<div className="flex items-center gap-1.5">
<span
className={`size-1.5 rounded-full ${
wallet ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-xs text-muted-foreground">
{getWalletName()}
</span>
)}
</div>
<WalletConnectionStatus status={connectionStatus} size="sm" />
</DropdownMenuItem>
) : (
<DropdownMenuItem