814 lines
28 KiB
TypeScript
814 lines
28 KiB
TypeScript
/**
|
|
* 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 } 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 { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
|
import { useInvitations } from '../../hooks/useInvitations.js';
|
|
import { useSatoshisConversion } from '../../hooks/useSatoshisConversion.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();
|
|
const { currencyCode, formattedFiatPerBchRate, formatSatoshisToFiat } =
|
|
useSatoshisConversion('USD');
|
|
|
|
// ── 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);
|
|
const [pendingImportedInvitationId, setPendingImportedInvitationId] = 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.length, 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((importedInvitationId?: string) => {
|
|
if (importedInvitationId) {
|
|
setPendingImportedInvitationId(importedInvitationId);
|
|
}
|
|
setImportingId(null);
|
|
}, []);
|
|
|
|
/**
|
|
* Once imported invitation is visible in the list, select and focus it.
|
|
*/
|
|
useEffect(() => {
|
|
if (!pendingImportedInvitationId) return;
|
|
|
|
const importedIndex = listItems.findIndex((item) => {
|
|
return item.value?.data.invitationIdentifier === pendingImportedInvitationId;
|
|
});
|
|
|
|
if (importedIndex >= 0) {
|
|
setSelectedIndex(importedIndex);
|
|
setFocusedPanel('list');
|
|
setPendingImportedInvitationId(null);
|
|
}
|
|
}, [pendingImportedInvitationId, listItems]);
|
|
|
|
// ── 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 ──────────────────────────────────────────────────
|
|
// Automatically blocked when any dialog/overlay is capturing input.
|
|
const isCaptured = useIsInputCaptured();
|
|
|
|
useBlockableInput((input, key) => {
|
|
if (key.tab) {
|
|
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
|
return;
|
|
}
|
|
|
|
if (input === 'c' && selectedInvitation) {
|
|
copyId();
|
|
}
|
|
|
|
if (input === 'i') {
|
|
setShowIdDialog(true);
|
|
}
|
|
});
|
|
|
|
// ── 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;
|
|
|
|
const getFiatSuffix = (satoshis: bigint): string => {
|
|
const fiatValue = formatSatoshisToFiat(satoshis);
|
|
return fiatValue ? ` (~${fiatValue})` : '';
|
|
};
|
|
|
|
const parseNumberishToBigInt = (value: unknown): bigint | null => {
|
|
if (typeof value === 'bigint') {
|
|
return value;
|
|
}
|
|
|
|
const asString = String(value).trim();
|
|
if (!/^[-]?\d+$/.test(asString)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return BigInt(asString);
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const isSatoshisVariable = (variableIdentifier: string): boolean => {
|
|
const templateVariable = selectedTemplate?.variables?.[variableIdentifier];
|
|
const templateType = templateVariable?.type?.toLowerCase();
|
|
const templateHint = templateVariable?.hint?.toLowerCase();
|
|
const identifier = variableIdentifier.toLowerCase();
|
|
|
|
if (templateHint?.includes('satoshi')) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
templateType === 'integer' &&
|
|
(identifier.includes('satoshi') || identifier.includes('amount'))
|
|
);
|
|
};
|
|
|
|
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>
|
|
{formattedFiatPerBchRate && (
|
|
<Text color={colors.textMuted}>
|
|
1 BCH = {formattedFiatPerBchRate}
|
|
</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 ?? ''];
|
|
const inputSatoshis = (
|
|
'valueSatoshis' in input && input.valueSatoshis !== undefined
|
|
)
|
|
? parseNumberishToBigInt(input.valueSatoshis)
|
|
: null;
|
|
return (
|
|
<Text
|
|
key={`input-${idx}`}
|
|
color={isUserInput ? colors.success : colors.text}
|
|
>
|
|
{' '}{isUserInput ? '• ' : '○ '}
|
|
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
|
{inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`}
|
|
</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 ?? ''];
|
|
const outputSatoshis = output.valueSatoshis !== undefined
|
|
? parseNumberishToBigInt(output.valueSatoshis)
|
|
: null;
|
|
return (
|
|
<Text
|
|
key={`output-${idx}`}
|
|
color={isUserOutput ? colors.success : colors.text}
|
|
>
|
|
{' '}{isUserOutput ? '• ' : '○ '}
|
|
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
|
{outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`}
|
|
</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);
|
|
const parsedVariableSatoshis = isSatoshisVariable(variable.variableIdentifier)
|
|
? parseNumberishToBigInt(variable.value)
|
|
: null;
|
|
return (
|
|
<Text
|
|
key={`var-${idx}`}
|
|
color={isUserVariable ? colors.success : colors.text}
|
|
>
|
|
{' '}{isUserVariable ? '• ' : '○ '}
|
|
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
|
{parsedVariableSatoshis !== null &&
|
|
` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`}
|
|
{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' && !isCaptured}
|
|
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' && !isCaptured}
|
|
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="screen"
|
|
appService={appService}
|
|
onClose={handleImportFlowClose}
|
|
showError={showError}
|
|
showInfo={showInfo}
|
|
setStatus={setStatus}
|
|
/>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|