Large amount of changes. Successfully broadcasts txs

This commit is contained in:
2026-03-08 15:53:50 +00:00
parent 66e9918e04
commit 9ef1720e1f
19 changed files with 1374 additions and 352 deletions

View File

@@ -0,0 +1,731 @@
/**
* Invitation Screen - Manages invitations (create, import, view, monitor).
*
* Provides:
* - Import invitation by ID with multi-step import flow
* - View active invitations with detailed information
* - Monitor invitation updates via SSE
* - Fill missing requirements
* - Sign and complete invitations
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Box, Text, useInput } from 'ink';
import { InputDialog } from '../../components/Dialog.js';
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
import { useNavigation } from '../../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
import { useInvitations } from '../../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
import { copyToClipboard } from '../../utils/clipboard.js';
import type { Invitation } from '../../../services/invitation.js';
import type { XOTemplate } from '@xo-cash/types';
import {
getInvitationState,
getStateColorName,
getInvitationInputs,
getInvitationOutputs,
getInvitationVariables,
getUserRole,
formatInvitationListItem,
formatInvitationId,
} from '../../../utils/invitation-utils.js';
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
/**
* Map state color name to theme color.
*/
function getStateColor(state: string): string {
const colorName = getStateColorName(state);
switch (colorName) {
case 'info':
return colors.info as string;
case 'warning':
return colors.warning as string;
case 'success':
return colors.success as string;
case 'error':
return colors.error as string;
case 'muted':
default:
return colors.textMuted as string;
}
}
/**
* Action menu items.
*/
const actionItems: ListItemData<string>[] = [
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
];
/**
* Invitation list item with invitation value or null for import action.
*/
type InvitationListItem = ListItemData<Invitation | null>;
/**
* Groups for the invitation list.
*/
const invitationListGroups: ListGroup[] = [
{ id: 'actions' },
{ id: 'invitations', separator: true },
];
/**
* Invitation Screen Component.
*/
export function InvitationScreen(): React.ReactElement {
const { navigate, data: navData } = useNavigation();
const { appService, showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
const invitations = useInvitations();
// ── UI state ─────────────────────────────────────────────────────────────
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
const [isLoading, setIsLoading] = useState(false);
// ── Import state ─────────────────────────────────────────────────────────
// Two phases: first the ID input dialog, then the multi-step import flow.
const [showIdDialog, setShowIdDialog] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
// ── Template cache ───────────────────────────────────────────────────────
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
// Check if we should open import dialog on mount
const initialMode = navData.mode as string | undefined;
useEffect(() => {
if (initialMode === 'import') {
setShowIdDialog(true);
}
}, [initialMode]);
/**
* Load templates for all invitations (for list display).
*/
useEffect(() => {
if (!appService) return;
invitations.forEach(inv => {
const templateId = inv.data.templateIdentifier;
if (!templateCache.has(templateId)) {
appService.engine.getTemplate(templateId).then(template => {
if (template) {
setTemplateCache(prev => new Map(prev).set(templateId, template));
}
});
}
});
}, [invitations, appService, templateCache]);
/**
* Build list items for ScrollableList.
*/
const listItems = useMemo((): InvitationListItem[] => {
const importItem: InvitationListItem = {
key: 'import',
label: '+ Import Invitation',
value: null,
group: 'actions',
color: 'info',
};
const invitationItems: InvitationListItem[] = invitations.map(inv => {
const template = templateCache.get(inv.data.templateIdentifier);
const formatted = formatInvitationListItem(inv, template);
return {
key: inv.data.invitationIdentifier,
label: formatted.label,
value: inv,
group: 'invitations',
color: formatted.statusColor,
hidden: !formatted.isValid,
};
});
return [importItem, ...invitationItems];
}, [invitations, templateCache]);
const selectedItem = listItems[selectedIndex];
const selectedInvitation = selectedItem?.value ?? null;
/**
* Load template for selected invitation.
*/
useEffect(() => {
if (!selectedInvitation || !appService) {
setSelectedTemplate(null);
return;
}
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
.then(template => setSelectedTemplate(template ?? null));
}, [selectedInvitation, appService]);
// ── Import flow callbacks ──────────────────────────────────────────────
/**
* ID dialog submitted — transition to the multi-step import flow.
*/
const handleImportIdSubmit = useCallback((invitationId: string) => {
if (!invitationId.trim()) {
setShowIdDialog(false);
return;
}
setShowIdDialog(false);
setImportingId(invitationId.trim());
}, []);
/**
* Import flow closed (completed or cancelled).
*/
const handleImportFlowClose = useCallback(() => {
setImportingId(null);
}, []);
// ── Action handlers ────────────────────────────────────────────────────
const acceptInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Accepting invitation...');
await selectedInvitation.accept();
showInfo('Invitation accepted! You are now a participant.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
setStatus('Ready');
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
} else {
showError(`Failed to accept: ${errorMsg}`);
}
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const signInvitation = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Signing invitation...');
await selectedInvitation.sign();
showInfo('Invitation signed!');
setStatus('Ready');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
await copyToClipboard(selectedInvitation.data.invitationIdentifier);
showInfo(`Copied!\n\n${selectedInvitation.data.invitationIdentifier}`);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [selectedInvitation, showInfo, showError]);
const fillRequirements = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
return;
}
try {
setIsLoading(true);
setStatus('Checking available roles...');
const roles = await selectedInvitation.getAvailableRoles();
if (roles.length === 0) {
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
} else {
const roleToTake = roles[0];
showInfo(`Accepting invitation as role: ${roleToTake}`);
setStatus(`Accepting as ${roleToTake}...`);
try {
await selectedInvitation.accept();
} catch (e) {
showError(`Failed to accept role: ${e instanceof Error ? e.message : String(e)}`);
setStatus('Ready');
return;
}
}
setStatus('Analyzing invitation...');
let requiredAmount = 0n;
const commits = selectedInvitation.data.commits || [];
for (const commit of commits) {
const variables = commit.data?.variables || [];
for (const variable of variables) {
if (variable.variableIdentifier?.toLowerCase().includes('satoshi')) {
requiredAmount = BigInt(variable.value?.toString() || '0');
break;
}
}
if (requiredAmount > 0n) break;
}
const fee = 500n;
const dust = 546n;
const totalNeeded = requiredAmount + fee + dust;
const utxos = await selectedInvitation.findSuitableResources({
templateIdentifier: selectedInvitation.data.templateIdentifier,
outputIdentifier: 'receiveOutput',
});
if (utxos.length === 0) {
showError('No suitable UTXOs found. Make sure your wallet has funds.');
setStatus('Ready');
return;
}
setStatus('Selecting UTXOs...');
const selectedUtxos: Array<{
outpointTransactionHash: string;
outpointIndex: number;
valueSatoshis: bigint;
}> = [];
let accumulated = 0n;
const seenLockingBytecodes = new Set<string>();
for (const utxo of utxos) {
const lockingBytecodeHex = utxo.lockingBytecode
? typeof utxo.lockingBytecode === 'string'
? utxo.lockingBytecode
: Buffer.from(utxo.lockingBytecode).toString('hex')
: undefined;
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
selectedUtxos.push({
outpointTransactionHash: utxo.outpointTransactionHash,
outpointIndex: utxo.outpointIndex,
valueSatoshis: BigInt(utxo.valueSatoshis),
});
accumulated += BigInt(utxo.valueSatoshis);
if (accumulated >= totalNeeded) break;
}
if (accumulated < totalNeeded) {
showError(`Insufficient funds. Need ${formatSatoshis(totalNeeded)}, have ${formatSatoshis(accumulated)}`);
setStatus('Ready');
return;
}
const changeAmount = accumulated - requiredAmount - fee;
setStatus('Adding inputs...');
await selectedInvitation.addInputs(
selectedUtxos.map(u => ({
outpointTransactionHash: new Uint8Array(Buffer.from(u.outpointTransactionHash, 'hex')),
outpointIndex: u.outpointIndex,
}))
);
if (changeAmount >= dust) {
setStatus('Adding change output...');
await selectedInvitation.addOutputs([{
valueSatoshis: changeAmount,
}]);
}
showInfo(
`Requirements filled!\n\n` +
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
`• Total: ${formatSatoshis(accumulated)}\n` +
`• Required: ${formatSatoshis(requiredAmount)}\n` +
`• Fee: ${formatSatoshis(fee)}\n` +
`• Change: ${formatSatoshis(changeAmount)}\n\n` +
`Now use "Sign Transaction" to complete.`
);
setStatus('Ready');
} catch (error) {
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
setStatus('Ready');
} finally {
setIsLoading(false);
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const handleAction = useCallback((action: string) => {
switch (action) {
case 'copy':
copyId();
break;
case 'accept':
acceptInvitation();
break;
case 'fill':
fillRequirements();
break;
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
}
break;
}
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
if (item.key === 'import') {
setShowIdDialog(true);
}
}, []);
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
if (item.value) {
handleAction(item.value);
}
}, [handleAction]);
// ── Keyboard navigation ──────────────────────────────────────────────────
// Disabled when the ID dialog or import flow is open.
const isOverlayOpen = showIdDialog || importingId !== null;
useInput((input, key) => {
if (key.tab) {
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
return;
}
if (input === 'c' && selectedInvitation) {
copyId();
}
if (input === 'i') {
setShowIdDialog(true);
}
}, { isActive: !isOverlayOpen });
// ── Render helpers ───────────────────────────────────────────────────────
const renderInvitationListItem = useCallback((
item: InvitationListItem,
isSelected: boolean,
isFocused: boolean
): React.ReactNode => {
if (item.key === 'import') {
return (
<Text
color={isFocused ? colors.focus : colors.info}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
{item.label}
</Text>
);
}
const inv = item.value;
if (!inv) return null;
const state = getInvitationState(inv);
const template = templateCache.get(inv.data.templateIdentifier);
const templateName = template?.name ?? 'Unknown';
return (
<Text
color={isFocused ? colors.focus : colors.text}
bold={isSelected}
>
{isFocused ? '▸ ' : ' '}
<Text color={getStateColor(state)}>[{state}]</Text>
{' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)})
</Text>
);
}, [templateCache]);
const renderDetails = () => {
if (!selectedInvitation) {
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
}
const state = getInvitationState(selectedInvitation);
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
const inputs = getInvitationInputs(selectedInvitation);
const outputs = getInvitationOutputs(selectedInvitation);
const variables = getInvitationVariables(selectedInvitation);
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
const userRole = getUserRole(selectedInvitation, userEntityId);
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
return (
<Box flexDirection="column">
{/* Type & Status */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%">
<Box flexDirection="column">
<Text color={colors.primary} bold>Type: </Text>
<Text color={colors.text}>{selectedTemplate?.name ?? 'Unknown Template'}</Text>
<Text color={colors.textMuted} dimColor>
{selectedTemplate?.description ?? 'No description available'}
</Text>
</Box>
</Box>
<Box width="50%">
<Box flexDirection="column">
<Text color={colors.primary} bold>Status: </Text>
<Text color={getStateColor(state)}>{state}</Text>
<Text color={colors.textMuted}>
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
</Text>
{action?.description && (
<Text color={colors.textMuted} dimColor>{action.description}</Text>
)}
</Box>
</Box>
</Box>
{/* Your Role */}
{userRole && (
<Box marginBottom={1} flexDirection="column">
<Text color={colors.primary} bold>Your Role: </Text>
<Text color={colors.success}>{roleInfo?.name ?? userRole}</Text>
{roleInfo?.description && (
<Text color={colors.textMuted} dimColor>{roleInfo.description}</Text>
)}
</Box>
)}
{/* Inputs & Outputs */}
<Box flexDirection="row" marginBottom={1}>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
{inputs.length === 0 ? (
<Text color={colors.textMuted}> No inputs yet</Text>
) : (
inputs.map((input, idx) => {
const isUserInput = input.entityIdentifier === userEntityId;
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
return (
<Text
key={`input-${idx}`}
color={isUserInput ? colors.success : colors.text}
>
{' '}{isUserInput ? '• ' : '○ '}
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
{input.roleIdentifier && ` (${input.roleIdentifier})`}
</Text>
);
})
)}
</Box>
<Box width="50%" flexDirection="column">
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
{outputs.length === 0 ? (
<Text color={colors.textMuted}> No outputs yet</Text>
) : (
outputs.map((output, idx) => {
const isUserOutput = output.entityIdentifier === userEntityId;
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
return (
<Text
key={`output-${idx}`}
color={isUserOutput ? colors.success : colors.text}
>
{' '}{isUserOutput ? '• ' : '○ '}
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
</Text>
);
})
)}
</Box>
</Box>
{/* Variables */}
<Box flexDirection="column">
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
{variables.length === 0 ? (
<Text color={colors.textMuted}> No variables set</Text>
) : (
variables.map((variable, idx) => {
const isUserVariable = variable.entityIdentifier === userEntityId;
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
const displayValue = typeof variable.value === 'bigint'
? variable.value.toString()
: String(variable.value);
return (
<Text
key={`var-${idx}`}
color={isUserVariable ? colors.success : colors.text}
>
{' '}{isUserVariable ? '• ' : '○ '}
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
{varTemplate?.description && (
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
)}
</Text>
);
})
)}
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
</Box>
</Box>
);
};
// ── Main render ──────────────────────────────────────────────────────────
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
</Box>
{/* Top row: List + Actions */}
<Box flexDirection="row" marginTop={1} height={12}>
{/* Left column: Invitation list */}
<Box flexDirection="column" width="70%" paddingRight={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Invitations </Text>
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={handleListItemActivate}
focus={focusedPanel === 'list' && !isOverlayOpen}
maxVisible={6}
groups={invitationListGroups}
emptyMessage="No invitations yet"
renderItem={renderInvitationListItem}
/>
</Box>
</Box>
{/* Right column: Actions */}
<Box flexDirection="column" width="30%" paddingLeft={1}>
<Box
borderStyle="single"
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Actions </Text>
<ScrollableList
items={actionItems}
selectedIndex={selectedActionIndex}
onSelect={setSelectedActionIndex}
onActivate={handleActionItemActivate}
focus={focusedPanel === 'actions' && !isOverlayOpen}
emptyMessage="No actions"
/>
</Box>
</Box>
</Box>
{/* Bottom row: Details */}
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<Box
borderStyle="single"
borderColor={colors.border}
flexDirection="column"
paddingX={1}
flexGrow={1}
>
<Text color={colors.primary} bold> Details </Text>
<Box marginTop={1} flexDirection="column">
{renderDetails()}
</Box>
</Box>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch panel : Navigate Enter: Select i: Import c: Copy ID Esc: Back
</Text>
</Box>
{/* Import ID dialog */}
{showIdDialog && (
<Box
position="absolute"
flexDirection="column"
alignItems="center"
justifyContent="center"
width="100%"
height="100%"
>
<InputDialog
title="Import Invitation"
prompt="Enter Invitation ID:"
placeholder="Paste invitation ID..."
onSubmit={handleImportIdSubmit}
onCancel={() => setShowIdDialog(false)}
isActive={true}
/>
</Box>
)}
{/* Multi-step import flow */}
{importingId && appService && (
<InvitationImportFlow
invitationId={importingId}
mode="dialog"
appService={appService}
onClose={handleImportFlowClose}
showError={showError}
showInfo={showInfo}
setStatus={setStatus}
/>
)}
</Box>
);
}