From a28d43a68bfdbaaa0d6cff9b297e89af62d5a6ef Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Sun, 22 Mar 2026 13:20:46 +0000 Subject: [PATCH] Massive changes. I dont know what happens. Rewrote the action wizard again. Fixed several bugs related to the utxo selection. QR codes were added for address. Add support for data results. Experiment with other methods of role extraction --- package-lock.json | 392 +++++++- package.json | 2 + src/services/app.ts | 22 + src/services/history.ts | 60 ++ src/services/invitation.ts | 2 +- src/tui/App.tsx | 10 +- src/tui/components/QRCode.tsx | 198 ++++ src/tui/components/index.ts | 1 + src/tui/screens/SeedInput.tsx | 3 - src/tui/screens/WalletState.tsx | 60 +- .../action-wizard/ActionWizardScreen.tsx | 164 +--- .../action-wizard/flows/DataWizardFlow.ts | 40 + .../flows/TransactionWizardFlow.ts | 36 + .../screens/action-wizard/flows/WizardFlow.ts | 22 + src/tui/screens/action-wizard/flows/index.ts | 21 + .../action-wizard/hooks/useActionWizard.ts | 417 +++++++++ .../hooks/useInvitationManager.ts | 259 ++++++ .../action-wizard/hooks/useRoleSelection.ts | 46 + .../action-wizard/hooks/useUtxoSelection.ts | 117 +++ .../action-wizard/hooks/useVariableInputs.ts | 70 ++ .../action-wizard/hooks/useWizardFocus.ts | 37 + .../action-wizard/hooks/useWizardKeyboard.ts | 149 +++ .../action-wizard/hooks/useWizardSteps.ts | 73 ++ src/tui/screens/action-wizard/index.ts | 5 +- .../action-wizard/steps/DataResultStep.tsx | 81 ++ .../screens/action-wizard/steps/InfoStep.tsx | 52 -- .../action-wizard/steps/InputsStep.tsx | 21 +- .../action-wizard/steps/VariablesStep.tsx | 17 +- src/tui/screens/action-wizard/steps/index.ts | 4 +- src/tui/screens/action-wizard/types.ts | 83 +- .../screens/action-wizard/useActionWizard.ts | 861 ------------------ .../InvitationImportFlow.tsx | 11 - .../steps/InputsSelectStep.tsx | 53 +- src/tui/types.ts | 4 +- src/utils/sync-server.ts | 2 +- 35 files changed, 2226 insertions(+), 1169 deletions(-) create mode 100644 src/tui/components/QRCode.tsx create mode 100644 src/tui/screens/action-wizard/flows/DataWizardFlow.ts create mode 100644 src/tui/screens/action-wizard/flows/TransactionWizardFlow.ts create mode 100644 src/tui/screens/action-wizard/flows/WizardFlow.ts create mode 100644 src/tui/screens/action-wizard/flows/index.ts create mode 100644 src/tui/screens/action-wizard/hooks/useActionWizard.ts create mode 100644 src/tui/screens/action-wizard/hooks/useInvitationManager.ts create mode 100644 src/tui/screens/action-wizard/hooks/useRoleSelection.ts create mode 100644 src/tui/screens/action-wizard/hooks/useUtxoSelection.ts create mode 100644 src/tui/screens/action-wizard/hooks/useVariableInputs.ts create mode 100644 src/tui/screens/action-wizard/hooks/useWizardFocus.ts create mode 100644 src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts create mode 100644 src/tui/screens/action-wizard/hooks/useWizardSteps.ts create mode 100644 src/tui/screens/action-wizard/steps/DataResultStep.tsx delete mode 100644 src/tui/screens/action-wizard/steps/InfoStep.tsx delete mode 100644 src/tui/screens/action-wizard/useActionWizard.ts diff --git a/package-lock.json b/package-lock.json index af90f0c..8197613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,14 @@ "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.10", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "tsx": "^4.21.0", "typescript": "^5.9.3" @@ -35,14 +37,15 @@ "license": "MIT", "dependencies": { "@bitauth/libauth": "^3.1.0-next.8", - "@electrum-cash/application": "^0.2.2-development.11641378031", - "@electrum-cash/network": "^4.1.4", - "@electrum-cash/protocol": "^2.3.0-development.12185537348", - "@xo-cash/crypto": "0.0.1", - "@xo-cash/primitives": "0.0.1", - "@xo-cash/state": "0.0.1", - "@xo-cash/types": "0.0.1", - "@xo-cash/utils": "0.0.1", + "@electrum-cash/application": "^0.2.3-development.13424909069", + "@electrum-cash/network": "^4.2.2", + "@electrum-cash/protocol": "^2.3.1", + "@electrum-cash/servers": "^3.1.0", + "@xo-cash/crypto": "file:../crypto", + "@xo-cash/primitives": "file:../primitives", + "@xo-cash/state": "file:../state", + "@xo-cash/types": "file:../types", + "@xo-cash/utils": "file:../utils", "eventemitter3": "^5.0.1" }, "devDependencies": { @@ -110,7 +113,7 @@ "version": "0.0.1", "license": "MIT", "dependencies": { - "@xo-cash/types": "0.0.1" + "@xo-cash/types": "0.0.1-development.13504604083" }, "devDependencies": { "@chalp/eslint-airbnb": "^1.3.0", @@ -130,9 +133,6 @@ "typescript": "^5.3.2", "typescript-eslint": "^8.53.1", "vitest": "^4.0.17" - }, - "engines": { - "node": ">=18.0.0" } }, "../types": { @@ -160,9 +160,6 @@ "typescript": "^5.3.2", "typescript-eslint": "^8.53.1", "vitest": "^4.0.17" - }, - "engines": { - "node": ">=18.0.0" } }, "node_modules/@alcalzone/ansi-tokenize": { @@ -723,6 +720,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -896,6 +903,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -992,6 +1008,96 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -1004,6 +1110,24 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1051,6 +1175,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -1084,6 +1217,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1234,6 +1373,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -1255,6 +1407,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -1571,6 +1732,18 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lossless-json": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.3.0.tgz", @@ -1689,6 +1862,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1710,6 +1919,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1719,6 +1937,15 @@ "node": ">=8" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/powershell-utils": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", @@ -1782,6 +2009,23 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -1835,6 +2079,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1905,6 +2164,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2216,6 +2481,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", @@ -2275,6 +2546,97 @@ } } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/package.json b/package.json index d10c0b8..51154be 100644 --- a/package.json +++ b/package.json @@ -30,12 +30,14 @@ "better-sqlite3": "^12.6.2", "clipboardy": "^5.1.0", "ink": "^6.6.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.0.10", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "tsx": "^4.21.0", "typescript": "^5.9.3" diff --git a/src/services/app.ts b/src/services/app.ts index 3072f80..4c36349 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -17,6 +17,7 @@ import { EventEmitter } from '../utils/event-emitter.js'; // TODO: Remove this. Exists to hash the seed for database namespace. import { createHash } from 'crypto'; import { p2pkhTemplate } from '@xo-cash/templates'; +import { hexToBin } from '@bitauth/libauth'; export type AppEventMap = { 'invitation-added': Invitation; @@ -58,6 +59,8 @@ export class AppService extends EventEmitter { // Import the default P2PKH template await engine.importTemplate(p2pkhTemplate); + console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2)); + // 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? @@ -77,6 +80,25 @@ export class AppService extends EventEmitter { applicationIdentifier: config.electrumApplicationIdentifier, }); + // TEMP because testing is painful + // Remove all reserved UTXOs on startup + // First, get every unspent output + const allUnspentOutputs = await engine.listUnspentOutputsData(); + + // Get a set of all the invitation identifiers + const allInvitationIdentifiers = new Set(allUnspentOutputs.map(output => output.invitationIdentifier)); + + // Iterate over the invitation identifiers and unreserve the outputs + for (const invitationIdentifier of allInvitationIdentifiers) { + // Get the outputs for the invitation + const outputs = allUnspentOutputs.filter(output => output.invitationIdentifier === invitationIdentifier); + // Unreserve the outputs + await engine.unreserveResources(outputs.map(output => ({ + outpointTransactionHash: hexToBin(output.outpointTransactionHash), + outpointIndex: output.outpointIndex, + })), invitationIdentifier); + } + return new AppService(engine, walletStorage, config, electrum); } diff --git a/src/services/history.ts b/src/services/history.ts index 41e2464..5ce12f6 100644 --- a/src/services/history.ts +++ b/src/services/history.ts @@ -68,6 +68,55 @@ export class HistoryService { private invitations: Invitation[] ) {} + async extractEntities(invitation: XOInvitation): Promise { + const entities = new Set(); + for (const commit of invitation.commits) { + entities.add(commit.entityIdentifier); + } + return Array.from(entities); + } + + // Entities are currently static per invitation. So, we can try to match the roles to entities by: + // Iterating through each commit, extract the entity into a Map. + // While we iterate through the commits, if we see a role declaration in the commit, we save that role onto the entity's roles array. + // After we have iterated through all the commits, we can return the Map. + async matchRolesToEntities(invitation: XOInvitation, entities: string[]): Promise> { + const entitiesMap = new Map>(); + for (const entity of entities) { + entitiesMap.set(entity, new Set()); + } + + // First pass, we are just going to try and find roleIdentifer values in the inputs, outputs, and variables. + // TODO: Update this once the invitations use XPubs + for (const commit of invitation.commits) { + const entity = commit.entityIdentifier; + const roles = entitiesMap.get(entity) ?? new Set(); + + for (const input of commit.data.inputs ?? []) { + if (input.roleIdentifier) roles.add(input.roleIdentifier); + } + for (const output of commit.data.outputs ?? []) { + if (output.roleIdentifier) roles.add(output.roleIdentifier); + } + for (const variable of commit.data.variables ?? []) { + if (variable.roleIdentifier) roles.add(variable.roleIdentifier); + } + } + + // TODO: We might be able to use the lockingBytecodes that we have generated to infer which role we are. But the templates dont tell us which role is responsible for a particular output. + // I.e, if we dont know what role an output was from, we cant match it using the lockingBytecode to a role. + // Example: 2 inputs to a TX for the same amount. We dont know whether we would be Sender1 or Sender2. + // So, for now we are just going to rely on the roleIdentifiers that we have found in the first pass. + + // Format into a record for easier access. + const entitiesRecord: Record = {}; + for (const [entity, roles] of entitiesMap.entries()) { + entitiesRecord[entity] = Array.from(roles); + } + + return entitiesRecord; + } + async getHistory(): Promise { const allUtxos = await this.engine.listUnspentOutputsData(); const ownOutpoints = new Set(); @@ -94,6 +143,10 @@ export class HistoryService { walletEntityIdentifier, }); this.indexInvitationOutputsByUtxoOrigin(invitationByOrigin, invitation); + + const entities = await this.extractEntities(invitation.data); + const entitiesRecord = await this.matchRolesToEntities(invitation.data, entities); + console.log(entitiesRecord); } const usedUtxoIds = new Set(); @@ -288,6 +341,13 @@ export class HistoryService { }; } + /** + * TODO: This is completely incorrect. It should be deriving the roles of the user based on the entity IDs in the commits, then mapping each commit to Inputs/Outputs so we know what belongs to the user. + * There are a few changes that will need to be made to make this work: + * 1. Provide a way to derive all entity IDs of the user for the invitation (If we are going with XPub) + * 2. Provide a way to get only the User's commits (and their inputs/outputs) + * 3. (Maybe) Include role on inputs and outputs - This one might be fine with just using the commit entity id + */ private deriveWalletRolesForInvitation( context: InvitationContext, outputs: HistoryUtxoItem[] diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 5aad491..dc7dd08 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -412,7 +412,7 @@ export class Invitation extends EventEmitter { await this.syncServer.publishInvitation(this.data); } - async findSuitableResources(options: FindSuitableResourcesParameters): Promise { + async findSuitableResources(options: Partial = {}): Promise { // Find the suitable resources const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options); diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 59b37f2..f55cbf9 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -80,8 +80,12 @@ function StatusBar(): React.ReactElement { function DialogOverlay(): React.ReactElement | null { const { dialog, setDialog } = useDialog(); + // 'custom' dialogs are rendered and managed by the screen itself; + // we only handle input for the built-in dialog types. + const isBuiltInDialog = dialog?.visible === true && dialog.type !== 'custom'; + useInput((input, key) => { - if (!dialog?.visible) return; + if (!isBuiltInDialog) return; if (key.return || input === 'y' || input === 'Y') { if (dialog.type === 'confirm' && dialog.onConfirm) { @@ -92,9 +96,9 @@ function DialogOverlay(): React.ReactElement | null { } else if (key.escape || input === 'n' || input === 'N') { dialog.onCancel?.(); } - }, { isActive: dialog?.visible ?? false }); + }, { isActive: isBuiltInDialog }); - if (!dialog?.visible) return null; + if (!isBuiltInDialog) return null; const borderColor = dialog.type === 'error' ? colors.error : dialog.type === 'confirm' ? colors.warning : diff --git a/src/tui/components/QRCode.tsx b/src/tui/components/QRCode.tsx new file mode 100644 index 0000000..e437abd --- /dev/null +++ b/src/tui/components/QRCode.tsx @@ -0,0 +1,198 @@ +/** + * QR Code component for displaying scannable QR codes in the terminal. + * + * Uses the lower-half-block character (▄) exclusively for rendering. The top + * half of each cell is controlled via backgroundColor and the bottom half via + * the foreground color. This avoids the sub-pixel seams that occur when mixing + * different Unicode block characters (█, ▀, ▄, space) across adjacent rows. + */ + +import React, { useMemo } from 'react'; +import { Box, Text } from 'ink'; +import QRCodeLib from 'qrcode'; +import { DialogWrapper } from './Dialog.js'; +import { colors } from '../theme.js'; + +/** Color used for light (background) QR modules. */ +const LIGHT = 'white'; + +/** Color used for dark (data) QR modules. Must match the dialog/terminal bg. */ +const DARK = colors.bg as string; + +/** Default quiet zone size in modules (QR spec recommends 4, 2 is usually sufficient). */ +const QUIET_ZONE = 2; + +/** + * A run of consecutive characters in a rendered QR row that share the + * same foreground/background color pair. + */ +interface ColorSpan { + /** The repeated ▄ characters for this span. */ + chars: string; + /** Foreground color (controls the bottom half of each cell). */ + fg: string; + /** Background color (controls the top half of each cell). */ + bg: string; +} + +/** + * Props for the QRCode component. + */ +interface QRCodeProps { + /** The data to encode in the QR code. */ + value: string; + /** Whether to wrap the QR code in a DialogWrapper. */ + dialog?: boolean; + /** Dialog title (only used when dialog is true). Defaults to "QR Code". */ + dialogTitle?: string; + /** Whether to display the raw encoded value as copyable text above the QR code. */ + showValue?: boolean; +} + +/** + * Generates the QR code module matrix with a quiet zone border. + * + * @param value - The string to encode. + * @param quietZone - Number of light-module rows/columns to add around the QR data. + * @returns A 2D array where `true` means dark module and `false` means light module. + */ +function generateMatrix(value: string, quietZone: number = QUIET_ZONE): boolean[][] { + const qr = QRCodeLib.create(value, { errorCorrectionLevel: 'M' }); + const { size, data } = qr.modules; + const totalSize = size + quietZone * 2; + + const matrix: boolean[][] = []; + + for (let row = 0; row < totalSize; row++) { + const matrixRow: boolean[] = []; + + for (let col = 0; col < totalSize; col++) { + const qrRow = row - quietZone; + const qrCol = col - quietZone; + const insideData = qrRow >= 0 && qrRow < size && qrCol >= 0 && qrCol < size; + + // Quiet zone modules are always light (false). + matrixRow.push(insideData ? data[qrRow * size + qrCol] === 1 : false); + } + + matrix.push(matrixRow); + } + + return matrix; +} + +/** + * Converts a pair of module rows into an array of {@link ColorSpan}s. + * + * Every cell uses the `▄` (lower half block) character. The foreground color + * paints the bottom half and the backgroundColor paints the top half, giving + * us artifact-free rendering with a single glyph. + * + * Consecutive cells that share the same color pair are merged into one span + * to keep the element count low. + * + * @param matrix - The full module matrix. + * @param row - The index of the top row in the pair (the bottom row is row + 1). + * @returns An array of color spans for this terminal line. + */ +function buildRowSpans(matrix: boolean[][], row: number): ColorSpan[] { + const width = matrix[0]?.length ?? 0; + const spans: ColorSpan[] = []; + + for (let col = 0; col < width; col++) { + const topDark = matrix[row]?.[col] ?? false; + const bottomDark = matrix[row + 1]?.[col] ?? false; + + // ▄ lower-half block: foreground = bottom color, backgroundColor = top color + const fg = bottomDark ? DARK : LIGHT; + const bg = topDark ? DARK : LIGHT; + + const last = spans[spans.length - 1]; + if (last && last.fg === fg && last.bg === bg) { + last.chars += '▄'; + } else { + spans.push({ chars: '▄', fg, bg }); + } + } + + return spans; +} + +/** + * Renders the full module matrix into an array of span-arrays, one per + * terminal row (each covering two QR module rows). + * + * @param matrix - The 2D dark/light module matrix from {@link generateMatrix}. + */ +function renderMatrix(matrix: boolean[][]): ColorSpan[][] { + const rows: ColorSpan[][] = []; + const height = matrix.length; + + for (let row = 0; row < height; row += 2) { + rows.push(buildRowSpans(matrix, row)); + } + + return rows; +} + +/** + * Displays a scannable QR code in the terminal. + * + * Supports optional dialog wrapping via the `dialog` prop and an optional + * copyable text display of the encoded value via `showValue`. + * + * @example + * ```tsx + * // Minimal usage + * + * + * // Inside a dialog with the raw value shown + * + * ``` + */ +export function QRCode({ + value, + dialog = false, + dialogTitle = 'QR Code', + showValue = false, +}: QRCodeProps): React.ReactElement { + const { rows, moduleCount } = useMemo(() => { + const matrix = generateMatrix(value); + return { + rows: renderMatrix(matrix), + moduleCount: matrix[0]?.length ?? 0, + }; + }, [value]); + + const qrContent = ( + + {showValue && ( + + {value} + + )} + + {rows.map((spans, i) => ( + + {spans.map((span, j) => ( + + {span.chars} + + ))} + + ))} + + + ); + + if (dialog) { + const dialogWidth = Math.max(moduleCount + 8, 40); + return ( + + {qrContent} + + ); + } + + return qrContent; +} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index f3bc7c7..ac25d49 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -16,3 +16,4 @@ export { } from './List.js'; export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js'; export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js'; +export { QRCode } from './QRCode.js'; diff --git a/src/tui/screens/SeedInput.tsx b/src/tui/screens/SeedInput.tsx index b6b0a88..5b5649d 100644 --- a/src/tui/screens/SeedInput.tsx +++ b/src/tui/screens/SeedInput.tsx @@ -55,10 +55,7 @@ function loadMnemonicFiles(): MnemonicFileEntry[] { const parsed = BCHMnemonicURL.fromURL(content); const raw = parsed.toObject(); - console.log(raw); - const mnemonicResult = encodeBip39Mnemonic(raw.entropy); - console.log(mnemonicResult); if (typeof mnemonicResult === 'string') continue; /** Use the URL comment as the label, falling back to a cleaned-up filename. */ diff --git a/src/tui/screens/WalletState.tsx b/src/tui/screens/WalletState.tsx index 49156c6..326bb5f 100644 --- a/src/tui/screens/WalletState.tsx +++ b/src/tui/screens/WalletState.tsx @@ -10,12 +10,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import { ScrollableList, type ListItemData } from '../components/List.js'; +import { QRCode } from '../components/QRCode.js'; import { useNavigation } from '../hooks/useNavigation.js'; -import { useAppContext, useStatus } from '../hooks/useAppContext.js'; +import { useAppContext, useStatus, useDialog } 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 { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth'; // Import utility functions import { @@ -71,6 +73,7 @@ export function WalletStateScreen(): React.ReactElement { const { navigate } = useNavigation(); const { appService, showError, showInfo } = useAppContext(); const { setStatus } = useStatus(); + const { setDialog } = useDialog(); // State const [balance, setBalance] = useState<{ totalSatoshis: bigint; utxoCount: number } | null>(null); @@ -80,6 +83,9 @@ export function WalletStateScreen(): React.ReactElement { const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0); const [isLoading, setIsLoading] = useState(true); + /** Cash address to display in the QR code dialog (null when dialog is hidden). */ + const [qrAddress, setQrAddress] = useState(null); + /** * Refreshes wallet state. */ @@ -122,7 +128,7 @@ export function WalletStateScreen(): React.ReactElement { }, [refresh]); /** - * Generates a new receiving address. + * Generates a new receiving address and displays it as a QR code. */ const generateNewAddress = useCallback(async () => { if (!appService) { @@ -145,21 +151,33 @@ export function WalletStateScreen(): React.ReactElement { // Generate the template identifier const templateId = generateTemplateIdentifier(p2pkhTemplate); - // Generate the locking bytecode - const lockingBytecode = await appService.engine.generateLockingBytecode( + // Generate the locking bytecode (returned as a hex string) + const lockingBytecodeHex = await appService.engine.generateLockingBytecode( templateId, 'receiveOutput', 'receiver', ); - showInfo(`New address generated!\n\nLocking bytecode:\n${formatHex(lockingBytecode, 40)}`); + // Convert the locking bytecode to a BCH cash address for display and QR encoding. + const result = lockingBytecodeToCashAddress({ bytecode: hexToBin(lockingBytecodeHex), prefix: 'bitcoincash' }); + + if (typeof result === 'string') { + showError(`Failed to encode address: ${result}`); + return; + } + + console.log(result); + + setQrAddress(result.address); + setDialog({ visible: true, type: 'custom', message: '' }); + setStatus('Address generated'); // Refresh to show updated state await refresh(); } catch (error) { showError(`Failed to generate address: ${error instanceof Error ? error.message : String(error)}`); } - }, [appService, setStatus, showInfo, showError, refresh]); + }, [appService, setStatus, showError, refresh]); /** * Handles menu action. @@ -209,8 +227,16 @@ export function WalletStateScreen(): React.ReactElement { }); }, [history]); - // Handle keyboard navigation between panels + // Handle keyboard navigation between panels and QR dialog dismissal useInput((input, key) => { + if (qrAddress) { + if (key.escape || key.return) { + setQrAddress(null); + setDialog(null); + } + return; + } + if (key.tab) { setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu'); } @@ -400,6 +426,26 @@ export function WalletStateScreen(): React.ReactElement { Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back + + {/* QR Code dialog overlay for generated addresses */} + {qrAddress && ( + + + + Press Enter or Esc to close + + + )} ); } diff --git a/src/tui/screens/action-wizard/ActionWizardScreen.tsx b/src/tui/screens/action-wizard/ActionWizardScreen.tsx index ae97d2b..1697599 100644 --- a/src/tui/screens/action-wizard/ActionWizardScreen.tsx +++ b/src/tui/screens/action-wizard/ActionWizardScreen.tsx @@ -1,149 +1,22 @@ import React from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text } from 'ink'; import { StepIndicator, type Step } from '../../components/ProgressBar.js'; import { Button } from '../../components/Button.js'; import { colors, logoSmall } from '../../theme.js'; -import { useActionWizard } from './useActionWizard.js'; +import { useActionWizard } from './hooks/useActionWizard.js'; +import { useWizardKeyboard } from './hooks/useWizardKeyboard.js'; // Steps -import { InfoStep } from './steps/InfoStep.js'; import { RoleSelectStep } from './steps/RoleSelectStep.js'; import { VariablesStep } from './steps/VariablesStep.js'; import { InputsStep } from './steps/InputsStep.js'; import { ReviewStep } from './steps/ReviewStep.js'; import { PublishStep } from './steps/PublishStep.js'; +import { DataResultStep } from './steps/DataResultStep.js'; export function ActionWizardScreen(): React.ReactElement { const wizard = useActionWizard(); - - // ── Keyboard handling ────────────────────────────────────────── - useInput( - (input, key) => { - // Tab to cycle between content area and button bar - if (key.tab) { - if (wizard.focusArea === 'content') { - // Within the role-select step, tab through roles first - if ( - wizard.currentStepData?.type === 'role-select' && - wizard.availableRoles.length > 0 - ) { - if ( - wizard.selectedRoleIndex < - wizard.availableRoles.length - 1 - ) { - wizard.setSelectedRoleIndex((prev) => prev + 1); - return; - } - } - // Within the inputs step, tab through UTXOs first - if ( - wizard.currentStepData?.type === 'inputs' && - wizard.availableUtxos.length > 0 - ) { - if ( - wizard.selectedUtxoIndex < - wizard.availableUtxos.length - 1 - ) { - wizard.setSelectedUtxoIndex((prev) => prev + 1); - return; - } - } - // Move focus down to the button bar - wizard.setFocusArea('buttons'); - wizard.setFocusedButton('next'); - } else { - // Cycle through buttons, then wrap back to content - if (wizard.focusedButton === 'back') { - wizard.setFocusedButton('cancel'); - } else if (wizard.focusedButton === 'cancel') { - wizard.setFocusedButton('next'); - } else { - wizard.setFocusArea('content'); - wizard.setFocusedInput(0); - wizard.setSelectedUtxoIndex(0); - wizard.setSelectedRoleIndex(0); - } - } - return; - } - - // Arrow keys for role selection in the content area - if ( - wizard.focusArea === 'content' && - wizard.currentStepData?.type === 'role-select' - ) { - if (key.upArrow) { - wizard.setSelectedRoleIndex((p) => Math.max(0, p - 1)); - } else if (key.downArrow) { - wizard.setSelectedRoleIndex((p) => - Math.min(wizard.availableRoles.length - 1, p + 1) - ); - } - return; - } - - // Arrow keys for UTXO selection in the content area - if ( - wizard.focusArea === 'content' && - wizard.currentStepData?.type === 'inputs' - ) { - if (key.upArrow) { - wizard.setSelectedUtxoIndex((p) => Math.max(0, p - 1)); - } else if (key.downArrow) { - wizard.setSelectedUtxoIndex((p) => - Math.min(wizard.availableUtxos.length - 1, p + 1) - ); - } else if (key.return || input === ' ') { - wizard.toggleUtxoSelection(wizard.selectedUtxoIndex); - } - return; - } - - // Arrow keys in button bar - if (wizard.focusArea === 'buttons') { - if (key.leftArrow) { - wizard.setFocusedButton((p) => - p === 'next' ? 'cancel' : p === 'cancel' ? 'back' : 'back' - ); - } else if (key.rightArrow) { - wizard.setFocusedButton((p) => - p === 'back' ? 'cancel' : p === 'cancel' ? 'next' : 'next' - ); - } - - // Enter on a button - if (key.return) { - if (wizard.focusedButton === 'back') wizard.previousStep(); - else if (wizard.focusedButton === 'cancel') wizard.cancel(); - else if (wizard.focusedButton === 'next') wizard.nextStep(); - } - } - - // 'c' to copy invitation ID on the publish step - if ( - input === 'c' && - wizard.currentStepData?.type === 'publish' && - wizard.invitationId - ) { - wizard.copyId(); - } - - // 'a' to select all UTXOs - if (input === 'a' && wizard.currentStepData?.type === 'inputs') { - wizard.setAvailableUtxos((p) => - p.map((u) => ({ ...u, selected: true })) - ); - } - - // 'n' to deselect all UTXOs - if (input === 'n' && wizard.currentStepData?.type === 'inputs') { - wizard.setAvailableUtxos((p) => - p.map((u) => ({ ...u, selected: false })) - ); - } - }, - { isActive: !wizard.textInputHasFocus } - ); + useWizardKeyboard(wizard); // ── Step router ──────────────────────────────────────────────── const renderStep = () => { @@ -152,15 +25,6 @@ export function ActionWizardScreen(): React.ReactElement { } switch (wizard.currentStepData?.type) { - case 'info': - return ( - - ); case 'role-select': return ( ); + case 'result': + return ( + + ); default: return null; } @@ -278,7 +150,7 @@ export function ActionWizardScreen(): React.ReactElement { wizard.focusArea === "buttons" && wizard.focusedButton === "back" } - disabled={wizard.currentStepData?.type === "publish"} + disabled={wizard.isLastStep} />