/** * Template List Screen - Lists available templates and starting actions. * * Allows users to: * - View imported templates * - Select a template and action to start a new transaction */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { colors, logoSmall } from '../theme.js'; // XO Imports import { generateTemplateIdentifier } from '@xo-cash/engine'; import type { XOTemplate } from '@xo-cash/types'; // Import utility functions import { formatTemplateListItem, formatActionListItem, getTemplateRoles, } from '../../utils/template-utils.js'; /** * Template item with metadata. */ interface TemplateItem { template: XOTemplate; templateIdentifier: string; availableActions: TemplateActionItem[]; } /** * Template list item with TemplateItem value. */ type TemplateListItem = ListItemData; /** * Action list item with available action value. */ type ActionListItem = ListItemData; interface TemplateActionItem { actionIdentifier: string; name: string; description?: string; roles: string[]; source: 'starting' | 'next' | 'starting+next'; } /** * Template List Screen Component. * Displays templates and their starting actions. */ export function TemplateListScreen(): React.ReactElement { const { navigate } = useNavigation(); const { appService, showError } = useAppContext(); const { setStatus } = useStatus(); // State const [templates, setTemplates] = useState([]); const [selectedTemplateIndex, setSelectedTemplateIndex] = useState(0); const [selectedActionIndex, setSelectedActionIndex] = useState(0); const [focusedPanel, setFocusedPanel] = useState<'templates' | 'actions'>('templates'); const [isLoading, setIsLoading] = useState(true); /** * Loads templates from the engine. */ const loadTemplates = useCallback(async () => { if (!appService) { showError('AppService not initialized'); return; } try { setIsLoading(true); setStatus('Loading templates...'); const templateList = await appService.engine.listImportedTemplates(); const allUtxos = await appService.engine.listUnspentOutputsData(); const ownedOutputsByTemplate = new Map>(); for (const utxo of allUtxos) { const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set(); existing.add(utxo.outputIdentifier); ownedOutputsByTemplate.set(utxo.templateIdentifier, existing); } const loadedTemplates = await Promise.all( templateList.map(async (template) => { const templateIdentifier = generateTemplateIdentifier(template); const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier); const actionMap = new Map(); for (const startingAction of rawStartingActions) { const existing = actionMap.get(startingAction.action); if (existing) { if (!existing.roles.includes(startingAction.role)) { existing.roles.push(startingAction.role); } continue; } const actionDef = template.actions?.[startingAction.action]; actionMap.set(startingAction.action, { actionIdentifier: startingAction.action, name: actionDef?.name || startingAction.action, description: actionDef?.description, roles: [startingAction.role], source: 'starting', }); } const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set(); for (const outputIdentifier of ownedOutputIdentifiers) { const outputDef = template.outputs?.[outputIdentifier]; if (!outputDef || typeof outputDef.lockscript !== 'string') continue; const lockingScriptDefinition = (template.lockingScripts as Record | undefined)?.[outputDef.lockscript] as | { roles?: Record }> } | undefined; if (!lockingScriptDefinition?.roles) continue; for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) { for (const actionSpec of lockscriptRoleDef.actions ?? []) { const actionIdentifier = typeof actionSpec === 'string' ? actionSpec : actionSpec.action; if (!actionIdentifier) continue; const roleIdentifier = typeof actionSpec === 'string' ? lockscriptRoleId : (actionSpec.role ?? lockscriptRoleId); const existing = actionMap.get(actionIdentifier); if (existing) { if (!existing.roles.includes(roleIdentifier)) { existing.roles.push(roleIdentifier); } if (existing.source === 'starting') { existing.source = 'starting+next'; } continue; } const actionDef = template.actions?.[actionIdentifier]; actionMap.set(actionIdentifier, { actionIdentifier, name: actionDef?.name || actionIdentifier, description: actionDef?.description, roles: [roleIdentifier], source: 'next', }); } } } const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name)); return { template, templateIdentifier, availableActions, }; }) ); setTemplates(loadedTemplates); setSelectedTemplateIndex(0); setSelectedActionIndex(0); setStatus('Ready'); setIsLoading(false); } catch (error) { showError(`Failed to load templates: ${error instanceof Error ? error.message : String(error)}`); setIsLoading(false); } }, [appService, setStatus, showError]); // Load templates on mount useEffect(() => { loadTemplates(); }, [loadTemplates]); // Get current template and its actions const currentTemplate = templates[selectedTemplateIndex]; const currentActions = currentTemplate?.availableActions ?? []; /** * Build template list items for ScrollableList. */ const templateListItems = useMemo((): TemplateListItem[] => { return templates.map((item, index) => { const formatted = formatTemplateListItem(item.template, index); return { key: item.templateIdentifier, label: formatted.label, description: formatted.description, value: item, hidden: !formatted.isValid, }; }); }, [templates]); /** * Build action list items for ScrollableList. */ const actionListItems = useMemo((): ActionListItem[] => { return currentActions.map((action, index) => { const formatted = formatActionListItem( action.actionIdentifier, currentTemplate?.template?.actions?.[action.actionIdentifier], action.roles.length, index ); const sourceSuffix = action.source === 'next' ? ' [next]' : action.source === 'starting+next' ? ' [start+next]' : ''; return { key: action.actionIdentifier, label: `${formatted.label}${sourceSuffix}`, description: formatted.description, value: action, hidden: !formatted.isValid, }; }); }, [currentActions, currentTemplate]); /** * Handle template selection change. */ const handleTemplateSelect = useCallback((index: number) => { setSelectedTemplateIndex(index); setSelectedActionIndex(0); // Reset action selection when template changes }, []); /** * Handles action selection. * Navigates to the Action Wizard where the user will choose their role. */ const handleActionActivate = useCallback((item: ActionListItem, index: number) => { if (!currentTemplate || !item.value) return; const action = item.value; // Navigate to the Action Wizard — role selection happens there navigate('wizard', { templateIdentifier: currentTemplate.templateIdentifier, actionIdentifier: action.actionIdentifier, actionRoles: action.roles, template: currentTemplate.template, }); }, [currentTemplate, navigate]); // Handle keyboard navigation useInput((input, key) => { // Tab to switch panels if (key.tab) { setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); return; } }); /** * Render custom template list item. */ const renderTemplateItem = useCallback(( item: TemplateListItem, isSelected: boolean, isFocused: boolean ): React.ReactNode => { return ( {isFocused ? '▸ ' : ' '} {item.label} ); }, []); /** * Render custom action list item. */ const renderActionItem = useCallback(( item: ActionListItem, isSelected: boolean, isFocused: boolean ): React.ReactNode => { return ( {isFocused ? '▸ ' : ' '} {item.label} ); }, []); return ( {/* Header */} {logoSmall} - Select Template & Action {/* Main content - two columns */} {/* Left column: Template list */} Templates {isLoading ? ( Loading... ) : ( )} {/* Right column: Actions list */} Available Actions {isLoading ? ( Loading... ) : !currentTemplate ? ( Select a template... ) : ( )} {/* Description box */} Description {/* Show template description when templates panel is focused */} {focusedPanel === 'templates' && currentTemplate ? ( {currentTemplate.template.name || 'Unnamed Template'} {currentTemplate.template.description || 'No description available'} {currentTemplate.template.version !== undefined && ( Version: {currentTemplate.template.version} )} {currentTemplate.template.roles && ( Roles: {getTemplateRoles(currentTemplate.template).map((role) => ( {' '}- {role.name}: {role.description || 'No description'} ))} )} ) : focusedPanel === 'templates' && !currentTemplate ? ( Select a template to see details ) : null} {/* Show action description when actions panel is focused */} {focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? ( {(() => { const action = currentActions[selectedActionIndex]; if (!action) return null; return ( <> {action.name} {action.description || 'No description available'} {/* List roles available for this action in current context */} {action.roles.length > 0 && ( Available Roles: {action.roles.map((roleId) => { const roleDef = currentTemplate.template.roles?.[roleId]; const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId; const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined; return ( {' '}- {roleName} {roleDescription ? `: ${roleDescription}` : ''} ); })} {' '}Source: {action.source} )} ); })()} ) : focusedPanel === 'actions' && !currentTemplate ? ( Select a template first ) : focusedPanel === 'actions' && currentActions.length === 0 ? ( No actions available ) : null} {/* Help text */} Tab: Switch list • Enter: Select action • ↑↓: Navigate • Esc: Back ); }