Compare commits
4 Commits
df57f1b9ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 66e9918e04 | |||
| 38a0ac436b | |||
| 2c547f766a | |||
| ef169e76db |
@@ -118,16 +118,10 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
const invitationsDb = this.storage.child('invitations');
|
const invitationsDb = this.storage.child('invitations');
|
||||||
|
|
||||||
// Load invitations from storage
|
// Load invitations from storage
|
||||||
console.time('loadInvitations');
|
|
||||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
||||||
console.timeEnd('loadInvitations');
|
|
||||||
|
|
||||||
console.time('createInvitations');
|
|
||||||
|
|
||||||
await Promise.all(invitations.map(async ({ key }) => {
|
await Promise.all(invitations.map(async ({ key }) => {
|
||||||
await this.createInvitation(key);
|
await this.createInvitation(key);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.timeEnd('createInvitations');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type { AcceptInvitationParameters, 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 { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
@@ -31,12 +32,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Try to get the invitation from the storage
|
// Try to get the invitation from the storage
|
||||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||||
if (invitationFromStorage) {
|
if (invitationFromStorage) {
|
||||||
|
console.log(`Invitation found in storage: ${invitation}`);
|
||||||
return this.create(invitationFromStorage, dependencies);
|
return this.create(invitationFromStorage, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get the invitation from the sync server
|
// Try to get the invitation from the sync server
|
||||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||||
if (invitationFromSyncServer) {
|
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||||
|
console.log(`Invitation found in sync server: ${invitation}`);
|
||||||
return this.create(invitationFromSyncServer, dependencies);
|
return this.create(invitationFromSyncServer, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +89,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
*/
|
*/
|
||||||
private storage: Storage;
|
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.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
@@ -108,28 +126,28 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
console.time(`connectAndGetInvitation-${this.data.invitationIdentifier}`);
|
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
this.syncServer.connect(),
|
this.syncServer.connect(),
|
||||||
this.syncServer.getInvitation(this.data.invitationIdentifier),
|
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
|
// 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;
|
const sseCommits = this.data.commits;
|
||||||
|
|
||||||
console.time(`mergeCommits-${this.data.invitationIdentifier}`);
|
|
||||||
// Merge the commits
|
// Merge the commits
|
||||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.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
|
// Set the invitation data with the combined commits
|
||||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||||
|
|
||||||
// Store the invitation in the storage
|
// Store the invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
console.timeEnd(`setInvitationData-${this.data.invitationIdentifier}`);
|
|
||||||
|
// Publish the invitation to the sync server
|
||||||
|
this.syncServer.publishInvitation(this.data);
|
||||||
|
|
||||||
|
// Compute and emit initial status
|
||||||
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,8 +173,8 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Set the new commits
|
// Set the new commits
|
||||||
this.data = { ...this.data, commits: newCommits };
|
this.data = { ...this.data, commits: newCommits };
|
||||||
|
|
||||||
// Calculate the new status of the invitation
|
// Calculate the new status of the invitation (fire-and-forget; handler is sync)
|
||||||
this.updateStatus();
|
this.updateStatus().catch(() => {});
|
||||||
|
|
||||||
// Emit the updated event
|
// Emit the updated event
|
||||||
this.emit('invitation-updated', this.data);
|
this.emit('invitation-updated', this.data);
|
||||||
@@ -185,26 +203,79 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Return the merged commits
|
// Return the merged commits
|
||||||
return Array.from(initialMap.values());
|
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 {
|
private async computeStatus(): Promise<string> {
|
||||||
// Calculate the status of the invitation
|
try {
|
||||||
this.emit('invitation-status-changed', 'pending - this is temporary');
|
return await this.computeStatusInternal();
|
||||||
|
} catch (err) {
|
||||||
|
return `error (${err instanceof Error ? err.message : String(err)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal status computation: returns a single word.
|
||||||
|
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||||
|
* - 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<string> {
|
||||||
|
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<void> {
|
||||||
|
const status = await this.computeStatus();
|
||||||
|
this.status = status;
|
||||||
|
this.emit('invitation-status-changed', status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the invitation
|
* Accept the invitation
|
||||||
*/
|
*/
|
||||||
async accept(): Promise<void> {
|
async accept(acceptParams?: AcceptInvitationParameters): Promise<void> {
|
||||||
// Accept the invitation
|
// Accept the invitation
|
||||||
this.data = await this.engine.acceptInvitation(this.data);
|
this.data = await this.engine.acceptInvitation(this.data, acceptParams);
|
||||||
|
|
||||||
// Sync the invitation to the sync server
|
// Sync the invitation to the sync server
|
||||||
this.syncServer.publishInvitation(this.data);
|
this.syncServer.publishInvitation(this.data);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,26 +291,26 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Store the signed invitation in the storage
|
// Store the signed invitation in the storage
|
||||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
|
this.data = signedInvitation;
|
||||||
|
this._weHaveSigned = true;
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcast the invitation
|
* Broadcast the invitation
|
||||||
*/
|
*/
|
||||||
async broadcast(): Promise<void> {
|
async broadcast(): Promise<void> {
|
||||||
// Broadcast the invitation
|
// Broadcast the transaction (executeAction returns transaction hash when broadcastTransaction: true)
|
||||||
const broadcastedInvitation = await this.engine.executeAction(this.data, {
|
await this.engine.executeAction(this.data, {
|
||||||
broadcastTransaction: true,
|
broadcastTransaction: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store the broadcasted invitation in the storage
|
this._broadcasted = true;
|
||||||
await this.storage.set(this.data.invitationIdentifier, broadcastedInvitation);
|
|
||||||
|
|
||||||
// TODO: Am I supposed to do something here?
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -260,7 +331,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.storage.set(this.data.invitationIdentifier, this.data);
|
await this.storage.set(this.data.invitationIdentifier, this.data);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,7 +366,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
this.updateStatus();
|
await this.updateStatus();
|
||||||
|
|
||||||
// Return the suitable resources
|
// Return the suitable resources
|
||||||
return unspentOutputs;
|
return unspentOutputs;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.j
|
|||||||
import { InvitationScreen } from './screens/Invitation.js';
|
import { InvitationScreen } from './screens/Invitation.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the App component.
|
* Props for the App component.
|
||||||
*/
|
*/
|
||||||
@@ -107,28 +109,14 @@ function DialogOverlay(): React.ReactElement | null {
|
|||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
<Box
|
<MessageDialog
|
||||||
flexDirection="column"
|
title={dialog.type === 'error' ? '✗ Error' :
|
||||||
borderStyle="double"
|
|
||||||
borderColor={borderColor}
|
|
||||||
paddingX={2}
|
|
||||||
paddingY={1}
|
|
||||||
width={60}
|
|
||||||
>
|
|
||||||
<Text color={borderColor} bold>
|
|
||||||
{dialog.type === 'error' ? '✗ Error' :
|
|
||||||
dialog.type === 'confirm' ? '? Confirm' :
|
dialog.type === 'confirm' ? '? Confirm' :
|
||||||
'ℹ Info'}
|
'ℹ Info'}
|
||||||
</Text>
|
message={dialog.message}
|
||||||
<Box marginY={1}>
|
onClose={dialog.onCancel ?? (() => {})}
|
||||||
<Text wrap="wrap">{dialog.message}</Text>
|
type={dialog.type as 'error' | 'info' | 'success'}
|
||||||
</Box>
|
/>
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{dialog.type === 'confirm'
|
|
||||||
? 'Press Y to confirm, N or ESC to cancel'
|
|
||||||
: 'Press Enter or ESC to close'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Dialog components for modals, confirmations, and input dialogs.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput, measureElement } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from 'ink-text-input';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
@@ -23,32 +23,61 @@ interface DialogWrapperProps {
|
|||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Base dialog wrapper component.
|
|
||||||
*/
|
|
||||||
function DialogWrapper({
|
function DialogWrapper({
|
||||||
title,
|
title,
|
||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
width = 60,
|
width = 60,
|
||||||
backgroundColor = colors.bg,
|
|
||||||
}: DialogWrapperProps): React.ReactElement {
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
|
const ref = useRef<any>(null);
|
||||||
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// measure after render
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
const { height } = measureElement(ref.current);
|
||||||
|
setHeight(height);
|
||||||
|
}
|
||||||
|
}, [children, title, width]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
|
||||||
|
{/* Opaque backing layer */}
|
||||||
|
{height !== null && (
|
||||||
<Box
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
>
|
||||||
|
{Array.from({ length: height }).map((_, i) => (
|
||||||
|
<Text key={i}>{' '.repeat(width)}</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actual dialog */}
|
||||||
|
<Box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
borderStyle="double"
|
borderStyle="double"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
// backgroundColor={backgroundColor || 'white'}
|
|
||||||
backgroundColor="white"
|
|
||||||
paddingX={2}
|
paddingX={2}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width={width}
|
width={width}
|
||||||
>
|
>
|
||||||
<Text color={borderColor} bold>{title}</Text>
|
<Text color={borderColor} bold>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Box marginY={1} flexDirection="column">
|
<Box marginY={1} flexDirection="column">
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List item type.
|
* Base list item data interface.
|
||||||
|
* Used by ScrollableList for item data.
|
||||||
|
*/
|
||||||
|
export interface ListItemData<T = unknown> {
|
||||||
|
/** 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<T> {
|
||||||
|
/** Array of list items */
|
||||||
|
items: ListItemData<T>[];
|
||||||
|
/** 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<T>, 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<T>, 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<T>(
|
||||||
|
items: ListItemData<T>[],
|
||||||
|
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] ?? 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] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<T>({
|
||||||
|
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<T>): 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<T>, index: number) => {
|
||||||
|
if (item.hidden) return null;
|
||||||
|
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const isFocused = focus && isSelected;
|
||||||
|
|
||||||
|
// Use custom render if provided
|
||||||
|
if (renderItem) {
|
||||||
|
return (
|
||||||
|
<Box key={item.key}>
|
||||||
|
{renderItem(item, isSelected, isFocused)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default rendering
|
||||||
|
const itemColor = isFocused
|
||||||
|
? colors.focus
|
||||||
|
: item.disabled
|
||||||
|
? colors.textMuted
|
||||||
|
: getColorFromName(item.color);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={item.key}>
|
||||||
|
<Text
|
||||||
|
color={itemColor as string}
|
||||||
|
bold={isSelected}
|
||||||
|
dimColor={item.disabled}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
{item.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor> {item.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 <Text color={colors.textMuted} dimColor>{emptyMessage}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{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 (
|
||||||
|
<Box key={group.id} flexDirection="column">
|
||||||
|
{/* Group label */}
|
||||||
|
{group.label && (
|
||||||
|
<Text color={colors.textMuted} bold>{group.label}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 && (
|
||||||
|
<Box marginY={1}>
|
||||||
|
<Text color={colors.textMuted}>────────────────────────</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No grouping - render with scroll window
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{displayItems.map(({ item, idx }) => renderListItem(item, idx))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const borderColor = focus ? colors.focus : colors.border;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Filter input */}
|
||||||
|
{filterable && isFiltering && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color={colors.info}>Filter: </Text>
|
||||||
|
<TextInput
|
||||||
|
value={filterText}
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
onSubmit={handleFilterSubmit}
|
||||||
|
placeholder={filterPlaceholder}
|
||||||
|
focus={isFiltering}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List content */}
|
||||||
|
{renderContent()}
|
||||||
|
|
||||||
|
{/* Scroll indicator */}
|
||||||
|
{showScrollIndicator && visibleCount > maxVisible && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{scrollWindow.start + 1}-{scrollWindow.end} of {visibleCount}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter hint */}
|
||||||
|
{filterable && !isFiltering && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Press '/' to filter
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{label && <Text color={colors.primary} bold>{label}</Text>}
|
||||||
|
{border ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={borderColor}
|
||||||
|
paddingX={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
) : content}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Legacy List Component (kept for backward compatibility)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy list item type.
|
||||||
|
* @deprecated Use ListItemData instead
|
||||||
*/
|
*/
|
||||||
export interface ListItem<T = unknown> {
|
export interface ListItem<T = unknown> {
|
||||||
/** Unique key for the item */
|
/** Unique key for the item */
|
||||||
@@ -23,7 +472,8 @@ export interface ListItem<T = unknown> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the List component.
|
* Props for the legacy List component.
|
||||||
|
* @deprecated Use ScrollableListProps instead
|
||||||
*/
|
*/
|
||||||
interface ListProps<T> {
|
interface ListProps<T> {
|
||||||
/** List items */
|
/** List items */
|
||||||
@@ -46,6 +496,7 @@ interface ListProps<T> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Selectable list with keyboard navigation.
|
* Selectable list with keyboard navigation.
|
||||||
|
* @deprecated Use ScrollableList instead
|
||||||
*/
|
*/
|
||||||
export function List<T>({
|
export function List<T>({
|
||||||
items,
|
items,
|
||||||
@@ -132,8 +583,12 @@ export function List<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// SimpleList Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple inline list for displaying items without selection.
|
* Props for SimpleList component.
|
||||||
*/
|
*/
|
||||||
interface SimpleListProps {
|
interface SimpleListProps {
|
||||||
items: string[];
|
items: string[];
|
||||||
@@ -141,6 +596,9 @@ interface SimpleListProps {
|
|||||||
bullet?: string;
|
bullet?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple inline list for displaying items without selection.
|
||||||
|
*/
|
||||||
export function SimpleList({
|
export function SimpleList({
|
||||||
items,
|
items,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
export { Screen } from './Screen.js';
|
export { Screen } from './Screen.js';
|
||||||
export { Input, TextDisplay } from './Input.js';
|
export { Input, TextDisplay } from './Input.js';
|
||||||
export { Button, ButtonRow } from './Button.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 { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
||||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@
|
|||||||
* - Select a template and action to start a new transaction
|
* - 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 { Box, Text, useInput } from 'ink';
|
||||||
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
@@ -16,18 +17,15 @@ import { colors, logoSmall } from '../theme.js';
|
|||||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
/**
|
// Import utility functions
|
||||||
* A unique starting action (deduplicated by action identifier).
|
import {
|
||||||
* Multiple roles that can start the same action are counted
|
formatTemplateListItem,
|
||||||
* but not shown as separate entries — role selection happens
|
formatActionListItem,
|
||||||
* inside the Action Wizard.
|
deduplicateStartingActions,
|
||||||
*/
|
getTemplateRoles,
|
||||||
interface UniqueStartingAction {
|
getRolesForAction,
|
||||||
actionIdentifier: string;
|
type UniqueStartingAction,
|
||||||
name: string;
|
} from '../../utils/template-utils.js';
|
||||||
description?: string;
|
|
||||||
roleCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template item with metadata.
|
* Template item with metadata.
|
||||||
@@ -38,6 +36,16 @@ interface TemplateItem {
|
|||||||
startingActions: UniqueStartingAction[];
|
startingActions: UniqueStartingAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Template list item with TemplateItem value.
|
||||||
|
*/
|
||||||
|
type TemplateListItem = ListItemData<TemplateItem>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Action list item with UniqueStartingAction value.
|
||||||
|
*/
|
||||||
|
type ActionListItem = ListItemData<UniqueStartingAction>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
* Displays templates and their starting actions.
|
* Displays templates and their starting actions.
|
||||||
@@ -74,27 +82,13 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
|
|
||||||
// Deduplicate by action identifier — role selection
|
// Use utility function to deduplicate actions
|
||||||
// is handled inside the Action Wizard, not here.
|
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
||||||
const actionMap = new Map<string, UniqueStartingAction>();
|
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
startingActions: Array.from(actionMap.values()),
|
startingActions,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -119,15 +113,59 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const currentTemplate = templates[selectedTemplateIndex];
|
const currentTemplate = templates[selectedTemplateIndex];
|
||||||
const currentActions = currentTemplate?.startingActions ?? [];
|
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.
|
* Handles action selection.
|
||||||
* Navigates to the Action Wizard where the user will choose their role.
|
* Navigates to the Action Wizard where the user will choose their role.
|
||||||
*/
|
*/
|
||||||
const handleActionSelect = useCallback(() => {
|
const handleActionActivate = useCallback((item: ActionListItem, index: number) => {
|
||||||
if (!currentTemplate || currentActions.length === 0) return;
|
if (!currentTemplate || !item.value) return;
|
||||||
|
|
||||||
const action = currentActions[selectedActionIndex];
|
const action = item.value;
|
||||||
if (!action) return;
|
|
||||||
|
|
||||||
// Navigate to the Action Wizard — role selection happens there
|
// Navigate to the Action Wizard — role selection happens there
|
||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
@@ -135,7 +173,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
actionIdentifier: action.actionIdentifier,
|
actionIdentifier: action.actionIdentifier,
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, currentActions, selectedActionIndex, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
@@ -144,124 +182,111 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
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 (
|
return (
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render custom action list item.
|
||||||
|
*/
|
||||||
|
const renderActionItem = useCallback((
|
||||||
|
item: ActionListItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Select Template & Action</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content - two columns */}
|
{/* Main content - two columns */}
|
||||||
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||||
{/* Left column: Template list */}
|
{/* Left column: Template list */}
|
||||||
<Box flexDirection='column' width='40%' paddingRight={1}>
|
<Box flexDirection="column" width="40%" paddingRight={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
borderColor={focusedPanel === 'templates' ? colors.focus : colors.primary}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Templates </Text>
|
<Text color={colors.primary} bold> Templates </Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
{isLoading ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
{(() => {
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
// Loading State
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No templates state
|
|
||||||
if (templates.length === 0) {
|
|
||||||
return <Text color={colors.textMuted}>No templates imported</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Templates state
|
|
||||||
return templates.map((item, index) => (
|
|
||||||
<Text
|
|
||||||
key={item.templateIdentifier}
|
|
||||||
color={index === selectedTemplateIndex ? colors.focus : colors.text}
|
|
||||||
bold={index === selectedTemplateIndex}
|
|
||||||
>
|
|
||||||
{index === selectedTemplateIndex && focusedPanel === 'templates' ? '▸ ' : ' '}
|
|
||||||
{index + 1}. {item.template.name || 'Unnamed Template'}
|
|
||||||
</Text>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
items={templateListItems}
|
||||||
|
selectedIndex={selectedTemplateIndex}
|
||||||
|
onSelect={handleTemplateSelect}
|
||||||
|
focus={focusedPanel === 'templates'}
|
||||||
|
emptyMessage="No templates imported"
|
||||||
|
renderItem={renderTemplateItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right column: Actions list */}
|
{/* Right column: Actions list */}
|
||||||
<Box flexDirection='column' width='60%' paddingLeft={1}>
|
<Box flexDirection="column" width="60%" paddingLeft={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Starting Actions </Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
{isLoading ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
{(() => {
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
// Loading state
|
|
||||||
if (isLoading) {
|
|
||||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No template selected state
|
|
||||||
if (!currentTemplate) {
|
|
||||||
return <Text color={colors.textMuted}>Select a template...</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No starting actions state
|
|
||||||
if (currentActions.length === 0) {
|
|
||||||
return <Text color={colors.textMuted}>No starting actions available</Text>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting actions state
|
|
||||||
return currentActions.map((action, index) => (
|
|
||||||
<Text
|
|
||||||
key={action.actionIdentifier}
|
|
||||||
color={index === selectedActionIndex ? colors.focus : colors.text}
|
|
||||||
bold={index === selectedActionIndex}
|
|
||||||
>
|
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
|
||||||
{index + 1}. {action.name}
|
|
||||||
{action.roleCount > 1
|
|
||||||
? ` (${action.roleCount} roles)`
|
|
||||||
: ''}
|
|
||||||
</Text>
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
|
) : !currentTemplate ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ScrollableList
|
||||||
|
items={actionListItems}
|
||||||
|
selectedIndex={selectedActionIndex}
|
||||||
|
onSelect={setSelectedActionIndex}
|
||||||
|
onActivate={handleActionActivate}
|
||||||
|
focus={focusedPanel === 'actions'}
|
||||||
|
emptyMessage="No starting actions available"
|
||||||
|
renderItem={renderActionItem}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -269,18 +294,18 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{/* Description box */}
|
{/* Description box */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={colors.border}
|
borderColor={colors.border}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width='100%'
|
width="100%"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Description </Text>
|
<Text color={colors.primary} bold> Description </Text>
|
||||||
|
|
||||||
{/* Show template description when templates panel is focused */}
|
{/* Show template description when templates panel is focused */}
|
||||||
{focusedPanel === 'templates' && currentTemplate ? (
|
{focusedPanel === 'templates' && currentTemplate ? (
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
{currentTemplate.template.name || 'Unnamed Template'}
|
{currentTemplate.template.name || 'Unnamed Template'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -293,11 +318,11 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{currentTemplate.template.roles && (
|
{currentTemplate.template.roles && (
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Roles:</Text>
|
<Text color={colors.text}>Roles:</Text>
|
||||||
{Object.entries(currentTemplate.template.roles).map(([roleId, role]) => (
|
{getTemplateRoles(currentTemplate.template).map((role) => (
|
||||||
<Text key={roleId} color={colors.textMuted}>
|
<Text key={role.roleId} color={colors.textMuted}>
|
||||||
{' '}- {role.name || roleId}: {role.description || 'No description'}
|
{' '}- {role.name}: {role.description || 'No description'}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -309,14 +334,13 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Show action description when actions panel is focused */}
|
{/* Show action description when actions panel is focused */}
|
||||||
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
{focusedPanel === 'actions' && currentTemplate && currentActions.length > 0 ? (
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection="column">
|
||||||
{(() => {
|
{(() => {
|
||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return null;
|
if (!action) return null;
|
||||||
|
|
||||||
// Collect all roles that can start this action
|
// Get roles that can start this action using utility function
|
||||||
const startEntries = (currentTemplate.template.start ?? [])
|
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
||||||
.filter((s) => s.action === action.actionIdentifier);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -328,18 +352,15 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* List available roles for this action */}
|
{/* List available roles for this action */}
|
||||||
{startEntries.length > 0 && (
|
{availableRoles.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Available Roles:</Text>
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
{startEntries.map((entry) => {
|
{availableRoles.map((role) => (
|
||||||
const roleDef = currentTemplate.template.roles?.[entry.role];
|
<Text key={role.roleId} color={colors.textMuted}>
|
||||||
return (
|
{' '}- {role.name}
|
||||||
<Text key={entry.role} color={colors.textMuted}>
|
{role.description ? `: ${role.description}` : ''}
|
||||||
{' '}- {roleDef?.name || entry.role}
|
|
||||||
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -7,25 +7,59 @@
|
|||||||
* - Navigation to other actions
|
* - 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 { 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 { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
import type { HistoryItem } from '../../services/history.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.
|
* Menu action items.
|
||||||
*/
|
*/
|
||||||
const menuItems = [
|
const menuItems: ListItemData<string>[] = [
|
||||||
{ label: 'New Transaction (from template)', value: 'new-tx' },
|
{ key: 'new-tx', label: 'New Transaction (from template)', value: 'new-tx' },
|
||||||
{ label: 'Import Invitation', value: 'import' },
|
{ key: 'import', label: 'Import Invitation', value: 'import' },
|
||||||
{ label: 'View Invitations', value: 'invitations' },
|
{ key: 'invitations', label: 'View Invitations', value: 'invitations' },
|
||||||
{ label: 'Generate New Address', value: 'new-address' },
|
{ key: 'new-address', label: 'Generate New Address', value: 'new-address' },
|
||||||
{ label: 'Refresh', value: 'refresh' },
|
{ key: 'refresh', label: 'Refresh', value: 'refresh' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History list item with HistoryItem value.
|
||||||
|
*/
|
||||||
|
type HistoryListItem = ListItemData<HistoryItem>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
* Displays wallet balance, history, and action menu.
|
* 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 [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null);
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([]);
|
const [history, setHistory] = useState<HistoryItem[]>([]);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
const [focusedPanel, setFocusedPanel] = useState<'menu' | 'history'>('menu');
|
||||||
|
const [selectedMenuIndex, setSelectedMenuIndex] = useState(0);
|
||||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@@ -124,10 +159,10 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}, [appService, setStatus, showInfo, showError, refresh]);
|
}, [appService, setStatus, showInfo, showError, refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles menu selection.
|
* Handles menu action.
|
||||||
*/
|
*/
|
||||||
const handleMenuSelect = useCallback((item: { value: string }) => {
|
const handleMenuAction = useCallback((action: string) => {
|
||||||
switch (item.value) {
|
switch (action) {
|
||||||
case 'new-tx':
|
case 'new-tx':
|
||||||
navigate('templates');
|
navigate('templates');
|
||||||
break;
|
break;
|
||||||
@@ -146,46 +181,131 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [navigate, generateNewAddress, refresh]);
|
}, [navigate, generateNewAddress, refresh]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle menu item activation.
|
||||||
|
*/
|
||||||
|
const handleMenuItemActivate = useCallback((item: ListItemData<string>, 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
|
// Handle keyboard navigation between panels
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
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 (
|
return (
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}[Invitation] {historyItem.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (historyItem.type === 'utxo_reserved') {
|
||||||
|
const sats = historyItem.valueSatoshis ?? 0n;
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}[Reserved] {formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (historyItem.type === 'utxo_received') {
|
||||||
|
const sats = historyItem.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{formatSatoshis(sats)}
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{historyItem.description}{reservedTag}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for other types
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Text color={itemColor}>
|
||||||
|
{indicator}{historyItem.type}: {historyItem.description}
|
||||||
|
</Text>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1}>
|
||||||
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Wallet Overview</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<Box flexDirection='row' marginTop={1} flexGrow={1}>
|
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
||||||
{/* Left column: Balance */}
|
{/* Left column: Balance */}
|
||||||
<Box
|
<Box
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
width='50%'
|
width="50%"
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={colors.primary}
|
borderColor={colors.primary}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Balance </Text>
|
<Text color={colors.primary} bold> Balance </Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Total Balance:</Text>
|
<Text color={colors.text}>Total Balance:</Text>
|
||||||
{balance ? (
|
{balance ? (
|
||||||
<>
|
<>
|
||||||
@@ -205,135 +325,58 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Right column: Actions menu */}
|
{/* Right column: Actions menu */}
|
||||||
<Box
|
<Box
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
width='50%'
|
width="50%"
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'menu' ? colors.focus : colors.border}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
<Box marginTop={1}>
|
<ScrollableList
|
||||||
<SelectInput
|
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onSelect={handleMenuSelect}
|
selectedIndex={selectedMenuIndex}
|
||||||
isFocused={focusedPanel === 'menu'}
|
onSelect={setSelectedMenuIndex}
|
||||||
indicatorComponent={({ isSelected }) => (
|
onActivate={handleMenuItemActivate}
|
||||||
<Text color={isSelected ? colors.focus : colors.text}>
|
focus={focusedPanel === 'menu'}
|
||||||
{isSelected ? '▸ ' : ' '}
|
emptyMessage="No actions"
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
itemComponent={({ isSelected, label }) => (
|
|
||||||
<Text
|
|
||||||
color={isSelected ? colors.text : colors.textMuted}
|
|
||||||
bold={isSelected}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Wallet History */}
|
{/* Wallet History */}
|
||||||
<Box marginTop={1} flexGrow={1}>
|
<Box marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'history' ? colors.focus : colors.border}
|
||||||
flexDirection='column'
|
flexDirection="column"
|
||||||
paddingX={1}
|
paddingX={1}
|
||||||
width='100%'
|
width="100%"
|
||||||
height={14}
|
height={14}
|
||||||
overflow='hidden'
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
<Text color={colors.primary} bold> Wallet History {history.length > 0 ? `(${selectedHistoryIndex + 1}/${history.length})` : ''}</Text>
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
) : history.length === 0 ? (
|
</Box>
|
||||||
<Text color={colors.textMuted}>No history found</Text>
|
|
||||||
) : (
|
) : (
|
||||||
// Show a scrolling window of items
|
<ScrollableList
|
||||||
(() => {
|
items={historyListItems}
|
||||||
const maxVisible = 10;
|
selectedIndex={selectedHistoryIndex}
|
||||||
const halfWindow = Math.floor(maxVisible / 2);
|
onSelect={setSelectedHistoryIndex}
|
||||||
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
|
focus={focusedPanel === 'history'}
|
||||||
const endIndex = Math.min(history.length, startIndex + maxVisible);
|
maxVisible={10}
|
||||||
// Adjust start if we're near the end
|
emptyMessage="No history found"
|
||||||
if (endIndex - startIndex < maxVisible) {
|
renderItem={renderHistoryItem}
|
||||||
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 (
|
|
||||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
|
||||||
<Text color={isSelected ? colors.focus : colors.text}>
|
|
||||||
{indicator}[Invitation] {item.description}
|
|
||||||
</Text>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
} else if (item.type === 'utxo_reserved') {
|
|
||||||
const sats = item.valueSatoshis ?? 0n;
|
|
||||||
return (
|
|
||||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
|
||||||
<Box>
|
|
||||||
<Text color={isSelected ? colors.focus : colors.warning}>
|
|
||||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}> {item.description}</Text>
|
|
||||||
</Box>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
} else if (item.type === 'utxo_received') {
|
|
||||||
const sats = item.valueSatoshis ?? 0n;
|
|
||||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
|
||||||
return (
|
|
||||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
|
||||||
<Box flexDirection='row'>
|
|
||||||
<Text color={isSelected ? colors.focus : colors.success}>
|
|
||||||
{indicator}{formatSatoshis(sats)}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{' '}{item.description}{reservedTag}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for other types
|
|
||||||
return (
|
|
||||||
<Box key={item.id} flexDirection='row' justifyContent='space-between'>
|
|
||||||
<Text color={isSelected ? colors.focus : colors.text}>
|
|
||||||
{indicator}{item.type}: {item.description}
|
|
||||||
</Text>
|
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})()
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigation } from '../../hooks/useNavigation.js';
|
|||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
import { formatSatoshis } from '../../theme.js';
|
import { formatSatoshis } from '../../theme.js';
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
import type { XOTemplate, XOInvitation } from '@xo-cash/types';
|
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
||||||
import type {
|
import type {
|
||||||
WizardStep,
|
WizardStep,
|
||||||
VariableInput,
|
VariableInput,
|
||||||
@@ -323,6 +323,8 @@ export function useActionWizard() {
|
|||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(xoInvitation)
|
||||||
|
|
||||||
// Wrap and track
|
// Wrap and track
|
||||||
const invitationInstance =
|
const invitationInstance =
|
||||||
await appService.createInvitation(xoInvitation);
|
await appService.createInvitation(xoInvitation);
|
||||||
@@ -358,8 +360,9 @@ export function useActionWizard() {
|
|||||||
setStatus('Adding required outputs...');
|
setStatus('Adding required outputs...');
|
||||||
|
|
||||||
const outputsToAdd = transaction.outputs.map(
|
const outputsToAdd = transaction.outputs.map(
|
||||||
(outputId: string) => ({
|
(output: XOTemplateTransactionOutput) => ({
|
||||||
outputIdentifier: outputId,
|
outputIdentifier: output.output,
|
||||||
|
roleIdentifier: roleIdentifier,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
259
src/utils/history-utils.ts
Normal file
259
src/utils/history-utils.ts
Normal file
@@ -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 };
|
||||||
|
}
|
||||||
264
src/utils/invitation-utils.ts
Normal file
264
src/utils/invitation-utils.ts
Normal file
@@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { XOInvitation } from "@xo-cash/types";
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
import { EventEmitter } from "./event-emitter.js";
|
import { EventEmitter } from "./event-emitter.js";
|
||||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
import { SSESession, type SSEvent } from "./sse-client.js";
|
||||||
import { decodeExtendedJsonObject, encodeExtendedJson } from "./ext-json.js";
|
import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson, encodeExtendedJsonObject } from "./ext-json.js";
|
||||||
|
|
||||||
export type SyncServerEventMap = {
|
export type SyncServerEventMap = {
|
||||||
'connected': void;
|
'connected': void;
|
||||||
@@ -66,7 +66,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = decodeExtendedJsonObject(await response.text()) as XOInvitation | undefined;
|
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
|
|
||||||
// Read the returned JSON
|
// Read the returned JSON
|
||||||
// TODO: This should use zod to verify the response
|
// TODO: This should use zod to verify the response
|
||||||
const data = decodeExtendedJsonObject(await response.text()) as XOInvitation;
|
const data = decodeExtendedJson(await response.text()) as XOInvitation;
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/utils/template-utils.ts
Normal file
246
src/utils/template-utils.ts
Normal file
@@ -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<string, UniqueStartingAction>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user