4 Commits

7 changed files with 95 additions and 467 deletions

39
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"tsx": "^4.21.0",
"zod": "^4.3.6"
},
"bin": {
@@ -37,7 +38,6 @@
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@vitest/coverage-v8": "^4.1.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.2"
}
@@ -1686,7 +1686,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1738,7 +1737,6 @@
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -2590,7 +2588,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -3013,7 +3010,6 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
@@ -3036,7 +3032,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3053,7 +3048,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3070,7 +3064,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3087,7 +3080,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3104,7 +3096,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3121,7 +3112,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3138,7 +3128,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3155,7 +3144,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3172,7 +3160,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3189,7 +3176,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3206,7 +3192,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3223,7 +3208,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3240,7 +3224,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3257,7 +3240,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3274,7 +3256,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3291,7 +3272,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3308,7 +3288,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3325,7 +3304,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3342,7 +3320,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3359,7 +3336,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3376,7 +3352,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3393,7 +3368,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3410,7 +3384,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3427,7 +3400,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3444,7 +3416,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3461,7 +3432,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -3475,7 +3445,6 @@
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -4293,9 +4262,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -155,3 +155,55 @@ xo-tui
# If not globally installed
npm run dev
```
## TODO
### Track invitation sync-server connectivity without blocking the UI
Each `Invitation` currently owns a `SyncServer` instance for its invitation
identifier. The invitation uses that instance to open an SSE connection, fetch
remote state, and publish local changes. Publish requests are intentionally
fire-and-forget so that invitation actions and the TUI stay responsive when the
sync server is slow or unavailable.
The tradeoff is that failed background requests and SSE connection changes are
not represented as application state. `SyncServer` already emits `connected`,
`disconnected`, and `error` events, and `Invitation` emits errors from failed
publishes, but there is no app-level owner that aggregates those events. The UI
therefore cannot reliably tell the user that an invitation may only be updated
locally and is not currently syncing with other participants.
Implement an app-owned `InvitationConnectivityService` (or similarly named
invitation watcher) with the following responsibilities:
- Register an invitation and its `SyncServer` when `AppService` creates or loads
it, and unregister it when the invitation is removed or stopped.
- Listen for each sync server's `connected`, `disconnected`, and `error` events,
plus invitation publish failures.
- Track connectivity separately from the invitation's business status
(`actionable`, `signed`, `ready`, and so on). Suggested transport states are
`connecting`, `online`, `offline`, and `degraded`, with the last error and
last successful connection timestamp available for diagnostics.
- Expose both per-invitation state and an aggregate app-level state such as
"one or more invitations are not syncing".
- Emit normalized connectivity-change events that the CLI can log and the TUI
can subscribe to without awaiting sync-server requests.
Keep local persistence and local invitation actions independent from remote
sync health. Failed sync attempts should not freeze normal wallet interaction.
The service should provide a retry path, or observe retry events from the SSE
client, and clear the warning after connectivity recovers. If publish retries
are added, make the retry policy explicit and preserve commit idempotency.
For UI integration, inject a small notification function or subscribe at the
app-context layer rather than having invitation instances render UI directly.
The first version can show an error dialog when the aggregate state becomes
unhealthy. A less intrusive version can expose the same state as a warning icon
or message in the TUI status bar and reserve dialogs for prolonged failures or
explicit user actions.
While making this change, consolidate invitation startup ownership. Startup is
currently triggered during `Invitation.create()` and again by
`AppService.createInvitation()`. The watcher should have one clear lifecycle
point so connections, listeners, retries, and cleanup are registered exactly
once.

View File

@@ -173,7 +173,7 @@ export class AppService extends EventEmitter<AppEventMap> {
// Attach listeners before SSE connects so updates are not missed.
await this.addInvitation(invitationInstance);
await invitationInstance.start();
invitationInstance.start();
return invitationInstance;
}

View File

@@ -224,12 +224,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
private async publishInvitation(
invitation: XOInvitation = this.data,
): Promise<void> {
try {
await this.syncServer.publishInvitation(invitation);
} catch (err) {
// Emit the error event. We might want to throw? but we need a better way of handling errors in the invitation system because we need the invitation to successfully initialize.
this.emit("error", err instanceof Error ? err : new Error(String(err)));
}
this.syncServer.publishInvitation(invitation).catch((error) => {
this.emit("error", error instanceof Error ? error : new Error(String(error)));
});
}
/**
@@ -374,9 +371,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
* Update the status of the invitation and emit the new single-word status.
*/
private async updateStatus(): Promise<void> {
const status = await this.computeStatus();
this.status = status;
this.emit("invitation-status-changed", status);
this.computeStatus().then(status => {
this.status = status;
this.emit("invitation-status-changed", status);
}).catch((error) => {
this.status = `error (${error instanceof Error ? error.message : String(error)})`;
this.emit("error", error instanceof Error ? error : new Error(String(error)));
});
}
/**

View File

@@ -17,7 +17,6 @@ import { WalletStateScreen } from './screens/WalletState.js';
import { TemplateListScreen } from './screens/TemplateList.js';
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
import { TransactionScreen } from './screens/Transaction.js';
import { MessageDialog } from './components/Dialog.js';
@@ -45,8 +44,6 @@ function Router(): React.ReactElement {
return <ActionWizardScreen />;
case 'invitations':
return <InvitationScreen />;
case 'transaction':
return <TransactionScreen />;
default:
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
}

View File

@@ -1,413 +0,0 @@
/**
* Transaction Screen - Reviews and broadcasts transactions.
*
* Provides:
* - Transaction details review
* - Input/output inspection
* - Fee calculation display
* - Broadcast confirmation
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, Text } from 'ink';
import { ConfirmDialog } from '../components/Dialog.js';
import { useNavigation } from '../hooks/useNavigation.js';
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
import { useBlockableInput } from '../hooks/useInputLayer.js';
import { useInvitation } from '../hooks/useInvitations.js';
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
import { copyToClipboard } from '../utils/clipboard.js';
/**
* Action menu items.
*/
const actionItems = [
{ label: 'Broadcast Transaction', value: 'broadcast' },
{ label: 'Sign Transaction', value: 'sign' },
{ label: 'Copy Transaction Hex', value: 'copy' },
{ label: 'Back to Invitation', value: 'back' },
];
/**
* Transaction Screen Component.
*/
export function TransactionScreen(): React.ReactElement {
const { navigate, goBack, data: navData } = useNavigation();
const { showError, showInfo } = useAppContext();
const { setStatus } = useStatus();
// Extract invitation ID from navigation data
const invitationId = navData.invitationId as string | undefined;
// Use hook to get invitation reactively
const invitationInstance = useInvitation(invitationId ?? null);
// State
const [focusedPanel, setFocusedPanel] = useState<'inputs' | 'outputs' | 'actions'>('actions');
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [showBroadcastConfirm, setShowBroadcastConfirm] = useState(false);
// Check if invitation exists
useEffect(() => {
if (!invitationId) {
showError('No invitation ID provided');
goBack();
return;
}
if (invitationId && !invitationInstance) {
showError('Invitation not found');
goBack();
}
}, [invitationId, invitationInstance, showError, goBack]);
const invitation = invitationInstance?.data ?? null;
/**
* Broadcast transaction.
*/
const broadcastTransaction = useCallback(async () => {
if (!invitationInstance) return;
setShowBroadcastConfirm(false);
setIsLoading(true);
setStatus('Broadcasting transaction...');
try {
await invitationInstance.broadcast();
showInfo(
`Transaction Broadcast Successful!\n\n` +
`The transaction has been submitted to the network.`
);
navigate('wallet');
} catch (error) {
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationInstance, showInfo, showError, navigate, setStatus]);
/**
* Sign transaction.
*/
const signTransaction = useCallback(async () => {
if (!invitationInstance) return;
setIsLoading(true);
setStatus('Signing transaction...');
try {
await invitationInstance.sign();
showInfo('Transaction signed successfully!');
} catch (error) {
showError(`Failed to sign: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [invitationInstance, showInfo, showError, setStatus]);
/**
* Copy transaction hex.
*/
const copyTransactionHex = useCallback(async () => {
if (!invitation) return;
try {
await copyToClipboard(invitation.invitationIdentifier);
showInfo(
`Copied Invitation ID!\n\n` +
`ID: ${invitation.invitationIdentifier}\n` +
`Commits: ${invitation.commits.length}`
);
} catch (error) {
showError(`Failed to copy: ${error instanceof Error ? error.message : String(error)}`);
}
}, [invitation, showInfo, showError]);
/**
* Handle action selection.
*/
const handleAction = useCallback((action: string) => {
switch (action) {
case 'broadcast':
setShowBroadcastConfirm(true);
break;
case 'sign':
signTransaction();
break;
case 'copy':
copyTransactionHex();
break;
case 'back':
goBack();
break;
}
}, [signTransaction, copyTransactionHex, goBack]);
// Handle keyboard navigation — automatically blocked when the confirm dialog is open.
useBlockableInput((input, key) => {
// Tab to switch panels
if (key.tab) {
setFocusedPanel(prev => {
if (prev === 'inputs') return 'outputs';
if (prev === 'outputs') return 'actions';
return 'inputs';
});
return;
}
// Up/Down in actions
if (focusedPanel === 'actions') {
if (key.upArrow || input === 'k') {
setSelectedActionIndex(prev => Math.max(0, prev - 1));
} else if (key.downArrow || input === 'j') {
setSelectedActionIndex(prev => Math.min(actionItems.length - 1, prev + 1));
}
}
// Enter to select
if (key.return && focusedPanel === 'actions') {
const action = actionItems[selectedActionIndex];
if (action) {
handleAction(action.value);
}
}
});
// Extract transaction data from invitation
const commits = invitation?.commits ?? [];
const inputs: Array<{ txid: string; index: number; value?: bigint; inputIdentifier?: string }> = [];
const outputs: Array<{ value?: bigint; lockingBytecode: string; outputIdentifier?: string; isTemplate: boolean }> = [];
const variables: Array<{ id: string; value: string }> = [];
// Parse commits for inputs, outputs, and variables
for (const commit of commits) {
// Extract variables (to help understand output values)
if (commit.data?.variables) {
for (const variable of commit.data.variables) {
variables.push({
id: variable.variableIdentifier,
value: String(variable.value),
});
}
}
if (commit.data?.inputs) {
for (const input of commit.data.inputs) {
// Convert Uint8Array to hex string if needed
const txidHex = input.outpointTransactionHash
? typeof input.outpointTransactionHash === 'string'
? input.outpointTransactionHash
: Buffer.from(input.outpointTransactionHash).toString('hex')
: undefined;
// Skip inputs that are just placeholders (no txid)
if (txidHex) {
inputs.push({
txid: txidHex,
index: input.outpointIndex ?? 0,
value: undefined, // Will be looked up from UTXO data
inputIdentifier: (input as any).inputIdentifier,
});
}
}
}
if (commit.data?.outputs) {
for (const output of commit.data.outputs) {
// Convert Uint8Array to hex string if needed
const lockingBytecodeHex = output.lockingBytecode
? typeof output.lockingBytecode === 'string'
? output.lockingBytecode
: Buffer.from(output.lockingBytecode).toString('hex')
: undefined;
// Check if this is a template-defined output (has outputIdentifier but no direct value)
const isTemplateOutput = !!(output as any).outputIdentifier && !output.valueSatoshis;
outputs.push({
value: output.valueSatoshis,
lockingBytecode: lockingBytecodeHex ?? '(pending)',
outputIdentifier: (output as any).outputIdentifier,
isTemplate: isTemplateOutput,
});
}
}
}
// Try to resolve template output values from variables
const resolvedOutputs = outputs.map(output => {
if (output.isTemplate && output.outputIdentifier) {
// Look for a matching variable (e.g., requestSatoshisOutput -> requestedSatoshis)
const satoshiVar = variables.find(v =>
v.id.toLowerCase().includes('satoshi') ||
v.id.toLowerCase().includes('amount')
);
if (satoshiVar) {
return {
...output,
value: BigInt(satoshiVar.value),
resolvedFrom: satoshiVar.id,
};
}
}
return output;
});
// Calculate totals (only for resolved values)
const totalOut = resolvedOutputs.reduce((sum, o) => sum + (o.value ?? 0n), 0n);
// Note: We can't calculate totalIn without UTXO lookup, so fee is unknown
const hasUnresolvedOutputs = resolvedOutputs.some(o => o.value === undefined);
const hasUnresolvedInputs = inputs.length > 0; // Input values are always unknown from commit data
return (
<Box flexDirection='column' flexGrow={1}>
{/* Header */}
<Box borderStyle='single' borderColor={colors.secondary} paddingX={1}>
<Text color={colors.primary} bold>{logoSmall} - Transaction Review</Text>
</Box>
{/* Summary box */}
<Box
borderStyle='single'
borderColor={colors.primary}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection='column'
>
<Text color={colors.primary} bold> Transaction Summary </Text>
{invitation ? (
<Box flexDirection='column' marginTop={1}>
<Text color={colors.text}>Inputs: {inputs.length} | Outputs: {resolvedOutputs.length} | Commits: {commits.length}</Text>
{hasUnresolvedInputs && (
<Text color={colors.textMuted}>Total In: (requires UTXO lookup)</Text>
)}
<Text color={colors.warning}>Total Out: {formatSatoshis(totalOut)}{hasUnresolvedOutputs ? ' (partial)' : ''}</Text>
{hasUnresolvedInputs ? (
<Text color={colors.textMuted}>Fee: (calculated at broadcast)</Text>
) : (
<Text color={colors.info}>Fee: {formatSatoshis(0n)}</Text>
)}
</Box>
) : (
<Text color={colors.textMuted}>Loading...</Text>
)}
</Box>
{/* Inputs and Outputs */}
<Box flexDirection='row' marginTop={1} marginX={1} flexGrow={1}>
{/* Inputs */}
<Box
borderStyle='single'
borderColor={focusedPanel === 'inputs' ? colors.focus : colors.border}
width='50%'
flexDirection='column'
paddingX={1}
>
<Text color={colors.primary} bold> Inputs </Text>
<Box flexDirection='column' marginTop={1}>
{inputs.length === 0 ? (
<Text color={colors.textMuted}>No inputs</Text>
) : (
inputs.map((input, index) => (
<Box key={`${input.txid}-${input.index}`} flexDirection='column' marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {formatHex(input.txid, 12)}:{input.index}
</Text>
{input.value !== undefined && (
<Text color={colors.textMuted}> {formatSatoshis(input.value)}</Text>
)}
</Box>
))
)}
</Box>
</Box>
{/* Outputs */}
<Box
borderStyle='single'
borderColor={focusedPanel === 'outputs' ? colors.focus : colors.border}
width='50%'
flexDirection='column'
paddingX={1}
marginLeft={1}
>
<Text color={colors.primary} bold> Outputs </Text>
<Box flexDirection='column' marginTop={1}>
{resolvedOutputs.length === 0 ? (
<Text color={colors.textMuted}>No outputs</Text>
) : (
resolvedOutputs.map((output, index) => (
<Box key={index} flexDirection='column' marginBottom={1}>
<Text color={colors.text}>
{index + 1}. {output.value !== undefined ? formatSatoshis(output.value) : '(pending)'}
{output.outputIdentifier && (
<Text color={colors.info}> [{output.outputIdentifier}]</Text>
)}
</Text>
<Text color={colors.textMuted}> {output.lockingBytecode !== '(pending)' ? formatHex(output.lockingBytecode, 20) : '(pending)'}</Text>
{(output as any).resolvedFrom && (
<Text color={colors.textMuted} dimColor> (from ${(output as any).resolvedFrom})</Text>
)}
</Box>
))
)}
</Box>
</Box>
</Box>
{/* Actions */}
<Box
borderStyle='single'
borderColor={focusedPanel === 'actions' ? colors.focus : colors.border}
marginTop={1}
marginX={1}
paddingX={1}
flexDirection='column'
>
<Text color={colors.primary} bold> Actions </Text>
<Box flexDirection='column' marginTop={1}>
{actionItems.map((item, index) => (
<Text
key={item.value}
color={index === selectedActionIndex && focusedPanel === 'actions' ? colors.focus : colors.text}
bold={index === selectedActionIndex && focusedPanel === 'actions'}
>
{index === selectedActionIndex && focusedPanel === 'actions' ? '▸ ' : ' '}
{item.label}
</Text>
))}
</Box>
</Box>
{/* Help text */}
<Box marginTop={1} marginX={1}>
<Text color={colors.textMuted} dimColor>
Tab: Switch focus Enter: Select Esc: Back
</Text>
</Box>
{/* Broadcast confirmation dialog */}
{showBroadcastConfirm && (
<Box
position='absolute'
flexDirection='column'
alignItems='center'
justifyContent='center'
width='100%'
height='100%'
>
<ConfirmDialog
title='Broadcast Transaction'
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
onConfirm={broadcastTransaction}
onCancel={() => setShowBroadcastConfirm(false)}
/>
</Box>
)}
</Box>
);
}

View File

@@ -63,7 +63,7 @@ const actionItems: ListItemData<string>[] = [
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
{ key: 'transaction', label: 'View Transaction', value: 'transaction' },
{ key: 'broadcast', label: 'Broadcast Transaction', value: 'broadcast' },
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
];
@@ -332,6 +332,30 @@ export function InvitationScreen(): React.ReactElement {
}
}, [selectedInvitation, showInfo, showError, setStatus]);
/**
* Broadcast transaction.
*/
const broadcastTransaction = useCallback(async () => {
if (!selectedInvitation) return;
setIsLoading(true);
setStatus('Broadcasting transaction...');
try {
await selectedInvitation.broadcast();
showInfo(
`Transaction Broadcast Successful!\n\n` +
`The transaction has been submitted to the network.`
);
setStatus('Ready');
} catch (error) {
showError(`Failed to broadcast: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
setStatus('Ready');
}
}, [selectedInvitation, showInfo, showError, setStatus]);
const copyId = useCallback(async () => {
if (!selectedInvitation) {
showError('No invitation selected');
@@ -489,13 +513,11 @@ export function InvitationScreen(): React.ReactElement {
case 'sign':
signInvitation();
break;
case 'transaction':
if (selectedInvitation) {
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
}
case 'broadcast':
broadcastTransaction();
break;
}
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]);
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
if (item.key === 'import') {