Large amount of changes. Successfully broadcasts txs

This commit is contained in:
2026-03-08 15:53:50 +00:00
parent 66e9918e04
commit 9ef1720e1f
19 changed files with 1373 additions and 351 deletions
@@ -0,0 +1,731 @@
/**
* Invitation Screen - Manages invitations (create, import, view, monitor).
*
* Provides:
* - Import invitation by ID with multi-step import flow
* - View active invitations with detailed information
* - Monitor invitation updates via SSE
* - Fill missing requirements
* - Sign and complete invitations
*/
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, 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 {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
getUserRole,
formatInvitationListItem,
formatInvitationId,
} from '../../../utils/invitation-utils.js';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
/**
* Map state color name to theme color.
*/
function getStateColor(state: string): string {
const colorName = getStateColorName(state);
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':
default:
return colors.textMuted as string;
}
}
/**
* Action menu items.
*/
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 },
];
/**
* Invitation Screen Component.
*/
export function InvitationScreen(): React.ReactElement {
const { navigate, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const invitations = useInvitations();
// ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
const [isLoading, setIsLoading] = useState(false);
// ── Import state ─────────────────────────────────────────────────────────
// Two phases: first the ID input dialog, then the multi-step import flow.
const [showIdDialog, setShowIdDialog] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
// ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
// Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined;
useEffect(() => {
if (initialMode === 'import') {
setShowIdDialog(true);
}
}, [initialMode]);
/**
* 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]);
/**
* Build list items for ScrollableList.
*/
const listItems = useMemo((): InvitationListItem[] => {
const importItem: InvitationListItem = {
key: 'import',
label: '+ Import Invitation',
value: null,
group: 'actions',
color: 'info',
};
const invitationItems: InvitationListItem[] = invitations.map(inv => {
const template = templateCache.get(inv.data.templateIdentifier);
const formatted = formatInvitationListItem(inv, template);
return {
key: inv.data.invitationIdentifier,
label: formatted.label,
value: inv,
group: 'invitations',
color: formatted.statusColor,
hidden: !formatted.isValid,
};
});
return [importItem, ...invitationItems];
}, [invitations, templateCache]);
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]);
// ── Import flow callbacks ──────────────────────────────────────────────
/**
* ID dialog submitted — transition to the multi-step import flow.
*/
const handleImportIdSubmit = useCallback((invitationId: string) => {
if (!invitationId.trim()) {
setShowIdDialog(false);
return;
}
setShowIdDialog(false);
setImportingId(invitationId.trim());
}, []);
/**
* Import flow closed (completed or cancelled).
*/
const handleImportFlowClose = useCallback(() => {
setImportingId(null);
}, []);
// ── Action handlers ────────────────────────────────────────────────────
const acceptInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Accepting invitation...');
await selectedInvitation.accept();
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
setStatus('Ready');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
} else {
showError(`Failed to accept: ${errorMsg}`);
}
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const signInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Signing invitation...');
await selectedInvitation.sign();
showInfo('Invitation signed!');
setStatus('Ready');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
await copyToClipboard(selectedInvitation.data.invitationIdentifier);
showInfo(`Copied!\n\n${selectedInvitation.data.invitationIdentifier}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [selectedInvitation, showInfo, showError]);
const fillRequirements = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Checking available roles...');
const roles = await selectedInvitation.getAvailableRoles();
if (roles.length === 0) {
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
} else {
const roleToTake = roles[0];
showInfo(`Accepting invitation as role: ${roleToTake}`);
setStatus(`Accepting as ${roleToTake}...`);
try {
await selectedInvitation.accept();
} catch (e) {
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
setStatus('Ready');
return;
}
}
setStatus('Analyzing invitation...');
let requiredAmount = 0n;
const commits = selectedInvitation.data.commits || [];
for (const commit of commits) {
const variables = commit.data?.variables || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
}
if (requiredAmount > 0n) break;
}
const fee = 500n;
const dust = 546n;
const totalNeeded = requiredAmount + fee + dust;
const utxos = await selectedInvitation.findSuitableResources({
templateIdentifier: selectedInvitation.data.templateIdentifier,
outputIdentifier: 'receiveOutput',
});
if (utxos.length === 0) {
showError('No suitable UTXOs found. Make sure your wallet has funds.');
setStatus('Ready');
return;
}
setStatus('Selecting UTXOs...');
const selectedUtxos: Array<{
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
}> = [];
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
const lockingBytecodeHex = utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
selectedUtxos.push({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
});
accumulated += BigInt(utxo.valueSatoshis);
if (accumulated >= totalNeeded) break;
}
if (accumulated < totalNeeded) {
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
setStatus('Ready');
return;
}
const changeAmount = accumulated - requiredAmount - fee;
setStatus('Adding inputs...');
await selectedInvitation.addInputs(
selectedUtxos.map(u => ({
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
outpointIndex: u.outpointIndex,
}))
);
if (changeAmount >= dust) {
setStatus('Adding change output...');
await selectedInvitation.addOutputs([{
valueSatoshis: changeAmount,
}]);
}
showInfo(
`Requirements filled!\n\n` +
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
`• Total: ${formatSatoshis(accumulated)}\n` +
`• Required: ${formatSatoshis(requiredAmount)}\n` +
`• Fee: ${formatSatoshis(fee)}\n` +
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
`Now use "Sign Transaction" to complete.`
);
setStatus('Ready');
} catch (error) {
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
setStatus('Ready');
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const handleAction = useCallback((action: string) => {
switch (action) {
case 'copy':
copyId();
break;
case 'accept':
acceptInvitation();
break;
case 'fill':
fillRequirements();
break;
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
}
break;
}
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
if (item.key === 'import') {
setShowIdDialog(true);
}
}, []);
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
if (item.value) {
handleAction(item.value);
}
}, [handleAction]);
// ── Keyboard navigation ──────────────────────────────────────────────────
// Disabled when the ID dialog or import flow is open.
const isOverlayOpen = showIdDialog || importingId !== null;
useInput((input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
return;
}
if (input === 'c' && selectedInvitation) {
copyId();
}
if (input === 'i') {
setShowIdDialog(true);
}
}, { isActive: !isOverlayOpen });
// ── Render helpers ───────────────────────────────────────────────────────
const renderInvitationListItem = useCallback((
item: InvitationListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
if (item.key === 'import') {
return (
<Text
color={isFocused ? colors.focus : colors.info}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}
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]);
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);
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">
{/* Type & 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>
{/* 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>
)}
{/* Inputs & Outputs */}
<Box flexDirection="row" marginBottom={1}>
<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>
<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>
{/* 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>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
</Box>
</Box>
);
};
// ── Main render ──────────────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Top row: List + Actions */}
<Box flexDirection="row" marginTop={1} height={12}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="70%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Invitations </Text>
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={handleListItemActivate}
focus={focusedPanel === 'list' && !isOverlayOpen}
maxVisible={6}
groups={invitationListGroups}
emptyMessage="No invitations yet"
renderItem={renderInvitationListItem}
/>
</Box>
</Box>
{/* Right column: Actions */}
<Box flexDirection="column" width="30%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Actions </Text>
<ScrollableList
items={actionItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionItemActivate}
focus={focusedPanel === 'actions' && !isOverlayOpen}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
{/* Bottom row: Details */}
<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>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch panel : Navigate Enter: Select i: Import c: Copy ID Esc: Back
</Text>
</Box>
{/* Import ID dialog */}
{showIdDialog && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<InputDialog
title="Import Invitation"
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={handleImportIdSubmit}
onCancel={() => setShowIdDialog(false)}
isActive={true}
/>
</Box>
)}
{/* Multi-step import flow */}
{importingId && appService && (
<InvitationImportFlow
invitationId={importingId}
mode="dialog"
appService={appService}
onClose={handleImportFlowClose}
showError={showError}
showInfo={showInfo}
setStatus={setStatus}
/>
)}
</Box>
);
}
@@ -0,0 +1,318 @@
/**
* InvitationImportFlow — orchestrates the multi-step invitation import.
*
* Manages the step state machine, accumulates data from each step, and
* injects it into the next step via props (dependency injection).
*
* Supports two display modes:
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
*/
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, logoSmall } from '../../../theme.js';
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
import { RoleSelectStep } from './steps/RoleSelectStep.js';
import { InputsSelectStep } from './steps/InputsSelectStep.js';
import { ReviewStep } from './steps/ReviewStep.js';
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
import type { Invitation } from '../../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
import { DialogWrapper } from '../../../components/Dialog.js';
import { InvitationBuilder } from '@xo-cash/engine';
import { hexToBin } from '@bitauth/libauth';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
export function InvitationImportFlow({
invitationId,
mode,
appService,
onClose,
showError,
showInfo,
setStatus,
}: ImportFlowProps): React.ReactElement {
// ── Accumulated state ────────────────────────────────────────────────────
const [currentStep, setCurrentStep] = useState(0);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
const [template, setTemplate] = useState<XOTemplate | null>(null);
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
const [changeAmount, setChangeAmount] = useState(0n);
const [requiredAmount, setRequiredAmount] = useState(0n);
// ── Cancel handler ───────────────────────────────────────────────────────
/**
* Cleans up (removes the invitation if it was fetched) and signals the parent.
*/
const handleCancel = useCallback(async () => {
if (invitation && appService) {
try {
await appService.removeInvitation(invitation);
} catch {
// Best-effort removal — don't block close on failure
}
}
onClose();
}, [invitation, appService, onClose]);
// ── Step completion callbacks ────────────────────────────────────────────
/**
* FetchStep completed — invitation and template are now available.
* Also pre-fetches available roles for the next steps.
*/
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
setInvitation(inv);
setTemplate(tmpl);
const builder = InvitationBuilder.fromInvitation(inv.data);
setBuildableInvitation(builder);
try {
const roles = await inv.getAvailableRoles();
setAvailableRoles(roles);
} catch (err) {
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
}
setCurrentStep(1); // → Preview
}, [showError]);
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
const handlePreviewComplete = useCallback(() => {
setCurrentStep(2); // → Role Select
}, []);
/** RoleSelectStep completed — user picked a role. */
const handleRoleComplete = useCallback((role: string) => {
setSelectedRole(role);
setCurrentStep(3); // → Inputs Select
}, []);
/** InputsSelectStep completed — user selected UTXOs. */
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
setSelectedInputs(inputs);
await invitation?.addInputs(inputs.map(input => ({
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
outpointIndex: input.outpointIndex,
})));
// Compute totals from selected inputs
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
// Determine required amount from invitation variables
const requiredSats = await invitation?.getSatsOut() ?? 0n;
setRequiredAmount(requiredSats);
// Set the change amount for the review step
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
setChangeAmount(changeAmountSats);
console.log('totalSelected:', totalSelected);
console.log('requiredAmount:', requiredSats);
console.log('DEFAULT_FEE:', DEFAULT_FEE);
console.log('changeAmount:', changeAmount);
// Add the change output if it exceeds the dust threshold
if (changeAmountSats >= DUST_THRESHOLD) {
await invitation?.addOutputs([{
valueSatoshis: changeAmountSats,
}]);
}
setCurrentStep(4); // → Review
}, [invitation, buildableInvitation, selectedInputs]);
/** ReviewStep completed — invitation import is done. */
const handleReviewComplete = useCallback(() => {
const roleName = (() => {
if (!selectedRole || !template) return selectedRole ?? '';
const raw = template.roles?.[selectedRole];
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
})();
showInfo(
`Invitation imported and accepted!\n\n` +
`Role: ${roleName}\n` +
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
);
setStatus('Ready');
onClose();
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
// ── Keyboard handling for FetchStep error retry ──────────────────────────
// FetchStep auto-advances on success but shows error state with retry on failure.
useInput((_input, key) => {
if (currentStep !== 0) return;
// Enter retries, Esc cancels — handled within FetchStep rendering,
// but we also catch Esc here for safety.
if (key.escape) handleCancel();
}, { isActive: currentStep === 0 });
// ── Step router ──────────────────────────────────────────────────────────
const renderStep = (): React.ReactNode => {
const stepDef = IMPORT_STEPS[currentStep];
if (!stepDef) return null;
switch (stepDef.type) {
case 'fetch':
return (
<FetchInvitationStep
invitationId={invitationId}
appService={appService}
onComplete={handleFetchComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'preview':
if (!invitation) return null;
return (
<PreviewInvitationStep
invitation={invitation}
template={template}
onComplete={handlePreviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'role-select':
if (!invitation) return null;
return (
<RoleSelectStep
invitation={invitation}
template={template}
availableRoles={availableRoles}
onComplete={handleRoleComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'inputs-select':
if (!invitation || !selectedRole) return null;
return (
<InputsSelectStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
appService={appService}
onComplete={handleInputsComplete}
onCancel={handleCancel}
isActive={true}
/>
);
case 'review':
if (!invitation || !selectedRole) return null;
return (
<ReviewStep
invitation={invitation}
template={template}
selectedRole={selectedRole}
selectedInputs={selectedInputs}
changeAmount={changeAmount}
requiredAmount={requiredAmount}
appService={appService}
onComplete={handleReviewComplete}
onCancel={handleCancel}
isActive={true}
/>
);
default:
return null;
}
};
// ── Step indicator data ──────────────────────────────────────────────────
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
// ── Layout: dialog mode ──────────────────────────────────────────────────
if (mode === 'dialog') {
return (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
{/* Step indicator (compact) */}
<Box marginTop={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</DialogWrapper>
</Box>
);
}
// ── Layout: screen mode ──────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
<Text color={colors.textMuted}>
{template?.name ?? 'Loading...'}
{selectedRole ? ` (as ${selectedRole})` : ''}
</Text>
</Box>
{/* Step indicator */}
<Box marginTop={1} paddingX={1}>
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
</Box>
{/* Step content */}
<Box
borderStyle="single"
borderColor={colors.primary}
flexDirection="column"
paddingX={1}
paddingY={1}
marginTop={1}
marginX={1}
flexGrow={1}
>
<Text color={colors.primary} bold>
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
</Text>
<Box marginTop={1} flexDirection="column">
{renderStep()}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Esc: Cancel import
</Text>
</Box>
</Box>
);
}
@@ -0,0 +1,74 @@
/**
* FetchInvitationStep — first step in the import flow.
*
* Receives an invitation ID, fetches the invitation from the sync server,
* resolves its template, and auto-advances once loaded.
* Shows a loading spinner while fetching and an error state with retry/cancel.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../../../../theme.js';
import type { FetchStepProps } from '../types.js';
export function FetchInvitationStep({
invitationId,
appService,
onComplete,
onCancel,
isActive,
}: FetchStepProps): React.ReactElement {
const [status, setStatus] = useState<'loading' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
/**
* Fetch the invitation and its template, then auto-advance.
*/
const fetchInvitation = useCallback(async () => {
setStatus('loading');
setErrorMessage(null);
try {
// Create/fetch the invitation instance (fetches from sync server if needed)
const invitation = await appService.createInvitation(invitationId);
// Resolve the template for display in later steps
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
// Auto-advance — hand the loaded data to the flow controller
onComplete(invitation, template ?? null);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorMessage(message);
setStatus('error');
}
}, [invitationId, appService, onComplete]);
// Kick off the fetch on mount
useEffect(() => {
if (isActive) {
fetchInvitation();
}
}, [isActive, fetchInvitation]);
return (
<Box flexDirection="column">
{status === 'loading' && (
<Box flexDirection="column">
<Text color={colors.info}>Fetching invitation...</Text>
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
</Box>
)}
{status === 'error' && (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to fetch invitation</Text>
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
</Box>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,226 @@
/**
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
*
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
* Auto-selects greedily, then lets the user toggle individual UTXOs.
* Shows required, selected, and change amounts.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
export function InputsSelectStep({
invitation,
template,
selectedRole,
appService,
onComplete,
onCancel,
isActive,
}: InputsSelectStepProps): React.ReactElement {
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
const [focusedIndex, setFocusedIndex] = useState(0);
const [requiredAmount, setRequiredAmount] = useState(0n);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fee = DEFAULT_FEE;
// Derived totals
const selectedAmount = utxos
.filter(u => u.selected)
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
const changeAmount = selectedAmount - requiredAmount - fee;
const hasEnough = selectedAmount >= requiredAmount + fee;
/**
* Determine the required satoshi amount from the invitation's variables.
*/
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
return await invitation.getSatsOut() ?? 0n;
}, [invitation]);
/**
* Fetch suitable UTXOs from the engine and auto-select greedily.
*/
const loadUtxos = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const required = await computeRequiredAmount();
setRequiredAmount(required);
const unspentOutputs = await invitation.findSuitableResources({
templateIdentifier: invitation.data.templateIdentifier,
outputIdentifier: 'receiveOutput',
});
// Map to selectable UTXOs
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
lockingBytecode: utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined,
selected: false,
}));
// Greedy auto-select, skipping duplicate locking bytecodes
let accumulated = 0n;
const seenBytecodes = new Set<string>();
for (const utxo of selectable) {
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
utxo.selected = true;
accumulated += utxo.valueSatoshis;
if (accumulated >= required + fee) break;
}
setUtxos(selectable);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsLoading(false);
}
}, [invitation, computeRequiredAmount, fee]);
// Load UTXOs on mount
useEffect(() => {
if (isActive) loadUtxos();
}, [isActive, loadUtxos]);
/**
* Toggle the selection of a UTXO at the given index.
*/
const toggleSelection = useCallback((index: number) => {
setUtxos(prev => {
const updated = [...prev];
const utxo = updated[index];
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
return updated;
});
}, []);
// Keyboard handling
useInput((input, key) => {
if (!isActive) return;
if (key.upArrow || input === 'k') {
setFocusedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
} else if (input === ' ' || (key.return && utxos.length > 0)) {
// Space or Enter toggles the focused UTXO
if (utxos.length > 0) toggleSelection(focusedIndex);
} else if (input === 'a') {
// Select all
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
} else if (input === 'n') {
// Deselect all
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
} else if (key.tab) {
// Tab confirms selection (moves to next step)
if (hasEnough) {
onComplete(utxos.filter(u => u.selected));
}
} else if (key.escape) {
onCancel();
}
}, { isActive });
// Loading state
if (isLoading) {
return (
<Box flexDirection="column">
<Text color={colors.info}>Finding suitable UTXOs...</Text>
</Box>
);
}
// Error state
if (error) {
return (
<Box flexDirection="column">
<Text color={colors.error} bold>Failed to load UTXOs</Text>
<Text color={colors.textMuted}>{error}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
// No UTXOs found
if (utxos.length === 0) {
return (
<Box flexDirection="column">
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Esc: Cancel</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column">
{/* Summary bar */}
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Required: </Text>
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={colors.primary} bold>Selected: </Text>
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
{hasEnough && changeAmount >= DUST_THRESHOLD && (
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
)}
{!hasEnough && (
<Text color={colors.error}> need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
)}
</Box>
{/* UTXO list */}
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
{utxos.map((utxo, index) => {
const isFocused = index === focusedIndex;
const checkMark = utxo.selected ? '☑' : '☐';
const txShort = utxo.outpointTransactionHash.slice(0, 8);
return (
<Text
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}:{utxo.outpointIndex})
</Text>
);
})}
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>
: Navigate Space: Toggle a: All n: None Tab: Confirm Esc: Cancel
</Text>
</Box>
</Box>
);
}
@@ -0,0 +1,167 @@
/**
* PreviewInvitationStep — displays the current state of a fetched invitation.
*
* Shows which roles, inputs, outputs, and variables have already been filled
* so the user can understand what they're joining before proceeding.
* Press Enter to continue, Esc to cancel.
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
} from '../../../../../utils/invitation-utils.js';
import type { PreviewStepProps } from '../types.js';
/**
* Map a semantic color name to an actual theme color value.
*/
function stateColor(state: string): string {
const name = getStateColorName(state);
switch (name) {
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':
default: return colors.textMuted as string;
}
}
export function PreviewInvitationStep({
invitation,
template,
onComplete,
onCancel,
isActive,
}: PreviewStepProps): React.ReactElement {
useInput((_input, key) => {
if (!isActive) return;
if (key.return) onComplete();
if (key.escape) onCancel();
}, { isActive });
const state = getInvitationState(invitation);
const action = template?.actions?.[invitation.data.actionIdentifier];
const inputs = getInvitationInputs(invitation);
const outputs = getInvitationOutputs(invitation);
const variables = getInvitationVariables(invitation);
// Collect role identifiers that appear across all commits
const filledRoles = new Set<string>();
for (const commit of invitation.data.commits ?? []) {
for (const input of commit.data?.inputs ?? []) {
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
}
}
return (
<Box flexDirection="column">
{/* Template & action info */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Template: </Text>
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
{template?.description && (
<Text color={colors.textMuted} dimColor>{template.description}</Text>
)}
</Box>
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Action: </Text>
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Status: </Text>
<Text color={stateColor(state)}>{state}</Text>
</Box>
</Box>
{/* Roles already filled */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
{filledRoles.size === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
Array.from(filledRoles).map(role => {
const roleInfoRaw = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Text key={role} color={colors.text}> {roleInfo?.name ?? role}</Text>
);
})
)}
</Box>
{/* Inputs & Outputs side by side */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
inputs.map((input, idx) => {
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
return (
<Text key={`input-${idx}`} color={colors.text}>
{' '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
);
})
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
<Text color={colors.textMuted}> None yet</Text>
) : (
outputs.map((output, idx) => {
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
return (
<Text key={`output-${idx}`} color={colors.text}>
{' '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
);
})
)}
</Box>
</Box>
{/* Variables */}
<Box flexDirection="column" marginBottom={1}>
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
{variables.length === 0 ? (
<Text color={colors.textMuted}> None set</Text>
) : (
variables.map((variable, idx) => {
const varTemplate = template?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Text key={`var-${idx}`} color={colors.text}>
{' '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
</Text>
);
})
)}
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter: Continue Esc: Cancel</Text>
</Box>
</Box>
);
}
@@ -0,0 +1,113 @@
/**
* ReviewStep — final step that summarizes the import and executes it.
*
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
* 1. Adds inputs (with the selected role identifier) to the invitation.
* 2. Optionally adds a change output if the change exceeds the dust threshold.
* 3. Calls `onComplete()` to signal the flow is finished.
*/
import React, { useState, useCallback } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors, formatSatoshis } from '../../../../theme.js';
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
/** Default fee estimate in satoshis. */
const DEFAULT_FEE = 500n;
/** Dust threshold — outputs below this are unspendable. */
const DUST_THRESHOLD = 546n;
export function ReviewStep({
invitation,
template,
selectedRole,
selectedInputs,
requiredAmount,
changeAmount,
appService,
onComplete,
onCancel,
isActive,
}: ReviewStepProps): React.ReactElement {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fee = DEFAULT_FEE;
const action = template?.actions?.[invitation.data.actionIdentifier];
// Compute totals from selected inputs
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
/**
* Execute the import: add inputs (with role) and optional change output.
*/
const submit = useCallback(async () => {
setIsSubmitting(true);
setError(null);
try {
onComplete();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setIsSubmitting(false);
}
}, [invitation, selectedRole, selectedInputs, onComplete]);
// Keyboard handling
useInput((_input, key) => {
if (!isActive || isSubmitting) return;
if (key.return) {
submit();
} else if (key.escape) {
onCancel();
}
}, { isActive });
// Resolve role display name
const roleInfoRaw = template?.roles?.[selectedRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box flexDirection="column">
<Text color={colors.primary} bold>Review Import</Text>
{/* Template & action */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
</Box>
{/* Funding summary */}
<Box marginTop={1} flexDirection="column">
<Text color={colors.primary} bold>Funding:</Text>
<Text color={colors.text}> UTXOs: {selectedInputs.length}</Text>
<Text color={colors.text}> Total: {formatSatoshis(totalSelected)}</Text>
<Text color={colors.text}> Required: {formatSatoshis(requiredAmount)}</Text>
<Text color={colors.text}> Fee: {formatSatoshis(fee)}</Text>
{changeAmount >= DUST_THRESHOLD && (
<Text color={colors.text}> Change: {formatSatoshis(changeAmount)}</Text>
)}
</Box>
{/* Error display */}
{error && (
<Box marginTop={1}>
<Text color={colors.error} bold>Error: {error}</Text>
</Box>
)}
{/* Status / hint */}
<Box marginTop={1}>
{isSubmitting ? (
<Text color={colors.info}>Submitting...</Text>
) : (
<Text color={colors.textMuted}>Enter: Confirm & Import Esc: Cancel</Text>
)}
</Box>
</Box>
);
}
@@ -0,0 +1,88 @@
/**
* RoleSelectStep — lets the user choose which role to take in the invitation.
*
* Displays available roles with their template-level and action-level descriptions.
* Arrow keys to navigate, Enter to select, Esc to cancel.
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { colors } from '../../../../theme.js';
import type { RoleSelectStepProps } from '../types.js';
export function RoleSelectStep({
invitation,
template,
availableRoles,
onComplete,
onCancel,
isActive,
}: RoleSelectStepProps): React.ReactElement {
const [selectedIndex, setSelectedIndex] = useState(0);
useInput((input, key) => {
if (!isActive) return;
if (key.upArrow || input === 'k') {
setSelectedIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
} else if (key.return) {
const role = availableRoles[selectedIndex];
if (role) onComplete(role);
} else if (key.escape) {
onCancel();
}
}, { isActive });
const action = template?.actions?.[invitation.data.actionIdentifier];
return (
<Box flexDirection="column">
{/* Context header */}
<Box marginBottom={1} flexDirection="column">
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
</Box>
{/* Role list */}
<Box 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 = template?.roles?.[role];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
const actionRoleRaw = action?.roles?.[role];
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
const isFocused = index === selectedIndex;
return (
<Box key={role} flexDirection="column">
<Text
color={isFocused ? colors.focus : colors.text}
bold={isFocused}
>
{isFocused ? '▸ ' : ' '}
{roleInfo?.name ?? role}
</Text>
{(roleInfo?.description || actionRole?.description) && (
<Text color={colors.textMuted} dimColor>
{' '}{actionRole?.description ?? roleInfo?.description}
</Text>
)}
</Box>
);
})
)}
</Box>
{/* Navigation hint */}
<Box marginTop={1}>
<Text color={colors.textMuted}>: Select role Enter: Accept Esc: Cancel</Text>
</Box>
</Box>
);
}
@@ -0,0 +1,122 @@
/**
* Shared types for the invitation import flow.
*
* Each step in the flow receives only what it needs via props (dependency injection).
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
*/
import type { Invitation } from '../../../../services/invitation.js';
import type { AppService } from '../../../../services/app.js';
import type { XOTemplate } from '@xo-cash/types';
// ── Step definitions ─────────────────────────────────────────────────────────
/** Identifies each step in the import flow. */
export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review';
/** A single step descriptor used by the flow controller and step indicator. */
export interface ImportStep {
name: string;
type: ImportStepType;
}
/** The ordered list of steps in the import flow. */
export const IMPORT_STEPS: ImportStep[] = [
{ name: 'Fetch', type: 'fetch' },
{ name: 'Preview', type: 'preview' },
{ name: 'Select Role', type: 'role-select' },
{ name: 'Select Inputs', type: 'inputs-select' },
{ name: 'Review', type: 'review' },
];
// ── Display mode ─────────────────────────────────────────────────────────────
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
export type ImportFlowMode = 'dialog' | 'screen';
// ── UTXO selection ───────────────────────────────────────────────────────────
/** A UTXO that the user can toggle on/off during the inputs step. */
export interface SelectableUTXO {
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
lockingBytecode?: string;
selected: boolean;
}
// ── Step props ───────────────────────────────────────────────────────────────
// Each step receives exactly the data and callbacks it needs.
/** Props for FetchInvitationStep — loads the invitation from an ID. */
export interface FetchStepProps {
invitationId: string;
appService: AppService;
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for PreviewInvitationStep — displays invitation state. */
export interface PreviewStepProps {
invitation: Invitation;
template: XOTemplate | null;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for RoleSelectStep — lets user pick a role. */
export interface RoleSelectStepProps {
invitation: Invitation;
template: XOTemplate | null;
availableRoles: string[];
onComplete: (selectedRole: string) => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
export interface InputsSelectStepProps {
invitation: Invitation;
template: XOTemplate | null;
selectedRole: string;
appService: AppService;
onComplete: (inputs: SelectableUTXO[]) => void;
onCancel: () => void;
isActive: boolean;
}
/** Props for ReviewStep — summarizes and executes the import. */
export interface ReviewStepProps {
invitation: Invitation;
template: XOTemplate | null;
selectedRole: string;
selectedInputs: SelectableUTXO[];
changeAmount: bigint;
requiredAmount: bigint;
appService: AppService;
onComplete: () => void;
onCancel: () => void;
isActive: boolean;
}
// ── Flow controller props ────────────────────────────────────────────────────
/** Props for the top-level InvitationImportFlow component. */
export interface ImportFlowProps {
/** The invitation ID to import (already entered by the user in InvitationScreen). */
invitationId: string;
/** Whether to render as a dialog overlay or a full screen. */
mode: ImportFlowMode;
/** The application service — injected, not pulled from context. */
appService: AppService;
/** Called when the flow completes or is cancelled. */
onClose: () => void;
/** Display an error message to the user. */
showError: (message: string) => void;
/** Display an info message to the user. */
showInfo: (message: string) => void;
/** Update the global status bar. */
setStatus: (message: string) => void;
}