Clean up and fixes
This commit is contained in:
@@ -37,13 +37,13 @@ function VariableInputField({
|
||||
focusColor,
|
||||
}: VariableInputFieldProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box flexDirection='column' marginBottom={1}>
|
||||
<Text color={focusColor}>{variable.name}</Text>
|
||||
{variable.hint && (
|
||||
<Text color={borderColor} dimColor>({variable.hint})</Text>
|
||||
)}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={isFocused ? focusColor : borderColor}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
@@ -107,7 +107,7 @@ interface SelectableUTXO {
|
||||
*/
|
||||
export function ActionWizardScreen(): React.ReactElement {
|
||||
const { navigate, goBack, data: navData } = useNavigation();
|
||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Extract navigation data
|
||||
@@ -210,7 +210,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
* Load available UTXOs for the inputs step.
|
||||
*/
|
||||
const loadAvailableUtxos = useCallback(async () => {
|
||||
if (!invitation || !templateIdentifier) return;
|
||||
if (!invitation || !templateIdentifier || !appService || !invitationId) return;
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
@@ -224,14 +224,23 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
const requested = requestedVar ? BigInt(requestedVar.value || '0') : 0n;
|
||||
setRequiredAmount(requested);
|
||||
|
||||
// Get the invitation instance
|
||||
const invitationInstance = appService.invitations.find(
|
||||
inv => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Find suitable resources
|
||||
const resources = await walletController.findSuitableResources(invitation, {
|
||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||
templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput', // Common output identifier
|
||||
});
|
||||
|
||||
// Convert to selectable UTXOs
|
||||
const utxos: SelectableUTXO[] = (resources.unspentOutputs || []).map((utxo: any) => ({
|
||||
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
@@ -271,7 +280,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitation, templateIdentifier, variables, walletController, showError, setStatus]);
|
||||
}, [invitation, templateIdentifier, variables, appService, invitationId, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Toggle UTXO selection.
|
||||
@@ -330,19 +339,24 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
* Create invitation and add variables.
|
||||
*/
|
||||
const createInvitationWithVariables = useCallback(async () => {
|
||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template) return;
|
||||
if (!templateIdentifier || !actionIdentifier || !roleIdentifier || !template || !appService) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Creating invitation...');
|
||||
|
||||
try {
|
||||
// Create invitation
|
||||
const tracked = await invitationController.createInvitation(
|
||||
// Create invitation using the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
);
|
||||
});
|
||||
|
||||
let inv = tracked.invitation;
|
||||
// Wrap it in an Invitation instance and add to AppService tracking
|
||||
const invitationInstance = await appService.createInvitation(xoInvitation);
|
||||
|
||||
console.log('Invitation Instance:', invitationInstance);
|
||||
|
||||
let inv = invitationInstance.data;
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
@@ -361,8 +375,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||
};
|
||||
});
|
||||
const updated = await invitationController.addVariables(invId, variableData);
|
||||
inv = updated.invitation;
|
||||
await invitationInstance.addVariables(variableData);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
// Add template-required outputs for the current role
|
||||
@@ -382,8 +396,8 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
// Note: roleIdentifier intentionally omitted to trigger lockingBytecode generation
|
||||
}));
|
||||
|
||||
const updated = await invitationController.addOutputs(invId, outputsToAdd);
|
||||
inv = updated.invitation;
|
||||
await invitationInstance.addOutputs(outputsToAdd);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
@@ -404,13 +418,13 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, invitationController, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
|
||||
}, [templateIdentifier, actionIdentifier, roleIdentifier, template, variables, appService, steps, currentStep, showError, setStatus, loadAvailableUtxos]);
|
||||
|
||||
/**
|
||||
* Add selected inputs and change output to invitation.
|
||||
*/
|
||||
const addInputsAndOutputs = useCallback(async () => {
|
||||
if (!invitationId || !invitation) return;
|
||||
if (!invitationId || !invitation || !appService) return;
|
||||
|
||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||
|
||||
@@ -433,13 +447,22 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
setStatus('Adding inputs and outputs...');
|
||||
|
||||
try {
|
||||
// Get the invitation instance
|
||||
const invitationInstance = appService.invitations.find(
|
||||
inv => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Add inputs
|
||||
const inputs = selectedUtxos.map(utxo => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointTransactionHash: new Uint8Array(Buffer.from(utxo.outpointTransactionHash, 'hex')),
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
}));
|
||||
|
||||
await invitationController.addInputs(invitationId, inputs);
|
||||
await invitationInstance.addInputs(inputs);
|
||||
|
||||
// Add change output
|
||||
const outputs = [{
|
||||
@@ -447,7 +470,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
// The engine will automatically generate the locking bytecode for change
|
||||
}];
|
||||
|
||||
await invitationController.addOutputs(invitationId, outputs);
|
||||
await invitationInstance.addOutputs(outputs);
|
||||
|
||||
// Add transaction metadata
|
||||
// Note: This would be done via appendInvitation but we don't have direct access here
|
||||
@@ -460,19 +483,31 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, invitationController, showError, setStatus]);
|
||||
}, [invitationId, invitation, availableUtxos, selectedAmount, requiredAmount, fee, changeAmount, appService, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Publish invitation.
|
||||
*/
|
||||
const publishInvitation = useCallback(async () => {
|
||||
if (!invitationId) return;
|
||||
if (!invitationId || !appService) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Publishing invitation...');
|
||||
|
||||
try {
|
||||
await invitationController.publishAndSubscribe(invitationId);
|
||||
// Get the invitation instance
|
||||
const invitationInstance = appService.invitations.find(
|
||||
inv => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// The invitation is already being tracked and synced via SSE
|
||||
// (started when created by appService.createInvitation)
|
||||
// No additional publish step needed
|
||||
|
||||
setCurrentStep(prev => prev + 1);
|
||||
setStatus('Invitation published');
|
||||
} catch (error) {
|
||||
@@ -480,7 +515,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, invitationController, showError, setStatus]);
|
||||
}, [invitationId, appService, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Navigate to previous step.
|
||||
@@ -634,7 +669,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
switch (currentStepData.type) {
|
||||
case 'info':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.primary} bold>Action: {actionName}</Text>
|
||||
<Text color={colors.textMuted}>{action?.description || 'No description'}</Text>
|
||||
<Box marginTop={1}>
|
||||
@@ -643,7 +678,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
{action?.roles?.[roleIdentifier ?? '']?.requirements && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Requirements:</Text>
|
||||
{action.roles[roleIdentifier ?? '']?.requirements?.variables?.map(v => (
|
||||
<Text key={v} color={colors.textMuted}> • Variable: {v}</Text>
|
||||
@@ -658,9 +693,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
|
||||
case 'variables':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>Enter required values:</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableInputField
|
||||
key={variable.id}
|
||||
@@ -684,10 +719,10 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
|
||||
case 'inputs':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>Select UTXOs to fund the transaction:</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.textMuted}>
|
||||
Required: {formatSatoshis(requiredAmount)} + {formatSatoshis(fee)} fee
|
||||
</Text>
|
||||
@@ -701,7 +736,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} flexDirection="column" borderStyle="single" borderColor={colors.border} paddingX={1}>
|
||||
<Box marginTop={1} flexDirection='column' borderStyle='single' borderColor={colors.border} paddingX={1}>
|
||||
{availableUtxos.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||
) : (
|
||||
@@ -730,17 +765,17 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
case 'review':
|
||||
const selectedUtxos = availableUtxos.filter(u => u.selected);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>Review your invitation:</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||
</Box>
|
||||
|
||||
{variables.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Variables:</Text>
|
||||
{variables.map(v => (
|
||||
<Text key={v.id} color={colors.textMuted}>
|
||||
@@ -751,7 +786,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{selectedUtxos.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Inputs ({selectedUtxos.length}):</Text>
|
||||
{selectedUtxos.slice(0, 3).map(u => (
|
||||
<Text key={`${u.outpointTransactionHash}:${u.outpointIndex}`} color={colors.textMuted}>
|
||||
@@ -765,7 +800,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
)}
|
||||
|
||||
{changeAmount > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Outputs:</Text>
|
||||
<Text color={colors.textMuted}> Change: {formatSatoshis(changeAmount)}</Text>
|
||||
</Box>
|
||||
@@ -781,12 +816,12 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
|
||||
case 'publish':
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.success} bold>✓ Invitation Created & Published!</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Invitation ID:</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={colors.primary}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
@@ -816,9 +851,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
const stepIndicatorSteps: Step[] = steps.map(s => ({ label: s.name }));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<Box flexDirection='column' flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1} flexDirection='column'>
|
||||
<Text color={colors.primary} bold>{logoSmall} - Action Wizard</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{template?.name} {'>'} {actionName} (as {roleIdentifier})
|
||||
@@ -832,9 +867,9 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
|
||||
{/* Content area */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={focusArea === 'content' ? colors.focus : colors.primary}
|
||||
flexDirection="column"
|
||||
flexDirection='column'
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
marginTop={1}
|
||||
@@ -854,15 +889,15 @@ export function ActionWizardScreen(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
{/* Buttons */}
|
||||
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
||||
<Box marginTop={1} marginX={1} justifyContent='space-between'>
|
||||
<Box gap={1}>
|
||||
<Button
|
||||
label="Back"
|
||||
label='Back'
|
||||
focused={focusArea === 'buttons' && focusedButton === 'back'}
|
||||
disabled={currentStepData?.type === 'publish'}
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
label='Cancel'
|
||||
focused={focusArea === 'buttons' && focusedButton === 'cancel'}
|
||||
/>
|
||||
</Box>
|
||||
@@ -14,14 +14,36 @@ import { Box, Text, useInput } from 'ink';
|
||||
import { InputDialog } from '../components/Dialog.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 { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { TrackedInvitation, InvitationState } from '../../services/invitation-flow.js';
|
||||
import type { Invitation } from '../../services/invitation.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.
|
||||
*/
|
||||
function getStateColor(state: InvitationState): string {
|
||||
function getStateColor(state: string): string {
|
||||
switch (state) {
|
||||
case 'created':
|
||||
case 'published':
|
||||
@@ -52,7 +74,6 @@ const actionItems = [
|
||||
{ label: 'Sign Transaction', value: 'sign' },
|
||||
{ label: 'View Transaction', value: 'transaction' },
|
||||
{ label: 'Copy Invitation ID', value: 'copy' },
|
||||
{ label: 'Refresh', value: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -60,11 +81,13 @@ const actionItems = [
|
||||
*/
|
||||
export function InvitationScreen(): React.ReactElement {
|
||||
const { navigate, data: navData } = useNavigation();
|
||||
const { walletController, invitationController, showError, showInfo } = useAppContext();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Use hooks for reactive invitation list
|
||||
const invitations = useInvitations();
|
||||
|
||||
// State
|
||||
const [invitations, setInvitations] = useState<TrackedInvitation[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
|
||||
@@ -75,37 +98,13 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
|
||||
/**
|
||||
* Load invitations.
|
||||
*/
|
||||
const loadInvitations = useCallback(() => {
|
||||
const tracked = invitationController.getAllInvitations();
|
||||
setInvitations(tracked);
|
||||
}, [invitationController]);
|
||||
|
||||
/**
|
||||
* Set up event listeners and initial load.
|
||||
* Show import dialog on mount if needed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
loadInvitations();
|
||||
|
||||
// Listen for updates
|
||||
const handleUpdate = () => {
|
||||
loadInvitations();
|
||||
};
|
||||
|
||||
invitationController.on('invitation-updated', handleUpdate);
|
||||
invitationController.on('invitation-state-changed', handleUpdate);
|
||||
|
||||
// Show import dialog if mode is 'import'
|
||||
if (initialMode === 'import') {
|
||||
setShowImportDialog(true);
|
||||
}
|
||||
|
||||
return () => {
|
||||
invitationController.off('invitation-updated', handleUpdate);
|
||||
invitationController.off('invitation-state-changed', handleUpdate);
|
||||
};
|
||||
}, [invitationController, loadInvitations, initialMode]);
|
||||
}, [initialMode]);
|
||||
|
||||
// Get selected invitation
|
||||
const selectedInvitation = invitations[selectedIndex];
|
||||
@@ -115,24 +114,23 @@ export function InvitationScreen(): React.ReactElement {
|
||||
*/
|
||||
const importInvitation = useCallback(async (invitationId: string) => {
|
||||
setShowImportDialog(false);
|
||||
if (!invitationId.trim()) return;
|
||||
if (!invitationId.trim() || !appService) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Importing invitation...');
|
||||
|
||||
const tracked = await invitationController.importInvitation(invitationId);
|
||||
await invitationController.publishAndSubscribe(tracked.invitation.invitationIdentifier);
|
||||
// Create invitation instance (will fetch from sync server)
|
||||
const invitation = await appService.createInvitation(invitationId);
|
||||
|
||||
loadInvitations();
|
||||
showInfo(`Invitation imported!\n\nTemplate: ${tracked.invitation.templateIdentifier}\nAction: ${tracked.invitation.actionIdentifier}`);
|
||||
showInfo(`Invitation imported!\n\nTemplate: ${invitation.data.templateIdentifier}\nAction: ${invitation.data.actionIdentifier}`);
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [invitationController, loadInvitations, showInfo, showError, setStatus]);
|
||||
}, [appService, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Accept selected invitation.
|
||||
@@ -147,8 +145,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setIsLoading(true);
|
||||
setStatus('Accepting invitation...');
|
||||
|
||||
await invitationController.acceptInvitation(selectedInvitation.invitation.invitationIdentifier);
|
||||
loadInvitations();
|
||||
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) {
|
||||
@@ -162,7 +159,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Sign selected invitation.
|
||||
@@ -177,8 +174,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setIsLoading(true);
|
||||
setStatus('Signing invitation...');
|
||||
|
||||
await invitationController.signInvitation(selectedInvitation.invitation.invitationIdentifier);
|
||||
loadInvitations();
|
||||
await selectedInvitation.sign();
|
||||
showInfo('Invitation signed!');
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
@@ -186,7 +182,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, invitationController, loadInvitations, showInfo, showError, setStatus]);
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Copy invitation ID.
|
||||
@@ -198,8 +194,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await copyToClipboard(selectedInvitation.invitation.invitationIdentifier);
|
||||
showInfo(`Copied!\n\n${selectedInvitation.invitation.invitationIdentifier}`);
|
||||
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)}`);
|
||||
}
|
||||
@@ -219,14 +215,12 @@ export function InvitationScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const invId = selectedInvitation.invitation.invitationIdentifier;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Step 1: Check available roles
|
||||
setStatus('Checking available roles...');
|
||||
const availableRoles = await invitationController.getAvailableRoles(invId);
|
||||
const availableRoles = await selectedInvitation.getAvailableRoles();
|
||||
|
||||
if (availableRoles.length === 0) {
|
||||
// Already participating, check if we can add inputs
|
||||
@@ -240,7 +234,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setStatus(`Accepting as ${roleToTake}...`);
|
||||
|
||||
try {
|
||||
await invitationController.acceptInvitation(invId);
|
||||
await selectedInvitation.accept();
|
||||
} catch (e) {
|
||||
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
|
||||
setStatus('Ready');
|
||||
@@ -250,17 +244,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
// Step 2: Check if invitation already has inputs or needs funding
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
// Get the tracked invitation with updated state
|
||||
const tracked = invitationController.getInvitation(invId);
|
||||
if (!tracked) {
|
||||
throw new Error('Invitation not found after accepting');
|
||||
}
|
||||
|
||||
// Calculate how much we need
|
||||
// Look for a requestedSatoshis variable in the invitation
|
||||
let requiredAmount = 0n;
|
||||
const commits = tracked.invitation.commits || [];
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
const variables = commit.data?.variables || [];
|
||||
for (const variable of variables) {
|
||||
@@ -276,17 +264,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const dust = 546n; // Dust threshold
|
||||
const totalNeeded = requiredAmount + fee + dust;
|
||||
|
||||
|
||||
// Find resources - use a common output identifier
|
||||
const resources = await walletController.findSuitableResources(
|
||||
tracked.invitation,
|
||||
{
|
||||
templateIdentifier: tracked.invitation.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput', // Try common identifier
|
||||
}
|
||||
);
|
||||
|
||||
const utxos = (resources as any)?.unspentOutputs || [];
|
||||
const utxos = await selectedInvitation.findSuitableResources({
|
||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput', // Try common identifier
|
||||
});
|
||||
|
||||
if (utxos.length === 0) {
|
||||
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||
@@ -342,10 +324,9 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
// Step 6: Add inputs to the invitation
|
||||
setStatus('Adding inputs...');
|
||||
await invitationController.addInputs(
|
||||
invId,
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
outpointTransactionHash: u.outpointTransactionHash,
|
||||
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
|
||||
outpointIndex: u.outpointIndex,
|
||||
}))
|
||||
);
|
||||
@@ -353,13 +334,12 @@ export function InvitationScreen(): React.ReactElement {
|
||||
// Step 7: Add change output
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await invitationController.addOutputs(invId, [{
|
||||
await selectedInvitation.addOutputs([{
|
||||
valueSatoshis: changeAmount,
|
||||
}]);
|
||||
}
|
||||
|
||||
// Reload and show success
|
||||
loadInvitations();
|
||||
// Show success
|
||||
showInfo(
|
||||
`Requirements filled!\n\n` +
|
||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||
@@ -377,7 +357,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedInvitation, invitationController, walletController, loadInvitations, showInfo, showError, setStatus]);
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Handle action selection.
|
||||
@@ -401,14 +381,11 @@ export function InvitationScreen(): React.ReactElement {
|
||||
break;
|
||||
case 'transaction':
|
||||
if (selectedInvitation) {
|
||||
navigate('transaction', { invitationId: selectedInvitation.invitation.invitationIdentifier });
|
||||
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
|
||||
}
|
||||
break;
|
||||
case 'refresh':
|
||||
loadInvitations();
|
||||
break;
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate, loadInvitations]);
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
@@ -482,17 +459,20 @@ export function InvitationScreen(): React.ReactElement {
|
||||
{invitations.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No invitations</Text>
|
||||
) : (
|
||||
invitations.map((inv, index) => (
|
||||
<Text
|
||||
key={inv.invitation.invitationIdentifier}
|
||||
color={index === selectedIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedIndex}
|
||||
>
|
||||
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
|
||||
<Text color={getStateColor(inv.state)}>[{inv.state}]</Text>
|
||||
{' '}{formatHex(inv.invitation.invitationIdentifier, 12)}
|
||||
</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>
|
||||
@@ -511,61 +491,68 @@ export function InvitationScreen(): React.ReactElement {
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{selectedInvitation ? (
|
||||
<>
|
||||
<Text color={colors.text}>ID: {formatHex(selectedInvitation.invitation.invitationIdentifier, 20)}</Text>
|
||||
<Text color={colors.text}>
|
||||
State: <Text color={getStateColor(selectedInvitation.state)}>{selectedInvitation.state}</Text>
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Template: {selectedInvitation.invitation.templateIdentifier?.slice(0, 20)}...
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Action: {selectedInvitation.invitation.actionIdentifier}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
Commits: {selectedInvitation.invitation.commits?.length ?? 0}
|
||||
</Text>
|
||||
|
||||
{/* State-specific guidance */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{selectedInvitation.state === 'created' && (
|
||||
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
||||
)}
|
||||
{selectedInvitation.state === 'published' && (
|
||||
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
||||
)}
|
||||
{selectedInvitation.state === 'pending' && (
|
||||
{(() => {
|
||||
const state = getInvitationState(selectedInvitation);
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
{selectedInvitation.state === 'ready' && (
|
||||
<>
|
||||
<Text color={colors.success}>→ Ready to sign!</Text>
|
||||
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
||||
</>
|
||||
)}
|
||||
{selectedInvitation.state === 'signed' && (
|
||||
<>
|
||||
<Text color={colors.success}>→ Signed!</Text>
|
||||
<Text color={colors.success}> View Transaction to broadcast</Text>
|
||||
</>
|
||||
)}
|
||||
{selectedInvitation.state === 'broadcast' && (
|
||||
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
||||
)}
|
||||
{selectedInvitation.state === 'completed' && (
|
||||
<Text color={colors.success}>✓ Transaction completed!</Text>
|
||||
)}
|
||||
{selectedInvitation.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>
|
||||
|
||||
@@ -23,7 +23,7 @@ type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
||||
*/
|
||||
export function SeedInputScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
const { walletController, showError, setWalletInitialized } = useAppContext();
|
||||
const { initializeWallet } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// State
|
||||
@@ -65,12 +65,11 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Initialize wallet via controller
|
||||
await walletController.initialize(seed);
|
||||
// Initialize wallet and create AppService
|
||||
await initializeWallet(seed);
|
||||
|
||||
showStatus('Wallet initialized successfully!', 'success');
|
||||
setStatus('Wallet ready');
|
||||
setWalletInitialized(true);
|
||||
|
||||
// Clear sensitive data before navigating
|
||||
setSeedPhrase('');
|
||||
@@ -86,7 +85,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
setStatus('Initialization failed');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [seedPhrase, walletController, navigate, showStatus, setStatus, setWalletInitialized]);
|
||||
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
useInput((input, key) => {
|
||||
@@ -116,7 +115,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
colors.border;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={1}>
|
||||
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
||||
{/* Logo */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.primary}>{logo}</Text>
|
||||
@@ -130,10 +129,10 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
<Box marginY={1} />
|
||||
|
||||
{/* Input section */}
|
||||
<Box flexDirection="column" width={64}>
|
||||
<Box flexDirection='column' width={64}>
|
||||
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={inputBorderColor}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
@@ -142,7 +141,7 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
value={seedPhrase}
|
||||
onChange={setSeedPhrase}
|
||||
onSubmit={handleSubmit}
|
||||
placeholder="Enter your seed phrase..."
|
||||
placeholder='Enter your seed phrase...'
|
||||
focus={focusedElement === 'input' && !isSubmitting}
|
||||
/>
|
||||
</Box>
|
||||
@@ -160,12 +159,12 @@ export function SeedInputScreen(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
{/* Submit button */}
|
||||
<Box justifyContent="center" marginTop={1}>
|
||||
<Box justifyContent='center' marginTop={1}>
|
||||
<Button
|
||||
label="Continue"
|
||||
label='Continue'
|
||||
focused={focusedElement === 'button'}
|
||||
disabled={isSubmitting}
|
||||
shortcut="Enter"
|
||||
shortcut='Enter'
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -11,7 +11,10 @@ import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, logoSmall } from '../theme.js';
|
||||
import type { XOTemplate, XOTemplateStartingActions } from '@xo-cash/types';
|
||||
|
||||
// XO Imports
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
import type { XOTemplate, XOTemplateActionRoleRequirement, XOTemplateStartingActions } from '@xo-cash/types';
|
||||
|
||||
/**
|
||||
* Template item with metadata.
|
||||
@@ -28,7 +31,7 @@ interface TemplateItem {
|
||||
*/
|
||||
export function TemplateListScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
const { walletController, showError } = useAppContext();
|
||||
const { appService, showError } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// State
|
||||
@@ -39,20 +42,24 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
/**
|
||||
* Loads templates from the wallet controller.
|
||||
* Loads templates from the engine.
|
||||
*/
|
||||
const loadTemplates = useCallback(async () => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Loading templates...');
|
||||
|
||||
const templateList = await walletController.getTemplates();
|
||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||
const templateList = await appService.engine.listImportedTemplates();
|
||||
|
||||
const loadedTemplates = await Promise.all(
|
||||
templateList.map(async (template) => {
|
||||
const templateIdentifier = generateTemplateIdentifier(template);
|
||||
const startingActions = await walletController.getStartingActions(templateIdentifier);
|
||||
const startingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||
return { template, templateIdentifier, startingActions };
|
||||
})
|
||||
);
|
||||
@@ -66,7 +73,7 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [walletController, setStatus, showError]);
|
||||
}, [appService, setStatus, showError]);
|
||||
|
||||
// Load templates on mount
|
||||
useEffect(() => {
|
||||
@@ -127,31 +134,39 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
});
|
||||
|
||||
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">
|
||||
{isLoading ? (
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
) : templates.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No templates imported</Text>
|
||||
) : (
|
||||
templates.map((item, index) => (
|
||||
<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}
|
||||
@@ -160,29 +175,43 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
));
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
</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">
|
||||
{!currentTemplate ? (
|
||||
<Text color={colors.textMuted}>Select a template...</Text>
|
||||
) : currentActions.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
||||
) : (
|
||||
currentActions.map((action, index) => {
|
||||
<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) => {
|
||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||
const name = actionDef?.name || action.action;
|
||||
return (
|
||||
@@ -195,8 +224,9 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
{index + 1}. {name} (as {action.role})
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
});
|
||||
})()}
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -205,16 +235,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>
|
||||
{currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
{/* Show template description when templates panel is focused */}
|
||||
{focusedPanel === 'templates' && currentTemplate ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text} bold>
|
||||
{currentTemplate.template.name || 'Unnamed Template'}
|
||||
</Text>
|
||||
@@ -227,7 +259,7 @@ 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}>
|
||||
@@ -237,9 +269,70 @@ export function TemplateListScreen(): React.ReactElement {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
) : focusedPanel === 'templates' && !currentTemplate ? (
|
||||
<Text color={colors.textMuted}>Select a template to see details</Text>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Show action description when actions panel is focused */}
|
||||
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{(() => {
|
||||
const action = currentActions[selectedActionIndex];
|
||||
if (!action) return null;
|
||||
|
||||
const actionDef = currentTemplate.template.actions?.[action.action];
|
||||
const roleDef = currentTemplate.template.roles?.[action.role];
|
||||
|
||||
// if (!actionDef || !roleDef) return null;
|
||||
|
||||
const [_roleName, role] = Object.entries(actionDef?.roles ?? {}).find(([roleId, role]) => roleId === action.role) || [];
|
||||
|
||||
console.log(JSON.stringify(role, null, 2));
|
||||
|
||||
const variableKeys = role?.requirements?.variables || []
|
||||
|
||||
console.log('variables', variableKeys);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text color={colors.text} bold>
|
||||
{actionDef?.name || action.action}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{actionDef?.description || 'No description available'}
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>
|
||||
Role: {roleDef?.name || action.role}
|
||||
</Text>
|
||||
{roleDef?.description && (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}{roleDef.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{/* Display variables if available */}
|
||||
{
|
||||
variableKeys.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Variables:</Text>
|
||||
{variableKeys.map((variableKey) => (
|
||||
<Text key={variableKey} color={colors.text}>
|
||||
- {currentTemplate.template.variables?.[variableKey]?.name || variableKey}: {currentTemplate.template.variables?.[variableKey]?.description || 'No description'}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||
<Text color={colors.textMuted}>Select a template first</Text>
|
||||
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Box, Text, useInput } from 'ink';
|
||||
import { ConfirmDialog } from '../components/Dialog.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useInvitation } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { XOInvitation } from '@xo-cash/types';
|
||||
@@ -32,59 +33,51 @@ const actionItems = [
|
||||
*/
|
||||
export function TransactionScreen(): React.ReactElement {
|
||||
const { navigate, goBack, data: navData } = useNavigation();
|
||||
const { invitationController, showError, showInfo, confirm } = useAppContext();
|
||||
const { showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Extract invitation ID from navigation data
|
||||
const invitationId = navData.invitationId as string | undefined;
|
||||
|
||||
// Use hook to get invitation reactively
|
||||
const invitationInstance = useInvitation(invitationId ?? null);
|
||||
|
||||
// State
|
||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
|
||||
|
||||
/**
|
||||
* Load invitation data.
|
||||
*/
|
||||
const loadInvitation = useCallback(() => {
|
||||
// Check if invitation exists
|
||||
useEffect(() => {
|
||||
if (!invitationId) {
|
||||
showError('No invitation ID provided');
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const tracked = invitationController.getInvitation(invitationId);
|
||||
if (!tracked) {
|
||||
if (invitationId && !invitationInstance) {
|
||||
showError('Invitation not found');
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
}, [invitationId, invitationInstance, showError, goBack]);
|
||||
|
||||
setInvitation(tracked.invitation);
|
||||
}, [invitationId, invitationController, showError, goBack]);
|
||||
|
||||
// Load on mount
|
||||
useEffect(() => {
|
||||
loadInvitation();
|
||||
}, [loadInvitation]);
|
||||
const invitation = invitationInstance?.data ?? null;
|
||||
|
||||
/**
|
||||
* Broadcast transaction.
|
||||
*/
|
||||
const broadcastTransaction = useCallback(async () => {
|
||||
if (!invitationId) return;
|
||||
if (!invitationInstance) return;
|
||||
|
||||
setShowBroadcastConfirm(false);
|
||||
setIsLoading(true);
|
||||
setStatus('Broadcasting transaction...');
|
||||
|
||||
try {
|
||||
const txHash = await invitationController.broadcastTransaction(invitationId);
|
||||
await invitationInstance.broadcast();
|
||||
showInfo(
|
||||
`Transaction Broadcast Successful!\n\n` +
|
||||
`Transaction Hash:\n${txHash}\n\n` +
|
||||
`The transaction has been submitted to the network.`
|
||||
);
|
||||
navigate('wallet');
|
||||
@@ -94,20 +87,19 @@ export function TransactionScreen(): React.ReactElement {
|
||||
setIsLoading(false);
|
||||
setStatus('Ready');
|
||||
}
|
||||
}, [invitationId, invitationController, showInfo, showError, navigate, setStatus]);
|
||||
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
|
||||
|
||||
/**
|
||||
* Sign transaction.
|
||||
*/
|
||||
const signTransaction = useCallback(async () => {
|
||||
if (!invitationId) return;
|
||||
if (!invitationInstance) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setStatus('Signing transaction...');
|
||||
|
||||
try {
|
||||
await invitationController.signInvitation(invitationId);
|
||||
loadInvitation();
|
||||
await invitationInstance.sign();
|
||||
showInfo('Transaction signed successfully!');
|
||||
} catch (error) {
|
||||
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
|
||||
@@ -115,7 +107,7 @@ export function TransactionScreen(): React.ReactElement {
|
||||
setIsLoading(false);
|
||||
setStatus('Ready');
|
||||
}
|
||||
}, [invitationId, invitationController, loadInvitation, showInfo, showError, setStatus]);
|
||||
}, [invitationInstance, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Copy transaction hex.
|
||||
@@ -273,24 +265,24 @@ export function TransactionScreen(): React.ReactElement {
|
||||
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
|
||||
|
||||
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} - Transaction Review</Text>
|
||||
</Box>
|
||||
|
||||
{/* Summary box */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={colors.primary}
|
||||
marginTop={1}
|
||||
marginX={1}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
flexDirection='column'
|
||||
>
|
||||
<Text color={colors.primary} bold> Transaction Summary </Text>
|
||||
{invitation ? (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
|
||||
{hasUnresolvedInputs && (
|
||||
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
|
||||
@@ -308,22 +300,22 @@ export function TransactionScreen(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
{/* Inputs and Outputs */}
|
||||
<Box flexDirection="row" marginTop={1} marginX={1} flexGrow={1}>
|
||||
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
|
||||
{/* Inputs */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
|
||||
width="50%"
|
||||
flexDirection="column"
|
||||
width='50%'
|
||||
flexDirection='column'
|
||||
paddingX={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Inputs </Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
{inputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No inputs</Text>
|
||||
) : (
|
||||
inputs.map((input, index) => (
|
||||
<Box key={`${input.txid}-${input.index}`} flexDirection="column" marginBottom={1}>
|
||||
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
|
||||
<Text color={colors.text}>
|
||||
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
|
||||
</Text>
|
||||
@@ -338,20 +330,20 @@ export function TransactionScreen(): React.ReactElement {
|
||||
|
||||
{/* Outputs */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
|
||||
width="50%"
|
||||
flexDirection="column"
|
||||
width='50%'
|
||||
flexDirection='column'
|
||||
paddingX={1}
|
||||
marginLeft={1}
|
||||
>
|
||||
<Text color={colors.primary} bold> Outputs </Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
{resolvedOutputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No outputs</Text>
|
||||
) : (
|
||||
resolvedOutputs.map((output, index) => (
|
||||
<Box key={index} flexDirection="column" marginBottom={1}>
|
||||
<Box key={index} flexDirection='column' marginBottom={1}>
|
||||
<Text color={colors.text}>
|
||||
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
|
||||
{output.outputIdentifier && (
|
||||
@@ -371,15 +363,15 @@ export function TransactionScreen(): React.ReactElement {
|
||||
|
||||
{/* Actions */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||
marginTop={1}
|
||||
marginX={1}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
flexDirection='column'
|
||||
>
|
||||
<Text color={colors.primary} bold> Actions </Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection='column' marginTop={1}>
|
||||
{actionItems.map((item, index) => (
|
||||
<Text
|
||||
key={item.value}
|
||||
@@ -403,16 +395,16 @@ export function TransactionScreen(): React.ReactElement {
|
||||
{/* Broadcast confirmation dialog */}
|
||||
{showBroadcastConfirm && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
position='absolute'
|
||||
flexDirection='column'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
width='100%'
|
||||
height='100%'
|
||||
>
|
||||
<ConfirmDialog
|
||||
title="Broadcast Transaction"
|
||||
message="Are you sure you want to broadcast this transaction? This action cannot be undone."
|
||||
title='Broadcast Transaction'
|
||||
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
||||
onConfirm={broadcastTransaction}
|
||||
onCancel={() => setShowBroadcastConfirm(false)}
|
||||
isActive={showBroadcastConfirm}
|
||||
|
||||
@@ -43,7 +43,7 @@ interface UTXOItem {
|
||||
*/
|
||||
export function WalletStateScreen(): React.ReactElement {
|
||||
const { navigate } = useNavigation();
|
||||
const { walletController, showError, showInfo } = useAppContext();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// State
|
||||
@@ -57,19 +57,17 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
* Refreshes wallet state.
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Loading wallet state...');
|
||||
|
||||
// Get balance
|
||||
const balanceData = await walletController.getBalance();
|
||||
setBalance({
|
||||
totalSatoshis: balanceData.totalSatoshis,
|
||||
utxoCount: balanceData.utxoCount,
|
||||
});
|
||||
|
||||
// Get UTXOs
|
||||
const utxoData = await walletController.getUnspentOutputs();
|
||||
const utxoData = await appService.engine.listUnspentOutputsData();
|
||||
setUtxos(utxoData.map((utxo) => ({
|
||||
key: `${utxo.outpointTransactionHash}:${utxo.outpointIndex}`,
|
||||
satoshis: BigInt(utxo.valueSatoshis),
|
||||
@@ -78,13 +76,20 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
reserved: utxo.reserved ?? false,
|
||||
})));
|
||||
|
||||
// Get balance
|
||||
const balanceData = utxoData.reduce((acc, utxo) => acc + BigInt(utxo.valueSatoshis), BigInt(0));
|
||||
setBalance({
|
||||
totalSatoshis: balanceData,
|
||||
utxoCount: utxoData.length,
|
||||
});
|
||||
|
||||
setStatus('Wallet ready');
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
showError(`Failed to load wallet state: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [walletController, setStatus, showError]);
|
||||
}, [appService, setStatus, showError]);
|
||||
|
||||
// Load wallet state on mount
|
||||
useEffect(() => {
|
||||
@@ -95,11 +100,16 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
* Generates a new receiving address.
|
||||
*/
|
||||
const generateNewAddress = useCallback(async () => {
|
||||
if (!appService) {
|
||||
showError('AppService not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus('Generating new address...');
|
||||
|
||||
// Get the default P2PKH template
|
||||
const templates = await walletController.getTemplates();
|
||||
const templates = await appService.engine.listImportedTemplates();
|
||||
const p2pkhTemplate = templates.find(t => t.name?.includes('P2PKH'));
|
||||
|
||||
if (!p2pkhTemplate) {
|
||||
@@ -111,7 +121,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||
|
||||
const lockingBytecode = await walletController.generateLockingBytecode(
|
||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||
templateId,
|
||||
'receiveOutput',
|
||||
'receiver',
|
||||
@@ -124,7 +134,7 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
} catch (error) {
|
||||
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}, [walletController, setStatus, showInfo, showError, refresh]);
|
||||
}, [appService, setStatus, showInfo, showError, refresh]);
|
||||
|
||||
/**
|
||||
* Handles menu selection.
|
||||
@@ -164,29 +174,29 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
}));
|
||||
|
||||
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 ? (
|
||||
<>
|
||||
@@ -206,14 +216,14 @@ 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>
|
||||
@@ -244,14 +254,14 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
{/* UTXO list */}
|
||||
<Box marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderStyle='single'
|
||||
borderColor={focusedPanel === 'utxos' ? colors.focus : colors.border}
|
||||
flexDirection="column"
|
||||
flexDirection='column'
|
||||
paddingX={1}
|
||||
width="100%"
|
||||
width='100%'
|
||||
>
|
||||
<Text color={colors.primary} bold> Unspent Outputs (UTXOs) </Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{isLoading ? (
|
||||
<Text color={colors.textMuted}>Loading...</Text>
|
||||
) : utxoListItems.length === 0 ? (
|
||||
|
||||
268
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
268
src/tui/screens/action-wizard/ActionWizardScreen.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
||||
import { Button } from '../../components/Button.js';
|
||||
import { colors, logoSmall } from '../../theme.js';
|
||||
import { useActionWizard } from './useActionWizard.js';
|
||||
|
||||
// Steps
|
||||
import { InfoStep } from './steps/InfoStep.js';
|
||||
import { VariablesStep } from './steps/VariablesStep.js';
|
||||
import { InputsStep } from './steps/InputsStep.js';
|
||||
import { ReviewStep } from './steps/ReviewStep.js';
|
||||
import { PublishStep } from './steps/PublishStep.js';
|
||||
|
||||
export function ActionWizardScreen(): React.ReactElement {
|
||||
const wizard = useActionWizard();
|
||||
|
||||
// ── Keyboard handling ──────────────────────────────────────────
|
||||
useInput(
|
||||
(input, key) => {
|
||||
// Tab to cycle between content area and button bar
|
||||
if (key.tab) {
|
||||
if (wizard.focusArea === 'content') {
|
||||
// Within the inputs step, tab through UTXOs first
|
||||
if (
|
||||
wizard.currentStepData?.type === 'inputs' &&
|
||||
wizard.availableUtxos.length > 0
|
||||
) {
|
||||
if (
|
||||
wizard.selectedUtxoIndex <
|
||||
wizard.availableUtxos.length - 1
|
||||
) {
|
||||
wizard.setSelectedUtxoIndex((prev) => prev + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Move focus down to the button bar
|
||||
wizard.setFocusArea('buttons');
|
||||
wizard.setFocusedButton('next');
|
||||
} else {
|
||||
// Cycle through buttons, then wrap back to content
|
||||
if (wizard.focusedButton === 'back') {
|
||||
wizard.setFocusedButton('cancel');
|
||||
} else if (wizard.focusedButton === 'cancel') {
|
||||
wizard.setFocusedButton('next');
|
||||
} else {
|
||||
wizard.setFocusArea('content');
|
||||
wizard.setFocusedInput(0);
|
||||
wizard.setSelectedUtxoIndex(0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for UTXO selection in the content area
|
||||
if (
|
||||
wizard.focusArea === 'content' &&
|
||||
wizard.currentStepData?.type === 'inputs'
|
||||
) {
|
||||
if (key.upArrow) {
|
||||
wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1));
|
||||
} else if (key.downArrow) {
|
||||
wizard.setSelectedUtxoIndex((p) =>
|
||||
Math.min(wizard.availableUtxos.length - 1, p + 1)
|
||||
);
|
||||
} else if (key.return || input === ' ') {
|
||||
wizard.toggleUtxoSelection(wizard.selectedUtxoIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys in button bar
|
||||
if (wizard.focusArea === 'buttons') {
|
||||
if (key.leftArrow) {
|
||||
wizard.setFocusedButton((p) =>
|
||||
p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back'
|
||||
);
|
||||
} else if (key.rightArrow) {
|
||||
wizard.setFocusedButton((p) =>
|
||||
p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next'
|
||||
);
|
||||
}
|
||||
|
||||
// Enter on a button
|
||||
if (key.return) {
|
||||
if (wizard.focusedButton === 'back') wizard.previousStep();
|
||||
else if (wizard.focusedButton === 'cancel') wizard.cancel();
|
||||
else if (wizard.focusedButton === 'next') wizard.nextStep();
|
||||
}
|
||||
}
|
||||
|
||||
// 'c' to copy invitation ID on the publish step
|
||||
if (
|
||||
input === 'c' &&
|
||||
wizard.currentStepData?.type === 'publish' &&
|
||||
wizard.invitationId
|
||||
) {
|
||||
wizard.copyId();
|
||||
}
|
||||
|
||||
// 'a' to select all UTXOs
|
||||
if (input === 'a' && wizard.currentStepData?.type === 'inputs') {
|
||||
wizard.setAvailableUtxos((p) =>
|
||||
p.map((u) => ({ ...u, selected: true }))
|
||||
);
|
||||
}
|
||||
|
||||
// 'n' to deselect all UTXOs
|
||||
if (input === 'n' && wizard.currentStepData?.type === 'inputs') {
|
||||
wizard.setAvailableUtxos((p) =>
|
||||
p.map((u) => ({ ...u, selected: false }))
|
||||
);
|
||||
}
|
||||
},
|
||||
{ isActive: !wizard.textInputHasFocus }
|
||||
);
|
||||
|
||||
// ── Step router ────────────────────────────────────────────────
|
||||
const renderStep = () => {
|
||||
if (wizard.isProcessing) {
|
||||
return <Text color={colors.info}>Processing...</Text>;
|
||||
}
|
||||
|
||||
switch (wizard.currentStepData?.type) {
|
||||
case 'info':
|
||||
return (
|
||||
<InfoStep
|
||||
template={wizard.template!}
|
||||
actionIdentifier={wizard.actionIdentifier!}
|
||||
roleIdentifier={wizard.roleIdentifier!}
|
||||
actionName={wizard.actionName}
|
||||
/>
|
||||
);
|
||||
case 'variables':
|
||||
return (
|
||||
<VariablesStep
|
||||
variables={wizard.variables}
|
||||
updateVariable={wizard.updateVariable}
|
||||
handleTextInputSubmit={wizard.handleTextInputSubmit}
|
||||
focusArea={wizard.focusArea}
|
||||
focusedInput={wizard.focusedInput}
|
||||
/>
|
||||
);
|
||||
case 'inputs':
|
||||
return (
|
||||
<InputsStep
|
||||
availableUtxos={wizard.availableUtxos}
|
||||
selectedUtxoIndex={wizard.selectedUtxoIndex}
|
||||
requiredAmount={wizard.requiredAmount}
|
||||
fee={wizard.fee}
|
||||
selectedAmount={wizard.selectedAmount}
|
||||
changeAmount={wizard.changeAmount}
|
||||
focusArea={wizard.focusArea}
|
||||
/>
|
||||
);
|
||||
case 'review':
|
||||
return (
|
||||
<ReviewStep
|
||||
template={wizard.template!}
|
||||
actionName={wizard.actionName}
|
||||
roleIdentifier={wizard.roleIdentifier!}
|
||||
variables={wizard.variables}
|
||||
availableUtxos={wizard.availableUtxos}
|
||||
changeAmount={wizard.changeAmount}
|
||||
/>
|
||||
);
|
||||
case 'publish':
|
||||
return <PublishStep invitationId={wizard.invitationId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────
|
||||
const stepIndicatorSteps: Step[] = wizard.steps.map((s) => ({
|
||||
label: s.name,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={colors.secondary}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
>
|
||||
<Text color={colors.primary} bold>
|
||||
{logoSmall} - Action Wizard
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{wizard.template?.name} {">"} {wizard.actionName} (as{" "}
|
||||
{wizard.roleIdentifier})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<Box marginTop={1} paddingX={1}>
|
||||
<StepIndicator
|
||||
steps={stepIndicatorSteps}
|
||||
currentStep={wizard.currentStep}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Content area */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={
|
||||
wizard.focusArea === "content" ? colors.focus : colors.primary
|
||||
}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
marginTop={1}
|
||||
marginX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold>
|
||||
{" "}
|
||||
{wizard.currentStepData?.name} ({wizard.currentStep + 1}/
|
||||
{wizard.steps.length}){" "}
|
||||
</Text>
|
||||
<Box marginTop={1}>{renderStep()}</Box>
|
||||
</Box>
|
||||
|
||||
{/* Buttons */}
|
||||
<Box marginTop={1} marginX={1} justifyContent="space-between">
|
||||
<Box gap={1}>
|
||||
<Button
|
||||
label="Back"
|
||||
focused={
|
||||
wizard.focusArea === "buttons" &&
|
||||
wizard.focusedButton === "back"
|
||||
}
|
||||
disabled={wizard.currentStepData?.type === "publish"}
|
||||
/>
|
||||
<Button
|
||||
label="Cancel"
|
||||
focused={
|
||||
wizard.focusArea === "buttons" &&
|
||||
wizard.focusedButton === "cancel"
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
label={
|
||||
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
||||
}
|
||||
focused={
|
||||
wizard.focusArea === "buttons" &&
|
||||
wizard.focusedButton === "next"
|
||||
}
|
||||
disabled={wizard.isProcessing}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1} marginX={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Tab: Navigate • Enter: Select • Esc: Back
|
||||
{wizard.currentStepData?.type === "publish"
|
||||
? " • c: Copy ID"
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
4
src/tui/screens/action-wizard/index.ts
Normal file
4
src/tui/screens/action-wizard/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './ActionWizardScreen.js';
|
||||
export * from './useActionWizard.js';
|
||||
export * from './types.js';
|
||||
export * from './steps/index.js';
|
||||
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
52
src/tui/screens/action-wizard/steps/InfoStep.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors } from '../../../theme.js';
|
||||
import type { WizardStepProps } from '../types.js';
|
||||
|
||||
type Props = Pick<
|
||||
WizardStepProps,
|
||||
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
|
||||
>;
|
||||
|
||||
export function InfoStep({
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
actionName,
|
||||
}: Props): React.ReactElement {
|
||||
const action = template?.actions?.[actionIdentifier];
|
||||
const role = action?.roles?.[roleIdentifier];
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.primary} bold>
|
||||
Action: {actionName}
|
||||
</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{action?.description || 'No description'}
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.text}>Your Role: </Text>
|
||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
||||
</Box>
|
||||
|
||||
{role?.requirements && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Requirements:</Text>
|
||||
{role.requirements.variables?.map((v) => (
|
||||
<Text key={v} color={colors.textMuted}>
|
||||
{' '}• Variable: {v}
|
||||
</Text>
|
||||
))}
|
||||
{role.requirements.slots && (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}• Slots: {role.requirements.slots.min} min (UTXO selection
|
||||
required)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
92
src/tui/screens/action-wizard/steps/InputsStep.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||
import type { WizardStepProps } from '../types.js';
|
||||
|
||||
type Props = Pick<
|
||||
WizardStepProps,
|
||||
| 'availableUtxos'
|
||||
| 'selectedUtxoIndex'
|
||||
| 'requiredAmount'
|
||||
| 'fee'
|
||||
| 'selectedAmount'
|
||||
| 'changeAmount'
|
||||
| 'focusArea'
|
||||
>;
|
||||
|
||||
export function InputsStep({
|
||||
availableUtxos,
|
||||
selectedUtxoIndex,
|
||||
requiredAmount,
|
||||
fee,
|
||||
selectedAmount,
|
||||
changeAmount,
|
||||
focusArea,
|
||||
}: Props): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>
|
||||
Select UTXOs to fund the transaction:
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.textMuted}>
|
||||
Required: {formatSatoshis(requiredAmount)} +{' '}
|
||||
{formatSatoshis(fee)} fee
|
||||
</Text>
|
||||
<Text
|
||||
color={
|
||||
selectedAmount >= requiredAmount + fee
|
||||
? colors.success
|
||||
: colors.warning
|
||||
}
|
||||
>
|
||||
Selected: {formatSatoshis(selectedAmount)}
|
||||
</Text>
|
||||
{selectedAmount > requiredAmount + fee && (
|
||||
<Text color={colors.info}>
|
||||
Change: {formatSatoshis(changeAmount)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
flexDirection='column'
|
||||
borderStyle='single'
|
||||
borderColor={colors.border}
|
||||
paddingX={1}
|
||||
>
|
||||
{availableUtxos.length === 0 ? (
|
||||
<Text color={colors.textMuted}>No UTXOs available</Text>
|
||||
) : (
|
||||
availableUtxos.map((utxo, index) => {
|
||||
const isCursor =
|
||||
selectedUtxoIndex === index && focusArea === 'content';
|
||||
return (
|
||||
<Box
|
||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||
>
|
||||
<Text
|
||||
color={isCursor ? colors.focus : colors.text}
|
||||
bold={isCursor}
|
||||
>
|
||||
{isCursor ? '▸ ' : ' '}[{utxo.selected ? 'X' : ' '}]{' '}
|
||||
{formatSatoshis(utxo.valueSatoshis)} -{' '}
|
||||
{formatHex(utxo.outpointTransactionHash, 12)}:
|
||||
{utxo.outpointIndex}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Space/Enter: Toggle • a: Select all • n: Deselect all
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
45
src/tui/screens/action-wizard/steps/PublishStep.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors } from '../../../theme.js';
|
||||
|
||||
interface PublishStepProps {
|
||||
invitationId: string | null;
|
||||
}
|
||||
|
||||
export function PublishStep({
|
||||
invitationId,
|
||||
}: PublishStepProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.success} bold>
|
||||
✓ Invitation Created & Published!
|
||||
</Text>
|
||||
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Invitation ID:</Text>
|
||||
<Box
|
||||
borderStyle='single'
|
||||
borderColor={colors.primary}
|
||||
paddingX={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<Text color={colors.accent}>
|
||||
{invitationId ?? '(unknown)'}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
Share this ID with the other party to complete the transaction.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.warning}>
|
||||
Press 'c' to copy ID to clipboard
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
93
src/tui/screens/action-wizard/steps/ReviewStep.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../theme.js';
|
||||
import type { VariableInput, SelectableUTXO } from '../types.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
interface ReviewStepProps {
|
||||
template: XOTemplate;
|
||||
actionName: string;
|
||||
roleIdentifier: string;
|
||||
variables: VariableInput[];
|
||||
availableUtxos: SelectableUTXO[];
|
||||
changeAmount: bigint;
|
||||
}
|
||||
|
||||
export function ReviewStep({
|
||||
template,
|
||||
actionName,
|
||||
roleIdentifier,
|
||||
variables,
|
||||
availableUtxos,
|
||||
changeAmount,
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>
|
||||
Review your invitation:
|
||||
</Text>
|
||||
|
||||
{/* Summary */}
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.textMuted}>Template: {template?.name}</Text>
|
||||
<Text color={colors.textMuted}>Action: {actionName}</Text>
|
||||
<Text color={colors.textMuted}>Role: {roleIdentifier}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Variables */}
|
||||
{variables.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Variables:</Text>
|
||||
{variables.map((v) => (
|
||||
<Text key={v.id} color={colors.textMuted}>
|
||||
{' '}
|
||||
{v.name}: {v.value || '(empty)'}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Inputs */}
|
||||
{selectedUtxos.length > 0 && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>
|
||||
Inputs ({selectedUtxos.length}):
|
||||
</Text>
|
||||
{selectedUtxos.slice(0, 3).map((u) => (
|
||||
<Text
|
||||
key={`${u.outpointTransactionHash}:${u.outpointIndex}`}
|
||||
color={colors.textMuted}
|
||||
>
|
||||
{' '}
|
||||
{formatSatoshis(u.valueSatoshis)}
|
||||
</Text>
|
||||
))}
|
||||
{selectedUtxos.length > 3 && (
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}...and {selectedUtxos.length - 3} more
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Outputs */}
|
||||
{changeAmount > 0n && (
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
<Text color={colors.text}>Outputs:</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{' '}Change: {formatSatoshis(changeAmount)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Confirmation prompt */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.warning}>
|
||||
Press Next to create and publish the invitation.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
49
src/tui/screens/action-wizard/steps/VariablesStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors } from '../../../theme.js';
|
||||
import { VariableInputField } from '../../../components/VariableInputField.js';
|
||||
import type { WizardStepProps } from '../types.js';
|
||||
|
||||
type Props = Pick<
|
||||
WizardStepProps,
|
||||
| 'variables'
|
||||
| 'updateVariable'
|
||||
| 'handleTextInputSubmit'
|
||||
| 'focusArea'
|
||||
| 'focusedInput'
|
||||
>;
|
||||
|
||||
export function VariablesStep({
|
||||
variables,
|
||||
updateVariable,
|
||||
handleTextInputSubmit,
|
||||
focusArea,
|
||||
focusedInput,
|
||||
}: Props): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection='column'>
|
||||
<Text color={colors.text} bold>
|
||||
Enter required values:
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection='column'>
|
||||
{variables.map((variable, index) => (
|
||||
<VariableInputField
|
||||
key={variable.id}
|
||||
variable={variable}
|
||||
index={index}
|
||||
isFocused={focusArea === 'content' && focusedInput === index}
|
||||
onChange={updateVariable}
|
||||
onSubmit={handleTextInputSubmit}
|
||||
borderColor={colors.border as string}
|
||||
focusColor={colors.primary as string}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Type your value, then press Enter to continue
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
5
src/tui/screens/action-wizard/steps/index.ts
Normal file
5
src/tui/screens/action-wizard/steps/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './InfoStep.js';
|
||||
export * from './VariablesStep.js';
|
||||
export * from './InputsStep.js';
|
||||
export * from './ReviewStep.js';
|
||||
export * from './PublishStep.js';
|
||||
62
src/tui/screens/action-wizard/types.ts
Normal file
62
src/tui/screens/action-wizard/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
export type StepType = 'info' | 'variables' | 'inputs' | 'review' | 'publish';
|
||||
|
||||
export interface WizardStep {
|
||||
name: string;
|
||||
type: StepType;
|
||||
}
|
||||
|
||||
export interface VariableInput {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hint?: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SelectableUTXO {
|
||||
outpointTransactionHash: string;
|
||||
outpointIndex: number;
|
||||
valueSatoshis: bigint;
|
||||
lockingBytecode?: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export type FocusArea = 'content' | 'buttons';
|
||||
export type ButtonFocus = 'back' | 'cancel' | 'next';
|
||||
|
||||
/**
|
||||
* The 'downward' contract — what every step component receives.
|
||||
*/
|
||||
export interface WizardStepProps {
|
||||
// Data
|
||||
template: XOTemplate;
|
||||
actionIdentifier: string;
|
||||
roleIdentifier: string;
|
||||
actionName: string;
|
||||
|
||||
// Variable state
|
||||
variables: VariableInput[];
|
||||
updateVariable: (index: number, value: string) => void;
|
||||
|
||||
// UTXO state
|
||||
availableUtxos: SelectableUTXO[];
|
||||
selectedUtxoIndex: number;
|
||||
requiredAmount: bigint;
|
||||
fee: bigint;
|
||||
selectedAmount: bigint;
|
||||
changeAmount: bigint;
|
||||
toggleUtxoSelection: (index: number) => void;
|
||||
|
||||
// Invitation
|
||||
invitationId: string | null;
|
||||
|
||||
// Focus
|
||||
focusArea: FocusArea;
|
||||
focusedInput: number;
|
||||
|
||||
// Callbacks
|
||||
handleTextInputSubmit: () => void;
|
||||
copyId: () => Promise<void>;
|
||||
}
|
||||
576
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
576
src/tui/screens/action-wizard/useActionWizard.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
||||
import type {
|
||||
WizardStep,
|
||||
VariableInput,
|
||||
SelectableUTXO,
|
||||
FocusArea,
|
||||
ButtonFocus,
|
||||
} from './types.js';
|
||||
|
||||
export function useActionWizard() {
|
||||
const { navigate, goBack, data: navData } = useNavigation();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// ── Navigation data ──────────────────────────────────────────────
|
||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||
const roleIdentifier = navData.roleIdentifier as string | undefined;
|
||||
const template = navData.template as XOTemplate | undefined;
|
||||
|
||||
// ── Wizard state ─────────────────────────────────────────────────
|
||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
// ── Variable inputs ──────────────────────────────────────────────
|
||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||
|
||||
// ── UTXO selection ───────────────────────────────────────────────
|
||||
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
||||
const [fee, setFee] = useState<bigint>(500n);
|
||||
|
||||
// ── Invitation ───────────────────────────────────────────────────
|
||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||
|
||||
// ── UI state ─────────────────────────────────────────────────────
|
||||
const [focusedInput, setFocusedInput] = useState(0);
|
||||
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
|
||||
const [focusArea, setFocusArea] = useState<FocusArea>('content');
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// ── Derived values ───────────────────────────────────────────────
|
||||
const currentStepData = steps[currentStep];
|
||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
||||
|
||||
const selectedAmount = availableUtxos
|
||||
.filter((u) => u.selected)
|
||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||
|
||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||
|
||||
const textInputHasFocus =
|
||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
||||
|
||||
// ── Initialization ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!template || !actionIdentifier || !roleIdentifier) {
|
||||
showError('Missing wizard data');
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const role = act?.roles?.[roleIdentifier];
|
||||
const requirements = role?.requirements;
|
||||
|
||||
// const wizardSteps: WizardStep[] = [{ name: 'Welcome', type: 'info' }];
|
||||
const wizardSteps: WizardStep[] = [];
|
||||
|
||||
// Add variables step if needed
|
||||
if (requirements?.variables && requirements.variables.length > 0) {
|
||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
||||
|
||||
const varInputs = requirements.variables.map((varId) => {
|
||||
const varDef = template.variables?.[varId];
|
||||
return {
|
||||
id: varId,
|
||||
name: varDef?.name || varId,
|
||||
type: varDef?.type || 'string',
|
||||
hint: varDef?.hint,
|
||||
value: '',
|
||||
};
|
||||
});
|
||||
setVariables(varInputs);
|
||||
}
|
||||
|
||||
// Add inputs step if role requires slots (funding inputs)
|
||||
// Slots indicate the role needs to provide transaction inputs/outputs
|
||||
if (requirements?.slots && requirements.slots.min > 0) {
|
||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
||||
}
|
||||
|
||||
// Add review step
|
||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
||||
|
||||
// Add publish step
|
||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
||||
|
||||
setSteps(wizardSteps);
|
||||
setStatus(`${actionIdentifier}/${roleIdentifier}`);
|
||||
}, [
|
||||
template,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
showError,
|
||||
goBack,
|
||||
setStatus,
|
||||
]);
|
||||
|
||||
// ── Update a single variable value ───────────────────────────────
|
||||
const updateVariable = useCallback((index: number, value: string) => {
|
||||
setVariables((prev) => {
|
||||
const updated = [...prev];
|
||||
const variable = updated[index];
|
||||
if (variable) {
|
||||
updated[index] = { ...variable, value };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Toggle a UTXO's selected state ──────────────────────────────
|
||||
const toggleUtxoSelection = useCallback((index: number) => {
|
||||
setAvailableUtxos((prev) => {
|
||||
const updated = [...prev];
|
||||
const utxo = updated[index];
|
||||
if (utxo) {
|
||||
updated[index] = { ...utxo, selected: !utxo.selected };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Handle Enter inside a TextInput ─────────────────────────────
|
||||
const handleTextInputSubmit = useCallback(() => {
|
||||
if (focusedInput < variables.length - 1) {
|
||||
setFocusedInput((prev) => prev + 1);
|
||||
} else {
|
||||
setFocusArea('buttons');
|
||||
setFocusedButton('next');
|
||||
}
|
||||
}, [focusedInput, variables.length]);
|
||||
|
||||
// ── Copy invitation ID to clipboard ─────────────────────────────
|
||||
const copyId = useCallback(async () => {
|
||||
if (!invitationId) return;
|
||||
try {
|
||||
await copyToClipboard(invitationId);
|
||||
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}, [invitationId, showInfo, showError]);
|
||||
|
||||
// ── Load available UTXOs for the inputs step ────────────────────
|
||||
const loadAvailableUtxos = useCallback(async () => {
|
||||
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
setStatus('Finding suitable UTXOs...');
|
||||
|
||||
// Determine required amount from variables
|
||||
const requestedVar = variables.find(
|
||||
(v) =>
|
||||
v.id.toLowerCase().includes('satoshi') ||
|
||||
v.id.toLowerCase().includes('amount')
|
||||
);
|
||||
const requested = requestedVar
|
||||
? BigInt(requestedVar.value || '0')
|
||||
: 0n;
|
||||
setRequiredAmount(requested);
|
||||
|
||||
// Find the tracked invitation instance
|
||||
const invitationInstance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Query for suitable resources
|
||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||
templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
// Map to selectable UTXOs
|
||||
const utxos: 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,
|
||||
}));
|
||||
|
||||
// Auto-select UTXOs greedily until the requirement is met
|
||||
let accumulated = 0n;
|
||||
const seenLockingBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of utxos) {
|
||||
if (
|
||||
utxo.lockingBytecode &&
|
||||
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (utxo.lockingBytecode) {
|
||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||
}
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= requested + fee) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableUtxos(utxos);
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [
|
||||
invitation,
|
||||
templateIdentifier,
|
||||
variables,
|
||||
appService,
|
||||
invitationId,
|
||||
fee,
|
||||
showError,
|
||||
setStatus,
|
||||
]);
|
||||
|
||||
// ── Create invitation and persist variables ─────────────────────
|
||||
const createInvitationWithVariables = useCallback(async () => {
|
||||
if (
|
||||
!templateIdentifier ||
|
||||
!actionIdentifier ||
|
||||
!roleIdentifier ||
|
||||
!template ||
|
||||
!appService
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Creating invitation...');
|
||||
|
||||
try {
|
||||
// Create via the engine
|
||||
const xoInvitation = await appService.engine.createInvitation({
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
});
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
|
||||
let inv = invitationInstance.data;
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
// Persist variable values
|
||||
if (variables.length > 0) {
|
||||
const variableData = variables.map((v) => {
|
||||
const isNumeric =
|
||||
['integer', 'number', 'satoshis'].includes(v.type) ||
|
||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
||||
|
||||
return {
|
||||
variableIdentifier: v.id,
|
||||
roleIdentifier,
|
||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
||||
};
|
||||
});
|
||||
await invitationInstance.addVariables(variableData);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
// Add template-required outputs for the current role
|
||||
const act = template.actions?.[actionIdentifier];
|
||||
const transaction = act?.transaction
|
||||
? template.transactions?.[act.transaction]
|
||||
: null;
|
||||
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
|
||||
const outputsToAdd = transaction.outputs.map(
|
||||
(outputId: string) => ({
|
||||
outputIdentifier: outputId,
|
||||
})
|
||||
);
|
||||
|
||||
await invitationInstance.addOutputs(outputsToAdd);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
setInvitation(inv);
|
||||
|
||||
// Advance and optionally kick off UTXO loading
|
||||
const nextStepType = steps[currentStep + 1]?.type;
|
||||
if (nextStepType === 'inputs') {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setTimeout(() => loadAvailableUtxos(), 100);
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
|
||||
setStatus('Invitation created');
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
template,
|
||||
variables,
|
||||
appService,
|
||||
steps,
|
||||
currentStep,
|
||||
showError,
|
||||
setStatus,
|
||||
loadAvailableUtxos,
|
||||
]);
|
||||
|
||||
// ── Add selected inputs + change output to the invitation ───────
|
||||
const addInputsAndOutputs = useCallback(async () => {
|
||||
if (!invitationId || !invitation || !appService) return;
|
||||
|
||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
||||
|
||||
if (selectedUtxos.length === 0) {
|
||||
showError('Please select at least one UTXO');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAmount < requiredAmount + fee) {
|
||||
showError(
|
||||
`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeAmount < 546n) {
|
||||
showError(
|
||||
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Adding inputs and outputs...');
|
||||
|
||||
try {
|
||||
const invitationInstance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Add selected inputs
|
||||
const inputs = selectedUtxos.map((utxo) => ({
|
||||
outpointTransactionHash: new Uint8Array(
|
||||
Buffer.from(utxo.outpointTransactionHash, 'hex')
|
||||
),
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
}));
|
||||
|
||||
await invitationInstance.addInputs(inputs);
|
||||
|
||||
// Add change output
|
||||
const outputs = [
|
||||
{
|
||||
valueSatoshis: changeAmount,
|
||||
},
|
||||
];
|
||||
|
||||
await invitationInstance.addOutputs(outputs);
|
||||
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setStatus('Inputs and outputs added');
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [
|
||||
invitationId,
|
||||
invitation,
|
||||
availableUtxos,
|
||||
selectedAmount,
|
||||
requiredAmount,
|
||||
fee,
|
||||
changeAmount,
|
||||
appService,
|
||||
showError,
|
||||
setStatus,
|
||||
]);
|
||||
|
||||
// ── Publish the invitation ──────────────────────────────────────
|
||||
const publishInvitation = useCallback(async () => {
|
||||
if (!invitationId || !appService) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
setStatus('Publishing invitation...');
|
||||
|
||||
try {
|
||||
const invitationInstance = appService.invitations.find(
|
||||
(inv) => inv.data.invitationIdentifier === invitationId
|
||||
);
|
||||
|
||||
if (!invitationInstance) {
|
||||
throw new Error('Invitation not found');
|
||||
}
|
||||
|
||||
// Already tracked and synced via SSE from createInvitation
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setStatus('Invitation published');
|
||||
} catch (error) {
|
||||
showError(
|
||||
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [invitationId, appService, showError, setStatus]);
|
||||
|
||||
// ── Navigate to the next step ───────────────────────────────────
|
||||
const nextStep = useCallback(async () => {
|
||||
if (currentStep >= steps.length - 1) return;
|
||||
|
||||
const stepType = currentStepData?.type;
|
||||
|
||||
if (stepType === 'variables') {
|
||||
const emptyVars = variables.filter(
|
||||
(v) => !v.value || v.value.trim() === ''
|
||||
);
|
||||
if (emptyVars.length > 0) {
|
||||
showError(
|
||||
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
await createInvitationWithVariables();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepType === 'inputs') {
|
||||
await addInputsAndOutputs();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stepType === 'review') {
|
||||
await publishInvitation();
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}, [
|
||||
currentStep,
|
||||
steps.length,
|
||||
currentStepData,
|
||||
variables,
|
||||
showError,
|
||||
createInvitationWithVariables,
|
||||
addInputsAndOutputs,
|
||||
publishInvitation,
|
||||
]);
|
||||
|
||||
// ── Navigate to the previous step ──────────────────────────────
|
||||
const previousStep = useCallback(() => {
|
||||
if (currentStep <= 0) {
|
||||
goBack();
|
||||
return;
|
||||
}
|
||||
setCurrentStep((prev) => prev - 1);
|
||||
setFocusArea('content');
|
||||
setFocusedInput(0);
|
||||
}, [currentStep, goBack]);
|
||||
|
||||
// ── Cancel the wizard entirely ──────────────────────────────────
|
||||
const cancel = useCallback(() => {
|
||||
goBack();
|
||||
}, [goBack]);
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────
|
||||
return {
|
||||
// Navigation / meta
|
||||
template,
|
||||
templateIdentifier,
|
||||
actionIdentifier,
|
||||
roleIdentifier,
|
||||
action,
|
||||
actionName,
|
||||
|
||||
// Steps
|
||||
steps,
|
||||
currentStep,
|
||||
currentStepData,
|
||||
|
||||
// Variables
|
||||
variables,
|
||||
updateVariable,
|
||||
handleTextInputSubmit,
|
||||
|
||||
// UTXOs
|
||||
availableUtxos,
|
||||
setAvailableUtxos,
|
||||
selectedUtxoIndex,
|
||||
setSelectedUtxoIndex,
|
||||
requiredAmount,
|
||||
fee,
|
||||
selectedAmount,
|
||||
changeAmount,
|
||||
toggleUtxoSelection,
|
||||
|
||||
// Invitation
|
||||
invitation,
|
||||
invitationId,
|
||||
|
||||
// UI focus
|
||||
focusedInput,
|
||||
setFocusedInput,
|
||||
focusedButton,
|
||||
setFocusedButton,
|
||||
focusArea,
|
||||
setFocusArea,
|
||||
isProcessing,
|
||||
textInputHasFocus,
|
||||
|
||||
// Actions
|
||||
nextStep,
|
||||
previousStep,
|
||||
cancel,
|
||||
copyId,
|
||||
} as const;
|
||||
}
|
||||
|
||||
/** Convenience type so other files can type the return value. */
|
||||
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
||||
@@ -2,9 +2,9 @@
|
||||
* Export all screen components.
|
||||
*/
|
||||
|
||||
export * from './action-wizard/index.js';
|
||||
export { SeedInputScreen } from './SeedInput.js';
|
||||
export { WalletStateScreen } from './WalletState.js';
|
||||
export { TemplateListScreen } from './TemplateList.js';
|
||||
export { ActionWizardScreen } from './ActionWizard.js';
|
||||
export { InvitationScreen } from './Invitation.js';
|
||||
export { TransactionScreen } from './Transaction.js';
|
||||
|
||||
Reference in New Issue
Block a user