Large amount of changes. Successfully broadcasts txs
This commit is contained in:
@@ -15,6 +15,8 @@ import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||
import type { HistoryItem } from '../../services/history.js';
|
||||
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
formatHistoryListItem,
|
||||
@@ -139,10 +141,10 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new locking bytecode
|
||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||
// Generate the template identifier
|
||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||
|
||||
// Generate the locking bytecode
|
||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||
templateId,
|
||||
'receiveOutput',
|
||||
|
||||
@@ -323,8 +323,6 @@ export function useActionWizard() {
|
||||
actionIdentifier,
|
||||
});
|
||||
|
||||
console.log(xoInvitation)
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
@@ -333,6 +331,8 @@ export function useActionWizard() {
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
setStatus('Adding variables...');
|
||||
|
||||
// Persist variable values
|
||||
if (variables.length > 0) {
|
||||
const variableData = variables.map((v) => {
|
||||
@@ -359,14 +359,24 @@ export function useActionWizard() {
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
|
||||
const outputsToAdd = transaction.outputs.map(
|
||||
(output: XOTemplateTransactionOutput) => ({
|
||||
outputIdentifier: output.output,
|
||||
roleIdentifier: roleIdentifier,
|
||||
})
|
||||
);
|
||||
const outputsToAdd = await Promise.all(transaction.outputs.map(
|
||||
async (output: XOTemplateTransactionOutput) => ({
|
||||
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
|
||||
outputIdentifier: output as unknown as string,
|
||||
// roleIdentifier: roleIdentifier,
|
||||
|
||||
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
|
||||
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
|
||||
})
|
||||
));
|
||||
|
||||
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
|
||||
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
|
||||
outputIdentifier: output.outputIdentifier,
|
||||
// roleIdentifier: output.roleIdentifier,
|
||||
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
|
||||
})));
|
||||
|
||||
await invitationInstance.addOutputs(outputsToAdd);
|
||||
inv = invitationInstance.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ export * from './action-wizard/index.js';
|
||||
export { SeedInputScreen } from './SeedInput.js';
|
||||
export { WalletStateScreen } from './WalletState.js';
|
||||
export { TemplateListScreen } from './TemplateList.js';
|
||||
export { InvitationScreen } from './Invitation.js';
|
||||
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||
export { TransactionScreen } from './Transaction.js';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||
*
|
||||
*
|
||||
* Provides:
|
||||
* - Import invitation by ID with role selection
|
||||
* - Import invitation by ID with multi-step import flow
|
||||
* - View active invitations with detailed information
|
||||
* - Monitor invitation updates via SSE
|
||||
* - Fill missing requirements
|
||||
@@ -11,17 +11,16 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { InputDialog } from '../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { Invitation } from '../../services/invitation.js';
|
||||
import { InputDialog } from '../../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
|
||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { Invitation } from '../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
@@ -31,7 +30,9 @@ import {
|
||||
getUserRole,
|
||||
formatInvitationListItem,
|
||||
formatInvitationId,
|
||||
} from '../../utils/invitation-utils.js';
|
||||
} from '../../../utils/invitation-utils.js';
|
||||
|
||||
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||
|
||||
/**
|
||||
* Map state color name to theme color.
|
||||
@@ -84,38 +85,30 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const { navigate, data: navData } = useNavigation();
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Use hooks for reactive invitation list
|
||||
|
||||
const invitations = useInvitations();
|
||||
|
||||
// State
|
||||
// ── UI state ─────────────────────────────────────────────────────────────
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
||||
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
||||
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
||||
// ── Import state ─────────────────────────────────────────────────────────
|
||||
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
|
||||
// Template cache for displaying invitation list with template names
|
||||
// ── Template cache ───────────────────────────────────────────────────────
|
||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||
|
||||
// Selected invitation template for details view
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||
|
||||
// Check if we should open import dialog on mount
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
|
||||
/**
|
||||
* Show import dialog on mount if needed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialMode === 'import') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, [initialMode]);
|
||||
|
||||
@@ -124,7 +117,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
|
||||
invitations.forEach(inv => {
|
||||
const templateId = inv.data.templateIdentifier;
|
||||
if (!templateCache.has(templateId)) {
|
||||
@@ -139,10 +132,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
/**
|
||||
* Build list items for ScrollableList.
|
||||
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
||||
*/
|
||||
const listItems = useMemo((): InvitationListItem[] => {
|
||||
// Import action at top
|
||||
const importItem: InvitationListItem = {
|
||||
key: 'import',
|
||||
label: '+ Import Invitation',
|
||||
@@ -151,26 +142,23 @@ export function InvitationScreen(): React.ReactElement {
|
||||
color: 'info',
|
||||
};
|
||||
|
||||
// Map invitations to list items
|
||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const formatted = formatInvitationListItem(inv, template);
|
||||
const state = getInvitationState(inv);
|
||||
|
||||
|
||||
return {
|
||||
key: inv.data.invitationIdentifier,
|
||||
label: formatted.label,
|
||||
value: inv,
|
||||
group: 'invitations',
|
||||
color: formatted.statusColor,
|
||||
hidden: !formatted.isValid, // Hide invalid items
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
|
||||
return [importItem, ...invitationItems];
|
||||
}, [invitations, templateCache]);
|
||||
|
||||
// Get selected invitation from list items
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
const selectedInvitation = selectedItem?.value ?? null;
|
||||
|
||||
@@ -182,116 +170,34 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setSelectedTemplate(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
||||
.then(template => setSelectedTemplate(template ?? null));
|
||||
}, [selectedInvitation, appService]);
|
||||
|
||||
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stage 1: Import invitation by ID (fetches invitation and moves to role selection).
|
||||
* ID dialog submitted — transition to the multi-step import flow.
|
||||
*/
|
||||
const handleImportIdSubmit = useCallback(async (invitationId: string) => {
|
||||
if (!invitationId.trim() || !appService) {
|
||||
setImportStage(null);
|
||||
const handleImportIdSubmit = useCallback((invitationId: string) => {
|
||||
if (!invitationId.trim()) {
|
||||
setShowIdDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Importing invitation:', invitationId);
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Fetching invitation...');
|
||||
|
||||
// Create invitation instance (will fetch from sync server)
|
||||
const invitation = await appService.createInvitation(invitationId);
|
||||
|
||||
console.log(invitation);
|
||||
|
||||
const missingRequirements = await invitation.getMissingRequirements();
|
||||
console.log(missingRequirements);
|
||||
|
||||
// Get available roles for this invitation
|
||||
const roles = await invitation.getAvailableRoles();
|
||||
|
||||
console.log(roles);
|
||||
|
||||
// Get the template for display
|
||||
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||
|
||||
// Store for next stage
|
||||
setImportingInvitation(invitation);
|
||||
setAvailableRoles(roles);
|
||||
setSelectedRoleIndex(0);
|
||||
setImportTemplate(template ?? null);
|
||||
|
||||
// Move to role selection stage
|
||||
setImportStage('role-select');
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setImportStage(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [appService, showError, setStatus]);
|
||||
setShowIdDialog(false);
|
||||
setImportingId(invitationId.trim());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stage 2: Accept invitation with selected role.
|
||||
* Import flow closed (completed or cancelled).
|
||||
*/
|
||||
const handleRoleSelect = useCallback(async () => {
|
||||
if (!importingInvitation || !appService) return;
|
||||
const handleImportFlowClose = useCallback(() => {
|
||||
setImportingId(null);
|
||||
}, []);
|
||||
|
||||
const selectedRole = availableRoles[selectedRoleIndex];
|
||||
if (!selectedRole) {
|
||||
showError('No role selected');
|
||||
return;
|
||||
}
|
||||
// ── Action handlers ────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus(`Accepting as ${selectedRole}...`);
|
||||
|
||||
// TODO: Engine doesnt support "accepting" without supplying some kind of data along with it.
|
||||
// We also dont have a way to say "this action will require inputs, so i will do that."
|
||||
// If it did, we could add an "input" with the role identifier.
|
||||
// For now, we are just going to hard-code the input with the role identifier.
|
||||
await importingInvitation.addInputs([{
|
||||
roleIdentifier: selectedRole,
|
||||
}]);
|
||||
|
||||
showInfo(`Invitation imported and accepted!\n\nRole: ${selectedRole}\nTemplate: ${importTemplate?.name ?? importingInvitation.data.templateIdentifier}\nAction: ${importingInvitation.data.actionIdentifier}`);
|
||||
setStatus('Ready');
|
||||
|
||||
// Reset import state
|
||||
setImportStage(null);
|
||||
setImportingInvitation(null);
|
||||
setAvailableRoles([]);
|
||||
setImportTemplate(null);
|
||||
} catch (error) {
|
||||
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [importingInvitation, availableRoles, selectedRoleIndex, appService, importTemplate, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Cancel import and remove the invitation if it was added.
|
||||
*/
|
||||
const handleImportCancel = useCallback(async () => {
|
||||
if (importingInvitation && appService) {
|
||||
// Remove the invitation since user declined
|
||||
await appService.removeInvitation(importingInvitation);
|
||||
}
|
||||
|
||||
setImportStage(null);
|
||||
setImportingInvitation(null);
|
||||
setAvailableRoles([]);
|
||||
setImportTemplate(null);
|
||||
}, [importingInvitation, appService]);
|
||||
|
||||
/**
|
||||
* Accept selected invitation (from actions menu).
|
||||
*/
|
||||
const acceptInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -307,7 +213,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// Check if already accepted
|
||||
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
||||
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||
} else {
|
||||
@@ -318,9 +223,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Sign selected invitation.
|
||||
*/
|
||||
const signInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -341,9 +243,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Copy invitation ID.
|
||||
*/
|
||||
const copyId = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -358,9 +257,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError]);
|
||||
|
||||
/**
|
||||
* Fill requirements for selected invitation.
|
||||
*/
|
||||
const fillRequirements = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -369,20 +265,17 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Step 1: Check available roles
|
||||
|
||||
setStatus('Checking available roles...');
|
||||
const roles = await selectedInvitation.getAvailableRoles();
|
||||
|
||||
|
||||
if (roles.length === 0) {
|
||||
// Already participating, check if we can add inputs
|
||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||
} else {
|
||||
// Need to accept a role first
|
||||
const roleToTake = roles[0];
|
||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||
setStatus(`Accepting as ${roleToTake}...`);
|
||||
|
||||
|
||||
try {
|
||||
await selectedInvitation.accept();
|
||||
} catch (e) {
|
||||
@@ -392,10 +285,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if invitation already has inputs or needs funding
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
// Calculate how much we need
|
||||
let requiredAmount = 0n;
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
@@ -413,21 +304,19 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const dust = 546n;
|
||||
const totalNeeded = requiredAmount + fee + dust;
|
||||
|
||||
// Find resources
|
||||
const utxos = await selectedInvitation.findSuitableResources({
|
||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
|
||||
if (utxos.length === 0) {
|
||||
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||
setStatus('Ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Select UTXOs
|
||||
setStatus('Selecting UTXOs...');
|
||||
|
||||
|
||||
const selectedUtxos: Array<{
|
||||
outpointTransactionHash: string;
|
||||
outpointIndex: number;
|
||||
@@ -443,12 +332,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined;
|
||||
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
|
||||
continue;
|
||||
}
|
||||
if (lockingBytecodeHex) {
|
||||
seenLockingBytecodes.add(lockingBytecodeHex);
|
||||
}
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
|
||||
|
||||
selectedUtxos.push({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
@@ -457,9 +342,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
});
|
||||
accumulated += BigInt(utxo.valueSatoshis);
|
||||
|
||||
if (accumulated >= totalNeeded) {
|
||||
break;
|
||||
}
|
||||
if (accumulated >= totalNeeded) break;
|
||||
}
|
||||
|
||||
if (accumulated < totalNeeded) {
|
||||
@@ -470,7 +353,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
const changeAmount = accumulated - requiredAmount - fee;
|
||||
|
||||
// Add inputs
|
||||
setStatus('Adding inputs...');
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
@@ -479,7 +361,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}))
|
||||
);
|
||||
|
||||
// Add change output
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await selectedInvitation.addOutputs([{
|
||||
@@ -487,7 +368,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}]);
|
||||
}
|
||||
|
||||
// Show success
|
||||
showInfo(
|
||||
`Requirements filled!\n\n` +
|
||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||
@@ -498,7 +378,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
`Now use "Sign Transaction" to complete.`
|
||||
);
|
||||
setStatus('Ready');
|
||||
|
||||
} catch (error) {
|
||||
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setStatus('Ready');
|
||||
@@ -507,9 +386,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Handle action selection.
|
||||
*/
|
||||
const handleAction = useCallback((action: string) => {
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
@@ -532,70 +408,44 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||
|
||||
/**
|
||||
* Handle list item activation.
|
||||
*/
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||
if (item.key === 'import') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
// For invitation items, we just select them - actions are in the actions panel
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle action item activation.
|
||||
*/
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
|
||||
if (item.value) {
|
||||
handleAction(item.value);
|
||||
}
|
||||
}, [handleAction]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
// ── Keyboard navigation ──────────────────────────────────────────────────
|
||||
// Disabled when the ID dialog or import flow is open.
|
||||
const isOverlayOpen = showIdDialog || importingId !== null;
|
||||
|
||||
useInput((input, key) => {
|
||||
// Handle role selection dialog navigation
|
||||
if (importStage === 'role-select') {
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedRoleIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
handleRoleSelect();
|
||||
} else if (key.escape) {
|
||||
handleImportCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't handle input while ID input dialog is open
|
||||
if (importStage === 'id') return;
|
||||
|
||||
// Tab to switch panels (list -> actions -> list)
|
||||
if (key.tab) {
|
||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'c' to copy
|
||||
if (input === 'c' && selectedInvitation) {
|
||||
copyId();
|
||||
}
|
||||
|
||||
// 'i' to import
|
||||
if (input === 'i') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, { isActive: importStage !== 'id' });
|
||||
}, { isActive: !isOverlayOpen });
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render custom list item for invitation list.
|
||||
*/
|
||||
const renderInvitationListItem = useCallback((
|
||||
item: InvitationListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
// Import item
|
||||
if (item.key === 'import') {
|
||||
return (
|
||||
<Text
|
||||
@@ -608,7 +458,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Invitation item
|
||||
const inv = item.value;
|
||||
if (!inv) return null;
|
||||
|
||||
@@ -628,9 +477,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
}, [templateCache]);
|
||||
|
||||
/**
|
||||
* Render detailed invitation information.
|
||||
*/
|
||||
const renderDetails = () => {
|
||||
if (!selectedInvitation) {
|
||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||
@@ -641,8 +487,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const inputs = getInvitationInputs(selectedInvitation);
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
|
||||
// Try to determine user's entity ID (from first commit they made)
|
||||
|
||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
@@ -650,7 +495,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Row 1: Type, Description, Status */}
|
||||
{/* Type & Status */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
@@ -675,7 +520,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Your Role */}
|
||||
{/* Your Role */}
|
||||
{userRole && (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Your Role: </Text>
|
||||
@@ -686,9 +531,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 3: Inputs & Outputs side by side */}
|
||||
{/* Inputs & Outputs */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{/* Inputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
{inputs.length === 0 ? (
|
||||
@@ -698,8 +542,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const isUserInput = input.entityIdentifier === userEntityId;
|
||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`input-${idx}`}
|
||||
<Text
|
||||
key={`input-${idx}`}
|
||||
color={isUserInput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserInput ? '• ' : '○ '}
|
||||
@@ -711,7 +555,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Outputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
{outputs.length === 0 ? (
|
||||
@@ -721,8 +564,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Text
|
||||
key={`output-${idx}`}
|
||||
<Text
|
||||
key={`output-${idx}`}
|
||||
color={isUserOutput ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserOutput ? '• ' : '○ '}
|
||||
@@ -735,7 +578,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 4: Variables */}
|
||||
{/* Variables */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
{variables.length === 0 ? (
|
||||
@@ -744,12 +587,12 @@ export function InvitationScreen(): React.ReactElement {
|
||||
variables.map((variable, idx) => {
|
||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Text
|
||||
key={`var-${idx}`}
|
||||
<Text
|
||||
key={`var-${idx}`}
|
||||
color={isUserVariable ? colors.success : colors.text}
|
||||
>
|
||||
{' '}{isUserVariable ? '• ' : '○ '}
|
||||
@@ -763,7 +606,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Shortcuts */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||
</Box>
|
||||
@@ -771,84 +613,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render role selection dialog for import flow.
|
||||
*/
|
||||
const renderRoleSelectionDialog = () => {
|
||||
if (!importingInvitation) return null;
|
||||
|
||||
const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier];
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="double"
|
||||
borderColor={colors.primary}
|
||||
backgroundColor="black"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={70}
|
||||
>
|
||||
<Text color={colors.primary} bold>Import Invitation - Select Role</Text>
|
||||
|
||||
{/* Invitation Details */}
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text color={colors.text}>Template: {importTemplate?.name ?? 'Unknown'}</Text>
|
||||
{importTemplate?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{importTemplate.description}</Text>
|
||||
)}
|
||||
<Text color={colors.text}>Action: {action?.name ?? importingInvitation.data.actionIdentifier}</Text>
|
||||
{action?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Role Selection */}
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||
{availableRoles.length === 0 ? (
|
||||
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||
) : (
|
||||
availableRoles.map((role, index) => {
|
||||
const roleInfoRaw = importTemplate?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
const actionRoleRaw = action?.roles?.[role];
|
||||
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||
return (
|
||||
<Box key={role} flexDirection="column">
|
||||
<Text
|
||||
color={index === selectedRoleIndex ? colors.focus : colors.text}
|
||||
bold={index === selectedRoleIndex}
|
||||
>
|
||||
{index === selectedRoleIndex ? '▸ ' : ' '}
|
||||
{roleInfo?.name ?? role}
|
||||
</Text>
|
||||
{(roleInfo?.description || actionRole?.description) && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Decline</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
// ── Main render ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
@@ -857,7 +622,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content - Top row: List + Actions */}
|
||||
{/* Top row: List + Actions */}
|
||||
<Box flexDirection="row" marginTop={1} height={12}>
|
||||
{/* Left column: Invitation list */}
|
||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||
@@ -874,7 +639,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={handleListItemActivate}
|
||||
focus={focusedPanel === 'list'}
|
||||
focus={focusedPanel === 'list' && !isOverlayOpen}
|
||||
maxVisible={6}
|
||||
groups={invitationListGroups}
|
||||
emptyMessage="No invitations yet"
|
||||
@@ -898,14 +663,14 @@ export function InvitationScreen(): React.ReactElement {
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionItemActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
focus={focusedPanel === 'actions' && !isOverlayOpen}
|
||||
emptyMessage="No actions"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom row: Details (full width) */}
|
||||
{/* Bottom row: Details */}
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
@@ -928,8 +693,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import ID dialog (Stage 1) */}
|
||||
{importStage === 'id' && (
|
||||
{/* Import ID dialog */}
|
||||
{showIdDialog && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
@@ -943,14 +708,24 @@ export function InvitationScreen(): React.ReactElement {
|
||||
prompt="Enter Invitation ID:"
|
||||
placeholder="Paste invitation ID..."
|
||||
onSubmit={handleImportIdSubmit}
|
||||
onCancel={() => setImportStage(null)}
|
||||
onCancel={() => setShowIdDialog(false)}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Role Selection dialog (Stage 2) */}
|
||||
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
||||
{/* Multi-step import flow */}
|
||||
{importingId && appService && (
|
||||
<InvitationImportFlow
|
||||
invitationId={importingId}
|
||||
mode="dialog"
|
||||
appService={appService}
|
||||
onClose={handleImportFlowClose}
|
||||
showError={showError}
|
||||
showInfo={showInfo}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* InvitationImportFlow — orchestrates the multi-step invitation import.
|
||||
*
|
||||
* Manages the step state machine, accumulates data from each step, and
|
||||
* injects it into the next step via props (dependency injection).
|
||||
*
|
||||
* Supports two display modes:
|
||||
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
|
||||
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, logoSmall } from '../../../theme.js';
|
||||
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
||||
|
||||
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||
import { ReviewStep } from './steps/ReviewStep.js';
|
||||
|
||||
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
||||
import type { Invitation } from '../../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||
import { InvitationBuilder } from '@xo-cash/engine';
|
||||
import { hexToBin } from '@bitauth/libauth';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
export function InvitationImportFlow({
|
||||
invitationId,
|
||||
mode,
|
||||
appService,
|
||||
onClose,
|
||||
showError,
|
||||
showInfo,
|
||||
setStatus,
|
||||
}: ImportFlowProps): React.ReactElement {
|
||||
// ── Accumulated state ────────────────────────────────────────────────────
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
||||
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||
const [changeAmount, setChangeAmount] = useState(0n);
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
|
||||
// ── Cancel handler ───────────────────────────────────────────────────────
|
||||
/**
|
||||
* Cleans up (removes the invitation if it was fetched) and signals the parent.
|
||||
*/
|
||||
const handleCancel = useCallback(async () => {
|
||||
if (invitation && appService) {
|
||||
try {
|
||||
await appService.removeInvitation(invitation);
|
||||
} catch {
|
||||
// Best-effort removal — don't block close on failure
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
}, [invitation, appService, onClose]);
|
||||
|
||||
// ── Step completion callbacks ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* FetchStep completed — invitation and template are now available.
|
||||
* Also pre-fetches available roles for the next steps.
|
||||
*/
|
||||
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
|
||||
setInvitation(inv);
|
||||
setTemplate(tmpl);
|
||||
|
||||
const builder = InvitationBuilder.fromInvitation(inv.data);
|
||||
setBuildableInvitation(builder);
|
||||
|
||||
try {
|
||||
const roles = await inv.getAvailableRoles();
|
||||
setAvailableRoles(roles);
|
||||
} catch (err) {
|
||||
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
setCurrentStep(1); // → Preview
|
||||
}, [showError]);
|
||||
|
||||
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||
const handlePreviewComplete = useCallback(() => {
|
||||
setCurrentStep(2); // → Role Select
|
||||
}, []);
|
||||
|
||||
/** RoleSelectStep completed — user picked a role. */
|
||||
const handleRoleComplete = useCallback((role: string) => {
|
||||
setSelectedRole(role);
|
||||
setCurrentStep(3); // → Inputs Select
|
||||
}, []);
|
||||
|
||||
/** InputsSelectStep completed — user selected UTXOs. */
|
||||
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||
setSelectedInputs(inputs);
|
||||
|
||||
await invitation?.addInputs(inputs.map(input => ({
|
||||
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||
outpointIndex: input.outpointIndex,
|
||||
})));
|
||||
|
||||
// Compute totals from selected inputs
|
||||
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||
|
||||
// Determine required amount from invitation variables
|
||||
const requiredSats = await invitation?.getSatsOut() ?? 0n;
|
||||
setRequiredAmount(requiredSats);
|
||||
|
||||
// Set the change amount for the review step
|
||||
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
|
||||
setChangeAmount(changeAmountSats);
|
||||
|
||||
console.log('totalSelected:', totalSelected);
|
||||
console.log('requiredAmount:', requiredSats);
|
||||
console.log('DEFAULT_FEE:', DEFAULT_FEE);
|
||||
console.log('changeAmount:', changeAmount);
|
||||
|
||||
// Add the change output if it exceeds the dust threshold
|
||||
if (changeAmountSats >= DUST_THRESHOLD) {
|
||||
await invitation?.addOutputs([{
|
||||
valueSatoshis: changeAmountSats,
|
||||
}]);
|
||||
}
|
||||
|
||||
setCurrentStep(4); // → Review
|
||||
}, [invitation, buildableInvitation, selectedInputs]);
|
||||
|
||||
/** ReviewStep completed — invitation import is done. */
|
||||
const handleReviewComplete = useCallback(() => {
|
||||
const roleName = (() => {
|
||||
if (!selectedRole || !template) return selectedRole ?? '';
|
||||
const raw = template.roles?.[selectedRole];
|
||||
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
|
||||
})();
|
||||
|
||||
showInfo(
|
||||
`Invitation imported and accepted!\n\n` +
|
||||
`Role: ${roleName}\n` +
|
||||
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
|
||||
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||
);
|
||||
setStatus('Ready');
|
||||
onClose();
|
||||
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||
|
||||
// ── Keyboard handling for FetchStep error retry ──────────────────────────
|
||||
// FetchStep auto-advances on success but shows error state with retry on failure.
|
||||
useInput((_input, key) => {
|
||||
if (currentStep !== 0) return;
|
||||
// Enter retries, Esc cancels — handled within FetchStep rendering,
|
||||
// but we also catch Esc here for safety.
|
||||
if (key.escape) handleCancel();
|
||||
}, { isActive: currentStep === 0 });
|
||||
|
||||
// ── Step router ──────────────────────────────────────────────────────────
|
||||
const renderStep = (): React.ReactNode => {
|
||||
const stepDef = IMPORT_STEPS[currentStep];
|
||||
if (!stepDef) return null;
|
||||
|
||||
switch (stepDef.type) {
|
||||
case 'fetch':
|
||||
return (
|
||||
<FetchInvitationStep
|
||||
invitationId={invitationId}
|
||||
appService={appService}
|
||||
onComplete={handleFetchComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'preview':
|
||||
if (!invitation) return null;
|
||||
return (
|
||||
<PreviewInvitationStep
|
||||
invitation={invitation}
|
||||
template={template}
|
||||
onComplete={handlePreviewComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'role-select':
|
||||
if (!invitation) return null;
|
||||
return (
|
||||
<RoleSelectStep
|
||||
invitation={invitation}
|
||||
template={template}
|
||||
availableRoles={availableRoles}
|
||||
onComplete={handleRoleComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'inputs-select':
|
||||
if (!invitation || !selectedRole) return null;
|
||||
return (
|
||||
<InputsSelectStep
|
||||
invitation={invitation}
|
||||
template={template}
|
||||
selectedRole={selectedRole}
|
||||
appService={appService}
|
||||
onComplete={handleInputsComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'review':
|
||||
if (!invitation || !selectedRole) return null;
|
||||
return (
|
||||
<ReviewStep
|
||||
invitation={invitation}
|
||||
template={template}
|
||||
selectedRole={selectedRole}
|
||||
selectedInputs={selectedInputs}
|
||||
changeAmount={changeAmount}
|
||||
requiredAmount={requiredAmount}
|
||||
appService={appService}
|
||||
onComplete={handleReviewComplete}
|
||||
onCancel={handleCancel}
|
||||
isActive={true}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ── Step indicator data ──────────────────────────────────────────────────
|
||||
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
|
||||
|
||||
// ── Layout: dialog mode ──────────────────────────────────────────────────
|
||||
if (mode === 'dialog') {
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
height="100%"
|
||||
>
|
||||
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
|
||||
{/* Step indicator (compact) */}
|
||||
<Box marginTop={1}>
|
||||
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||
</Box>
|
||||
|
||||
{/* Step content */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{renderStep()}
|
||||
</Box>
|
||||
</DialogWrapper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout: screen mode ──────────────────────────────────────────────────
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{/* Header */}
|
||||
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
|
||||
<Text color={colors.textMuted}>
|
||||
{template?.name ?? 'Loading...'}
|
||||
{selectedRole ? ` (as ${selectedRole})` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Step indicator */}
|
||||
<Box marginTop={1} paddingX={1}>
|
||||
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||
</Box>
|
||||
|
||||
{/* Step content */}
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={colors.primary}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
marginTop={1}
|
||||
marginX={1}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Text color={colors.primary} bold>
|
||||
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
|
||||
</Text>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{renderStep()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box marginTop={1} marginX={1}>
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
Esc: Cancel import
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* FetchInvitationStep — first step in the import flow.
|
||||
*
|
||||
* Receives an invitation ID, fetches the invitation from the sync server,
|
||||
* resolves its template, and auto-advances once loaded.
|
||||
* Shows a loading spinner while fetching and an error state with retry/cancel.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { colors } from '../../../../theme.js';
|
||||
import type { FetchStepProps } from '../types.js';
|
||||
|
||||
export function FetchInvitationStep({
|
||||
invitationId,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: FetchStepProps): React.ReactElement {
|
||||
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch the invitation and its template, then auto-advance.
|
||||
*/
|
||||
const fetchInvitation = useCallback(async () => {
|
||||
setStatus('loading');
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
// Create/fetch the invitation instance (fetches from sync server if needed)
|
||||
const invitation = await appService.createInvitation(invitationId);
|
||||
|
||||
// Resolve the template for display in later steps
|
||||
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||
|
||||
// Auto-advance — hand the loaded data to the flow controller
|
||||
onComplete(invitation, template ?? null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [invitationId, appService, onComplete]);
|
||||
|
||||
// Kick off the fetch on mount
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
fetchInvitation();
|
||||
}
|
||||
}, [isActive, fetchInvitation]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{status === 'loading' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.info}>Fetching invitation...</Text>
|
||||
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.error} bold>Failed to fetch invitation</Text>
|
||||
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
|
||||
*
|
||||
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
|
||||
* Auto-selects greedily, then lets the user toggle individual UTXOs.
|
||||
* Shows required, selected, and change amounts.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
export function InputsSelectStep({
|
||||
invitation,
|
||||
template,
|
||||
selectedRole,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: InputsSelectStepProps): React.ReactElement {
|
||||
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
|
||||
// Derived totals
|
||||
const selectedAmount = utxos
|
||||
.filter(u => u.selected)
|
||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||
const hasEnough = selectedAmount >= requiredAmount + fee;
|
||||
|
||||
/**
|
||||
* Determine the required satoshi amount from the invitation's variables.
|
||||
*/
|
||||
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
|
||||
return await invitation.getSatsOut() ?? 0n;
|
||||
}, [invitation]);
|
||||
|
||||
/**
|
||||
* Fetch suitable UTXOs from the engine and auto-select greedily.
|
||||
*/
|
||||
const loadUtxos = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const required = await computeRequiredAmount();
|
||||
setRequiredAmount(required);
|
||||
|
||||
const unspentOutputs = await invitation.findSuitableResources({
|
||||
templateIdentifier: invitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
});
|
||||
|
||||
// Map to selectable UTXOs
|
||||
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
outpointIndex: utxo.outpointIndex,
|
||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||
lockingBytecode: utxo.lockingBytecode
|
||||
? typeof utxo.lockingBytecode === 'string'
|
||||
? utxo.lockingBytecode
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
// Greedy auto-select, skipping duplicate locking bytecodes
|
||||
let accumulated = 0n;
|
||||
const seenBytecodes = new Set<string>();
|
||||
|
||||
for (const utxo of selectable) {
|
||||
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
|
||||
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
|
||||
|
||||
utxo.selected = true;
|
||||
accumulated += utxo.valueSatoshis;
|
||||
|
||||
if (accumulated >= required + fee) break;
|
||||
}
|
||||
|
||||
setUtxos(selectable);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [invitation, computeRequiredAmount, fee]);
|
||||
|
||||
// Load UTXOs on mount
|
||||
useEffect(() => {
|
||||
if (isActive) loadUtxos();
|
||||
}, [isActive, loadUtxos]);
|
||||
|
||||
/**
|
||||
* Toggle the selection of a UTXO at the given index.
|
||||
*/
|
||||
const toggleSelection = useCallback((index: number) => {
|
||||
setUtxos(prev => {
|
||||
const updated = [...prev];
|
||||
const utxo = updated[index];
|
||||
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyboard handling
|
||||
useInput((input, key) => {
|
||||
if (!isActive) return;
|
||||
|
||||
if (key.upArrow || input === 'k') {
|
||||
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||
} else if (input === ' ' || (key.return && utxos.length > 0)) {
|
||||
// Space or Enter toggles the focused UTXO
|
||||
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||
} else if (input === 'a') {
|
||||
// Select all
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||
} else if (input === 'n') {
|
||||
// Deselect all
|
||||
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||
} else if (key.tab) {
|
||||
// Tab confirms selection (moves to next step)
|
||||
if (hasEnough) {
|
||||
onComplete(utxos.filter(u => u.selected));
|
||||
}
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
}, { isActive });
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.info}>Finding suitable UTXOs...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.error} bold>Failed to load UTXOs</Text>
|
||||
<Text color={colors.textMuted}>{error}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// No UTXOs found
|
||||
if (utxos.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Summary bar */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Required: </Text>
|
||||
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
|
||||
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Selected: </Text>
|
||||
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
|
||||
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
||||
)}
|
||||
{!hasEnough && (
|
||||
<Text color={colors.error}> — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* UTXO list */}
|
||||
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
|
||||
{utxos.map((utxo, index) => {
|
||||
const isFocused = index === focusedIndex;
|
||||
const checkMark = utxo.selected ? '☑' : '☐';
|
||||
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||
bold={isFocused}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>
|
||||
↑↓: Navigate • Space: Toggle • a: All • n: None • Tab: Confirm • Esc: Cancel
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* PreviewInvitationStep — displays the current state of a fetched invitation.
|
||||
*
|
||||
* Shows which roles, inputs, outputs, and variables have already been filled
|
||||
* so the user can understand what they're joining before proceeding.
|
||||
* Press Enter to continue, Esc to cancel.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
getInvitationInputs,
|
||||
getInvitationOutputs,
|
||||
getInvitationVariables,
|
||||
} from '../../../../../utils/invitation-utils.js';
|
||||
import type { PreviewStepProps } from '../types.js';
|
||||
|
||||
/**
|
||||
* Map a semantic color name to an actual theme color value.
|
||||
*/
|
||||
function stateColor(state: string): string {
|
||||
const name = getStateColorName(state);
|
||||
switch (name) {
|
||||
case 'info': return colors.info as string;
|
||||
case 'warning': return colors.warning as string;
|
||||
case 'success': return colors.success as string;
|
||||
case 'error': return colors.error as string;
|
||||
case 'muted':
|
||||
default: return colors.textMuted as string;
|
||||
}
|
||||
}
|
||||
|
||||
export function PreviewInvitationStep({
|
||||
invitation,
|
||||
template,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: PreviewStepProps): React.ReactElement {
|
||||
useInput((_input, key) => {
|
||||
if (!isActive) return;
|
||||
if (key.return) onComplete();
|
||||
if (key.escape) onCancel();
|
||||
}, { isActive });
|
||||
|
||||
const state = getInvitationState(invitation);
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
const inputs = getInvitationInputs(invitation);
|
||||
const outputs = getInvitationOutputs(invitation);
|
||||
const variables = getInvitationVariables(invitation);
|
||||
|
||||
// Collect role identifiers that appear across all commits
|
||||
const filledRoles = new Set<string>();
|
||||
for (const commit of invitation.data.commits ?? []) {
|
||||
for (const input of commit.data?.inputs ?? []) {
|
||||
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Template & action info */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Template: </Text>
|
||||
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||
{template?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{template.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Action: </Text>
|
||||
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||
{action?.description && (
|
||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Status: </Text>
|
||||
<Text color={stateColor(state)}>{state}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Roles already filled */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
|
||||
{filledRoles.size === 0 ? (
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
) : (
|
||||
Array.from(filledRoles).map(role => {
|
||||
const roleInfoRaw = template?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
return (
|
||||
<Text key={role} color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Inputs & Outputs side by side */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
{inputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
) : (
|
||||
inputs.map((input, idx) => {
|
||||
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||
return (
|
||||
<Text key={`input-${idx}`} color={colors.text}>
|
||||
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
{outputs.length === 0 ? (
|
||||
<Text color={colors.textMuted}> None yet</Text>
|
||||
) : (
|
||||
outputs.map((output, idx) => {
|
||||
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||
return (
|
||||
<Text key={`output-${idx}`} color={colors.text}>
|
||||
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Variables */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
{variables.length === 0 ? (
|
||||
<Text color={colors.textMuted}> None set</Text>
|
||||
) : (
|
||||
variables.map((variable, idx) => {
|
||||
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||
const displayValue = typeof variable.value === 'bigint'
|
||||
? variable.value.toString()
|
||||
: String(variable.value);
|
||||
return (
|
||||
<Text key={`var-${idx}`} color={colors.text}>
|
||||
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||
</Text>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>Enter: Continue • Esc: Cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* ReviewStep — final step that summarizes the import and executes it.
|
||||
*
|
||||
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
|
||||
* 1. Adds inputs (with the selected role identifier) to the invitation.
|
||||
* 2. Optionally adds a change output if the change exceeds the dust threshold.
|
||||
* 3. Calls `onComplete()` to signal the flow is finished.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||
|
||||
/** Default fee estimate in satoshis. */
|
||||
const DEFAULT_FEE = 500n;
|
||||
|
||||
/** Dust threshold — outputs below this are unspendable. */
|
||||
const DUST_THRESHOLD = 546n;
|
||||
|
||||
export function ReviewStep({
|
||||
invitation,
|
||||
template,
|
||||
selectedRole,
|
||||
selectedInputs,
|
||||
requiredAmount,
|
||||
changeAmount,
|
||||
appService,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: ReviewStepProps): React.ReactElement {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fee = DEFAULT_FEE;
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
|
||||
// Compute totals from selected inputs
|
||||
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||
|
||||
/**
|
||||
* Execute the import: add inputs (with role) and optional change output.
|
||||
*/
|
||||
const submit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [invitation, selectedRole, selectedInputs, onComplete]);
|
||||
|
||||
// Keyboard handling
|
||||
useInput((_input, key) => {
|
||||
if (!isActive || isSubmitting) return;
|
||||
|
||||
if (key.return) {
|
||||
submit();
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
}, { isActive });
|
||||
|
||||
// Resolve role display name
|
||||
const roleInfoRaw = template?.roles?.[selectedRole];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Review Import</Text>
|
||||
|
||||
{/* Template & action */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Funding summary */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Funding:</Text>
|
||||
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}</Text>
|
||||
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
||||
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</Text>
|
||||
{changeAmount >= DUST_THRESHOLD && (
|
||||
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.error} bold>Error: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Status / hint */}
|
||||
<Box marginTop={1}>
|
||||
{isSubmitting ? (
|
||||
<Text color={colors.info}>Submitting...</Text>
|
||||
) : (
|
||||
<Text color={colors.textMuted}>Enter: Confirm & Import • Esc: Cancel</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* RoleSelectStep — lets the user choose which role to take in the invitation.
|
||||
*
|
||||
* Displays available roles with their template-level and action-level descriptions.
|
||||
* Arrow keys to navigate, Enter to select, Esc to cancel.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { colors } from '../../../../theme.js';
|
||||
import type { RoleSelectStepProps } from '../types.js';
|
||||
|
||||
export function RoleSelectStep({
|
||||
invitation,
|
||||
template,
|
||||
availableRoles,
|
||||
onComplete,
|
||||
onCancel,
|
||||
isActive,
|
||||
}: RoleSelectStepProps): React.ReactElement {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!isActive) return;
|
||||
|
||||
if (key.upArrow || input === 'k') {
|
||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow || input === 'j') {
|
||||
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
const role = availableRoles[selectedIndex];
|
||||
if (role) onComplete(role);
|
||||
} else if (key.escape) {
|
||||
onCancel();
|
||||
}
|
||||
}, { isActive });
|
||||
|
||||
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Context header */}
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
|
||||
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Role list */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||
|
||||
{availableRoles.length === 0 ? (
|
||||
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||
) : (
|
||||
availableRoles.map((role, index) => {
|
||||
const roleInfoRaw = template?.roles?.[role];
|
||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||
const actionRoleRaw = action?.roles?.[role];
|
||||
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||
const isFocused = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={role} flexDirection="column">
|
||||
<Text
|
||||
color={isFocused ? colors.focus : colors.text}
|
||||
bold={isFocused}
|
||||
>
|
||||
{isFocused ? '▸ ' : ' '}
|
||||
{roleInfo?.name ?? role}
|
||||
</Text>
|
||||
{(roleInfo?.description || actionRole?.description) && (
|
||||
<Text color={colors.textMuted} dimColor>
|
||||
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation hint */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
122
src/tui/screens/invitations/invitation-import/types.ts
Normal file
122
src/tui/screens/invitations/invitation-import/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Shared types for the invitation import flow.
|
||||
*
|
||||
* Each step in the flow receives only what it needs via props (dependency injection).
|
||||
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
|
||||
*/
|
||||
|
||||
import type { Invitation } from '../../../../services/invitation.js';
|
||||
import type { AppService } from '../../../../services/app.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
// ── Step definitions ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Identifies each step in the import flow. */
|
||||
export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review';
|
||||
|
||||
/** A single step descriptor used by the flow controller and step indicator. */
|
||||
export interface ImportStep {
|
||||
name: string;
|
||||
type: ImportStepType;
|
||||
}
|
||||
|
||||
/** The ordered list of steps in the import flow. */
|
||||
export const IMPORT_STEPS: ImportStep[] = [
|
||||
{ name: 'Fetch', type: 'fetch' },
|
||||
{ name: 'Preview', type: 'preview' },
|
||||
{ name: 'Select Role', type: 'role-select' },
|
||||
{ name: 'Select Inputs', type: 'inputs-select' },
|
||||
{ name: 'Review', type: 'review' },
|
||||
];
|
||||
|
||||
// ── Display mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
|
||||
export type ImportFlowMode = 'dialog' | 'screen';
|
||||
|
||||
// ── UTXO selection ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A UTXO that the user can toggle on/off during the inputs step. */
|
||||
export interface SelectableUTXO {
|
||||
outpointTransactionHash: string;
|
||||
outpointIndex: number;
|
||||
valueSatoshis: bigint;
|
||||
lockingBytecode?: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// ── Step props ───────────────────────────────────────────────────────────────
|
||||
// Each step receives exactly the data and callbacks it needs.
|
||||
|
||||
/** Props for FetchInvitationStep — loads the invitation from an ID. */
|
||||
export interface FetchStepProps {
|
||||
invitationId: string;
|
||||
appService: AppService;
|
||||
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Props for PreviewInvitationStep — displays invitation state. */
|
||||
export interface PreviewStepProps {
|
||||
invitation: Invitation;
|
||||
template: XOTemplate | null;
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Props for RoleSelectStep — lets user pick a role. */
|
||||
export interface RoleSelectStepProps {
|
||||
invitation: Invitation;
|
||||
template: XOTemplate | null;
|
||||
availableRoles: string[];
|
||||
onComplete: (selectedRole: string) => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||
export interface InputsSelectStepProps {
|
||||
invitation: Invitation;
|
||||
template: XOTemplate | null;
|
||||
selectedRole: string;
|
||||
appService: AppService;
|
||||
onComplete: (inputs: SelectableUTXO[]) => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Props for ReviewStep — summarizes and executes the import. */
|
||||
export interface ReviewStepProps {
|
||||
invitation: Invitation;
|
||||
template: XOTemplate | null;
|
||||
selectedRole: string;
|
||||
selectedInputs: SelectableUTXO[];
|
||||
changeAmount: bigint;
|
||||
requiredAmount: bigint;
|
||||
appService: AppService;
|
||||
onComplete: () => void;
|
||||
onCancel: () => void;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// ── Flow controller props ────────────────────────────────────────────────────
|
||||
|
||||
/** Props for the top-level InvitationImportFlow component. */
|
||||
export interface ImportFlowProps {
|
||||
/** The invitation ID to import (already entered by the user in InvitationScreen). */
|
||||
invitationId: string;
|
||||
/** Whether to render as a dialog overlay or a full screen. */
|
||||
mode: ImportFlowMode;
|
||||
/** The application service — injected, not pulled from context. */
|
||||
appService: AppService;
|
||||
/** Called when the flow completes or is cancelled. */
|
||||
onClose: () => void;
|
||||
/** Display an error message to the user. */
|
||||
showError: (message: string) => void;
|
||||
/** Display an info message to the user. */
|
||||
showInfo: (message: string) => void;
|
||||
/** Update the global status bar. */
|
||||
setStatus: (message: string) => void;
|
||||
}
|
||||
Reference in New Issue
Block a user