Invitations screen changes. Scrollable list. Details. And role selection on import
This commit is contained in:
@@ -2,63 +2,52 @@
|
||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||
*
|
||||
* Provides:
|
||||
* - Import invitation by ID
|
||||
* - View active invitations
|
||||
* - Import invitation by ID with role selection
|
||||
* - View active invitations with detailed information
|
||||
* - Monitor invitation updates via SSE
|
||||
* - Fill missing requirements
|
||||
* - Sign and complete invitations
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { InputDialog } from '../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { Invitation } from '../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
getUserRole,
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../utils/invitation-utils.js';
|
||||
|
||||
/**
|
||||
* Get state display string for invitation.
|
||||
* For now we'll use a simple derived state based on commits.
|
||||
*/
|
||||
function getInvitationState(invitation: Invitation): string {
|
||||
const commits = invitation.data.commits || [];
|
||||
if (commits.length === 0) return 'created';
|
||||
|
||||
// Check if invitation has been signed (has signatures)
|
||||
const hasSig = commits.some(c => c.signature);
|
||||
if (hasSig) return 'signed';
|
||||
|
||||
// Check if invitation has inputs/outputs
|
||||
const hasInputs = commits.some(c => c.data?.inputs && c.data.inputs.length > 0);
|
||||
const hasOutputs = commits.some(c => c.data?.outputs && c.data.outputs.length > 0);
|
||||
|
||||
if (hasInputs || hasOutputs) return 'pending';
|
||||
|
||||
return 'published';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for invitation state.
|
||||
* Map state color name to theme color.
|
||||
*/
|
||||
function getStateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
case 'published':
|
||||
const colorName = getStateColorName(state);
|
||||
switch (colorName) {
|
||||
case 'info':
|
||||
return colors.info as string;
|
||||
case 'pending':
|
||||
case 'warning':
|
||||
return colors.warning as string;
|
||||
case 'ready':
|
||||
case 'signed':
|
||||
case 'success':
|
||||
return colors.success as string;
|
||||
case 'broadcast':
|
||||
case 'completed':
|
||||
return colors.success as string;
|
||||
case 'expired':
|
||||
case 'error':
|
||||
return colors.error as string;
|
||||
case 'muted':
|
||||
default:
|
||||
return colors.textMuted as string;
|
||||
}
|
||||
@@ -67,13 +56,25 @@ function getStateColor(state: string): string {
|
||||
/**
|
||||
* Action menu items.
|
||||
*/
|
||||
const actionItems = [
|
||||
{ label: 'Import Invitation', value: 'import' },
|
||||
{ label: 'Accept & Join', value: 'accept' },
|
||||
{ label: 'Fill Requirements', value: 'fill' },
|
||||
{ label: 'Sign Transaction', value: 'sign' },
|
||||
{ label: 'View Transaction', value: 'transaction' },
|
||||
{ label: 'Copy Invitation ID', value: 'copy' },
|
||||
const actionItems: ListItemData<string>[] = [
|
||||
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
||||
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
||||
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
||||
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
|
||||
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Invitation list item with invitation value or null for import action.
|
||||
*/
|
||||
type InvitationListItem = ListItemData<Invitation | null>;
|
||||
|
||||
/**
|
||||
* Groups for the invitation list.
|
||||
*/
|
||||
const invitationListGroups: ListGroup[] = [
|
||||
{ id: 'actions' },
|
||||
{ id: 'invitations', separator: true },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -90,10 +91,22 @@ export function InvitationScreen(): React.ReactElement {
|
||||
// State
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
||||
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
||||
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
||||
|
||||
// Template cache for displaying invitation list with template names
|
||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||
|
||||
// Selected invitation template for details view
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||
|
||||
// Check if we should open import dialog on mount
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
|
||||
@@ -102,38 +115,167 @@ export function InvitationScreen(): React.ReactElement {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialMode === 'import') {
|
||||
setShowImportDialog(true);
|
||||
setImportStage('id');
|
||||
}
|
||||
}, [initialMode]);
|
||||
|
||||
// Get selected invitation
|
||||
const selectedInvitation = invitations[selectedIndex];
|
||||
/**
|
||||
* Load templates for all invitations (for list display).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
invitations.forEach(inv => {
|
||||
const templateId = inv.data.templateIdentifier;
|
||||
if (!templateCache.has(templateId)) {
|
||||
appService.engine.getTemplate(templateId).then(template => {
|
||||
if (template) {
|
||||
setTemplateCache(prev => new Map(prev).set(templateId, template));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [invitations, appService, templateCache]);
|
||||
|
||||
/**
|
||||
* Import invitation by ID.
|
||||
* Build list items for ScrollableList.
|
||||
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
||||
*/
|
||||
const importInvitation = useCallback(async (invitationId: string) => {
|
||||
setShowImportDialog(false);
|
||||
if (!invitationId.trim() || !appService) return;
|
||||
const listItems = useMemo((): InvitationListItem[] => {
|
||||
// Import action at top
|
||||
const importItem: InvitationListItem = {
|
||||
key: 'import',
|
||||
label: '+ Import Invitation',
|
||||
value: null,
|
||||
group: 'actions',
|
||||
color: 'info',
|
||||
};
|
||||
|
||||
// Map invitations to list items
|
||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const formatted = formatInvitationListItem(inv, template);
|
||||
const state = getInvitationState(inv);
|
||||
|
||||
return {
|
||||
key: inv.data.invitationIdentifier,
|
||||
label: formatted.label,
|
||||
value: inv,
|
||||
group: 'invitations',
|
||||
color: formatted.statusColor,
|
||||
hidden: !formatted.isValid, // Hide invalid items
|
||||
};
|
||||
});
|
||||
|
||||
return [importItem, ...invitationItems];
|
||||
}, [invitations, templateCache]);
|
||||
|
||||
// Get selected invitation from list items
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
const selectedInvitation = selectedItem?.value ?? null;
|
||||
|
||||
/**
|
||||
* Load template for selected invitation.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!selectedInvitation || !appService) {
|
||||
setSelectedTemplate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
||||
.then(template => setSelectedTemplate(template ?? null));
|
||||
}, [selectedInvitation, appService]);
|
||||
|
||||
/**
|
||||
* Stage 1: Import invitation by ID (fetches invitation and moves to role selection).
|
||||
*/
|
||||
const handleImportIdSubmit = useCallback(async (invitationId: string) => {
|
||||
if (!invitationId.trim() || !appService) {
|
||||
setImportStage(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Importing invitation...');
|
||||
setStatus('Fetching invitation...');
|
||||
|
||||
// Create invitation instance (will fetch from sync server)
|
||||
const invitation = await appService.createInvitation(invitationId);
|
||||
|
||||
showInfo(`Invitation imported!\n\nTemplate: ${invitation.data.templateIdentifier}\nAction: ${invitation.data.actionIdentifier}`);
|
||||
// Get available roles for this invitation
|
||||
const roles = await invitation.getAvailableRoles();
|
||||
|
||||
// Get the template for display
|
||||
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||
|
||||
// Store for next stage
|
||||
setImportingInvitation(invitation);
|
||||
setAvailableRoles(roles);
|
||||
setSelectedRoleIndex(0);
|
||||
setImportTemplate(template ?? null);
|
||||
|
||||
// Move to role selection stage
|
||||
setImportStage('role-select');
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setImportStage(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [appService, showInfo, showError, setStatus]);
|
||||
}, [appService, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Accept selected invitation.
|
||||
* Stage 2: Accept invitation with selected role.
|
||||
*/
|
||||
const handleRoleSelect = useCallback(async () => {
|
||||
if (!importingInvitation || !appService) return;
|
||||
|
||||
const selectedRole = availableRoles[selectedRoleIndex];
|
||||
if (!selectedRole) {
|
||||
showError('No role selected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus(`Accepting as ${selectedRole}...`);
|
||||
|
||||
await importingInvitation.accept();
|
||||
|
||||
showInfo(`Invitation imported and accepted!\n\nRole: ${selectedRole}\nTemplate: ${importTemplate?.name ?? importingInvitation.data.templateIdentifier}\nAction: ${importingInvitation.data.actionIdentifier}`);
|
||||
setStatus('Ready');
|
||||
|
||||
// Reset import state
|
||||
setImportStage(null);
|
||||
setImportingInvitation(null);
|
||||
setAvailableRoles([]);
|
||||
setImportTemplate(null);
|
||||
} catch (error) {
|
||||
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [importingInvitation, availableRoles, selectedRoleIndex, appService, importTemplate, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Cancel import and remove the invitation if it was added.
|
||||
*/
|
||||
const handleImportCancel = useCallback(async () => {
|
||||
if (importingInvitation && appService) {
|
||||
// Remove the invitation since user declined
|
||||
await appService.removeInvitation(importingInvitation);
|
||||
}
|
||||
|
||||
setImportStage(null);
|
||||
setImportingInvitation(null);
|
||||
setAvailableRoles([]);
|
||||
setImportTemplate(null);
|
||||
}, [importingInvitation, appService]);
|
||||
|
||||
/**
|
||||
* Accept selected invitation (from actions menu).
|
||||
*/
|
||||
const acceptInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
@@ -203,11 +345,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
/**
|
||||
* Fill requirements for selected invitation.
|
||||
* This automatically:
|
||||
* 1. Accepts the invitation (if not already)
|
||||
* 2. Finds suitable UTXOs
|
||||
* 3. Selects UTXOs to cover the required amount
|
||||
* 4. Appends inputs and change output to the invitation
|
||||
*/
|
||||
const fillRequirements = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
@@ -220,16 +357,14 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
// Step 1: Check available roles
|
||||
setStatus('Checking available roles...');
|
||||
const availableRoles = await selectedInvitation.getAvailableRoles();
|
||||
const roles = await selectedInvitation.getAvailableRoles();
|
||||
|
||||
if (availableRoles.length === 0) {
|
||||
if (roles.length === 0) {
|
||||
// Already participating, check if we can add inputs
|
||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||
} else {
|
||||
// Need to accept a role first
|
||||
// TODO: Let user pick role if multiple available
|
||||
// For now, auto-select the first available role
|
||||
const roleToTake = availableRoles[0];
|
||||
const roleToTake = roles[0];
|
||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||
setStatus(`Accepting as ${roleToTake}...`);
|
||||
|
||||
@@ -246,7 +381,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
// Calculate how much we need
|
||||
// Look for a requestedSatoshis variable in the invitation
|
||||
let requiredAmount = 0n;
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
@@ -260,14 +394,14 @@ export function InvitationScreen(): React.ReactElement {
|
||||
if (requiredAmount > 0n) break;
|
||||
}
|
||||
|
||||
const fee = 500n; // Estimated fee
|
||||
const dust = 546n; // Dust threshold
|
||||
const fee = 500n;
|
||||
const dust = 546n;
|
||||
const totalNeeded = requiredAmount + fee + dust;
|
||||
|
||||
// Find resources - use a common output identifier
|
||||
// Find resources
|
||||
const utxos = await selectedInvitation.findSuitableResources({
|
||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput', // Try common identifier
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
if (utxos.length === 0) {
|
||||
@@ -276,7 +410,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Select UTXOs (auto-select to cover the amount)
|
||||
// Select UTXOs
|
||||
setStatus('Selecting UTXOs...');
|
||||
|
||||
const selectedUtxos: Array<{
|
||||
@@ -288,7 +422,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
// Check lockingBytecode uniqueness
|
||||
const lockingBytecodeHex = utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
@@ -322,7 +455,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
const changeAmount = accumulated - requiredAmount - fee;
|
||||
|
||||
// Step 6: Add inputs to the invitation
|
||||
// Add inputs
|
||||
setStatus('Adding inputs...');
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
@@ -331,7 +464,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}))
|
||||
);
|
||||
|
||||
// Step 7: Add change output
|
||||
// Add change output
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await selectedInvitation.addOutputs([{
|
||||
@@ -364,9 +497,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
*/
|
||||
const handleAction = useCallback((action: string) => {
|
||||
switch (action) {
|
||||
case 'import':
|
||||
setShowImportDialog(true);
|
||||
break;
|
||||
case 'copy':
|
||||
copyId();
|
||||
break;
|
||||
@@ -387,54 +517,323 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||
|
||||
/**
|
||||
* Handle list item activation.
|
||||
*/
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
||||
if (item.key === 'import') {
|
||||
setImportStage('id');
|
||||
}
|
||||
// For invitation items, we just select them - actions are in the actions panel
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle action item activation.
|
||||
*/
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||
if (item.value) {
|
||||
handleAction(item.value);
|
||||
}
|
||||
}, [handleAction]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
// Don't handle input while dialog is open
|
||||
if (showImportDialog) return;
|
||||
|
||||
// Tab to switch panels
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => {
|
||||
if (prev === 'list') return 'details';
|
||||
if (prev === 'details') return 'actions';
|
||||
return 'list';
|
||||
});
|
||||
// Handle role selection dialog navigation
|
||||
if (importStage === 'role-select') {
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedRoleIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
handleRoleSelect();
|
||||
} else if (key.escape) {
|
||||
handleImportCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down navigation
|
||||
if (key.upArrow || input === 'k') {
|
||||
if (focusedPanel === 'list') {
|
||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (focusedPanel === 'actions') {
|
||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
if (focusedPanel === 'list') {
|
||||
setSelectedIndex(prev => Math.min(invitations.length - 1, prev + 1));
|
||||
} else if (focusedPanel === 'actions') {
|
||||
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
|
||||
}
|
||||
}
|
||||
// Don't handle input while ID input dialog is open
|
||||
if (importStage === 'id') return;
|
||||
|
||||
// Enter to select action
|
||||
if (key.return && focusedPanel === 'actions') {
|
||||
const action = actionItems[selectedActionIndex];
|
||||
if (action) {
|
||||
handleAction(action.value);
|
||||
}
|
||||
// Tab to switch panels (list -> actions -> list)
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'c' to copy
|
||||
if (input === 'c') {
|
||||
if (input === 'c' && selectedInvitation) {
|
||||
copyId();
|
||||
}
|
||||
|
||||
// 'i' to import
|
||||
if (input === 'i') {
|
||||
setShowImportDialog(true);
|
||||
setImportStage('id');
|
||||
}
|
||||
}, { isActive: !showImportDialog });
|
||||
}, { isActive: importStage !== 'id' });
|
||||
|
||||
/**
|
||||
* Render custom list item for invitation list.
|
||||
*/
|
||||
const renderInvitationListItem = useCallback((
|
||||
item: InvitationListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
// Import item
|
||||
if (item.key === 'import') {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.info}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// Invitation item
|
||||
const inv = item.value;
|
||||
if (!inv) return null;
|
||||
|
||||
const state = getInvitationState(inv);
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const templateName = template?.name ?? 'Unknown';
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
<Text color={getStateColor(state)}>[{state}]</Text>
|
||||
{' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)})
|
||||
</Text>
|
||||
);
|
||||
}, [templateCache]);
|
||||
|
||||
/**
|
||||
* Render detailed invitation information.
|
||||
*/
|
||||
const renderDetails = () => {
|
||||
if (!selectedInvitation) {
|
||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||
}
|
||||
|
||||
const state = getInvitationState(selectedInvitation);
|
||||
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
||||
const inputs = getInvitationInputs(selectedInvitation);
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
|
||||
// Try to determine user's entity ID (from first commit they made)
|
||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Row 1: Type, Description, Status */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Type: </Text>
|
||||
<Text color={colors.text}>{selectedTemplate?.name ?? 'Unknown Template'}</Text>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{selectedTemplate?.description ?? 'No description available'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Status: </Text>
|
||||
<Text color={getStateColor(state)}>{state}</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
||||
</Text>
|
||||
{action?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Your Role */}
|
||||
{userRole && (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Your Role: </Text>
|
||||
<Text color={colors.success}>{roleInfo?.name ?? userRole}</Text>
|
||||
{roleInfo?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{roleInfo.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 3: Inputs & Outputs side by side */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{/* Inputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
{inputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No inputs yet</Text>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const isUserInput = input.entityIdentifier === userEntityId;
|
||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`input-${idx}`}
|
||||
color={isUserInput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Outputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
{outputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No outputs yet</Text>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`output-${idx}`}
|
||||
color={isUserOutput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 4: Variables */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
{variables.length === 0 ? (
|
||||
<Text color={colors.textMuted}> No variables set</Text>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Text
|
||||
key={`var-${idx}`}
|
||||
color={isUserVariable ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserVariable ? '• ' : '○ '}
|
||||
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
{varTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Shortcuts */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render role selection dialog for import flow.
|
||||
*/
|
||||
const renderRoleSelectionDialog = () => {
|
||||
if (!importingInvitation) return null;
|
||||
|
||||
const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier];
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="double"
|
||||
borderColor={colors.primary}
|
||||
backgroundColor="white"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={70}
|
||||
>
|
||||
<Text color={colors.primary} bold>Import Invitation - Select Role</Text>
|
||||
|
||||
{/* Invitation Details */}
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text color={colors.text}>Template: {importTemplate?.name ?? 'Unknown'}</Text>
|
||||
{importTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{importTemplate.description}</Text>
|
||||
)}
|
||||
<Text color={colors.text}>Action: {action?.name ?? importingInvitation.data.actionIdentifier}</Text>
|
||||
{action?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Role Selection */}
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||
{availableRoles.length === 0 ? (
|
||||
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||
) : (
|
||||
availableRoles.map((role, index) => {
|
||||
const roleInfoRaw = importTemplate?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
const actionRoleRaw = action?.roles?.[role];
|
||||
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||
return (
|
||||
<Box key={role} flexDirection="column">
|
||||
<Text
|
||||
color={index === selectedRoleIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedRoleIndex}
|
||||
>
|
||||
{index === selectedRoleIndex ? '▸ ' : ' '}
|
||||
{roleInfo?.name ?? role}
|
||||
</Text>
|
||||
{(roleInfo?.description || actionRole?.description) && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Decline</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
@@ -443,10 +842,10 @@ export function InvitationScreen(): React.ReactElement {
|
||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content - three columns */}
|
||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||
{/* Main content - Top row: List + Actions */}
|
||||
<Box flexDirection="row" marginTop={1} height={12}>
|
||||
{/* Left column: Invitation list */}
|
||||
<Box flexDirection="column" width="40%" paddingRight={1}>
|
||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
|
||||
@@ -454,115 +853,23 @@ export function InvitationScreen(): React.ReactElement {
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Active Invitations </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{invitations.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No invitations</Text>
|
||||
) : (
|
||||
invitations.map((inv, index) => {
|
||||
const state = getInvitationState(inv);
|
||||
return (
|
||||
<Text
|
||||
key={inv.data.invitationIdentifier}
|
||||
color={index === selectedIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedIndex}
|
||||
>
|
||||
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
|
||||
<Text color={getStateColor(state)}>[{state}]</Text>
|
||||
{' '}{formatHex(inv.data.invitationIdentifier, 12)}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Middle column: Details */}
|
||||
<Box flexDirection="column" width="40%" paddingX={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'details' ? colors.focus : colors.border}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Details </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{selectedInvitation ? (
|
||||
<>
|
||||
{(() => {
|
||||
const state = getInvitationState(selectedInvitation);
|
||||
return (
|
||||
<>
|
||||
<Text color={colors.text}>ID: {formatHex(selectedInvitation.data.invitationIdentifier, 20)}</Text>
|
||||
<Text color={colors.text}>
|
||||
State: <Text color={getStateColor(state)}>{state}</Text>
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Template: {selectedInvitation.data.templateIdentifier?.slice(0, 20)}...
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Action: {selectedInvitation.data.actionIdentifier}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Commits: {selectedInvitation.data.commits?.length ?? 0}
|
||||
</Text>
|
||||
|
||||
{/* State-specific guidance */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{state === 'created' && (
|
||||
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
||||
)}
|
||||
{state === 'published' && (
|
||||
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
||||
)}
|
||||
{state === 'pending' && (
|
||||
<>
|
||||
<Text color={colors.warning}>→ Action needed!</Text>
|
||||
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
|
||||
<Text color={colors.warning}> your UTXOs and complete your part</Text>
|
||||
</>
|
||||
)}
|
||||
{state === 'ready' && (
|
||||
<>
|
||||
<Text color={colors.success}>→ Ready to sign!</Text>
|
||||
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
||||
</>
|
||||
)}
|
||||
{state === 'signed' && (
|
||||
<>
|
||||
<Text color={colors.success}>→ Signed!</Text>
|
||||
<Text color={colors.success}> View Transaction to broadcast</Text>
|
||||
</>
|
||||
)}
|
||||
{state === 'broadcast' && (
|
||||
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
||||
)}
|
||||
{state === 'completed' && (
|
||||
<Text color={colors.success}>✓ Transaction completed!</Text>
|
||||
)}
|
||||
{state === 'error' && (
|
||||
<Text color={colors.error}>✗ Error - check logs</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<Text color={colors.textMuted}>Select an invitation</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Text color={colors.primary} bold> Invitations </Text>
|
||||
<ScrollableList
|
||||
items={listItems}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={handleListItemActivate}
|
||||
focus={focusedPanel === 'list'}
|
||||
maxVisible={6}
|
||||
groups={invitationListGroups}
|
||||
emptyMessage="No invitations yet"
|
||||
renderItem={renderInvitationListItem}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: Actions */}
|
||||
<Box flexDirection="column" width="20%" paddingLeft={1}>
|
||||
<Box flexDirection="column" width="30%" paddingLeft={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||
@@ -571,18 +878,30 @@ export function InvitationScreen(): React.ReactElement {
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Actions </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{actionItems.map((item, index) => (
|
||||
<Text
|
||||
key={item.value}
|
||||
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
|
||||
bold={index === selectedActionIndex && focusedPanel === 'actions'}
|
||||
>
|
||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
<ScrollableList
|
||||
items={actionItems}
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionItemActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
emptyMessage="No actions"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom row: Details (full width) */}
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={colors.border}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Details </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{renderDetails()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -594,8 +913,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import dialog */}
|
||||
{showImportDialog && (
|
||||
{/* Import ID dialog (Stage 1) */}
|
||||
{importStage === 'id' && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
@@ -608,12 +927,15 @@ export function InvitationScreen(): React.ReactElement {
|
||||
title="Import Invitation"
|
||||
prompt="Enter Invitation ID:"
|
||||
placeholder="Paste invitation ID..."
|
||||
onSubmit={importInvitation}
|
||||
onCancel={() => setShowImportDialog(false)}
|
||||
isActive={showImportDialog}
|
||||
onSubmit={handleImportIdSubmit}
|
||||
onCancel={() => setImportStage(null)}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Role Selection dialog (Stage 2) */}
|
||||
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* - Select a template and action to start a new transaction
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
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, logoSmall } from '../theme.js';
|
||||
@@ -16,18 +17,15 @@ import { colors, logoSmall } from '../theme.js';
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* A unique starting action (deduplicated by action identifier).
|
||||
* Multiple roles that can start the same action are counted
|
||||
* but not shown as separate entries — role selection happens
|
||||
* inside the Action Wizard.
|
||||
*/
|
||||
interface UniqueStartingAction {
|
||||
actionIdentifier: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
roleCount: number;
|
||||
}
|
||||
// Import utility functions
|
||||
import {
|
||||
formatTemplateListItem,
|
||||
formatActionListItem,
|
||||
deduplicateStartingActions,
|
||||
getTemplateRoles,
|
||||
getRolesForAction,
|
||||
type UniqueStartingAction,
|
||||
} from '../../utils/template-utils.js';
|
||||
|
||||
/**
|
||||
* Template item with metadata.
|
||||
@@ -38,6 +36,16 @@ interface TemplateItem {
|
||||
startingActions: UniqueStartingAction[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Template list item with TemplateItem value.
|
||||
*/
|
||||
type TemplateListItem = ListItemData<TemplateItem>;
|
||||
|
||||
/**
|
||||
* Action list item with UniqueStartingAction value.
|
||||
*/
|
||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
||||
|
||||
/**
|
||||
* Template List Screen Component.
|
||||
* Displays templates and their starting actions.
|
||||
@@ -74,27 +82,13 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
|
||||
// Deduplicate by action identifier — role selection
|
||||
// is handled inside the Action Wizard, not here.
|
||||
const actionMap = new Map<string, UniqueStartingAction>();
|
||||
for (const sa of rawStartingActions) {
|
||||
if (actionMap.has(sa.action)) {
|
||||
actionMap.get(sa.action)!.roleCount++;
|
||||
} else {
|
||||
const actionDef = template.actions?.[sa.action];
|
||||
actionMap.set(sa.action, {
|
||||
actionIdentifier: sa.action,
|
||||
name: actionDef?.name || sa.action,
|
||||
description: actionDef?.description,
|
||||
roleCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Use utility function to deduplicate actions
|
||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
||||
|
||||
return {
|
||||
template,
|
||||
templateIdentifier,
|
||||
startingActions: Array.from(actionMap.values()),
|
||||
startingActions,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -119,15 +113,59 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const currentTemplate = templates[selectedTemplateIndex];
|
||||
const currentActions = currentTemplate?.startingActions ?? [];
|
||||
|
||||
/**
|
||||
* Build template list items for ScrollableList.
|
||||
*/
|
||||
const templateListItems = useMemo((): TemplateListItem[] => {
|
||||
return templates.map((item, index) => {
|
||||
const formatted = formatTemplateListItem(item.template, index);
|
||||
return {
|
||||
key: item.templateIdentifier,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: item,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}, [templates]);
|
||||
|
||||
/**
|
||||
* Build action list items for ScrollableList.
|
||||
*/
|
||||
const actionListItems = useMemo((): ActionListItem[] => {
|
||||
return currentActions.map((action, index) => {
|
||||
const formatted = formatActionListItem(
|
||||
action.actionIdentifier,
|
||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||
action.roleCount,
|
||||
index
|
||||
);
|
||||
return {
|
||||
key: action.actionIdentifier,
|
||||
label: formatted.label,
|
||||
description: formatted.description,
|
||||
value: action,
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
}, [currentActions, currentTemplate]);
|
||||
|
||||
/**
|
||||
* Handle template selection change.
|
||||
*/
|
||||
const handleTemplateSelect = useCallback((index: number) => {
|
||||
setSelectedTemplateIndex(index);
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handles action selection.
|
||||
* Navigates to the Action Wizard where the user will choose their role.
|
||||
*/
|
||||
const handleActionSelect = useCallback(() => {
|
||||
if (!currentTemplate || currentActions.length === 0) return;
|
||||
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
|
||||
if (!currentTemplate || !item.value) return;
|
||||
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return;
|
||||
const action = item.value;
|
||||
|
||||
// Navigate to the Action Wizard — role selection happens there
|
||||
navigate('wizard', {
|
||||
@@ -135,7 +173,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
actionIdentifier: action.actionIdentifier,
|
||||
template: currentTemplate.template,
|
||||
});
|
||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
||||
}, [currentTemplate, navigate]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
@@ -144,124 +182,111 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||
return;
|
||||
}
|
||||
|
||||
// Up/Down navigation
|
||||
if (key.upArrow || input === 'k') {
|
||||
if (focusedPanel === 'templates') {
|
||||
setSelectedTemplateIndex(prev => Math.max(0, prev - 1));
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
} else {
|
||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
||||
}
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
if (focusedPanel === 'templates') {
|
||||
setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1));
|
||||
setSelectedActionIndex(0); // Reset action selection when template changes
|
||||
} else {
|
||||
setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select action
|
||||
if (key.return && focusedPanel === 'actions') {
|
||||
handleActionSelect();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render custom template list item.
|
||||
*/
|
||||
const renderTemplateItem = useCallback((
|
||||
item: TemplateListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Render custom action list item.
|
||||
*/
|
||||
const renderActionItem = useCallback((
|
||||
item: ActionListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
return (
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isSelected}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
);
|
||||
}, []);
|
||||
|
||||
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} - Select Template & Action</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content - two columns */}
|
||||
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||
{/* Left column: Template list */}
|
||||
<Box flexDirection='column' width='40%' paddingRight={1}>
|
||||
<Box flexDirection="column" width="40%" paddingRight={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Templates </Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
|
||||
{(() => {
|
||||
// Loading State
|
||||
if (isLoading) {
|
||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||
}
|
||||
|
||||
// No templates state
|
||||
if (templates.length === 0) {
|
||||
return <Text color={colors.textMuted}>No templates imported</Text>;
|
||||
}
|
||||
|
||||
// Templates state
|
||||
return templates.map((item, index) => (
|
||||
<Text
|
||||
key={item.templateIdentifier}
|
||||
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedTemplateIndex}
|
||||
>
|
||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollableList
|
||||
items={templateListItems}
|
||||
selectedIndex={selectedTemplateIndex}
|
||||
onSelect={handleTemplateSelect}
|
||||
focus={focusedPanel === 'templates'}
|
||||
emptyMessage="No templates imported"
|
||||
renderItem={renderTemplateItem}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right column: Actions list */}
|
||||
<Box flexDirection='column' width='60%' paddingLeft={1}>
|
||||
<Box flexDirection="column" width="60%" paddingLeft={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
|
||||
{(() => {
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
||||
}
|
||||
|
||||
// No template selected state
|
||||
if (!currentTemplate) {
|
||||
return <Text color={colors.textMuted}>Select a template...</Text>;
|
||||
}
|
||||
|
||||
// No starting actions state
|
||||
if (currentActions.length === 0) {
|
||||
return <Text color={colors.textMuted}>No starting actions available</Text>;
|
||||
}
|
||||
|
||||
// Starting actions state
|
||||
return currentActions.map((action, index) => (
|
||||
<Text
|
||||
key={action.actionIdentifier}
|
||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedActionIndex}
|
||||
>
|
||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
||||
{index + 1}. {action.name}
|
||||
{action.roleCount > 1
|
||||
? ` (${action.roleCount} roles)`
|
||||
: ''}
|
||||
</Text>
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
{isLoading ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
</Box>
|
||||
) : !currentTemplate ? (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Select a template...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollableList
|
||||
items={actionListItems}
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
emptyMessage="No starting actions available"
|
||||
renderItem={renderActionItem}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -269,18 +294,18 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{/* Description box */}
|
||||
<Box marginTop={1}>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderStyle="single"
|
||||
borderColor={colors.border}
|
||||
flexDirection='column'
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
width='100%'
|
||||
width="100%"
|
||||
>
|
||||
<Text color={colors.primary} bold> Description </Text>
|
||||
|
||||
{/* Show template description when templates panel is focused */}
|
||||
{focusedPanel === 'templates' && currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text} bold>
|
||||
{currentTemplate.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
@@ -293,11 +318,11 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
</Text>
|
||||
)}
|
||||
{currentTemplate.template.roles && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Roles:</Text>
|
||||
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
||||
<Text key={roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name || roleId}: {role.description || 'No description'}
|
||||
{getTemplateRoles(currentTemplate.template).map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}: {role.description || 'No description'}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
@@ -309,14 +334,13 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
|
||||
{/* Show action description when actions panel is focused */}
|
||||
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{(() => {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return null;
|
||||
|
||||
// Collect all roles that can start this action
|
||||
const startEntries = (currentTemplate.template.start ?? [])
|
||||
.filter((s) => s.action === action.actionIdentifier);
|
||||
// Get roles that can start this action using utility function
|
||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -328,18 +352,15 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
</Text>
|
||||
|
||||
{/* List available roles for this action */}
|
||||
{startEntries.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{availableRoles.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Available Roles:</Text>
|
||||
{startEntries.map((entry) => {
|
||||
const roleDef = currentTemplate.template.roles?.[entry.role];
|
||||
return (
|
||||
<Text key={entry.role} color={colors.textMuted}>
|
||||
{' '}- {roleDef?.name || entry.role}
|
||||
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{availableRoles.map((role) => (
|
||||
<Text key={role.roleId} color={colors.textMuted}>
|
||||
{' '}- {role.name}
|
||||
{role.description ? `: ${role.description}` : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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