Large amount of changes. Successfully broadcasts txs
This commit is contained in:
BIN
Electrum.sqlite-journal
Normal file
BIN
Electrum.sqlite-journal
Normal file
Binary file not shown.
@@ -7,7 +7,9 @@
|
|||||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"nuke": "tsx scripts/rm-dbs.ts",
|
||||||
|
"nuke:dry": "tsx scripts/rm-dbs.ts --dry"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"crypto",
|
"crypto",
|
||||||
|
|||||||
35
scripts/rm-dbs.ts
Normal file
35
scripts/rm-dbs.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all the databases without the use of external tools
|
||||||
|
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
|
||||||
|
*/
|
||||||
|
const rmDbs = async (dry = false) => {
|
||||||
|
// First, we need to find all the database base files
|
||||||
|
// These end in either .db.sqlite, .sqlite, .db
|
||||||
|
// Get all the files in the current directory
|
||||||
|
const files = await fs.readdir('./');
|
||||||
|
|
||||||
|
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
||||||
|
const dbFiles = files.filter(file => file.endsWith('.db.sqlite') || file.endsWith('.sqlite') || file.endsWith('.db'));
|
||||||
|
|
||||||
|
// We need to remove all the files
|
||||||
|
await deleteFiles(dbFiles, dry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteFiles = async (files: string[], dry = false) => {
|
||||||
|
if (dry) {
|
||||||
|
console.log('Dry run, would delete:', files);
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(files.map(file => fs.rm(file)));
|
||||||
|
console.log('All databases removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read args
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dry = args.includes('--dry');
|
||||||
|
|
||||||
|
// Delete the files
|
||||||
|
await rmDbs(dry);
|
||||||
@@ -55,6 +55,8 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
// Set default locking parameters for P2PKH
|
||||||
|
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||||
|
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||||
await engine.setDefaultLockingParameters(
|
await engine.setDefaultLockingParameters(
|
||||||
generateTemplateIdentifier(p2pkhTemplate),
|
generateTemplateIdentifier(p2pkhTemplate),
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
@@ -63,9 +65,10 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
// Create our own storage for the invitations
|
// Create our own storage for the invitations
|
||||||
const storage = await Storage.create(config.invitationStoragePath);
|
const storage = await Storage.create(config.invitationStoragePath);
|
||||||
|
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||||
|
|
||||||
// Create the app service
|
// Create the app service
|
||||||
return new AppService(engine, storage, config);
|
return new AppService(engine, walletStorage, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* - UTXOs we own (with descriptions derived from template outputs)
|
* - UTXOs we own (with descriptions derived from template outputs)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Engine } from '@xo-cash/engine';
|
import { type Engine, compileCashAssemblyString } from '@xo-cash/engine';
|
||||||
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
import type { Invitation } from './invitation.js';
|
import type { Invitation } from './invitation.js';
|
||||||
import { binToHex } from '@bitauth/libauth';
|
import { binToHex } from '@bitauth/libauth';
|
||||||
@@ -203,7 +203,7 @@ export class HistoryService {
|
|||||||
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
||||||
|
|
||||||
if (!outputDef) {
|
if (!outputDef) {
|
||||||
return `${utxo.outputIdentifier} output`;
|
return `[${template.name}] ${utxo.outputIdentifier} output`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start with the output name or identifier
|
// Start with the output name or identifier
|
||||||
@@ -211,11 +211,7 @@ export class HistoryService {
|
|||||||
|
|
||||||
// If there's a description, parse it and replace variable placeholders
|
// If there's a description, parse it and replace variable placeholders
|
||||||
if (outputDef.description) {
|
if (outputDef.description) {
|
||||||
description = outputDef.description
|
description = compileCashAssemblyString(outputDef.description, {})
|
||||||
// Replace <variableName> placeholders (we don't have variable values here, so just clean up)
|
|
||||||
.replace(/<([^>]+)>/g, (_, varId) => varId)
|
|
||||||
// Remove $() wrappers
|
|
||||||
.replace(/\$\(([^)]+)\)/g, '$1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return description;
|
return description;
|
||||||
@@ -239,14 +235,13 @@ export class HistoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
const committedVariables = invitation.commits.flatMap(c => c.data.variables ?? []);
|
||||||
|
const formattedVariables = committedVariables.reduce((acc, v) => {
|
||||||
|
acc[v.variableIdentifier ?? ''] = v.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, XOInvitationVariableValue>);
|
||||||
|
|
||||||
|
const description = compileCashAssemblyString(transaction.description, formattedVariables);
|
||||||
|
|
||||||
return transaction.description
|
return description;
|
||||||
// Replace <variableName> with actual values
|
|
||||||
.replace(/<([^>]+)>/g, (match, varId) => {
|
|
||||||
const variable = committedVariables.find(v => v.variableIdentifier === varId);
|
|
||||||
return variable ? String(variable.value) : match;
|
|
||||||
})
|
|
||||||
// Remove the $() wrapper around variable expressions
|
|
||||||
.replace(/\$\(([^)]+)\)/g, '$1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||||
import { hasInvitationExpired } 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, XOInvitationVariableValue } from '@xo-cash/types';
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
import type { SSEvent } from '../utils/sse-client.js';
|
import type { SSEvent } from '../utils/sse-client.js';
|
||||||
@@ -9,6 +9,7 @@ import type { Storage } from './storage.js';
|
|||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js'
|
import { EventEmitter } from '../utils/event-emitter.js'
|
||||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||||
|
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||||
|
|
||||||
export type InvitationEventMap = {
|
export type InvitationEventMap = {
|
||||||
'invitation-updated': XOInvitation;
|
'invitation-updated': XOInvitation;
|
||||||
@@ -32,14 +33,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
// Try to get the invitation from the storage
|
// Try to get the invitation from the storage
|
||||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||||
if (invitationFromStorage) {
|
if (invitationFromStorage) {
|
||||||
console.log(`Invitation found in storage: ${invitation}`);
|
|
||||||
return this.create(invitationFromStorage, dependencies);
|
return this.create(invitationFromStorage, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get the invitation from the sync server
|
// Try to get the invitation from the sync server
|
||||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||||
console.log(`Invitation found in sync server: ${invitation}`);
|
|
||||||
return this.create(invitationFromSyncServer, dependencies);
|
return this.create(invitationFromSyncServer, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +344,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.syncServer.publishInvitation(this.data);
|
await this.syncServer.publishInvitation(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the locking bytecode for the invitation
|
||||||
|
* TODO: Find out if this has side-effects or needs special handling
|
||||||
|
*/
|
||||||
|
async generateLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||||
// Add the outputs to the invitation
|
// Add the outputs to the invitation
|
||||||
await this.append({ outputs });
|
await this.append({ outputs });
|
||||||
@@ -410,4 +417,89 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Get the sats out for the invitation
|
||||||
|
* TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still...
|
||||||
|
*/
|
||||||
|
async getSatsOut(outputIdentifier?: string): Promise<bigint> {
|
||||||
|
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
|
||||||
|
if (outputIdentifier) {
|
||||||
|
// Get the valueSatoshis identifier from the template
|
||||||
|
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = template.outputs[outputIdentifier];
|
||||||
|
if (!output) {
|
||||||
|
throw new Error(`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSatoshisIdentifier = output.valueSatoshis;
|
||||||
|
if (!valueSatoshisIdentifier) {
|
||||||
|
throw new Error(`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a list of all the variables from the commits
|
||||||
|
const variables = this.data.commits.flatMap(c => c.data?.variables ?? []);
|
||||||
|
|
||||||
|
// Create a dictionary of the variables
|
||||||
|
const formattedVariables = variables.reduce((acc, v) => {
|
||||||
|
acc[v.variableIdentifier ?? ''] = v.value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, XOInvitationVariableValue>);
|
||||||
|
|
||||||
|
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||||
|
const valueSatoshis = await compileCashAssemblyString(String(valueSatoshisIdentifier), formattedVariables);
|
||||||
|
|
||||||
|
// Return the value satoshis as a bigint
|
||||||
|
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||||
|
return BigInt(valueSatoshis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
|
||||||
|
const action = this.data.actionIdentifier;
|
||||||
|
if (!action) {
|
||||||
|
throw new Error(`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template
|
||||||
|
const template = await this.engine.getTemplate(this.data.templateIdentifier);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction ID from the action
|
||||||
|
const transactionID = template.actions[action]?.transaction
|
||||||
|
if (!transactionID) {
|
||||||
|
throw new Error(`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction from the template
|
||||||
|
const transaction = template.transactions?.[transactionID];
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error(`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the outputs from the transaction
|
||||||
|
const outputs = transaction.outputs;
|
||||||
|
if (!outputs) {
|
||||||
|
throw new Error(`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a value to store the cummulative total of the outputs
|
||||||
|
let totalSats = 0n;
|
||||||
|
|
||||||
|
// Iterate through the outputs and sum the valueSatoshis
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
totalSats += await this.getSatsOut(output);
|
||||||
|
} else {
|
||||||
|
totalSats += await this.getSatsOut(output.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSats;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { SeedInputScreen } from './screens/SeedInput.js';
|
|||||||
import { WalletStateScreen } from './screens/WalletState.js';
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/Invitation.js';
|
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
import { MessageDialog } from './components/Dialog.js';
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface DialogWrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DialogWrapper({
|
export function DialogWrapper({
|
||||||
title,
|
title,
|
||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ 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 { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
|
||||||
// Import utility functions
|
// Import utility functions
|
||||||
import {
|
import {
|
||||||
formatHistoryListItem,
|
formatHistoryListItem,
|
||||||
@@ -139,10 +141,10 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new locking bytecode
|
// Generate the template identifier
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||||
|
|
||||||
|
// Generate the locking bytecode
|
||||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||||
templateId,
|
templateId,
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
|
|||||||
@@ -323,8 +323,6 @@ export function useActionWizard() {
|
|||||||
actionIdentifier,
|
actionIdentifier,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(xoInvitation)
|
|
||||||
|
|
||||||
// Wrap and track
|
// Wrap and track
|
||||||
const invitationInstance =
|
const invitationInstance =
|
||||||
await appService.createInvitation(xoInvitation);
|
await appService.createInvitation(xoInvitation);
|
||||||
@@ -333,6 +331,8 @@ export function useActionWizard() {
|
|||||||
const invId = inv.invitationIdentifier;
|
const invId = inv.invitationIdentifier;
|
||||||
setInvitationId(invId);
|
setInvitationId(invId);
|
||||||
|
|
||||||
|
setStatus('Adding variables...');
|
||||||
|
|
||||||
// Persist variable values
|
// Persist variable values
|
||||||
if (variables.length > 0) {
|
if (variables.length > 0) {
|
||||||
const variableData = variables.map((v) => {
|
const variableData = variables.map((v) => {
|
||||||
@@ -359,14 +359,24 @@ export function useActionWizard() {
|
|||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
setStatus('Adding required outputs...');
|
setStatus('Adding required outputs...');
|
||||||
|
|
||||||
const outputsToAdd = transaction.outputs.map(
|
const outputsToAdd = await Promise.all(transaction.outputs.map(
|
||||||
(output: XOTemplateTransactionOutput) => ({
|
async (output: XOTemplateTransactionOutput) => ({
|
||||||
outputIdentifier: output.output,
|
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
|
||||||
roleIdentifier: roleIdentifier,
|
outputIdentifier: output as unknown as string,
|
||||||
})
|
// roleIdentifier: roleIdentifier,
|
||||||
);
|
|
||||||
|
// TODO: This feels like an odd requirement? Shouldnt this be handled in the engine?
|
||||||
|
lockingBytecode: await invitationInstance.generateLockingBytecode(output as unknown as string, roleIdentifier),
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOuputs accept a hex string. 3. Have addOutputs handling the lockscript generation
|
||||||
|
await invitationInstance.addOutputs(outputsToAdd.map((output) => ({
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
// roleIdentifier: output.roleIdentifier,
|
||||||
|
lockingBytecode: new Uint8Array(Buffer.from(output.lockingBytecode, 'hex')),
|
||||||
|
})));
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputsToAdd);
|
|
||||||
inv = invitationInstance.data;
|
inv = invitationInstance.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ export * from './action-wizard/index.js';
|
|||||||
export { SeedInputScreen } from './SeedInput.js';
|
export { SeedInputScreen } from './SeedInput.js';
|
||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { InvitationScreen } from './Invitation.js';
|
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
export { TransactionScreen } from './Transaction.js';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - Import invitation by ID with role selection
|
* - Import invitation by ID with multi-step import flow
|
||||||
* - View active invitations with detailed information
|
* - View active invitations with detailed information
|
||||||
* - Monitor invitation updates via SSE
|
* - Monitor invitation updates via SSE
|
||||||
* - Fill missing requirements
|
* - Fill missing requirements
|
||||||
@@ -11,17 +11,16 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } 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 { 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, 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 type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
// Import utility functions
|
|
||||||
import {
|
import {
|
||||||
getInvitationState,
|
getInvitationState,
|
||||||
getStateColorName,
|
getStateColorName,
|
||||||
@@ -31,7 +30,9 @@ import {
|
|||||||
getUserRole,
|
getUserRole,
|
||||||
formatInvitationListItem,
|
formatInvitationListItem,
|
||||||
formatInvitationId,
|
formatInvitationId,
|
||||||
} from '../../utils/invitation-utils.js';
|
} from '../../../utils/invitation-utils.js';
|
||||||
|
|
||||||
|
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map state color name to theme color.
|
* Map state color name to theme color.
|
||||||
@@ -84,38 +85,30 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const { navigate, data: navData } = useNavigation();
|
const { navigate, data: navData } = useNavigation();
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// Use hooks for reactive invitation list
|
|
||||||
const invitations = useInvitations();
|
const invitations = useInvitations();
|
||||||
|
|
||||||
// State
|
// ── UI 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' | 'actions'>('list');
|
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
// ── Import state ─────────────────────────────────────────────────────────
|
||||||
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||||
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
const [importingId, setImportingId] = useState<string | null>(null);
|
||||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
||||||
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
|
||||||
|
|
||||||
// Template cache for displaying invitation list with template names
|
// ── Template cache ───────────────────────────────────────────────────────
|
||||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||||
|
|
||||||
// Selected invitation template for details view
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
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;
|
||||||
|
|
||||||
/**
|
|
||||||
* Show import dialog on mount if needed.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialMode === 'import') {
|
if (initialMode === 'import') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
}, [initialMode]);
|
}, [initialMode]);
|
||||||
|
|
||||||
@@ -124,7 +117,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!appService) return;
|
if (!appService) return;
|
||||||
|
|
||||||
invitations.forEach(inv => {
|
invitations.forEach(inv => {
|
||||||
const templateId = inv.data.templateIdentifier;
|
const templateId = inv.data.templateIdentifier;
|
||||||
if (!templateCache.has(templateId)) {
|
if (!templateCache.has(templateId)) {
|
||||||
@@ -139,10 +132,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build list items for ScrollableList.
|
* Build list items for ScrollableList.
|
||||||
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
|
||||||
*/
|
*/
|
||||||
const listItems = useMemo((): InvitationListItem[] => {
|
const listItems = useMemo((): InvitationListItem[] => {
|
||||||
// Import action at top
|
|
||||||
const importItem: InvitationListItem = {
|
const importItem: InvitationListItem = {
|
||||||
key: 'import',
|
key: 'import',
|
||||||
label: '+ Import Invitation',
|
label: '+ Import Invitation',
|
||||||
@@ -151,26 +142,23 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
color: 'info',
|
color: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map invitations to list items
|
|
||||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||||
const template = templateCache.get(inv.data.templateIdentifier);
|
const template = templateCache.get(inv.data.templateIdentifier);
|
||||||
const formatted = formatInvitationListItem(inv, template);
|
const formatted = formatInvitationListItem(inv, template);
|
||||||
const state = getInvitationState(inv);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: inv.data.invitationIdentifier,
|
key: inv.data.invitationIdentifier,
|
||||||
label: formatted.label,
|
label: formatted.label,
|
||||||
value: inv,
|
value: inv,
|
||||||
group: 'invitations',
|
group: 'invitations',
|
||||||
color: formatted.statusColor,
|
color: formatted.statusColor,
|
||||||
hidden: !formatted.isValid, // Hide invalid items
|
hidden: !formatted.isValid,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [importItem, ...invitationItems];
|
return [importItem, ...invitationItems];
|
||||||
}, [invitations, templateCache]);
|
}, [invitations, templateCache]);
|
||||||
|
|
||||||
// Get selected invitation from list items
|
|
||||||
const selectedItem = listItems[selectedIndex];
|
const selectedItem = listItems[selectedIndex];
|
||||||
const selectedInvitation = selectedItem?.value ?? null;
|
const selectedInvitation = selectedItem?.value ?? null;
|
||||||
|
|
||||||
@@ -182,116 +170,34 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setSelectedTemplate(null);
|
setSelectedTemplate(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
appService.engine.getTemplate(selectedInvitation.data.templateIdentifier)
|
||||||
.then(template => setSelectedTemplate(template ?? null));
|
.then(template => setSelectedTemplate(template ?? null));
|
||||||
}, [selectedInvitation, appService]);
|
}, [selectedInvitation, appService]);
|
||||||
|
|
||||||
|
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 1: Import invitation by ID (fetches invitation and moves to role selection).
|
* ID dialog submitted — transition to the multi-step import flow.
|
||||||
*/
|
*/
|
||||||
const handleImportIdSubmit = useCallback(async (invitationId: string) => {
|
const handleImportIdSubmit = useCallback((invitationId: string) => {
|
||||||
if (!invitationId.trim() || !appService) {
|
if (!invitationId.trim()) {
|
||||||
setImportStage(null);
|
setShowIdDialog(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowIdDialog(false);
|
||||||
console.log('Importing invitation:', invitationId);
|
setImportingId(invitationId.trim());
|
||||||
|
}, []);
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Fetching invitation...');
|
|
||||||
|
|
||||||
// Create invitation instance (will fetch from sync server)
|
|
||||||
const invitation = await appService.createInvitation(invitationId);
|
|
||||||
|
|
||||||
console.log(invitation);
|
|
||||||
|
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
|
||||||
console.log(missingRequirements);
|
|
||||||
|
|
||||||
// Get available roles for this invitation
|
|
||||||
const roles = await invitation.getAvailableRoles();
|
|
||||||
|
|
||||||
console.log(roles);
|
|
||||||
|
|
||||||
// Get the template for display
|
|
||||||
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
|
||||||
|
|
||||||
// Store for next stage
|
|
||||||
setImportingInvitation(invitation);
|
|
||||||
setAvailableRoles(roles);
|
|
||||||
setSelectedRoleIndex(0);
|
|
||||||
setImportTemplate(template ?? null);
|
|
||||||
|
|
||||||
// Move to role selection stage
|
|
||||||
setImportStage('role-select');
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
setImportStage(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [appService, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 2: Accept invitation with selected role.
|
* Import flow closed (completed or cancelled).
|
||||||
*/
|
*/
|
||||||
const handleRoleSelect = useCallback(async () => {
|
const handleImportFlowClose = useCallback(() => {
|
||||||
if (!importingInvitation || !appService) return;
|
setImportingId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedRole = availableRoles[selectedRoleIndex];
|
// ── Action handlers ────────────────────────────────────────────────────
|
||||||
if (!selectedRole) {
|
|
||||||
showError('No role selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus(`Accepting as ${selectedRole}...`);
|
|
||||||
|
|
||||||
// TODO: Engine doesnt support "accepting" without supplying some kind of data along with it.
|
|
||||||
// We also dont have a way to say "this action will require inputs, so i will do that."
|
|
||||||
// If it did, we could add an "input" with the role identifier.
|
|
||||||
// For now, we are just going to hard-code the input with the role identifier.
|
|
||||||
await importingInvitation.addInputs([{
|
|
||||||
roleIdentifier: selectedRole,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
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) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -307,7 +213,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
// Check if already accepted
|
|
||||||
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
||||||
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||||
} else {
|
} else {
|
||||||
@@ -318,9 +223,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign selected invitation.
|
|
||||||
*/
|
|
||||||
const signInvitation = useCallback(async () => {
|
const signInvitation = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -341,9 +243,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy invitation ID.
|
|
||||||
*/
|
|
||||||
const copyId = useCallback(async () => {
|
const copyId = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -358,9 +257,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError]);
|
}, [selectedInvitation, showInfo, showError]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill requirements for selected invitation.
|
|
||||||
*/
|
|
||||||
const fillRequirements = useCallback(async () => {
|
const fillRequirements = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -369,20 +265,17 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Step 1: Check available roles
|
|
||||||
setStatus('Checking available roles...');
|
setStatus('Checking available roles...');
|
||||||
const roles = await selectedInvitation.getAvailableRoles();
|
const roles = await selectedInvitation.getAvailableRoles();
|
||||||
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
// Already participating, check if we can add inputs
|
|
||||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||||
} else {
|
} else {
|
||||||
// Need to accept a role first
|
|
||||||
const roleToTake = roles[0];
|
const roleToTake = roles[0];
|
||||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||||
setStatus(`Accepting as ${roleToTake}...`);
|
setStatus(`Accepting as ${roleToTake}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await selectedInvitation.accept();
|
await selectedInvitation.accept();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -392,10 +285,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Check if invitation already has inputs or needs funding
|
|
||||||
setStatus('Analyzing invitation...');
|
setStatus('Analyzing invitation...');
|
||||||
|
|
||||||
// Calculate how much we need
|
|
||||||
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) {
|
||||||
@@ -413,21 +304,19 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const dust = 546n;
|
const dust = 546n;
|
||||||
const totalNeeded = requiredAmount + fee + dust;
|
const totalNeeded = requiredAmount + fee + dust;
|
||||||
|
|
||||||
// Find resources
|
|
||||||
const utxos = await selectedInvitation.findSuitableResources({
|
const utxos = await selectedInvitation.findSuitableResources({
|
||||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||||
outputIdentifier: 'receiveOutput',
|
outputIdentifier: 'receiveOutput',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (utxos.length === 0) {
|
if (utxos.length === 0) {
|
||||||
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
showError('No suitable UTXOs found. Make sure your wallet has funds.');
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select UTXOs
|
|
||||||
setStatus('Selecting UTXOs...');
|
setStatus('Selecting UTXOs...');
|
||||||
|
|
||||||
const selectedUtxos: Array<{
|
const selectedUtxos: Array<{
|
||||||
outpointTransactionHash: string;
|
outpointTransactionHash: string;
|
||||||
outpointIndex: number;
|
outpointIndex: number;
|
||||||
@@ -443,12 +332,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
|
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||||
continue;
|
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
|
||||||
}
|
|
||||||
if (lockingBytecodeHex) {
|
|
||||||
seenLockingBytecodes.add(lockingBytecodeHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedUtxos.push({
|
selectedUtxos.push({
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
@@ -457,9 +342,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
accumulated += BigInt(utxo.valueSatoshis);
|
accumulated += BigInt(utxo.valueSatoshis);
|
||||||
|
|
||||||
if (accumulated >= totalNeeded) {
|
if (accumulated >= totalNeeded) break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accumulated < totalNeeded) {
|
if (accumulated < totalNeeded) {
|
||||||
@@ -470,7 +353,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const changeAmount = accumulated - requiredAmount - fee;
|
const changeAmount = accumulated - requiredAmount - fee;
|
||||||
|
|
||||||
// Add inputs
|
|
||||||
setStatus('Adding inputs...');
|
setStatus('Adding inputs...');
|
||||||
await selectedInvitation.addInputs(
|
await selectedInvitation.addInputs(
|
||||||
selectedUtxos.map(u => ({
|
selectedUtxos.map(u => ({
|
||||||
@@ -479,7 +361,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add change output
|
|
||||||
if (changeAmount >= dust) {
|
if (changeAmount >= dust) {
|
||||||
setStatus('Adding change output...');
|
setStatus('Adding change output...');
|
||||||
await selectedInvitation.addOutputs([{
|
await selectedInvitation.addOutputs([{
|
||||||
@@ -487,7 +368,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success
|
|
||||||
showInfo(
|
showInfo(
|
||||||
`Requirements filled!\n\n` +
|
`Requirements filled!\n\n` +
|
||||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||||
@@ -498,7 +378,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
`Now use "Sign Transaction" to complete.`
|
`Now use "Sign Transaction" to complete.`
|
||||||
);
|
);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
@@ -507,9 +386,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle action selection.
|
|
||||||
*/
|
|
||||||
const handleAction = useCallback((action: string) => {
|
const handleAction = useCallback((action: string) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'copy':
|
case 'copy':
|
||||||
@@ -532,70 +408,44 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||||
|
|
||||||
/**
|
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||||
* Handle list item activation.
|
|
||||||
*/
|
|
||||||
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
// For invitation items, we just select them - actions are in the actions panel
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
|
||||||
* Handle action item activation.
|
|
||||||
*/
|
|
||||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
|
||||||
if (item.value) {
|
if (item.value) {
|
||||||
handleAction(item.value);
|
handleAction(item.value);
|
||||||
}
|
}
|
||||||
}, [handleAction]);
|
}, [handleAction]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// ── Keyboard navigation ──────────────────────────────────────────────────
|
||||||
|
// Disabled when the ID dialog or import flow is open.
|
||||||
|
const isOverlayOpen = showIdDialog || importingId !== null;
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
// Handle role selection dialog navigation
|
|
||||||
if (importStage === 'role-select') {
|
|
||||||
if (key.upArrow || input === 'k') {
|
|
||||||
setSelectedRoleIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
|
||||||
} else if (key.return) {
|
|
||||||
handleRoleSelect();
|
|
||||||
} else if (key.escape) {
|
|
||||||
handleImportCancel();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't handle input while ID input dialog is open
|
|
||||||
if (importStage === 'id') return;
|
|
||||||
|
|
||||||
// Tab to switch panels (list -> actions -> list)
|
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'c' to copy
|
|
||||||
if (input === 'c' && selectedInvitation) {
|
if (input === 'c' && selectedInvitation) {
|
||||||
copyId();
|
copyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'i' to import
|
|
||||||
if (input === 'i') {
|
if (input === 'i') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
}, { isActive: importStage !== 'id' });
|
}, { isActive: !isOverlayOpen });
|
||||||
|
|
||||||
|
// ── Render helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Render custom list item for invitation list.
|
|
||||||
*/
|
|
||||||
const renderInvitationListItem = useCallback((
|
const renderInvitationListItem = useCallback((
|
||||||
item: InvitationListItem,
|
item: InvitationListItem,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
// Import item
|
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
@@ -608,7 +458,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invitation item
|
|
||||||
const inv = item.value;
|
const inv = item.value;
|
||||||
if (!inv) return null;
|
if (!inv) return null;
|
||||||
|
|
||||||
@@ -628,9 +477,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}, [templateCache]);
|
}, [templateCache]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Render detailed invitation information.
|
|
||||||
*/
|
|
||||||
const renderDetails = () => {
|
const renderDetails = () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||||
@@ -641,8 +487,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const inputs = getInvitationInputs(selectedInvitation);
|
const inputs = getInvitationInputs(selectedInvitation);
|
||||||
const outputs = getInvitationOutputs(selectedInvitation);
|
const outputs = getInvitationOutputs(selectedInvitation);
|
||||||
const variables = getInvitationVariables(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 userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||||
@@ -650,7 +495,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Row 1: Type, Description, Status */}
|
{/* Type & Status */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Box width="50%">
|
<Box width="50%">
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
@@ -675,7 +520,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Row 2: Your Role */}
|
{/* Your Role */}
|
||||||
{userRole && (
|
{userRole && (
|
||||||
<Box marginBottom={1} flexDirection="column">
|
<Box marginBottom={1} flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Your Role: </Text>
|
<Text color={colors.primary} bold>Your Role: </Text>
|
||||||
@@ -686,9 +531,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 3: Inputs & Outputs side by side */}
|
{/* Inputs & Outputs */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
{/* Inputs */}
|
|
||||||
<Box width="50%" flexDirection="column">
|
<Box width="50%" flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
{inputs.length === 0 ? (
|
{inputs.length === 0 ? (
|
||||||
@@ -698,8 +542,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const isUserInput = input.entityIdentifier === userEntityId;
|
const isUserInput = input.entityIdentifier === userEntityId;
|
||||||
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
const inputTemplate = selectedTemplate?.inputs?.[input.inputIdentifier ?? ''];
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`input-${idx}`}
|
key={`input-${idx}`}
|
||||||
color={isUserInput ? colors.success : colors.text}
|
color={isUserInput ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserInput ? '• ' : '○ '}
|
{' '}{isUserInput ? '• ' : '○ '}
|
||||||
@@ -711,7 +555,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Outputs */}
|
|
||||||
<Box width="50%" flexDirection="column">
|
<Box width="50%" flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
{outputs.length === 0 ? (
|
{outputs.length === 0 ? (
|
||||||
@@ -721,8 +564,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const isUserOutput = output.entityIdentifier === userEntityId;
|
const isUserOutput = output.entityIdentifier === userEntityId;
|
||||||
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
const outputTemplate = selectedTemplate?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`output-${idx}`}
|
key={`output-${idx}`}
|
||||||
color={isUserOutput ? colors.success : colors.text}
|
color={isUserOutput ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserOutput ? '• ' : '○ '}
|
{' '}{isUserOutput ? '• ' : '○ '}
|
||||||
@@ -735,7 +578,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Row 4: Variables */}
|
{/* Variables */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
{variables.length === 0 ? (
|
{variables.length === 0 ? (
|
||||||
@@ -744,12 +587,12 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
variables.map((variable, idx) => {
|
variables.map((variable, idx) => {
|
||||||
const isUserVariable = variable.entityIdentifier === userEntityId;
|
const isUserVariable = variable.entityIdentifier === userEntityId;
|
||||||
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
const varTemplate = selectedTemplate?.variables?.[variable.variableIdentifier];
|
||||||
const displayValue = typeof variable.value === 'bigint'
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
? variable.value.toString()
|
? variable.value.toString()
|
||||||
: String(variable.value);
|
: String(variable.value);
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={`var-${idx}`}
|
key={`var-${idx}`}
|
||||||
color={isUserVariable ? colors.success : colors.text}
|
color={isUserVariable ? colors.success : colors.text}
|
||||||
>
|
>
|
||||||
{' '}{isUserVariable ? '• ' : '○ '}
|
{' '}{isUserVariable ? '• ' : '○ '}
|
||||||
@@ -763,7 +606,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Shortcuts */}
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -771,84 +613,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ── Main render ──────────────────────────────────────────────────────────
|
||||||
* 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="black"
|
|
||||||
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}>
|
||||||
@@ -857,7 +622,7 @@ 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 - Top row: List + Actions */}
|
{/* Top row: List + Actions */}
|
||||||
<Box flexDirection="row" marginTop={1} height={12}>
|
<Box flexDirection="row" marginTop={1} height={12}>
|
||||||
{/* Left column: Invitation list */}
|
{/* Left column: Invitation list */}
|
||||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||||
@@ -874,7 +639,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onSelect={setSelectedIndex}
|
onSelect={setSelectedIndex}
|
||||||
onActivate={handleListItemActivate}
|
onActivate={handleListItemActivate}
|
||||||
focus={focusedPanel === 'list'}
|
focus={focusedPanel === 'list' && !isOverlayOpen}
|
||||||
maxVisible={6}
|
maxVisible={6}
|
||||||
groups={invitationListGroups}
|
groups={invitationListGroups}
|
||||||
emptyMessage="No invitations yet"
|
emptyMessage="No invitations yet"
|
||||||
@@ -898,14 +663,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedActionIndex}
|
selectedIndex={selectedActionIndex}
|
||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionItemActivate}
|
onActivate={handleActionItemActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions' && !isOverlayOpen}
|
||||||
emptyMessage="No actions"
|
emptyMessage="No actions"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Bottom row: Details (full width) */}
|
{/* Bottom row: Details */}
|
||||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
@@ -928,8 +693,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Import ID dialog (Stage 1) */}
|
{/* Import ID dialog */}
|
||||||
{importStage === 'id' && (
|
{showIdDialog && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -943,14 +708,24 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
prompt="Enter Invitation ID:"
|
prompt="Enter Invitation ID:"
|
||||||
placeholder="Paste invitation ID..."
|
placeholder="Paste invitation ID..."
|
||||||
onSubmit={handleImportIdSubmit}
|
onSubmit={handleImportIdSubmit}
|
||||||
onCancel={() => setImportStage(null)}
|
onCancel={() => setShowIdDialog(false)}
|
||||||
isActive={true}
|
isActive={true}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Role Selection dialog (Stage 2) */}
|
{/* Multi-step import flow */}
|
||||||
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
{importingId && appService && (
|
||||||
|
<InvitationImportFlow
|
||||||
|
invitationId={importingId}
|
||||||
|
mode="dialog"
|
||||||
|
appService={appService}
|
||||||
|
onClose={handleImportFlowClose}
|
||||||
|
showError={showError}
|
||||||
|
showInfo={showInfo}
|
||||||
|
setStatus={setStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* InvitationImportFlow — orchestrates the multi-step invitation import.
|
||||||
|
*
|
||||||
|
* Manages the step state machine, accumulates data from each step, and
|
||||||
|
* injects it into the next step via props (dependency injection).
|
||||||
|
*
|
||||||
|
* Supports two display modes:
|
||||||
|
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
|
||||||
|
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors, logoSmall } from '../../../theme.js';
|
||||||
|
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
||||||
|
|
||||||
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
|
||||||
|
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
||||||
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
|
import { InvitationBuilder } from '@xo-cash/engine';
|
||||||
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function InvitationImportFlow({
|
||||||
|
invitationId,
|
||||||
|
mode,
|
||||||
|
appService,
|
||||||
|
onClose,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
setStatus,
|
||||||
|
}: ImportFlowProps): React.ReactElement {
|
||||||
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
|
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
||||||
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
|
||||||
|
// ── Cancel handler ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Cleans up (removes the invitation if it was fetched) and signals the parent.
|
||||||
|
*/
|
||||||
|
const handleCancel = useCallback(async () => {
|
||||||
|
if (invitation && appService) {
|
||||||
|
try {
|
||||||
|
await appService.removeInvitation(invitation);
|
||||||
|
} catch {
|
||||||
|
// Best-effort removal — don't block close on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [invitation, appService, onClose]);
|
||||||
|
|
||||||
|
// ── Step completion callbacks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FetchStep completed — invitation and template are now available.
|
||||||
|
* Also pre-fetches available roles for the next steps.
|
||||||
|
*/
|
||||||
|
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
|
||||||
|
setInvitation(inv);
|
||||||
|
setTemplate(tmpl);
|
||||||
|
|
||||||
|
const builder = InvitationBuilder.fromInvitation(inv.data);
|
||||||
|
setBuildableInvitation(builder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roles = await inv.getAvailableRoles();
|
||||||
|
setAvailableRoles(roles);
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(1); // → Preview
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
|
const handlePreviewComplete = useCallback(() => {
|
||||||
|
setCurrentStep(2); // → Role Select
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setCurrentStep(3); // → Inputs Select
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
|
setSelectedInputs(inputs);
|
||||||
|
|
||||||
|
await invitation?.addInputs(inputs.map(input => ({
|
||||||
|
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||||
|
outpointIndex: input.outpointIndex,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
// Determine required amount from invitation variables
|
||||||
|
const requiredSats = await invitation?.getSatsOut() ?? 0n;
|
||||||
|
setRequiredAmount(requiredSats);
|
||||||
|
|
||||||
|
// Set the change amount for the review step
|
||||||
|
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
|
||||||
|
setChangeAmount(changeAmountSats);
|
||||||
|
|
||||||
|
console.log('totalSelected:', totalSelected);
|
||||||
|
console.log('requiredAmount:', requiredSats);
|
||||||
|
console.log('DEFAULT_FEE:', DEFAULT_FEE);
|
||||||
|
console.log('changeAmount:', changeAmount);
|
||||||
|
|
||||||
|
// Add the change output if it exceeds the dust threshold
|
||||||
|
if (changeAmountSats >= DUST_THRESHOLD) {
|
||||||
|
await invitation?.addOutputs([{
|
||||||
|
valueSatoshis: changeAmountSats,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(4); // → Review
|
||||||
|
}, [invitation, buildableInvitation, selectedInputs]);
|
||||||
|
|
||||||
|
/** ReviewStep completed — invitation import is done. */
|
||||||
|
const handleReviewComplete = useCallback(() => {
|
||||||
|
const roleName = (() => {
|
||||||
|
if (!selectedRole || !template) return selectedRole ?? '';
|
||||||
|
const raw = template.roles?.[selectedRole];
|
||||||
|
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
|
||||||
|
})();
|
||||||
|
|
||||||
|
showInfo(
|
||||||
|
`Invitation imported and accepted!\n\n` +
|
||||||
|
`Role: ${roleName}\n` +
|
||||||
|
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
|
||||||
|
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||||
|
);
|
||||||
|
setStatus('Ready');
|
||||||
|
onClose();
|
||||||
|
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||||
|
|
||||||
|
// ── Keyboard handling for FetchStep error retry ──────────────────────────
|
||||||
|
// FetchStep auto-advances on success but shows error state with retry on failure.
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (currentStep !== 0) return;
|
||||||
|
// Enter retries, Esc cancels — handled within FetchStep rendering,
|
||||||
|
// but we also catch Esc here for safety.
|
||||||
|
if (key.escape) handleCancel();
|
||||||
|
}, { isActive: currentStep === 0 });
|
||||||
|
|
||||||
|
// ── Step router ──────────────────────────────────────────────────────────
|
||||||
|
const renderStep = (): React.ReactNode => {
|
||||||
|
const stepDef = IMPORT_STEPS[currentStep];
|
||||||
|
if (!stepDef) return null;
|
||||||
|
|
||||||
|
switch (stepDef.type) {
|
||||||
|
case 'fetch':
|
||||||
|
return (
|
||||||
|
<FetchInvitationStep
|
||||||
|
invitationId={invitationId}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleFetchComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'preview':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<PreviewInvitationStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
onComplete={handlePreviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'role-select':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<RoleSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
availableRoles={availableRoles}
|
||||||
|
onComplete={handleRoleComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'inputs-select':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<InputsSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleInputsComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'review':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
selectedInputs={selectedInputs}
|
||||||
|
changeAmount={changeAmount}
|
||||||
|
requiredAmount={requiredAmount}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleReviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step indicator data ──────────────────────────────────────────────────
|
||||||
|
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
|
||||||
|
|
||||||
|
// ── Layout: dialog mode ──────────────────────────────────────────────────
|
||||||
|
if (mode === 'dialog') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
|
||||||
|
{/* Step indicator (compact) */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout: screen mode ──────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{template?.name ?? 'Loading...'}
|
||||||
|
{selectedRole ? ` (as ${selectedRole})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<Box marginTop={1} paddingX={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<Box marginTop={1} marginX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Esc: Cancel import
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* FetchInvitationStep — first step in the import flow.
|
||||||
|
*
|
||||||
|
* Receives an invitation ID, fetches the invitation from the sync server,
|
||||||
|
* resolves its template, and auto-advances once loaded.
|
||||||
|
* Shows a loading spinner while fetching and an error state with retry/cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import type { FetchStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function FetchInvitationStep({
|
||||||
|
invitationId,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: FetchStepProps): React.ReactElement {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the invitation and its template, then auto-advance.
|
||||||
|
*/
|
||||||
|
const fetchInvitation = useCallback(async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create/fetch the invitation instance (fetches from sync server if needed)
|
||||||
|
const invitation = await appService.createInvitation(invitationId);
|
||||||
|
|
||||||
|
// Resolve the template for display in later steps
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
|
||||||
|
// Auto-advance — hand the loaded data to the flow controller
|
||||||
|
onComplete(invitation, template ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, onComplete]);
|
||||||
|
|
||||||
|
// Kick off the fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
fetchInvitation();
|
||||||
|
}
|
||||||
|
}, [isActive, fetchInvitation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Fetching invitation...</Text>
|
||||||
|
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to fetch invitation</Text>
|
||||||
|
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
|
||||||
|
*
|
||||||
|
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
|
||||||
|
* Auto-selects greedily, then lets the user toggle individual UTXOs.
|
||||||
|
* Shows required, selected, and change amounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function InputsSelectStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
selectedRole,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: InputsSelectStepProps): React.ReactElement {
|
||||||
|
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
|
// Derived totals
|
||||||
|
const selectedAmount = utxos
|
||||||
|
.filter(u => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
|
const hasEnough = selectedAmount >= requiredAmount + fee;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the required satoshi amount from the invitation's variables.
|
||||||
|
*/
|
||||||
|
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
|
||||||
|
return await invitation.getSatsOut() ?? 0n;
|
||||||
|
}, [invitation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch suitable UTXOs from the engine and auto-select greedily.
|
||||||
|
*/
|
||||||
|
const loadUtxos = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const required = await computeRequiredAmount();
|
||||||
|
setRequiredAmount(required);
|
||||||
|
|
||||||
|
const unspentOutputs = await invitation.findSuitableResources({
|
||||||
|
templateIdentifier: invitation.data.templateIdentifier,
|
||||||
|
outputIdentifier: 'receiveOutput',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map to selectable UTXOs
|
||||||
|
const selectable: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === 'string'
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Greedy auto-select, skipping duplicate locking bytecodes
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of selectable) {
|
||||||
|
if (utxo.lockingBytecode && seenBytecodes.has(utxo.lockingBytecode)) continue;
|
||||||
|
if (utxo.lockingBytecode) seenBytecodes.add(utxo.lockingBytecode);
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= required + fee) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUtxos(selectable);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [invitation, computeRequiredAmount, fee]);
|
||||||
|
|
||||||
|
// Load UTXOs on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) loadUtxos();
|
||||||
|
}, [isActive, loadUtxos]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the selection of a UTXO at the given index.
|
||||||
|
*/
|
||||||
|
const toggleSelection = useCallback((index: number) => {
|
||||||
|
setUtxos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard handling
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||||
|
} else if (input === ' ' || (key.return && utxos.length > 0)) {
|
||||||
|
// Space or Enter toggles the focused UTXO
|
||||||
|
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||||
|
} else if (input === 'a') {
|
||||||
|
// Select all
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||||
|
} else if (input === 'n') {
|
||||||
|
// Deselect all
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||||
|
} else if (key.tab) {
|
||||||
|
// Tab confirms selection (moves to next step)
|
||||||
|
if (hasEnough) {
|
||||||
|
onComplete(utxos.filter(u => u.selected));
|
||||||
|
}
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Finding suitable UTXOs...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to load UTXOs</Text>
|
||||||
|
<Text color={colors.textMuted}>{error}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No UTXOs found
|
||||||
|
if (utxos.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Required: </Text>
|
||||||
|
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
|
||||||
|
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Selected: </Text>
|
||||||
|
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
|
||||||
|
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
||||||
|
)}
|
||||||
|
{!hasEnough && (
|
||||||
|
<Text color={colors.error}> — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* UTXO list */}
|
||||||
|
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
|
||||||
|
{utxos.map((utxo, index) => {
|
||||||
|
const isFocused = index === focusedIndex;
|
||||||
|
const checkMark = utxo.selected ? '☑' : '☐';
|
||||||
|
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
↑↓: Navigate • Space: Toggle • a: All • n: None • Tab: Confirm • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* PreviewInvitationStep — displays the current state of a fetched invitation.
|
||||||
|
*
|
||||||
|
* Shows which roles, inputs, outputs, and variables have already been filled
|
||||||
|
* so the user can understand what they're joining before proceeding.
|
||||||
|
* Press Enter to continue, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import {
|
||||||
|
getInvitationState,
|
||||||
|
getStateColorName,
|
||||||
|
getInvitationInputs,
|
||||||
|
getInvitationOutputs,
|
||||||
|
getInvitationVariables,
|
||||||
|
} from '../../../../../utils/invitation-utils.js';
|
||||||
|
import type { PreviewStepProps } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a semantic color name to an actual theme color value.
|
||||||
|
*/
|
||||||
|
function stateColor(state: string): string {
|
||||||
|
const name = getStateColorName(state);
|
||||||
|
switch (name) {
|
||||||
|
case 'info': return colors.info as string;
|
||||||
|
case 'warning': return colors.warning as string;
|
||||||
|
case 'success': return colors.success as string;
|
||||||
|
case 'error': return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
|
default: return colors.textMuted as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewInvitationStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: PreviewStepProps): React.ReactElement {
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
if (key.return) onComplete();
|
||||||
|
if (key.escape) onCancel();
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const state = getInvitationState(invitation);
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
const inputs = getInvitationInputs(invitation);
|
||||||
|
const outputs = getInvitationOutputs(invitation);
|
||||||
|
const variables = getInvitationVariables(invitation);
|
||||||
|
|
||||||
|
// Collect role identifiers that appear across all commits
|
||||||
|
const filledRoles = new Set<string>();
|
||||||
|
for (const commit of invitation.data.commits ?? []) {
|
||||||
|
for (const input of commit.data?.inputs ?? []) {
|
||||||
|
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Template & action info */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Template: </Text>
|
||||||
|
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
{template?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{template.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Action: </Text>
|
||||||
|
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
{action?.description && (
|
||||||
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Status: </Text>
|
||||||
|
<Text color={stateColor(state)}>{state}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Roles already filled */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
|
||||||
|
{filledRoles.size === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
) : (
|
||||||
|
Array.from(filledRoles).map(role => {
|
||||||
|
const roleInfoRaw = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
return (
|
||||||
|
<Text key={role} color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Inputs & Outputs side by side */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
|
{inputs.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
) : (
|
||||||
|
inputs.map((input, idx) => {
|
||||||
|
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Text key={`input-${idx}`} color={colors.text}>
|
||||||
|
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box width="50%" flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
) : (
|
||||||
|
outputs.map((output, idx) => {
|
||||||
|
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Text key={`output-${idx}`} color={colors.text}>
|
||||||
|
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
|
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
|
{variables.length === 0 ? (
|
||||||
|
<Text color={colors.textMuted}> None set</Text>
|
||||||
|
) : (
|
||||||
|
variables.map((variable, idx) => {
|
||||||
|
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||||
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
|
? variable.value.toString()
|
||||||
|
: String(variable.value);
|
||||||
|
return (
|
||||||
|
<Text key={`var-${idx}`} color={colors.text}>
|
||||||
|
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Enter: Continue • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* ReviewStep — final step that summarizes the import and executes it.
|
||||||
|
*
|
||||||
|
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
|
||||||
|
* 1. Adds inputs (with the selected role identifier) to the invitation.
|
||||||
|
* 2. Optionally adds a change output if the change exceeds the dust threshold.
|
||||||
|
* 3. Calls `onComplete()` to signal the flow is finished.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function ReviewStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
selectedRole,
|
||||||
|
selectedInputs,
|
||||||
|
requiredAmount,
|
||||||
|
changeAmount,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: ReviewStepProps): React.ReactElement {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the import: add inputs (with role) and optional change output.
|
||||||
|
*/
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [invitation, selectedRole, selectedInputs, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard handling
|
||||||
|
useInput((_input, key) => {
|
||||||
|
if (!isActive || isSubmitting) return;
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
submit();
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Resolve role display name
|
||||||
|
const roleInfoRaw = template?.roles?.[selectedRole];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Review Import</Text>
|
||||||
|
|
||||||
|
{/* Template & action */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Funding summary */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Funding:</Text>
|
||||||
|
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||||
|
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}</Text>
|
||||||
|
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
||||||
|
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</Text>
|
||||||
|
{changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error} bold>Error: {error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status / hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Text color={colors.info}>Submitting...</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.textMuted}>Enter: Confirm & Import • Esc: Cancel</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* RoleSelectStep — lets the user choose which role to take in the invitation.
|
||||||
|
*
|
||||||
|
* Displays available roles with their template-level and action-level descriptions.
|
||||||
|
* Arrow keys to navigate, Enter to select, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import type { RoleSelectStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function RoleSelectStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
availableRoles,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: RoleSelectStepProps): React.ReactElement {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (!isActive) return;
|
||||||
|
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
const role = availableRoles[selectedIndex];
|
||||||
|
if (role) onComplete(role);
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Context header */}
|
||||||
|
<Box marginBottom={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Role list */}
|
||||||
|
<Box 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 = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
const actionRoleRaw = action?.roles?.[role];
|
||||||
|
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||||
|
const isFocused = index === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={role} flexDirection="column">
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{roleInfo?.name ?? role}
|
||||||
|
</Text>
|
||||||
|
{(roleInfo?.description || actionRole?.description) && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/tui/screens/invitations/invitation-import/types.ts
Normal file
122
src/tui/screens/invitations/invitation-import/types.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the invitation import flow.
|
||||||
|
*
|
||||||
|
* Each step in the flow receives only what it needs via props (dependency injection).
|
||||||
|
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
|
import type { AppService } from '../../../../services/app.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
|
// ── Step definitions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Identifies each step in the import flow. */
|
||||||
|
export type ImportStepType = 'fetch' | 'preview' | 'role-select' | 'inputs-select' | 'review';
|
||||||
|
|
||||||
|
/** A single step descriptor used by the flow controller and step indicator. */
|
||||||
|
export interface ImportStep {
|
||||||
|
name: string;
|
||||||
|
type: ImportStepType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ordered list of steps in the import flow. */
|
||||||
|
export const IMPORT_STEPS: ImportStep[] = [
|
||||||
|
{ name: 'Fetch', type: 'fetch' },
|
||||||
|
{ name: 'Preview', type: 'preview' },
|
||||||
|
{ name: 'Select Role', type: 'role-select' },
|
||||||
|
{ name: 'Select Inputs', type: 'inputs-select' },
|
||||||
|
{ name: 'Review', type: 'review' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Display mode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
|
||||||
|
export type ImportFlowMode = 'dialog' | 'screen';
|
||||||
|
|
||||||
|
// ── UTXO selection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A UTXO that the user can toggle on/off during the inputs step. */
|
||||||
|
export interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step props ───────────────────────────────────────────────────────────────
|
||||||
|
// Each step receives exactly the data and callbacks it needs.
|
||||||
|
|
||||||
|
/** Props for FetchInvitationStep — loads the invitation from an ID. */
|
||||||
|
export interface FetchStepProps {
|
||||||
|
invitationId: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for PreviewInvitationStep — displays invitation state. */
|
||||||
|
export interface PreviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for RoleSelectStep — lets user pick a role. */
|
||||||
|
export interface RoleSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
availableRoles: string[];
|
||||||
|
onComplete: (selectedRole: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
|
export interface InputsSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (inputs: SelectableUTXO[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for ReviewStep — summarizes and executes the import. */
|
||||||
|
export interface ReviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
selectedInputs: SelectableUTXO[];
|
||||||
|
changeAmount: bigint;
|
||||||
|
requiredAmount: bigint;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flow controller props ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the top-level InvitationImportFlow component. */
|
||||||
|
export interface ImportFlowProps {
|
||||||
|
/** The invitation ID to import (already entered by the user in InvitationScreen). */
|
||||||
|
invitationId: string;
|
||||||
|
/** Whether to render as a dialog overlay or a full screen. */
|
||||||
|
mode: ImportFlowMode;
|
||||||
|
/** The application service — injected, not pulled from context. */
|
||||||
|
appService: AppService;
|
||||||
|
/** Called when the flow completes or is cancelled. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Display an error message to the user. */
|
||||||
|
showError: (message: string) => void;
|
||||||
|
/** Display an info message to the user. */
|
||||||
|
showInfo: (message: string) => void;
|
||||||
|
/** Update the global status bar. */
|
||||||
|
setStatus: (message: string) => void;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user