422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
/**
|
|
* 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 } 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 { VariablesStep } from './steps/VariablesStep.js';
|
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
|
import { ReviewStep } from './steps/ReviewStep.js';
|
|
|
|
import { IMPORT_STEPS, type ImportFlowProps, type ImportStepType, type ImportVariableInput, 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 { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
|
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;
|
|
|
|
/**
|
|
* Resolve the fixed index of a flow step from `IMPORT_STEPS`.
|
|
* We centralize this so step transitions do not rely on magic numbers.
|
|
*/
|
|
function getStepIndex(type: ImportStepType): number {
|
|
const index = IMPORT_STEPS.findIndex((step) => step.type === type);
|
|
if (index === -1) {
|
|
throw new Error(`Import step not found: ${type}`);
|
|
}
|
|
return index;
|
|
}
|
|
|
|
const PREVIEW_STEP_INDEX = getStepIndex('preview');
|
|
const ROLE_SELECT_STEP_INDEX = getStepIndex('role-select');
|
|
const VARIABLES_STEP_INDEX = getStepIndex('variables');
|
|
const INPUTS_SELECT_STEP_INDEX = getStepIndex('inputs-select');
|
|
const REVIEW_STEP_INDEX = getStepIndex('review');
|
|
|
|
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 [template, setTemplate] = useState<XOTemplate | null>(null);
|
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
|
const [variableInputs, setVariableInputs] = useState<ImportVariableInput[]>([]);
|
|
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);
|
|
|
|
try {
|
|
const roles = await inv.getAvailableRoles();
|
|
setAvailableRoles(roles);
|
|
} catch (err) {
|
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
|
|
setCurrentStep(PREVIEW_STEP_INDEX); // → Preview
|
|
}, [showError]);
|
|
|
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
|
const handlePreviewComplete = useCallback(() => {
|
|
setCurrentStep(ROLE_SELECT_STEP_INDEX); // → Role Select
|
|
}, []);
|
|
|
|
/** RoleSelectStep completed — user picked a role. */
|
|
const handleRoleComplete = useCallback((role: string) => {
|
|
setSelectedRole(role);
|
|
|
|
const action = template?.actions?.[invitation?.data.actionIdentifier ?? ""];
|
|
const roleRequirements = action?.roles?.[role]?.requirements?.variables ?? [];
|
|
const hasRequiredVariables = roleRequirements.length > 0;
|
|
|
|
if (!hasRequiredVariables) {
|
|
setVariableInputs([]);
|
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
|
return;
|
|
}
|
|
|
|
const initializedVariables: ImportVariableInput[] = roleRequirements.map((variableId) => {
|
|
const variableDefinition = template?.variables?.[variableId];
|
|
return {
|
|
id: variableId,
|
|
name: variableDefinition?.name ?? variableId,
|
|
type: variableDefinition?.type ?? 'string',
|
|
hint: variableDefinition?.hint,
|
|
value: '',
|
|
};
|
|
});
|
|
|
|
setVariableInputs(initializedVariables);
|
|
setCurrentStep(VARIABLES_STEP_INDEX); // → Variables
|
|
}, [template, invitation]);
|
|
|
|
/** VariablesStep edited a field value. */
|
|
const handleVariableUpdate = useCallback((index: number, value: string) => {
|
|
setVariableInputs((previous) => {
|
|
const updated = [...previous];
|
|
const current = updated[index];
|
|
if (current) {
|
|
updated[index] = { ...current, value };
|
|
}
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
/**
|
|
* Convert variable input value to its invitation payload representation.
|
|
* Numeric variables are persisted as bigint so they match action wizard behavior.
|
|
*/
|
|
const parseVariableValue = useCallback((variable: ImportVariableInput) => {
|
|
const variableHint = variable.hint?.toLowerCase();
|
|
const isNumeric =
|
|
['integer', 'number', 'satoshis'].includes(variable.type) ||
|
|
(variableHint !== undefined && ['satoshis', 'amount'].includes(variableHint));
|
|
|
|
if (!isNumeric) {
|
|
return variable.value;
|
|
}
|
|
|
|
return BigInt(variable.value || '0');
|
|
}, []);
|
|
|
|
/** VariablesStep completed — persist variables then continue to input selection. */
|
|
const handleVariablesComplete = useCallback(async () => {
|
|
if (!invitation || !selectedRole) return;
|
|
|
|
const emptyVariables = variableInputs.filter((variable) => variable.value.trim() === '');
|
|
if (emptyVariables.length > 0) {
|
|
showError(`Please enter values for: ${emptyVariables.map((variable) => variable.name).join(', ')}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await invitation.addVariables(
|
|
variableInputs.map((variable) => ({
|
|
variableIdentifier: variable.id,
|
|
roleIdentifier: selectedRole,
|
|
value: parseVariableValue(variable),
|
|
})),
|
|
);
|
|
setCurrentStep(INPUTS_SELECT_STEP_INDEX); // → Inputs Select
|
|
} catch (error) {
|
|
showError(
|
|
`Failed to add variables: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
}, [invitation, selectedRole, variableInputs, parseVariableValue, showError]);
|
|
|
|
/** 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);
|
|
|
|
// Add the change output if it exceeds the dust threshold
|
|
if (changeAmountSats >= DUST_THRESHOLD) {
|
|
await invitation?.addOutputs([{
|
|
valueSatoshis: changeAmountSats,
|
|
}]);
|
|
}
|
|
|
|
setCurrentStep(REVIEW_STEP_INDEX); // → Review
|
|
}, [invitation]);
|
|
|
|
/** 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(invitation?.data.invitationIdentifier);
|
|
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
|
|
|
// ── Keyboard handling ────────────────────────────────────────────────────
|
|
// The import flow registers its own layer so it captures input above the
|
|
// parent screen. Individual steps also register sub-layers when needed.
|
|
useInputLayer('import-flow');
|
|
|
|
useLayeredInput('import-flow', (_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();
|
|
});
|
|
|
|
// ── 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 'variables':
|
|
return (
|
|
<VariablesStep
|
|
variables={variableInputs}
|
|
onUpdateVariable={handleVariableUpdate}
|
|
onComplete={handleVariablesComplete}
|
|
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>
|
|
);
|
|
}
|