Invitations screen changes. Scrollable list. Details. And role selection on import

This commit is contained in:
2026-02-09 08:14:52 +00:00
parent df57f1b9ad
commit ef169e76db
10 changed files with 2237 additions and 557 deletions

View File

@@ -7,25 +7,59 @@
* - Navigation to other actions
*/
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import SelectInput from 'ink-select-input';
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 = [
{ 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' },
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.
@@ -39,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement {
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);
@@ -124,10 +159,10 @@ export function WalletStateScreen(): React.ReactElement {
}, [appService, setStatus, showInfo, showError, refresh]);
/**
* Handles menu selection.
* Handles menu action.
*/
const handleMenuSelect = useCallback((item: { value: string }) => {
switch (item.value) {
const handleMenuAction = useCallback((action: string) => {
switch (action) {
case 'new-tx':
navigate('templates');
break;
@@ -146,46 +181,131 @@ export function WalletStateScreen(): React.ReactElement {
}
}, [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');
}
// Navigate history items when focused
if (focusedPanel === 'history' && history.length > 0) {
if (key.upArrow) {
setSelectedHistoryIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
}
}
});
/**
* 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}>
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
<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}>
<Box flexDirection="row" marginTop={1} flexGrow={1}>
{/* Left column: Balance */}
<Box
flexDirection='column'
width='50%'
flexDirection="column"
width="50%"
paddingRight={1}
>
<Box
borderStyle='single'
borderStyle="single"
borderColor={colors.primary}
flexDirection='column'
flexDirection="column"
paddingX={1}
paddingY={1}
>
<Text color={colors.primary} bold> Balance </Text>
<Box marginTop={1} flexDirection='column'>
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Total Balance:</Text>
{balance ? (
<>
@@ -205,37 +325,25 @@ export function WalletStateScreen(): React.ReactElement {
{/* Right column: Actions menu */}
<Box
flexDirection='column'
width='50%'
flexDirection="column"
width="50%"
paddingLeft={1}
>
<Box
borderStyle='single'
borderStyle="single"
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
flexDirection='column'
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>
<ScrollableList
items={menuItems}
selectedIndex={selectedMenuIndex}
onSelect={setSelectedMenuIndex}
onActivate={handleMenuItemActivate}
focus={focusedPanel === 'menu'}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
@@ -243,95 +351,30 @@ export function WalletStateScreen(): React.ReactElement {
{/* Wallet History */}
<Box marginTop={1} flexGrow={1}>
<Box
borderStyle='single'
borderStyle="single"
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
flexDirection='column'
flexDirection="column"
paddingX={1}
width='100%'
width="100%"
height={14}
overflow='hidden'
overflow="hidden"
>
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
<Box marginTop={1} flexDirection='column'>
{isLoading ? (
{isLoading ? (
<Box marginTop={1}>
<Text color={colors.textMuted}>Loading...</Text>
) : history.length === 0 ? (
<Text color={colors.textMuted}>No history found</Text>
) : (
// Show a scrolling window of items
(() => {
const maxVisible = 10;
const halfWindow = Math.floor(maxVisible / 2);
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
const endIndex = Math.min(history.length, startIndex + maxVisible);
// Adjust start if we're near the end
if (endIndex - startIndex < maxVisible) {
startIndex = Math.max(0, endIndex - maxVisible);
}
const visibleItems = history.slice(startIndex, endIndex);
return visibleItems.map((item, idx) => {
const actualIndex = startIndex + idx;
const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history';
const indicator = isSelected ? '▸ ' : ' ';
const dateStr = item.timestamp
? new Date(item.timestamp).toLocaleDateString()
: '';
// Format the history item based on type
if (item.type === 'invitation_created') {
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Text color={isSelected ? colors.focus : colors.text}>
{indicator}[Invitation] {item.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (item.type === 'utxo_reserved') {
const sats = item.valueSatoshis ?? 0n;
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Box>
<Text color={isSelected ? colors.focus : colors.warning}>
{indicator}[Reserved] {formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}> {item.description}</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
} else if (item.type === 'utxo_received') {
const sats = item.valueSatoshis ?? 0n;
const reservedTag = item.reserved ? ' [Reserved]' : '';
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Box flexDirection='row'>
<Text color={isSelected ? colors.focus : colors.success}>
{indicator}{formatSatoshis(sats)}
</Text>
<Text color={colors.textMuted}>
{' '}{item.description}{reservedTag}
</Text>
</Box>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
}
// Fallback for other types
return (
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
<Text color={isSelected ? colors.focus : colors.text}>
{indicator}{item.type}: {item.description}
</Text>
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
</Box>
);
});
})()
)}
</Box>
</Box>
) : (
<ScrollableList
items={historyListItems}
selectedIndex={selectedHistoryIndex}
onSelect={setSelectedHistoryIndex}
focus={focusedPanel === 'history'}
maxVisible={10}
emptyMessage="No history found"
renderItem={renderHistoryItem}
/>
)}
</Box>
</Box>