Invitations screen changes. Scrollable list. Details. And role selection on import
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user