Compare commits
4 Commits
0acc70b613
...
f1ac89ef91
| Author | SHA1 | Date | |
|---|---|---|---|
|
f1ac89ef91
|
|||
|
17a41cf29a
|
|||
|
0b848989a2
|
|||
|
1776fbbf61
|
39
package-lock.json
generated
39
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -37,7 +38,6 @@
|
|||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@vitest/coverage-v8": "^4.1.2",
|
"@vitest/coverage-v8": "^4.1.2",
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.2"
|
"vitest": "^4.1.2"
|
||||||
}
|
}
|
||||||
@@ -1686,7 +1686,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -1738,7 +1737,6 @@
|
|||||||
"version": "4.13.0",
|
"version": "4.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"resolve-pkg-maps": "^1.0.0"
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
@@ -2590,7 +2588,6 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
@@ -3013,7 +3010,6 @@
|
|||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.27.0",
|
||||||
@@ -3036,7 +3032,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3053,7 +3048,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3070,7 +3064,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3087,7 +3080,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3104,7 +3096,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3121,7 +3112,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3138,7 +3128,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3155,7 +3144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3172,7 +3160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3189,7 +3176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3206,7 +3192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3223,7 +3208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3240,7 +3224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3257,7 +3240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3274,7 +3256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3291,7 +3272,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3308,7 +3288,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3325,7 +3304,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3342,7 +3320,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3359,7 +3336,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3376,7 +3352,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3393,7 +3368,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3410,7 +3384,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3427,7 +3400,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3444,7 +3416,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3461,7 +3432,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3475,7 +3445,6 @@
|
|||||||
"version": "0.27.7",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -4293,9 +4262,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
52
readme.md
52
readme.md
@@ -155,3 +155,55 @@ xo-tui
|
|||||||
# If not globally installed
|
# If not globally installed
|
||||||
npm run dev
|
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.
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
|
|
||||||
// Attach listeners before SSE connects so updates are not missed.
|
// Attach listeners before SSE connects so updates are not missed.
|
||||||
await this.addInvitation(invitationInstance);
|
await this.addInvitation(invitationInstance);
|
||||||
await invitationInstance.start();
|
invitationInstance.start();
|
||||||
|
|
||||||
return invitationInstance;
|
return invitationInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,12 +224,9 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
private async publishInvitation(
|
private async publishInvitation(
|
||||||
invitation: XOInvitation = this.data,
|
invitation: XOInvitation = this.data,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
this.syncServer.publishInvitation(invitation).catch((error) => {
|
||||||
await this.syncServer.publishInvitation(invitation);
|
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||||
} 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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -374,9 +371,13 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Update the status of the invitation and emit the new single-word status.
|
* Update the status of the invitation and emit the new single-word status.
|
||||||
*/
|
*/
|
||||||
private async updateStatus(): Promise<void> {
|
private async updateStatus(): Promise<void> {
|
||||||
const status = await this.computeStatus();
|
this.computeStatus().then(status => {
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.emit("invitation-status-changed", 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)));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { WalletStateScreen } from './screens/WalletState.js';
|
|||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
|
||||||
|
|
||||||
import { MessageDialog } from './components/Dialog.js';
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
|
|
||||||
@@ -45,8 +44,6 @@ function Router(): React.ReactElement {
|
|||||||
return <ActionWizardScreen />;
|
return <ActionWizardScreen />;
|
||||||
case 'invitations':
|
case 'invitations':
|
||||||
return <InvitationScreen />;
|
return <InvitationScreen />;
|
||||||
case 'transaction':
|
|
||||||
return <TransactionScreen />;
|
|
||||||
default:
|
default:
|
||||||
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
return <Text color={colors.error}>Unknown screen: {screen}</Text>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,7 @@ const actionItems: ListItemData<string>[] = [
|
|||||||
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
{ key: 'accept', label: 'Accept & Join', value: 'accept' },
|
||||||
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
{ key: 'fill', label: 'Fill Requirements', value: 'fill' },
|
||||||
{ key: 'sign', label: 'Sign Transaction', value: 'sign' },
|
{ 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' },
|
{ key: 'copy', label: 'Copy Invitation ID', value: 'copy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -332,6 +332,30 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [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 () => {
|
const copyId = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -489,13 +513,11 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
case 'sign':
|
case 'sign':
|
||||||
signInvitation();
|
signInvitation();
|
||||||
break;
|
break;
|
||||||
case 'transaction':
|
case 'broadcast':
|
||||||
if (selectedInvitation) {
|
broadcastTransaction();
|
||||||
navigate('transaction', { invitationId: selectedInvitation.data.invitationIdentifier });
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, broadcastTransaction, navigate]);
|
||||||
|
|
||||||
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
|
|||||||
Reference in New Issue
Block a user