diff --git a/src/services/app.ts b/src/services/app.ts index ab80fee..fe5d91b 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -118,16 +118,10 @@ export class AppService extends EventEmitter { const invitationsDb = this.storage.child('invitations'); // Load invitations from storage - console.time('loadInvitations'); const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[]; - console.timeEnd('loadInvitations'); - - console.time('createInvitations'); await Promise.all(invitations.map(async ({ key }) => { await this.createInvitation(key); })); - - console.timeEnd('createInvitations'); } } \ No newline at end of file diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 7c19056..f7a58af 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -1,4 +1,5 @@ import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine'; +import { hasInvitationExpired } from '@xo-cash/engine'; import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types'; import type { UnspentOutputData } from '@xo-cash/state'; @@ -86,6 +87,21 @@ export class Invitation extends EventEmitter { */ private storage: Storage; + /** + * True after we have successfully called sign() on this invitation (session-only, not persisted). + */ + private _weHaveSigned = false; + + /** + * True after we have successfully called broadcast() on this invitation (session-only, not persisted). + */ + private _broadcasted = false; + + /** + * The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown). + */ + public status: string = 'unknown'; + /** * Create an invitation and start the SSE Session required for it. */ @@ -108,28 +124,25 @@ export class Invitation extends EventEmitter { async start(): Promise { // Connect to the sync server and get the invitation (in parallel) - console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`); const [_, invitation] = await Promise.all([ this.syncServer.connect(), this.syncServer.getInvitation(this.data.invitationIdentifier), ]); - console.timeEnd(`connectAndGetInvitation-${this.data.invitationIdentifier}`); // There is a chance we get SSE messages before the invitation is returned, so we want to combine any commits const sseCommits = this.data.commits; - console.time(`mergeCommits-${this.data.invitationIdentifier}`); // Merge the commits const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []); - console.timeEnd(`mergeCommits-${this.data.invitationIdentifier}`); - console.time(`setInvitationData-${this.data.invitationIdentifier}`); // Set the invitation data with the combined commits this.data = { ...this.data, ...invitation, commits: combinedCommits }; // Store the invitation in the storage await this.storage.set(this.data.invitationIdentifier, this.data); - console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`); + + // Compute and emit initial status + await this.updateStatus(); } /** @@ -155,8 +168,8 @@ export class Invitation extends EventEmitter { // Set the new commits this.data = { ...this.data, commits: newCommits }; - // Calculate the new status of the invitation - this.updateStatus(); + // Calculate the new status of the invitation (fire-and-forget; handler is sync) + this.updateStatus().catch(() => {}); // Emit the updated event this.emit('invitation-updated', this.data); @@ -185,12 +198,64 @@ export class Invitation extends EventEmitter { // Return the merged commits return Array.from(initialMap.values()); } + /** - * Update the status of the invitation based on the filled in information + * Compute the invitation status as a single word: expired | complete | ready | signed | actionable | unknown. */ - private updateStatus(): void { - // Calculate the status of the invitation - this.emit('invitation-status-changed', 'pending - this is temporary'); + private async computeStatus(): Promise { + try { + return await this.computeStatusInternal(); + } catch (err) { + return `error (${err instanceof Error ? err.message : String(err)})`; + } + } + + /** + * Internal status computation: returns a single word. + * - expired: any commit has expired + * - complete: we have broadcast this invitation + * - ready: no missing requirements and we have signed (ready to broadcast) + * - signed: we have signed but there are still missing parts (waiting for others) + * - actionable: you can provide data (missing requirements and/or you can sign) + * - unknown: template/action not found or error + */ + private async computeStatusInternal(): Promise { + if (hasInvitationExpired(this.data)) { + return 'expired'; + } + if (this._broadcasted) { + return 'complete'; + } + + let missingReqs; + try { + missingReqs = await this.engine.listMissingRequirements(this.data); + } catch { + return 'unknown'; + } + + const hasMissing = + (missingReqs.variables?.length ?? 0) > 0 || + (missingReqs.inputs?.length ?? 0) > 0 || + (missingReqs.outputs?.length ?? 0) > 0 || + (missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0); + + if (!hasMissing && this._weHaveSigned) { + return 'ready'; + } + if (hasMissing && this._weHaveSigned) { + return 'signed'; + } + return 'actionable'; + } + + /** + * Update the status of the invitation and emit the new single-word status. + */ + private async updateStatus(): Promise { + const status = await this.computeStatus(); + this.status = status; + this.emit('invitation-status-changed', status); } /** @@ -204,7 +269,7 @@ export class Invitation extends EventEmitter { this.syncServer.publishInvitation(this.data); // Update the status of the invitation - this.updateStatus(); + await this.updateStatus(); } /** @@ -220,26 +285,26 @@ export class Invitation extends EventEmitter { // Store the signed invitation in the storage await this.storage.set(this.data.invitationIdentifier, signedInvitation); + this.data = signedInvitation; + this._weHaveSigned = true; + // Update the status of the invitation - this.updateStatus(); + await this.updateStatus(); } /** * Broadcast the invitation */ async broadcast(): Promise { - // Broadcast the invitation - const broadcastedInvitation = await this.engine.executeAction(this.data, { + // Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true) + await this.engine.executeAction(this.data, { broadcastTransaction: true, }); - // Store the broadcasted invitation in the storage - await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation); - - // TODO: Am I supposed to do something here? + this._broadcasted = true; // Update the status of the invitation - this.updateStatus(); + await this.updateStatus(); } // ============================================================================ @@ -260,7 +325,7 @@ export class Invitation extends EventEmitter { await this.storage.set(this.data.invitationIdentifier, this.data); // Update the status of the invitation - this.updateStatus(); + await this.updateStatus(); } /** @@ -295,7 +360,7 @@ export class Invitation extends EventEmitter { const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options); // Update the status of the invitation - this.updateStatus(); + await this.updateStatus(); // Return the suitable resources return unspentOutputs; diff --git a/src/tui/components/List.tsx b/src/tui/components/List.tsx index 1627e92..a50ca81 100644 --- a/src/tui/components/List.tsx +++ b/src/tui/components/List.tsx @@ -1,13 +1,462 @@ /** - * Selectable list component with keyboard navigation. + * List components with keyboard navigation. + * + * Provides: + * - ScrollableList: Full-featured list with grouping, filtering, and custom rendering + * - List: Basic selectable list (legacy, kept for backward compatibility) + * - SimpleList: Non-selectable list for display only */ -import React from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; +import TextInput from 'ink-text-input'; import { colors } from '../theme.js'; +// ============================================================================= +// Types +// ============================================================================= + /** - * List item type. + * Base list item data interface. + * Used by ScrollableList for item data. + */ +export interface ListItemData { + /** Unique key for the item */ + key: string; + /** Display label */ + label: string; + /** Optional secondary text/description */ + description?: string; + /** Optional value associated with item */ + value?: T; + /** Whether item is disabled (can't be activated) */ + disabled?: boolean; + /** Whether item should be hidden (not rendered, skipped in navigation) */ + hidden?: boolean; + /** Custom color name for the item (semantic: 'info', 'warning', 'success', 'error', 'muted') */ + color?: string; + /** Group identifier for grouping items */ + group?: string; +} + +/** + * Group definition for organizing list items. + */ +export interface ListGroup { + /** Unique group identifier */ + id: string; + /** Optional header text to display above group */ + label?: string; + /** Whether to show a separator after this group */ + separator?: boolean; +} + +/** + * Props for ScrollableList component. + */ +export interface ScrollableListProps { + /** Array of list items */ + items: ListItemData[]; + /** Currently selected index */ + selectedIndex: number; + /** Handler called when selection changes */ + onSelect: (index: number) => void; + /** Handler called when item is activated (Enter key) */ + onActivate?: (item: ListItemData, index: number) => void; + /** Whether the list is focused for keyboard input */ + focus?: boolean; + /** Maximum number of visible items (enables scrolling). Default: 10 */ + maxVisible?: number; + /** Whether to show a border around the list */ + border?: boolean; + /** Optional label/title for the list */ + label?: string; + /** Message to show when list is empty */ + emptyMessage?: string; + /** Group definitions for organizing items */ + groups?: ListGroup[]; + /** Whether to enable filtering/search */ + filterable?: boolean; + /** Placeholder text for filter input */ + filterPlaceholder?: string; + /** Handler called when filter text changes */ + onFilterChange?: (filter: string) => void; + /** Custom render function for items */ + renderItem?: (item: ListItemData, isSelected: boolean, isFocused: boolean) => React.ReactNode; + /** Whether to wrap around when navigating past ends */ + wrapNavigation?: boolean; + /** Whether to show the scroll position indicator (e.g., "1-5 of 10"). Default: true */ + showScrollIndicator?: boolean; +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Map semantic color names to theme colors. + */ +function getColorFromName(colorName: string | undefined): string { + 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': + return colors.textMuted as string; + case 'accent': + return colors.accent as string; + case 'primary': + return colors.primary as string; + default: + return colors.text as string; + } +} + +/** + * Find the next valid (non-hidden) index in a direction. + * + * @param items - Array of items + * @param currentIndex - Current index + * @param direction - Direction to search (1 for down, -1 for up) + * @param wrap - Whether to wrap around at ends + * @returns Next valid index, or current if none found + */ +function findNextValidIndex( + items: ListItemData[], + currentIndex: number, + direction: 1 | -1, + wrap: boolean = false +): number { + if (items.length === 0) return 0; + + // Count visible items + const visibleIndices = items + .map((item, idx) => ({ item, idx })) + .filter(({ item }) => !item.hidden) + .map(({ idx }) => idx); + + if (visibleIndices.length === 0) return currentIndex; + + // Find current position in visible indices + const currentVisiblePos = visibleIndices.indexOf(currentIndex); + + if (currentVisiblePos === -1) { + // Current index is hidden, find nearest visible + return visibleIndices[0]; + } + + // Calculate next position + let nextVisiblePos = currentVisiblePos + direction; + + if (wrap) { + if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1; + if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0; + } else { + nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos)); + } + + return visibleIndices[nextVisiblePos]; +} + +/** + * Calculate scroll window for visible items. + */ +function calculateScrollWindow( + selectedIndex: number, + totalItems: number, + maxVisible: number +): { startIndex: number; endIndex: number } { + const halfWindow = Math.floor(maxVisible / 2); + let startIndex = Math.max(0, selectedIndex - halfWindow); + let endIndex = Math.min(totalItems, startIndex + maxVisible); + + // Adjust start if we're near the end + if (endIndex - startIndex < maxVisible) { + startIndex = Math.max(0, endIndex - maxVisible); + } + + return { startIndex, endIndex }; +} + +// ============================================================================= +// ScrollableList Component +// ============================================================================= + +/** + * Full-featured scrollable list with grouping, filtering, and custom rendering. + */ +export function ScrollableList({ + items, + selectedIndex, + onSelect, + onActivate, + focus = true, + maxVisible = 10, + border = false, + label, + emptyMessage = 'No items', + groups, + filterable = false, + filterPlaceholder = 'Filter...', + onFilterChange, + renderItem, + wrapNavigation = false, + showScrollIndicator = true, +}: ScrollableListProps): React.ReactElement { + // Filter state + const [filterText, setFilterText] = useState(''); + const [isFiltering, setIsFiltering] = useState(false); + + // Filter items based on filter text + const filteredItems = useMemo(() => { + if (!filterText.trim()) return items; + const lowerFilter = filterText.toLowerCase(); + return items.map(item => ({ + ...item, + hidden: item.hidden || !item.label.toLowerCase().includes(lowerFilter), + })); + }, [items, filterText]); + + // Get visible (non-hidden) items count + const visibleCount = useMemo(() => + filteredItems.filter(item => !item.hidden).length, + [filteredItems] + ); + + // Handle keyboard navigation + useInput((input, key) => { + if (!focus) return; + + // Toggle filter mode with '/' + if (filterable && input === '/' && !isFiltering) { + setIsFiltering(true); + return; + } + + // Exit filter mode with Escape + if (isFiltering && key.escape) { + setIsFiltering(false); + setFilterText(''); + onFilterChange?.(''); + return; + } + + // Don't process navigation when filtering + if (isFiltering) return; + + // Navigation + if (key.upArrow || input === 'k') { + const newIndex = findNextValidIndex(filteredItems, selectedIndex, -1, wrapNavigation); + onSelect(newIndex); + } else if (key.downArrow || input === 'j') { + const newIndex = findNextValidIndex(filteredItems, selectedIndex, 1, wrapNavigation); + onSelect(newIndex); + } else if (key.return && onActivate) { + const item = filteredItems[selectedIndex]; + if (item && !item.disabled && !item.hidden) { + onActivate(item, selectedIndex); + } + } + }, { isActive: focus && !isFiltering }); + + // Handle filter text change + const handleFilterChange = useCallback((value: string) => { + setFilterText(value); + onFilterChange?.(value); + }, [onFilterChange]); + + // Handle filter submit (Enter in filter mode) + const handleFilterSubmit = useCallback(() => { + setIsFiltering(false); + }, []); + + // Render a single item + const renderListItem = (item: ListItemData, index: number) => { + if (item.hidden) return null; + + const isSelected = index === selectedIndex; + const isFocused = focus && isSelected; + + // Use custom render if provided + if (renderItem) { + return ( + + {renderItem(item, isSelected, isFocused)} + + ); + } + + // Default rendering + const itemColor = isFocused + ? colors.focus + : item.disabled + ? colors.textMuted + : getColorFromName(item.color); + + return ( + + + {isFocused ? '▸ ' : ' '} + {item.label} + + {item.description && ( + {item.description} + )} + + ); + }; + + // Get all visible (non-hidden) items with their original indices + const visibleItemsWithIndices = useMemo(() => { + return filteredItems + .map((item, idx) => ({ item, idx })) + .filter(({ item }) => !item.hidden); + }, [filteredItems]); + + // Calculate scroll window based on visible items only + const scrollWindow = useMemo(() => { + // Find position of selected index in visible items + const selectedVisiblePos = visibleItemsWithIndices.findIndex(({ idx }) => idx === selectedIndex); + const effectivePos = selectedVisiblePos >= 0 ? selectedVisiblePos : 0; + + const halfWindow = Math.floor(maxVisible / 2); + let start = Math.max(0, effectivePos - halfWindow); + let end = Math.min(visibleItemsWithIndices.length, start + maxVisible); + + // Adjust start if we're near the end + if (end - start < maxVisible) { + start = Math.max(0, end - maxVisible); + } + + return { start, end }; + }, [visibleItemsWithIndices, selectedIndex, maxVisible]); + + // Get the slice of visible items to display + const displayItems = useMemo(() => { + return visibleItemsWithIndices.slice(scrollWindow.start, scrollWindow.end); + }, [visibleItemsWithIndices, scrollWindow]); + + // Render content based on grouping + const renderContent = () => { + // Show empty message if no visible items + if (visibleCount === 0) { + return {emptyMessage}; + } + + // If groups are defined, render grouped (but still respect maxVisible) + if (groups && groups.length > 0) { + // Get display item indices for quick lookup + const displayIndices = new Set(displayItems.map(({ idx }) => idx)); + + return ( + + {groups.map((group, groupIndex) => { + // Filter to only items that are in this group AND in the display window + const groupItems = displayItems.filter(({ item }) => item.group === group.id); + + if (groupItems.length === 0) return null; + + return ( + + {/* Group label */} + {group.label && ( + {group.label} + )} + + {/* Group items */} + {groupItems.map(({ item, idx }) => renderListItem(item, idx))} + + {/* Separator - only show if there are more groups with items after this */} + {group.separator && groupIndex < groups.length - 1 && ( + + ──────────────────────── + + )} + + ); + })} + + ); + } + + // No grouping - render with scroll window + return ( + + {displayItems.map(({ item, idx }) => renderListItem(item, idx))} + + ); + }; + + const borderColor = focus ? colors.focus : colors.border; + + const content = ( + + {/* Filter input */} + {filterable && isFiltering && ( + + Filter: + + + )} + + {/* List content */} + {renderContent()} + + {/* Scroll indicator */} + {showScrollIndicator && visibleCount > maxVisible && ( + + {scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount} + + )} + + {/* Filter hint */} + {filterable && !isFiltering && ( + + Press '/' to filter + + )} + + ); + + return ( + + {label && {label}} + {border ? ( + + {content} + + ) : content} + + ); +} + +// ============================================================================= +// Legacy List Component (kept for backward compatibility) +// ============================================================================= + +/** + * Legacy list item type. + * @deprecated Use ListItemData instead */ export interface ListItem { /** Unique key for the item */ @@ -23,7 +472,8 @@ export interface ListItem { } /** - * Props for the List component. + * Props for the legacy List component. + * @deprecated Use ScrollableListProps instead */ interface ListProps { /** List items */ @@ -46,6 +496,7 @@ interface ListProps { /** * Selectable list with keyboard navigation. + * @deprecated Use ScrollableList instead */ export function List({ items, @@ -132,8 +583,12 @@ export function List({ ); } +// ============================================================================= +// SimpleList Component +// ============================================================================= + /** - * Simple inline list for displaying items without selection. + * Props for SimpleList component. */ interface SimpleListProps { items: string[]; @@ -141,6 +596,9 @@ interface SimpleListProps { bullet?: string; } +/** + * Simple inline list for displaying items without selection. + */ export function SimpleList({ items, label, diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index a61142e..f3bc7c7 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -5,6 +5,14 @@ export { Screen } from './Screen.js'; export { Input, TextDisplay } from './Input.js'; export { Button, ButtonRow } from './Button.js'; -export { List, SimpleList, type ListItem } from './List.js'; +export { + List, + SimpleList, + ScrollableList, + type ListItem, + type ListItemData, + type ListGroup, + type ScrollableListProps, +} from './List.js'; export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js'; export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js'; diff --git a/src/tui/screens/Invitation.tsx b/src/tui/screens/Invitation.tsx index 4ceb72b..b84be1d 100644 --- a/src/tui/screens/Invitation.tsx +++ b/src/tui/screens/Invitation.tsx @@ -2,63 +2,52 @@ * Invitation Screen - Manages invitations (create, import, view, monitor). * * Provides: - * - Import invitation by ID - * - View active invitations + * - Import invitation by ID with role selection + * - View active invitations with detailed information * - Monitor invitation updates via SSE * - Fill missing requirements * - Sign and complete invitations */ -import React, { useState, useEffect, useCallback } from 'react'; +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, formatHex, formatSatoshis } from '../theme.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, + getInvitationInputs, + getInvitationOutputs, + getInvitationVariables, + getUserRole, + formatInvitationListItem, + formatInvitationId, +} from '../../utils/invitation-utils.js'; /** - * Get state display string for invitation. - * For now we'll use a simple derived state based on commits. - */ -function getInvitationState(invitation: Invitation): string { - const commits = invitation.data.commits || []; - if (commits.length === 0) return 'created'; - - // Check if invitation has been signed (has signatures) - const hasSig = commits.some(c => c.signature); - if (hasSig) return 'signed'; - - // Check if invitation has inputs/outputs - const hasInputs = commits.some(c => c.data?.inputs && c.data.inputs.length > 0); - const hasOutputs = commits.some(c => c.data?.outputs && c.data.outputs.length > 0); - - if (hasInputs || hasOutputs) return 'pending'; - - return 'published'; -} - -/** - * Get color for invitation state. + * Map state color name to theme color. */ function getStateColor(state: string): string { - switch (state) { - case 'created': - case 'published': + const colorName = getStateColorName(state); + switch (colorName) { + case 'info': return colors.info as string; - case 'pending': + case 'warning': return colors.warning as string; - case 'ready': - case 'signed': + case 'success': return colors.success as string; - case 'broadcast': - case 'completed': - return colors.success as string; - case 'expired': case 'error': return colors.error as string; + case 'muted': default: return colors.textMuted as string; } @@ -67,13 +56,25 @@ function getStateColor(state: string): string { /** * Action menu items. */ -const actionItems = [ - { label: 'Import Invitation', value: 'import' }, - { label: 'Accept & Join', value: 'accept' }, - { label: 'Fill Requirements', value: 'fill' }, - { label: 'Sign Transaction', value: 'sign' }, - { label: 'View Transaction', value: 'transaction' }, - { label: 'Copy Invitation ID', value: 'copy' }, +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 }, ]; /** @@ -90,10 +91,22 @@ export function InvitationScreen(): React.ReactElement { // State const [selectedIndex, setSelectedIndex] = useState(0); const [selectedActionIndex, setSelectedActionIndex] = useState(0); - const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list'); - const [showImportDialog, setShowImportDialog] = useState(false); + 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(null); + const [availableRoles, setAvailableRoles] = useState([]); + const [selectedRoleIndex, setSelectedRoleIndex] = useState(0); + const [importTemplate, setImportTemplate] = useState(null); + + // Template cache for displaying invitation list with template names + const [templateCache, setTemplateCache] = useState>(new Map()); + + // Selected invitation template for details view + const [selectedTemplate, setSelectedTemplate] = useState(null); + // Check if we should open import dialog on mount const initialMode = navData.mode as string | undefined; @@ -102,38 +115,167 @@ export function InvitationScreen(): React.ReactElement { */ useEffect(() => { if (initialMode === 'import') { - setShowImportDialog(true); + setImportStage('id'); } }, [initialMode]); - // Get selected invitation - const selectedInvitation = invitations[selectedIndex]; + /** + * 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]); /** - * Import invitation by ID. + * Build list items for ScrollableList. + * Index 0 is "Import Invitation", subsequent indices are actual invitations. */ - const importInvitation = useCallback(async (invitationId: string) => { - setShowImportDialog(false); - if (!invitationId.trim() || !appService) return; + const listItems = useMemo((): InvitationListItem[] => { + // Import action at top + const importItem: InvitationListItem = { + key: 'import', + label: '+ Import Invitation', + value: null, + group: 'actions', + 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 + }; + }); + + return [importItem, ...invitationItems]; + }, [invitations, templateCache]); + + // Get selected invitation from list items + 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]); + + /** + * Stage 1: Import invitation by ID (fetches invitation and moves to role selection). + */ + const handleImportIdSubmit = useCallback(async (invitationId: string) => { + if (!invitationId.trim() || !appService) { + setImportStage(null); + return; + } try { setIsLoading(true); - setStatus('Importing invitation...'); + setStatus('Fetching invitation...'); // Create invitation instance (will fetch from sync server) const invitation = await appService.createInvitation(invitationId); - showInfo(`Invitation imported!\n\nTemplate: ${invitation.data.templateIdentifier}\nAction: ${invitation.data.actionIdentifier}`); + // Get available roles for this invitation + const roles = await invitation.getAvailableRoles(); + + // 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, showInfo, showError, setStatus]); + }, [appService, showError, setStatus]); /** - * Accept selected invitation. + * Stage 2: Accept invitation with selected role. + */ + const handleRoleSelect = useCallback(async () => { + if (!importingInvitation || !appService) return; + + const selectedRole = availableRoles[selectedRoleIndex]; + if (!selectedRole) { + showError('No role selected'); + return; + } + + try { + setIsLoading(true); + setStatus(`Accepting as ${selectedRole}...`); + + await importingInvitation.accept(); + + 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) { @@ -203,11 +345,6 @@ export function InvitationScreen(): React.ReactElement { /** * Fill requirements for selected invitation. - * This automatically: - * 1. Accepts the invitation (if not already) - * 2. Finds suitable UTXOs - * 3. Selects UTXOs to cover the required amount - * 4. Appends inputs and change output to the invitation */ const fillRequirements = useCallback(async () => { if (!selectedInvitation) { @@ -220,16 +357,14 @@ export function InvitationScreen(): React.ReactElement { // Step 1: Check available roles setStatus('Checking available roles...'); - const availableRoles = await selectedInvitation.getAvailableRoles(); + const roles = await selectedInvitation.getAvailableRoles(); - if (availableRoles.length === 0) { + 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 - // TODO: Let user pick role if multiple available - // For now, auto-select the first available role - const roleToTake = availableRoles[0]; + const roleToTake = roles[0]; showInfo(`Accepting invitation as role: ${roleToTake}`); setStatus(`Accepting as ${roleToTake}...`); @@ -246,7 +381,6 @@ export function InvitationScreen(): React.ReactElement { setStatus('Analyzing invitation...'); // Calculate how much we need - // Look for a requestedSatoshis variable in the invitation let requiredAmount = 0n; const commits = selectedInvitation.data.commits || []; for (const commit of commits) { @@ -260,14 +394,14 @@ export function InvitationScreen(): React.ReactElement { if (requiredAmount > 0n) break; } - const fee = 500n; // Estimated fee - const dust = 546n; // Dust threshold + const fee = 500n; + const dust = 546n; const totalNeeded = requiredAmount + fee + dust; - // Find resources - use a common output identifier + // Find resources const utxos = await selectedInvitation.findSuitableResources({ templateIdentifier: selectedInvitation.data.templateIdentifier, - outputIdentifier: 'receiveOutput', // Try common identifier + outputIdentifier: 'receiveOutput', }); if (utxos.length === 0) { @@ -276,7 +410,7 @@ export function InvitationScreen(): React.ReactElement { return; } - // Step 5: Select UTXOs (auto-select to cover the amount) + // Select UTXOs setStatus('Selecting UTXOs...'); const selectedUtxos: Array<{ @@ -288,7 +422,6 @@ export function InvitationScreen(): React.ReactElement { const seenLockingBytecodes = new Set(); for (const utxo of utxos) { - // Check lockingBytecode uniqueness const lockingBytecodeHex = utxo.lockingBytecode ? typeof utxo.lockingBytecode === 'string' ? utxo.lockingBytecode @@ -322,7 +455,7 @@ export function InvitationScreen(): React.ReactElement { const changeAmount = accumulated - requiredAmount - fee; - // Step 6: Add inputs to the invitation + // Add inputs setStatus('Adding inputs...'); await selectedInvitation.addInputs( selectedUtxos.map(u => ({ @@ -331,7 +464,7 @@ export function InvitationScreen(): React.ReactElement { })) ); - // Step 7: Add change output + // Add change output if (changeAmount >= dust) { setStatus('Adding change output...'); await selectedInvitation.addOutputs([{ @@ -364,9 +497,6 @@ export function InvitationScreen(): React.ReactElement { */ const handleAction = useCallback((action: string) => { switch (action) { - case 'import': - setShowImportDialog(true); - break; case 'copy': copyId(); break; @@ -387,54 +517,323 @@ export function InvitationScreen(): React.ReactElement { } }, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]); + /** + * Handle list item activation. + */ + const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => { + if (item.key === 'import') { + setImportStage('id'); + } + // For invitation items, we just select them - actions are in the actions panel + }, []); + + /** + * Handle action item activation. + */ + const handleActionItemActivate = useCallback((item: ListItemData, index: number) => { + if (item.value) { + handleAction(item.value); + } + }, [handleAction]); + // Handle keyboard navigation useInput((input, key) => { - // Don't handle input while dialog is open - if (showImportDialog) return; - - // Tab to switch panels - if (key.tab) { - setFocusedPanel(prev => { - if (prev === 'list') return 'details'; - if (prev === 'details') return 'actions'; - return 'list'; - }); + // 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; } - // Up/Down navigation - if (key.upArrow || input === 'k') { - if (focusedPanel === 'list') { - setSelectedIndex(prev => Math.max(0, prev - 1)); - } else if (focusedPanel === 'actions') { - setSelectedActionIndex(prev => Math.max(0, prev - 1)); - } - } else if (key.downArrow || input === 'j') { - if (focusedPanel === 'list') { - setSelectedIndex(prev => Math.min(invitations.length - 1, prev + 1)); - } else if (focusedPanel === 'actions') { - setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1)); - } - } + // Don't handle input while ID input dialog is open + if (importStage === 'id') return; - // Enter to select action - if (key.return && focusedPanel === 'actions') { - const action = actionItems[selectedActionIndex]; - if (action) { - handleAction(action.value); - } + // Tab to switch panels (list -> actions -> list) + if (key.tab) { + setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list'); + return; } // 'c' to copy - if (input === 'c') { + if (input === 'c' && selectedInvitation) { copyId(); } // 'i' to import if (input === 'i') { - setShowImportDialog(true); + setImportStage('id'); } - }, { isActive: !showImportDialog }); + }, { isActive: importStage !== 'id' }); + + /** + * 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 ( + + {isFocused ? '▸ ' : ' '} + {item.label} + + ); + } + + // Invitation item + 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]); + + /** + * Render detailed invitation information. + */ + 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); + + // 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]; + const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null; + + return ( + + {/* Row 1: Type, Description, Status */} + + + + Type: + {selectedTemplate?.name ?? 'Unknown Template'} + + {selectedTemplate?.description ?? 'No description available'} + + + + + + Status: + {state} + + Action: {action?.name ?? selectedInvitation.data.actionIdentifier} + + {action?.description && ( + {action.description} + )} + + + + + {/* Row 2: Your Role */} + {userRole && ( + + Your Role: + {roleInfo?.name ?? userRole} + {roleInfo?.description && ( + {roleInfo.description} + )} + + )} + + {/* Row 3: Inputs & Outputs side by side */} + + {/* Inputs */} + + Inputs ({inputs.length}): + {inputs.length === 0 ? ( + No inputs yet + ) : ( + inputs.map((input, idx) => { + const isUserInput = input.entityIdentifier === userEntityId; + const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? '']; + return ( + + {' '}{isUserInput ? '• ' : '○ '} + {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`} + {input.roleIdentifier && ` (${input.roleIdentifier})`} + + ); + }) + )} + + + {/* Outputs */} + + Outputs ({outputs.length}): + {outputs.length === 0 ? ( + No outputs yet + ) : ( + outputs.map((output, idx) => { + const isUserOutput = output.entityIdentifier === userEntityId; + const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? '']; + return ( + + {' '}{isUserOutput ? '• ' : '○ '} + {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`} + {output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`} + + ); + }) + )} + + + + {/* Row 4: 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); + return ( + + {' '}{isUserVariable ? '• ' : '○ '} + {varTemplate?.name ?? variable.variableIdentifier}: {displayValue} + {varTemplate?.description && ( + - {varTemplate.description} + )} + + ); + }) + )} + + + {/* Shortcuts */} + + c: Copy ID + + + ); + }; + + /** + * Render role selection dialog for import flow. + */ + const renderRoleSelectionDialog = () => { + if (!importingInvitation) return null; + + const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier]; + + return ( + + + Import Invitation - Select Role + + {/* Invitation Details */} + + Template: {importTemplate?.name ?? 'Unknown'} + {importTemplate?.description && ( + {importTemplate.description} + )} + Action: {action?.name ?? importingInvitation.data.actionIdentifier} + {action?.description && ( + {action.description} + )} + + + {/* Role Selection */} + + Available Roles: + {availableRoles.length === 0 ? ( + No roles available (you may have already joined) + ) : ( + 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 ( + + + {index === selectedRoleIndex ? '▸ ' : ' '} + {roleInfo?.name ?? role} + + {(roleInfo?.description || actionRole?.description) && ( + + {' '}{actionRole?.description ?? roleInfo?.description} + + )} + + ); + }) + )} + + + + ↑↓: Select role • Enter: Accept • Esc: Decline + + + + ); + }; return ( @@ -443,10 +842,10 @@ export function InvitationScreen(): React.ReactElement { {logoSmall} - Invitations - {/* Main content - three columns */} - + {/* Main content - Top row: List + Actions */} + {/* Left column: Invitation list */} - + - Active Invitations - - {invitations.length === 0 ? ( - No invitations - ) : ( - invitations.map((inv, index) => { - const state = getInvitationState(inv); - return ( - - {index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '} - [{state}] - {' '}{formatHex(inv.data.invitationIdentifier, 12)} - - ); - }) - )} - - - - - {/* Middle column: Details */} - - - Details - - {selectedInvitation ? ( - <> - {(() => { - const state = getInvitationState(selectedInvitation); - return ( - <> - ID: {formatHex(selectedInvitation.data.invitationIdentifier, 20)} - - State: {state} - - - Template: {selectedInvitation.data.templateIdentifier?.slice(0, 20)}... - - - Action: {selectedInvitation.data.actionIdentifier} - - - Commits: {selectedInvitation.data.commits?.length ?? 0} - - - {/* State-specific guidance */} - - {state === 'created' && ( - → Share this ID with the other party - )} - {state === 'published' && ( - → Waiting for other party to join... - )} - {state === 'pending' && ( - <> - → Action needed! - Use "Fill Requirements" to add - your UTXOs and complete your part - - )} - {state === 'ready' && ( - <> - → Ready to sign! - Use "Sign Transaction" - - )} - {state === 'signed' && ( - <> - → Signed! - View Transaction to broadcast - - )} - {state === 'broadcast' && ( - → Transaction broadcast! Waiting for confirmation... - )} - {state === 'completed' && ( - ✓ Transaction completed! - )} - {state === 'error' && ( - ✗ Error - check logs - )} - - - - c: Copy ID - - - ); - })()} - - ) : ( - Select an invitation - )} - + Invitations + {/* Right column: Actions */} - + Actions - - {actionItems.map((item, index) => ( - - {index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '} - {item.label} - - ))} - + + + + + + {/* Bottom row: Details (full width) */} + + + Details + + {renderDetails()} @@ -594,8 +913,8 @@ export function InvitationScreen(): React.ReactElement { - {/* Import dialog */} - {showImportDialog && ( + {/* Import ID dialog (Stage 1) */} + {importStage === 'id' && ( setShowImportDialog(false)} - isActive={showImportDialog} + onSubmit={handleImportIdSubmit} + onCancel={() => setImportStage(null)} + isActive={true} /> )} + + {/* Role Selection dialog (Stage 2) */} + {importStage === 'role-select' && renderRoleSelectionDialog()} ); } diff --git a/src/tui/screens/TemplateList.tsx b/src/tui/screens/TemplateList.tsx index 134d8b7..d4d707f 100644 --- a/src/tui/screens/TemplateList.tsx +++ b/src/tui/screens/TemplateList.tsx @@ -6,8 +6,9 @@ * - Select a template and action to start a new transaction */ -import React, { useState, useEffect, useCallback } from 'react'; +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'; @@ -16,18 +17,15 @@ import { colors, logoSmall } from '../theme.js'; import { generateTemplateIdentifier } from '@xo-cash/engine'; import type { XOTemplate } from '@xo-cash/types'; -/** - * A unique starting action (deduplicated by action identifier). - * Multiple roles that can start the same action are counted - * but not shown as separate entries — role selection happens - * inside the Action Wizard. - */ -interface UniqueStartingAction { - actionIdentifier: string; - name: string; - description?: string; - roleCount: number; -} +// Import utility functions +import { + formatTemplateListItem, + formatActionListItem, + deduplicateStartingActions, + getTemplateRoles, + getRolesForAction, + type UniqueStartingAction, +} from '../../utils/template-utils.js'; /** * Template item with metadata. @@ -38,6 +36,16 @@ interface TemplateItem { startingActions: UniqueStartingAction[]; } +/** + * Template list item with TemplateItem value. + */ +type TemplateListItem = ListItemData; + +/** + * Action list item with UniqueStartingAction value. + */ +type ActionListItem = ListItemData; + /** * Template List Screen Component. * Displays templates and their starting actions. @@ -74,27 +82,13 @@ export function TemplateListScreen(): React.ReactElement { const templateIdentifier = generateTemplateIdentifier(template); const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier); - // Deduplicate by action identifier — role selection - // is handled inside the Action Wizard, not here. - const actionMap = new Map(); - for (const sa of rawStartingActions) { - if (actionMap.has(sa.action)) { - actionMap.get(sa.action)!.roleCount++; - } else { - const actionDef = template.actions?.[sa.action]; - actionMap.set(sa.action, { - actionIdentifier: sa.action, - name: actionDef?.name || sa.action, - description: actionDef?.description, - roleCount: 1, - }); - } - } + // Use utility function to deduplicate actions + const startingActions = deduplicateStartingActions(template, rawStartingActions); return { template, templateIdentifier, - startingActions: Array.from(actionMap.values()), + startingActions, }; }) ); @@ -119,15 +113,59 @@ export function TemplateListScreen(): React.ReactElement { const currentTemplate = templates[selectedTemplateIndex]; const currentActions = currentTemplate?.startingActions ?? []; + /** + * 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.roleCount, + index + ); + return { + key: action.actionIdentifier, + label: formatted.label, + 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 handleActionSelect = useCallback(() => { - if (!currentTemplate || currentActions.length === 0) return; + const handleActionActivate = useCallback((item: ActionListItem, index: number) => { + if (!currentTemplate || !item.value) return; - const action = currentActions[selectedActionIndex]; - if (!action) return; + const action = item.value; // Navigate to the Action Wizard — role selection happens there navigate('wizard', { @@ -135,7 +173,7 @@ export function TemplateListScreen(): React.ReactElement { actionIdentifier: action.actionIdentifier, template: currentTemplate.template, }); - }, [currentTemplate, currentActions, selectedActionIndex, navigate]); + }, [currentTemplate, navigate]); // Handle keyboard navigation useInput((input, key) => { @@ -144,124 +182,111 @@ export function TemplateListScreen(): React.ReactElement { setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates'); return; } - - // Up/Down navigation - if (key.upArrow || input === 'k') { - if (focusedPanel === 'templates') { - setSelectedTemplateIndex(prev => Math.max(0, prev - 1)); - setSelectedActionIndex(0); // Reset action selection when template changes - } else { - setSelectedActionIndex(prev => Math.max(0, prev - 1)); - } - } else if (key.downArrow || input === 'j') { - if (focusedPanel === 'templates') { - setSelectedTemplateIndex(prev => Math.min(templates.length - 1, prev + 1)); - setSelectedActionIndex(0); // Reset action selection when template changes - } else { - setSelectedActionIndex(prev => Math.min(currentActions.length - 1, prev + 1)); - } - } - - // Enter to select action - if (key.return && focusedPanel === 'actions') { - handleActionSelect(); - } }); + /** + * 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 - - - {(() => { - // Loading State - if (isLoading) { - return Loading...; - } - - // No templates state - if (templates.length === 0) { - return No templates imported; - } - - // Templates state - return templates.map((item, index) => ( - - {index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '} - {index + 1}. {item.template.name || 'Unnamed Template'} - - )); - })()} - - + {isLoading ? ( + + Loading... + + ) : ( + + )} {/* Right column: Actions list */} - + Starting Actions - - - {(() => { - // Loading state - if (isLoading) { - return Loading...; - } - - // No template selected state - if (!currentTemplate) { - return Select a template...; - } - - // No starting actions state - if (currentActions.length === 0) { - return No starting actions available; - } - - // Starting actions state - return currentActions.map((action, index) => ( - - {index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '} - {index + 1}. {action.name} - {action.roleCount > 1 - ? ` (${action.roleCount} roles)` - : ''} - - )); - })()} - - + {isLoading ? ( + + Loading... + + ) : !currentTemplate ? ( + + Select a template... + + ) : ( + + )} @@ -269,18 +294,18 @@ export function TemplateListScreen(): React.ReactElement { {/* Description box */} Description {/* Show template description when templates panel is focused */} {focusedPanel === 'templates' && currentTemplate ? ( - + {currentTemplate.template.name || 'Unnamed Template'} @@ -293,11 +318,11 @@ export function TemplateListScreen(): React.ReactElement { )} {currentTemplate.template.roles && ( - + Roles: - {Object.entries(currentTemplate.template.roles).map(([roleId, role]) => ( - - {' '}- {role.name || roleId}: {role.description || 'No description'} + {getTemplateRoles(currentTemplate.template).map((role) => ( + + {' '}- {role.name}: {role.description || 'No description'} ))} @@ -309,14 +334,13 @@ export function TemplateListScreen(): React.ReactElement { {/* Show action description when actions panel is focused */} {focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? ( - + {(() => { const action = currentActions[selectedActionIndex]; if (!action) return null; - // Collect all roles that can start this action - const startEntries = (currentTemplate.template.start ?? []) - .filter((s) => s.action === action.actionIdentifier); + // Get roles that can start this action using utility function + const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier); return ( <> @@ -328,18 +352,15 @@ export function TemplateListScreen(): React.ReactElement { {/* List available roles for this action */} - {startEntries.length > 0 && ( - + {availableRoles.length > 0 && ( + Available Roles: - {startEntries.map((entry) => { - const roleDef = currentTemplate.template.roles?.[entry.role]; - return ( - - {' '}- {roleDef?.name || entry.role} - {roleDef?.description ? `: ${roleDef.description}` : ''} - - ); - })} + {availableRoles.map((role) => ( + + {' '}- {role.name} + {role.description ? `: ${role.description}` : ''} + + ))} )} diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 79ddbc2..1484e18 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -7,25 +7,59 @@ * - Navigation to other actions */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import SelectInput from 'ink-select-input'; +import { ScrollableList, type ListItemData } from '../components/List.js'; import { useNavigation } from '../hooks/useNavigation.js'; import { useAppContext, useStatus } from '../hooks/useAppContext.js'; import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js'; import type { HistoryItem } from '../../services/history.js'; +// Import utility functions +import { + formatHistoryListItem, + getHistoryItemColorName, + formatHistoryDate, + type HistoryColorName, +} from '../../utils/history-utils.js'; + +/** + * Map history color name to theme color. + */ +function getHistoryColor(colorName: HistoryColorName): string { + 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': + return colors.textMuted as string; + case 'text': + default: + return colors.text as string; + } +} + /** * Menu action items. */ -const menuItems = [ - { label: 'New Transaction (from template)', value: 'new-tx' }, - { label: 'Import Invitation', value: 'import' }, - { label: 'View Invitations', value: 'invitations' }, - { label: 'Generate New Address', value: 'new-address' }, - { label: 'Refresh', value: 'refresh' }, +const menuItems: ListItemData[] = [ + { key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' }, + { key: 'import', label: 'Import Invitation', value: 'import' }, + { key: 'invitations', label: 'View Invitations', value: 'invitations' }, + { key: 'new-address', label: 'Generate New Address', value: 'new-address' }, + { key: 'refresh', label: 'Refresh', value: 'refresh' }, ]; +/** + * History list item with HistoryItem value. + */ +type HistoryListItem = ListItemData; + /** * Wallet State Screen Component. * Displays wallet balance, history, and action menu. @@ -39,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement { const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); const [history, setHistory] = useState([]); const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu'); + const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); @@ -124,10 +159,10 @@ export function WalletStateScreen(): React.ReactElement { }, [appService, setStatus, showInfo, showError, refresh]); /** - * Handles menu selection. + * Handles menu action. */ - const handleMenuSelect = useCallback((item: { value: string }) => { - switch (item.value) { + const handleMenuAction = useCallback((action: string) => { + switch (action) { case 'new-tx': navigate('templates'); break; @@ -146,46 +181,131 @@ export function WalletStateScreen(): React.ReactElement { } }, [navigate, generateNewAddress, refresh]); + /** + * Handle menu item activation. + */ + const handleMenuItemActivate = useCallback((item: ListItemData, index: number) => { + if (item.value) { + handleMenuAction(item.value); + } + }, [handleMenuAction]); + + /** + * Build history list items for ScrollableList. + */ + const historyListItems = useMemo((): HistoryListItem[] => { + return history.map(item => { + const formatted = formatHistoryListItem(item, false); + return { + key: item.id, + label: formatted.label, + description: formatted.description, + value: item, + color: formatted.color, + hidden: !formatted.isValid, + }; + }); + }, [history]); + // Handle keyboard navigation between panels useInput((input, key) => { if (key.tab) { setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); } - - // Navigate history items when focused - if (focusedPanel === 'history' && history.length > 0) { - if (key.upArrow) { - setSelectedHistoryIndex(prev => Math.max(0, prev - 1)); - } else if (key.downArrow) { - setSelectedHistoryIndex(prev => Math.min(history.length - 1, prev + 1)); - } - } }); + /** + * Render custom history list item. + */ + const renderHistoryItem = useCallback(( + item: HistoryListItem, + isSelected: boolean, + isFocused: boolean + ): React.ReactNode => { + const historyItem = item.value; + if (!historyItem) return null; + + const colorName = getHistoryItemColorName(historyItem.type, isFocused); + const itemColor = isFocused ? colors.focus : getHistoryColor(colorName); + const dateStr = formatHistoryDate(historyItem.timestamp); + const indicator = isFocused ? '▸ ' : ' '; + + // Format based on type + if (historyItem.type === 'invitation_created') { + return ( + + + {indicator}[Invitation] {historyItem.description} + + {dateStr && {dateStr}} + + ); + } else if (historyItem.type === 'utxo_reserved') { + const sats = historyItem.valueSatoshis ?? 0n; + return ( + + + + {indicator}[Reserved] {formatSatoshis(sats)} + + {historyItem.description} + + {dateStr && {dateStr}} + + ); + } else if (historyItem.type === 'utxo_received') { + const sats = historyItem.valueSatoshis ?? 0n; + const reservedTag = historyItem.reserved ? ' [Reserved]' : ''; + return ( + + + + {indicator}{formatSatoshis(sats)} + + + {' '}{historyItem.description}{reservedTag} + + + {dateStr && {dateStr}} + + ); + } + + // Fallback for other types + return ( + + + {indicator}{historyItem.type}: {historyItem.description} + + {dateStr && {dateStr}} + + ); + }, []); + return ( - + {/* Header */} - + {logoSmall} - Wallet Overview {/* Main content */} - + {/* Left column: Balance */} Balance - + Total Balance: {balance ? ( <> @@ -205,37 +325,25 @@ export function WalletStateScreen(): React.ReactElement { {/* Right column: Actions menu */} Actions - - ( - - {isSelected ? '▸ ' : ' '} - - )} - itemComponent={({ isSelected, label }) => ( - - {label} - - )} - /> - + @@ -243,95 +351,30 @@ export function WalletStateScreen(): React.ReactElement { {/* Wallet History */} Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''} - - {isLoading ? ( + {isLoading ? ( + Loading... - ) : history.length === 0 ? ( - No history found - ) : ( - // Show a scrolling window of items - (() => { - const maxVisible = 10; - const halfWindow = Math.floor(maxVisible / 2); - let startIndex = Math.max(0, selectedHistoryIndex - halfWindow); - const endIndex = Math.min(history.length, startIndex + maxVisible); - // Adjust start if we're near the end - if (endIndex - startIndex < maxVisible) { - startIndex = Math.max(0, endIndex - maxVisible); - } - const visibleItems = history.slice(startIndex, endIndex); - - return visibleItems.map((item, idx) => { - const actualIndex = startIndex + idx; - const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history'; - const indicator = isSelected ? '▸ ' : ' '; - const dateStr = item.timestamp - ? new Date(item.timestamp).toLocaleDateString() - : ''; - - // Format the history item based on type - if (item.type === 'invitation_created') { - return ( - - - {indicator}[Invitation] {item.description} - - {dateStr && {dateStr}} - - ); - } else if (item.type === 'utxo_reserved') { - const sats = item.valueSatoshis ?? 0n; - return ( - - - - {indicator}[Reserved] {formatSatoshis(sats)} - - {item.description} - - {dateStr && {dateStr}} - - ); - } else if (item.type === 'utxo_received') { - const sats = item.valueSatoshis ?? 0n; - const reservedTag = item.reserved ? ' [Reserved]' : ''; - return ( - - - - {indicator}{formatSatoshis(sats)} - - - {' '}{item.description}{reservedTag} - - - {dateStr && {dateStr}} - - ); - } - - // Fallback for other types - return ( - - - {indicator}{item.type}: {item.description} - - {dateStr && {dateStr}} - - ); - }); - })() - )} - + + ) : ( + + )} diff --git a/src/utils/history-utils.ts b/src/utils/history-utils.ts new file mode 100644 index 0000000..4a3d9cd --- /dev/null +++ b/src/utils/history-utils.ts @@ -0,0 +1,259 @@ +/** + * History utility functions. + * + * Pure functions for parsing and formatting wallet history data. + * These functions have no React dependencies and can be used + * in both TUI and CLI contexts. + */ + +import type { HistoryItem, HistoryItemType } from '../services/history.js'; + +/** + * Color names for history item types. + * These are semantic color names that can be mapped to actual colors + * by the consuming application (TUI or CLI). + */ +export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text'; + +/** + * Formatted history list item data. + */ +export interface FormattedHistoryItem { + /** The display label for the history item */ + label: string; + /** Optional secondary description */ + description?: string; + /** The formatted date string */ + dateStr?: string; + /** The semantic color name for this item type */ + color: HistoryColorName; + /** The history item type */ + type: HistoryItemType; + /** Whether the item data is valid */ + isValid: boolean; +} + +/** + * Get the semantic color name for a history item type. + * + * @param type - The history item type + * @param isSelected - Whether the item is currently selected + * @returns A semantic color name + */ +export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName { + if (isSelected) return 'info'; // Use focus color when selected + + switch (type) { + case 'invitation_created': + return 'text'; + case 'utxo_reserved': + return 'warning'; + case 'utxo_received': + return 'success'; + default: + return 'text'; + } +} + +/** + * Format a satoshi value for display. + * + * @param satoshis - The value in satoshis + * @returns Formatted string with BCH amount + */ +export function formatSatoshisValue(satoshis: bigint | number): string { + const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis); + const bch = Number(value) / 100_000_000; + return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`; +} + +/** + * Format a timestamp for display. + * + * @param timestamp - Unix timestamp in milliseconds + * @returns Formatted date string or undefined + */ +export function formatHistoryDate(timestamp?: number): string | undefined { + if (!timestamp) return undefined; + return new Date(timestamp).toLocaleDateString(); +} + +/** + * Format a history item for display in a list. + * + * @param item - The history item to format + * @param isSelected - Whether the item is currently selected + * @returns Formatted item data for display + */ +export function formatHistoryListItem( + item: HistoryItem | null | undefined, + isSelected: boolean = false +): FormattedHistoryItem { + if (!item) { + return { + label: '', + description: undefined, + dateStr: undefined, + color: 'muted', + type: 'utxo_received', + isValid: false, + }; + } + + const dateStr = formatHistoryDate(item.timestamp); + const color = getHistoryItemColorName(item.type, isSelected); + + switch (item.type) { + case 'invitation_created': + return { + label: `[Invitation] ${item.description}`, + description: undefined, + dateStr, + color, + type: item.type, + isValid: true, + }; + + case 'utxo_reserved': { + const satsStr = item.valueSatoshis !== undefined + ? formatSatoshisValue(item.valueSatoshis) + : 'Unknown amount'; + return { + label: `[Reserved] ${satsStr}`, + description: item.description, + dateStr, + color, + type: item.type, + isValid: true, + }; + } + + case 'utxo_received': { + const satsStr = item.valueSatoshis !== undefined + ? formatSatoshisValue(item.valueSatoshis) + : 'Unknown amount'; + const reservedTag = item.reserved ? ' [Reserved]' : ''; + return { + label: satsStr, + description: `${item.description}${reservedTag}`, + dateStr, + color, + type: item.type, + isValid: true, + }; + } + + default: + return { + label: `${item.type}: ${item.description}`, + description: undefined, + dateStr, + color: 'text', + type: item.type, + isValid: true, + }; + } +} + +/** + * Get a type label for display. + * + * @param type - The history item type + * @returns Human-readable type label + */ +export function getHistoryTypeLabel(type: HistoryItemType): string { + switch (type) { + case 'invitation_created': + return 'Invitation'; + case 'utxo_reserved': + return 'Reserved'; + case 'utxo_received': + return 'Received'; + default: + return type; + } +} + +/** + * Calculate scrolling window indices for a list. + * + * @param selectedIndex - Currently selected index + * @param totalItems - Total number of items + * @param maxVisible - Maximum visible items + * @returns Start and end indices for the visible window + */ +export function calculateScrollWindow( + selectedIndex: number, + totalItems: number, + maxVisible: number +): { startIndex: number; endIndex: number } { + const halfWindow = Math.floor(maxVisible / 2); + let startIndex = Math.max(0, selectedIndex - halfWindow); + const endIndex = Math.min(totalItems, startIndex + maxVisible); + + // Adjust start if we're near the end + if (endIndex - startIndex < maxVisible) { + startIndex = Math.max(0, endIndex - maxVisible); + } + + return { startIndex, endIndex }; +} + +/** + * Check if a history item is a UTXO-related event. + * + * @param item - The history item to check + * @returns True if the item is UTXO-related + */ +export function isUtxoEvent(item: HistoryItem): boolean { + return item.type === 'utxo_received' || item.type === 'utxo_reserved'; +} + +/** + * Filter history items by type. + * + * @param items - Array of history items + * @param types - Types to include + * @returns Filtered array + */ +export function filterHistoryByType( + items: HistoryItem[], + types: HistoryItemType[] +): HistoryItem[] { + return items.filter(item => types.includes(item.type)); +} + +/** + * Get summary statistics for history items. + * + * @param items - Array of history items + * @returns Summary statistics + */ +export function getHistorySummary(items: HistoryItem[]): { + totalReceived: bigint; + totalReserved: bigint; + invitationCount: number; + utxoCount: number; +} { + let totalReceived = 0n; + let totalReserved = 0n; + let invitationCount = 0; + let utxoCount = 0; + + for (const item of items) { + switch (item.type) { + case 'invitation_created': + invitationCount++; + break; + case 'utxo_reserved': + totalReserved += item.valueSatoshis ?? 0n; + break; + case 'utxo_received': + totalReceived += item.valueSatoshis ?? 0n; + utxoCount++; + break; + } + } + + return { totalReceived, totalReserved, invitationCount, utxoCount }; +} diff --git a/src/utils/invitation-utils.ts b/src/utils/invitation-utils.ts new file mode 100644 index 0000000..8929bab --- /dev/null +++ b/src/utils/invitation-utils.ts @@ -0,0 +1,264 @@ +/** + * Invitation utility functions. + * + * Pure functions for parsing and formatting invitation data. + * These functions have no React dependencies and can be used + * in both TUI and CLI contexts. + */ + +import type { Invitation } from '../services/invitation.js'; +import type { XOTemplate } from '@xo-cash/types'; + +/** + * Color names for invitation states. + * These are semantic color names that can be mapped to actual colors + * by the consuming application (TUI or CLI). + */ +export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted'; + +/** + * Input data extracted from invitation commits. + */ +export interface InvitationInput { + inputIdentifier?: string; + roleIdentifier?: string; + entityIdentifier: string; +} + +/** + * Output data extracted from invitation commits. + */ +export interface InvitationOutput { + outputIdentifier?: string; + roleIdentifier?: string; + valueSatoshis?: bigint; + entityIdentifier: string; +} + +/** + * Variable data extracted from invitation commits. + */ +export interface InvitationVariable { + variableIdentifier: string; + value: unknown; + roleIdentifier?: string; + entityIdentifier: string; +} + +/** + * Formatted invitation list item data. + */ +export interface FormattedInvitationItem { + /** The display label for the invitation */ + label: string; + /** The current status of the invitation */ + status: string; + /** The semantic color name for the status */ + statusColor: StateColorName; + /** Whether the invitation data is valid */ + isValid: boolean; +} + +/** + * Get the current state/status of an invitation. + * + * @param invitation - The invitation to get state for + * @returns The status string + */ +export function getInvitationState(invitation: Invitation): string { + return invitation.status; +} + +/** + * Get the semantic color name for an invitation state. + * + * @param state - The invitation state string + * @returns A semantic color name + */ +export function getStateColorName(state: string): StateColorName { + switch (state) { + case 'created': + case 'published': + return 'info'; + case 'pending': + return 'warning'; + case 'ready': + case 'signed': + case 'broadcast': + case 'completed': + return 'success'; + case 'expired': + case 'error': + return 'error'; + default: + return 'muted'; + } +} + +/** + * Extract all inputs from invitation commits. + * + * @param invitation - The invitation to extract inputs from + * @returns Array of input data + */ +export function getInvitationInputs(invitation: Invitation): InvitationInput[] { + const inputs: InvitationInput[] = []; + for (const commit of invitation.data.commits || []) { + for (const input of commit.data?.inputs || []) { + inputs.push({ + inputIdentifier: input.inputIdentifier, + roleIdentifier: input.roleIdentifier, + entityIdentifier: commit.entityIdentifier, + }); + } + } + return inputs; +} + +/** + * Extract all outputs from invitation commits. + * + * @param invitation - The invitation to extract outputs from + * @returns Array of output data + */ +export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] { + const outputs: InvitationOutput[] = []; + for (const commit of invitation.data.commits || []) { + for (const output of commit.data?.outputs || []) { + outputs.push({ + outputIdentifier: output.outputIdentifier, + roleIdentifier: output.roleIdentifier, + valueSatoshis: output.valueSatoshis, + entityIdentifier: commit.entityIdentifier, + }); + } + } + return outputs; +} + +/** + * Extract all variables from invitation commits. + * + * @param invitation - The invitation to extract variables from + * @returns Array of variable data + */ +export function getInvitationVariables(invitation: Invitation): InvitationVariable[] { + const variables: InvitationVariable[] = []; + for (const commit of invitation.data.commits || []) { + for (const variable of commit.data?.variables || []) { + variables.push({ + variableIdentifier: variable.variableIdentifier, + value: variable.value, + roleIdentifier: variable.roleIdentifier, + entityIdentifier: commit.entityIdentifier, + }); + } + } + return variables; +} + +/** + * Get the user's role from commits (the role they have accepted). + * + * @param invitation - The invitation to check + * @param userEntityId - The user's entity identifier + * @returns The role identifier if found, null otherwise + */ +export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null { + if (!userEntityId) return null; + + for (const commit of invitation.data.commits || []) { + if (commit.entityIdentifier === userEntityId) { + // Check inputs for role + for (const input of commit.data?.inputs || []) { + if (input.roleIdentifier) return input.roleIdentifier; + } + // Check outputs for role + for (const output of commit.data?.outputs || []) { + if (output.roleIdentifier) return output.roleIdentifier; + } + // Check variables for role + for (const variable of commit.data?.variables || []) { + if (variable.roleIdentifier) return variable.roleIdentifier; + } + } + } + return null; +} + +/** + * Format an invitation for display in a list. + * + * @param invitation - The invitation to format + * @param template - Optional template for additional info (name) + * @returns Formatted item data for display + */ +export function formatInvitationListItem( + invitation: Invitation, + template?: XOTemplate | null +): FormattedInvitationItem { + // Validate that we have the minimum required data + const invitationId = invitation?.data?.invitationIdentifier; + const actionId = invitation?.data?.actionIdentifier; + + if (!invitationId || !actionId) { + return { + label: '', + status: 'error', + statusColor: 'error', + isValid: false, + }; + } + + const state = getInvitationState(invitation); + const templateName = template?.name ?? 'Unknown'; + const shortId = formatInvitationId(invitationId, 8); + + return { + label: `[${state}] ${templateName}-${actionId} (${shortId})`, + status: state, + statusColor: getStateColorName(state), + isValid: true, + }; +} + +/** + * Format an invitation ID for display (truncated). + * + * @param id - The full invitation ID + * @param maxLength - Maximum length for display + * @returns Truncated ID string + */ +export function formatInvitationId(id: string, maxLength: number = 16): string { + if (id.length <= maxLength) return id; + const half = Math.floor((maxLength - 3) / 2); + return `${id.slice(0, half)}...${id.slice(-half)}`; +} + +/** + * Get all unique entity identifiers from an invitation's commits. + * + * @param invitation - The invitation to check + * @returns Array of unique entity identifiers + */ +export function getInvitationParticipants(invitation: Invitation): string[] { + const participants = new Set(); + for (const commit of invitation.data.commits || []) { + if (commit.entityIdentifier) { + participants.add(commit.entityIdentifier); + } + } + return Array.from(participants); +} + +/** + * Check if a user is a participant in an invitation. + * + * @param invitation - The invitation to check + * @param userEntityId - The user's entity identifier + * @returns True if the user has made at least one commit + */ +export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean { + if (!userEntityId) return false; + return getInvitationParticipants(invitation).includes(userEntityId); +} diff --git a/src/utils/template-utils.ts b/src/utils/template-utils.ts new file mode 100644 index 0000000..a10fdad --- /dev/null +++ b/src/utils/template-utils.ts @@ -0,0 +1,246 @@ +/** + * Template utility functions. + * + * Pure functions for parsing and formatting template data. + * These functions have no React dependencies and can be used + * in both TUI and CLI contexts. + */ + +import type { XOTemplate, XOTemplateAction } from '@xo-cash/types'; + +/** + * Formatted template list item data. + */ +export interface FormattedTemplateItem { + /** The display label for the template */ + label: string; + /** The template description */ + description?: string; + /** Whether the template data is valid */ + isValid: boolean; +} + +/** + * Formatted action list item data. + */ +export interface FormattedActionItem { + /** The display label for the action */ + label: string; + /** The action description */ + description?: string; + /** Number of roles that can start this action */ + roleCount: number; + /** Whether the action data is valid */ + isValid: boolean; +} + +/** + * A unique starting action (deduplicated by action identifier). + * Multiple roles that can start the same action are counted + * but not shown as separate entries. + */ +export interface UniqueStartingAction { + actionIdentifier: string; + name: string; + description?: string; + roleCount: number; +} + +/** + * Role information from a template. + */ +export interface TemplateRole { + roleId: string; + name: string; + description?: string; +} + +/** + * Format a template for display in a list. + * + * @param template - The template to format + * @param index - Optional index for numbered display + * @returns Formatted item data for display + */ +export function formatTemplateListItem( + template: XOTemplate | null | undefined, + index?: number +): FormattedTemplateItem { + if (!template) { + return { + label: '', + description: undefined, + isValid: false, + }; + } + + const name = template.name || 'Unnamed Template'; + const prefix = index !== undefined ? `${index + 1}. ` : ''; + + return { + label: `${prefix}${name}`, + description: template.description, + isValid: true, + }; +} + +/** + * Format an action for display in a list. + * + * @param actionId - The action identifier + * @param action - The action definition from the template + * @param roleCount - Number of roles that can start this action + * @param index - Optional index for numbered display + * @returns Formatted item data for display + */ +export function formatActionListItem( + actionId: string, + action: XOTemplateAction | null | undefined, + roleCount: number = 1, + index?: number +): FormattedActionItem { + if (!actionId) { + return { + label: '', + description: undefined, + roleCount: 0, + isValid: false, + }; + } + + const name = action?.name || actionId; + const prefix = index !== undefined ? `${index + 1}. ` : ''; + const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : ''; + + return { + label: `${prefix}${name}${roleSuffix}`, + description: action?.description, + roleCount, + isValid: true, + }; +} + +/** + * Deduplicate starting actions from a template. + * Multiple roles that can start the same action are counted + * but returned as a single entry. + * + * @param template - The template to process + * @param startingActions - Array of { action, role } pairs + * @returns Array of unique starting actions with role counts + */ +export function deduplicateStartingActions( + template: XOTemplate, + startingActions: Array<{ action: string; role: string }> +): UniqueStartingAction[] { + const actionMap = new Map(); + + for (const sa of startingActions) { + if (actionMap.has(sa.action)) { + actionMap.get(sa.action)!.roleCount++; + } else { + const actionDef = template.actions?.[sa.action]; + actionMap.set(sa.action, { + actionIdentifier: sa.action, + name: actionDef?.name || sa.action, + description: actionDef?.description, + roleCount: 1, + }); + } + } + + return Array.from(actionMap.values()); +} + +/** + * Get all roles from a template. + * + * @param template - The template to process + * @returns Array of role information + */ +export function getTemplateRoles(template: XOTemplate): TemplateRole[] { + if (!template.roles) return []; + + return Object.entries(template.roles).map(([roleId, role]) => { + // Handle case where role might be a string instead of object + const roleObj = typeof role === 'object' ? role : null; + return { + roleId, + name: roleObj?.name || roleId, + description: roleObj?.description, + }; + }); +} + +/** + * Get roles that can start a specific action. + * + * @param template - The template to check + * @param actionIdentifier - The action to check + * @returns Array of role information for roles that can start this action + */ +export function getRolesForAction( + template: XOTemplate, + actionIdentifier: string +): TemplateRole[] { + const startEntries = (template.start ?? []) + .filter((s) => s.action === actionIdentifier); + + return startEntries.map((entry) => { + const roleDef = template.roles?.[entry.role]; + const roleObj = typeof roleDef === 'object' ? roleDef : null; + return { + roleId: entry.role, + name: roleObj?.name || entry.role, + description: roleObj?.description, + }; + }); +} + +/** + * Get template name safely. + * + * @param template - The template + * @returns The template name or a default + */ +export function getTemplateName(template: XOTemplate | null | undefined): string { + return template?.name || 'Unknown Template'; +} + +/** + * Get template description safely. + * + * @param template - The template + * @returns The template description or undefined + */ +export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined { + return template?.description; +} + +/** + * Get action name safely. + * + * @param template - The template containing the action + * @param actionId - The action identifier + * @returns The action name or the action ID as fallback + */ +export function getActionName( + template: XOTemplate | null | undefined, + actionId: string +): string { + return template?.actions?.[actionId]?.name || actionId; +} + +/** + * Get action description safely. + * + * @param template - The template containing the action + * @param actionId - The action identifier + * @returns The action description or undefined + */ +export function getActionDescription( + template: XOTemplate | null | undefined, + actionId: string +): string | undefined { + return template?.actions?.[actionId]?.description; +}