/** * Wallet State Screen - Displays wallet balances and history. * * Shows: * - Total balance * - Wallet history (invitations, reservations) * - Navigation to other actions */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import type { HistoryItem } from '../../services/history.js'; // Import utility functions import { formatHistoryListItem, getHistoryItemColorName, formatHistoryDate, type HistoryColorName, } from '../../utils/history-utils.js'; /** * Map history color name to theme color. */ function getHistoryColor(colorName: HistoryColorName): string { switch (colorName) { case 'info': return colors.info as string; case 'warning': return colors.warning as string; case 'success': return colors.success as string; case 'error': return colors.error as string; case 'muted': return colors.textMuted as string; case 'text': default: return colors.text as string; } } /** * Menu action items. */ const menuItems: ListItemData[] = [ { key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' }, { key: 'import', label: 'Import Invitation', value: 'import' }, { key: 'invitations', label: 'View Invitations', value: 'invitations' }, { key: 'new-address', label: 'Generate New Address', value: 'new-address' }, { key: 'refresh', label: 'Refresh', value: 'refresh' }, ]; /** * History list item with HistoryItem value. */ type HistoryListItem = ListItemData; /** * Wallet State Screen Component. * Displays wallet balance, history, and action menu. */ export function WalletStateScreen(): React.ReactElement { const { navigate } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); // State const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [history, setHistory] = useState([]); const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu'); const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); /** * Refreshes wallet state. */ const refresh = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setIsLoading(true); setStatus('Loading wallet state...'); // Get UTXOs for balance calculation const utxoData = await appService.engine.listUnspentOutputsData(); // Calculate balance const selectableUtxos = utxoData.filter(utxo => utxo.selectable); const balanceData = selectableUtxos.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0)); setBalance({ totalSatoshis: balanceData, utxoCount: selectableUtxos.length, }); // Get wallet history from the history service const historyData = await appService.history.getHistory(); setHistory(historyData); setStatus('Wallet ready'); setIsLoading(false); } catch (error) { showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`); setIsLoading(false); } }, [appService, setStatus, showError]); // Load wallet state on mount useEffect(() => { refresh(); }, [refresh]); /** * Generates a new receiving address. */ const generateNewAddress = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setStatus('Generating new address...'); // Get the default P2PKH template const templates = await appService.engine.listImportedTemplates(); const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH')); if (!p2pkhTemplate) { showError('P2PKH template not found'); return; } // Generate a new locking bytecode const { generateTemplateIdentifier } = await import('@xo-cash/engine'); const templateId = generateTemplateIdentifier(p2pkhTemplate); const lockingBytecode = await appService.engine.generateLockingBytecode( templateId, 'receiveOutput', 'receiver', ); showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`); // Refresh to show updated state await refresh(); } catch (error) { showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`); } }, [appService, setStatus, showInfo, showError, refresh]); /** * Handles menu action. */ const handleMenuAction = useCallback((action: string) => { switch (action) { case 'new-tx': navigate('templates'); break; case 'import': navigate('invitations', { mode: 'import' }); break; case 'invitations': navigate('invitations', { mode: 'list' }); break; case 'new-address': generateNewAddress(); break; case 'refresh': refresh(); break; } }, [navigate, generateNewAddress, refresh]); /** * Handle menu item activation. */ const handleMenuItemActivate = useCallback((item: ListItemData, index: number) => { if (item.value) { handleMenuAction(item.value); } }, [handleMenuAction]); /** * Build history list items for ScrollableList. */ const historyListItems = useMemo((): HistoryListItem[] => { return history.map(item => { const formatted = formatHistoryListItem(item, false); return { key: item.id, label: formatted.label, description: formatted.description, value: item, color: formatted.color, hidden: !formatted.isValid, }; }); }, [history]); // Handle keyboard navigation between panels useInput((input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); } }); /** * Render custom history list item. */ const renderHistoryItem = useCallback(( item: HistoryListItem, isSelected: boolean, isFocused: boolean ): React.ReactNode => { const historyItem = item.value; if (!historyItem) return null; const colorName = getHistoryItemColorName(historyItem.type, isFocused); const itemColor = isFocused ? colors.focus : getHistoryColor(colorName); const dateStr = formatHistoryDate(historyItem.timestamp); const indicator = isFocused ? '▸ ' : ' '; // Format based on type if (historyItem.type === 'invitation_created') { return ( {indicator}[Invitation] {historyItem.description} {dateStr && {dateStr}} ); } else if (historyItem.type === 'utxo_reserved') { const sats = historyItem.valueSatoshis ?? 0n; return ( {indicator}[Reserved] {formatSatoshis(sats)} {historyItem.description} {dateStr && {dateStr}} ); } else if (historyItem.type === 'utxo_received') { const sats = historyItem.valueSatoshis ?? 0n; const reservedTag = historyItem.reserved ? ' [Reserved]' : ''; return ( {indicator}{formatSatoshis(sats)} {' '}{historyItem.description}{reservedTag} {dateStr && {dateStr}} ); } // Fallback for other types return ( {indicator}{historyItem.type}: {historyItem.description} {dateStr && {dateStr}} ); }, []); return ( {/* Header */} {logoSmall} - Wallet Overview {/* Main content */} {/* Left column: Balance */} Balance Total Balance: {balance ? ( <> {formatSatoshis(balance.totalSatoshis)} UTXOs: {balance.utxoCount} ) : ( Loading... )} {/* Right column: Actions menu */} Actions {/* Wallet History */} Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''} {isLoading ? ( Loading... ) : ( )} {/* Help text */} Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back ); }