Files
xo-cli/src/tui/screens/WalletState.tsx

390 lines
12 KiB
TypeScript

/**
* 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<string>[] = [
{ 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<HistoryItem>;
/**
* 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<HistoryItem[]>([]);
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<string>, 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 (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}[Invitation] {historyItem.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_reserved') {
const sats = historyItem.valueSatoshis ?? 0n;
return (
<Box flexDirection="row" justifyContent="space-between">
<Box>
<Text color={itemColor}>
{indicator}[Reserved] {formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}> {historyItem.description}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (historyItem.type === 'utxo_received') {
const sats = historyItem.valueSatoshis ?? 0n;
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
return (
<Box flexDirection="row" justifyContent="space-between">
<Box flexDirection="row">
<Text color={itemColor}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{historyItem.description}{reservedTag}
</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types
return (
<Box flexDirection="row" justifyContent="space-between">
<Text color={itemColor}>
{indicator}{historyItem.type}: {historyItem.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}, []);
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
</Box>
{/* Main content */}
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Balance */}
<Box
flexDirection="column"
width="50%"
paddingRight={1}
>
<Box
borderStyle="single"
borderColor={colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Text color={colors.primary} bold> Balance </Text>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Total Balance:</Text>
{balance ? (
<>
<Text color={colors.success} bold>
{formatSatoshis(balance.totalSatoshis)}
</Text>
<Text color={colors.textMuted}>
UTXOs: {balance.utxoCount}
</Text>
</>
) : (
<Text color={colors.textMuted}>Loading...</Text>
)}
</Box>
</Box>
</Box>
{/* Right column: Actions menu */}
<Box
flexDirection="column"
width="50%"
paddingLeft={1}
>
<Box
borderStyle="single"
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
>
<Text color={colors.primary} bold> Actions </Text>
<ScrollableList
items={menuItems}
selectedIndex={selectedMenuIndex}
onSelect={setSelectedMenuIndex}
onActivate={handleMenuItemActivate}
focus={focusedPanel === 'menu'}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
{/* Wallet History */}
<Box marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
width="100%"
height={14}
overflow="hidden"
>
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
</Box>
) : (
<ScrollableList
items={historyListItems}
selectedIndex={selectedHistoryIndex}
onSelect={setSelectedHistoryIndex}
focus={focusedPanel === 'history'}
maxVisible={10}
emptyMessage="No history found"
renderItem={renderHistoryItem}
/>
)}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch focus Enter: Select : Navigate Esc: Back
</Text>
</Box>
</Box>
);
}