Invitations screen changes. Scrollable list. Details. And role selection on import
This commit is contained in:
@@ -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 { 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';
|
||||||
|
|
||||||
@@ -86,6 +87,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 +124,25 @@ 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}`);
|
|
||||||
|
// Compute and emit initial status
|
||||||
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -155,8 +168,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,12 +198,64 @@ 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.
|
||||||
|
* - 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,7 +269,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
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 +285,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 +325,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 +360,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;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate next position
|
||||||
|
let nextVisiblePos = currentVisiblePos + direction;
|
||||||
|
|
||||||
|
if (wrap) {
|
||||||
|
if (nextVisiblePos < 0) nextVisiblePos = visibleIndices.length - 1;
|
||||||
|
if (nextVisiblePos >= visibleIndices.length) nextVisiblePos = 0;
|
||||||
|
} else {
|
||||||
|
nextVisiblePos = Math.max(0, Math.min(visibleIndices.length - 1, nextVisiblePos));
|
||||||
|
}
|
||||||
|
|
||||||
|
return visibleIndices[nextVisiblePos];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate scroll window for visible items.
|
||||||
|
*/
|
||||||
|
function calculateScrollWindow(
|
||||||
|
selectedIndex: number,
|
||||||
|
totalItems: number,
|
||||||
|
maxVisible: number
|
||||||
|
): { startIndex: number; endIndex: number } {
|
||||||
|
const halfWindow = Math.floor(maxVisible / 2);
|
||||||
|
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
||||||
|
let endIndex = Math.min(totalItems, startIndex + maxVisible);
|
||||||
|
|
||||||
|
// Adjust start if we're near the end
|
||||||
|
if (endIndex - startIndex < maxVisible) {
|
||||||
|
startIndex = Math.max(0, endIndex - maxVisible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startIndex, endIndex };
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ScrollableList Component
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-featured scrollable list with grouping, filtering, and custom rendering.
|
||||||
|
*/
|
||||||
|
export function ScrollableList<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';
|
||||||
|
|||||||
@@ -2,63 +2,52 @@
|
|||||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - Import invitation by ID
|
* - Import invitation by ID with role selection
|
||||||
* - View active invitations
|
* - View active invitations with detailed information
|
||||||
* - Monitor invitation updates via SSE
|
* - Monitor invitation updates via SSE
|
||||||
* - Fill missing requirements
|
* - Fill missing requirements
|
||||||
* - Sign and complete invitations
|
* - Sign and complete invitations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import { InputDialog } from '../components/Dialog.js';
|
import { InputDialog } from '../components/Dialog.js';
|
||||||
|
import { ScrollableList, type ListItemData, type ListGroup } 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 { useInvitations } from '../hooks/useInvitations.js';
|
import { useInvitations } from '../hooks/useInvitations.js';
|
||||||
import { colors, logoSmall, formatHex, formatSatoshis } from '../theme.js';
|
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
import type { Invitation } from '../../services/invitation.js';
|
import type { Invitation } from '../../services/invitation.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
// Import utility functions
|
||||||
|
import {
|
||||||
|
getInvitationState,
|
||||||
|
getStateColorName,
|
||||||
|
getInvitationInputs,
|
||||||
|
getInvitationOutputs,
|
||||||
|
getInvitationVariables,
|
||||||
|
getUserRole,
|
||||||
|
formatInvitationListItem,
|
||||||
|
formatInvitationId,
|
||||||
|
} from '../../utils/invitation-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get state display string for invitation.
|
* Map state color name to theme color.
|
||||||
* For now we'll use a simple derived state based on commits.
|
|
||||||
*/
|
|
||||||
function getInvitationState(invitation: Invitation): string {
|
|
||||||
const commits = invitation.data.commits || [];
|
|
||||||
if (commits.length === 0) return 'created';
|
|
||||||
|
|
||||||
// Check if invitation has been signed (has signatures)
|
|
||||||
const hasSig = commits.some(c => c.signature);
|
|
||||||
if (hasSig) return 'signed';
|
|
||||||
|
|
||||||
// Check if invitation has inputs/outputs
|
|
||||||
const hasInputs = commits.some(c => c.data?.inputs && c.data.inputs.length > 0);
|
|
||||||
const hasOutputs = commits.some(c => c.data?.outputs && c.data.outputs.length > 0);
|
|
||||||
|
|
||||||
if (hasInputs || hasOutputs) return 'pending';
|
|
||||||
|
|
||||||
return 'published';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get color for invitation state.
|
|
||||||
*/
|
*/
|
||||||
function getStateColor(state: string): string {
|
function getStateColor(state: string): string {
|
||||||
switch (state) {
|
const colorName = getStateColorName(state);
|
||||||
case 'created':
|
switch (colorName) {
|
||||||
case 'published':
|
case 'info':
|
||||||
return colors.info as string;
|
return colors.info as string;
|
||||||
case 'pending':
|
case 'warning':
|
||||||
return colors.warning as string;
|
return colors.warning as string;
|
||||||
case 'ready':
|
case 'success':
|
||||||
case 'signed':
|
|
||||||
return colors.success as string;
|
return colors.success as string;
|
||||||
case 'broadcast':
|
|
||||||
case 'completed':
|
|
||||||
return colors.success as string;
|
|
||||||
case 'expired':
|
|
||||||
case 'error':
|
case 'error':
|
||||||
return colors.error as string;
|
return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
default:
|
default:
|
||||||
return colors.textMuted as string;
|
return colors.textMuted as string;
|
||||||
}
|
}
|
||||||
@@ -67,13 +56,25 @@ function getStateColor(state: string): string {
|
|||||||
/**
|
/**
|
||||||
* Action menu items.
|
* Action menu items.
|
||||||
*/
|
*/
|
||||||
const actionItems = [
|
const actionItems: ListItemData<string>[] = [
|
||||||
{ label: 'Import Invitation', value: 'import' },
|
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
||||||
{ label: 'Accept & Join', value: 'accept' },
|
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
||||||
{ label: 'Fill Requirements', value: 'fill' },
|
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
||||||
{ label: 'Sign Transaction', value: 'sign' },
|
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
|
||||||
{ label: 'View Transaction', value: 'transaction' },
|
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||||
{ label: 'Copy Invitation ID', value: 'copy' },
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invitation list item with invitation value or null for import action.
|
||||||
|
*/
|
||||||
|
type InvitationListItem = ListItemData<Invitation | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups for the invitation list.
|
||||||
|
*/
|
||||||
|
const invitationListGroups: ListGroup[] = [
|
||||||
|
{ id: 'actions' },
|
||||||
|
{ id: 'invitations', separator: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,10 +91,22 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
// State
|
// State
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'details' | 'actions'>('list');
|
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
||||||
|
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
||||||
|
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
||||||
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||||
|
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
|
||||||
|
// Template cache for displaying invitation list with template names
|
||||||
|
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||||
|
|
||||||
|
// Selected invitation template for details view
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
|
||||||
// Check if we should open import dialog on mount
|
// Check if we should open import dialog on mount
|
||||||
const initialMode = navData.mode as string | undefined;
|
const initialMode = navData.mode as string | undefined;
|
||||||
|
|
||||||
@@ -102,38 +115,167 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialMode === 'import') {
|
if (initialMode === 'import') {
|
||||||
setShowImportDialog(true);
|
setImportStage('id');
|
||||||
}
|
}
|
||||||
}, [initialMode]);
|
}, [initialMode]);
|
||||||
|
|
||||||
// Get selected invitation
|
/**
|
||||||
const selectedInvitation = invitations[selectedIndex];
|
* Load templates for all invitations (for list display).
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!appService) return;
|
||||||
|
|
||||||
|
invitations.forEach(inv => {
|
||||||
|
const templateId = inv.data.templateIdentifier;
|
||||||
|
if (!templateCache.has(templateId)) {
|
||||||
|
appService.engine.getTemplate(templateId).then(template => {
|
||||||
|
if (template) {
|
||||||
|
setTemplateCache(prev => new Map(prev).set(templateId, template));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [invitations, appService, templateCache]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Import invitation by ID.
|
* Build list items for ScrollableList.
|
||||||
|
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
||||||
*/
|
*/
|
||||||
const importInvitation = useCallback(async (invitationId: string) => {
|
const listItems = useMemo((): InvitationListItem[] => {
|
||||||
setShowImportDialog(false);
|
// Import action at top
|
||||||
if (!invitationId.trim() || !appService) return;
|
const importItem: InvitationListItem = {
|
||||||
|
key: 'import',
|
||||||
|
label: '+ Import Invitation',
|
||||||
|
value: null,
|
||||||
|
group: 'actions',
|
||||||
|
color: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map invitations to list items
|
||||||
|
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||||
|
const template = templateCache.get(inv.data.templateIdentifier);
|
||||||
|
const formatted = formatInvitationListItem(inv, template);
|
||||||
|
const state = getInvitationState(inv);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: inv.data.invitationIdentifier,
|
||||||
|
label: formatted.label,
|
||||||
|
value: inv,
|
||||||
|
group: 'invitations',
|
||||||
|
color: formatted.statusColor,
|
||||||
|
hidden: !formatted.isValid, // Hide invalid items
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [importItem, ...invitationItems];
|
||||||
|
}, [invitations, templateCache]);
|
||||||
|
|
||||||
|
// Get selected invitation from list items
|
||||||
|
const selectedItem = listItems[selectedIndex];
|
||||||
|
const selectedInvitation = selectedItem?.value ?? null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load template for selected invitation.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedInvitation || !appService) {
|
||||||
|
setSelectedTemplate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
||||||
|
.then(template => setSelectedTemplate(template ?? null));
|
||||||
|
}, [selectedInvitation, appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage 1: Import invitation by ID (fetches invitation and moves to role selection).
|
||||||
|
*/
|
||||||
|
const handleImportIdSubmit = useCallback(async (invitationId: string) => {
|
||||||
|
if (!invitationId.trim() || !appService) {
|
||||||
|
setImportStage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setStatus('Importing invitation...');
|
setStatus('Fetching invitation...');
|
||||||
|
|
||||||
// Create invitation instance (will fetch from sync server)
|
// Create invitation instance (will fetch from sync server)
|
||||||
const invitation = await appService.createInvitation(invitationId);
|
const invitation = await appService.createInvitation(invitationId);
|
||||||
|
|
||||||
showInfo(`Invitation imported!\n\nTemplate: ${invitation.data.templateIdentifier}\nAction: ${invitation.data.actionIdentifier}`);
|
// Get available roles for this invitation
|
||||||
|
const roles = await invitation.getAvailableRoles();
|
||||||
|
|
||||||
|
// Get the template for display
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
|
||||||
|
// Store for next stage
|
||||||
|
setImportingInvitation(invitation);
|
||||||
|
setAvailableRoles(roles);
|
||||||
|
setSelectedRoleIndex(0);
|
||||||
|
setImportTemplate(template ?? null);
|
||||||
|
|
||||||
|
// Move to role selection stage
|
||||||
|
setImportStage('role-select');
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
setImportStage(null);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [appService, showInfo, showError, setStatus]);
|
}, [appService, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept selected invitation.
|
* Stage 2: Accept invitation with selected role.
|
||||||
|
*/
|
||||||
|
const handleRoleSelect = useCallback(async () => {
|
||||||
|
if (!importingInvitation || !appService) return;
|
||||||
|
|
||||||
|
const selectedRole = availableRoles[selectedRoleIndex];
|
||||||
|
if (!selectedRole) {
|
||||||
|
showError('No role selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setStatus(`Accepting as ${selectedRole}...`);
|
||||||
|
|
||||||
|
await importingInvitation.accept();
|
||||||
|
|
||||||
|
showInfo(`Invitation imported and accepted!\n\nRole: ${selectedRole}\nTemplate: ${importTemplate?.name ?? importingInvitation.data.templateIdentifier}\nAction: ${importingInvitation.data.actionIdentifier}`);
|
||||||
|
setStatus('Ready');
|
||||||
|
|
||||||
|
// Reset import state
|
||||||
|
setImportStage(null);
|
||||||
|
setImportingInvitation(null);
|
||||||
|
setAvailableRoles([]);
|
||||||
|
setImportTemplate(null);
|
||||||
|
} catch (error) {
|
||||||
|
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [importingInvitation, availableRoles, selectedRoleIndex, appService, importTemplate, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel import and remove the invitation if it was added.
|
||||||
|
*/
|
||||||
|
const handleImportCancel = useCallback(async () => {
|
||||||
|
if (importingInvitation && appService) {
|
||||||
|
// Remove the invitation since user declined
|
||||||
|
await appService.removeInvitation(importingInvitation);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportStage(null);
|
||||||
|
setImportingInvitation(null);
|
||||||
|
setAvailableRoles([]);
|
||||||
|
setImportTemplate(null);
|
||||||
|
}, [importingInvitation, appService]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept selected invitation (from actions menu).
|
||||||
*/
|
*/
|
||||||
const acceptInvitation = useCallback(async () => {
|
const acceptInvitation = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
@@ -203,11 +345,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fill requirements for selected invitation.
|
* Fill requirements for selected invitation.
|
||||||
* This automatically:
|
|
||||||
* 1. Accepts the invitation (if not already)
|
|
||||||
* 2. Finds suitable UTXOs
|
|
||||||
* 3. Selects UTXOs to cover the required amount
|
|
||||||
* 4. Appends inputs and change output to the invitation
|
|
||||||
*/
|
*/
|
||||||
const fillRequirements = useCallback(async () => {
|
const fillRequirements = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
@@ -220,16 +357,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Step 1: Check available roles
|
// Step 1: Check available roles
|
||||||
setStatus('Checking available roles...');
|
setStatus('Checking available roles...');
|
||||||
const availableRoles = await selectedInvitation.getAvailableRoles();
|
const roles = await selectedInvitation.getAvailableRoles();
|
||||||
|
|
||||||
if (availableRoles.length === 0) {
|
if (roles.length === 0) {
|
||||||
// Already participating, check if we can add inputs
|
// Already participating, check if we can add inputs
|
||||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||||
} else {
|
} else {
|
||||||
// Need to accept a role first
|
// Need to accept a role first
|
||||||
// TODO: Let user pick role if multiple available
|
const roleToTake = roles[0];
|
||||||
// For now, auto-select the first available role
|
|
||||||
const roleToTake = availableRoles[0];
|
|
||||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||||
setStatus(`Accepting as ${roleToTake}...`);
|
setStatus(`Accepting as ${roleToTake}...`);
|
||||||
|
|
||||||
@@ -246,7 +381,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setStatus('Analyzing invitation...');
|
setStatus('Analyzing invitation...');
|
||||||
|
|
||||||
// Calculate how much we need
|
// Calculate how much we need
|
||||||
// Look for a requestedSatoshis variable in the invitation
|
|
||||||
let requiredAmount = 0n;
|
let requiredAmount = 0n;
|
||||||
const commits = selectedInvitation.data.commits || [];
|
const commits = selectedInvitation.data.commits || [];
|
||||||
for (const commit of commits) {
|
for (const commit of commits) {
|
||||||
@@ -260,14 +394,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
if (requiredAmount > 0n) break;
|
if (requiredAmount > 0n) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fee = 500n; // Estimated fee
|
const fee = 500n;
|
||||||
const dust = 546n; // Dust threshold
|
const dust = 546n;
|
||||||
const totalNeeded = requiredAmount + fee + dust;
|
const totalNeeded = requiredAmount + fee + dust;
|
||||||
|
|
||||||
// Find resources - use a common output identifier
|
// Find resources
|
||||||
const utxos = await selectedInvitation.findSuitableResources({
|
const utxos = await selectedInvitation.findSuitableResources({
|
||||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||||
outputIdentifier: 'receiveOutput', // Try common identifier
|
outputIdentifier: 'receiveOutput',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (utxos.length === 0) {
|
if (utxos.length === 0) {
|
||||||
@@ -276,7 +410,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Select UTXOs (auto-select to cover the amount)
|
// Select UTXOs
|
||||||
setStatus('Selecting UTXOs...');
|
setStatus('Selecting UTXOs...');
|
||||||
|
|
||||||
const selectedUtxos: Array<{
|
const selectedUtxos: Array<{
|
||||||
@@ -288,7 +422,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const seenLockingBytecodes = new Set<string>();
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
for (const utxo of utxos) {
|
||||||
// Check lockingBytecode uniqueness
|
|
||||||
const lockingBytecodeHex = utxo.lockingBytecode
|
const lockingBytecodeHex = utxo.lockingBytecode
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
? utxo.lockingBytecode
|
? utxo.lockingBytecode
|
||||||
@@ -322,7 +455,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const changeAmount = accumulated - requiredAmount - fee;
|
const changeAmount = accumulated - requiredAmount - fee;
|
||||||
|
|
||||||
// Step 6: Add inputs to the invitation
|
// Add inputs
|
||||||
setStatus('Adding inputs...');
|
setStatus('Adding inputs...');
|
||||||
await selectedInvitation.addInputs(
|
await selectedInvitation.addInputs(
|
||||||
selectedUtxos.map(u => ({
|
selectedUtxos.map(u => ({
|
||||||
@@ -331,7 +464,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Step 7: Add change output
|
// Add change output
|
||||||
if (changeAmount >= dust) {
|
if (changeAmount >= dust) {
|
||||||
setStatus('Adding change output...');
|
setStatus('Adding change output...');
|
||||||
await selectedInvitation.addOutputs([{
|
await selectedInvitation.addOutputs([{
|
||||||
@@ -364,9 +497,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
const handleAction = useCallback((action: string) => {
|
const handleAction = useCallback((action: string) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'import':
|
|
||||||
setShowImportDialog(true);
|
|
||||||
break;
|
|
||||||
case 'copy':
|
case 'copy':
|
||||||
copyId();
|
copyId();
|
||||||
break;
|
break;
|
||||||
@@ -387,54 +517,323 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle list item activation.
|
||||||
|
*/
|
||||||
|
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
||||||
|
if (item.key === 'import') {
|
||||||
|
setImportStage('id');
|
||||||
|
}
|
||||||
|
// For invitation items, we just select them - actions are in the actions panel
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle action item activation.
|
||||||
|
*/
|
||||||
|
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||||
|
if (item.value) {
|
||||||
|
handleAction(item.value);
|
||||||
|
}
|
||||||
|
}, [handleAction]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
// Don't handle input while dialog is open
|
// Handle role selection dialog navigation
|
||||||
if (showImportDialog) return;
|
if (importStage === 'role-select') {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
// Tab to switch panels
|
setSelectedRoleIndex(prev => Math.max(0, prev - 1));
|
||||||
if (key.tab) {
|
} else if (key.downArrow || input === 'j') {
|
||||||
setFocusedPanel(prev => {
|
setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||||
if (prev === 'list') return 'details';
|
} else if (key.return) {
|
||||||
if (prev === 'details') return 'actions';
|
handleRoleSelect();
|
||||||
return 'list';
|
} else if (key.escape) {
|
||||||
});
|
handleImportCancel();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Up/Down navigation
|
// Don't handle input while ID input dialog is open
|
||||||
if (key.upArrow || input === 'k') {
|
if (importStage === 'id') return;
|
||||||
if (focusedPanel === 'list') {
|
|
||||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (focusedPanel === 'actions') {
|
|
||||||
setSelectedActionIndex(prev => Math.max(0, prev - 1));
|
|
||||||
}
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
if (focusedPanel === 'list') {
|
|
||||||
setSelectedIndex(prev => Math.min(invitations.length - 1, prev + 1));
|
|
||||||
} else if (focusedPanel === 'actions') {
|
|
||||||
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter to select action
|
// Tab to switch panels (list -> actions -> list)
|
||||||
if (key.return && focusedPanel === 'actions') {
|
if (key.tab) {
|
||||||
const action = actionItems[selectedActionIndex];
|
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||||
if (action) {
|
return;
|
||||||
handleAction(action.value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'c' to copy
|
// 'c' to copy
|
||||||
if (input === 'c') {
|
if (input === 'c' && selectedInvitation) {
|
||||||
copyId();
|
copyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'i' to import
|
// 'i' to import
|
||||||
if (input === 'i') {
|
if (input === 'i') {
|
||||||
setShowImportDialog(true);
|
setImportStage('id');
|
||||||
}
|
}
|
||||||
}, { isActive: !showImportDialog });
|
}, { isActive: importStage !== 'id' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render custom list item for invitation list.
|
||||||
|
*/
|
||||||
|
const renderInvitationListItem = useCallback((
|
||||||
|
item: InvitationListItem,
|
||||||
|
isSelected: boolean,
|
||||||
|
isFocused: boolean
|
||||||
|
): React.ReactNode => {
|
||||||
|
// Import item
|
||||||
|
if (item.key === 'import') {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.info}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invitation item
|
||||||
|
const inv = item.value;
|
||||||
|
if (!inv) return null;
|
||||||
|
|
||||||
|
const state = getInvitationState(inv);
|
||||||
|
const template = templateCache.get(inv.data.templateIdentifier);
|
||||||
|
const templateName = template?.name ?? 'Unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isSelected}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
<Text color={getStateColor(state)}>[{state}]</Text>
|
||||||
|
{' '}{templateName}-{inv.data.actionIdentifier} ({formatInvitationId(inv.data.invitationIdentifier, 8)})
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}, [templateCache]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render detailed invitation information.
|
||||||
|
*/
|
||||||
|
const renderDetails = () => {
|
||||||
|
if (!selectedInvitation) {
|
||||||
|
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getInvitationState(selectedInvitation);
|
||||||
|
const action = selectedTemplate?.actions?.[selectedInvitation.data.actionIdentifier];
|
||||||
|
const inputs = getInvitationInputs(selectedInvitation);
|
||||||
|
const outputs = getInvitationOutputs(selectedInvitation);
|
||||||
|
const variables = getInvitationVariables(selectedInvitation);
|
||||||
|
|
||||||
|
// Try to determine user's entity ID (from first commit they made)
|
||||||
|
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||||
|
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||||
|
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Row 1: Type, Description, Status */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box width="50%">
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Type: </Text>
|
||||||
|
<Text color={colors.text}>{selectedTemplate?.name ?? 'Unknown Template'}</Text>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{selectedTemplate?.description ?? 'No description available'}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box width="50%">
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Status: </Text>
|
||||||
|
<Text color={getStateColor(state)}>{state}</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Action: {action?.name ?? selectedInvitation.data.actionIdentifier}
|
||||||
|
</Text>
|
||||||
|
{action?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 2: Your Role */}
|
||||||
|
{userRole && (
|
||||||
|
<Box marginBottom={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Your Role: </Text>
|
||||||
|
<Text color={colors.success}>{roleInfo?.name ?? userRole}</Text>
|
||||||
|
{roleInfo?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{roleInfo.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Row 3: Inputs & Outputs side by side */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
{/* Inputs */}
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
|
{inputs.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> No inputs yet</Text>
|
||||||
|
) : (
|
||||||
|
inputs.map((input, idx) => {
|
||||||
|
const isUserInput = input.entityIdentifier === userEntityId;
|
||||||
|
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`input-${idx}`}
|
||||||
|
color={isUserInput ? colors.success : colors.text}
|
||||||
|
>
|
||||||
|
{' '}{isUserInput ? '• ' : '○ '}
|
||||||
|
{inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> No outputs yet</Text>
|
||||||
|
) : (
|
||||||
|
outputs.map((output, idx) => {
|
||||||
|
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||||
|
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`output-${idx}`}
|
||||||
|
color={isUserOutput ? colors.success : colors.text}
|
||||||
|
>
|
||||||
|
{' '}{isUserOutput ? '• ' : '○ '}
|
||||||
|
{outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
|
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Row 4: Variables */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
|
{variables.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> No variables set</Text>
|
||||||
|
) : (
|
||||||
|
variables.map((variable, idx) => {
|
||||||
|
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||||
|
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||||
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
|
? variable.value.toString()
|
||||||
|
: String(variable.value);
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`var-${idx}`}
|
||||||
|
color={isUserVariable ? colors.success : colors.text}
|
||||||
|
>
|
||||||
|
{' '}{isUserVariable ? '• ' : '○ '}
|
||||||
|
{varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
|
{varTemplate?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor> - {varTemplate.description}</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Shortcuts */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render role selection dialog for import flow.
|
||||||
|
*/
|
||||||
|
const renderRoleSelectionDialog = () => {
|
||||||
|
if (!importingInvitation) return null;
|
||||||
|
|
||||||
|
const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
backgroundColor="white"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
width={70}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>Import Invitation - Select Role</Text>
|
||||||
|
|
||||||
|
{/* Invitation Details */}
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {importTemplate?.name ?? 'Unknown'}</Text>
|
||||||
|
{importTemplate?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{importTemplate.description}</Text>
|
||||||
|
)}
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? importingInvitation.data.actionIdentifier}</Text>
|
||||||
|
{action?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||||
|
{availableRoles.length === 0 ? (
|
||||||
|
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||||
|
) : (
|
||||||
|
availableRoles.map((role, index) => {
|
||||||
|
const roleInfoRaw = importTemplate?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
const actionRoleRaw = action?.roles?.[role];
|
||||||
|
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||||
|
return (
|
||||||
|
<Box key={role} flexDirection="column">
|
||||||
|
<Text
|
||||||
|
color={index === selectedRoleIndex ? colors.focus : colors.text}
|
||||||
|
bold={index === selectedRoleIndex}
|
||||||
|
>
|
||||||
|
{index === selectedRoleIndex ? '▸ ' : ' '}
|
||||||
|
{roleInfo?.name ?? role}
|
||||||
|
</Text>
|
||||||
|
{(roleInfo?.description || actionRole?.description) && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Decline</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -443,10 +842,10 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content - three columns */}
|
{/* Main content - Top row: List + Actions */}
|
||||||
<Box flexDirection="row" marginTop={1} flexGrow={1}>
|
<Box flexDirection="row" marginTop={1} height={12}>
|
||||||
{/* Left column: Invitation list */}
|
{/* Left column: Invitation list */}
|
||||||
<Box flexDirection="column" width="40%" paddingRight={1}>
|
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
|
borderColor={focusedPanel === 'list' ? colors.focus : colors.primary}
|
||||||
@@ -454,115 +853,23 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Active Invitations </Text>
|
<Text color={colors.primary} bold> Invitations </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<ScrollableList
|
||||||
{invitations.length === 0 ? (
|
items={listItems}
|
||||||
<Text color={colors.textMuted}>No invitations</Text>
|
selectedIndex={selectedIndex}
|
||||||
) : (
|
onSelect={setSelectedIndex}
|
||||||
invitations.map((inv, index) => {
|
onActivate={handleListItemActivate}
|
||||||
const state = getInvitationState(inv);
|
focus={focusedPanel === 'list'}
|
||||||
return (
|
maxVisible={6}
|
||||||
<Text
|
groups={invitationListGroups}
|
||||||
key={inv.data.invitationIdentifier}
|
emptyMessage="No invitations yet"
|
||||||
color={index === selectedIndex ? colors.focus : colors.text}
|
renderItem={renderInvitationListItem}
|
||||||
bold={index === selectedIndex}
|
/>
|
||||||
>
|
|
||||||
{index === selectedIndex && focusedPanel === 'list' ? '▸ ' : ' '}
|
|
||||||
<Text color={getStateColor(state)}>[{state}]</Text>
|
|
||||||
{' '}{formatHex(inv.data.invitationIdentifier, 12)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Middle column: Details */}
|
|
||||||
<Box flexDirection="column" width="40%" paddingX={1}>
|
|
||||||
<Box
|
|
||||||
borderStyle="single"
|
|
||||||
borderColor={focusedPanel === 'details' ? colors.focus : colors.border}
|
|
||||||
flexDirection="column"
|
|
||||||
paddingX={1}
|
|
||||||
flexGrow={1}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold> Details </Text>
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
{selectedInvitation ? (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
const state = getInvitationState(selectedInvitation);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text color={colors.text}>ID: {formatHex(selectedInvitation.data.invitationIdentifier, 20)}</Text>
|
|
||||||
<Text color={colors.text}>
|
|
||||||
State: <Text color={getStateColor(state)}>{state}</Text>
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Template: {selectedInvitation.data.templateIdentifier?.slice(0, 20)}...
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Action: {selectedInvitation.data.actionIdentifier}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
Commits: {selectedInvitation.data.commits?.length ?? 0}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* State-specific guidance */}
|
|
||||||
<Box marginTop={1} flexDirection="column">
|
|
||||||
{state === 'created' && (
|
|
||||||
<Text color={colors.info}>→ Share this ID with the other party</Text>
|
|
||||||
)}
|
|
||||||
{state === 'published' && (
|
|
||||||
<Text color={colors.info}>→ Waiting for other party to join...</Text>
|
|
||||||
)}
|
|
||||||
{state === 'pending' && (
|
|
||||||
<>
|
|
||||||
<Text color={colors.warning}>→ Action needed!</Text>
|
|
||||||
<Text color={colors.warning}> Use "Fill Requirements" to add</Text>
|
|
||||||
<Text color={colors.warning}> your UTXOs and complete your part</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === 'ready' && (
|
|
||||||
<>
|
|
||||||
<Text color={colors.success}>→ Ready to sign!</Text>
|
|
||||||
<Text color={colors.success}> Use "Sign Transaction"</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === 'signed' && (
|
|
||||||
<>
|
|
||||||
<Text color={colors.success}>→ Signed!</Text>
|
|
||||||
<Text color={colors.success}> View Transaction to broadcast</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{state === 'broadcast' && (
|
|
||||||
<Text color={colors.success}>→ Transaction broadcast! Waiting for confirmation...</Text>
|
|
||||||
)}
|
|
||||||
{state === 'completed' && (
|
|
||||||
<Text color={colors.success}>✓ Transaction completed!</Text>
|
|
||||||
)}
|
|
||||||
{state === 'error' && (
|
|
||||||
<Text color={colors.error}>✗ Error - check logs</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text color={colors.textMuted}>Select an invitation</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right column: Actions */}
|
{/* Right column: Actions */}
|
||||||
<Box flexDirection="column" width="20%" paddingLeft={1}>
|
<Box flexDirection="column" width="30%" paddingLeft={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
|
||||||
@@ -571,18 +878,30 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Actions </Text>
|
<Text color={colors.primary} bold> Actions </Text>
|
||||||
<Box marginTop={1} flexDirection="column">
|
<ScrollableList
|
||||||
{actionItems.map((item, index) => (
|
items={actionItems}
|
||||||
<Text
|
selectedIndex={selectedActionIndex}
|
||||||
key={item.value}
|
onSelect={setSelectedActionIndex}
|
||||||
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
|
onActivate={handleActionItemActivate}
|
||||||
bold={index === selectedActionIndex && focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions'}
|
||||||
>
|
emptyMessage="No actions"
|
||||||
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
|
/>
|
||||||
{item.label}
|
</Box>
|
||||||
</Text>
|
</Box>
|
||||||
))}
|
</Box>
|
||||||
</Box>
|
|
||||||
|
{/* Bottom row: Details (full width) */}
|
||||||
|
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.border}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold> Details </Text>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderDetails()}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -594,8 +913,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Import dialog */}
|
{/* Import ID dialog (Stage 1) */}
|
||||||
{showImportDialog && (
|
{importStage === 'id' && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -608,12 +927,15 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
title="Import Invitation"
|
title="Import Invitation"
|
||||||
prompt="Enter Invitation ID:"
|
prompt="Enter Invitation ID:"
|
||||||
placeholder="Paste invitation ID..."
|
placeholder="Paste invitation ID..."
|
||||||
onSubmit={importInvitation}
|
onSubmit={handleImportIdSubmit}
|
||||||
onCancel={() => setShowImportDialog(false)}
|
onCancel={() => setImportStage(null)}
|
||||||
isActive={showImportDialog}
|
isActive={true}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Role Selection dialog (Stage 2) */}
|
||||||
|
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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 (
|
return (
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
<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
|
</Box>
|
||||||
if (isLoading) {
|
) : (
|
||||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
<ScrollableList
|
||||||
}
|
items={templateListItems}
|
||||||
|
selectedIndex={selectedTemplateIndex}
|
||||||
// No templates state
|
onSelect={handleTemplateSelect}
|
||||||
if (templates.length === 0) {
|
focus={focusedPanel === 'templates'}
|
||||||
return <Text color={colors.textMuted}>No templates imported</Text>;
|
emptyMessage="No templates imported"
|
||||||
}
|
renderItem={renderTemplateItem}
|
||||||
|
/>
|
||||||
// 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>
|
</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
|
</Box>
|
||||||
if (isLoading) {
|
) : !currentTemplate ? (
|
||||||
return <Text color={colors.textMuted}>Loading...</Text>;
|
<Box marginTop={1}>
|
||||||
}
|
<Text color={colors.textMuted}>Select a template...</Text>
|
||||||
|
</Box>
|
||||||
// No template selected state
|
) : (
|
||||||
if (!currentTemplate) {
|
<ScrollableList
|
||||||
return <Text color={colors.textMuted}>Select a template...</Text>;
|
items={actionListItems}
|
||||||
}
|
selectedIndex={selectedActionIndex}
|
||||||
|
onSelect={setSelectedActionIndex}
|
||||||
// No starting actions state
|
onActivate={handleActionActivate}
|
||||||
if (currentActions.length === 0) {
|
focus={focusedPanel === 'actions'}
|
||||||
return <Text color={colors.textMuted}>No starting actions available</Text>;
|
emptyMessage="No starting actions available"
|
||||||
}
|
renderItem={renderActionItem}
|
||||||
|
/>
|
||||||
// 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>
|
</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}
|
</Text>
|
||||||
{roleDef?.description ? `: ${roleDef.description}` : ''}
|
))}
|
||||||
</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 (
|
||||||
|
<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 (
|
return (
|
||||||
<Box flexDirection='column' flexGrow={1}>
|
<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,37 +325,25 @@ 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}
|
selectedIndex={selectedMenuIndex}
|
||||||
onSelect={handleMenuSelect}
|
onSelect={setSelectedMenuIndex}
|
||||||
isFocused={focusedPanel === 'menu'}
|
onActivate={handleMenuItemActivate}
|
||||||
indicatorComponent={({ isSelected }) => (
|
focus={focusedPanel === 'menu'}
|
||||||
<Text color={isSelected ? colors.focus : colors.text}>
|
emptyMessage="No actions"
|
||||||
{isSelected ? '▸ ' : ' '}
|
/>
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
itemComponent={({ isSelected, label }) => (
|
|
||||||
<Text
|
|
||||||
color={isSelected ? colors.text : colors.textMuted}
|
|
||||||
bold={isSelected}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -243,95 +351,30 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
{/* 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>
|
) : (
|
||||||
) : (
|
<ScrollableList
|
||||||
// Show a scrolling window of items
|
items={historyListItems}
|
||||||
(() => {
|
selectedIndex={selectedHistoryIndex}
|
||||||
const maxVisible = 10;
|
onSelect={setSelectedHistoryIndex}
|
||||||
const halfWindow = Math.floor(maxVisible / 2);
|
focus={focusedPanel === 'history'}
|
||||||
let startIndex = Math.max(0, selectedHistoryIndex - halfWindow);
|
maxVisible={10}
|
||||||
const endIndex = Math.min(history.length, startIndex + maxVisible);
|
emptyMessage="No history found"
|
||||||
// Adjust start if we're near the end
|
renderItem={renderHistoryItem}
|
||||||
if (endIndex - startIndex < maxVisible) {
|
/>
|
||||||
startIndex = Math.max(0, endIndex - maxVisible);
|
)}
|
||||||
}
|
|
||||||
const visibleItems = history.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
return visibleItems.map((item, idx) => {
|
|
||||||
const actualIndex = startIndex + idx;
|
|
||||||
const isSelected = actualIndex === selectedHistoryIndex && focusedPanel === 'history';
|
|
||||||
const indicator = isSelected ? '▸ ' : ' ';
|
|
||||||
const dateStr = item.timestamp
|
|
||||||
? new Date(item.timestamp).toLocaleDateString()
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Format the history item based on type
|
|
||||||
if (item.type === 'invitation_created') {
|
|
||||||
return (
|
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
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