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",
|
||||
"build": "tsc",
|
||||
"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": [
|
||||
"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);
|
||||
|
||||
// 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(
|
||||
generateTemplateIdentifier(p2pkhTemplate),
|
||||
'receiveOutput',
|
||||
@@ -63,9 +65,10 @@ export class AppService extends EventEmitter<AppEventMap> {
|
||||
|
||||
// Create our own storage for the invitations
|
||||
const storage = await Storage.create(config.invitationStoragePath);
|
||||
const walletStorage = await storage.child(seedHash.slice(0, 8))
|
||||
|
||||
// Create the app service
|
||||
return new AppService(engine, storage, config);
|
||||
return new AppService(engine, walletStorage, config);
|
||||
}
|
||||
|
||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* - UTXOs we own (with descriptions derived from template outputs)
|
||||
*/
|
||||
|
||||
import type { Engine } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOTemplate } from '@xo-cash/types';
|
||||
import { type Engine, compileCashAssemblyString } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationVariableValue, XOTemplate } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
import type { Invitation } from './invitation.js';
|
||||
import { binToHex } from '@bitauth/libauth';
|
||||
@@ -203,7 +203,7 @@ export class HistoryService {
|
||||
const outputDef = template.outputs?.[utxo.outputIdentifier];
|
||||
|
||||
if (!outputDef) {
|
||||
return `${utxo.outputIdentifier} output`;
|
||||
return `[${template.name}] ${utxo.outputIdentifier} output`;
|
||||
}
|
||||
|
||||
// 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 (outputDef.description) {
|
||||
description = 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');
|
||||
description = compileCashAssemblyString(outputDef.description, {})
|
||||
}
|
||||
|
||||
return description;
|
||||
@@ -239,14 +235,13 @@ export class HistoryService {
|
||||
}
|
||||
|
||||
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>);
|
||||
|
||||
return transaction.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');
|
||||
const description = compileCashAssemblyString(transaction.description, formattedVariables);
|
||||
|
||||
return description;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable, XOInvitationVariableValue } from '@xo-cash/types';
|
||||
import type { UnspentOutputData } from '@xo-cash/state';
|
||||
|
||||
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 { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
||||
import { compileCashAssemblyString } from '@xo-cash/engine';
|
||||
|
||||
export type InvitationEventMap = {
|
||||
'invitation-updated': XOInvitation;
|
||||
@@ -32,14 +33,12 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
// Try to get the invitation from the storage
|
||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||
if (invitationFromStorage) {
|
||||
console.log(`Invitation found in storage: ${invitation}`);
|
||||
return this.create(invitationFromStorage, dependencies);
|
||||
}
|
||||
|
||||
// Try to get the invitation from the sync server
|
||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
||||
console.log(`Invitation found in sync server: ${invitation}`);
|
||||
return this.create(invitationFromSyncServer, dependencies);
|
||||
}
|
||||
|
||||
@@ -345,6 +344,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
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> {
|
||||
// Add the outputs to the invitation
|
||||
await this.append({ outputs });
|
||||
@@ -410,4 +417,89 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
||||
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 { TemplateListScreen } from './screens/TemplateList.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 { MessageDialog } from './components/Dialog.js';
|
||||
|
||||
@@ -24,7 +24,7 @@ interface DialogWrapperProps {
|
||||
}
|
||||
|
||||
|
||||
function DialogWrapper({
|
||||
export function DialogWrapper({
|
||||
title,
|
||||
borderColor = colors.primary,
|
||||
children,
|
||||
|
||||
@@ -15,6 +15,8 @@ import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||
import type { HistoryItem } from '../../services/history.js';
|
||||
|
||||
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
formatHistoryListItem,
|
||||
@@ -139,10 +141,10 @@ export function WalletStateScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a new locking bytecode
|
||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
||||
// Generate the template identifier
|
||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||
|
||||
// Generate the locking bytecode
|
||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
||||
templateId,
|
||||
'receiveOutput',
|
||||
|
||||
@@ -323,8 +323,6 @@ export function useActionWizard() {
|
||||
actionIdentifier,
|
||||
});
|
||||
|
||||
console.log(xoInvitation)
|
||||
|
||||
// Wrap and track
|
||||
const invitationInstance =
|
||||
await appService.createInvitation(xoInvitation);
|
||||
@@ -333,6 +331,8 @@ export function useActionWizard() {
|
||||
const invId = inv.invitationIdentifier;
|
||||
setInvitationId(invId);
|
||||
|
||||
setStatus('Adding variables...');
|
||||
|
||||
// Persist variable values
|
||||
if (variables.length > 0) {
|
||||
const variableData = variables.map((v) => {
|
||||
@@ -359,14 +359,24 @@ export function useActionWizard() {
|
||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||
setStatus('Adding required outputs...');
|
||||
|
||||
const outputsToAdd = transaction.outputs.map(
|
||||
(output: XOTemplateTransactionOutput) => ({
|
||||
outputIdentifier: output.output,
|
||||
roleIdentifier: roleIdentifier,
|
||||
})
|
||||
);
|
||||
const outputsToAdd = await Promise.all(transaction.outputs.map(
|
||||
async (output: XOTemplateTransactionOutput) => ({
|
||||
// TODO: Fix this. Currently, there is a type mismatch due to branches/versions of the libraries
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@ export * from './action-wizard/index.js';
|
||||
export { SeedInputScreen } from './SeedInput.js';
|
||||
export { WalletStateScreen } from './WalletState.js';
|
||||
export { TemplateListScreen } from './TemplateList.js';
|
||||
export { InvitationScreen } from './Invitation.js';
|
||||
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||
export { TransactionScreen } from './Transaction.js';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||
*
|
||||
* Provides:
|
||||
* - Import invitation by ID with role selection
|
||||
* - Import invitation by ID with multi-step import flow
|
||||
* - View active invitations with detailed information
|
||||
* - Monitor invitation updates via SSE
|
||||
* - Fill missing requirements
|
||||
@@ -11,17 +11,16 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { InputDialog } from '../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../components/List.js';
|
||||
import { useNavigation } from '../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
||||
import { copyToClipboard } from '../utils/clipboard.js';
|
||||
import type { Invitation } from '../../services/invitation.js';
|
||||
import { InputDialog } from '../../components/Dialog.js';
|
||||
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
|
||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||
import type { Invitation } from '../../../services/invitation.js';
|
||||
import type { XOTemplate } from '@xo-cash/types';
|
||||
|
||||
// Import utility functions
|
||||
import {
|
||||
getInvitationState,
|
||||
getStateColorName,
|
||||
@@ -31,7 +30,9 @@ import {
|
||||
getUserRole,
|
||||
formatInvitationListItem,
|
||||
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.
|
||||
@@ -85,37 +86,29 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const { appService, showError, showInfo } = useAppContext();
|
||||
const { setStatus } = useStatus();
|
||||
|
||||
// Use hooks for reactive invitation list
|
||||
const invitations = useInvitations();
|
||||
|
||||
// State
|
||||
// ── UI state ─────────────────────────────────────────────────────────────
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
||||
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
||||
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
||||
// ── Import state ─────────────────────────────────────────────────────────
|
||||
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
|
||||
// Template cache for displaying invitation list with template names
|
||||
// ── Template cache ───────────────────────────────────────────────────────
|
||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||
|
||||
// Selected invitation template for details view
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||
|
||||
// Check if we should open import dialog on mount
|
||||
const initialMode = navData.mode as string | undefined;
|
||||
|
||||
/**
|
||||
* Show import dialog on mount if needed.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (initialMode === 'import') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, [initialMode]);
|
||||
|
||||
@@ -139,10 +132,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
/**
|
||||
* Build list items for ScrollableList.
|
||||
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
||||
*/
|
||||
const listItems = useMemo((): InvitationListItem[] => {
|
||||
// Import action at top
|
||||
const importItem: InvitationListItem = {
|
||||
key: 'import',
|
||||
label: '+ Import Invitation',
|
||||
@@ -151,11 +142,9 @@ export function InvitationScreen(): React.ReactElement {
|
||||
color: 'info',
|
||||
};
|
||||
|
||||
// Map invitations to list items
|
||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||
const template = templateCache.get(inv.data.templateIdentifier);
|
||||
const formatted = formatInvitationListItem(inv, template);
|
||||
const state = getInvitationState(inv);
|
||||
|
||||
return {
|
||||
key: inv.data.invitationIdentifier,
|
||||
@@ -163,14 +152,13 @@ export function InvitationScreen(): React.ReactElement {
|
||||
value: inv,
|
||||
group: 'invitations',
|
||||
color: formatted.statusColor,
|
||||
hidden: !formatted.isValid, // Hide invalid items
|
||||
hidden: !formatted.isValid,
|
||||
};
|
||||
});
|
||||
|
||||
return [importItem, ...invitationItems];
|
||||
}, [invitations, templateCache]);
|
||||
|
||||
// Get selected invitation from list items
|
||||
const selectedItem = listItems[selectedIndex];
|
||||
const selectedInvitation = selectedItem?.value ?? null;
|
||||
|
||||
@@ -187,111 +175,29 @@ export function InvitationScreen(): React.ReactElement {
|
||||
.then(template => setSelectedTemplate(template ?? null));
|
||||
}, [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) => {
|
||||
if (!invitationId.trim() || !appService) {
|
||||
setImportStage(null);
|
||||
const handleImportIdSubmit = useCallback((invitationId: string) => {
|
||||
if (!invitationId.trim()) {
|
||||
setShowIdDialog(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Importing invitation:', invitationId);
|
||||
|
||||
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]);
|
||||
setShowIdDialog(false);
|
||||
setImportingId(invitationId.trim());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stage 2: Accept invitation with selected role.
|
||||
* Import flow closed (completed or cancelled).
|
||||
*/
|
||||
const handleRoleSelect = useCallback(async () => {
|
||||
if (!importingInvitation || !appService) return;
|
||||
const handleImportFlowClose = useCallback(() => {
|
||||
setImportingId(null);
|
||||
}, []);
|
||||
|
||||
const selectedRole = availableRoles[selectedRoleIndex];
|
||||
if (!selectedRole) {
|
||||
showError('No role selected');
|
||||
return;
|
||||
}
|
||||
// ── Action handlers ────────────────────────────────────────────────────
|
||||
|
||||
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 () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -307,7 +213,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
setStatus('Ready');
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
// Check if already accepted
|
||||
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.');
|
||||
} else {
|
||||
@@ -318,9 +223,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Sign selected invitation.
|
||||
*/
|
||||
const signInvitation = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -341,9 +243,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Copy invitation ID.
|
||||
*/
|
||||
const copyId = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -358,9 +257,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError]);
|
||||
|
||||
/**
|
||||
* Fill requirements for selected invitation.
|
||||
*/
|
||||
const fillRequirements = useCallback(async () => {
|
||||
if (!selectedInvitation) {
|
||||
showError('No invitation selected');
|
||||
@@ -370,15 +266,12 @@ export function InvitationScreen(): React.ReactElement {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Step 1: Check available roles
|
||||
setStatus('Checking available roles...');
|
||||
const roles = await selectedInvitation.getAvailableRoles();
|
||||
|
||||
if (roles.length === 0) {
|
||||
// Already participating, check if we can add inputs
|
||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||
} else {
|
||||
// Need to accept a role first
|
||||
const roleToTake = roles[0];
|
||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||
setStatus(`Accepting as ${roleToTake}...`);
|
||||
@@ -392,10 +285,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Check if invitation already has inputs or needs funding
|
||||
setStatus('Analyzing invitation...');
|
||||
|
||||
// Calculate how much we need
|
||||
let requiredAmount = 0n;
|
||||
const commits = selectedInvitation.data.commits || [];
|
||||
for (const commit of commits) {
|
||||
@@ -413,7 +304,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const dust = 546n;
|
||||
const totalNeeded = requiredAmount + fee + dust;
|
||||
|
||||
// Find resources
|
||||
const utxos = await selectedInvitation.findSuitableResources({
|
||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||
outputIdentifier: 'receiveOutput',
|
||||
@@ -425,7 +315,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Select UTXOs
|
||||
setStatus('Selecting UTXOs...');
|
||||
|
||||
const selectedUtxos: Array<{
|
||||
@@ -443,12 +332,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||
: undefined;
|
||||
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
|
||||
continue;
|
||||
}
|
||||
if (lockingBytecodeHex) {
|
||||
seenLockingBytecodes.add(lockingBytecodeHex);
|
||||
}
|
||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
|
||||
|
||||
selectedUtxos.push({
|
||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||
@@ -457,9 +342,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
});
|
||||
accumulated += BigInt(utxo.valueSatoshis);
|
||||
|
||||
if (accumulated >= totalNeeded) {
|
||||
break;
|
||||
}
|
||||
if (accumulated >= totalNeeded) break;
|
||||
}
|
||||
|
||||
if (accumulated < totalNeeded) {
|
||||
@@ -470,7 +353,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
const changeAmount = accumulated - requiredAmount - fee;
|
||||
|
||||
// Add inputs
|
||||
setStatus('Adding inputs...');
|
||||
await selectedInvitation.addInputs(
|
||||
selectedUtxos.map(u => ({
|
||||
@@ -479,7 +361,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}))
|
||||
);
|
||||
|
||||
// Add change output
|
||||
if (changeAmount >= dust) {
|
||||
setStatus('Adding change output...');
|
||||
await selectedInvitation.addOutputs([{
|
||||
@@ -487,7 +368,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}]);
|
||||
}
|
||||
|
||||
// Show success
|
||||
showInfo(
|
||||
`Requirements filled!\n\n` +
|
||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||
@@ -498,7 +378,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
`Now use "Sign Transaction" to complete.`
|
||||
);
|
||||
setStatus('Ready');
|
||||
|
||||
} catch (error) {
|
||||
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||
setStatus('Ready');
|
||||
@@ -507,9 +386,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||
|
||||
/**
|
||||
* Handle action selection.
|
||||
*/
|
||||
const handleAction = useCallback((action: string) => {
|
||||
switch (action) {
|
||||
case 'copy':
|
||||
@@ -532,70 +408,44 @@ export function InvitationScreen(): React.ReactElement {
|
||||
}
|
||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||
|
||||
/**
|
||||
* Handle list item activation.
|
||||
*/
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
||||
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||
if (item.key === 'import') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
// For invitation items, we just select them - actions are in the actions panel
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle action item activation.
|
||||
*/
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
|
||||
if (item.value) {
|
||||
handleAction(item.value);
|
||||
}
|
||||
}, [handleAction]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
// ── Keyboard navigation ──────────────────────────────────────────────────
|
||||
// Disabled when the ID dialog or import flow is open.
|
||||
const isOverlayOpen = showIdDialog || importingId !== null;
|
||||
|
||||
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) {
|
||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'c' to copy
|
||||
if (input === 'c' && selectedInvitation) {
|
||||
copyId();
|
||||
}
|
||||
|
||||
// 'i' to import
|
||||
if (input === 'i') {
|
||||
setImportStage('id');
|
||||
setShowIdDialog(true);
|
||||
}
|
||||
}, { isActive: importStage !== 'id' });
|
||||
}, { isActive: !isOverlayOpen });
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Render custom list item for invitation list.
|
||||
*/
|
||||
const renderInvitationListItem = useCallback((
|
||||
item: InvitationListItem,
|
||||
isSelected: boolean,
|
||||
isFocused: boolean
|
||||
): React.ReactNode => {
|
||||
// Import item
|
||||
if (item.key === 'import') {
|
||||
return (
|
||||
<Text
|
||||
@@ -608,7 +458,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Invitation item
|
||||
const inv = item.value;
|
||||
if (!inv) return null;
|
||||
|
||||
@@ -628,9 +477,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
}, [templateCache]);
|
||||
|
||||
/**
|
||||
* Render detailed invitation information.
|
||||
*/
|
||||
const renderDetails = () => {
|
||||
if (!selectedInvitation) {
|
||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||
@@ -642,7 +488,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
const outputs = getInvitationOutputs(selectedInvitation);
|
||||
const variables = getInvitationVariables(selectedInvitation);
|
||||
|
||||
// Try to determine user's entity ID (from first commit they made)
|
||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||
@@ -650,7 +495,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Row 1: Type, Description, Status */}
|
||||
{/* Type & Status */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
<Box width="50%">
|
||||
<Box flexDirection="column">
|
||||
@@ -675,7 +520,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 2: Your Role */}
|
||||
{/* Your Role */}
|
||||
{userRole && (
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text color={colors.primary} bold>Your Role: </Text>
|
||||
@@ -686,9 +531,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Row 3: Inputs & Outputs side by side */}
|
||||
{/* Inputs & Outputs */}
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{/* Inputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||
{inputs.length === 0 ? (
|
||||
@@ -711,7 +555,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Outputs */}
|
||||
<Box width="50%" flexDirection="column">
|
||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||
{outputs.length === 0 ? (
|
||||
@@ -735,7 +578,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Row 4: Variables */}
|
||||
{/* Variables */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||
{variables.length === 0 ? (
|
||||
@@ -763,7 +606,6 @@ export function InvitationScreen(): React.ReactElement {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Shortcuts */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||
</Box>
|
||||
@@ -771,84 +613,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
};
|
||||
// ── Main render ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
@@ -857,7 +622,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||
</Box>
|
||||
|
||||
{/* Main content - Top row: List + Actions */}
|
||||
{/* Top row: List + Actions */}
|
||||
<Box flexDirection="row" marginTop={1} height={12}>
|
||||
{/* Left column: Invitation list */}
|
||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||
@@ -874,7 +639,7 @@ export function InvitationScreen(): React.ReactElement {
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={setSelectedIndex}
|
||||
onActivate={handleListItemActivate}
|
||||
focus={focusedPanel === 'list'}
|
||||
focus={focusedPanel === 'list' && !isOverlayOpen}
|
||||
maxVisible={6}
|
||||
groups={invitationListGroups}
|
||||
emptyMessage="No invitations yet"
|
||||
@@ -898,14 +663,14 @@ export function InvitationScreen(): React.ReactElement {
|
||||
selectedIndex={selectedActionIndex}
|
||||
onSelect={setSelectedActionIndex}
|
||||
onActivate={handleActionItemActivate}
|
||||
focus={focusedPanel === 'actions'}
|
||||
focus={focusedPanel === 'actions' && !isOverlayOpen}
|
||||
emptyMessage="No actions"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Bottom row: Details (full width) */}
|
||||
{/* Bottom row: Details */}
|
||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
@@ -928,8 +693,8 @@ export function InvitationScreen(): React.ReactElement {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Import ID dialog (Stage 1) */}
|
||||
{importStage === 'id' && (
|
||||
{/* Import ID dialog */}
|
||||
{showIdDialog && (
|
||||
<Box
|
||||
position="absolute"
|
||||
flexDirection="column"
|
||||
@@ -943,14 +708,24 @@ export function InvitationScreen(): React.ReactElement {
|
||||
prompt="Enter Invitation ID:"
|
||||
placeholder="Paste invitation ID..."
|
||||
onSubmit={handleImportIdSubmit}
|
||||
onCancel={() => setImportStage(null)}
|
||||
onCancel={() => setShowIdDialog(false)}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Role Selection dialog (Stage 2) */}
|
||||
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
||||
{/* Multi-step import flow */}
|
||||
{importingId && appService && (
|
||||
<InvitationImportFlow
|
||||
invitationId={importingId}
|
||||
mode="dialog"
|
||||
appService={appService}
|
||||
onClose={handleImportFlowClose}
|
||||
showError={showError}
|
||||
showInfo={showInfo}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
</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