/** * 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[] = [ { 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; /** * 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(null); const [pendingImportedInvitationId, setPendingImportedInvitationId] = useState(null); // ── Template cache ─────────────────────────────────────────────────────── const [templateCache, setTemplateCache] = useState>(new Map()); const [selectedTemplate, setSelectedTemplate] = useState(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(); 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, _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 ( {isFocused ? '▸ ' : ' '} {item.label} ); } 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 ( {isFocused ? '▸ ' : ' '} [{state}] {' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)}) ); }, [templateCache]); const renderDetails = () => { if (!selectedInvitation) { return Select an invitation to view details; } 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 ( {/* Type & Status */} Type: {selectedTemplate?.name ?? 'Unknown Template'} {selectedTemplate?.description ?? 'No description available'} Status: {state} Action: {action?.name ?? selectedInvitation.data.actionIdentifier} {formattedFiatPerBchRate && ( 1 BCH = {formattedFiatPerBchRate} )} {action?.description && ( {action.description} )} {/* Your Role */} {userRole && ( Your Role: {roleInfo?.name ?? userRole} {roleInfo?.description && ( {roleInfo.description} )} )} {/* Inputs & Outputs */} Inputs ({inputs.length}): {inputs.length === 0 ? ( No inputs yet ) : ( 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 ( {' '}{isUserInput ? '• ' : '○ '} {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} {input.roleIdentifier && ` (${input.roleIdentifier})`} {inputSatoshis !== null && ` ${formatSatoshis(inputSatoshis)}${getFiatSuffix(inputSatoshis)}`} ); }) )} Outputs ({outputs.length}): {outputs.length === 0 ? ( No outputs yet ) : ( 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 ( {' '}{isUserOutput ? '• ' : '○ '} {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} {outputSatoshis !== null && ` (${formatSatoshis(outputSatoshis)}${getFiatSuffix(outputSatoshis)})`} ); }) )} {/* Variables */} Variables ({variables.length}): {variables.length === 0 ? ( No variables set ) : ( 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 ( {' '}{isUserVariable ? '• ' : '○ '} {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} {parsedVariableSatoshis !== null && ` (${formatSatoshis(parsedVariableSatoshis)}${getFiatSuffix(parsedVariableSatoshis)})`} {varTemplate?.description && ( - {varTemplate.description} )} ); }) )} c: Copy ID ); }; // ── Main render ────────────────────────────────────────────────────────── return ( {/* Header */} {logoSmall} - Invitations {/* Top row: List + Actions */} {/* Left column: Invitation list */} Invitations {/* Right column: Actions */} Actions {/* Bottom row: Details */} Details {renderDetails()} {/* Help text */} Tab: Switch panel • ↑↓: Navigate • Enter: Select • i: Import • c: Copy ID • Esc: Back {/* Import ID dialog */} {showIdDialog && ( setShowIdDialog(false)} isActive={true} /> )} {/* Multi-step import flow */} {importingId && appService && ( )} ); }