mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-17 10:56:52 +02:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
65
src/components/WalletConnectionStatus.tsx
Normal file
65
src/components/WalletConnectionStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user