Large amount of changes. Successfully broadcasts txs
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user