554 lines
17 KiB
TypeScript
554 lines
17 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 } from 'ink';
|
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
|
import { QRCode } from '../components/QRCode.js';
|
|
import { useNavigation } from '../hooks/useNavigation.js';
|
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
|
import { useSatoshisConversion } from '../hooks/useSatoshisConversion.js';
|
|
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
|
import type { HistoryItem } from '../../services/history.js';
|
|
|
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
|
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';
|
|
|
|
// Import utility functions
|
|
import {
|
|
buildHistoryDisplayRows,
|
|
getHistoryItemColorName,
|
|
formatHistoryDate,
|
|
type HistoryDisplayRow,
|
|
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: 'unreserve-all', label: 'Unreserve All Resources', value: 'unreserve-all' },
|
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
|
];
|
|
|
|
/**
|
|
* History list item with display row value.
|
|
*/
|
|
type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
|
|
|
/**
|
|
* QR code dialog overlay — auto-captures input via the layer system.
|
|
* Rendered only while a QR address is visible; closes on Enter/Esc.
|
|
*/
|
|
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
|
useInputLayer('qr-dialog');
|
|
|
|
useLayeredInput('qr-dialog', (_input, key) => {
|
|
if (key.escape || key.return) {
|
|
onClose();
|
|
}
|
|
});
|
|
|
|
return (
|
|
<Box
|
|
position="absolute"
|
|
flexDirection="column"
|
|
alignItems="center"
|
|
width="100%"
|
|
>
|
|
<QRCode
|
|
value={address}
|
|
dialog
|
|
dialogTitle="Receive Address"
|
|
showValue
|
|
/>
|
|
<Box justifyContent="center" marginTop={1}>
|
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
const {
|
|
currencyCode,
|
|
fiatPerBchRate,
|
|
formattedFiatPerBchRate,
|
|
formatSatoshisToFiat,
|
|
} = useSatoshisConversion('USD');
|
|
|
|
// 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);
|
|
|
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
|
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
|
|
|
/**
|
|
* 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]);
|
|
|
|
// Keep wallet state in sync with invitation lifecycle and updates.
|
|
useEffect(() => {
|
|
if (!appService) return;
|
|
|
|
const onWalletStateChanged = () => {
|
|
void refresh();
|
|
};
|
|
|
|
appService.on('wallet-state-changed', onWalletStateChanged);
|
|
|
|
return () => {
|
|
appService.off('wallet-state-changed', onWalletStateChanged);
|
|
};
|
|
}, [appService, refresh]);
|
|
|
|
/**
|
|
* Generates a new receiving address and displays it as a QR code.
|
|
*/
|
|
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 the template identifier
|
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
|
|
|
// Generate the locking bytecode (returned as a hex string)
|
|
const lockingBytecodeHex = await appService.engine.generateLockingBytecode(
|
|
templateId,
|
|
'receiveOutput',
|
|
'receiver',
|
|
);
|
|
|
|
// Convert the locking bytecode to a BCH cash address for display and QR encoding.
|
|
const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' });
|
|
|
|
if (typeof result === 'string') {
|
|
showError(`Failed to encode address: ${result}`);
|
|
return;
|
|
}
|
|
|
|
console.log(result);
|
|
|
|
setQrAddress(result.address);
|
|
setStatus('Address generated');
|
|
|
|
// Refresh to show updated state
|
|
await refresh();
|
|
} catch (error) {
|
|
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}, [appService, setStatus, showError, refresh]);
|
|
|
|
/**
|
|
* Unreserves all reserved UTXOs and refreshes the wallet state.
|
|
*/
|
|
const unreserveAll = useCallback(async () => {
|
|
if (!appService) {
|
|
showError('AppService not initialized');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setStatus('Unreserving all resources...');
|
|
const count = await appService.unreserveAllResources();
|
|
showInfo(`Unreserved ${count} resource(s)`);
|
|
await refresh();
|
|
} catch (error) {
|
|
showError(`Failed to unreserve resources: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
}, [appService, setStatus, showError, showInfo, 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 'unreserve-all':
|
|
unreserveAll();
|
|
break;
|
|
case 'refresh':
|
|
refresh();
|
|
break;
|
|
}
|
|
}, [navigate, generateNewAddress, unreserveAll, 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 buildHistoryDisplayRows(history).map(row => {
|
|
return {
|
|
key: row.id,
|
|
label: row.label,
|
|
description: row.description,
|
|
value: row,
|
|
color: getHistoryItemColorName(row, false),
|
|
hidden: false,
|
|
};
|
|
});
|
|
}, [history]);
|
|
|
|
/**
|
|
* Fiat values are memoized so we only recompute when balance or rate changes.
|
|
*/
|
|
const formattedUsdPerBchRate = useMemo(() => {
|
|
return formattedFiatPerBchRate;
|
|
}, [formattedFiatPerBchRate]);
|
|
|
|
const formattedUsdBalance = useMemo(() => {
|
|
if (!balance || fiatPerBchRate === null) {
|
|
return null;
|
|
}
|
|
|
|
return formatSatoshisToFiat(balance.totalSatoshis);
|
|
}, [balance, fiatPerBchRate, formatSatoshisToFiat]);
|
|
|
|
const getFiatSuffix = useCallback((satoshis: bigint): string => {
|
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
|
return fiatValue ? ` (~${fiatValue})` : '';
|
|
}, [formatSatoshisToFiat]);
|
|
|
|
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
|
const isCaptured = useIsInputCaptured();
|
|
|
|
useBlockableInput((_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 row = item.value;
|
|
if (!row) return null;
|
|
|
|
const colorName = getHistoryItemColorName(row, isFocused);
|
|
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
|
const dateStr = formatHistoryDate(row.timestamp);
|
|
const indicator = isFocused ? '▸ ' : ' ';
|
|
const groupingPrefix = row.isNested ? ' -> ' : '';
|
|
|
|
if (row.type === 'invitation') {
|
|
return (
|
|
<Box flexDirection="row" justifyContent="space-between">
|
|
<Text color={itemColor}>
|
|
{indicator}[Invitation] {row.label}
|
|
</Text>
|
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (row.type === 'invitation_input') {
|
|
const inputSatoshis = row.utxo?.valueSatoshis;
|
|
const inputFiatSuffix = inputSatoshis !== undefined
|
|
? getFiatSuffix(inputSatoshis)
|
|
: '';
|
|
return (
|
|
<Box flexDirection="row" justifyContent="space-between">
|
|
<Box>
|
|
<Text color={itemColor}>
|
|
{indicator}{groupingPrefix}[Input] {row.label}
|
|
{inputFiatSuffix}
|
|
</Text>
|
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
|
</Box>
|
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (row.type === 'invitation_output') {
|
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
|
return (
|
|
<Box flexDirection="row" justifyContent="space-between">
|
|
<Box flexDirection="row">
|
|
<Text color={itemColor}>
|
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
|
{getFiatSuffix(sats)}
|
|
</Text>
|
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
|
</Box>
|
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (row.type === 'utxo') {
|
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
|
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
|
|
return (
|
|
<Box flexDirection="row" justifyContent="space-between">
|
|
<Box flexDirection="row">
|
|
<Text color={itemColor}>
|
|
{indicator}{formatSatoshis(sats)}
|
|
{getFiatSuffix(sats)}
|
|
</Text>
|
|
{row.description && <Text color={colors.textMuted}> {row.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}{row.label}
|
|
</Text>
|
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
</Box>
|
|
);
|
|
}, [getFiatSuffix]);
|
|
|
|
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>
|
|
{formattedUsdBalance ? (
|
|
<Text color={colors.info}>
|
|
Approx. Fiat ({currencyCode}): {formattedUsdBalance}
|
|
</Text>
|
|
) : (
|
|
<Text color={colors.textMuted}>
|
|
Approx. Fiat ({currencyCode}): Waiting for BCH/{currencyCode} rate...
|
|
</Text>
|
|
)}
|
|
{formattedUsdPerBchRate && (
|
|
<Text color={colors.textMuted}>
|
|
1 BCH = {formattedUsdPerBchRate}
|
|
</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' && !isCaptured}
|
|
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' && !isCaptured}
|
|
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>
|
|
|
|
{/* QR Code dialog overlay for generated addresses */}
|
|
{qrAddress && (
|
|
<QRDialogOverlay
|
|
address={qrAddress}
|
|
onClose={() => setQrAddress(null)}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|