390 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|