Initial Commit
This commit is contained in:
285
src/tui/screens/WalletState.tsx
Normal file
285
src/tui/screens/WalletState.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Wallet State Screen - Displays wallet balances and UTXOs.
|
||||
*
|
||||
* Shows:
|
||||
* - Total balance
|
||||
* - List of unspent outputs
|
||||
* - Navigation to other actions
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import SelectInput from 'ink-select-input';
|
||||
import { Screen } from '../components/Screen.js';
|
||||
import { List, type ListItem } 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';
|
||||
|
||||
/**
|
||||
* Menu action items.
|
||||
*/
|
||||
const menuItems = [
|
||||
{ label: 'New Transaction (from template)', value: 'new-tx' },
|
||||
{ label: 'Import Invitation', value: 'import' },
|
||||
{ label: 'View Invitations', value: 'invitations' },
|
||||
{ label: 'Generate New Address', value: 'new-address' },
|
||||
{ label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* UTXO display item.
|
||||
*/
|
||||
interface UTXOItem {
|
||||
key: string;
|
||||
satoshis: bigint;
|
||||
txid: string;
|
||||
index: number;
|
||||
reserved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wallet State Screen Component.
|
||||
* Displays wallet balance, UTXOs, and action menu.
|
||||
*/
|
||||
export function WalletStateScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
const { walletController, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// State
|
||||
const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||
const [utxos, setUtxos] = useState<UTXOItem[]>([]);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'utxos'>('menu');
|
||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
/**
|
||||
* Refreshes wallet state.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Loading wallet state...');
|
||||
|
||||
// Get balance
|
||||
const balanceData = await walletController.getBalance();
|
||||
setBalance({
|
||||
totalSatoshis: balanceData.totalSatoshis,
|
||||
utxoCount: balanceData.utxoCount,
|
||||
});
|
||||
|
||||
// Get UTXOs
|
||||
const utxoData = await walletController.getUnspentOutputs();
|
||||
setUtxos(utxoData.map((utxo) => ({
|
||||
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
||||
satoshis: BigInt(utxo.valueSatoshis),
|
||||
txid: utxo.outpointTransactionHash,
|
||||
index: utxo.outpointIndex,
|
||||
reserved: utxo.reserved ?? false,
|
||||
})));
|
||||
|
||||
setStatus('Wallet ready');
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [walletController, setStatus, showError]);
|
||||
|
||||
// Load wallet state on mount
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
/**
|
||||
* Generates a new receiving address.
|
||||
*/
|
||||
const generateNewAddress = useCallback(async () => {
|
||||
try {
|
||||
setStatus('Generating new address...');
|
||||
|
||||
// Get the default P2PKH template
|
||||
const templates = await walletController.getTemplates();
|
||||
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 walletController.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)}`);
|
||||
}
|
||||
}, [walletController, setStatus, showInfo, showError, refresh]);
|
||||
|
||||
/**
|
||||
* Handles menu selection.
|
||||
*/
|
||||
const handleMenuSelect = useCallback((item: { value: string }) => {
|
||||
switch (item.value) {
|
||||
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 keyboard navigation between panels
|
||||
useInput((input, key) => {
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'menu' ? 'utxos' : 'menu');
|
||||
}
|
||||
});
|
||||
|
||||
// Convert UTXOs to list items
|
||||
const utxoListItems: ListItem[] = utxos.map((utxo, index) => ({
|
||||
key: utxo.key,
|
||||
label: `${formatSatoshis(utxo.satoshis)} | ${formatHex(utxo.txid, 16)}:${utxo.index}`,
|
||||
description: utxo.reserved ? '[Reserved]' : undefined,
|
||||
}));
|
||||
|
||||
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>
|
||||
<Box marginTop={1}>
|
||||
<SelectInput
|
||||
items={menuItems}
|
||||
onSelect={handleMenuSelect}
|
||||
isFocused={focusedPanel === 'menu'}
|
||||
indicatorComponent={({ isSelected }) => (
|
||||
<Text color={isSelected ? colors.focus : colors.text}>
|
||||
{isSelected ? '▸ ' : ' '}
|
||||
</Text>
|
||||
)}
|
||||
itemComponent={({ isSelected, label }) => (
|
||||
<Text
|
||||
color={isSelected ? colors.text : colors.textMuted}
|
||||
bold={isSelected}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* UTXO list */}
|
||||
<Box marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
>
|
||||
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{isLoading ? (
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
) : utxoListItems.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No unspent outputs found</Text>
|
||||
) : (
|
||||
utxoListItems.map((item, index) => (
|
||||
<Box key={item.key}>
|
||||
<Text color={index === selectedUtxoIndex && focusedPanel === 'utxos' ? colors.focus : colors.text}>
|
||||
{index === selectedUtxoIndex && focusedPanel === 'utxos' ? '▸ ' : ' '}
|
||||
{index + 1}. {item.label}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text color={colors.warning}> {item.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user