Compare commits
6 Commits
66e9918e04
...
b475b23beb
| Author | SHA1 | Date | |
|---|---|---|---|
| b475b23beb | |||
| 7fd89c5663 | |||
| a28d43a68b | |||
| be52f73e64 | |||
| dd275593cd | |||
| 9ef1720e1f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,4 +7,6 @@ dist/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.sqlite-journal
|
||||||
resolvedTemplate.json
|
resolvedTemplate.json
|
||||||
|
mnemonic-*
|
||||||
654
package-lock.json
generated
654
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
@@ -17,15 +18,16 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"prettier": "^3.8.1",
|
||||||
"ink-spinner": "^5.0.0",
|
"qrcode": "^1.5.4",
|
||||||
"ink-text-input": "^6.0.0",
|
"react": "^19.2.4",
|
||||||
"react": "^19.2.4"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
@@ -36,27 +38,35 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.1.0-next.8",
|
"@bitauth/libauth": "^3.1.0-next.8",
|
||||||
"@electrum-cash/application": "^0.2.2-development.11641378031",
|
"@electrum-cash/application": "^0.2.3-development.13424909069",
|
||||||
"@electrum-cash/network": "^4.1.4",
|
"@electrum-cash/network": "^4.2.2",
|
||||||
"@electrum-cash/protocol": "^2.3.0-development.12185537348",
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/crypto": "0.0.1",
|
"@electrum-cash/servers": "^3.1.0",
|
||||||
"@xo-cash/primitives": "0.0.1",
|
"@xo-cash/crypto": "file:../crypto",
|
||||||
"@xo-cash/state": "0.0.1",
|
"@xo-cash/primitives": "file:../primitives",
|
||||||
"@xo-cash/templates": "0.0.1",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/types": "0.0.1",
|
"@xo-cash/types": "file:../types",
|
||||||
"@xo-cash/utils": "0.0.1",
|
"@xo-cash/utils": "file:../utils",
|
||||||
"eventemitter3": "^5.0.1"
|
"eventemitter3": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@generalprotocols/eslint-config": "^1.0.1",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
"@types/node": "^20.10.0",
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
"del-cli": "^7.0.0",
|
"@stylistic/eslint-plugin": "^5.7.0",
|
||||||
"eslint": "^8.57.1",
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
"eslint-plugin-prettier": "^5.5.4",
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
|
"@viz-kit/esbuild-analyzer": "^1.0.0",
|
||||||
|
"@xo-cash/eslint-config": "1.0.1",
|
||||||
|
"cspell": "^9.6.0",
|
||||||
|
"eslint": "^9.39.2",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"typedoc": "^0.28.15",
|
"tsdown": "^0.20.0-beta.4",
|
||||||
"typescript": "^5.3.2"
|
"typedoc": "^0.28.16",
|
||||||
|
"typedoc-plugin-coverage": "^4.0.2",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"typescript-eslint": "^8.53.1",
|
||||||
|
"vitest": "^4.0.17"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -78,6 +88,7 @@
|
|||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
"@generalprotocols/cspell-dictionary": "^1.0.1",
|
||||||
"@stylistic/eslint-plugin": "^5.7.0",
|
"@stylistic/eslint-plugin": "^5.7.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
@@ -103,7 +114,7 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xo-cash/types": "0.0.1"
|
"@xo-cash/types": "0.0.1-development.13504604083"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chalp/eslint-airbnb": "^1.3.0",
|
"@chalp/eslint-airbnb": "^1.3.0",
|
||||||
@@ -123,9 +134,6 @@
|
|||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"../types": {
|
"../types": {
|
||||||
@@ -153,9 +161,6 @@
|
|||||||
"typescript": "^5.3.2",
|
"typescript": "^5.3.2",
|
||||||
"typescript-eslint": "^8.53.1",
|
"typescript-eslint": "^8.53.1",
|
||||||
"vitest": "^4.0.17"
|
"vitest": "^4.0.17"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alcalzone/ansi-tokenize": {
|
"node_modules/@alcalzone/ansi-tokenize": {
|
||||||
@@ -180,6 +185,54 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@electrum-cash/debug-logs": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/debug-logs/-/debug-logs-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-GU/CvRR9lZ0d8gy9CXGW7f//OHCIydBavv9q+JcxjGj8Xr7HwlGqHx+Wzhx9y3YmJrXfExpgClcd++gTjdEmzA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.3.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/network": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/network/-/network-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-v2Wwt2o0VBBALPArx2dJEDvSqewKjiTW5KAd+jEXxxgdSxFygjJIrFcXryKOCv2CDbpE5+lXAogPAjx6FqW/nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electrum-cash/debug-logs": "^1.0.0",
|
||||||
|
"@electrum-cash/web-socket": "^1.2.3",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"debug": "^4.3.2",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lossless-json": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/protocol": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/protocol/-/protocol-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-fVahMlWAl4hfbLba8yM5ko/D4Rc0FpyQ20rILzjOyj1R0VymmaJ3SuiiHmvTbe2enZFyjKTV4v2oL+7bs6YNkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/network": "^4.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electrum-cash/web-socket": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electrum-cash/web-socket/-/web-socket-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-sFWujTt98mvsMvE6gWjG44evkF3PcZhG1JffkkEUAVan+c9X51wkiWr3hkjPeIW0WfNpMTrFkvpGqVYmRj1RCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electrum-cash/debug-logs": "^1.0.0",
|
||||||
|
"@monsterbitar/isomorphic-ws": "^5.3.0",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lossless-json": "^4.0.1",
|
||||||
|
"ws": "^8.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||||
@@ -622,6 +675,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@monsterbitar/isomorphic-ws": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monsterbitar/isomorphic-ws/-/isomorphic-ws-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-BWfWUffbg3uO4K6Cyokg9ff43lPaXAOZcCnNe1lcjCjUMDVrRAb5qEHG5qeJp3ud2SPYbORaNsls5as6SR3oig==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"ws": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@sec-ant/readable-stream": {
|
"node_modules/@sec-ant/readable-stream": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
|
||||||
@@ -654,30 +716,40 @@
|
|||||||
"version": "25.0.10",
|
"version": "25.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz",
|
||||||
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
|
"integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/qrcode": {
|
||||||
"version": "15.7.15",
|
"version": "1.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.27",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xo-cash/engine": {
|
"node_modules/@xo-cash/engine": {
|
||||||
"resolved": "../engine",
|
"resolved": "../engine",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -733,6 +805,15 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async-mutex": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/auto-bind": {
|
"node_modules/auto-bind": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz",
|
||||||
@@ -823,6 +904,15 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@@ -868,18 +958,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cli-spinners": {
|
|
||||||
"version": "2.9.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
|
|
||||||
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cli-truncate": {
|
"node_modules/cli-truncate": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||||
@@ -931,6 +1009,96 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/code-excerpt": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
|
||||||
@@ -943,6 +1111,24 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"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": {
|
"node_modules/convert-to-spaces": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz",
|
||||||
@@ -973,6 +1159,32 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -1006,6 +1218,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||||
@@ -1094,6 +1312,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz",
|
||||||
@@ -1150,6 +1374,19 @@
|
|||||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fs-constants": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||||
@@ -1171,6 +1408,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"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": {
|
"node_modules/get-east-asian-width": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||||
@@ -1318,56 +1564,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ink-select-input": {
|
|
||||||
"version": "6.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz",
|
|
||||||
"integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"figures": "^6.1.0",
|
|
||||||
"to-rotated": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=5.0.0",
|
|
||||||
"react": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink-spinner": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cli-spinners": "^2.7.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.16"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=4.0.0",
|
|
||||||
"react": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink-text-input": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chalk": "^5.3.0",
|
|
||||||
"type-fest": "^4.18.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"ink": ">=5",
|
|
||||||
"react": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ink/node_modules/signal-exit": {
|
"node_modules/ink/node_modules/signal-exit": {
|
||||||
"version": "3.0.7",
|
"version": "3.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||||
@@ -1537,6 +1733,24 @@
|
|||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
"license": "ISC"
|
"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",
|
||||||
|
"integrity": "sha512-ToxOC+SsduRmdSuoLZLYAr5zy1Qu7l5XhmPWM3zefCZ5IcrzW/h108qbJUKfOlDlhvhjUK84+8PSVX0kxnit0g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mimic-fn": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
@@ -1573,6 +1787,12 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -1643,6 +1863,42 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/parse-ms": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||||
@@ -1664,6 +1920,15 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -1673,6 +1938,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/powershell-utils": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz",
|
||||||
@@ -1711,6 +1985,21 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-ms": {
|
"node_modules/pretty-ms": {
|
||||||
"version": "9.3.0",
|
"version": "9.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||||
@@ -1736,6 +2025,23 @@
|
|||||||
"once": "^1.3.1"
|
"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": {
|
"node_modules/rc": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||||
@@ -1789,6 +2095,21 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"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",
|
||||||
@@ -1859,6 +2180,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -2067,17 +2394,11 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/to-rotated": {
|
"node_modules/tslib": {
|
||||||
"version": "1.0.0",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "MIT",
|
"license": "0BSD"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.21.0",
|
||||||
@@ -2141,7 +2462,6 @@
|
|||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
@@ -2177,6 +2497,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/widest-line": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz",
|
||||||
@@ -2236,6 +2562,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": {
|
"node_modules/yoctocolors": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
|
||||||
@@ -2253,6 +2670,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz",
|
||||||
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
"integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
package.json
18
package.json
@@ -7,7 +7,11 @@
|
|||||||
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
"dev": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com tsx src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
"start": "SYNC_SERVER_URL=https://sync.xo.harvmaster.com node dist/index.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"nuke": "tsx scripts/rm-dbs.ts",
|
||||||
|
"nuke:dry": "tsx scripts/rm-dbs.ts --dry",
|
||||||
|
"format": "prettier --write \"**/*.{js,ts,md,json}\" --ignore-path .gitignore",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"crypto",
|
"crypto",
|
||||||
@@ -20,6 +24,7 @@
|
|||||||
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
"description": "XO Wallet CLI - Terminal User Interface for XO crypto wallet",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitauth/libauth": "^3.0.0",
|
"@bitauth/libauth": "^3.0.0",
|
||||||
|
"@electrum-cash/protocol": "^2.3.1",
|
||||||
"@xo-cash/engine": "file:../engine",
|
"@xo-cash/engine": "file:../engine",
|
||||||
"@xo-cash/state": "file:../state",
|
"@xo-cash/state": "file:../state",
|
||||||
"@xo-cash/templates": "file:../templates",
|
"@xo-cash/templates": "file:../templates",
|
||||||
@@ -27,15 +32,16 @@
|
|||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"clipboardy": "^5.1.0",
|
"clipboardy": "^5.1.0",
|
||||||
"ink": "^6.6.0",
|
"ink": "^6.6.0",
|
||||||
"ink-select-input": "^6.0.0",
|
"prettier": "^3.8.1",
|
||||||
"ink-spinner": "^5.0.0",
|
"qrcode": "^1.5.4",
|
||||||
"ink-text-input": "^6.0.0",
|
"react": "^19.2.4",
|
||||||
"react": "^19.2.4"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.0.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
|
|||||||
40
scripts/rm-dbs.ts
Normal file
40
scripts/rm-dbs.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import fs from "fs/promises";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all the databases without the use of external tools
|
||||||
|
* TODO: Fix the ts linking issue here. Should just be adding this as a dir in tsconfig.json
|
||||||
|
*/
|
||||||
|
const rmDbs = async (dry = false) => {
|
||||||
|
// First, we need to find all the database base files
|
||||||
|
// These end in either .db.sqlite, .sqlite, .db
|
||||||
|
// Get all the files in the current directory
|
||||||
|
const files = await fs.readdir("./");
|
||||||
|
|
||||||
|
// Filter out the files that end in .db.sqlite, .sqlite, .db
|
||||||
|
const dbFiles = files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.endsWith(".db.sqlite") ||
|
||||||
|
file.endsWith(".sqlite") ||
|
||||||
|
file.endsWith(".db"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// We need to remove all the files
|
||||||
|
await deleteFiles(dbFiles, dry);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFiles = async (files: string[], dry = false) => {
|
||||||
|
if (dry) {
|
||||||
|
console.log("Dry run, would delete:", files);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(files.map((file) => fs.rm(file)));
|
||||||
|
console.log("All databases removed");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read args
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dry = args.includes("--dry");
|
||||||
|
|
||||||
|
// Delete the files
|
||||||
|
await rmDbs(dry);
|
||||||
21
src/app.ts
21
src/app.ts
@@ -3,9 +3,9 @@
|
|||||||
* Simplified to render TUI immediately and let it handle AppService creation.
|
* Simplified to render TUI immediately and let it handle AppService creation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { render, type Instance } from 'ink';
|
import { render, type Instance } from "ink";
|
||||||
import { App as AppComponent } from './tui/App.js';
|
import { App as AppComponent } from "./tui/App.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for the CLI application.
|
* Configuration options for the CLI application.
|
||||||
@@ -48,13 +48,14 @@ export class App {
|
|||||||
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
static async create(config: Partial<AppConfig> = {}): Promise<App> {
|
||||||
// Set default configuration
|
// Set default configuration
|
||||||
const fullConfig: AppConfig = {
|
const fullConfig: AppConfig = {
|
||||||
syncServerUrl: config.syncServerUrl ?? 'http://localhost:3000',
|
syncServerUrl: config.syncServerUrl ?? "http://localhost:3000",
|
||||||
databasePath: config.databasePath ?? './',
|
databasePath: config.databasePath ?? "./",
|
||||||
databaseFilename: config.databaseFilename ?? 'xo-wallet.db',
|
databaseFilename: config.databaseFilename ?? "xo-wallet.db",
|
||||||
invitationStoragePath: config.invitationStoragePath ?? './xo-invitations.db',
|
invitationStoragePath:
|
||||||
|
config.invitationStoragePath ?? "./xo-invitations.db",
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('Full config:', fullConfig);
|
console.log("Full config:", fullConfig);
|
||||||
|
|
||||||
const app = new App(fullConfig);
|
const app = new App(fullConfig);
|
||||||
await app.start();
|
await app.start();
|
||||||
@@ -71,11 +72,13 @@ export class App {
|
|||||||
this.inkInstance = render(
|
this.inkInstance = render(
|
||||||
React.createElement(AppComponent, {
|
React.createElement(AppComponent, {
|
||||||
config: this.config,
|
config: this.config,
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait for the app to exit
|
// Wait for the app to exit
|
||||||
await this.inkInstance.waitUntilExit();
|
await this.inkInstance.waitUntilExit();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -9,7 +9,7 @@
|
|||||||
* 5. Real-time updates via SSE
|
* 5. Real-time updates via SSE
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { App } from './app.js';
|
import { App } from "./app.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main entry point.
|
* Main entry point.
|
||||||
@@ -18,12 +18,12 @@ async function main(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
// Create and start the application
|
// Create and start the application
|
||||||
await App.create({
|
await App.create({
|
||||||
syncServerUrl: process.env['SYNC_SERVER_URL'] ?? 'http://localhost:3000',
|
syncServerUrl: process.env["SYNC_SERVER_URL"] ?? "http://localhost:3000",
|
||||||
databasePath: process.env['DB_PATH'] ?? './',
|
databasePath: process.env["DB_PATH"] ?? "./",
|
||||||
databaseFilename: process.env['DB_FILENAME'] ?? 'xo-wallet.db',
|
databaseFilename: process.env["DB_FILENAME"] ?? "xo-wallet.db",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start XO Wallet CLI:', error);
|
console.error("Failed to start XO Wallet CLI:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,29 +3,33 @@ import {
|
|||||||
type XOEngineOptions,
|
type XOEngineOptions,
|
||||||
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
// This is temporary. Will likely be moved to where we import templates in the cli. I think that makes more sense as this is a library thing
|
||||||
generateTemplateIdentifier,
|
generateTemplateIdentifier,
|
||||||
} from '@xo-cash/engine';
|
} from "@xo-cash/engine";
|
||||||
import type { XOInvitation } from '@xo-cash/types';
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
|
|
||||||
import { Invitation } from './invitation.js';
|
import { Invitation } from "./invitation.js";
|
||||||
import { Storage } from './storage.js';
|
import { Storage } from "./storage.js";
|
||||||
import { SyncServer } from '../utils/sync-server.js';
|
import { SyncServer } from "../utils/sync-server.js";
|
||||||
import { HistoryService } from './history.js';
|
import { HistoryService } from "./history.js";
|
||||||
|
import { ElectrumService } from "./electrum.js";
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js';
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
|
|
||||||
// TODO: Remove this. Exists to hash the seed for database namespace.
|
// TODO: Remove this. Exists to hash the seed for database namespace.
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from "crypto";
|
||||||
import { p2pkhTemplate } from '@xo-cash/templates';
|
import { p2pkhTemplate } from "@xo-cash/templates";
|
||||||
|
import { hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
export type AppEventMap = {
|
export type AppEventMap = {
|
||||||
'invitation-added': Invitation;
|
"invitation-added": Invitation;
|
||||||
'invitation-removed': Invitation;
|
"invitation-removed": Invitation;
|
||||||
}
|
};
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
syncServerUrl: string;
|
syncServerUrl: string;
|
||||||
engineConfig: XOEngineOptions;
|
engineConfig: XOEngineOptions;
|
||||||
invitationStoragePath: string;
|
invitationStoragePath: string;
|
||||||
|
electrumHost?: string;
|
||||||
|
electrumApplicationIdentifier?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AppService extends EventEmitter<AppEventMap> {
|
export class AppService extends EventEmitter<AppEventMap> {
|
||||||
@@ -33,13 +37,14 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
public storage: Storage;
|
public storage: Storage;
|
||||||
public config: AppConfig;
|
public config: AppConfig;
|
||||||
public history: HistoryService;
|
public history: HistoryService;
|
||||||
|
public electrum: ElectrumService;
|
||||||
|
|
||||||
public invitations: Invitation[] = [];
|
public invitations: Invitation[] = [];
|
||||||
|
|
||||||
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
static async create(seed: string, config: AppConfig): Promise<AppService> {
|
||||||
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
// Because of a bug that lets wallets read the unspents of other wallets, we are going to manually namespace the storage paths for the app.
|
||||||
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
// We are going to do this by computing a hash of the seed and prefixing the storage paths with it.
|
||||||
const seedHash = createHash('sha256').update(seed).digest('hex');
|
const seedHash = createHash("sha256").update(seed).digest("hex");
|
||||||
|
|
||||||
// We want to only prefix the file name
|
// We want to only prefix the file name
|
||||||
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
const prefixedStoragePath = `${seedHash.slice(0, 8)}-${config.engineConfig.databaseFilename}`;
|
||||||
@@ -54,38 +59,88 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
// Import the default P2PKH template
|
// Import the default P2PKH template
|
||||||
await engine.importTemplate(p2pkhTemplate);
|
await engine.importTemplate(p2pkhTemplate);
|
||||||
|
|
||||||
|
// console.log('p2pkhTemplate', JSON.stringify(p2pkhTemplate.transactions, null, 2));
|
||||||
|
|
||||||
// Set default locking parameters for P2PKH
|
// Set default locking parameters for P2PKH
|
||||||
|
// To my knowledge, this doesnt generate any lockscript, so discovery of funds will not work automatically.
|
||||||
|
// TODO: Add discovery for funds in the first index? Or until we return 0 TXs?
|
||||||
await engine.setDefaultLockingParameters(
|
await engine.setDefaultLockingParameters(
|
||||||
generateTemplateIdentifier(p2pkhTemplate),
|
generateTemplateIdentifier(p2pkhTemplate),
|
||||||
'receiveOutput',
|
"receiveOutput",
|
||||||
'receiver',
|
"receiver",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create our own storage for the invitations
|
// Create our own storage for the invitations
|
||||||
const storage = await Storage.create(config.invitationStoragePath);
|
const storage = await Storage.create(config.invitationStoragePath);
|
||||||
|
const walletStorage = await storage.child(seedHash.slice(0, 8));
|
||||||
|
|
||||||
// Create the app service
|
// Create the app service
|
||||||
return new AppService(engine, storage, config);
|
const electrum = new ElectrumService({
|
||||||
|
host: config.electrumHost,
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(engine: Engine, storage: Storage, config: AppConfig) {
|
constructor(
|
||||||
|
engine: Engine,
|
||||||
|
storage: Storage,
|
||||||
|
config: AppConfig,
|
||||||
|
electrum: ElectrumService,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.engine = engine;
|
this.engine = engine;
|
||||||
this.storage = storage;
|
this.storage = storage;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.electrum = electrum;
|
||||||
this.history = new HistoryService(engine, this.invitations);
|
this.history = new HistoryService(engine, this.invitations);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createInvitation(invitation: XOInvitation | string): Promise<Invitation> {
|
async createInvitation(
|
||||||
|
invitation: XOInvitation | string,
|
||||||
|
): Promise<Invitation> {
|
||||||
// Make sure the engine has the template imported
|
// Make sure the engine has the template imported
|
||||||
const invitationStorage = this.storage.child('invitations')
|
const invitationStorage = this.storage.child("invitations");
|
||||||
const invitationSyncServer = new SyncServer(this.config.syncServerUrl, typeof invitation === 'string' ? invitation : invitation.invitationIdentifier);
|
const invitationSyncServer = new SyncServer(
|
||||||
|
this.config.syncServerUrl,
|
||||||
|
typeof invitation === "string"
|
||||||
|
? invitation
|
||||||
|
: invitation.invitationIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
const deps = {
|
const deps = {
|
||||||
engine: this.engine,
|
engine: this.engine,
|
||||||
syncServer: invitationSyncServer,
|
syncServer: invitationSyncServer,
|
||||||
storage: invitationStorage,
|
storage: invitationStorage,
|
||||||
|
electrum: this.electrum,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create the invitation
|
// Create the invitation
|
||||||
@@ -102,26 +157,31 @@ export class AppService extends EventEmitter<AppEventMap> {
|
|||||||
this.invitations.push(invitation);
|
this.invitations.push(invitation);
|
||||||
|
|
||||||
// Emit the invitation-added event
|
// Emit the invitation-added event
|
||||||
this.emit('invitation-added', invitation);
|
this.emit("invitation-added", invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeInvitation(invitation: Invitation): Promise<void> {
|
async removeInvitation(invitation: Invitation): Promise<void> {
|
||||||
// Remove the invitation from the invitations array
|
// Remove the invitation from the invitations array
|
||||||
this.invitations = this.invitations.filter(i => i !== invitation);
|
this.invitations = this.invitations.filter((i) => i !== invitation);
|
||||||
|
|
||||||
// Emit the invitation-removed event
|
// Emit the invitation-removed event
|
||||||
this.emit('invitation-removed', invitation);
|
this.emit("invitation-removed", invitation);
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Get the invitations db
|
// Get the invitations db
|
||||||
const invitationsDb = this.storage.child('invitations');
|
const invitationsDb = this.storage.child("invitations");
|
||||||
|
|
||||||
// Load invitations from storage
|
// Load invitations from storage
|
||||||
const invitations = await invitationsDb.all() as { key: string; value: XOInvitation }[];
|
const invitations = (await invitationsDb.all()) as {
|
||||||
|
key: string;
|
||||||
|
value: XOInvitation;
|
||||||
|
}[];
|
||||||
|
|
||||||
await Promise.all(invitations.map(async ({ key }) => {
|
await Promise.all(
|
||||||
await this.createInvitation(key);
|
invitations.map(async ({ key }) => {
|
||||||
}));
|
await this.createInvitation(key);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
53
src/services/electrum.ts
Normal file
53
src/services/electrum.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
fetchTransactionBlockHeight,
|
||||||
|
initializeElectrumClient,
|
||||||
|
} from "@electrum-cash/protocol";
|
||||||
|
|
||||||
|
export interface ElectrumServiceConfig {
|
||||||
|
host?: string;
|
||||||
|
applicationIdentifier?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small Electrum adapter used by CLI services.
|
||||||
|
* Keeps connection logic in one place and exposes a tiny API.
|
||||||
|
*/
|
||||||
|
export class ElectrumService {
|
||||||
|
private readonly host: string;
|
||||||
|
private readonly applicationIdentifier: string;
|
||||||
|
private clientPromise?: ReturnType<typeof initializeElectrumClient>;
|
||||||
|
|
||||||
|
constructor(config: ElectrumServiceConfig = {}) {
|
||||||
|
this.host =
|
||||||
|
config.host ?? process.env["ELECTRUM_HOST"] ?? "bch.imaginary.cash";
|
||||||
|
this.applicationIdentifier = "xo-cli";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getClient() {
|
||||||
|
if (!this.clientPromise) {
|
||||||
|
this.clientPromise = initializeElectrumClient(
|
||||||
|
this.applicationIdentifier,
|
||||||
|
this.host,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.clientPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the transaction is known by Electrum
|
||||||
|
* (confirmed or currently in mempool).
|
||||||
|
*/
|
||||||
|
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const client = await this.getClient();
|
||||||
|
const height = await fetchTransactionBlockHeight(client, transactionHash);
|
||||||
|
|
||||||
|
// Electrum returns numbers for known transactions
|
||||||
|
// (e.g. >0 confirmed, 0/-1 unconfirmed variants).
|
||||||
|
return typeof height === "number";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,84 @@
|
|||||||
import type { AcceptInvitationParameters, AppendInvitationParameters, Engine, FindSuitableResourcesParameters } from '@xo-cash/engine';
|
import type {
|
||||||
import { hasInvitationExpired } from '@xo-cash/engine';
|
AcceptInvitationParameters,
|
||||||
import type { XOInvitation, XOInvitationCommit, XOInvitationInput, XOInvitationOutput, XOInvitationVariable } from '@xo-cash/types';
|
AppendInvitationParameters,
|
||||||
import type { UnspentOutputData } from '@xo-cash/state';
|
Engine,
|
||||||
|
FindSuitableResourcesParameters,
|
||||||
|
} from "@xo-cash/engine";
|
||||||
|
import { hasInvitationExpired, mergeInvitationCommits } from "@xo-cash/engine";
|
||||||
|
import type {
|
||||||
|
XOInvitation,
|
||||||
|
XOInvitationCommit,
|
||||||
|
XOInvitationInput,
|
||||||
|
XOInvitationOutput,
|
||||||
|
XOInvitationVariable,
|
||||||
|
XOInvitationVariableValue,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
import type { UnspentOutputData } from "@xo-cash/state";
|
||||||
|
import {
|
||||||
|
binToHex,
|
||||||
|
encodeTransaction,
|
||||||
|
generateTransaction,
|
||||||
|
hashTransaction,
|
||||||
|
hexToBin,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
|
||||||
import type { SSEvent } from '../utils/sse-client.js';
|
import type { SSEvent } from "../utils/sse-client.js";
|
||||||
import type { SyncServer } from '../utils/sync-server.js';
|
import type { SyncServer } from "../utils/sync-server.js";
|
||||||
import type { Storage } from './storage.js';
|
import type { Storage } from "./storage.js";
|
||||||
|
import type { ElectrumService } from "./electrum.js";
|
||||||
|
|
||||||
import { EventEmitter } from '../utils/event-emitter.js'
|
import { EventEmitter } from "../utils/event-emitter.js";
|
||||||
import { decodeExtendedJsonObject } from '../utils/ext-json.js';
|
import { decodeExtendedJsonObject } from "../utils/ext-json.js";
|
||||||
|
import { compileCashAssemblyString } from "@xo-cash/engine";
|
||||||
|
|
||||||
export type InvitationEventMap = {
|
export type InvitationEventMap = {
|
||||||
'invitation-updated': XOInvitation;
|
"invitation-updated": XOInvitation;
|
||||||
'invitation-status-changed': string;
|
"invitation-status-changed": string;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type InvitationDependencies = {
|
export type InvitationDependencies = {
|
||||||
syncServer: SyncServer;
|
syncServer: SyncServer;
|
||||||
storage: Storage;
|
storage: Storage;
|
||||||
engine: Engine;
|
engine: Engine;
|
||||||
}
|
electrum: ElectrumService;
|
||||||
|
};
|
||||||
|
|
||||||
export class Invitation extends EventEmitter<InvitationEventMap> {
|
export class Invitation extends EventEmitter<InvitationEventMap> {
|
||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
static async create(invitation: XOInvitation | string, dependencies: InvitationDependencies): Promise<Invitation> {
|
static async create(
|
||||||
|
invitation: XOInvitation | string,
|
||||||
|
dependencies: InvitationDependencies,
|
||||||
|
): Promise<Invitation> {
|
||||||
// If the invitation is a string, its probably an invitation identifier.
|
// If the invitation is a string, its probably an invitation identifier.
|
||||||
// We will try to find the data then just call the create method again, but this time with the data.
|
// We will try to find the data then just call the create method again, but this time with the data.
|
||||||
if(typeof invitation === 'string') {
|
if (typeof invitation === "string") {
|
||||||
// Try to get the invitation from the storage
|
// Try to get the invitation from the storage
|
||||||
const invitationFromStorage = await dependencies.storage.get(invitation);
|
const invitationFromStorage = await dependencies.storage.get(invitation);
|
||||||
if (invitationFromStorage) {
|
if (invitationFromStorage) {
|
||||||
console.log(`Invitation found in storage: ${invitation}`);
|
|
||||||
return this.create(invitationFromStorage, dependencies);
|
return this.create(invitationFromStorage, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get the invitation from the sync server
|
// Try to get the invitation from the sync server
|
||||||
const invitationFromSyncServer = await dependencies.syncServer.getInvitation(invitation);
|
const invitationFromSyncServer =
|
||||||
if (invitationFromSyncServer && invitationFromSyncServer.invitationIdentifier === invitation) {
|
await dependencies.syncServer.getInvitation(invitation);
|
||||||
console.log(`Invitation found in sync server: ${invitation}`);
|
if (
|
||||||
|
invitationFromSyncServer &&
|
||||||
|
invitationFromSyncServer.invitationIdentifier === invitation
|
||||||
|
) {
|
||||||
return this.create(invitationFromSyncServer, dependencies);
|
return this.create(invitationFromSyncServer, dependencies);
|
||||||
}
|
}
|
||||||
|
|
||||||
// We cant find it. Throw an error.
|
// We cant find it. Throw an error.
|
||||||
throw new Error(`Invitation not found in local or remote storage: ${invitation}`);
|
throw new Error(
|
||||||
|
`Invitation not found in local or remote storage: ${invitation}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await dependencies.engine.getTemplate(invitation.templateIdentifier);
|
const template = await dependencies.engine.getTemplate(
|
||||||
|
invitation.templateIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
throw new Error(`Template not found: ${invitation.templateIdentifier}`);
|
||||||
@@ -66,11 +97,11 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* The invitation data.
|
* The invitation data.
|
||||||
*/
|
*/
|
||||||
public data: XOInvitation = {
|
public data: XOInvitation = {
|
||||||
invitationIdentifier: '',
|
invitationIdentifier: "",
|
||||||
commits: [],
|
commits: [],
|
||||||
createdAtTimestamp: 0,
|
createdAtTimestamp: 0,
|
||||||
templateIdentifier: '',
|
templateIdentifier: "",
|
||||||
actionIdentifier: '',
|
actionIdentifier: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,42 +119,32 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
* TODO: This should be a composite with the sync server (probably. We currently double handle this work, which is stupid)
|
||||||
*/
|
*/
|
||||||
private storage: Storage;
|
private storage: Storage;
|
||||||
|
private electrum: ElectrumService;
|
||||||
/**
|
|
||||||
* True after we have successfully called sign() on this invitation (session-only, not persisted).
|
|
||||||
*/
|
|
||||||
private _weHaveSigned = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True after we have successfully called broadcast() on this invitation (session-only, not persisted).
|
|
||||||
*/
|
|
||||||
private _broadcasted = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
* The status of the invitation (last emitted word: pending, actionable, signed, ready, complete, expired, unknown).
|
||||||
*/
|
*/
|
||||||
public status: string = 'unknown';
|
public status: string = "unknown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invitation and start the SSE Session required for it.
|
* Create an invitation and start the SSE Session required for it.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(invitation: XOInvitation, dependencies: InvitationDependencies) {
|
||||||
invitation: XOInvitation,
|
|
||||||
dependencies: InvitationDependencies
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.data = invitation;
|
this.data = invitation;
|
||||||
this.engine = dependencies.engine;
|
this.engine = dependencies.engine;
|
||||||
this.syncServer = dependencies.syncServer;
|
this.syncServer = dependencies.syncServer;
|
||||||
this.storage = dependencies.storage;
|
this.storage = dependencies.storage;
|
||||||
|
this.electrum = dependencies.electrum;
|
||||||
|
|
||||||
// I cannot express this enough, but the event handler does not need a clean up.
|
// Create a listerner for the messages from the SSE Session (sync server)
|
||||||
// There is this beautiful thing called a "garbage collector". Once this class is removed from scope (removed from the invitations array) all the references
|
this.syncServer.on("message", this.handleSSEMessage.bind(this));
|
||||||
// will be removed, including the SSE Session (and therefore this handler).
|
|
||||||
this.syncServer.on('message', this.handleSSEMessage.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the invitation - Connect sync server and download latest invitation data.
|
||||||
|
*/
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Connect to the sync server and get the invitation (in parallel)
|
// Connect to the sync server and get the invitation (in parallel)
|
||||||
const [_, invitation] = await Promise.all([
|
const [_, invitation] = await Promise.all([
|
||||||
@@ -135,7 +156,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
const sseCommits = this.data.commits;
|
const sseCommits = this.data.commits;
|
||||||
|
|
||||||
// Merge the commits
|
// Merge the commits
|
||||||
const combinedCommits = this.mergeCommits(sseCommits, invitation?.commits ?? []);
|
const combinedCommits = this.mergeCommits(
|
||||||
|
sseCommits,
|
||||||
|
invitation?.commits ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
// Set the invitation data with the combined commits
|
// Set the invitation data with the combined commits
|
||||||
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
this.data = { ...this.data, ...invitation, commits: combinedCommits };
|
||||||
@@ -154,13 +178,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* Handle an SSE message.
|
* Handle an SSE message.
|
||||||
*
|
*
|
||||||
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
* TODO: Invitation should sync up the initial data (top level) then everything after that should be the commits. This makes it easier to merge as we go instead of just having to overwrite the entire invitation.
|
||||||
* Why this level of thought is required is beyond me. We should be given a `mergeCommits` method or "something" that lets us take whole invitation and merge commits into it.
|
|
||||||
* NOTE: signInvitation does merge the commits... But we want to be able to add commits in *before* signing the invitation. So, we are just going to receive a single commit at a time, then just invitation.commits.push(commit); to get around this.
|
|
||||||
* I hope we dont end up with duplicate commits :/... We also dont have a way to list invitiations, which is an... interesting choice.
|
|
||||||
*/
|
*/
|
||||||
private handleSSEMessage(event: SSEvent): void {
|
private handleSSEMessage(event: SSEvent): void {
|
||||||
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
const data = JSON.parse(event.data) as { topic?: string; data?: unknown };
|
||||||
if (data.topic === 'invitation-updated') {
|
if (data.topic === "invitation-updated") {
|
||||||
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
const invitation = decodeExtendedJsonObject(data.data) as XOInvitation;
|
||||||
|
|
||||||
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
if (invitation.invitationIdentifier !== this.data.invitationIdentifier) {
|
||||||
@@ -168,7 +189,10 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
// Filter out commits that already exist (probably a faster way to do this. This is n^2)
|
||||||
const newCommits = this.mergeCommits(this.data.commits, invitation.commits);
|
const newCommits = this.mergeCommits(
|
||||||
|
this.data.commits,
|
||||||
|
invitation.commits,
|
||||||
|
);
|
||||||
|
|
||||||
// Set the new commits
|
// Set the new commits
|
||||||
this.data = { ...this.data, commits: newCommits };
|
this.data = { ...this.data, commits: newCommits };
|
||||||
@@ -177,7 +201,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
this.updateStatus().catch(() => {});
|
this.updateStatus().catch(() => {});
|
||||||
|
|
||||||
// Emit the updated event
|
// Emit the updated event
|
||||||
this.emit('invitation-updated', this.data);
|
this.emit("invitation-updated", this.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,16 +211,19 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
* @param additional - The additional commits
|
* @param additional - The additional commits
|
||||||
* @returns The merged commits
|
* @returns The merged commits
|
||||||
*/
|
*/
|
||||||
private mergeCommits(initial: XOInvitationCommit[], additional: XOInvitationCommit[]): XOInvitationCommit[] {
|
private mergeCommits(
|
||||||
|
initial: XOInvitationCommit[],
|
||||||
|
additional: XOInvitationCommit[],
|
||||||
|
): XOInvitationCommit[] {
|
||||||
// Create a map of the initial commits
|
// Create a map of the initial commits
|
||||||
const initialMap = new Map<string, XOInvitationCommit>();
|
const initialMap = new Map<string, XOInvitationCommit>();
|
||||||
for(const commit of initial) {
|
for (const commit of initial) {
|
||||||
initialMap.set(commit.commitIdentifier, commit);
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge the additional commits
|
// Merge the additional commits
|
||||||
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
// TODO: They are immutable? So, it should be fine to "ovewrite" existing commits as it should be the same data, right?
|
||||||
for(const commit of additional) {
|
for (const commit of additional) {
|
||||||
initialMap.set(commit.commitIdentifier, commit);
|
initialMap.set(commit.commitIdentifier, commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,41 +245,101 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Internal status computation: returns a single word.
|
* Internal status computation: returns a single word.
|
||||||
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
* NOTE: This could be a Enum-like object as well. May be a nice improvement. - DO NOT USE TS ENUM, THEY ARENT NATIVELY SUPPORTED IN NODE.JS
|
||||||
* - expired: any commit has expired
|
|
||||||
* - complete: we have broadcast this invitation
|
* - complete: we have broadcast this invitation
|
||||||
|
* - expired: any commit has expired
|
||||||
* - ready: no missing requirements and we have signed (ready to broadcast)
|
* - ready: no missing requirements and we have signed (ready to broadcast)
|
||||||
* - signed: we have signed but there are still missing parts (waiting for others)
|
* - signed: we have signed but there are still missing parts (waiting for others)
|
||||||
* - actionable: you can provide data (missing requirements and/or you can sign)
|
* - actionable: you can provide data (missing requirements and/or you can sign)
|
||||||
* - unknown: template/action not found or error
|
* - unknown: template/action not found or error
|
||||||
*/
|
*/
|
||||||
private async computeStatusInternal(): Promise<string> {
|
private async computeStatusInternal(): Promise<string> {
|
||||||
if (hasInvitationExpired(this.data)) {
|
|
||||||
return 'expired';
|
|
||||||
}
|
|
||||||
if (this._broadcasted) {
|
|
||||||
return 'complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
let missingReqs;
|
let missingReqs;
|
||||||
try {
|
try {
|
||||||
missingReqs = await this.engine.listMissingRequirements(this.data);
|
missingReqs = await this.engine.listMissingRequirements(this.data);
|
||||||
} catch {
|
} catch {
|
||||||
return 'unknown';
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasMissing =
|
const hasMissing =
|
||||||
(missingReqs.variables?.length ?? 0) > 0 ||
|
(missingReqs.variables?.length ?? 0) > 0 ||
|
||||||
(missingReqs.inputs?.length ?? 0) > 0 ||
|
(missingReqs.inputs?.length ?? 0) > 0 ||
|
||||||
(missingReqs.outputs?.length ?? 0) > 0 ||
|
(missingReqs.outputs?.length ?? 0) > 0 ||
|
||||||
(missingReqs.roles !== undefined && Object.keys(missingReqs.roles).length > 0);
|
(missingReqs.roles !== undefined &&
|
||||||
|
Object.keys(missingReqs.roles).length > 0);
|
||||||
|
|
||||||
if (!hasMissing && this._weHaveSigned) {
|
const hasSignedCommit = this.hasSignedCommitInInvitation();
|
||||||
return 'ready';
|
|
||||||
|
if (!hasMissing) {
|
||||||
|
const transactionHash = await this.deriveTransactionHash();
|
||||||
|
if (
|
||||||
|
transactionHash &&
|
||||||
|
(await this.electrum.hasSeenTransaction(transactionHash))
|
||||||
|
) {
|
||||||
|
return "complete";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (hasMissing && this._weHaveSigned) {
|
|
||||||
return 'signed';
|
if (hasInvitationExpired(this.data)) {
|
||||||
|
return "expired";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMissing && hasSignedCommit) {
|
||||||
|
return "ready";
|
||||||
|
}
|
||||||
|
if (hasMissing && hasSignedCommit) {
|
||||||
|
return "signed";
|
||||||
|
}
|
||||||
|
return "actionable";
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasSignedCommitInInvitation(): boolean {
|
||||||
|
for (const commit of this.data.commits) {
|
||||||
|
for (const input of commit.data.inputs ?? []) {
|
||||||
|
if (!input.mergesWith) continue;
|
||||||
|
if (input.unlockingBytecode === undefined) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the transaction to get the TX hash, this is so we can check its status on the blockchain.
|
||||||
|
* TODO: Remove this. This should be part of the engine. The code is virtually identical to `executeAction` except it doesnt throw if the invitation is expired
|
||||||
|
* @returns txHash or undefined if the transaction could not be built
|
||||||
|
*/
|
||||||
|
private async deriveTransactionHash(): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) return undefined;
|
||||||
|
|
||||||
|
const mergedCommit = mergeInvitationCommits(this.data, template);
|
||||||
|
if (!mergedCommit) return undefined;
|
||||||
|
|
||||||
|
const transactionResult = generateTransaction({
|
||||||
|
version: mergedCommit.transactionVersion,
|
||||||
|
locktime: mergedCommit.transactionLocktime,
|
||||||
|
// @ts-expect-error merged inputs include additional invitation metadata.
|
||||||
|
inputs: mergedCommit.inputs,
|
||||||
|
// @ts-expect-error merged outputs include additional invitation metadata.
|
||||||
|
outputs: mergedCommit.outputs,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transactionResult.success) return undefined;
|
||||||
|
|
||||||
|
const transactionHex = binToHex(
|
||||||
|
encodeTransaction(transactionResult.transaction),
|
||||||
|
);
|
||||||
|
const rawHash: unknown = hashTransaction(hexToBin(transactionHex));
|
||||||
|
if (typeof rawHash === "string") return rawHash;
|
||||||
|
if (rawHash instanceof Uint8Array) return binToHex(rawHash);
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
return 'actionable';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,7 +348,7 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
private async updateStatus(): Promise<void> {
|
private async updateStatus(): Promise<void> {
|
||||||
const status = await this.computeStatus();
|
const status = await this.computeStatus();
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.emit('invitation-status-changed', status);
|
this.emit("invitation-status-changed", status);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,7 +379,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
await this.storage.set(this.data.invitationIdentifier, signedInvitation);
|
||||||
|
|
||||||
this.data = signedInvitation;
|
this.data = signedInvitation;
|
||||||
this._weHaveSigned = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
@@ -307,8 +393,6 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
broadcastTransaction: true,
|
broadcastTransaction: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this._broadcasted = true;
|
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
}
|
}
|
||||||
@@ -345,6 +429,21 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.syncServer.publishInvitation(this.data);
|
await this.syncServer.publishInvitation(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the locking bytecode for the invitation
|
||||||
|
* TODO: Find out if this has side-effects or needs special handling
|
||||||
|
*/
|
||||||
|
async generateLockingBytecode(
|
||||||
|
outputIdentifier: string,
|
||||||
|
roleIdentifier?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
async addOutputs(outputs: XOInvitationOutput[]): Promise<void> {
|
||||||
// Add the outputs to the invitation
|
// Add the outputs to the invitation
|
||||||
await this.append({ outputs });
|
await this.append({ outputs });
|
||||||
@@ -361,9 +460,14 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
await this.syncServer.publishInvitation(this.data);
|
await this.syncServer.publishInvitation(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findSuitableResources(options: FindSuitableResourcesParameters): Promise<UnspentOutputData[]> {
|
async findSuitableResources(
|
||||||
|
options: Partial<FindSuitableResourcesParameters> = {},
|
||||||
|
): Promise<UnspentOutputData[]> {
|
||||||
// Find the suitable resources
|
// Find the suitable resources
|
||||||
const { unspentOutputs } = await this.engine.findSuitableResources(this.data, options);
|
const { unspentOutputs } = await this.engine.findSuitableResources(
|
||||||
|
this.data,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
// Update the status of the invitation
|
// Update the status of the invitation
|
||||||
await this.updateStatus();
|
await this.updateStatus();
|
||||||
@@ -407,7 +511,127 @@ export class Invitation extends EventEmitter<InvitationEventMap> {
|
|||||||
/**
|
/**
|
||||||
* Get the locking bytecode for the invitation
|
* Get the locking bytecode for the invitation
|
||||||
*/
|
*/
|
||||||
async getLockingBytecode(outputIdentifier: string, roleIdentifier?: string): Promise<string> {
|
async getLockingBytecode(
|
||||||
return this.engine.generateLockingBytecode(this.data.templateIdentifier, outputIdentifier, roleIdentifier);
|
outputIdentifier: string,
|
||||||
|
roleIdentifier?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return this.engine.generateLockingBytecode(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sats out for the invitation
|
||||||
|
* TODO: Clean up this function. Why is it so big? Can obviously make it 2 functions instead of recursive, but still...
|
||||||
|
*/
|
||||||
|
async getSatsOut(outputIdentifier?: string): Promise<bigint> {
|
||||||
|
// If an output identifier is provided, find all outputs with that identifier, and its valueSatoshis identifier back to the variables
|
||||||
|
if (outputIdentifier) {
|
||||||
|
// Get the valueSatoshis identifier from the template
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(
|
||||||
|
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = template.outputs[outputIdentifier];
|
||||||
|
if (!output) {
|
||||||
|
throw new Error(
|
||||||
|
`Output not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSatoshisIdentifier = output.valueSatoshis;
|
||||||
|
if (!valueSatoshisIdentifier) {
|
||||||
|
throw new Error(
|
||||||
|
`Value satoshis identifier not found: ${outputIdentifier} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a list of all the variables from the commits
|
||||||
|
const variables = this.data.commits.flatMap(
|
||||||
|
(c) => c.data?.variables ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a dictionary of the variables
|
||||||
|
const formattedVariables = variables.reduce(
|
||||||
|
(acc, v) => {
|
||||||
|
acc[v.variableIdentifier ?? ""] = v.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, XOInvitationVariableValue>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compile the CashAssembly expression to get the value satoshis (It handles the variable replacement for us)
|
||||||
|
const valueSatoshis = await compileCashAssemblyString(
|
||||||
|
String(valueSatoshisIdentifier),
|
||||||
|
formattedVariables,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the value satoshis as a bigint
|
||||||
|
// TODO: Check this of a vulnerability or crash - I assume there might be one if someone made the `valueSatoshis` a malicious expression
|
||||||
|
return BigInt(valueSatoshis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we didnt get an output identifier, go through the action outputs and sum the valueSatoshis
|
||||||
|
const action = this.data.actionIdentifier;
|
||||||
|
if (!action) {
|
||||||
|
throw new Error(
|
||||||
|
`Action not found: ${this.data.actionIdentifier} when trying to get sats out for output: ${outputIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the template
|
||||||
|
const template = await this.engine.getTemplate(
|
||||||
|
this.data.templateIdentifier,
|
||||||
|
);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error(
|
||||||
|
`Template not found: ${this.data.templateIdentifier} when trying to get sats out for action: ${action}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction ID from the action
|
||||||
|
const transactionID = template.actions[action]?.transaction;
|
||||||
|
if (!transactionID) {
|
||||||
|
throw new Error(
|
||||||
|
`Transactions not found: ${action} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction from the template
|
||||||
|
const transaction = template.transactions?.[transactionID];
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error(
|
||||||
|
`Transaction not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the outputs from the transaction
|
||||||
|
const outputs = transaction.outputs;
|
||||||
|
if (!outputs) {
|
||||||
|
throw new Error(
|
||||||
|
`Outputs not found: ${transactionID} in template: ${this.data.templateIdentifier}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a value to store the cummulative total of the outputs
|
||||||
|
let totalSats = 0n;
|
||||||
|
|
||||||
|
// Iterate through the outputs and sum the valueSatoshis
|
||||||
|
for (const output of outputs) {
|
||||||
|
if (typeof output === "string") {
|
||||||
|
totalSats += await this.getSatsOut(output);
|
||||||
|
} else {
|
||||||
|
totalSats += await this.getSatsOut(output.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalSats;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import Database from 'better-sqlite3';
|
import Database from "better-sqlite3";
|
||||||
import { decodeExtendedJson, encodeExtendedJson } from '../utils/ext-json.js';
|
import { decodeExtendedJson, encodeExtendedJson } from "../utils/ext-json.js";
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
static async create(dbPath: string): Promise<Storage> {
|
static async create(dbPath: string): Promise<Storage> {
|
||||||
@@ -7,9 +7,13 @@ export class Storage {
|
|||||||
const database = new Database(dbPath);
|
const database = new Database(dbPath);
|
||||||
|
|
||||||
// Create the storage table if it doesn't exist
|
// Create the storage table if it doesn't exist
|
||||||
database.prepare('CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)').run();
|
database
|
||||||
|
.prepare(
|
||||||
|
"CREATE TABLE IF NOT EXISTS storage (key TEXT PRIMARY KEY, value TEXT)",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
return new Storage(database, '');
|
return new Storage(database, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -39,43 +43,50 @@ export class Storage {
|
|||||||
|
|
||||||
// Insert or replace the value into the database with full key (including basePath)
|
// Insert or replace the value into the database with full key (including basePath)
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
this.database.prepare('INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)').run(fullKey, encodedValue);
|
this.database
|
||||||
|
.prepare("INSERT OR REPLACE INTO storage (key, value) VALUES (?, ?)")
|
||||||
|
.run(fullKey, encodedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
* Get all key-value pairs from this storage namespace (shallow only - no nested children)
|
||||||
*/
|
*/
|
||||||
async all(): Promise<{ key: string; value: any }[]> {
|
async all(): Promise<{ key: string; value: any }[]> {
|
||||||
let query = 'SELECT key, value FROM storage';
|
let query = "SELECT key, value FROM storage";
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
|
||||||
if (this.basePath) {
|
if (this.basePath) {
|
||||||
// Filter by basePath prefix
|
// Filter by basePath prefix
|
||||||
query += ' WHERE key LIKE ?';
|
query += " WHERE key LIKE ?";
|
||||||
params.push(`${this.basePath}.%`);
|
params.push(`${this.basePath}.%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all the rows from the database
|
// Get all the rows from the database
|
||||||
const rows = await this.database.prepare(query).all(...params) as { key: string; value: any }[];
|
const rows = (await this.database.prepare(query).all(...params)) as {
|
||||||
|
key: string;
|
||||||
|
value: any;
|
||||||
|
}[];
|
||||||
|
|
||||||
// Filter for shallow results (only direct children)
|
// Filter for shallow results (only direct children)
|
||||||
const filteredRows = rows.filter(row => {
|
const filteredRows = rows.filter((row) => {
|
||||||
const strippedKey = this.stripBasePath(row.key);
|
const strippedKey = this.stripBasePath(row.key);
|
||||||
// Only include keys that don't have additional dots (no deeper nesting)
|
// Only include keys that don't have additional dots (no deeper nesting)
|
||||||
return !strippedKey.includes('.');
|
return !strippedKey.includes(".");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Decode the extended json objects and strip basePath from keys
|
// Decode the extended json objects and strip basePath from keys
|
||||||
return filteredRows.map(row => ({
|
return filteredRows.map((row) => ({
|
||||||
key: this.stripBasePath(row.key),
|
key: this.stripBasePath(row.key),
|
||||||
value: decodeExtendedJson(row.value)
|
value: decodeExtendedJson(row.value),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(key: string): Promise<any> {
|
async get(key: string): Promise<any> {
|
||||||
// Get the row from the database using full key
|
// Get the row from the database using full key
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
const row = await this.database.prepare('SELECT value FROM storage WHERE key = ?').get(fullKey) as { value: any };
|
const row = (await this.database
|
||||||
|
.prepare("SELECT value FROM storage WHERE key = ?")
|
||||||
|
.get(fullKey)) as { value: any };
|
||||||
|
|
||||||
// Return null if not found
|
// Return null if not found
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -87,16 +98,18 @@ export class Storage {
|
|||||||
async remove(key: string): Promise<void> {
|
async remove(key: string): Promise<void> {
|
||||||
// Delete using full key
|
// Delete using full key
|
||||||
const fullKey = this.getFullKey(key);
|
const fullKey = this.getFullKey(key);
|
||||||
this.database.prepare('DELETE FROM storage WHERE key = ?').run(fullKey);
|
this.database.prepare("DELETE FROM storage WHERE key = ?").run(fullKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(): Promise<void> {
|
async clear(): Promise<void> {
|
||||||
if (this.basePath) {
|
if (this.basePath) {
|
||||||
// Clear only items under this namespace
|
// Clear only items under this namespace
|
||||||
this.database.prepare('DELETE FROM storage WHERE key LIKE ?').run(`${this.basePath}.%`);
|
this.database
|
||||||
|
.prepare("DELETE FROM storage WHERE key LIKE ?")
|
||||||
|
.run(`${this.basePath}.%`);
|
||||||
} else {
|
} else {
|
||||||
// Clear everything
|
// Clear everything
|
||||||
this.database.prepare('DELETE FROM storage').run();
|
this.database.prepare("DELETE FROM storage").run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, useApp, useInput } from 'ink';
|
import { Box, Text, useApp } from 'ink';
|
||||||
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
import { NavigationProvider, useNavigation } from './hooks/useNavigation.js';
|
||||||
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js';
|
||||||
|
import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js';
|
||||||
import type { AppConfig } from '../app.js';
|
import type { AppConfig } from '../app.js';
|
||||||
import { colors, logoSmall } from './theme.js';
|
import { colors, logoSmall } from './theme.js';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ import { SeedInputScreen } from './screens/SeedInput.js';
|
|||||||
import { WalletStateScreen } from './screens/WalletState.js';
|
import { WalletStateScreen } from './screens/WalletState.js';
|
||||||
import { TemplateListScreen } from './screens/TemplateList.js';
|
import { TemplateListScreen } from './screens/TemplateList.js';
|
||||||
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js';
|
||||||
import { InvitationScreen } from './screens/Invitation.js';
|
import { InvitationScreen } from './screens/invitations/InvitationScreen.js';
|
||||||
import { TransactionScreen } from './screens/Transaction.js';
|
import { TransactionScreen } from './screens/Transaction.js';
|
||||||
|
|
||||||
import { MessageDialog } from './components/Dialog.js';
|
import { MessageDialog } from './components/Dialog.js';
|
||||||
@@ -78,21 +79,7 @@ function StatusBar(): React.ReactElement {
|
|||||||
* Dialog overlay component for modals.
|
* Dialog overlay component for modals.
|
||||||
*/
|
*/
|
||||||
function DialogOverlay(): React.ReactElement | null {
|
function DialogOverlay(): React.ReactElement | null {
|
||||||
const { dialog, setDialog } = useDialog();
|
const { dialog } = useDialog();
|
||||||
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (!dialog?.visible) return;
|
|
||||||
|
|
||||||
if (key.return || input === 'y' || input === 'Y') {
|
|
||||||
if (dialog.type === 'confirm' && dialog.onConfirm) {
|
|
||||||
dialog.onConfirm();
|
|
||||||
} else {
|
|
||||||
dialog.onCancel?.();
|
|
||||||
}
|
|
||||||
} else if (key.escape || input === 'n' || input === 'N') {
|
|
||||||
dialog.onCancel?.();
|
|
||||||
}
|
|
||||||
}, { isActive: dialog?.visible ?? false });
|
|
||||||
|
|
||||||
if (!dialog?.visible) return null;
|
if (!dialog?.visible) return null;
|
||||||
|
|
||||||
@@ -128,16 +115,12 @@ function MainContent(): React.ReactElement {
|
|||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const { goBack, canGoBack } = useNavigation();
|
const { goBack, canGoBack } = useNavigation();
|
||||||
const { screen } = useNavigation();
|
const { screen } = useNavigation();
|
||||||
const { dialog } = useDialog();
|
|
||||||
const appContext = useAppContext();
|
const appContext = useAppContext();
|
||||||
|
|
||||||
// Global keybindings (disabled when dialog is shown)
|
// Global keybindings — auto-blocked when any dialog/overlay is capturing input.
|
||||||
useInput((input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
// Don't handle global keys when dialog is shown
|
// Quit on Ctrl+C
|
||||||
if (dialog?.visible) return;
|
if (key.ctrl && input === 'c') {
|
||||||
|
|
||||||
// Quit on 'q' or Ctrl+C
|
|
||||||
if (input === 'q' || (key.ctrl && input === 'c')) {
|
|
||||||
appContext.exit();
|
appContext.exit();
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
@@ -179,8 +162,8 @@ function MainContent(): React.ReactElement {
|
|||||||
export function App({ config }: AppProps): React.ReactElement {
|
export function App({ config }: AppProps): React.ReactElement {
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
|
|
||||||
|
// Cleanup will be handled by React when components unmount
|
||||||
const handleExit = () => {
|
const handleExit = () => {
|
||||||
// Cleanup will be handled by React when components unmount
|
|
||||||
exit();
|
exit();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,9 +172,11 @@ export function App({ config }: AppProps): React.ReactElement {
|
|||||||
config={config}
|
config={config}
|
||||||
onExit={handleExit}
|
onExit={handleExit}
|
||||||
>
|
>
|
||||||
<NavigationProvider initialScreen="seed-input">
|
<InputLayerProvider>
|
||||||
<MainContent />
|
<NavigationProvider initialScreen="seed-input">
|
||||||
</NavigationProvider>
|
<MainContent />
|
||||||
|
</NavigationProvider>
|
||||||
|
</InputLayerProvider>
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
* Dialog components for modals, confirmations, and input dialogs.
|
* Dialog components for modals, confirmations, and input dialogs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef, useState } from 'react';
|
import React, { useId, useRef, useState } from 'react';
|
||||||
import { Box, Text, useInput, measureElement } from 'ink';
|
import { Box, Text, measureElement } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
import { useInputLayer, useLayeredInput } from '../hooks/useInputLayer.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base dialog wrapper props.
|
* Base dialog wrapper props.
|
||||||
@@ -24,11 +25,12 @@ interface DialogWrapperProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function DialogWrapper({
|
export function DialogWrapper({
|
||||||
title,
|
title,
|
||||||
borderColor = colors.primary,
|
borderColor = colors.primary,
|
||||||
children,
|
children,
|
||||||
width = 60,
|
width = 60,
|
||||||
|
backgroundColor = colors.bg,
|
||||||
}: DialogWrapperProps): React.ReactElement {
|
}: DialogWrapperProps): React.ReactElement {
|
||||||
const ref = useRef<any>(null);
|
const ref = useRef<any>(null);
|
||||||
const [height, setHeight] = useState<number | null>(null);
|
const [height, setHeight] = useState<number | null>(null);
|
||||||
@@ -51,9 +53,12 @@ function DialogWrapper({
|
|||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
{Array.from({ length: height }).map((_, i) => (
|
{Array.from({ length: height }).map((_, i) => (
|
||||||
<Text key={i}>{' '.repeat(width)}</Text>
|
<Text key={i} backgroundColor={backgroundColor}>
|
||||||
|
{' '.repeat(width)}
|
||||||
|
</Text>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -67,6 +72,7 @@ function DialogWrapper({
|
|||||||
paddingX={2}
|
paddingX={2}
|
||||||
paddingY={1}
|
paddingY={1}
|
||||||
width={width}
|
width={width}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
>
|
>
|
||||||
<Text color={borderColor} bold>
|
<Text color={borderColor} bold>
|
||||||
{title}
|
{title}
|
||||||
@@ -113,15 +119,17 @@ export function InputDialog({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: InputDialogProps): React.ReactElement {
|
}: InputDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
useInput((input, key) => {
|
// Auto-capture input when this dialog is mounted.
|
||||||
if (!isActive) return;
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
}, { isActive });
|
});
|
||||||
|
|
||||||
const handleSubmit = (val: string) => {
|
const handleSubmit = (val: string) => {
|
||||||
onSubmit(val);
|
onSubmit(val);
|
||||||
@@ -178,11 +186,13 @@ export function ConfirmDialog({
|
|||||||
confirmLabel = 'Yes',
|
confirmLabel = 'Yes',
|
||||||
cancelLabel = 'No',
|
cancelLabel = 'No',
|
||||||
}: ConfirmDialogProps): React.ReactElement {
|
}: ConfirmDialogProps): React.ReactElement {
|
||||||
|
const layerId = useId();
|
||||||
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
|
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
|
||||||
|
|
||||||
useInput((input, key) => {
|
// Auto-capture input when this dialog is mounted.
|
||||||
if (!isActive) return;
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (input, key) => {
|
||||||
if (key.leftArrow || key.rightArrow || key.tab) {
|
if (key.leftArrow || key.rightArrow || key.tab) {
|
||||||
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
|
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
|
||||||
} else if (key.return) {
|
} else if (key.return) {
|
||||||
@@ -196,7 +206,7 @@ export function ConfirmDialog({
|
|||||||
} else if (input === 'y' || input === 'Y') {
|
} else if (input === 'y' || input === 'Y') {
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}
|
}
|
||||||
}, { isActive });
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogWrapper title={title} borderColor={colors.warning}>
|
<DialogWrapper title={title} borderColor={colors.warning}>
|
||||||
@@ -250,13 +260,16 @@ export function MessageDialog({
|
|||||||
type = 'info',
|
type = 'info',
|
||||||
isActive = true,
|
isActive = true,
|
||||||
}: MessageDialogProps): React.ReactElement {
|
}: MessageDialogProps): React.ReactElement {
|
||||||
useInput((input, key) => {
|
const layerId = useId();
|
||||||
if (!isActive) return;
|
|
||||||
|
|
||||||
|
// Auto-capture input when this dialog is mounted.
|
||||||
|
useInputLayer(layerId);
|
||||||
|
|
||||||
|
useLayeredInput(layerId, (_input, key) => {
|
||||||
if (key.return || key.escape) {
|
if (key.return || key.escape) {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}, { isActive });
|
});
|
||||||
|
|
||||||
const borderColor = type === 'error' ? colors.error :
|
const borderColor = type === 'error' ? colors.error :
|
||||||
type === 'success' ? colors.success :
|
type === 'success' ? colors.success :
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from './TextInput.js';
|
||||||
import { colors } from '../theme.js';
|
import { colors } from '../theme.js';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -116,8 +116,14 @@ interface LoadingProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
|
||||||
// Simple spinner using Ink's spinner component
|
|
||||||
const Spinner = require('ink-spinner').default;
|
// Was using ink-spinner, but its not updated for react 19.
|
||||||
|
// Just putting nothing here for now
|
||||||
|
const Spinner = (props: any) => {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|||||||
198
src/tui/components/QRCode.tsx
Normal file
198
src/tui/components/QRCode.tsx
Normal file
@@ -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
|
||||||
|
* <QRCode value="bitcoincash:qr..." />
|
||||||
|
*
|
||||||
|
* // Inside a dialog with the raw value shown
|
||||||
|
* <QRCode value="bitcoincash:qr..." dialog dialogTitle="Receive Address" showValue />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
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 = (
|
||||||
|
<Box flexDirection="column" alignItems="center">
|
||||||
|
{showValue && (
|
||||||
|
<Box marginBottom={1} width={moduleCount}>
|
||||||
|
<Text color={colors.textMuted} wrap="wrap">{value}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{rows.map((spans, i) => (
|
||||||
|
<Text key={i}>
|
||||||
|
{spans.map((span, j) => (
|
||||||
|
<Text key={j} color={span.fg} backgroundColor={span.bg}>
|
||||||
|
{span.chars}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dialog) {
|
||||||
|
const dialogWidth = Math.max(moduleCount + 8, 40);
|
||||||
|
return (
|
||||||
|
<DialogWrapper title={dialogTitle} borderColor={colors.primary} width={dialogWidth}>
|
||||||
|
{qrContent}
|
||||||
|
</DialogWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return qrContent;
|
||||||
|
}
|
||||||
216
src/tui/components/TextInput.tsx
Normal file
216
src/tui/components/TextInput.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import React, {useState, useEffect} from 'react';
|
||||||
|
import {Text, useInput} from 'ink';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import type {Except} from 'type-fest';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
/**
|
||||||
|
* Text to display when `value` is empty.
|
||||||
|
*/
|
||||||
|
readonly placeholder?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen to user's input. Useful in case there are multiple input components
|
||||||
|
* at the same time and input must be "routed" to a specific component.
|
||||||
|
*/
|
||||||
|
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all chars and mask the value. Useful for password inputs.
|
||||||
|
*/
|
||||||
|
readonly mask?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show cursor and allow navigation inside text input with arrow keys.
|
||||||
|
*/
|
||||||
|
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight pasted text
|
||||||
|
*/
|
||||||
|
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value to display in a text input.
|
||||||
|
*/
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when value updates.
|
||||||
|
*/
|
||||||
|
readonly onChange: (value: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to call when `Enter` is pressed, where first argument is a value of the input.
|
||||||
|
*/
|
||||||
|
readonly onSubmit?: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
value: originalValue,
|
||||||
|
placeholder = '',
|
||||||
|
focus = true,
|
||||||
|
mask,
|
||||||
|
highlightPastedText = false,
|
||||||
|
showCursor = true,
|
||||||
|
onChange,
|
||||||
|
onSubmit,
|
||||||
|
}: Props) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
cursorOffset: (originalValue || '').length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {cursorOffset, cursorWidth} = state;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState(previousState => {
|
||||||
|
if (!focus || !showCursor) {
|
||||||
|
return previousState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = originalValue || '';
|
||||||
|
|
||||||
|
if (previousState.cursorOffset > newValue.length - 1) {
|
||||||
|
return {
|
||||||
|
cursorOffset: newValue.length,
|
||||||
|
cursorWidth: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return previousState;
|
||||||
|
});
|
||||||
|
}, [originalValue, focus, showCursor]);
|
||||||
|
|
||||||
|
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
||||||
|
|
||||||
|
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
||||||
|
let renderedValue = value;
|
||||||
|
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
||||||
|
|
||||||
|
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes
|
||||||
|
if (showCursor && focus) {
|
||||||
|
renderedPlaceholder =
|
||||||
|
placeholder.length > 0
|
||||||
|
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
|
||||||
|
: chalk.inverse(' ');
|
||||||
|
|
||||||
|
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
for (const char of value) {
|
||||||
|
renderedValue +=
|
||||||
|
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
|
||||||
|
? chalk.inverse(char)
|
||||||
|
: char;
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length > 0 && cursorOffset === value.length) {
|
||||||
|
renderedValue += chalk.inverse(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useInput(
|
||||||
|
(input, key) => {
|
||||||
|
if (
|
||||||
|
key.upArrow ||
|
||||||
|
key.downArrow ||
|
||||||
|
(key.ctrl && input === 'c') ||
|
||||||
|
key.tab ||
|
||||||
|
(key.shift && key.tab)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (onSubmit) {
|
||||||
|
onSubmit(originalValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextCursorOffset = cursorOffset;
|
||||||
|
let nextValue = originalValue;
|
||||||
|
let nextCursorWidth = 0;
|
||||||
|
|
||||||
|
if (key.leftArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else if (key.rightArrow) {
|
||||||
|
if (showCursor) {
|
||||||
|
nextCursorOffset++;
|
||||||
|
}
|
||||||
|
} else if (key.backspace || key.delete) {
|
||||||
|
if (cursorOffset > 0) {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset - 1) +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset--;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextValue =
|
||||||
|
originalValue.slice(0, cursorOffset) +
|
||||||
|
input +
|
||||||
|
originalValue.slice(cursorOffset, originalValue.length);
|
||||||
|
|
||||||
|
nextCursorOffset += input.length;
|
||||||
|
|
||||||
|
if (input.length > 1) {
|
||||||
|
nextCursorWidth = input.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset < 0) {
|
||||||
|
nextCursorOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorOffset > originalValue.length) {
|
||||||
|
nextCursorOffset = originalValue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
cursorOffset: nextCursorOffset,
|
||||||
|
cursorWidth: nextCursorWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextValue !== originalValue) {
|
||||||
|
onChange(nextValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{isActive: focus},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
{placeholder
|
||||||
|
? value.length > 0
|
||||||
|
? renderedValue
|
||||||
|
: renderedPlaceholder
|
||||||
|
: renderedValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextInput;
|
||||||
|
|
||||||
|
type UncontrolledProps = {
|
||||||
|
/**
|
||||||
|
* Initial value.
|
||||||
|
*/
|
||||||
|
readonly initialValue?: string;
|
||||||
|
} & Except<Props, 'value' | 'onChange'>;
|
||||||
|
|
||||||
|
export function UncontrolledTextInput({
|
||||||
|
initialValue = '',
|
||||||
|
...props
|
||||||
|
}: UncontrolledProps) {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
|
||||||
|
return <TextInput {...props} value={value} onChange={setValue} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, Text } from "ink";
|
import { Box, Text } from "ink";
|
||||||
import TextInput from "ink-text-input";
|
import TextInput from "./TextInput.js";
|
||||||
import { formatSatoshis } from "../theme.js";
|
|
||||||
|
|
||||||
interface VariableInputFieldProps {
|
interface VariableInputFieldProps {
|
||||||
variable: {
|
variable: {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
* Export all shared components.
|
* Export all shared components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { Screen } from './Screen.js';
|
export { Screen } from "./Screen.js";
|
||||||
export { Input, TextDisplay } from './Input.js';
|
export { Input, TextDisplay } from "./Input.js";
|
||||||
export { Button, ButtonRow } from './Button.js';
|
export { Button, ButtonRow } from "./Button.js";
|
||||||
export {
|
export {
|
||||||
List,
|
List,
|
||||||
SimpleList,
|
SimpleList,
|
||||||
@@ -13,6 +13,12 @@ export {
|
|||||||
type ListItemData,
|
type ListItemData,
|
||||||
type ListGroup,
|
type ListGroup,
|
||||||
type ScrollableListProps,
|
type ScrollableListProps,
|
||||||
} from './List.js';
|
} from "./List.js";
|
||||||
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
|
export { InputDialog, ConfirmDialog, MessageDialog } from "./Dialog.js";
|
||||||
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';
|
export {
|
||||||
|
ProgressBar,
|
||||||
|
StepIndicator,
|
||||||
|
Loading,
|
||||||
|
type Step,
|
||||||
|
} from "./ProgressBar.js";
|
||||||
|
export { QRCode } from "./QRCode.js";
|
||||||
|
|||||||
@@ -2,12 +2,24 @@
|
|||||||
* Export all hooks.
|
* Export all hooks.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { NavigationProvider, useNavigation } from './useNavigation.js';
|
export { NavigationProvider, useNavigation } from "./useNavigation.js";
|
||||||
export { AppProvider, useAppContext, useDialog, useStatus } from './useAppContext.js';
|
export {
|
||||||
|
AppProvider,
|
||||||
|
useAppContext,
|
||||||
|
useDialog,
|
||||||
|
useStatus,
|
||||||
|
} from "./useAppContext.js";
|
||||||
export {
|
export {
|
||||||
useInvitations,
|
useInvitations,
|
||||||
useInvitation,
|
useInvitation,
|
||||||
useInvitationData,
|
useInvitationData,
|
||||||
useCreateInvitation,
|
useCreateInvitation,
|
||||||
useInvitationIds,
|
useInvitationIds,
|
||||||
} from './useInvitations.js';
|
} from "./useInvitations.js";
|
||||||
|
export {
|
||||||
|
InputLayerProvider,
|
||||||
|
useInputLayer,
|
||||||
|
useLayeredInput,
|
||||||
|
useBlockableInput,
|
||||||
|
useIsInputCaptured,
|
||||||
|
} from "./useInputLayer.js";
|
||||||
|
|||||||
169
src/tui/hooks/useInputLayer.tsx
Normal file
169
src/tui/hooks/useInputLayer.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* Input Layer System — stack-based keyboard input capture for dialogs and overlays.
|
||||||
|
*
|
||||||
|
* Only "capturing" components (dialogs, overlays, import flows) register layers.
|
||||||
|
* When any layer exists on the stack, all non-capturing input handlers are blocked.
|
||||||
|
*
|
||||||
|
* Hooks:
|
||||||
|
* - `useInputLayer(id)` — push a capturing layer (dialogs/overlays).
|
||||||
|
* - `useLayeredInput(id, …)` — handle input for a specific capturing layer.
|
||||||
|
* - `useBlockableInput(…)` — handle input for screens / global keys; auto-blocked
|
||||||
|
* when any capturing layer is on the stack.
|
||||||
|
* - `useIsInputCaptured()` — returns true when a capturing layer is present
|
||||||
|
* (useful for disabling `focus` on child components).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useInput } from 'ink';
|
||||||
|
|
||||||
|
// ── Context ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputLayerContextType {
|
||||||
|
/** Push a capturing layer. Returns a cleanup that pops it. */
|
||||||
|
push: (layerId: string) => () => void;
|
||||||
|
/** True when `layerId` is the topmost entry in the stack. */
|
||||||
|
isTop: (layerId: string) => boolean;
|
||||||
|
/** True when the stack has no entries (no dialog/overlay is capturing). */
|
||||||
|
isStackEmpty: () => boolean;
|
||||||
|
/** Monotonic counter — bumped on every push/pop so consumers re-render. */
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InputLayerContext = createContext<InputLayerContextType | null>(null);
|
||||||
|
|
||||||
|
// ── Provider ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface InputLayerProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the component tree and provides the input-layer stack.
|
||||||
|
*
|
||||||
|
* Place this inside your outermost providers but above any component
|
||||||
|
* that calls the input-layer hooks.
|
||||||
|
*/
|
||||||
|
export function InputLayerProvider({ children }: InputLayerProviderProps): React.ReactElement {
|
||||||
|
const stackRef = useRef<string[]>([]);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
|
||||||
|
const bump = useCallback(() => setVersion((v) => v + 1), []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(layerId: string): (() => void) => {
|
||||||
|
stackRef.current = [...stackRef.current, layerId];
|
||||||
|
bump();
|
||||||
|
return () => {
|
||||||
|
stackRef.current = stackRef.current.filter((id) => id !== layerId);
|
||||||
|
bump();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[bump],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isTop = useCallback(
|
||||||
|
(layerId: string): boolean => {
|
||||||
|
const s = stackRef.current;
|
||||||
|
return s.length > 0 && s[s.length - 1] === layerId;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isStackEmpty = useCallback(
|
||||||
|
(): boolean => stackRef.current.length === 0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo<InputLayerContextType>(
|
||||||
|
() => ({ push, isTop, isStackEmpty, version }),
|
||||||
|
[push, isTop, isStackEmpty, version],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InputLayerContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</InputLayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hooks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a **capturing** layer (dialog / overlay / import flow).
|
||||||
|
*
|
||||||
|
* Pushes on mount, pops on unmount. While this layer is present every
|
||||||
|
* `useBlockableInput` handler in the tree is automatically disabled.
|
||||||
|
*
|
||||||
|
* @returns `{ isActive }` — true only when this layer is the topmost.
|
||||||
|
*/
|
||||||
|
export function useInputLayer(layerId: string): { isActive: boolean } {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useInputLayer must be used within an InputLayerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { push } = ctx;
|
||||||
|
useEffect(() => {
|
||||||
|
const pop = push(layerId);
|
||||||
|
return pop;
|
||||||
|
}, [push, layerId]);
|
||||||
|
|
||||||
|
return { isActive: ctx.isTop(layerId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input handler for a **capturing** layer.
|
||||||
|
*
|
||||||
|
* Only fires when `layerId` is the topmost entry in the stack.
|
||||||
|
*/
|
||||||
|
export function useLayeredInput(
|
||||||
|
layerId: string,
|
||||||
|
handler: (input: string, key: any) => void,
|
||||||
|
options?: { isActive?: boolean },
|
||||||
|
): void {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useLayeredInput must be used within an InputLayerProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTopLayer = ctx.isTop(layerId);
|
||||||
|
const externalActive = options?.isActive !== false;
|
||||||
|
useInput(handler, { isActive: isTopLayer && externalActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input handler for **non-capturing** components (screens, global keys).
|
||||||
|
*
|
||||||
|
* Fires only when the capture stack is empty (no dialog/overlay is open).
|
||||||
|
* This is the hook screens should use instead of raw `useInput`.
|
||||||
|
*/
|
||||||
|
export function useBlockableInput(
|
||||||
|
handler: (input: string, key: any) => void,
|
||||||
|
options?: { isActive?: boolean },
|
||||||
|
): void {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
|
||||||
|
const nothingCapturing = ctx ? ctx.isStackEmpty() : true;
|
||||||
|
const externalActive = options?.isActive !== false;
|
||||||
|
useInput(handler, { isActive: nothingCapturing && externalActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `true` when any capturing layer is on the stack.
|
||||||
|
*
|
||||||
|
* Use this to disable `focus` props on child components (e.g. ScrollableList)
|
||||||
|
* so their internal `useInput` handlers don't fire while a dialog is open.
|
||||||
|
*/
|
||||||
|
export function useIsInputCaptured(): boolean {
|
||||||
|
const ctx = useContext(InputLayerContext);
|
||||||
|
return ctx ? !ctx.isStackEmpty() : false;
|
||||||
|
}
|
||||||
@@ -1,25 +1,80 @@
|
|||||||
/**
|
/**
|
||||||
* Seed Input Screen - Initial screen for wallet seed phrase entry.
|
* Seed Input Screen - Initial screen for wallet seed phrase entry.
|
||||||
*
|
*
|
||||||
* Allows users to enter their BIP39 seed phrase to initialize the wallet.
|
* Allows users to enter their BIP39 seed phrase to initialize the wallet,
|
||||||
|
* or select from previously saved mnemonic files on disk.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import TextInput from 'ink-text-input';
|
import TextInput from '../components/TextInput.js';
|
||||||
import { Button } from '../components/Button.js';
|
import { Button } from '../components/Button.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||||
import { colors, logo } from '../theme.js';
|
import { colors, logo } from '../theme.js';
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { BCHMnemonicURL } from '../../utils/bch-mnemonic-url.js';
|
||||||
|
import { encodeBip39Mnemonic } from '@bitauth/libauth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Status message type.
|
* Status message type.
|
||||||
*/
|
*/
|
||||||
type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
type StatusType = 'idle' | 'loading' | 'error' | 'success';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed mnemonic file entry with the derived seed phrase ready for wallet init.
|
||||||
|
*/
|
||||||
|
interface MnemonicFileEntry {
|
||||||
|
filename: string;
|
||||||
|
/** Friendly label derived from filename or the URL comment field. */
|
||||||
|
label: string;
|
||||||
|
/** The BIP39 mnemonic phrase derived from the file's entropy. */
|
||||||
|
mnemonic: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus sections the user can tab between.
|
||||||
|
* When saved wallets exist the file list is shown first.
|
||||||
|
*/
|
||||||
|
type FocusSection = 'files' | 'input' | 'button';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads mnemonic-* files from cwd, parses each as a BCHMnemonicURL,
|
||||||
|
* and converts the entropy back to a BIP39 English mnemonic phrase.
|
||||||
|
*/
|
||||||
|
function loadMnemonicFiles(): MnemonicFileEntry[] {
|
||||||
|
const cwd = process.cwd();
|
||||||
|
const filenames = fs.readdirSync(cwd).filter((f) => f.startsWith('mnemonic-'));
|
||||||
|
const entries: MnemonicFileEntry[] = [];
|
||||||
|
|
||||||
|
for (const filename of filenames) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(path.join(cwd, filename), 'utf-8').trim();
|
||||||
|
const parsed = BCHMnemonicURL.fromURL(content);
|
||||||
|
const raw = parsed.toObject();
|
||||||
|
|
||||||
|
const mnemonicResult = encodeBip39Mnemonic(raw.entropy);
|
||||||
|
if (typeof mnemonicResult === 'string') continue;
|
||||||
|
|
||||||
|
/** Use the URL comment as the label, falling back to a cleaned-up filename. */
|
||||||
|
const label = raw.comment
|
||||||
|
?? filename.replace(/^mnemonic-/, '').replace(/\.[^.]+$/, '');
|
||||||
|
|
||||||
|
entries.push({ filename, label, mnemonic: mnemonicResult.phrase });
|
||||||
|
} catch {
|
||||||
|
// Skip files that can't be parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed Input Screen Component.
|
* Seed Input Screen Component.
|
||||||
* Provides seed phrase entry for wallet initialization.
|
* Provides seed phrase entry for wallet initialization and a selectable
|
||||||
|
* list of previously saved mnemonic files.
|
||||||
*/
|
*/
|
||||||
export function SeedInputScreen(): React.ReactElement {
|
export function SeedInputScreen(): React.ReactElement {
|
||||||
const { navigate } = useNavigation();
|
const { navigate } = useNavigation();
|
||||||
@@ -30,9 +85,28 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
const [seedPhrase, setSeedPhrase] = useState('');
|
const [seedPhrase, setSeedPhrase] = useState('');
|
||||||
const [statusMessage, setStatusMessage] = useState('');
|
const [statusMessage, setStatusMessage] = useState('');
|
||||||
const [statusType, setStatusType] = useState<StatusType>('idle');
|
const [statusType, setStatusType] = useState<StatusType>('idle');
|
||||||
const [focusedElement, setFocusedElement] = useState<'input' | 'button'>('input');
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Mnemonic file list state
|
||||||
|
const [mnemonicFiles, setMnemonicFiles] = useState<MnemonicFileEntry[]>([]);
|
||||||
|
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||||
|
|
||||||
|
// Focus: when saved wallets exist default to the file list, otherwise the input.
|
||||||
|
const [focusedSection, setFocusedSection] = useState<FocusSection>('input');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const entries = loadMnemonicFiles();
|
||||||
|
setMnemonicFiles(entries);
|
||||||
|
if (entries.length > 0) setFocusedSection('files');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ordered list of focusable sections (files section only when entries exist).
|
||||||
|
*/
|
||||||
|
const focusSections: FocusSection[] = mnemonicFiles.length > 0
|
||||||
|
? ['files', 'input', 'button']
|
||||||
|
: ['input', 'button'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a status message with the given type.
|
* Shows a status message with the given type.
|
||||||
*/
|
*/
|
||||||
@@ -42,12 +116,37 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles seed phrase submission.
|
* Shared wallet initialization handler used by both manual entry and file selection.
|
||||||
|
*/
|
||||||
|
const doInitialize = useCallback(async (seed: string) => {
|
||||||
|
showStatus('Initializing wallet...', 'loading');
|
||||||
|
setStatus('Initializing wallet...');
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await initializeWallet(seed);
|
||||||
|
|
||||||
|
showStatus('Wallet initialized successfully!', 'success');
|
||||||
|
setStatus('Wallet ready');
|
||||||
|
setSeedPhrase('');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('wallet');
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
|
||||||
|
showStatus(message, 'error');
|
||||||
|
setStatus('Initialization failed');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [initializeWallet, navigate, showStatus, setStatus]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles manual seed phrase submission with validation.
|
||||||
*/
|
*/
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const seed = seedPhrase.trim();
|
const seed = seedPhrase.trim();
|
||||||
|
|
||||||
// Basic validation
|
|
||||||
if (!seed) {
|
if (!seed) {
|
||||||
showStatus('Please enter your seed phrase', 'error');
|
showStatus('Please enter your seed phrase', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -59,60 +158,66 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading status
|
await doInitialize(seed);
|
||||||
showStatus('Initializing wallet...', 'loading');
|
}, [seedPhrase, doInitialize, showStatus]);
|
||||||
setStatus('Initializing wallet...');
|
|
||||||
setIsSubmitting(true);
|
|
||||||
|
|
||||||
try {
|
/**
|
||||||
// Initialize wallet and create AppService
|
* Handles selecting a mnemonic file from the list.
|
||||||
await initializeWallet(seed);
|
*/
|
||||||
|
const handleFileSelect = useCallback(async (index: number) => {
|
||||||
|
const entry = mnemonicFiles[index];
|
||||||
|
if (!entry) return;
|
||||||
|
await doInitialize(entry.mnemonic);
|
||||||
|
}, [mnemonicFiles, doInitialize]);
|
||||||
|
|
||||||
showStatus('Wallet initialized successfully!', 'success');
|
// Keyboard navigation
|
||||||
setStatus('Wallet ready');
|
useBlockableInput((_input, key) => {
|
||||||
|
|
||||||
// Clear sensitive data before navigating
|
|
||||||
setSeedPhrase('');
|
|
||||||
|
|
||||||
// Navigate to wallet state screen
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('wallet');
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Failed to initialize wallet';
|
|
||||||
showStatus(message, 'error');
|
|
||||||
setStatus('Initialization failed');
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [seedPhrase, initializeWallet, navigate, showStatus, setStatus]);
|
|
||||||
|
|
||||||
// Handle keyboard navigation
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (isSubmitting) return;
|
if (isSubmitting) return;
|
||||||
|
|
||||||
// Tab to switch focus
|
// Tab / Shift-Tab to cycle focus sections
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedElement(prev => prev === 'input' ? 'button' : 'input');
|
setFocusedSection((prev) => {
|
||||||
|
const idx = focusSections.indexOf(prev);
|
||||||
|
const next = key.shift
|
||||||
|
? (idx - 1 + focusSections.length) % focusSections.length
|
||||||
|
: (idx + 1) % focusSections.length;
|
||||||
|
return focusSections[next]!;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter on button submits
|
// Arrow keys inside the file list
|
||||||
if (key.return && focusedElement === 'button') {
|
if (focusedSection === 'files' && mnemonicFiles.length > 0) {
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.min(mnemonicFiles.length - 1, prev + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.return) {
|
||||||
|
handleFileSelect(selectedFileIndex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter on button submits manual seed
|
||||||
|
if (key.return && focusedSection === 'button') {
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get status color
|
// Derived style helpers
|
||||||
const statusColor = statusType === 'error' ? colors.error :
|
const statusColor = statusType === 'error' ? colors.error :
|
||||||
statusType === 'success' ? colors.success :
|
statusType === 'success' ? colors.success :
|
||||||
statusType === 'loading' ? colors.info :
|
statusType === 'loading' ? colors.info :
|
||||||
colors.textMuted;
|
colors.textMuted;
|
||||||
|
|
||||||
// Get border color based on status
|
|
||||||
const inputBorderColor = statusType === 'error' ? colors.error :
|
const inputBorderColor = statusType === 'error' ? colors.error :
|
||||||
statusType === 'success' ? colors.success :
|
statusType === 'success' ? colors.success :
|
||||||
focusedElement === 'input' ? colors.focus :
|
focusedSection === 'input' ? colors.focus :
|
||||||
colors.border;
|
colors.borderMuted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
<Box flexDirection='column' alignItems='center' paddingY={1}>
|
||||||
@@ -123,14 +228,82 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
|
<Text color={colors.text} bold>Welcome to XO Wallet CLI</Text>
|
||||||
<Text color={colors.textMuted}>Enter your seed phrase to get started</Text>
|
<Text color={colors.textMuted}>Enter your seed phrase or select a saved wallet</Text>
|
||||||
|
|
||||||
{/* Spacer */}
|
|
||||||
<Box marginY={1} />
|
<Box marginY={1} />
|
||||||
|
|
||||||
{/* Input section */}
|
|
||||||
<Box flexDirection='column' width={64}>
|
<Box flexDirection='column' width={64}>
|
||||||
<Text color={colors.text} bold>Seed Phrase (12 or 24 words):</Text>
|
{/* ── Saved Wallets ─────────────────────────────────── */}
|
||||||
|
{mnemonicFiles.length > 0 && (
|
||||||
|
<Box flexDirection='column' marginBottom={1}>
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{'▸ '}Saved Wallets
|
||||||
|
</Text>
|
||||||
|
<Text color={colors.textMuted}> ({mnemonicFiles.length})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
flexDirection='column'
|
||||||
|
borderStyle='single'
|
||||||
|
borderColor={focusedSection === 'files' ? colors.focus : colors.borderMuted}
|
||||||
|
paddingX={1}
|
||||||
|
>
|
||||||
|
{mnemonicFiles.map((entry, idx) => {
|
||||||
|
const isHighlighted = focusedSection === 'files' && idx === selectedFileIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={entry.filename} paddingY={0}>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
bold={isHighlighted}
|
||||||
|
>
|
||||||
|
{isHighlighted ? ' ▶ ' : ' '}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.text}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
bold={isHighlighted}
|
||||||
|
>
|
||||||
|
{` ${entry.label} `}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color={isHighlighted ? colors.bg : colors.textMuted}
|
||||||
|
backgroundColor={isHighlighted ? colors.focus : undefined}
|
||||||
|
dimColor={!isHighlighted}
|
||||||
|
>
|
||||||
|
{` (${entry.filename})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{focusedSection === 'files' && (
|
||||||
|
<Box marginTop={0} paddingX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
↑↓ navigate • Enter: load wallet
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Divider between sections ──────────────────────── */}
|
||||||
|
{mnemonicFiles.length > 0 && (
|
||||||
|
<Box marginBottom={1} justifyContent='center'>
|
||||||
|
<Text color={colors.borderMuted}>{'─'.repeat(20)} or {'─'.repeat(20)}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Manual Seed Entry ─────────────────────────────── */}
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{'▸ '}Manual Entry
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.text}>Seed Phrase (12 or 24 words):</Text>
|
||||||
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
borderStyle='single'
|
borderStyle='single'
|
||||||
borderColor={inputBorderColor}
|
borderColor={inputBorderColor}
|
||||||
@@ -142,7 +315,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
onChange={setSeedPhrase}
|
onChange={setSeedPhrase}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
placeholder='Enter your seed phrase...'
|
placeholder='Enter your seed phrase...'
|
||||||
focus={focusedElement === 'input' && !isSubmitting}
|
focus={focusedSection === 'input' && !isSubmitting}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -162,7 +335,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
<Box justifyContent='center' marginTop={1}>
|
<Box justifyContent='center' marginTop={1}>
|
||||||
<Button
|
<Button
|
||||||
label='Continue'
|
label='Continue'
|
||||||
focused={focusedElement === 'button'}
|
focused={focusedSection === 'button'}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
shortcut='Enter'
|
shortcut='Enter'
|
||||||
/>
|
/>
|
||||||
@@ -172,7 +345,7 @@ export function SeedInputScreen(): React.ReactElement {
|
|||||||
{/* Help text */}
|
{/* Help text */}
|
||||||
<Box marginTop={2}>
|
<Box marginTop={2}>
|
||||||
<Text color={colors.textMuted} dimColor>
|
<Text color={colors.textMuted} dimColor>
|
||||||
Tab: navigate • Enter: submit • q: quit
|
Tab: navigate sections • Enter: submit • Esc: back
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||||
import { colors, logoSmall } from '../theme.js';
|
import { colors, logoSmall } from '../theme.js';
|
||||||
|
|
||||||
// XO Imports
|
// XO Imports
|
||||||
@@ -21,10 +22,7 @@ import type { XOTemplate } from '@xo-cash/types';
|
|||||||
import {
|
import {
|
||||||
formatTemplateListItem,
|
formatTemplateListItem,
|
||||||
formatActionListItem,
|
formatActionListItem,
|
||||||
deduplicateStartingActions,
|
|
||||||
getTemplateRoles,
|
getTemplateRoles,
|
||||||
getRolesForAction,
|
|
||||||
type UniqueStartingAction,
|
|
||||||
} from '../../utils/template-utils.js';
|
} from '../../utils/template-utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +31,7 @@ import {
|
|||||||
interface TemplateItem {
|
interface TemplateItem {
|
||||||
template: XOTemplate;
|
template: XOTemplate;
|
||||||
templateIdentifier: string;
|
templateIdentifier: string;
|
||||||
startingActions: UniqueStartingAction[];
|
availableActions: TemplateActionItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +40,17 @@ interface TemplateItem {
|
|||||||
type TemplateListItem = ListItemData<TemplateItem>;
|
type TemplateListItem = ListItemData<TemplateItem>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action list item with UniqueStartingAction value.
|
* Action list item with available action value.
|
||||||
*/
|
*/
|
||||||
type ActionListItem = ListItemData<UniqueStartingAction>;
|
type ActionListItem = ListItemData<TemplateActionItem>;
|
||||||
|
|
||||||
|
interface TemplateActionItem {
|
||||||
|
actionIdentifier: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
roles: string[];
|
||||||
|
source: 'starting' | 'next' | 'starting+next';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template List Screen Component.
|
* Template List Screen Component.
|
||||||
@@ -76,19 +82,90 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
setStatus('Loading templates...');
|
setStatus('Loading templates...');
|
||||||
|
|
||||||
const templateList = await appService.engine.listImportedTemplates();
|
const templateList = await appService.engine.listImportedTemplates();
|
||||||
|
const allUtxos = await appService.engine.listUnspentOutputsData();
|
||||||
|
|
||||||
|
const ownedOutputsByTemplate = new Map<string, Set<string>>();
|
||||||
|
for (const utxo of allUtxos) {
|
||||||
|
const existing = ownedOutputsByTemplate.get(utxo.templateIdentifier) ?? new Set<string>();
|
||||||
|
existing.add(utxo.outputIdentifier);
|
||||||
|
ownedOutputsByTemplate.set(utxo.templateIdentifier, existing);
|
||||||
|
}
|
||||||
|
|
||||||
const loadedTemplates = await Promise.all(
|
const loadedTemplates = await Promise.all(
|
||||||
templateList.map(async (template) => {
|
templateList.map(async (template) => {
|
||||||
const templateIdentifier = generateTemplateIdentifier(template);
|
const templateIdentifier = generateTemplateIdentifier(template);
|
||||||
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
const rawStartingActions = await appService.engine.listStartingActions(templateIdentifier);
|
||||||
|
const actionMap = new Map<string, TemplateActionItem>();
|
||||||
|
|
||||||
// Use utility function to deduplicate actions
|
for (const startingAction of rawStartingActions) {
|
||||||
const startingActions = deduplicateStartingActions(template, rawStartingActions);
|
const existing = actionMap.get(startingAction.action);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(startingAction.role)) {
|
||||||
|
existing.roles.push(startingAction.role);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[startingAction.action];
|
||||||
|
actionMap.set(startingAction.action, {
|
||||||
|
actionIdentifier: startingAction.action,
|
||||||
|
name: actionDef?.name || startingAction.action,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [startingAction.role],
|
||||||
|
source: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownedOutputIdentifiers = ownedOutputsByTemplate.get(templateIdentifier) ?? new Set<string>();
|
||||||
|
for (const outputIdentifier of ownedOutputIdentifiers) {
|
||||||
|
const outputDef = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDef || typeof outputDef.lockscript !== 'string') continue;
|
||||||
|
|
||||||
|
const lockingScriptDefinition = (template.lockingScripts as Record<string, unknown> | undefined)?.[outputDef.lockscript] as
|
||||||
|
| { roles?: Record<string, { actions?: Array<{ action?: string; role?: string } | string> }> }
|
||||||
|
| undefined;
|
||||||
|
if (!lockingScriptDefinition?.roles) continue;
|
||||||
|
|
||||||
|
for (const [lockscriptRoleId, lockscriptRoleDef] of Object.entries(lockingScriptDefinition.roles)) {
|
||||||
|
for (const actionSpec of lockscriptRoleDef.actions ?? []) {
|
||||||
|
const actionIdentifier = typeof actionSpec === 'string'
|
||||||
|
? actionSpec
|
||||||
|
: actionSpec.action;
|
||||||
|
if (!actionIdentifier) continue;
|
||||||
|
|
||||||
|
const roleIdentifier = typeof actionSpec === 'string'
|
||||||
|
? lockscriptRoleId
|
||||||
|
: (actionSpec.role ?? lockscriptRoleId);
|
||||||
|
|
||||||
|
const existing = actionMap.get(actionIdentifier);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.roles.includes(roleIdentifier)) {
|
||||||
|
existing.roles.push(roleIdentifier);
|
||||||
|
}
|
||||||
|
if (existing.source === 'starting') {
|
||||||
|
existing.source = 'starting+next';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionDef = template.actions?.[actionIdentifier];
|
||||||
|
actionMap.set(actionIdentifier, {
|
||||||
|
actionIdentifier,
|
||||||
|
name: actionDef?.name || actionIdentifier,
|
||||||
|
description: actionDef?.description,
|
||||||
|
roles: [roleIdentifier],
|
||||||
|
source: 'next',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableActions = Array.from(actionMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
template,
|
template,
|
||||||
templateIdentifier,
|
templateIdentifier,
|
||||||
startingActions,
|
availableActions,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -111,7 +188,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
|
|
||||||
// Get current template and its actions
|
// Get current template and its actions
|
||||||
const currentTemplate = templates[selectedTemplateIndex];
|
const currentTemplate = templates[selectedTemplateIndex];
|
||||||
const currentActions = currentTemplate?.startingActions ?? [];
|
const currentActions = currentTemplate?.availableActions ?? [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build template list items for ScrollableList.
|
* Build template list items for ScrollableList.
|
||||||
@@ -137,12 +214,17 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const formatted = formatActionListItem(
|
const formatted = formatActionListItem(
|
||||||
action.actionIdentifier,
|
action.actionIdentifier,
|
||||||
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
currentTemplate?.template?.actions?.[action.actionIdentifier],
|
||||||
action.roleCount,
|
action.roles.length,
|
||||||
index
|
index
|
||||||
);
|
);
|
||||||
|
const sourceSuffix = action.source === 'next'
|
||||||
|
? ' [next]'
|
||||||
|
: action.source === 'starting+next'
|
||||||
|
? ' [start+next]'
|
||||||
|
: '';
|
||||||
return {
|
return {
|
||||||
key: action.actionIdentifier,
|
key: action.actionIdentifier,
|
||||||
label: formatted.label,
|
label: `${formatted.label}${sourceSuffix}`,
|
||||||
description: formatted.description,
|
description: formatted.description,
|
||||||
value: action,
|
value: action,
|
||||||
hidden: !formatted.isValid,
|
hidden: !formatted.isValid,
|
||||||
@@ -171,13 +253,13 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
navigate('wizard', {
|
navigate('wizard', {
|
||||||
templateIdentifier: currentTemplate.templateIdentifier,
|
templateIdentifier: currentTemplate.templateIdentifier,
|
||||||
actionIdentifier: action.actionIdentifier,
|
actionIdentifier: action.actionIdentifier,
|
||||||
|
actionRoles: action.roles,
|
||||||
template: currentTemplate.template,
|
template: currentTemplate.template,
|
||||||
});
|
});
|
||||||
}, [currentTemplate, navigate]);
|
}, [currentTemplate, navigate]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation
|
||||||
useInput((input, key) => {
|
useBlockableInput((_input, key) => {
|
||||||
// Tab to switch panels
|
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
setFocusedPanel(prev => prev === 'templates' ? 'actions' : 'templates');
|
||||||
return;
|
return;
|
||||||
@@ -267,7 +349,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
paddingX={1}
|
paddingX={1}
|
||||||
flexGrow={1}
|
flexGrow={1}
|
||||||
>
|
>
|
||||||
<Text color={colors.primary} bold> Starting Actions </Text>
|
<Text color={colors.primary} bold> Available Actions </Text>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>Loading...</Text>
|
<Text color={colors.textMuted}>Loading...</Text>
|
||||||
@@ -283,7 +365,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionActivate}
|
onActivate={handleActionActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions'}
|
||||||
emptyMessage="No starting actions available"
|
emptyMessage="No actions available"
|
||||||
renderItem={renderActionItem}
|
renderItem={renderActionItem}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -339,9 +421,6 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
const action = currentActions[selectedActionIndex];
|
const action = currentActions[selectedActionIndex];
|
||||||
if (!action) return null;
|
if (!action) return null;
|
||||||
|
|
||||||
// Get roles that can start this action using utility function
|
|
||||||
const availableRoles = getRolesForAction(currentTemplate.template, action.actionIdentifier);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text color={colors.text} bold>
|
<Text color={colors.text} bold>
|
||||||
@@ -351,16 +430,24 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
{action.description || 'No description available'}
|
{action.description || 'No description available'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* List available roles for this action */}
|
{/* List roles available for this action in current context */}
|
||||||
{availableRoles.length > 0 && (
|
{action.roles.length > 0 && (
|
||||||
<Box marginTop={1} flexDirection="column">
|
<Box marginTop={1} flexDirection="column">
|
||||||
<Text color={colors.text}>Available Roles:</Text>
|
<Text color={colors.text}>Available Roles:</Text>
|
||||||
{availableRoles.map((role) => (
|
{action.roles.map((roleId) => {
|
||||||
<Text key={role.roleId} color={colors.textMuted}>
|
const roleDef = currentTemplate.template.roles?.[roleId];
|
||||||
{' '}- {role.name}
|
const roleName = typeof roleDef === 'object' ? roleDef?.name ?? roleId : roleId;
|
||||||
{role.description ? `: ${role.description}` : ''}
|
const roleDescription = typeof roleDef === 'object' ? roleDef?.description : undefined;
|
||||||
</Text>
|
return (
|
||||||
))}
|
<Text key={roleId} color={colors.textMuted}>
|
||||||
|
{' '}- {roleName}
|
||||||
|
{roleDescription ? `: ${roleDescription}` : ''}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}Source: {action.source}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -370,7 +457,7 @@ export function TemplateListScreen(): React.ReactElement {
|
|||||||
) : focusedPanel === 'actions' && !currentTemplate ? (
|
) : focusedPanel === 'actions' && !currentTemplate ? (
|
||||||
<Text color={colors.textMuted}>Select a template first</Text>
|
<Text color={colors.textMuted}>Select a template first</Text>
|
||||||
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
) : focusedPanel === 'actions' && currentActions.length === 0 ? (
|
||||||
<Text color={colors.textMuted}>No starting actions available</Text>
|
<Text color={colors.textMuted}>No actions available</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -9,10 +9,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ConfirmDialog } from '../components/Dialog.js';
|
import { ConfirmDialog } from '../components/Dialog.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useBlockableInput } from '../hooks/useInputLayer.js';
|
||||||
import { useInvitation } from '../hooks/useInvitations.js';
|
import { useInvitation } from '../hooks/useInvitations.js';
|
||||||
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
import { colors, logoSmall, formatSatoshis, formatHex } from '../theme.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { copyToClipboard } from '../utils/clipboard.js';
|
||||||
@@ -147,10 +148,8 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [signTransaction, copyTransactionHex, goBack]);
|
}, [signTransaction, copyTransactionHex, goBack]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// Handle keyboard navigation — automatically blocked when the confirm dialog is open.
|
||||||
useInput((input, key) => {
|
useBlockableInput((input, key) => {
|
||||||
if (showBroadcastConfirm) return;
|
|
||||||
|
|
||||||
// Tab to switch panels
|
// Tab to switch panels
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => {
|
setFocusedPanel(prev => {
|
||||||
@@ -177,7 +176,7 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
handleAction(action.value);
|
handleAction(action.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, { isActive: !showBroadcastConfirm });
|
});
|
||||||
|
|
||||||
// Extract transaction data from invitation
|
// Extract transaction data from invitation
|
||||||
const commits = invitation?.commits ?? [];
|
const commits = invitation?.commits ?? [];
|
||||||
@@ -407,7 +406,6 @@ export function TransactionScreen(): React.ReactElement {
|
|||||||
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
message='Are you sure you want to broadcast this transaction? This action cannot be undone.'
|
||||||
onConfirm={broadcastTransaction}
|
onConfirm={broadcastTransaction}
|
||||||
onCancel={() => setShowBroadcastConfirm(false)}
|
onCancel={() => setShowBroadcastConfirm(false)}
|
||||||
isActive={showBroadcastConfirm}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,18 +8,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { ScrollableList, type ListItemData } from '../components/List.js';
|
import { ScrollableList, type ListItemData } from '../components/List.js';
|
||||||
|
import { QRCode } from '../components/QRCode.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
||||||
|
import { useInputLayer, useLayeredInput, useBlockableInput, useIsInputCaptured } from '../hooks/useInputLayer.js';
|
||||||
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
import { colors, formatSatoshis, formatHex, logoSmall } from '../theme.js';
|
||||||
import type { HistoryItem } from '../../services/history.js';
|
import type { HistoryItem } from '../../services/history.js';
|
||||||
|
|
||||||
|
import { generateTemplateIdentifier } from '@xo-cash/engine';
|
||||||
|
import { hexToBin, lockingBytecodeToCashAddress } from '@bitauth/libauth';
|
||||||
|
|
||||||
// Import utility functions
|
// Import utility functions
|
||||||
import {
|
import {
|
||||||
formatHistoryListItem,
|
buildHistoryDisplayRows,
|
||||||
getHistoryItemColorName,
|
getHistoryItemColorName,
|
||||||
formatHistoryDate,
|
formatHistoryDate,
|
||||||
|
type HistoryDisplayRow,
|
||||||
type HistoryColorName,
|
type HistoryColorName,
|
||||||
} from '../../utils/history-utils.js';
|
} from '../../utils/history-utils.js';
|
||||||
|
|
||||||
@@ -56,9 +62,42 @@ const menuItems: ListItemData<string>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History list item with HistoryItem value.
|
* History list item with display row value.
|
||||||
*/
|
*/
|
||||||
type HistoryListItem = ListItemData<HistoryItem>;
|
type HistoryListItem = ListItemData<HistoryDisplayRow>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* QR code dialog overlay — auto-captures input via the layer system.
|
||||||
|
* Rendered only while a QR address is visible; closes on Enter/Esc.
|
||||||
|
*/
|
||||||
|
function QRDialogOverlay({ address, onClose }: { address: string; onClose: () => void }): React.ReactElement {
|
||||||
|
useInputLayer('qr-dialog');
|
||||||
|
|
||||||
|
useLayeredInput('qr-dialog', (_input, key) => {
|
||||||
|
if (key.escape || key.return) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<QRCode
|
||||||
|
value={address}
|
||||||
|
dialog
|
||||||
|
dialogTitle="Receive Address"
|
||||||
|
showValue
|
||||||
|
/>
|
||||||
|
<Box justifyContent="center" marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wallet State Screen Component.
|
* Wallet State Screen Component.
|
||||||
@@ -77,6 +116,9 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
const [selectedHistoryIndex, setSelectedHistoryIndex] = useState(0);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
/** Cash address to display in the QR code dialog (null when dialog is hidden). */
|
||||||
|
const [qrAddress, setQrAddress] = useState<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes wallet state.
|
* Refreshes wallet state.
|
||||||
*/
|
*/
|
||||||
@@ -119,7 +161,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a new receiving address.
|
* Generates a new receiving address and displays it as a QR code.
|
||||||
*/
|
*/
|
||||||
const generateNewAddress = useCallback(async () => {
|
const generateNewAddress = useCallback(async () => {
|
||||||
if (!appService) {
|
if (!appService) {
|
||||||
@@ -139,24 +181,35 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a new locking bytecode
|
// Generate the template identifier
|
||||||
const { generateTemplateIdentifier } = await import('@xo-cash/engine');
|
|
||||||
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
const templateId = generateTemplateIdentifier(p2pkhTemplate);
|
||||||
|
|
||||||
const lockingBytecode = await appService.engine.generateLockingBytecode(
|
// Generate the locking bytecode (returned as a hex string)
|
||||||
|
const lockingBytecodeHex = await appService.engine.generateLockingBytecode(
|
||||||
templateId,
|
templateId,
|
||||||
'receiveOutput',
|
'receiveOutput',
|
||||||
'receiver',
|
'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);
|
||||||
|
setStatus('Address generated');
|
||||||
|
|
||||||
// Refresh to show updated state
|
// Refresh to show updated state
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to generate address: ${error instanceof Error ? error.message : String(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.
|
* Handles menu action.
|
||||||
@@ -194,21 +247,22 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
* Build history list items for ScrollableList.
|
* Build history list items for ScrollableList.
|
||||||
*/
|
*/
|
||||||
const historyListItems = useMemo((): HistoryListItem[] => {
|
const historyListItems = useMemo((): HistoryListItem[] => {
|
||||||
return history.map(item => {
|
return buildHistoryDisplayRows(history).map(row => {
|
||||||
const formatted = formatHistoryListItem(item, false);
|
|
||||||
return {
|
return {
|
||||||
key: item.id,
|
key: row.id,
|
||||||
label: formatted.label,
|
label: row.label,
|
||||||
description: formatted.description,
|
description: row.description,
|
||||||
value: item,
|
value: row,
|
||||||
color: formatted.color,
|
color: getHistoryItemColorName(row, false),
|
||||||
hidden: !formatted.isValid,
|
hidden: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [history]);
|
}, [history]);
|
||||||
|
|
||||||
// Handle keyboard navigation between panels
|
// Screen input — automatically blocked when any dialog/overlay is capturing.
|
||||||
useInput((input, key) => {
|
const isCaptured = useIsInputCaptured();
|
||||||
|
|
||||||
|
useBlockableInput((_input, key) => {
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
setFocusedPanel(prev => prev === 'menu' ? 'history' : 'menu');
|
||||||
}
|
}
|
||||||
@@ -222,49 +276,63 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const historyItem = item.value;
|
const row = item.value;
|
||||||
if (!historyItem) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const colorName = getHistoryItemColorName(historyItem.type, isFocused);
|
const colorName = getHistoryItemColorName(row, isFocused);
|
||||||
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
const itemColor = isFocused ? colors.focus : getHistoryColor(colorName);
|
||||||
const dateStr = formatHistoryDate(historyItem.timestamp);
|
const dateStr = formatHistoryDate(row.timestamp);
|
||||||
const indicator = isFocused ? '▸ ' : ' ';
|
const indicator = isFocused ? '▸ ' : ' ';
|
||||||
|
const groupingPrefix = row.isNested ? ' -> ' : '';
|
||||||
|
|
||||||
// Format based on type
|
if (row.type === 'invitation') {
|
||||||
if (historyItem.type === 'invitation_created') {
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}[Invitation] {historyItem.description}
|
{indicator}[Invitation] {row.label}
|
||||||
</Text>
|
</Text>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else if (historyItem.type === 'utxo_reserved') {
|
}
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
|
if (row.type === 'invitation_input') {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box>
|
<Box>
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}[Reserved] {formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Input] {row.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text color={colors.textMuted}> {historyItem.description}</Text>
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
} else if (historyItem.type === 'utxo_received') {
|
}
|
||||||
const sats = historyItem.valueSatoshis ?? 0n;
|
|
||||||
const reservedTag = historyItem.reserved ? ' [Reserved]' : '';
|
if (row.type === 'invitation_output') {
|
||||||
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Box flexDirection="row">
|
<Box flexDirection="row">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{formatSatoshis(sats)}
|
{indicator}{groupingPrefix}[Output] {formatSatoshis(sats)}
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{' '}{historyItem.description}{reservedTag}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}</Text>}
|
||||||
|
</Box>
|
||||||
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'utxo') {
|
||||||
|
const sats = row.utxo?.valueSatoshis ?? 0n;
|
||||||
|
const reservedTag = row.utxo?.reserved ? ' [Reserved]' : '';
|
||||||
|
return (
|
||||||
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
|
<Box flexDirection="row">
|
||||||
|
<Text color={itemColor}>{indicator}{formatSatoshis(sats)}</Text>
|
||||||
|
{row.description && <Text color={colors.textMuted}> {row.description}{reservedTag}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -275,7 +343,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Box flexDirection="row" justifyContent="space-between">
|
<Box flexDirection="row" justifyContent="space-between">
|
||||||
<Text color={itemColor}>
|
<Text color={itemColor}>
|
||||||
{indicator}{historyItem.type}: {historyItem.description}
|
{indicator}{row.label}
|
||||||
</Text>
|
</Text>
|
||||||
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
{dateStr && <Text color={colors.textMuted}>{dateStr}</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -341,7 +409,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedMenuIndex}
|
selectedIndex={selectedMenuIndex}
|
||||||
onSelect={setSelectedMenuIndex}
|
onSelect={setSelectedMenuIndex}
|
||||||
onActivate={handleMenuItemActivate}
|
onActivate={handleMenuItemActivate}
|
||||||
focus={focusedPanel === 'menu'}
|
focus={focusedPanel === 'menu' && !isCaptured}
|
||||||
emptyMessage="No actions"
|
emptyMessage="No actions"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -369,7 +437,7 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
items={historyListItems}
|
items={historyListItems}
|
||||||
selectedIndex={selectedHistoryIndex}
|
selectedIndex={selectedHistoryIndex}
|
||||||
onSelect={setSelectedHistoryIndex}
|
onSelect={setSelectedHistoryIndex}
|
||||||
focus={focusedPanel === 'history'}
|
focus={focusedPanel === 'history' && !isCaptured}
|
||||||
maxVisible={10}
|
maxVisible={10}
|
||||||
emptyMessage="No history found"
|
emptyMessage="No history found"
|
||||||
renderItem={renderHistoryItem}
|
renderItem={renderHistoryItem}
|
||||||
@@ -384,6 +452,14 @@ export function WalletStateScreen(): React.ReactElement {
|
|||||||
Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back
|
Tab: Switch focus • Enter: Select • ↑↓: Navigate • Esc: Back
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* QR Code dialog overlay for generated addresses */}
|
||||||
|
{qrAddress && (
|
||||||
|
<QRDialogOverlay
|
||||||
|
address={qrAddress}
|
||||||
|
onClose={() => setQrAddress(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +1,22 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
import { StepIndicator, type Step } from '../../components/ProgressBar.js';
|
||||||
import { Button } from '../../components/Button.js';
|
import { Button } from '../../components/Button.js';
|
||||||
import { colors, logoSmall } from '../../theme.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
|
// Steps
|
||||||
import { InfoStep } from './steps/InfoStep.js';
|
|
||||||
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
import { VariablesStep } from './steps/VariablesStep.js';
|
import { VariablesStep } from './steps/VariablesStep.js';
|
||||||
import { InputsStep } from './steps/InputsStep.js';
|
import { InputsStep } from './steps/InputsStep.js';
|
||||||
import { ReviewStep } from './steps/ReviewStep.js';
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
import { PublishStep } from './steps/PublishStep.js';
|
import { PublishStep } from './steps/PublishStep.js';
|
||||||
|
import { DataResultStep } from './steps/DataResultStep.js';
|
||||||
|
|
||||||
export function ActionWizardScreen(): React.ReactElement {
|
export function ActionWizardScreen(): React.ReactElement {
|
||||||
const wizard = useActionWizard();
|
const wizard = useActionWizard();
|
||||||
|
useWizardKeyboard(wizard);
|
||||||
// ── 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 }
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Step router ────────────────────────────────────────────────
|
// ── Step router ────────────────────────────────────────────────
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
@@ -152,15 +25,6 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (wizard.currentStepData?.type) {
|
switch (wizard.currentStepData?.type) {
|
||||||
case 'info':
|
|
||||||
return (
|
|
||||||
<InfoStep
|
|
||||||
template={wizard.template!}
|
|
||||||
actionIdentifier={wizard.actionIdentifier!}
|
|
||||||
roleIdentifier={wizard.roleIdentifier!}
|
|
||||||
actionName={wizard.actionName}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'role-select':
|
case 'role-select':
|
||||||
return (
|
return (
|
||||||
<RoleSelectStep
|
<RoleSelectStep
|
||||||
@@ -205,7 +69,21 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'publish':
|
case 'publish':
|
||||||
return <PublishStep invitationId={wizard.invitationId} />;
|
return (
|
||||||
|
<PublishStep
|
||||||
|
invitationId={wizard.invitationId}
|
||||||
|
requirementsComplete={wizard.requirementsComplete}
|
||||||
|
hasSignedAndBroadcasted={wizard.hasSignedAndBroadcasted}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'result':
|
||||||
|
return (
|
||||||
|
<DataResultStep
|
||||||
|
actionName={wizard.actionName}
|
||||||
|
variables={wizard.variables}
|
||||||
|
dataResults={wizard.dataResults}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -272,7 +150,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
wizard.focusArea === "buttons" &&
|
wizard.focusArea === "buttons" &&
|
||||||
wizard.focusedButton === "back"
|
wizard.focusedButton === "back"
|
||||||
}
|
}
|
||||||
disabled={wizard.currentStepData?.type === "publish"}
|
disabled={wizard.isLastStep}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Cancel"
|
label="Cancel"
|
||||||
@@ -283,9 +161,7 @@ export function ActionWizardScreen(): React.ReactElement {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
label={
|
label={wizard.nextButtonLabel}
|
||||||
wizard.currentStepData?.type === "publish" ? "Done" : "Next"
|
|
||||||
}
|
|
||||||
focused={
|
focused={
|
||||||
wizard.focusArea === "buttons" &&
|
wizard.focusArea === "buttons" &&
|
||||||
wizard.focusedButton === "next"
|
wizard.focusedButton === "next"
|
||||||
|
|||||||
40
src/tui/screens/action-wizard/flows/DataWizardFlow.ts
Normal file
40
src/tui/screens/action-wizard/flows/DataWizardFlow.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
import { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow strategy for data-only actions (e.g. sign, verify).
|
||||||
|
*
|
||||||
|
* These actions produce computed data rather than a transaction.
|
||||||
|
* No invitation, UTXOs, or fees are involved — just variables in,
|
||||||
|
* data result out.
|
||||||
|
*
|
||||||
|
* NOTE: Engine-level data action execution is not yet implemented.
|
||||||
|
* The result step is currently stubbed.
|
||||||
|
*/
|
||||||
|
export class DataWizardFlow extends WizardFlow {
|
||||||
|
readonly type = "data" as const;
|
||||||
|
|
||||||
|
/** The data field identifiers this action produces (from action.data). */
|
||||||
|
readonly dataOutputs: string[];
|
||||||
|
|
||||||
|
constructor(dataOutputs: string[]) {
|
||||||
|
super();
|
||||||
|
this.dataOutputs = dataOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStepTypes(context: FlowContext): StepType[] {
|
||||||
|
const steps: StepType[] = [];
|
||||||
|
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||||
|
if (context.hasVariables) steps.push("variables");
|
||||||
|
steps.push("result");
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
canFinalize(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalActionLabel(): string {
|
||||||
|
return "Done";
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/tui/screens/action-wizard/flows/TransactionWizardFlow.ts
Normal file
36
src/tui/screens/action-wizard/flows/TransactionWizardFlow.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
import { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flow strategy for transaction-based actions.
|
||||||
|
*
|
||||||
|
* Handles both single-role actions (sendSatoshis, burn) where the
|
||||||
|
* creator provides inputs and signs locally, and multi-role actions
|
||||||
|
* (receive, request) where the creator publishes an invitation for
|
||||||
|
* another party to complete.
|
||||||
|
*/
|
||||||
|
export class TransactionWizardFlow extends WizardFlow {
|
||||||
|
readonly type = "transaction" as const;
|
||||||
|
|
||||||
|
getStepTypes(context: FlowContext): StepType[] {
|
||||||
|
const steps: StepType[] = [];
|
||||||
|
if (context.availableRoles.length > 1) steps.push("role-select");
|
||||||
|
if (context.hasVariables) steps.push("variables");
|
||||||
|
if (context.shouldCollectInputs) steps.push("inputs");
|
||||||
|
steps.push("review");
|
||||||
|
steps.push("publish");
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
canFinalize(context: FlowContext): boolean {
|
||||||
|
return (
|
||||||
|
context.requirementsComplete &&
|
||||||
|
context.wizardCollectedInputs &&
|
||||||
|
!context.hasSignedAndBroadcasted
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFinalActionLabel(context: FlowContext): string {
|
||||||
|
return this.canFinalize(context) ? "Sign & Broadcast" : "Done";
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/tui/screens/action-wizard/flows/WizardFlow.ts
Normal file
22
src/tui/screens/action-wizard/flows/WizardFlow.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { FlowContext, StepType } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract strategy that defines the shape of a wizard flow.
|
||||||
|
*
|
||||||
|
* Subclasses declare which steps are needed, whether the action can be
|
||||||
|
* finalized, and what the final button should say. They hold no React
|
||||||
|
* state — the orchestrator hook wires domain hooks to the step configs
|
||||||
|
* produced from these methods.
|
||||||
|
*/
|
||||||
|
export abstract class WizardFlow {
|
||||||
|
abstract readonly type: "transaction" | "data";
|
||||||
|
|
||||||
|
/** Determine which step types this flow needs given the current context. */
|
||||||
|
abstract getStepTypes(context: FlowContext): StepType[];
|
||||||
|
|
||||||
|
/** Whether the action can be finalized (e.g. signed & broadcast). */
|
||||||
|
abstract canFinalize(context: FlowContext): boolean;
|
||||||
|
|
||||||
|
/** Label for the primary action button on the final step. */
|
||||||
|
abstract getFinalActionLabel(context: FlowContext): string;
|
||||||
|
}
|
||||||
21
src/tui/screens/action-wizard/flows/index.ts
Normal file
21
src/tui/screens/action-wizard/flows/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { XOTemplateAction } from "@xo-cash/types";
|
||||||
|
import { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||||
|
import { DataWizardFlow } from "./DataWizardFlow.js";
|
||||||
|
import type { WizardFlow } from "./WizardFlow.js";
|
||||||
|
|
||||||
|
export { WizardFlow } from "./WizardFlow.js";
|
||||||
|
export { TransactionWizardFlow } from "./TransactionWizardFlow.js";
|
||||||
|
export { DataWizardFlow } from "./DataWizardFlow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect a template action and return the appropriate wizard flow strategy.
|
||||||
|
*
|
||||||
|
* Actions with `data` fields and no `transaction` are data-only flows.
|
||||||
|
* Everything else uses the transaction flow.
|
||||||
|
*/
|
||||||
|
export function createWizardFlow(action: XOTemplateAction): WizardFlow {
|
||||||
|
if (action.data?.length && !action.transaction) {
|
||||||
|
return new DataWizardFlow(action.data);
|
||||||
|
}
|
||||||
|
return new TransactionWizardFlow();
|
||||||
|
}
|
||||||
534
src/tui/screens/action-wizard/hooks/useActionWizard.ts
Normal file
534
src/tui/screens/action-wizard/hooks/useActionWizard.ts
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import { useNavigation } from "../../../hooks/useNavigation.js";
|
||||||
|
import { useAppContext, useStatus } from "../../../hooks/useAppContext.js";
|
||||||
|
import { copyToClipboard } from "../../../utils/clipboard.js";
|
||||||
|
import { roleRequiresInputs } from "../../../../utils/invitation-flow.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import type { StepConfig, FlowContext, DataResult } from "../types.js";
|
||||||
|
import {
|
||||||
|
createWizardFlow,
|
||||||
|
type WizardFlow,
|
||||||
|
DataWizardFlow,
|
||||||
|
} from "../flows/index.js";
|
||||||
|
import { useRoleSelection } from "./useRoleSelection.js";
|
||||||
|
import { useVariableInputs } from "./useVariableInputs.js";
|
||||||
|
import { useUtxoSelection } from "./useUtxoSelection.js";
|
||||||
|
import { useInvitationManager } from "./useInvitationManager.js";
|
||||||
|
import { useWizardFocus } from "./useWizardFocus.js";
|
||||||
|
import { useWizardSteps } from "./useWizardSteps.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin orchestrator that composes domain hooks and wires them
|
||||||
|
* to step configs produced by the WizardFlow strategy.
|
||||||
|
*
|
||||||
|
* This replaces the original 861-line god-hook.
|
||||||
|
*/
|
||||||
|
export function useActionWizard() {
|
||||||
|
const { goBack, data: navData } = useNavigation();
|
||||||
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
|
if (!appService) {
|
||||||
|
throw new Error("AppService not initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigation data ───────────────────────────────────────────
|
||||||
|
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
||||||
|
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
||||||
|
const template = navData.template as XOTemplate | undefined;
|
||||||
|
const actionRolesFromNavigation = navData.actionRoles as string[] | undefined;
|
||||||
|
|
||||||
|
// ── Derived template data ─────────────────────────────────────
|
||||||
|
const action = template?.actions?.[actionIdentifier ?? ""];
|
||||||
|
const actionName = action?.name || actionIdentifier || "Unknown";
|
||||||
|
|
||||||
|
// ── Flow strategy ─────────────────────────────────────────────
|
||||||
|
const flow = useMemo<WizardFlow>(() => {
|
||||||
|
// Create a default action if no action is found
|
||||||
|
if (!action) {
|
||||||
|
return createWizardFlow({ name: "", description: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the flow from the action
|
||||||
|
return createWizardFlow(action);
|
||||||
|
}, [action]);
|
||||||
|
|
||||||
|
// ── Domain hooks ──────────────────────────────────────────────
|
||||||
|
const roleSelection = useRoleSelection(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
actionRolesFromNavigation,
|
||||||
|
);
|
||||||
|
const variableInputs = useVariableInputs();
|
||||||
|
const utxoSelection = useUtxoSelection();
|
||||||
|
const invitationManager = useInvitationManager({
|
||||||
|
appService,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
setStatus,
|
||||||
|
});
|
||||||
|
const focus = useWizardFocus();
|
||||||
|
|
||||||
|
// ── Data results (data-only flows) ────────────────────────────
|
||||||
|
const [dataResults, setDataResults] = useState<DataResult[]>([]);
|
||||||
|
|
||||||
|
// ── Initialize variables when role becomes available ──────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (template && actionIdentifier && roleSelection.effectiveRole) {
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[roleSelection.effectiveRole];
|
||||||
|
const varIds = role?.requirements?.variables;
|
||||||
|
if (varIds && varIds.length > 0) {
|
||||||
|
variableInputs.initFromTemplate(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
variableInputs.initFromTemplate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Determine whether creator should provide inputs ───────────
|
||||||
|
const shouldCollectInputs = useMemo(() => {
|
||||||
|
if (flow.type !== "transaction") return false;
|
||||||
|
if (!template || !actionIdentifier || !roleSelection.effectiveRole)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const totalActionRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const isSingleRoleAction = totalActionRoles <= 1;
|
||||||
|
return (
|
||||||
|
isSingleRoleAction &&
|
||||||
|
roleRequiresInputs(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [flow.type, template, actionIdentifier, roleSelection.effectiveRole]);
|
||||||
|
|
||||||
|
// ── Build flow context for strategy methods ───────────────────
|
||||||
|
const flowContext = useMemo<FlowContext>(
|
||||||
|
() => ({
|
||||||
|
availableRoles: roleSelection.availableRoles,
|
||||||
|
hasVariables: variableInputs.variables.length > 0,
|
||||||
|
shouldCollectInputs,
|
||||||
|
requirementsComplete: invitationManager.requirementsComplete,
|
||||||
|
wizardCollectedInputs: shouldCollectInputs,
|
||||||
|
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
roleSelection.availableRoles,
|
||||||
|
variableInputs.variables.length,
|
||||||
|
shouldCollectInputs,
|
||||||
|
invitationManager.requirementsComplete,
|
||||||
|
invitationManager.hasSignedAndBroadcasted,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Handle Enter inside a TextInput ───────────────────────────
|
||||||
|
const handleTextInputSubmit = useCallback(() => {
|
||||||
|
if (focus.focusedInput < variableInputs.variables.length - 1) {
|
||||||
|
focus.setFocusedInput((prev) => prev + 1);
|
||||||
|
} else {
|
||||||
|
focus.moveToButtons();
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
focus.focusedInput,
|
||||||
|
variableInputs.variables.length,
|
||||||
|
focus.setFocusedInput,
|
||||||
|
focus.moveToButtons,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Copy invitation ID to clipboard ───────────────────────────
|
||||||
|
const copyId = useCallback(async () => {
|
||||||
|
if (!invitationManager.invitationId) return;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(invitationManager.invitationId);
|
||||||
|
showInfo(`Copied to clipboard!\n\n${invitationManager.invitationId}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [invitationManager.invitationId, showInfo, showError]);
|
||||||
|
|
||||||
|
// ── Helper: create invitation if it doesn't exist yet ─────────
|
||||||
|
const ensureInvitation = useCallback(
|
||||||
|
async (roleId?: string): Promise<string | null> => {
|
||||||
|
if (invitationManager.invitationId) return invitationManager.invitationId;
|
||||||
|
const role = roleId ?? roleSelection.effectiveRole;
|
||||||
|
if (!templateIdentifier || !actionIdentifier || !role || !template)
|
||||||
|
return null;
|
||||||
|
return invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
role,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
invitationManager.invitationId,
|
||||||
|
invitationManager.createWithVariables,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Helper: load UTXOs after invitation is created ────────────
|
||||||
|
const loadUtxosForInvitation = useCallback(
|
||||||
|
async (invId: string) => {
|
||||||
|
if (!appService || !templateIdentifier) return;
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv) => inv.data.invitationIdentifier === invId,
|
||||||
|
);
|
||||||
|
if (instance) {
|
||||||
|
invitationManager.setIsProcessing(true);
|
||||||
|
try {
|
||||||
|
await utxoSelection.loadUtxos(
|
||||||
|
instance,
|
||||||
|
templateIdentifier,
|
||||||
|
variableInputs.variables,
|
||||||
|
setStatus,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
invitationManager.setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
appService,
|
||||||
|
templateIdentifier,
|
||||||
|
variableInputs.variables,
|
||||||
|
utxoSelection.loadUtxos,
|
||||||
|
invitationManager.setIsProcessing,
|
||||||
|
setStatus,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Build step configs from flow strategy ─────────────────────
|
||||||
|
const stepConfigs = useMemo<StepConfig[]>(() => {
|
||||||
|
const stepTypes = flow.getStepTypes(flowContext);
|
||||||
|
|
||||||
|
return stepTypes.map((type): StepConfig => {
|
||||||
|
switch (type) {
|
||||||
|
case "role-select":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Select Role",
|
||||||
|
validate: () => {
|
||||||
|
const selectedRole =
|
||||||
|
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||||
|
return selectedRole ? null : "Please select a role";
|
||||||
|
},
|
||||||
|
onNext: async () => {
|
||||||
|
const selectedRole =
|
||||||
|
roleSelection.availableRoles[roleSelection.selectedRoleIndex];
|
||||||
|
if (!selectedRole) return false;
|
||||||
|
|
||||||
|
// Initialize variables for this role immediately
|
||||||
|
if (template && actionIdentifier) {
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const role = act?.roles?.[selectedRole];
|
||||||
|
const hasVars =
|
||||||
|
(role?.requirements?.variables?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasVars) {
|
||||||
|
variableInputs.initFromTemplate(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no variables step follows, create the invitation now (transaction flows only)
|
||||||
|
if (!hasVars && flow.type === "transaction") {
|
||||||
|
if (templateIdentifier && template) {
|
||||||
|
const invId = await invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
template,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
if (!invId) return false;
|
||||||
|
|
||||||
|
// Pre-load UTXOs if the inputs step follows
|
||||||
|
const totalRoles = Object.keys(act?.roles ?? {}).length;
|
||||||
|
const needsInputs =
|
||||||
|
totalRoles <= 1 &&
|
||||||
|
roleRequiresInputs(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
selectedRole,
|
||||||
|
);
|
||||||
|
if (needsInputs) {
|
||||||
|
await loadUtxosForInvitation(invId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roleSelection.setRoleIdentifier(selectedRole);
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "variables":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Variables",
|
||||||
|
validate: () => variableInputs.validate(),
|
||||||
|
onNext: async () => {
|
||||||
|
if (flow.type === "transaction") {
|
||||||
|
if (
|
||||||
|
!templateIdentifier ||
|
||||||
|
!actionIdentifier ||
|
||||||
|
!template ||
|
||||||
|
!roleSelection.effectiveRole
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const invId = await invitationManager.createWithVariables(
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
template,
|
||||||
|
variableInputs.variables,
|
||||||
|
);
|
||||||
|
if (!invId) return false;
|
||||||
|
|
||||||
|
// Pre-load UTXOs if the inputs step follows
|
||||||
|
if (shouldCollectInputs) {
|
||||||
|
await loadUtxosForInvitation(invId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For data flows, just advance — variables are used in the result step
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "inputs":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Select UTXOs",
|
||||||
|
validate: () => utxoSelection.validate(),
|
||||||
|
onNext: async () => {
|
||||||
|
const selectedUtxos = utxoSelection.availableUtxos.filter(
|
||||||
|
(u) => u.selected,
|
||||||
|
);
|
||||||
|
const success = await invitationManager.addInputsAndOutputs(
|
||||||
|
selectedUtxos,
|
||||||
|
utxoSelection.changeAmount,
|
||||||
|
);
|
||||||
|
if (success) focus.resetToContent();
|
||||||
|
return success;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "review":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Review",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
// Ensure invitation exists (covers the case where no prior step created it)
|
||||||
|
const invId = await ensureInvitation();
|
||||||
|
if (!invId) return false;
|
||||||
|
await invitationManager.refreshRequirements(invId);
|
||||||
|
focus.resetToContent();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "publish":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Publish",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
if (flow.canFinalize(flowContext)) {
|
||||||
|
await invitationManager.signAndBroadcast();
|
||||||
|
// Stay on publish step (it's the last step, stepper won't advance)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
goBack();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "result":
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: "Result",
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => {
|
||||||
|
// Data-only flows: populate stubbed results, then exit
|
||||||
|
if (flow instanceof DataWizardFlow) {
|
||||||
|
const results: DataResult[] = flow.dataOutputs.map((dataId) => {
|
||||||
|
const dataDef = template?.data?.[dataId];
|
||||||
|
return {
|
||||||
|
id: dataId,
|
||||||
|
name: dataDef?.hint ?? dataId,
|
||||||
|
type: dataDef?.type ?? "unknown",
|
||||||
|
hint: dataDef?.hint,
|
||||||
|
value: null, // Engine-level data execution not yet implemented
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setDataResults(results);
|
||||||
|
}
|
||||||
|
goBack();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
name: type,
|
||||||
|
validate: () => null,
|
||||||
|
onNext: async () => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
flow,
|
||||||
|
flowContext,
|
||||||
|
roleSelection,
|
||||||
|
variableInputs,
|
||||||
|
utxoSelection,
|
||||||
|
invitationManager,
|
||||||
|
focus,
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
shouldCollectInputs,
|
||||||
|
ensureInvitation,
|
||||||
|
loadUtxosForInvitation,
|
||||||
|
goBack,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Step navigation ───────────────────────────────────────────
|
||||||
|
const stepper = useWizardSteps(stepConfigs, goBack, showError);
|
||||||
|
|
||||||
|
// ── Set initial status ────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!template || !actionIdentifier) {
|
||||||
|
showError("Missing wizard data");
|
||||||
|
goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(
|
||||||
|
roleSelection.effectiveRole
|
||||||
|
? `${actionIdentifier}/${roleSelection.effectiveRole}`
|
||||||
|
: actionIdentifier,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
roleSelection.effectiveRole,
|
||||||
|
showError,
|
||||||
|
goBack,
|
||||||
|
setStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Convenience derived values ────────────────────────────────
|
||||||
|
const textInputHasFocus =
|
||||||
|
stepper.currentStepData?.type === "variables" &&
|
||||||
|
focus.focusArea === "content";
|
||||||
|
|
||||||
|
const canSignAndBroadcast = flow.canFinalize(flowContext);
|
||||||
|
|
||||||
|
const isLastStep = stepper.currentStep >= stepper.steps.length - 1;
|
||||||
|
const lastStepType = stepper.currentStepData?.type;
|
||||||
|
const nextButtonLabel =
|
||||||
|
lastStepType === "publish"
|
||||||
|
? flow.getFinalActionLabel(flowContext)
|
||||||
|
: lastStepType === "result"
|
||||||
|
? "Done"
|
||||||
|
: "Next";
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────
|
||||||
|
return {
|
||||||
|
// Meta
|
||||||
|
template,
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
roleIdentifier: roleSelection.effectiveRole,
|
||||||
|
action,
|
||||||
|
actionName,
|
||||||
|
flow,
|
||||||
|
flowContext,
|
||||||
|
|
||||||
|
// Role selection
|
||||||
|
availableRoles: roleSelection.availableRoles,
|
||||||
|
selectedRoleIndex: roleSelection.selectedRoleIndex,
|
||||||
|
setSelectedRoleIndex: roleSelection.setSelectedRoleIndex,
|
||||||
|
|
||||||
|
// Steps
|
||||||
|
steps: stepper.steps,
|
||||||
|
currentStep: stepper.currentStep,
|
||||||
|
currentStepData: stepper.currentStepData,
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
variables: variableInputs.variables,
|
||||||
|
updateVariable: variableInputs.updateVariable,
|
||||||
|
handleTextInputSubmit,
|
||||||
|
|
||||||
|
// UTXOs
|
||||||
|
availableUtxos: utxoSelection.availableUtxos,
|
||||||
|
selectedUtxoIndex: utxoSelection.selectedUtxoIndex,
|
||||||
|
setSelectedUtxoIndex: utxoSelection.setSelectedUtxoIndex,
|
||||||
|
requiredAmount: utxoSelection.requiredAmount,
|
||||||
|
fee: utxoSelection.fee,
|
||||||
|
selectedAmount: utxoSelection.selectedAmount,
|
||||||
|
changeAmount: utxoSelection.changeAmount,
|
||||||
|
toggleUtxoSelection: utxoSelection.toggleSelection,
|
||||||
|
selectAll: utxoSelection.selectAll,
|
||||||
|
deselectAll: utxoSelection.deselectAll,
|
||||||
|
|
||||||
|
// Invitation
|
||||||
|
invitation: invitationManager.invitation,
|
||||||
|
invitationId: invitationManager.invitationId,
|
||||||
|
requirementsComplete: invitationManager.requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted: invitationManager.hasSignedAndBroadcasted,
|
||||||
|
canSignAndBroadcast,
|
||||||
|
|
||||||
|
// Data results
|
||||||
|
dataResults,
|
||||||
|
|
||||||
|
// UI focus
|
||||||
|
focusedInput: focus.focusedInput,
|
||||||
|
setFocusedInput: focus.setFocusedInput,
|
||||||
|
focusedButton: focus.focusedButton,
|
||||||
|
setFocusedButton: focus.setFocusedButton,
|
||||||
|
focusArea: focus.focusArea,
|
||||||
|
setFocusArea: focus.setFocusArea,
|
||||||
|
isProcessing: invitationManager.isProcessing,
|
||||||
|
textInputHasFocus,
|
||||||
|
nextButtonLabel,
|
||||||
|
isLastStep,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
nextStep: stepper.nextStep,
|
||||||
|
previousStep: stepper.previousStep,
|
||||||
|
cancel: stepper.cancel,
|
||||||
|
copyId,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience type so other files can type the return value. */
|
||||||
|
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
||||||
295
src/tui/screens/action-wizard/hooks/useInvitationManager.ts
Normal file
295
src/tui/screens/action-wizard/hooks/useInvitationManager.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type {
|
||||||
|
XOTemplate,
|
||||||
|
XOInvitation,
|
||||||
|
XOTemplateTransactionOutput,
|
||||||
|
} from "@xo-cash/types";
|
||||||
|
import type { VariableInput, SelectableUTXO } from "../types.js";
|
||||||
|
import {
|
||||||
|
getTransactionOutputIdentifier,
|
||||||
|
isInvitationRequirementsComplete,
|
||||||
|
resolveProvidedLockingBytecodeHex,
|
||||||
|
} from "../../../../utils/invitation-flow.js";
|
||||||
|
import type { AppService } from "../../../../services/app.js";
|
||||||
|
|
||||||
|
interface InvitationManagerDeps {
|
||||||
|
appService: AppService;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
showInfo: (msg: string) => void;
|
||||||
|
setStatus: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the full invitation lifecycle for transaction-based actions:
|
||||||
|
* creation, variable persistence, output generation, input addition,
|
||||||
|
* signing, and broadcasting.
|
||||||
|
*
|
||||||
|
* Only relevant for TransactionWizardFlow — data flows bypass this entirely.
|
||||||
|
*/
|
||||||
|
export function useInvitationManager(deps: InvitationManagerDeps) {
|
||||||
|
const { appService, showError, showInfo, setStatus } = deps;
|
||||||
|
|
||||||
|
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
||||||
|
const [invitationId, setInvitationId] = useState<string | null>(null);
|
||||||
|
const [requirementsComplete, setRequirementsComplete] = useState(false);
|
||||||
|
const [hasSignedAndBroadcasted, setHasSignedAndBroadcasted] = useState(false);
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
|
/** Re-check whether all invitation requirements are satisfied. */
|
||||||
|
const refreshRequirements = useCallback(
|
||||||
|
async (identifier: string | null = invitationId): Promise<boolean> => {
|
||||||
|
if (!identifier || !appService) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === identifier,
|
||||||
|
);
|
||||||
|
if (!instance) {
|
||||||
|
setRequirementsComplete(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const complete = await isInvitationRequirementsComplete(instance);
|
||||||
|
setRequirementsComplete(complete);
|
||||||
|
return complete;
|
||||||
|
},
|
||||||
|
[appService, invitationId],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an invitation, persist variable values, and add
|
||||||
|
* template-required transaction outputs.
|
||||||
|
*
|
||||||
|
* @returns The invitation identifier on success, or null on failure.
|
||||||
|
*/
|
||||||
|
const createWithVariables = useCallback(
|
||||||
|
async (
|
||||||
|
templateIdentifier: string,
|
||||||
|
actionIdentifier: string,
|
||||||
|
roleIdentifier: string,
|
||||||
|
template: XOTemplate,
|
||||||
|
variables: VariableInput[],
|
||||||
|
): Promise<string | null> => {
|
||||||
|
if (!appService) return null;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Creating invitation...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create via the engine
|
||||||
|
const xoInvitation = await appService.engine.createInvitation({
|
||||||
|
templateIdentifier,
|
||||||
|
actionIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap and track
|
||||||
|
const invitationInstance =
|
||||||
|
await appService.createInvitation(xoInvitation);
|
||||||
|
let inv = invitationInstance.data;
|
||||||
|
const invId = inv.invitationIdentifier;
|
||||||
|
setInvitationId(invId);
|
||||||
|
|
||||||
|
// Persist variable values
|
||||||
|
if (variables.length > 0) {
|
||||||
|
setStatus("Adding variables...");
|
||||||
|
const variableData = variables.map((v) => {
|
||||||
|
const isNumeric =
|
||||||
|
["integer", "number", "satoshis"].includes(v.type) ||
|
||||||
|
(v.hint && ["satoshis", "amount"].includes(v.hint));
|
||||||
|
|
||||||
|
return {
|
||||||
|
variableIdentifier: v.id,
|
||||||
|
roleIdentifier,
|
||||||
|
value: isNumeric ? BigInt(v.value || "0") : v.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await invitationInstance.addVariables(variableData);
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build variable values lookup for output resolution
|
||||||
|
const variableValuesByIdentifier = variables.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
if (
|
||||||
|
typeof variable.value === "string" &&
|
||||||
|
variable.value.trim().length > 0
|
||||||
|
) {
|
||||||
|
acc[variable.id] = variable.value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add template-required transaction outputs
|
||||||
|
const act = template.actions?.[actionIdentifier];
|
||||||
|
const transaction = act?.transaction
|
||||||
|
? template.transactions?.[act.transaction]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (transaction?.outputs && transaction.outputs.length > 0) {
|
||||||
|
setStatus("Adding required outputs...");
|
||||||
|
const outputsToAdd = await Promise.all(
|
||||||
|
transaction.outputs.map(
|
||||||
|
async (output: XOTemplateTransactionOutput) => {
|
||||||
|
const outputIdentifier = getTransactionOutputIdentifier(output);
|
||||||
|
if (!outputIdentifier) {
|
||||||
|
throw new Error("Invalid transaction output definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedHex = resolveProvidedLockingBytecodeHex(
|
||||||
|
template,
|
||||||
|
outputIdentifier,
|
||||||
|
variableValuesByIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lockingBytecodeHex =
|
||||||
|
providedHex ??
|
||||||
|
(await invitationInstance.generateLockingBytecode(
|
||||||
|
outputIdentifier,
|
||||||
|
roleIdentifier,
|
||||||
|
));
|
||||||
|
|
||||||
|
return {
|
||||||
|
outputIdentifier,
|
||||||
|
lockingBytecode: lockingBytecodeHex,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Clean this up. Suggestions: 1. Convert to bytes above. 2. Have addOutputs accept a hex string. 3. Have addOutputs handle lockscript generation.
|
||||||
|
await invitationInstance.addOutputs(
|
||||||
|
outputsToAdd.map((output) => ({
|
||||||
|
outputIdentifier: output.outputIdentifier,
|
||||||
|
lockingBytecode: new Uint8Array(
|
||||||
|
Buffer.from(output.lockingBytecode, "hex"),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
inv = invitationInstance.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvitation(inv);
|
||||||
|
await refreshRequirements(invId);
|
||||||
|
setStatus("Invitation created");
|
||||||
|
return invId;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appService, showError, setStatus, refreshRequirements],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the selected UTXOs as inputs and a change output to the invitation.
|
||||||
|
*
|
||||||
|
* @returns true on success, false on failure.
|
||||||
|
*/
|
||||||
|
const addInputsAndOutputs = useCallback(
|
||||||
|
async (
|
||||||
|
selectedUtxos: SelectableUTXO[],
|
||||||
|
changeAmount: bigint,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!invitationId || !appService) return false;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Adding inputs and outputs...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||||
|
);
|
||||||
|
if (!instance) throw new Error("Invitation not found");
|
||||||
|
|
||||||
|
const inputs = selectedUtxos.map((utxo) => ({
|
||||||
|
outpointTransactionHash: new Uint8Array(
|
||||||
|
Buffer.from(utxo.outpointTransactionHash, "hex"),
|
||||||
|
),
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await instance.addInputs(inputs);
|
||||||
|
await instance.addOutputs([{ valueSatoshis: changeAmount }]);
|
||||||
|
await refreshRequirements(invitationId);
|
||||||
|
setStatus("Inputs and outputs added");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[invitationId, appService, showError, setStatus, refreshRequirements],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Sign the invitation and broadcast the transaction. */
|
||||||
|
const signAndBroadcast = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!invitationId || !appService) return false;
|
||||||
|
|
||||||
|
setIsProcessing(true);
|
||||||
|
setStatus("Signing invitation...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const instance = appService.invitations.find(
|
||||||
|
(inv: any) => inv.data.invitationIdentifier === invitationId,
|
||||||
|
);
|
||||||
|
if (!instance) throw new Error("Invitation not found");
|
||||||
|
|
||||||
|
const complete = await refreshRequirements(invitationId);
|
||||||
|
if (!complete) {
|
||||||
|
showError("Invitation requirements are not complete yet.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await instance.sign();
|
||||||
|
setStatus("Broadcasting transaction...");
|
||||||
|
await instance.broadcast();
|
||||||
|
setHasSignedAndBroadcasted(true);
|
||||||
|
setStatus("Transaction signed and broadcasted");
|
||||||
|
showInfo("Transaction signed and broadcasted.");
|
||||||
|
await refreshRequirements(invitationId);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
showError(
|
||||||
|
`Failed to sign and broadcast: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
invitationId,
|
||||||
|
appService,
|
||||||
|
setStatus,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
refreshRequirements,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
invitation,
|
||||||
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
|
isProcessing,
|
||||||
|
setIsProcessing,
|
||||||
|
refreshRequirements,
|
||||||
|
createWithVariables,
|
||||||
|
addInputsAndOutputs,
|
||||||
|
signAndBroadcast,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvitationManagerState = ReturnType<typeof useInvitationManager>;
|
||||||
50
src/tui/screens/action-wizard/hooks/useRoleSelection.ts
Normal file
50
src/tui/screens/action-wizard/hooks/useRoleSelection.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import { resolveActionRoles } from "../../../../utils/invitation-flow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages role selection state for the wizard.
|
||||||
|
*
|
||||||
|
* Derives the list of available roles from the template and auto-selects
|
||||||
|
* when only one role exists for the action.
|
||||||
|
*/
|
||||||
|
export function useRoleSelection(
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
actionRolesFromNavigation: string[] | undefined,
|
||||||
|
) {
|
||||||
|
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
||||||
|
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
||||||
|
|
||||||
|
/** Roles that can start this action, derived from template start entries. */
|
||||||
|
const availableRoles = useMemo(() => {
|
||||||
|
return resolveActionRoles(
|
||||||
|
template,
|
||||||
|
actionIdentifier,
|
||||||
|
actionRolesFromNavigation,
|
||||||
|
);
|
||||||
|
}, [template, actionIdentifier, actionRolesFromNavigation]);
|
||||||
|
|
||||||
|
/** The role to use for the flow — either explicitly selected or auto-selected when only one exists. */
|
||||||
|
const effectiveRole =
|
||||||
|
roleIdentifier ??
|
||||||
|
(availableRoles.length === 1 ? availableRoles[0] : undefined);
|
||||||
|
|
||||||
|
// Auto-select when only one role exists.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!roleIdentifier && availableRoles.length === 1) {
|
||||||
|
setRoleIdentifier(availableRoles[0]);
|
||||||
|
}
|
||||||
|
}, [roleIdentifier, availableRoles]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roleIdentifier,
|
||||||
|
setRoleIdentifier,
|
||||||
|
selectedRoleIndex,
|
||||||
|
setSelectedRoleIndex,
|
||||||
|
availableRoles,
|
||||||
|
effectiveRole,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoleSelectionState = ReturnType<typeof useRoleSelection>;
|
||||||
123
src/tui/screens/action-wizard/hooks/useUtxoSelection.ts
Normal file
123
src/tui/screens/action-wizard/hooks/useUtxoSelection.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import type { SelectableUTXO, VariableInput } from "../types.js";
|
||||||
|
import type { Invitation } from "../../../../services/invitation.js";
|
||||||
|
import { formatSatoshis } from "../../../theme.js";
|
||||||
|
import {
|
||||||
|
autoSelectGreedyUtxos,
|
||||||
|
mapUnspentOutputsToSelectable,
|
||||||
|
} from "../../../../utils/invitation-flow.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages UTXO selection state for the wizard's inputs step.
|
||||||
|
*
|
||||||
|
* Only active for transaction flows that require the creator
|
||||||
|
* to provide funding inputs.
|
||||||
|
*/
|
||||||
|
export function useUtxoSelection() {
|
||||||
|
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
||||||
|
const [fee, setFee] = useState<bigint>(500n);
|
||||||
|
|
||||||
|
const selectedAmount = useMemo(
|
||||||
|
() =>
|
||||||
|
availableUtxos
|
||||||
|
.filter((u) => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n),
|
||||||
|
[availableUtxos],
|
||||||
|
);
|
||||||
|
|
||||||
|
const changeAmount = useMemo(
|
||||||
|
() => selectedAmount - requiredAmount - fee,
|
||||||
|
[selectedAmount, requiredAmount, fee],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Toggle the selected state of a single UTXO. */
|
||||||
|
const toggleSelection = useCallback((index: number) => {
|
||||||
|
setAvailableUtxos((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) {
|
||||||
|
updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Select all available UTXOs. */
|
||||||
|
const selectAll = useCallback(() => {
|
||||||
|
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: true })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Deselect all UTXOs. */
|
||||||
|
const deselectAll = useCallback(() => {
|
||||||
|
setAvailableUtxos((prev) => prev.map((u) => ({ ...u, selected: false })));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query the invitation instance for suitable UTXOs and auto-select
|
||||||
|
* greedily to meet the required amount.
|
||||||
|
*/
|
||||||
|
const loadUtxos = useCallback(
|
||||||
|
async (
|
||||||
|
invitationInstance: Invitation,
|
||||||
|
templateIdentifier: string,
|
||||||
|
variables: VariableInput[],
|
||||||
|
setStatus: (msg: string) => void,
|
||||||
|
): Promise<void> => {
|
||||||
|
setStatus("Finding suitable UTXOs...");
|
||||||
|
|
||||||
|
// Derive required amount from variables that look like satoshi/amount fields.
|
||||||
|
const requestedVar = variables.find(
|
||||||
|
(v) =>
|
||||||
|
v.id.toLowerCase().includes("satoshi") ||
|
||||||
|
v.id.toLowerCase().includes("amount"),
|
||||||
|
);
|
||||||
|
const requested = requestedVar ? BigInt(requestedVar.value || "0") : 0n;
|
||||||
|
setRequiredAmount(requested);
|
||||||
|
|
||||||
|
const unspentOutputs = await invitationInstance.findSuitableResources({
|
||||||
|
templateIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapped = mapUnspentOutputsToSelectable(unspentOutputs);
|
||||||
|
const autoSelected = autoSelectGreedyUtxos(mapped, requested + fee);
|
||||||
|
setAvailableUtxos(autoSelected as SelectableUTXO[]);
|
||||||
|
setStatus("Ready");
|
||||||
|
},
|
||||||
|
[fee],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Validate that the selection meets the required amounts. */
|
||||||
|
const validate = useCallback((): string | null => {
|
||||||
|
const selected = availableUtxos.filter((u) => u.selected);
|
||||||
|
if (selected.length === 0) {
|
||||||
|
return "Please select at least one UTXO";
|
||||||
|
}
|
||||||
|
if (selectedAmount < requiredAmount + fee) {
|
||||||
|
return `Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`;
|
||||||
|
}
|
||||||
|
if (changeAmount < 546n) {
|
||||||
|
return `Change amount (${changeAmount}) is below dust threshold (546 sats)`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [availableUtxos, selectedAmount, requiredAmount, fee, changeAmount]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
availableUtxos,
|
||||||
|
setAvailableUtxos,
|
||||||
|
selectedUtxoIndex,
|
||||||
|
setSelectedUtxoIndex,
|
||||||
|
requiredAmount,
|
||||||
|
fee,
|
||||||
|
selectedAmount,
|
||||||
|
changeAmount,
|
||||||
|
toggleSelection,
|
||||||
|
selectAll,
|
||||||
|
deselectAll,
|
||||||
|
loadUtxos,
|
||||||
|
validate,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UtxoSelectionState = ReturnType<typeof useUtxoSelection>;
|
||||||
75
src/tui/screens/action-wizard/hooks/useVariableInputs.ts
Normal file
75
src/tui/screens/action-wizard/hooks/useVariableInputs.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
import type { VariableInput } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the variable input state for the wizard's variables step.
|
||||||
|
*
|
||||||
|
* Populates variables from the template's action/role requirements
|
||||||
|
* and provides validation + update helpers.
|
||||||
|
*/
|
||||||
|
export function useVariableInputs() {
|
||||||
|
const [variables, setVariables] = useState<VariableInput[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the variable list from the template's role requirements.
|
||||||
|
* Calling this again replaces the current variables entirely.
|
||||||
|
*/
|
||||||
|
const initFromTemplate = useCallback(
|
||||||
|
(
|
||||||
|
template: XOTemplate,
|
||||||
|
actionIdentifier: string,
|
||||||
|
roleIdentifier: string,
|
||||||
|
) => {
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
const role = action?.roles?.[roleIdentifier];
|
||||||
|
const varIds = role?.requirements?.variables ?? [];
|
||||||
|
|
||||||
|
const varInputs: VariableInput[] = varIds.map((varId) => {
|
||||||
|
const varDef = template.variables?.[varId];
|
||||||
|
return {
|
||||||
|
id: varId,
|
||||||
|
name: varDef?.name || varId,
|
||||||
|
type: varDef?.type || "string",
|
||||||
|
hint: varDef?.hint,
|
||||||
|
value: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setVariables(varInputs);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Update a single variable's value by index. */
|
||||||
|
const updateVariable = useCallback((index: number, value: string) => {
|
||||||
|
setVariables((prev) => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const variable = updated[index];
|
||||||
|
if (variable) {
|
||||||
|
updated[index] = { ...variable, value };
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Returns an error message if any required variable is empty, or null if valid. */
|
||||||
|
const validate = useCallback((): string | null => {
|
||||||
|
const emptyVars = variables.filter(
|
||||||
|
(v) => !v.value || v.value.trim() === "",
|
||||||
|
);
|
||||||
|
if (emptyVars.length > 0) {
|
||||||
|
return `Please enter values for: ${emptyVars.map((v) => v.name).join(", ")}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [variables]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
variables,
|
||||||
|
setVariables,
|
||||||
|
initFromTemplate,
|
||||||
|
updateVariable,
|
||||||
|
validate,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariableInputsState = ReturnType<typeof useVariableInputs>;
|
||||||
37
src/tui/screens/action-wizard/hooks/useWizardFocus.ts
Normal file
37
src/tui/screens/action-wizard/hooks/useWizardFocus.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import type { FocusArea, ButtonFocus } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages which area of the wizard UI has keyboard focus and
|
||||||
|
* which specific element within that area is highlighted.
|
||||||
|
*/
|
||||||
|
export function useWizardFocus() {
|
||||||
|
const [focusArea, setFocusArea] = useState<FocusArea>("content");
|
||||||
|
const [focusedButton, setFocusedButton] = useState<ButtonFocus>("next");
|
||||||
|
const [focusedInput, setFocusedInput] = useState(0);
|
||||||
|
|
||||||
|
/** Reset focus to the content area at the first element. */
|
||||||
|
const resetToContent = useCallback(() => {
|
||||||
|
setFocusArea("content");
|
||||||
|
setFocusedInput(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Move focus to the button bar. */
|
||||||
|
const moveToButtons = useCallback((button: ButtonFocus = "next") => {
|
||||||
|
setFocusArea("buttons");
|
||||||
|
setFocusedButton(button);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusArea,
|
||||||
|
setFocusArea,
|
||||||
|
focusedButton,
|
||||||
|
setFocusedButton,
|
||||||
|
focusedInput,
|
||||||
|
setFocusedInput,
|
||||||
|
resetToContent,
|
||||||
|
moveToButtons,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WizardFocusState = ReturnType<typeof useWizardFocus>;
|
||||||
153
src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts
Normal file
153
src/tui/screens/action-wizard/hooks/useWizardKeyboard.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useBlockableInput } from "../../../hooks/useInputLayer.js";
|
||||||
|
import type { ActionWizardState } from "./useActionWizard.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard input handler for the action wizard.
|
||||||
|
*
|
||||||
|
* Dispatches key presses to step-specific handlers based on the
|
||||||
|
* current step type and focus area. Extracted from the screen
|
||||||
|
* component to keep it purely presentational.
|
||||||
|
*/
|
||||||
|
export function useWizardKeyboard(wizard: ActionWizardState): void {
|
||||||
|
useBlockableInput(
|
||||||
|
(input, key) => {
|
||||||
|
// ── Tab: cycle through content and button bar ─────────
|
||||||
|
if (key.tab) {
|
||||||
|
handleTab(wizard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content-area: step-specific input handling ────────
|
||||||
|
if (wizard.focusArea === "content") {
|
||||||
|
if (wizard.currentStepData?.type === "role-select") {
|
||||||
|
handleRoleSelectInput(wizard, input, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (wizard.currentStepData?.type === "inputs") {
|
||||||
|
handleInputsStepInput(wizard, input, key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button bar navigation + activation ────────────────
|
||||||
|
if (wizard.focusArea === "buttons") {
|
||||||
|
handleButtonBarInput(wizard, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Global shortcuts ──────────────────────────────────
|
||||||
|
if (
|
||||||
|
input === "c" &&
|
||||||
|
wizard.currentStepData?.type === "publish" &&
|
||||||
|
wizard.invitationId
|
||||||
|
) {
|
||||||
|
wizard.copyId();
|
||||||
|
}
|
||||||
|
if (input === "a" && wizard.currentStepData?.type === "inputs") {
|
||||||
|
wizard.selectAll();
|
||||||
|
}
|
||||||
|
if (input === "n" && wizard.currentStepData?.type === "inputs") {
|
||||||
|
wizard.deselectAll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ isActive: !wizard.textInputHasFocus },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tab cycling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleTab(wizard: ActionWizardState): void {
|
||||||
|
if (wizard.focusArea === "content") {
|
||||||
|
// Within role-select, tab through roles before moving to buttons
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === "role-select" &&
|
||||||
|
wizard.availableRoles.length > 0 &&
|
||||||
|
wizard.selectedRoleIndex < wizard.availableRoles.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedRoleIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within inputs, tab through UTXOs before moving to buttons
|
||||||
|
if (
|
||||||
|
wizard.currentStepData?.type === "inputs" &&
|
||||||
|
wizard.availableUtxos.length > 0 &&
|
||||||
|
wizard.selectedUtxoIndex < wizard.availableUtxos.length - 1
|
||||||
|
) {
|
||||||
|
wizard.setSelectedUtxoIndex((prev) => prev + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Role-select step ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleRoleSelectInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
_input: string,
|
||||||
|
key: { upArrow: boolean; downArrow: boolean },
|
||||||
|
): void {
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inputs step (UTXO selection) ────────────────────────────────
|
||||||
|
|
||||||
|
function handleInputsStepInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
input: string,
|
||||||
|
key: { upArrow: boolean; downArrow: boolean; return: boolean },
|
||||||
|
): void {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Button bar ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function handleButtonBarInput(
|
||||||
|
wizard: ActionWizardState,
|
||||||
|
key: { leftArrow: boolean; rightArrow: boolean; return: boolean },
|
||||||
|
): void {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
if (wizard.focusedButton === "back") wizard.previousStep();
|
||||||
|
else if (wizard.focusedButton === "cancel") wizard.cancel();
|
||||||
|
else if (wizard.focusedButton === "next") wizard.nextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/tui/screens/action-wizard/hooks/useWizardSteps.ts
Normal file
73
src/tui/screens/action-wizard/hooks/useWizardSteps.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useCallback, useMemo } from "react";
|
||||||
|
import type { StepConfig, WizardStep } from "../types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic step navigation driven by an array of StepConfig objects.
|
||||||
|
*
|
||||||
|
* The orchestrator builds the StepConfig[] from the flow strategy
|
||||||
|
* and domain hooks; this hook just manages the step index and
|
||||||
|
* delegates validate/onNext to the current config.
|
||||||
|
*/
|
||||||
|
export function useWizardSteps(
|
||||||
|
stepConfigs: StepConfig[],
|
||||||
|
onCancel: () => void,
|
||||||
|
showError: (msg: string) => void,
|
||||||
|
) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
|
||||||
|
/** Flat step descriptors for the progress indicator. */
|
||||||
|
const steps: WizardStep[] = useMemo(
|
||||||
|
() => stepConfigs.map((c) => ({ name: c.name, type: c.type })),
|
||||||
|
[stepConfigs],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentStepData = steps[currentStep];
|
||||||
|
const currentConfig = stepConfigs[currentStep];
|
||||||
|
|
||||||
|
/** Validate the current step, run its onNext, then advance if not the last step. */
|
||||||
|
const nextStep = useCallback(async () => {
|
||||||
|
const config = stepConfigs[currentStep];
|
||||||
|
if (!config) return;
|
||||||
|
|
||||||
|
const error = config.validate();
|
||||||
|
if (error) {
|
||||||
|
showError(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await config.onNext();
|
||||||
|
if (!success) return;
|
||||||
|
|
||||||
|
// Don't advance past the last step — the final step's onNext handles exit.
|
||||||
|
if (currentStep < stepConfigs.length - 1) {
|
||||||
|
setCurrentStep((prev) => prev + 1);
|
||||||
|
}
|
||||||
|
}, [currentStep, stepConfigs, showError]);
|
||||||
|
|
||||||
|
/** Go back one step, or cancel the wizard if already at the first step. */
|
||||||
|
const previousStep = useCallback(() => {
|
||||||
|
if (currentStep <= 0) {
|
||||||
|
onCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep((prev) => prev - 1);
|
||||||
|
}, [currentStep, onCancel]);
|
||||||
|
|
||||||
|
/** Cancel the wizard entirely. */
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
onCancel();
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
currentStepData,
|
||||||
|
currentConfig,
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
cancel,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WizardStepsState = ReturnType<typeof useWizardSteps>;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './ActionWizardScreen.js';
|
export * from "./ActionWizardScreen.js";
|
||||||
export * from './useActionWizard.js';
|
export * from "./hooks/useActionWizard.js";
|
||||||
export * from './types.js';
|
export * from "./types.js";
|
||||||
export * from './steps/index.js';
|
export * from "./steps/index.js";
|
||||||
|
export * from "./flows/index.js";
|
||||||
|
|||||||
81
src/tui/screens/action-wizard/steps/DataResultStep.tsx
Normal file
81
src/tui/screens/action-wizard/steps/DataResultStep.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../theme.js';
|
||||||
|
import type { VariableInput, DataResult } from '../types.js';
|
||||||
|
|
||||||
|
interface DataResultStepProps {
|
||||||
|
actionName: string;
|
||||||
|
variables: VariableInput[];
|
||||||
|
dataResults: DataResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the result of a data-only action (e.g. sign, verify).
|
||||||
|
*
|
||||||
|
* NOTE: Engine-level data action execution is not yet implemented.
|
||||||
|
* The computed values are stubbed until the engine supports evaluating
|
||||||
|
* CashASM data expressions outside of a transaction context.
|
||||||
|
*/
|
||||||
|
export function DataResultStep({
|
||||||
|
actionName,
|
||||||
|
variables,
|
||||||
|
dataResults,
|
||||||
|
}: DataResultStepProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{actionName} — Result
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Variables that were provided */}
|
||||||
|
{variables.length > 0 && (
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Provided values:</Text>
|
||||||
|
{variables.map((v) => (
|
||||||
|
<Text key={v.id} color={colors.textMuted}>
|
||||||
|
{' '}{v.name}: {v.value || '(empty)'}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Computed data results */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Output:</Text>
|
||||||
|
{dataResults.length === 0 ? (
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
{' '}Engine support for data actions is not yet implemented.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
dataResults.map((result) => (
|
||||||
|
<Box key={result.id} flexDirection="column" marginTop={0}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{' '}{result.name} ({result.type}):
|
||||||
|
</Text>
|
||||||
|
{result.value !== null ? (
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
paddingX={1}
|
||||||
|
marginLeft={2}
|
||||||
|
>
|
||||||
|
<Text color={colors.accent}>{result.value}</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.warning} dimColor>
|
||||||
|
{' '}Pending — engine data execution not yet available
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Press Done to exit.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import { colors } from '../../../theme.js';
|
|
||||||
import type { WizardStepProps } from '../types.js';
|
|
||||||
|
|
||||||
type Props = Pick<
|
|
||||||
WizardStepProps,
|
|
||||||
'template' | 'actionIdentifier' | 'roleIdentifier' | 'actionName'
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function InfoStep({
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
actionName,
|
|
||||||
}: Props): React.ReactElement {
|
|
||||||
const action = template?.actions?.[actionIdentifier];
|
|
||||||
const role = action?.roles?.[roleIdentifier];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection='column'>
|
|
||||||
<Text color={colors.primary} bold>
|
|
||||||
Action: {actionName}
|
|
||||||
</Text>
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{action?.description || 'No description'}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.text}>Your Role: </Text>
|
|
||||||
<Text color={colors.accent}>{roleIdentifier}</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{role?.requirements && (
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
|
||||||
<Text color={colors.text}>Requirements:</Text>
|
|
||||||
{role.requirements.variables?.map((v) => (
|
|
||||||
<Text key={v} color={colors.textMuted}>
|
|
||||||
{' '}• Variable: {v}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
{role.requirements.slots && (
|
|
||||||
<Text color={colors.textMuted}>
|
|
||||||
{' '}• Slots: {role.requirements.slots.min} min (UTXO selection
|
|
||||||
required)
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
import { colors, formatSatoshis, formatHex } from '../../../theme.js';
|
||||||
import type { WizardStepProps } from '../types.js';
|
import type { SelectableUTXO, FocusArea } from '../types.js';
|
||||||
|
|
||||||
type Props = Pick<
|
interface Props {
|
||||||
WizardStepProps,
|
availableUtxos: SelectableUTXO[];
|
||||||
| 'availableUtxos'
|
selectedUtxoIndex: number;
|
||||||
| 'selectedUtxoIndex'
|
requiredAmount: bigint;
|
||||||
| 'requiredAmount'
|
fee: bigint;
|
||||||
| 'fee'
|
selectedAmount: bigint;
|
||||||
| 'selectedAmount'
|
changeAmount: bigint;
|
||||||
| 'changeAmount'
|
focusArea: FocusArea;
|
||||||
| 'focusArea'
|
}
|
||||||
>;
|
|
||||||
|
|
||||||
export function InputsStep({
|
export function InputsStep({
|
||||||
availableUtxos,
|
availableUtxos,
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ import { colors } from '../../../theme.js';
|
|||||||
|
|
||||||
interface PublishStepProps {
|
interface PublishStepProps {
|
||||||
invitationId: string | null;
|
invitationId: string | null;
|
||||||
|
requirementsComplete: boolean;
|
||||||
|
hasSignedAndBroadcasted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PublishStep({
|
export function PublishStep({
|
||||||
invitationId,
|
invitationId,
|
||||||
|
requirementsComplete,
|
||||||
|
hasSignedAndBroadcasted,
|
||||||
}: PublishStepProps): React.ReactElement {
|
}: PublishStepProps): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection='column'>
|
<Box flexDirection='column'>
|
||||||
<Text color={colors.success} bold>
|
<Text color={colors.success} bold>
|
||||||
✓ Invitation Created & Published!
|
✓ Invitation Ready
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box marginTop={1} flexDirection='column'>
|
<Box marginTop={1} flexDirection='column'>
|
||||||
@@ -30,9 +34,19 @@ export function PublishStep({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted}>
|
{hasSignedAndBroadcasted ? (
|
||||||
Share this ID with the other party to complete the transaction.
|
<Text color={colors.success}>
|
||||||
</Text>
|
Transaction signed and broadcasted.
|
||||||
|
</Text>
|
||||||
|
) : requirementsComplete ? (
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
Requirements are complete. Use the Sign & Broadcast button to finalize.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.warning}>
|
||||||
|
Requirements are incomplete. Complete missing requirements before signing.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@ import React from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { colors } from '../../../theme.js';
|
import { colors } from '../../../theme.js';
|
||||||
import { VariableInputField } from '../../../components/VariableInputField.js';
|
import { VariableInputField } from '../../../components/VariableInputField.js';
|
||||||
import type { WizardStepProps } from '../types.js';
|
import type { VariableInput, FocusArea } from '../types.js';
|
||||||
|
|
||||||
type Props = Pick<
|
interface Props {
|
||||||
WizardStepProps,
|
variables: VariableInput[];
|
||||||
| 'variables'
|
updateVariable: (index: number, value: string) => void;
|
||||||
| 'updateVariable'
|
handleTextInputSubmit: () => void;
|
||||||
| 'handleTextInputSubmit'
|
focusArea: FocusArea;
|
||||||
| 'focusArea'
|
focusedInput: number;
|
||||||
| 'focusedInput'
|
}
|
||||||
>;
|
|
||||||
|
|
||||||
export function VariablesStep({
|
export function VariablesStep({
|
||||||
variables,
|
variables,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export * from './InfoStep.js';
|
export * from "./RoleSelectStep.js";
|
||||||
export * from './RoleSelectStep.js';
|
export * from "./VariablesStep.js";
|
||||||
export * from './VariablesStep.js';
|
export * from "./InputsStep.js";
|
||||||
export * from './InputsStep.js';
|
export * from "./ReviewStep.js";
|
||||||
export * from './ReviewStep.js';
|
export * from "./PublishStep.js";
|
||||||
export * from './PublishStep.js';
|
export * from "./DataResultStep.js";
|
||||||
|
|||||||
@@ -1,12 +1,50 @@
|
|||||||
import type { XOTemplate } from '@xo-cash/types';
|
/**
|
||||||
|
* Shared types for the action wizard.
|
||||||
|
*/
|
||||||
|
|
||||||
export type StepType = 'info' | 'role-select' | 'variables' | 'inputs' | 'review' | 'publish';
|
/** Supported step types in the wizard. */
|
||||||
|
export type StepType =
|
||||||
|
| "role-select"
|
||||||
|
| "variables"
|
||||||
|
| "inputs"
|
||||||
|
| "review"
|
||||||
|
| "publish"
|
||||||
|
| "result";
|
||||||
|
|
||||||
|
/** A step displayed in the wizard's progress indicator. */
|
||||||
export interface WizardStep {
|
export interface WizardStep {
|
||||||
name: string;
|
name: string;
|
||||||
type: StepType;
|
type: StepType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a single wizard step.
|
||||||
|
* The flow strategy determines which steps exist; the orchestrator
|
||||||
|
* wires validate/onNext to the appropriate domain hooks.
|
||||||
|
*/
|
||||||
|
export interface StepConfig {
|
||||||
|
type: StepType;
|
||||||
|
name: string;
|
||||||
|
/** Return an error message if the step is invalid, or null if OK to proceed. */
|
||||||
|
validate: () => string | null;
|
||||||
|
/** Execute transition logic. Return true on success, false to stay on this step. */
|
||||||
|
onNext: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context passed to WizardFlow strategy methods so they can
|
||||||
|
* determine steps and finalization state without holding React state.
|
||||||
|
*/
|
||||||
|
export interface FlowContext {
|
||||||
|
availableRoles: string[];
|
||||||
|
hasVariables: boolean;
|
||||||
|
shouldCollectInputs: boolean;
|
||||||
|
requirementsComplete: boolean;
|
||||||
|
wizardCollectedInputs: boolean;
|
||||||
|
hasSignedAndBroadcasted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variable input state for the variables step. */
|
||||||
export interface VariableInput {
|
export interface VariableInput {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -15,6 +53,7 @@ export interface VariableInput {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A UTXO that can be toggled for transaction funding. */
|
||||||
export interface SelectableUTXO {
|
export interface SelectableUTXO {
|
||||||
outpointTransactionHash: string;
|
outpointTransactionHash: string;
|
||||||
outpointIndex: number;
|
outpointIndex: number;
|
||||||
@@ -23,40 +62,18 @@ export interface SelectableUTXO {
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FocusArea = 'content' | 'buttons';
|
/** Which area of the wizard UI currently has keyboard focus. */
|
||||||
export type ButtonFocus = 'back' | 'cancel' | 'next';
|
export type FocusArea = "content" | "buttons";
|
||||||
|
|
||||||
/**
|
/** Which button in the bottom bar is focused. */
|
||||||
* The 'downward' contract — what every step component receives.
|
export type ButtonFocus = "back" | "cancel" | "next";
|
||||||
*/
|
|
||||||
export interface WizardStepProps {
|
|
||||||
// Data
|
|
||||||
template: XOTemplate;
|
|
||||||
actionIdentifier: string;
|
|
||||||
roleIdentifier: string;
|
|
||||||
actionName: string;
|
|
||||||
|
|
||||||
// Variable state
|
/** A computed data result from a data-only action. */
|
||||||
variables: VariableInput[];
|
export interface DataResult {
|
||||||
updateVariable: (index: number, value: string) => void;
|
id: string;
|
||||||
|
name: string;
|
||||||
// UTXO state
|
type: string;
|
||||||
availableUtxos: SelectableUTXO[];
|
hint?: string;
|
||||||
selectedUtxoIndex: number;
|
/** null when the engine hasn't computed the value yet. */
|
||||||
requiredAmount: bigint;
|
value: string | null;
|
||||||
fee: bigint;
|
|
||||||
selectedAmount: bigint;
|
|
||||||
changeAmount: bigint;
|
|
||||||
toggleUtxoSelection: (index: number) => void;
|
|
||||||
|
|
||||||
// Invitation
|
|
||||||
invitationId: string | null;
|
|
||||||
|
|
||||||
// Focus
|
|
||||||
focusArea: FocusArea;
|
|
||||||
focusedInput: number;
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
handleTextInputSubmit: () => void;
|
|
||||||
copyId: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
||||||
import { useNavigation } from '../../hooks/useNavigation.js';
|
|
||||||
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
|
||||||
import { formatSatoshis } from '../../theme.js';
|
|
||||||
import { copyToClipboard } from '../../utils/clipboard.js';
|
|
||||||
import type { XOTemplate, XOInvitation, XOTemplateTransactionOutput } from '@xo-cash/types';
|
|
||||||
import type {
|
|
||||||
WizardStep,
|
|
||||||
VariableInput,
|
|
||||||
SelectableUTXO,
|
|
||||||
FocusArea,
|
|
||||||
ButtonFocus,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
export function useActionWizard() {
|
|
||||||
const { navigate, goBack, data: navData } = useNavigation();
|
|
||||||
const { appService, showError, showInfo } = useAppContext();
|
|
||||||
const { setStatus } = useStatus();
|
|
||||||
|
|
||||||
// ── Navigation data ──────────────────────────────────────────────
|
|
||||||
// Role is no longer passed via navigation — it is selected in the wizard.
|
|
||||||
const templateIdentifier = navData.templateIdentifier as string | undefined;
|
|
||||||
const actionIdentifier = navData.actionIdentifier as string | undefined;
|
|
||||||
const template = navData.template as XOTemplate | undefined;
|
|
||||||
|
|
||||||
// ── Role selection state ────────────────────────────────────────
|
|
||||||
const [roleIdentifier, setRoleIdentifier] = useState<string | undefined>();
|
|
||||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Roles that can start this action, derived from the template's
|
|
||||||
* `start` entries filtered to the current action.
|
|
||||||
*/
|
|
||||||
const availableRoles = useMemo(() => {
|
|
||||||
if (!template || !actionIdentifier) return [];
|
|
||||||
const starts = template.start ?? [];
|
|
||||||
const roleIds = starts
|
|
||||||
.filter((s) => s.action === actionIdentifier)
|
|
||||||
.map((s) => s.role);
|
|
||||||
// Deduplicate while preserving order
|
|
||||||
return [...new Set(roleIds)];
|
|
||||||
}, [template, actionIdentifier]);
|
|
||||||
|
|
||||||
// ── Wizard state ─────────────────────────────────────────────────
|
|
||||||
const [steps, setSteps] = useState<WizardStep[]>([]);
|
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
|
||||||
|
|
||||||
// ── Variable inputs ──────────────────────────────────────────────
|
|
||||||
const [variables, setVariables] = useState<VariableInput[]>([]);
|
|
||||||
|
|
||||||
// ── UTXO selection ───────────────────────────────────────────────
|
|
||||||
const [availableUtxos, setAvailableUtxos] = useState<SelectableUTXO[]>([]);
|
|
||||||
const [selectedUtxoIndex, setSelectedUtxoIndex] = useState(0);
|
|
||||||
const [requiredAmount, setRequiredAmount] = useState<bigint>(0n);
|
|
||||||
const [fee, setFee] = useState<bigint>(500n);
|
|
||||||
|
|
||||||
// ── Invitation ───────────────────────────────────────────────────
|
|
||||||
const [invitation, setInvitation] = useState<XOInvitation | null>(null);
|
|
||||||
const [invitationId, setInvitationId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// ── UI state ─────────────────────────────────────────────────────
|
|
||||||
const [focusedInput, setFocusedInput] = useState(0);
|
|
||||||
const [focusedButton, setFocusedButton] = useState<ButtonFocus>('next');
|
|
||||||
const [focusArea, setFocusArea] = useState<FocusArea>('content');
|
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
// ── Derived values ───────────────────────────────────────────────
|
|
||||||
const currentStepData = steps[currentStep];
|
|
||||||
const action = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const actionName = action?.name || actionIdentifier || 'Unknown';
|
|
||||||
|
|
||||||
const selectedAmount = availableUtxos
|
|
||||||
.filter((u) => u.selected)
|
|
||||||
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
|
||||||
|
|
||||||
const changeAmount = selectedAmount - requiredAmount - fee;
|
|
||||||
|
|
||||||
const textInputHasFocus =
|
|
||||||
currentStepData?.type === 'variables' && focusArea === 'content';
|
|
||||||
|
|
||||||
// ── Initialization ───────────────────────────────────────────────
|
|
||||||
// Builds the wizard steps dynamically based on the selected role.
|
|
||||||
// Re-runs when roleIdentifier changes to add role-specific steps.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!template || !actionIdentifier) {
|
|
||||||
showError('Missing wizard data');
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wizardSteps: WizardStep[] = [];
|
|
||||||
|
|
||||||
// Always start with role selection
|
|
||||||
wizardSteps.push({ name: 'Select Role', type: 'role-select' });
|
|
||||||
|
|
||||||
// Add role-specific steps only after role is selected
|
|
||||||
if (roleIdentifier) {
|
|
||||||
const act = template.actions?.[actionIdentifier];
|
|
||||||
const role = act?.roles?.[roleIdentifier];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
// Add variables step if needed
|
|
||||||
if (requirements?.variables && requirements.variables.length > 0) {
|
|
||||||
wizardSteps.push({ name: 'Variables', type: 'variables' });
|
|
||||||
|
|
||||||
const varInputs = requirements.variables.map((varId) => {
|
|
||||||
const varDef = template.variables?.[varId];
|
|
||||||
return {
|
|
||||||
id: varId,
|
|
||||||
name: varDef?.name || varId,
|
|
||||||
type: varDef?.type || 'string',
|
|
||||||
hint: varDef?.hint,
|
|
||||||
value: '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setVariables(varInputs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add inputs step if role requires slots (funding inputs)
|
|
||||||
if (requirements?.slots && requirements.slots.min > 0) {
|
|
||||||
wizardSteps.push({ name: 'Select UTXOs', type: 'inputs' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always add review and publish at the end
|
|
||||||
wizardSteps.push({ name: 'Review', type: 'review' });
|
|
||||||
wizardSteps.push({ name: 'Publish', type: 'publish' });
|
|
||||||
|
|
||||||
setSteps(wizardSteps);
|
|
||||||
setStatus(roleIdentifier ? `${actionIdentifier}/${roleIdentifier}` : actionIdentifier);
|
|
||||||
}, [
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
showError,
|
|
||||||
goBack,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Auto-advance from role-select after role is chosen ──────────
|
|
||||||
// This runs after the main useEffect has rebuilt steps, ensuring
|
|
||||||
// we advance to the correct step (variables, inputs, or review).
|
|
||||||
useEffect(() => {
|
|
||||||
if (roleIdentifier && currentStep === 0 && steps[0]?.type === 'role-select') {
|
|
||||||
setCurrentStep(1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}
|
|
||||||
}, [roleIdentifier, currentStep, steps]);
|
|
||||||
|
|
||||||
// ── Update a single variable value ───────────────────────────────
|
|
||||||
const updateVariable = useCallback((index: number, value: string) => {
|
|
||||||
setVariables((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const variable = updated[index];
|
|
||||||
if (variable) {
|
|
||||||
updated[index] = { ...variable, value };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Toggle a UTXO's selected state ──────────────────────────────
|
|
||||||
const toggleUtxoSelection = useCallback((index: number) => {
|
|
||||||
setAvailableUtxos((prev) => {
|
|
||||||
const updated = [...prev];
|
|
||||||
const utxo = updated[index];
|
|
||||||
if (utxo) {
|
|
||||||
updated[index] = { ...utxo, selected: !utxo.selected };
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Handle Enter inside a TextInput ─────────────────────────────
|
|
||||||
const handleTextInputSubmit = useCallback(() => {
|
|
||||||
if (focusedInput < variables.length - 1) {
|
|
||||||
setFocusedInput((prev) => prev + 1);
|
|
||||||
} else {
|
|
||||||
setFocusArea('buttons');
|
|
||||||
setFocusedButton('next');
|
|
||||||
}
|
|
||||||
}, [focusedInput, variables.length]);
|
|
||||||
|
|
||||||
// ── Copy invitation ID to clipboard ─────────────────────────────
|
|
||||||
const copyId = useCallback(async () => {
|
|
||||||
if (!invitationId) return;
|
|
||||||
try {
|
|
||||||
await copyToClipboard(invitationId);
|
|
||||||
showInfo(`Copied to clipboard!\n\n${invitationId}`);
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to copy: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [invitationId, showInfo, showError]);
|
|
||||||
|
|
||||||
// ── Load available UTXOs for the inputs step ────────────────────
|
|
||||||
const loadAvailableUtxos = useCallback(async () => {
|
|
||||||
if (!invitation || !templateIdentifier || !appService || !invitationId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Finding suitable UTXOs...');
|
|
||||||
|
|
||||||
// Determine required amount from variables
|
|
||||||
const requestedVar = variables.find(
|
|
||||||
(v) =>
|
|
||||||
v.id.toLowerCase().includes('satoshi') ||
|
|
||||||
v.id.toLowerCase().includes('amount')
|
|
||||||
);
|
|
||||||
const requested = requestedVar
|
|
||||||
? BigInt(requestedVar.value || '0')
|
|
||||||
: 0n;
|
|
||||||
setRequiredAmount(requested);
|
|
||||||
|
|
||||||
// Find the tracked invitation instance
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Query for suitable resources
|
|
||||||
const unspentOutputs = await invitationInstance.findSuitableResources({
|
|
||||||
templateIdentifier,
|
|
||||||
outputIdentifier: 'receiveOutput',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Map to selectable UTXOs
|
|
||||||
const utxos: SelectableUTXO[] = unspentOutputs.map((utxo: any) => ({
|
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
valueSatoshis: BigInt(utxo.valueSatoshis),
|
|
||||||
lockingBytecode: utxo.lockingBytecode
|
|
||||||
? typeof utxo.lockingBytecode === 'string'
|
|
||||||
? utxo.lockingBytecode
|
|
||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
|
||||||
: undefined,
|
|
||||||
selected: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Auto-select UTXOs greedily until the requirement is met
|
|
||||||
let accumulated = 0n;
|
|
||||||
const seenLockingBytecodes = new Set<string>();
|
|
||||||
|
|
||||||
for (const utxo of utxos) {
|
|
||||||
if (
|
|
||||||
utxo.lockingBytecode &&
|
|
||||||
seenLockingBytecodes.has(utxo.lockingBytecode)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (utxo.lockingBytecode) {
|
|
||||||
seenLockingBytecodes.add(utxo.lockingBytecode);
|
|
||||||
}
|
|
||||||
|
|
||||||
utxo.selected = true;
|
|
||||||
accumulated += utxo.valueSatoshis;
|
|
||||||
|
|
||||||
if (accumulated >= requested + fee) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAvailableUtxos(utxos);
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to load UTXOs: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
invitation,
|
|
||||||
templateIdentifier,
|
|
||||||
variables,
|
|
||||||
appService,
|
|
||||||
invitationId,
|
|
||||||
fee,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Create invitation and persist variables ─────────────────────
|
|
||||||
/**
|
|
||||||
* Creates an invitation, optionally persists variable values,
|
|
||||||
* and adds template-required outputs.
|
|
||||||
*
|
|
||||||
* Accepts an explicit `roleId` to avoid stale-closure issues
|
|
||||||
* when called immediately after setting role state.
|
|
||||||
*
|
|
||||||
* Does NOT advance the wizard step — the caller is responsible.
|
|
||||||
*
|
|
||||||
* @returns `true` on success, `false` on failure.
|
|
||||||
*/
|
|
||||||
const createInvitationWithVariables = useCallback(
|
|
||||||
async (roleId?: string): Promise<boolean> => {
|
|
||||||
const effectiveRole = roleId ?? roleIdentifier;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!templateIdentifier ||
|
|
||||||
!actionIdentifier ||
|
|
||||||
!effectiveRole ||
|
|
||||||
!template ||
|
|
||||||
!appService
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Creating invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create via the engine
|
|
||||||
const xoInvitation = await appService.engine.createInvitation({
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(xoInvitation)
|
|
||||||
|
|
||||||
// Wrap and track
|
|
||||||
const invitationInstance =
|
|
||||||
await appService.createInvitation(xoInvitation);
|
|
||||||
|
|
||||||
let inv = invitationInstance.data;
|
|
||||||
const invId = inv.invitationIdentifier;
|
|
||||||
setInvitationId(invId);
|
|
||||||
|
|
||||||
// Persist variable values
|
|
||||||
if (variables.length > 0) {
|
|
||||||
const variableData = variables.map((v) => {
|
|
||||||
const isNumeric =
|
|
||||||
['integer', 'number', 'satoshis'].includes(v.type) ||
|
|
||||||
(v.hint && ['satoshis', 'amount'].includes(v.hint));
|
|
||||||
|
|
||||||
return {
|
|
||||||
variableIdentifier: v.id,
|
|
||||||
roleIdentifier: effectiveRole,
|
|
||||||
value: isNumeric ? BigInt(v.value || '0') : v.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
await invitationInstance.addVariables(variableData);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add template-required outputs for the current role
|
|
||||||
const act = template.actions?.[actionIdentifier];
|
|
||||||
const transaction = act?.transaction
|
|
||||||
? template.transactions?.[act.transaction]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (transaction?.outputs && transaction.outputs.length > 0) {
|
|
||||||
setStatus('Adding required outputs...');
|
|
||||||
|
|
||||||
const outputsToAdd = transaction.outputs.map(
|
|
||||||
(output: XOTemplateTransactionOutput) => ({
|
|
||||||
outputIdentifier: output.output,
|
|
||||||
roleIdentifier: roleIdentifier,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputsToAdd);
|
|
||||||
inv = invitationInstance.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvitation(inv);
|
|
||||||
setStatus('Invitation created');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to create invitation: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
template,
|
|
||||||
variables,
|
|
||||||
appService,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Add selected inputs + change output to the invitation ───────
|
|
||||||
const addInputsAndOutputs = useCallback(async () => {
|
|
||||||
if (!invitationId || !invitation || !appService) return;
|
|
||||||
|
|
||||||
const selectedUtxos = availableUtxos.filter((u) => u.selected);
|
|
||||||
|
|
||||||
if (selectedUtxos.length === 0) {
|
|
||||||
showError('Please select at least one UTXO');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedAmount < requiredAmount + fee) {
|
|
||||||
showError(
|
|
||||||
`Insufficient funds. Need ${formatSatoshis(requiredAmount + fee)}, selected ${formatSatoshis(selectedAmount)}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changeAmount < 546n) {
|
|
||||||
showError(
|
|
||||||
`Change amount (${changeAmount}) is below dust threshold (546 sats)`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Adding inputs and outputs...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add selected inputs
|
|
||||||
const inputs = selectedUtxos.map((utxo) => ({
|
|
||||||
outpointTransactionHash: new Uint8Array(
|
|
||||||
Buffer.from(utxo.outpointTransactionHash, 'hex')
|
|
||||||
),
|
|
||||||
outpointIndex: utxo.outpointIndex,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await invitationInstance.addInputs(inputs);
|
|
||||||
|
|
||||||
// Add change output
|
|
||||||
const outputs = [
|
|
||||||
{
|
|
||||||
valueSatoshis: changeAmount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
await invitationInstance.addOutputs(outputs);
|
|
||||||
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setStatus('Inputs and outputs added');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to add inputs/outputs: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
invitationId,
|
|
||||||
invitation,
|
|
||||||
availableUtxos,
|
|
||||||
selectedAmount,
|
|
||||||
requiredAmount,
|
|
||||||
fee,
|
|
||||||
changeAmount,
|
|
||||||
appService,
|
|
||||||
showError,
|
|
||||||
setStatus,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Publish the invitation ──────────────────────────────────────
|
|
||||||
const publishInvitation = useCallback(async () => {
|
|
||||||
if (!invitationId || !appService) return;
|
|
||||||
|
|
||||||
setIsProcessing(true);
|
|
||||||
setStatus('Publishing invitation...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const invitationInstance = appService.invitations.find(
|
|
||||||
(inv) => inv.data.invitationIdentifier === invitationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!invitationInstance) {
|
|
||||||
throw new Error('Invitation not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already tracked and synced via SSE from createInvitation
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setStatus('Invitation published');
|
|
||||||
} catch (error) {
|
|
||||||
showError(
|
|
||||||
`Failed to publish: ${error instanceof Error ? error.message : String(error)}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsProcessing(false);
|
|
||||||
}
|
|
||||||
}, [invitationId, appService, showError, setStatus]);
|
|
||||||
|
|
||||||
// ── Navigate to the next step ───────────────────────────────────
|
|
||||||
const nextStep = useCallback(async () => {
|
|
||||||
if (currentStep >= steps.length - 1) return;
|
|
||||||
|
|
||||||
const stepType = currentStepData?.type;
|
|
||||||
|
|
||||||
// ── Role selection ──────────────────────────────────────────
|
|
||||||
if (stepType === 'role-select') {
|
|
||||||
const selectedRole = availableRoles[selectedRoleIndex];
|
|
||||||
if (!selectedRole) {
|
|
||||||
showError('Please select a role');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check what the selected role requires
|
|
||||||
const act = template?.actions?.[actionIdentifier ?? ''];
|
|
||||||
const role = act?.roles?.[selectedRole];
|
|
||||||
const requirements = role?.requirements;
|
|
||||||
|
|
||||||
const hasVariables =
|
|
||||||
requirements?.variables && requirements.variables.length > 0;
|
|
||||||
const hasSlots = requirements?.slots && requirements.slots.min > 0;
|
|
||||||
|
|
||||||
// If there is no variables step, the invitation must be created now
|
|
||||||
// because the variables step would normally handle it.
|
|
||||||
if (!hasVariables) {
|
|
||||||
const success = await createInvitationWithVariables(selectedRole);
|
|
||||||
if (!success) return;
|
|
||||||
|
|
||||||
// If we're going to the inputs step, load UTXOs
|
|
||||||
if (hasSlots) {
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set role — this triggers the useEffect to rebuild steps and advance
|
|
||||||
setRoleIdentifier(selectedRole);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Variables ───────────────────────────────────────────────
|
|
||||||
if (stepType === 'variables') {
|
|
||||||
const emptyVars = variables.filter(
|
|
||||||
(v) => !v.value || v.value.trim() === ''
|
|
||||||
);
|
|
||||||
if (emptyVars.length > 0) {
|
|
||||||
showError(
|
|
||||||
`Please enter values for: ${emptyVars.map((v) => v.name).join(', ')}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the invitation and persist the variable values
|
|
||||||
const success = await createInvitationWithVariables();
|
|
||||||
if (!success) return;
|
|
||||||
|
|
||||||
// Advance, optionally kicking off UTXO loading
|
|
||||||
const nextStepType = steps[currentStep + 1]?.type;
|
|
||||||
if (nextStepType === 'inputs') {
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setTimeout(() => loadAvailableUtxos(), 100);
|
|
||||||
} else {
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inputs ──────────────────────────────────────────────────
|
|
||||||
if (stepType === 'inputs') {
|
|
||||||
await addInputsAndOutputs();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Review ──────────────────────────────────────────────────
|
|
||||||
if (stepType === 'review') {
|
|
||||||
await publishInvitation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Generic advance (e.g. publish → done) ───────────────────
|
|
||||||
setCurrentStep((prev) => prev + 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [
|
|
||||||
currentStep,
|
|
||||||
steps,
|
|
||||||
currentStepData,
|
|
||||||
availableRoles,
|
|
||||||
selectedRoleIndex,
|
|
||||||
template,
|
|
||||||
actionIdentifier,
|
|
||||||
variables,
|
|
||||||
showError,
|
|
||||||
createInvitationWithVariables,
|
|
||||||
loadAvailableUtxos,
|
|
||||||
addInputsAndOutputs,
|
|
||||||
publishInvitation,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ── Navigate to the previous step ──────────────────────────────
|
|
||||||
const previousStep = useCallback(() => {
|
|
||||||
if (currentStep <= 0) {
|
|
||||||
goBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentStep((prev) => prev - 1);
|
|
||||||
setFocusArea('content');
|
|
||||||
setFocusedInput(0);
|
|
||||||
}, [currentStep, goBack]);
|
|
||||||
|
|
||||||
// ── Cancel the wizard entirely ──────────────────────────────────
|
|
||||||
const cancel = useCallback(() => {
|
|
||||||
goBack();
|
|
||||||
}, [goBack]);
|
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────
|
|
||||||
return {
|
|
||||||
// Navigation / meta
|
|
||||||
template,
|
|
||||||
templateIdentifier,
|
|
||||||
actionIdentifier,
|
|
||||||
roleIdentifier,
|
|
||||||
action,
|
|
||||||
actionName,
|
|
||||||
|
|
||||||
// Role selection
|
|
||||||
availableRoles,
|
|
||||||
selectedRoleIndex,
|
|
||||||
setSelectedRoleIndex,
|
|
||||||
|
|
||||||
// Steps
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
currentStepData,
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
variables,
|
|
||||||
updateVariable,
|
|
||||||
handleTextInputSubmit,
|
|
||||||
|
|
||||||
// UTXOs
|
|
||||||
availableUtxos,
|
|
||||||
setAvailableUtxos,
|
|
||||||
selectedUtxoIndex,
|
|
||||||
setSelectedUtxoIndex,
|
|
||||||
requiredAmount,
|
|
||||||
fee,
|
|
||||||
selectedAmount,
|
|
||||||
changeAmount,
|
|
||||||
toggleUtxoSelection,
|
|
||||||
|
|
||||||
// Invitation
|
|
||||||
invitation,
|
|
||||||
invitationId,
|
|
||||||
|
|
||||||
// UI focus
|
|
||||||
focusedInput,
|
|
||||||
setFocusedInput,
|
|
||||||
focusedButton,
|
|
||||||
setFocusedButton,
|
|
||||||
focusArea,
|
|
||||||
setFocusArea,
|
|
||||||
isProcessing,
|
|
||||||
textInputHasFocus,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
nextStep,
|
|
||||||
previousStep,
|
|
||||||
cancel,
|
|
||||||
copyId,
|
|
||||||
} as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convenience type so other files can type the return value. */
|
|
||||||
export type ActionWizardState = ReturnType<typeof useActionWizard>;
|
|
||||||
@@ -6,5 +6,5 @@ export * from './action-wizard/index.js';
|
|||||||
export { SeedInputScreen } from './SeedInput.js';
|
export { SeedInputScreen } from './SeedInput.js';
|
||||||
export { WalletStateScreen } from './WalletState.js';
|
export { WalletStateScreen } from './WalletState.js';
|
||||||
export { TemplateListScreen } from './TemplateList.js';
|
export { TemplateListScreen } from './TemplateList.js';
|
||||||
export { InvitationScreen } from './Invitation.js';
|
export { InvitationScreen } from './invitations/InvitationScreen.js';
|
||||||
export { TransactionScreen } from './Transaction.js';
|
export { TransactionScreen } from './Transaction.js';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
* Invitation Screen - Manages invitations (create, import, view, monitor).
|
||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - Import invitation by ID with role selection
|
* - Import invitation by ID with multi-step import flow
|
||||||
* - View active invitations with detailed information
|
* - View active invitations with detailed information
|
||||||
* - Monitor invitation updates via SSE
|
* - Monitor invitation updates via SSE
|
||||||
* - Fill missing requirements
|
* - Fill missing requirements
|
||||||
@@ -10,18 +10,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { InputDialog } from '../components/Dialog.js';
|
import { InputDialog } from '../../components/Dialog.js';
|
||||||
import { ScrollableList, type ListItemData, type ListGroup } from '../components/List.js';
|
import { ScrollableList, type ListItemData, type ListGroup } from '../../components/List.js';
|
||||||
import { useNavigation } from '../hooks/useNavigation.js';
|
import { useNavigation } from '../../hooks/useNavigation.js';
|
||||||
import { useAppContext, useStatus } from '../hooks/useAppContext.js';
|
import { useAppContext, useStatus } from '../../hooks/useAppContext.js';
|
||||||
import { useInvitations } from '../hooks/useInvitations.js';
|
import { useBlockableInput, useIsInputCaptured } from '../../hooks/useInputLayer.js';
|
||||||
import { colors, logoSmall, formatSatoshis } from '../theme.js';
|
import { useInvitations } from '../../hooks/useInvitations.js';
|
||||||
import { copyToClipboard } from '../utils/clipboard.js';
|
import { colors, logoSmall, formatSatoshis } from '../../theme.js';
|
||||||
import type { Invitation } from '../../services/invitation.js';
|
import { copyToClipboard } from '../../utils/clipboard.js';
|
||||||
|
import type { Invitation } from '../../../services/invitation.js';
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
|
||||||
// Import utility functions
|
|
||||||
import {
|
import {
|
||||||
getInvitationState,
|
getInvitationState,
|
||||||
getStateColorName,
|
getStateColorName,
|
||||||
@@ -31,7 +31,9 @@ import {
|
|||||||
getUserRole,
|
getUserRole,
|
||||||
formatInvitationListItem,
|
formatInvitationListItem,
|
||||||
formatInvitationId,
|
formatInvitationId,
|
||||||
} from '../../utils/invitation-utils.js';
|
} from '../../../utils/invitation-utils.js';
|
||||||
|
|
||||||
|
import { InvitationImportFlow } from './invitation-import/InvitationImportFlow.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map state color name to theme color.
|
* Map state color name to theme color.
|
||||||
@@ -85,37 +87,29 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const { appService, showError, showInfo } = useAppContext();
|
const { appService, showError, showInfo } = useAppContext();
|
||||||
const { setStatus } = useStatus();
|
const { setStatus } = useStatus();
|
||||||
|
|
||||||
// Use hooks for reactive invitation list
|
|
||||||
const invitations = useInvitations();
|
const invitations = useInvitations();
|
||||||
|
|
||||||
// State
|
// ── UI state ─────────────────────────────────────────────────────────────
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
|
||||||
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
const [focusedPanel, setFocusedPanel] = useState<'list' | 'actions'>('list');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Import flow state - two stages: 'id' for entering ID, 'role-select' for choosing role
|
// ── Import state ─────────────────────────────────────────────────────────
|
||||||
const [importStage, setImportStage] = useState<'id' | 'role-select' | null>(null);
|
// Two phases: first the ID input dialog, then the multi-step import flow.
|
||||||
const [importingInvitation, setImportingInvitation] = useState<Invitation | null>(null);
|
const [showIdDialog, setShowIdDialog] = useState(false);
|
||||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
const [importingId, setImportingId] = useState<string | null>(null);
|
||||||
const [selectedRoleIndex, setSelectedRoleIndex] = useState(0);
|
|
||||||
const [importTemplate, setImportTemplate] = useState<XOTemplate | null>(null);
|
|
||||||
|
|
||||||
// Template cache for displaying invitation list with template names
|
// ── Template cache ───────────────────────────────────────────────────────
|
||||||
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
const [templateCache, setTemplateCache] = useState<Map<string, XOTemplate>>(new Map());
|
||||||
|
|
||||||
// Selected invitation template for details view
|
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
|
||||||
// Check if we should open import dialog on mount
|
// Check if we should open import dialog on mount
|
||||||
const initialMode = navData.mode as string | undefined;
|
const initialMode = navData.mode as string | undefined;
|
||||||
|
|
||||||
/**
|
|
||||||
* Show import dialog on mount if needed.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialMode === 'import') {
|
if (initialMode === 'import') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
}, [initialMode]);
|
}, [initialMode]);
|
||||||
|
|
||||||
@@ -139,10 +133,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build list items for ScrollableList.
|
* Build list items for ScrollableList.
|
||||||
* Index 0 is "Import Invitation", subsequent indices are actual invitations.
|
|
||||||
*/
|
*/
|
||||||
const listItems = useMemo((): InvitationListItem[] => {
|
const listItems = useMemo((): InvitationListItem[] => {
|
||||||
// Import action at top
|
|
||||||
const importItem: InvitationListItem = {
|
const importItem: InvitationListItem = {
|
||||||
key: 'import',
|
key: 'import',
|
||||||
label: '+ Import Invitation',
|
label: '+ Import Invitation',
|
||||||
@@ -151,11 +143,9 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
color: 'info',
|
color: 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map invitations to list items
|
|
||||||
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
const invitationItems: InvitationListItem[] = invitations.map(inv => {
|
||||||
const template = templateCache.get(inv.data.templateIdentifier);
|
const template = templateCache.get(inv.data.templateIdentifier);
|
||||||
const formatted = formatInvitationListItem(inv, template);
|
const formatted = formatInvitationListItem(inv, template);
|
||||||
const state = getInvitationState(inv);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: inv.data.invitationIdentifier,
|
key: inv.data.invitationIdentifier,
|
||||||
@@ -163,14 +153,13 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
value: inv,
|
value: inv,
|
||||||
group: 'invitations',
|
group: 'invitations',
|
||||||
color: formatted.statusColor,
|
color: formatted.statusColor,
|
||||||
hidden: !formatted.isValid, // Hide invalid items
|
hidden: !formatted.isValid,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [importItem, ...invitationItems];
|
return [importItem, ...invitationItems];
|
||||||
}, [invitations, templateCache]);
|
}, [invitations, templateCache]);
|
||||||
|
|
||||||
// Get selected invitation from list items
|
|
||||||
const selectedItem = listItems[selectedIndex];
|
const selectedItem = listItems[selectedIndex];
|
||||||
const selectedInvitation = selectedItem?.value ?? null;
|
const selectedInvitation = selectedItem?.value ?? null;
|
||||||
|
|
||||||
@@ -187,111 +176,29 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
.then(template => setSelectedTemplate(template ?? null));
|
.then(template => setSelectedTemplate(template ?? null));
|
||||||
}, [selectedInvitation, appService]);
|
}, [selectedInvitation, appService]);
|
||||||
|
|
||||||
|
// ── Import flow callbacks ──────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 1: Import invitation by ID (fetches invitation and moves to role selection).
|
* ID dialog submitted — transition to the multi-step import flow.
|
||||||
*/
|
*/
|
||||||
const handleImportIdSubmit = useCallback(async (invitationId: string) => {
|
const handleImportIdSubmit = useCallback((invitationId: string) => {
|
||||||
if (!invitationId.trim() || !appService) {
|
if (!invitationId.trim()) {
|
||||||
setImportStage(null);
|
setShowIdDialog(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setShowIdDialog(false);
|
||||||
console.log('Importing invitation:', invitationId);
|
setImportingId(invitationId.trim());
|
||||||
|
}, []);
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus('Fetching invitation...');
|
|
||||||
|
|
||||||
// Create invitation instance (will fetch from sync server)
|
|
||||||
const invitation = await appService.createInvitation(invitationId);
|
|
||||||
|
|
||||||
console.log(invitation);
|
|
||||||
|
|
||||||
const missingRequirements = await invitation.getMissingRequirements();
|
|
||||||
console.log(missingRequirements);
|
|
||||||
|
|
||||||
// Get available roles for this invitation
|
|
||||||
const roles = await invitation.getAvailableRoles();
|
|
||||||
|
|
||||||
console.log(roles);
|
|
||||||
|
|
||||||
// Get the template for display
|
|
||||||
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
|
||||||
|
|
||||||
// Store for next stage
|
|
||||||
setImportingInvitation(invitation);
|
|
||||||
setAvailableRoles(roles);
|
|
||||||
setSelectedRoleIndex(0);
|
|
||||||
setImportTemplate(template ?? null);
|
|
||||||
|
|
||||||
// Move to role selection stage
|
|
||||||
setImportStage('role-select');
|
|
||||||
setStatus('Ready');
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to import: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
setImportStage(null);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [appService, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage 2: Accept invitation with selected role.
|
* Import flow closed (completed or cancelled).
|
||||||
*/
|
*/
|
||||||
const handleRoleSelect = useCallback(async () => {
|
const handleImportFlowClose = useCallback(() => {
|
||||||
if (!importingInvitation || !appService) return;
|
setImportingId(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const selectedRole = availableRoles[selectedRoleIndex];
|
// ── Action handlers ────────────────────────────────────────────────────
|
||||||
if (!selectedRole) {
|
|
||||||
showError('No role selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
setStatus(`Accepting as ${selectedRole}...`);
|
|
||||||
|
|
||||||
// TODO: Engine doesnt support "accepting" without supplying some kind of data along with it.
|
|
||||||
// We also dont have a way to say "this action will require inputs, so i will do that."
|
|
||||||
// If it did, we could add an "input" with the role identifier.
|
|
||||||
// For now, we are just going to hard-code the input with the role identifier.
|
|
||||||
await importingInvitation.addInputs([{
|
|
||||||
roleIdentifier: selectedRole,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
showInfo(`Invitation imported and accepted!\n\nRole: ${selectedRole}\nTemplate: ${importTemplate?.name ?? importingInvitation.data.templateIdentifier}\nAction: ${importingInvitation.data.actionIdentifier}`);
|
|
||||||
setStatus('Ready');
|
|
||||||
|
|
||||||
// Reset import state
|
|
||||||
setImportStage(null);
|
|
||||||
setImportingInvitation(null);
|
|
||||||
setAvailableRoles([]);
|
|
||||||
setImportTemplate(null);
|
|
||||||
} catch (error) {
|
|
||||||
showError(`Failed to accept: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [importingInvitation, availableRoles, selectedRoleIndex, appService, importTemplate, showInfo, showError, setStatus]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel import and remove the invitation if it was added.
|
|
||||||
*/
|
|
||||||
const handleImportCancel = useCallback(async () => {
|
|
||||||
if (importingInvitation && appService) {
|
|
||||||
// Remove the invitation since user declined
|
|
||||||
await appService.removeInvitation(importingInvitation);
|
|
||||||
}
|
|
||||||
|
|
||||||
setImportStage(null);
|
|
||||||
setImportingInvitation(null);
|
|
||||||
setAvailableRoles([]);
|
|
||||||
setImportTemplate(null);
|
|
||||||
}, [importingInvitation, appService]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Accept selected invitation (from actions menu).
|
|
||||||
*/
|
|
||||||
const acceptInvitation = useCallback(async () => {
|
const acceptInvitation = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -307,7 +214,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
// Check if already accepted
|
|
||||||
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
if (errorMsg.toLowerCase().includes('already') || errorMsg.toLowerCase().includes('participant')) {
|
||||||
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
showInfo('You have already accepted this invitation.\n\nNext step: Use "Fill Requirements" to add your UTXOs.');
|
||||||
} else {
|
} else {
|
||||||
@@ -318,9 +224,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign selected invitation.
|
|
||||||
*/
|
|
||||||
const signInvitation = useCallback(async () => {
|
const signInvitation = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -341,9 +244,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy invitation ID.
|
|
||||||
*/
|
|
||||||
const copyId = useCallback(async () => {
|
const copyId = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -358,9 +258,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError]);
|
}, [selectedInvitation, showInfo, showError]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill requirements for selected invitation.
|
|
||||||
*/
|
|
||||||
const fillRequirements = useCallback(async () => {
|
const fillRequirements = useCallback(async () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
showError('No invitation selected');
|
showError('No invitation selected');
|
||||||
@@ -370,15 +267,12 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Step 1: Check available roles
|
|
||||||
setStatus('Checking available roles...');
|
setStatus('Checking available roles...');
|
||||||
const roles = await selectedInvitation.getAvailableRoles();
|
const roles = await selectedInvitation.getAvailableRoles();
|
||||||
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
// Already participating, check if we can add inputs
|
|
||||||
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
showInfo('You are already participating in this invitation. Checking if inputs are needed...');
|
||||||
} else {
|
} else {
|
||||||
// Need to accept a role first
|
|
||||||
const roleToTake = roles[0];
|
const roleToTake = roles[0];
|
||||||
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
showInfo(`Accepting invitation as role: ${roleToTake}`);
|
||||||
setStatus(`Accepting as ${roleToTake}...`);
|
setStatus(`Accepting as ${roleToTake}...`);
|
||||||
@@ -392,10 +286,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Check if invitation already has inputs or needs funding
|
|
||||||
setStatus('Analyzing invitation...');
|
setStatus('Analyzing invitation...');
|
||||||
|
|
||||||
// Calculate how much we need
|
|
||||||
let requiredAmount = 0n;
|
let requiredAmount = 0n;
|
||||||
const commits = selectedInvitation.data.commits || [];
|
const commits = selectedInvitation.data.commits || [];
|
||||||
for (const commit of commits) {
|
for (const commit of commits) {
|
||||||
@@ -413,7 +305,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const dust = 546n;
|
const dust = 546n;
|
||||||
const totalNeeded = requiredAmount + fee + dust;
|
const totalNeeded = requiredAmount + fee + dust;
|
||||||
|
|
||||||
// Find resources
|
|
||||||
const utxos = await selectedInvitation.findSuitableResources({
|
const utxos = await selectedInvitation.findSuitableResources({
|
||||||
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
templateIdentifier: selectedInvitation.data.templateIdentifier,
|
||||||
outputIdentifier: 'receiveOutput',
|
outputIdentifier: 'receiveOutput',
|
||||||
@@ -425,7 +316,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select UTXOs
|
|
||||||
setStatus('Selecting UTXOs...');
|
setStatus('Selecting UTXOs...');
|
||||||
|
|
||||||
const selectedUtxos: Array<{
|
const selectedUtxos: Array<{
|
||||||
@@ -443,12 +333,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
: Buffer.from(utxo.lockingBytecode).toString('hex')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) {
|
if (lockingBytecodeHex && seenLockingBytecodes.has(lockingBytecodeHex)) continue;
|
||||||
continue;
|
if (lockingBytecodeHex) seenLockingBytecodes.add(lockingBytecodeHex);
|
||||||
}
|
|
||||||
if (lockingBytecodeHex) {
|
|
||||||
seenLockingBytecodes.add(lockingBytecodeHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedUtxos.push({
|
selectedUtxos.push({
|
||||||
outpointTransactionHash: utxo.outpointTransactionHash,
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
@@ -457,9 +343,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
});
|
});
|
||||||
accumulated += BigInt(utxo.valueSatoshis);
|
accumulated += BigInt(utxo.valueSatoshis);
|
||||||
|
|
||||||
if (accumulated >= totalNeeded) {
|
if (accumulated >= totalNeeded) break;
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accumulated < totalNeeded) {
|
if (accumulated < totalNeeded) {
|
||||||
@@ -470,7 +354,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
const changeAmount = accumulated - requiredAmount - fee;
|
const changeAmount = accumulated - requiredAmount - fee;
|
||||||
|
|
||||||
// Add inputs
|
|
||||||
setStatus('Adding inputs...');
|
setStatus('Adding inputs...');
|
||||||
await selectedInvitation.addInputs(
|
await selectedInvitation.addInputs(
|
||||||
selectedUtxos.map(u => ({
|
selectedUtxos.map(u => ({
|
||||||
@@ -479,7 +362,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add change output
|
|
||||||
if (changeAmount >= dust) {
|
if (changeAmount >= dust) {
|
||||||
setStatus('Adding change output...');
|
setStatus('Adding change output...');
|
||||||
await selectedInvitation.addOutputs([{
|
await selectedInvitation.addOutputs([{
|
||||||
@@ -487,7 +369,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success
|
|
||||||
showInfo(
|
showInfo(
|
||||||
`Requirements filled!\n\n` +
|
`Requirements filled!\n\n` +
|
||||||
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
`• Selected ${selectedUtxos.length} UTXO(s)\n` +
|
||||||
@@ -498,7 +379,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
`Now use "Sign Transaction" to complete.`
|
`Now use "Sign Transaction" to complete.`
|
||||||
);
|
);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
showError(`Failed to fill requirements: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
setStatus('Ready');
|
setStatus('Ready');
|
||||||
@@ -507,9 +387,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, showInfo, showError, setStatus]);
|
}, [selectedInvitation, showInfo, showError, setStatus]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle action selection.
|
|
||||||
*/
|
|
||||||
const handleAction = useCallback((action: string) => {
|
const handleAction = useCallback((action: string) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'copy':
|
case 'copy':
|
||||||
@@ -532,70 +409,44 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
}, [selectedInvitation, copyId, acceptInvitation, fillRequirements, signInvitation, navigate]);
|
||||||
|
|
||||||
/**
|
const handleListItemActivate = useCallback((item: InvitationListItem, _index: number) => {
|
||||||
* Handle list item activation.
|
|
||||||
*/
|
|
||||||
const handleListItemActivate = useCallback((item: InvitationListItem, index: number) => {
|
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
// For invitation items, we just select them - actions are in the actions panel
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
const handleActionItemActivate = useCallback((item: ListItemData<string>, _index: number) => {
|
||||||
* Handle action item activation.
|
|
||||||
*/
|
|
||||||
const handleActionItemActivate = useCallback((item: ListItemData<string>, index: number) => {
|
|
||||||
if (item.value) {
|
if (item.value) {
|
||||||
handleAction(item.value);
|
handleAction(item.value);
|
||||||
}
|
}
|
||||||
}, [handleAction]);
|
}, [handleAction]);
|
||||||
|
|
||||||
// Handle keyboard navigation
|
// ── Keyboard navigation ──────────────────────────────────────────────────
|
||||||
useInput((input, key) => {
|
// Automatically blocked when any dialog/overlay is capturing input.
|
||||||
// Handle role selection dialog navigation
|
const isCaptured = useIsInputCaptured();
|
||||||
if (importStage === 'role-select') {
|
|
||||||
if (key.upArrow || input === 'k') {
|
|
||||||
setSelectedRoleIndex(prev => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow || input === 'j') {
|
|
||||||
setSelectedRoleIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
|
||||||
} else if (key.return) {
|
|
||||||
handleRoleSelect();
|
|
||||||
} else if (key.escape) {
|
|
||||||
handleImportCancel();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't handle input while ID input dialog is open
|
useBlockableInput((input, key) => {
|
||||||
if (importStage === 'id') return;
|
|
||||||
|
|
||||||
// Tab to switch panels (list -> actions -> list)
|
|
||||||
if (key.tab) {
|
if (key.tab) {
|
||||||
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
setFocusedPanel(prev => prev === 'list' ? 'actions' : 'list');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'c' to copy
|
|
||||||
if (input === 'c' && selectedInvitation) {
|
if (input === 'c' && selectedInvitation) {
|
||||||
copyId();
|
copyId();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'i' to import
|
|
||||||
if (input === 'i') {
|
if (input === 'i') {
|
||||||
setImportStage('id');
|
setShowIdDialog(true);
|
||||||
}
|
}
|
||||||
}, { isActive: importStage !== 'id' });
|
});
|
||||||
|
|
||||||
|
// ── Render helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Render custom list item for invitation list.
|
|
||||||
*/
|
|
||||||
const renderInvitationListItem = useCallback((
|
const renderInvitationListItem = useCallback((
|
||||||
item: InvitationListItem,
|
item: InvitationListItem,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
isFocused: boolean
|
isFocused: boolean
|
||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
// Import item
|
|
||||||
if (item.key === 'import') {
|
if (item.key === 'import') {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
@@ -608,7 +459,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invitation item
|
|
||||||
const inv = item.value;
|
const inv = item.value;
|
||||||
if (!inv) return null;
|
if (!inv) return null;
|
||||||
|
|
||||||
@@ -628,9 +478,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
}, [templateCache]);
|
}, [templateCache]);
|
||||||
|
|
||||||
/**
|
|
||||||
* Render detailed invitation information.
|
|
||||||
*/
|
|
||||||
const renderDetails = () => {
|
const renderDetails = () => {
|
||||||
if (!selectedInvitation) {
|
if (!selectedInvitation) {
|
||||||
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
return <Text color={colors.textMuted}>Select an invitation to view details</Text>;
|
||||||
@@ -642,7 +489,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
const outputs = getInvitationOutputs(selectedInvitation);
|
const outputs = getInvitationOutputs(selectedInvitation);
|
||||||
const variables = getInvitationVariables(selectedInvitation);
|
const variables = getInvitationVariables(selectedInvitation);
|
||||||
|
|
||||||
// Try to determine user's entity ID (from first commit they made)
|
|
||||||
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
const userEntityId = selectedInvitation.data.commits?.[0]?.entityIdentifier ?? null;
|
||||||
const userRole = getUserRole(selectedInvitation, userEntityId);
|
const userRole = getUserRole(selectedInvitation, userEntityId);
|
||||||
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
const roleInfoRaw = userRole && selectedTemplate?.roles?.[userRole];
|
||||||
@@ -650,7 +496,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{/* Row 1: Type, Description, Status */}
|
{/* Type & Status */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
<Box width="50%">
|
<Box width="50%">
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
@@ -675,7 +521,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Row 2: Your Role */}
|
{/* Your Role */}
|
||||||
{userRole && (
|
{userRole && (
|
||||||
<Box marginBottom={1} flexDirection="column">
|
<Box marginBottom={1} flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Your Role: </Text>
|
<Text color={colors.primary} bold>Your Role: </Text>
|
||||||
@@ -686,9 +532,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 3: Inputs & Outputs side by side */}
|
{/* Inputs & Outputs */}
|
||||||
<Box flexDirection="row" marginBottom={1}>
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
{/* Inputs */}
|
|
||||||
<Box width="50%" flexDirection="column">
|
<Box width="50%" flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
{inputs.length === 0 ? (
|
{inputs.length === 0 ? (
|
||||||
@@ -711,7 +556,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Outputs */}
|
|
||||||
<Box width="50%" flexDirection="column">
|
<Box width="50%" flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
{outputs.length === 0 ? (
|
{outputs.length === 0 ? (
|
||||||
@@ -735,7 +579,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Row 4: Variables */}
|
{/* Variables */}
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
{variables.length === 0 ? (
|
{variables.length === 0 ? (
|
||||||
@@ -763,7 +607,6 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Shortcuts */}
|
|
||||||
<Box marginTop={1}>
|
<Box marginTop={1}>
|
||||||
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
<Text color={colors.textMuted} dimColor>c: Copy ID</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -771,84 +614,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ── Main render ──────────────────────────────────────────────────────────
|
||||||
* Render role selection dialog for import flow.
|
|
||||||
*/
|
|
||||||
const renderRoleSelectionDialog = () => {
|
|
||||||
if (!importingInvitation) return null;
|
|
||||||
|
|
||||||
const action = importTemplate?.actions?.[importingInvitation.data.actionIdentifier];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
flexDirection="column"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="center"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="double"
|
|
||||||
borderColor={colors.primary}
|
|
||||||
backgroundColor="black"
|
|
||||||
paddingX={2}
|
|
||||||
paddingY={1}
|
|
||||||
width={70}
|
|
||||||
>
|
|
||||||
<Text color={colors.primary} bold>Import Invitation - Select Role</Text>
|
|
||||||
|
|
||||||
{/* Invitation Details */}
|
|
||||||
<Box marginY={1} flexDirection="column">
|
|
||||||
<Text color={colors.text}>Template: {importTemplate?.name ?? 'Unknown'}</Text>
|
|
||||||
{importTemplate?.description && (
|
|
||||||
<Text color={colors.textMuted} dimColor>{importTemplate.description}</Text>
|
|
||||||
)}
|
|
||||||
<Text color={colors.text}>Action: {action?.name ?? importingInvitation.data.actionIdentifier}</Text>
|
|
||||||
{action?.description && (
|
|
||||||
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Role Selection */}
|
|
||||||
<Box marginY={1} flexDirection="column">
|
|
||||||
<Text color={colors.primary} bold>Available Roles:</Text>
|
|
||||||
{availableRoles.length === 0 ? (
|
|
||||||
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
|
||||||
) : (
|
|
||||||
availableRoles.map((role, index) => {
|
|
||||||
const roleInfoRaw = importTemplate?.roles?.[role];
|
|
||||||
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
|
||||||
const actionRoleRaw = action?.roles?.[role];
|
|
||||||
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
|
||||||
return (
|
|
||||||
<Box key={role} flexDirection="column">
|
|
||||||
<Text
|
|
||||||
color={index === selectedRoleIndex ? colors.focus : colors.text}
|
|
||||||
bold={index === selectedRoleIndex}
|
|
||||||
>
|
|
||||||
{index === selectedRoleIndex ? '▸ ' : ' '}
|
|
||||||
{roleInfo?.name ?? role}
|
|
||||||
</Text>
|
|
||||||
{(roleInfo?.description || actionRole?.description) && (
|
|
||||||
<Text color={colors.textMuted} dimColor>
|
|
||||||
{' '}{actionRole?.description ?? roleInfo?.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginTop={1}>
|
|
||||||
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Decline</Text>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" flexGrow={1}>
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
@@ -857,7 +623,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
<Text color={colors.primary} bold>{logoSmall} - Invitations</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Main content - Top row: List + Actions */}
|
{/* Top row: List + Actions */}
|
||||||
<Box flexDirection="row" marginTop={1} height={12}>
|
<Box flexDirection="row" marginTop={1} height={12}>
|
||||||
{/* Left column: Invitation list */}
|
{/* Left column: Invitation list */}
|
||||||
<Box flexDirection="column" width="70%" paddingRight={1}>
|
<Box flexDirection="column" width="70%" paddingRight={1}>
|
||||||
@@ -874,7 +640,7 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedIndex}
|
selectedIndex={selectedIndex}
|
||||||
onSelect={setSelectedIndex}
|
onSelect={setSelectedIndex}
|
||||||
onActivate={handleListItemActivate}
|
onActivate={handleListItemActivate}
|
||||||
focus={focusedPanel === 'list'}
|
focus={focusedPanel === 'list' && !isCaptured}
|
||||||
maxVisible={6}
|
maxVisible={6}
|
||||||
groups={invitationListGroups}
|
groups={invitationListGroups}
|
||||||
emptyMessage="No invitations yet"
|
emptyMessage="No invitations yet"
|
||||||
@@ -898,14 +664,14 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
selectedIndex={selectedActionIndex}
|
selectedIndex={selectedActionIndex}
|
||||||
onSelect={setSelectedActionIndex}
|
onSelect={setSelectedActionIndex}
|
||||||
onActivate={handleActionItemActivate}
|
onActivate={handleActionItemActivate}
|
||||||
focus={focusedPanel === 'actions'}
|
focus={focusedPanel === 'actions' && !isCaptured}
|
||||||
emptyMessage="No actions"
|
emptyMessage="No actions"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Bottom row: Details (full width) */}
|
{/* Bottom row: Details */}
|
||||||
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
<Box flexDirection="column" marginTop={1} flexGrow={1}>
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
@@ -928,8 +694,8 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Import ID dialog (Stage 1) */}
|
{/* Import ID dialog */}
|
||||||
{importStage === 'id' && (
|
{showIdDialog && (
|
||||||
<Box
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -943,14 +709,24 @@ export function InvitationScreen(): React.ReactElement {
|
|||||||
prompt="Enter Invitation ID:"
|
prompt="Enter Invitation ID:"
|
||||||
placeholder="Paste invitation ID..."
|
placeholder="Paste invitation ID..."
|
||||||
onSubmit={handleImportIdSubmit}
|
onSubmit={handleImportIdSubmit}
|
||||||
onCancel={() => setImportStage(null)}
|
onCancel={() => setShowIdDialog(false)}
|
||||||
isActive={true}
|
isActive={true}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Role Selection dialog (Stage 2) */}
|
{/* Multi-step import flow */}
|
||||||
{importStage === 'role-select' && renderRoleSelectionDialog()}
|
{importingId && appService && (
|
||||||
|
<InvitationImportFlow
|
||||||
|
invitationId={importingId}
|
||||||
|
mode="screen"
|
||||||
|
appService={appService}
|
||||||
|
onClose={handleImportFlowClose}
|
||||||
|
showError={showError}
|
||||||
|
showInfo={showInfo}
|
||||||
|
setStatus={setStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
/**
|
||||||
|
* InvitationImportFlow — orchestrates the multi-step invitation import.
|
||||||
|
*
|
||||||
|
* Manages the step state machine, accumulates data from each step, and
|
||||||
|
* injects it into the next step via props (dependency injection).
|
||||||
|
*
|
||||||
|
* Supports two display modes:
|
||||||
|
* - `'dialog'`: renders as an absolute-positioned overlay (used when called from InvitationScreen)
|
||||||
|
* - `'screen'`: renders as a full-screen component with header, step indicator, and button bar
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, logoSmall } from '../../../theme.js';
|
||||||
|
import { StepIndicator, type Step } from '../../../components/ProgressBar.js';
|
||||||
|
|
||||||
|
import { FetchInvitationStep } from './steps/FetchInvitationStep.js';
|
||||||
|
import { PreviewInvitationStep } from './steps/PreviewInvitationStep.js';
|
||||||
|
import { RoleSelectStep } from './steps/RoleSelectStep.js';
|
||||||
|
import { InputsSelectStep } from './steps/InputsSelectStep.js';
|
||||||
|
import { ReviewStep } from './steps/ReviewStep.js';
|
||||||
|
|
||||||
|
import { IMPORT_STEPS, type ImportFlowProps, type SelectableUTXO } from './types.js';
|
||||||
|
import type { Invitation } from '../../../../services/invitation.js';
|
||||||
|
import type { XOTemplate } from '@xo-cash/types';
|
||||||
|
import { DialogWrapper } from '../../../components/Dialog.js';
|
||||||
|
import { useInputLayer, useLayeredInput } from '../../../hooks/useInputLayer.js';
|
||||||
|
import { InvitationBuilder } from '@xo-cash/engine';
|
||||||
|
import { hexToBin } from '@bitauth/libauth';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function InvitationImportFlow({
|
||||||
|
invitationId,
|
||||||
|
mode,
|
||||||
|
appService,
|
||||||
|
onClose,
|
||||||
|
showError,
|
||||||
|
showInfo,
|
||||||
|
setStatus,
|
||||||
|
}: ImportFlowProps): React.ReactElement {
|
||||||
|
// ── Accumulated state ────────────────────────────────────────────────────
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [invitation, setInvitation] = useState<Invitation | null>(null);
|
||||||
|
const [buildableInvitation, setBuildableInvitation] = useState<InvitationBuilder | null>(null);
|
||||||
|
const [template, setTemplate] = useState<XOTemplate | null>(null);
|
||||||
|
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<string | null>(null);
|
||||||
|
const [selectedInputs, setSelectedInputs] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [changeAmount, setChangeAmount] = useState(0n);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
|
||||||
|
// ── Cancel handler ───────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Cleans up (removes the invitation if it was fetched) and signals the parent.
|
||||||
|
*/
|
||||||
|
const handleCancel = useCallback(async () => {
|
||||||
|
if (invitation && appService) {
|
||||||
|
try {
|
||||||
|
await appService.removeInvitation(invitation);
|
||||||
|
} catch {
|
||||||
|
// Best-effort removal — don't block close on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [invitation, appService, onClose]);
|
||||||
|
|
||||||
|
// ── Step completion callbacks ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FetchStep completed — invitation and template are now available.
|
||||||
|
* Also pre-fetches available roles for the next steps.
|
||||||
|
*/
|
||||||
|
const handleFetchComplete = useCallback(async (inv: Invitation, tmpl: XOTemplate | null) => {
|
||||||
|
setInvitation(inv);
|
||||||
|
setTemplate(tmpl);
|
||||||
|
|
||||||
|
const builder = InvitationBuilder.fromInvitation(inv.data);
|
||||||
|
setBuildableInvitation(builder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const roles = await inv.getAvailableRoles();
|
||||||
|
setAvailableRoles(roles);
|
||||||
|
} catch (err) {
|
||||||
|
showError(`Failed to get roles: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(1); // → Preview
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
/** PreviewStep completed — user reviewed the invitation state and wants to proceed. */
|
||||||
|
const handlePreviewComplete = useCallback(() => {
|
||||||
|
setCurrentStep(2); // → Role Select
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** RoleSelectStep completed — user picked a role. */
|
||||||
|
const handleRoleComplete = useCallback((role: string) => {
|
||||||
|
setSelectedRole(role);
|
||||||
|
setCurrentStep(3); // → Inputs Select
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** InputsSelectStep completed — user selected UTXOs. */
|
||||||
|
const handleInputsComplete = useCallback(async (inputs: SelectableUTXO[]) => {
|
||||||
|
setSelectedInputs(inputs);
|
||||||
|
|
||||||
|
await invitation?.addInputs(inputs.map(input => ({
|
||||||
|
outpointTransactionHash: hexToBin(input.outpointTransactionHash),
|
||||||
|
outpointIndex: input.outpointIndex,
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = inputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
// Determine required amount from invitation variables
|
||||||
|
const requiredSats = await invitation?.getSatsOut() ?? 0n;
|
||||||
|
setRequiredAmount(requiredSats);
|
||||||
|
|
||||||
|
// Set the change amount for the review step
|
||||||
|
const changeAmountSats = totalSelected - requiredSats - DEFAULT_FEE;
|
||||||
|
setChangeAmount(changeAmountSats);
|
||||||
|
|
||||||
|
// Add the change output if it exceeds the dust threshold
|
||||||
|
if (changeAmountSats >= DUST_THRESHOLD) {
|
||||||
|
await invitation?.addOutputs([{
|
||||||
|
valueSatoshis: changeAmountSats,
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentStep(4); // → Review
|
||||||
|
}, [invitation, buildableInvitation, selectedInputs]);
|
||||||
|
|
||||||
|
/** ReviewStep completed — invitation import is done. */
|
||||||
|
const handleReviewComplete = useCallback(() => {
|
||||||
|
const roleName = (() => {
|
||||||
|
if (!selectedRole || !template) return selectedRole ?? '';
|
||||||
|
const raw = template.roles?.[selectedRole];
|
||||||
|
return (raw && typeof raw === 'object' && 'name' in raw) ? String(raw.name) : selectedRole;
|
||||||
|
})();
|
||||||
|
|
||||||
|
showInfo(
|
||||||
|
`Invitation imported and accepted!\n\n` +
|
||||||
|
`Role: ${roleName}\n` +
|
||||||
|
`Template: ${template?.name ?? invitation?.data.templateIdentifier ?? 'Unknown'}\n` +
|
||||||
|
`Action: ${invitation?.data.actionIdentifier ?? 'Unknown'}`
|
||||||
|
);
|
||||||
|
setStatus('Ready');
|
||||||
|
onClose();
|
||||||
|
}, [selectedRole, template, invitation, showInfo, setStatus, onClose]);
|
||||||
|
|
||||||
|
// ── Keyboard handling ────────────────────────────────────────────────────
|
||||||
|
// The import flow registers its own layer so it captures input above the
|
||||||
|
// parent screen. Individual steps also register sub-layers when needed.
|
||||||
|
useInputLayer('import-flow');
|
||||||
|
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (currentStep !== 0) return;
|
||||||
|
// Enter retries, Esc cancels — handled within FetchStep rendering,
|
||||||
|
// but we also catch Esc here for safety.
|
||||||
|
if (key.escape) handleCancel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Step router ──────────────────────────────────────────────────────────
|
||||||
|
const renderStep = (): React.ReactNode => {
|
||||||
|
const stepDef = IMPORT_STEPS[currentStep];
|
||||||
|
if (!stepDef) return null;
|
||||||
|
|
||||||
|
switch (stepDef.type) {
|
||||||
|
case 'fetch':
|
||||||
|
return (
|
||||||
|
<FetchInvitationStep
|
||||||
|
invitationId={invitationId}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleFetchComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'preview':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<PreviewInvitationStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
onComplete={handlePreviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'role-select':
|
||||||
|
if (!invitation) return null;
|
||||||
|
return (
|
||||||
|
<RoleSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
availableRoles={availableRoles}
|
||||||
|
onComplete={handleRoleComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'inputs-select':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<InputsSelectStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleInputsComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'review':
|
||||||
|
if (!invitation || !selectedRole) return null;
|
||||||
|
return (
|
||||||
|
<ReviewStep
|
||||||
|
invitation={invitation}
|
||||||
|
template={template}
|
||||||
|
selectedRole={selectedRole}
|
||||||
|
selectedInputs={selectedInputs}
|
||||||
|
changeAmount={changeAmount}
|
||||||
|
requiredAmount={requiredAmount}
|
||||||
|
appService={appService}
|
||||||
|
onComplete={handleReviewComplete}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
isActive={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Step indicator data ──────────────────────────────────────────────────
|
||||||
|
const indicatorSteps: Step[] = IMPORT_STEPS.map(s => ({ label: s.name }));
|
||||||
|
|
||||||
|
// ── Layout: dialog mode ──────────────────────────────────────────────────
|
||||||
|
if (mode === 'dialog') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
>
|
||||||
|
<DialogWrapper title="Import Invitation" borderColor={colors.primary}>
|
||||||
|
{/* Step indicator (compact) */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</DialogWrapper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout: screen mode ──────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" flexGrow={1}>
|
||||||
|
{/* Header */}
|
||||||
|
<Box borderStyle="single" borderColor={colors.secondary} paddingX={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>{logoSmall} - Import Invitation</Text>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
{template?.name ?? 'Loading...'}
|
||||||
|
{selectedRole ? ` (as ${selectedRole})` : ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<Box marginTop={1} paddingX={1}>
|
||||||
|
<StepIndicator steps={indicatorSteps} currentStep={currentStep} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<Box
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor={colors.primary}
|
||||||
|
flexDirection="column"
|
||||||
|
paddingX={1}
|
||||||
|
paddingY={1}
|
||||||
|
marginTop={1}
|
||||||
|
marginX={1}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
<Text color={colors.primary} bold>
|
||||||
|
{IMPORT_STEPS[currentStep]?.name ?? 'Unknown'} ({currentStep + 1}/{IMPORT_STEPS.length})
|
||||||
|
</Text>
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
{renderStep()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Help text */}
|
||||||
|
<Box marginTop={1} marginX={1}>
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
Esc: Cancel import
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* FetchInvitationStep — first step in the import flow.
|
||||||
|
*
|
||||||
|
* Receives an invitation ID, fetches the invitation from the sync server,
|
||||||
|
* resolves its template, and auto-advances once loaded.
|
||||||
|
* Shows a loading spinner while fetching and an error state with retry/cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import type { FetchStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function FetchInvitationStep({
|
||||||
|
invitationId,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
isActive,
|
||||||
|
}: FetchStepProps): React.ReactElement {
|
||||||
|
const [status, setStatus] = useState<'loading' | 'error'>('loading');
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the invitation and its template, then auto-advance.
|
||||||
|
*/
|
||||||
|
const fetchInvitation = useCallback(async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create/fetch the invitation instance (fetches from sync server if needed)
|
||||||
|
const invitation = await appService.createInvitation(invitationId);
|
||||||
|
|
||||||
|
// Resolve the template for display in later steps
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
|
||||||
|
// Auto-advance — hand the loaded data to the flow controller
|
||||||
|
onComplete(invitation, template ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setErrorMessage(message);
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, [invitationId, appService, onComplete]);
|
||||||
|
|
||||||
|
// Kick off the fetch on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive) {
|
||||||
|
fetchInvitation();
|
||||||
|
}
|
||||||
|
}, [isActive, fetchInvitation]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Fetching invitation...</Text>
|
||||||
|
<Text color={colors.textMuted} dimColor>ID: {invitationId}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to fetch invitation</Text>
|
||||||
|
<Text color={colors.textMuted} wrap="wrap">{errorMessage}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Press Enter to retry or Esc to cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* InputsSelectStep — lets the user select UTXOs to fund the invitation.
|
||||||
|
*
|
||||||
|
* On mount, queries for suitable resources via the invitation's `findSuitableResources`.
|
||||||
|
* Auto-selects greedily, then lets the user toggle individual UTXOs.
|
||||||
|
* Shows required, selected, and change amounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { InputsSelectStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
import { autoSelectGreedyUtxos, mapUnspentOutputsToSelectable } from '../../../../../utils/invitation-flow.js';
|
||||||
|
import type { UnspentOutputData } from '@xo-cash/state';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function InputsSelectStep({
|
||||||
|
invitation,
|
||||||
|
appService,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: InputsSelectStepProps): React.ReactElement {
|
||||||
|
const [utxos, setUtxos] = useState<SelectableUTXO[]>([]);
|
||||||
|
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||||
|
const [requiredAmount, setRequiredAmount] = useState(0n);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
|
||||||
|
// Derived totals
|
||||||
|
const selectedAmount = utxos
|
||||||
|
.filter(u => u.selected)
|
||||||
|
.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
const changeAmount = selectedAmount - requiredAmount - fee;
|
||||||
|
const hasEnough = selectedAmount >= requiredAmount + fee;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the required satoshi amount from the invitation's variables.
|
||||||
|
*/
|
||||||
|
const computeRequiredAmount = useCallback(async (): Promise<bigint> => {
|
||||||
|
return await invitation.getSatsOut() ?? 0n;
|
||||||
|
}, [invitation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch suitable UTXOs from the engine and auto-select greedily.
|
||||||
|
*/
|
||||||
|
const loadUtxos = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const required = await computeRequiredAmount();
|
||||||
|
setRequiredAmount(required);
|
||||||
|
|
||||||
|
const template = await appService.engine.getTemplate(invitation.data.templateIdentifier);
|
||||||
|
if (!template) {
|
||||||
|
throw new Error('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the action that we are calling from the template
|
||||||
|
const action = template.actions[invitation.data.actionIdentifier];
|
||||||
|
if (!action) {
|
||||||
|
throw new Error('Action not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!action.transaction) {
|
||||||
|
throw new Error('Action does not have a transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transaction that the action is creating
|
||||||
|
const transaction = template.transactions?.[action.transaction];
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error(`Transaction not found for action: ${action.transaction}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transaction.outputs) {
|
||||||
|
throw new Error(`Transaction does not have outputs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a set to store all the output identifiers
|
||||||
|
const outputIdentifiers = new Set<string>();
|
||||||
|
for (const output of transaction.outputs) {
|
||||||
|
outputIdentifiers.add(output.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of the utxoID to suitable resource
|
||||||
|
const utxoIdToSuitableResource = new Map<string, UnspentOutputData>();
|
||||||
|
for (const outputIdentifier of outputIdentifiers) {
|
||||||
|
const suitableResources = await invitation.findSuitableResources({
|
||||||
|
|
||||||
|
outputIdentifier,
|
||||||
|
});
|
||||||
|
for (const suitableResource of suitableResources) {
|
||||||
|
utxoIdToSuitableResource.set(suitableResource.outpointTransactionHash + ':' + suitableResource.outpointIndex, suitableResource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = mapUnspentOutputsToSelectable(Array.from(utxoIdToSuitableResource.values()));
|
||||||
|
const autoSelected = autoSelectGreedyUtxos(selectable, required + fee);
|
||||||
|
setUtxos(autoSelected as SelectableUTXO[]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [invitation, computeRequiredAmount, fee]);
|
||||||
|
|
||||||
|
// Load UTXOs once on mount. We use a ref guard to prevent re-firing when
|
||||||
|
// `loadUtxos` identity changes due to parent re-renders — each re-fire
|
||||||
|
// flashes the loading state, causing the visible flicker bug.
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && !hasLoadedRef.current) {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
|
loadUtxos();
|
||||||
|
}
|
||||||
|
}, [isActive, loadUtxos]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle the selection of a UTXO at the given index.
|
||||||
|
*/
|
||||||
|
const toggleSelection = useCallback((index: number) => {
|
||||||
|
setUtxos(prev => {
|
||||||
|
const updated = [...prev];
|
||||||
|
const utxo = updated[index];
|
||||||
|
if (utxo) updated[index] = { ...utxo, selected: !utxo.selected };
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keyboard handling — gated by the import-flow layer so dialogs on top block input.
|
||||||
|
useLayeredInput('import-flow', (input, key) => {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setFocusedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setFocusedIndex(prev => Math.min(utxos.length - 1, prev + 1));
|
||||||
|
} else if (input === ' ' || (key.return && utxos.length > 0)) {
|
||||||
|
if (utxos.length > 0) toggleSelection(focusedIndex);
|
||||||
|
} else if (input === 'a') {
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: true })));
|
||||||
|
} else if (input === 'n') {
|
||||||
|
setUtxos(prev => prev.map(u => ({ ...u, selected: false })));
|
||||||
|
} else if (key.return) {
|
||||||
|
if (hasEnough) {
|
||||||
|
onComplete(utxos.filter(u => u.selected));
|
||||||
|
}
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.info}>Finding suitable UTXOs...</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.error} bold>Failed to load UTXOs</Text>
|
||||||
|
<Text color={colors.textMuted}>{error}</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No UTXOs found
|
||||||
|
if (utxos.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.warning}>No suitable UTXOs found. Make sure your wallet has funds.</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Summary bar */}
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Required: </Text>
|
||||||
|
<Text color={colors.text}>{formatSatoshis(requiredAmount + fee)}</Text>
|
||||||
|
<Text color={colors.textMuted}> (amount {formatSatoshis(requiredAmount)} + fee {formatSatoshis(fee)})</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box flexDirection="row" marginBottom={1}>
|
||||||
|
<Text color={colors.primary} bold>Selected: </Text>
|
||||||
|
<Text color={hasEnough ? colors.success : colors.error}>{formatSatoshis(selectedAmount)}</Text>
|
||||||
|
{hasEnough && changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.textMuted}> (change: {formatSatoshis(changeAmount)})</Text>
|
||||||
|
)}
|
||||||
|
{!hasEnough && (
|
||||||
|
<Text color={colors.error}> — need {formatSatoshis(requiredAmount + fee - selectedAmount)} more</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* UTXO list */}
|
||||||
|
<Text color={colors.primary} bold>UTXOs ({utxos.length}):</Text>
|
||||||
|
{utxos.map((utxo, index) => {
|
||||||
|
const isFocused = index === focusedIndex;
|
||||||
|
const checkMark = utxo.selected ? '☑' : '☐';
|
||||||
|
const txShort = utxo.outpointTransactionHash.slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${utxo.outpointTransactionHash}:${utxo.outpointIndex}`}
|
||||||
|
color={isFocused ? colors.focus : utxo.selected ? colors.success : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}{checkMark} {formatSatoshis(utxo.valueSatoshis)} ({txShort}…:{utxo.outpointIndex})
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>
|
||||||
|
↑↓: Navigate • Space: Toggle • a: All • n: None • return: Confirm • Esc: Cancel
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* PreviewInvitationStep — displays the current state of a fetched invitation.
|
||||||
|
*
|
||||||
|
* Shows which roles, inputs, outputs, and variables have already been filled
|
||||||
|
* so the user can understand what they're joining before proceeding.
|
||||||
|
* Press Enter to continue, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import {
|
||||||
|
getInvitationState,
|
||||||
|
getStateColorName,
|
||||||
|
getInvitationInputs,
|
||||||
|
getInvitationOutputs,
|
||||||
|
getInvitationVariables,
|
||||||
|
} from '../../../../../utils/invitation-utils.js';
|
||||||
|
import type { PreviewStepProps } from '../types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a semantic color name to an actual theme color value.
|
||||||
|
*/
|
||||||
|
function stateColor(state: string): string {
|
||||||
|
const name = getStateColorName(state);
|
||||||
|
switch (name) {
|
||||||
|
case 'info': return colors.info as string;
|
||||||
|
case 'warning': return colors.warning as string;
|
||||||
|
case 'success': return colors.success as string;
|
||||||
|
case 'error': return colors.error as string;
|
||||||
|
case 'muted':
|
||||||
|
default: return colors.textMuted as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewInvitationStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: PreviewStepProps): React.ReactElement {
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (key.return) onComplete();
|
||||||
|
if (key.escape) onCancel();
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const state = getInvitationState(invitation);
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
const inputs = getInvitationInputs(invitation);
|
||||||
|
const outputs = getInvitationOutputs(invitation);
|
||||||
|
const variables = getInvitationVariables(invitation);
|
||||||
|
|
||||||
|
// Collect role identifiers that appear across all commits
|
||||||
|
const filledRoles = new Set<string>();
|
||||||
|
for (const commit of invitation.data.commits ?? []) {
|
||||||
|
for (const input of commit.data?.inputs ?? []) {
|
||||||
|
if (input.roleIdentifier) filledRoles.add(input.roleIdentifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Template info */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Template:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.text}>{template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
{template?.description && (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted} dimColor>{template.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Action info */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Action:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.text}>{action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
{action?.description && (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted} dimColor>{action.description}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Status:</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text color={stateColor(state)}>{state}</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Roles already filled */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Roles Filled ({filledRoles.size}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{filledRoles.size === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
Array.from(filledRoles).map(role => {
|
||||||
|
const roleInfoRaw = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
return (
|
||||||
|
<Box key={role}>
|
||||||
|
<Text color={colors.text}> • {roleInfo?.name ?? role}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Inputs */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Inputs ({inputs.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{inputs.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
inputs.map((input, idx) => {
|
||||||
|
const inputTemplate = template?.inputs?.[input.inputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Box key={`input-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {inputTemplate?.name ?? input.inputIdentifier ?? `Input ${idx}`}
|
||||||
|
{input.roleIdentifier && ` (${input.roleIdentifier})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Outputs */}
|
||||||
|
<Box flexDirection="column" marginBottom={1} marginLeft={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Outputs ({outputs.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{outputs.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}>None yet</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
outputs.map((output, idx) => {
|
||||||
|
const outputTemplate = template?.outputs?.[output.outputIdentifier ?? ''];
|
||||||
|
return (
|
||||||
|
<Box key={`output-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {outputTemplate?.name ?? output.outputIdentifier ?? `Output ${idx}`}
|
||||||
|
{output.valueSatoshis !== undefined && ` (${formatSatoshis(output.valueSatoshis)})`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Variables */}
|
||||||
|
<Box flexDirection="column" marginBottom={1}>
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.primary} bold>Variables ({variables.length}):</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginLeft={1}>
|
||||||
|
{variables.length === 0 ? (
|
||||||
|
<Box>
|
||||||
|
<Text color={colors.textMuted}> None set</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
variables.map((variable, idx) => {
|
||||||
|
const varTemplate = template?.variables?.[variable.variableIdentifier];
|
||||||
|
const displayValue = typeof variable.value === 'bigint'
|
||||||
|
? variable.value.toString()
|
||||||
|
: String(variable.value);
|
||||||
|
return (
|
||||||
|
<Box key={`var-${idx}`}>
|
||||||
|
<Text color={colors.text}>
|
||||||
|
{' '}• {varTemplate?.name ?? variable.variableIdentifier}: {displayValue}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>Enter: Continue • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* ReviewStep — final step that summarizes the import and executes it.
|
||||||
|
*
|
||||||
|
* Displays the accumulated selections (role, inputs, amounts) and on confirmation:
|
||||||
|
* 1. Adds inputs (with the selected role identifier) to the invitation.
|
||||||
|
* 2. Optionally adds a change output if the change exceeds the dust threshold.
|
||||||
|
* 3. Calls `onComplete()` to signal the flow is finished.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors, formatSatoshis } from '../../../../theme.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { ReviewStepProps, SelectableUTXO } from '../types.js';
|
||||||
|
|
||||||
|
/** Default fee estimate in satoshis. */
|
||||||
|
const DEFAULT_FEE = 500n;
|
||||||
|
|
||||||
|
/** Dust threshold — outputs below this are unspendable. */
|
||||||
|
const DUST_THRESHOLD = 546n;
|
||||||
|
|
||||||
|
export function ReviewStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
selectedRole,
|
||||||
|
selectedInputs,
|
||||||
|
requiredAmount,
|
||||||
|
changeAmount,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: ReviewStepProps): React.ReactElement {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fee = DEFAULT_FEE;
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
// Compute totals from selected inputs
|
||||||
|
const totalSelected = selectedInputs.reduce((sum, u) => sum + u.valueSatoshis, 0n);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the import: add inputs (with role) and optional change output.
|
||||||
|
*/
|
||||||
|
const submit = useCallback(async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
}, [invitation, selectedRole, selectedInputs, onComplete]);
|
||||||
|
|
||||||
|
// Keyboard handling — gated by the import-flow layer.
|
||||||
|
useLayeredInput('import-flow', (_input, key) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
if (key.return) {
|
||||||
|
submit();
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
// Resolve role display name
|
||||||
|
const roleInfoRaw = template?.roles?.[selectedRole];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Review Import</Text>
|
||||||
|
|
||||||
|
{/* Template & action */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? invitation.data.templateIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
<Text color={colors.text}>Role: {roleInfo?.name ?? selectedRole}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Funding summary */}
|
||||||
|
<Box marginTop={1} flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Funding:</Text>
|
||||||
|
<Text color={colors.text}> • UTXOs: {selectedInputs.length}</Text>
|
||||||
|
<Text color={colors.text}> • Total: {formatSatoshis(totalSelected)}</Text>
|
||||||
|
<Text color={colors.text}> • Required: {formatSatoshis(requiredAmount)}</Text>
|
||||||
|
<Text color={colors.text}> • Fee: {formatSatoshis(fee)}</Text>
|
||||||
|
{changeAmount >= DUST_THRESHOLD && (
|
||||||
|
<Text color={colors.text}> • Change: {formatSatoshis(changeAmount)}</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.error} bold>Error: {error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status / hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<Text color={colors.info}>Submitting...</Text>
|
||||||
|
) : (
|
||||||
|
<Text color={colors.textMuted}>Enter: Confirm & Import • Esc: Cancel</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* RoleSelectStep — lets the user choose which role to take in the invitation.
|
||||||
|
*
|
||||||
|
* Displays available roles with their template-level and action-level descriptions.
|
||||||
|
* Arrow keys to navigate, Enter to select, Esc to cancel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Box, Text } from 'ink';
|
||||||
|
import { colors } from '../../../../theme.js';
|
||||||
|
import { useLayeredInput } from '../../../../hooks/useInputLayer.js';
|
||||||
|
import type { RoleSelectStepProps } from '../types.js';
|
||||||
|
|
||||||
|
export function RoleSelectStep({
|
||||||
|
invitation,
|
||||||
|
template,
|
||||||
|
availableRoles,
|
||||||
|
onComplete,
|
||||||
|
onCancel,
|
||||||
|
isActive,
|
||||||
|
}: RoleSelectStepProps): React.ReactElement {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
useLayeredInput('import-flow', (input, key) => {
|
||||||
|
if (key.upArrow || input === 'k') {
|
||||||
|
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow || input === 'j') {
|
||||||
|
setSelectedIndex(prev => Math.min(availableRoles.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
const role = availableRoles[selectedIndex];
|
||||||
|
if (role) onComplete(role);
|
||||||
|
} else if (key.escape) {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
}, { isActive });
|
||||||
|
|
||||||
|
const action = template?.actions?.[invitation.data.actionIdentifier];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Context header */}
|
||||||
|
<Box marginBottom={1} flexDirection="column">
|
||||||
|
<Text color={colors.text}>Template: {template?.name ?? 'Unknown'}</Text>
|
||||||
|
<Text color={colors.text}>Action: {action?.name ?? invitation.data.actionIdentifier}</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Role list */}
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Text color={colors.primary} bold>Available Roles:</Text>
|
||||||
|
|
||||||
|
{availableRoles.length === 0 ? (
|
||||||
|
<Text color={colors.warning}>No roles available (you may have already joined)</Text>
|
||||||
|
) : (
|
||||||
|
availableRoles.map((role, index) => {
|
||||||
|
const roleInfoRaw = template?.roles?.[role];
|
||||||
|
const roleInfo = roleInfoRaw && typeof roleInfoRaw === 'object' ? roleInfoRaw : null;
|
||||||
|
const actionRoleRaw = action?.roles?.[role];
|
||||||
|
const actionRole = actionRoleRaw && typeof actionRoleRaw === 'object' ? actionRoleRaw : null;
|
||||||
|
const isFocused = index === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={role} flexDirection="column">
|
||||||
|
<Text
|
||||||
|
color={isFocused ? colors.focus : colors.text}
|
||||||
|
bold={isFocused}
|
||||||
|
>
|
||||||
|
{isFocused ? '▸ ' : ' '}
|
||||||
|
{roleInfo?.name ?? role}
|
||||||
|
</Text>
|
||||||
|
{(roleInfo?.description || actionRole?.description) && (
|
||||||
|
<Text color={colors.textMuted} dimColor>
|
||||||
|
{' '}{actionRole?.description ?? roleInfo?.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Navigation hint */}
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color={colors.textMuted}>↑↓: Select role • Enter: Accept • Esc: Cancel</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/tui/screens/invitations/invitation-import/types.ts
Normal file
127
src/tui/screens/invitations/invitation-import/types.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for the invitation import flow.
|
||||||
|
*
|
||||||
|
* Each step in the flow receives only what it needs via props (dependency injection).
|
||||||
|
* The flow controller (`InvitationImportFlow`) accumulates data and passes it forward.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Invitation } from "../../../../services/invitation.js";
|
||||||
|
import type { AppService } from "../../../../services/app.js";
|
||||||
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
|
// ── Step definitions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Identifies each step in the import flow. */
|
||||||
|
export type ImportStepType =
|
||||||
|
| "fetch"
|
||||||
|
| "preview"
|
||||||
|
| "role-select"
|
||||||
|
| "inputs-select"
|
||||||
|
| "review";
|
||||||
|
|
||||||
|
/** A single step descriptor used by the flow controller and step indicator. */
|
||||||
|
export interface ImportStep {
|
||||||
|
name: string;
|
||||||
|
type: ImportStepType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ordered list of steps in the import flow. */
|
||||||
|
export const IMPORT_STEPS: ImportStep[] = [
|
||||||
|
{ name: "Fetch", type: "fetch" },
|
||||||
|
{ name: "Preview", type: "preview" },
|
||||||
|
{ name: "Select Role", type: "role-select" },
|
||||||
|
{ name: "Select Inputs", type: "inputs-select" },
|
||||||
|
{ name: "Review", type: "review" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Display mode ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Controls whether the import flow renders as a dialog overlay or a full screen. */
|
||||||
|
export type ImportFlowMode = "dialog" | "screen";
|
||||||
|
|
||||||
|
// ── UTXO selection ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A UTXO that the user can toggle on/off during the inputs step. */
|
||||||
|
export interface SelectableUTXO {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step props ───────────────────────────────────────────────────────────────
|
||||||
|
// Each step receives exactly the data and callbacks it needs.
|
||||||
|
|
||||||
|
/** Props for FetchInvitationStep — loads the invitation from an ID. */
|
||||||
|
export interface FetchStepProps {
|
||||||
|
invitationId: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (invitation: Invitation, template: XOTemplate | null) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for PreviewInvitationStep — displays invitation state. */
|
||||||
|
export interface PreviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for RoleSelectStep — lets user pick a role. */
|
||||||
|
export interface RoleSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
availableRoles: string[];
|
||||||
|
onComplete: (selectedRole: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for InputsSelectStep — lets user pick UTXOs to fund the invitation. */
|
||||||
|
export interface InputsSelectStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: (inputs: SelectableUTXO[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props for ReviewStep — summarizes and executes the import. */
|
||||||
|
export interface ReviewStepProps {
|
||||||
|
invitation: Invitation;
|
||||||
|
template: XOTemplate | null;
|
||||||
|
selectedRole: string;
|
||||||
|
selectedInputs: SelectableUTXO[];
|
||||||
|
changeAmount: bigint;
|
||||||
|
requiredAmount: bigint;
|
||||||
|
appService: AppService;
|
||||||
|
onComplete: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Flow controller props ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Props for the top-level InvitationImportFlow component. */
|
||||||
|
export interface ImportFlowProps {
|
||||||
|
/** The invitation ID to import (already entered by the user in InvitationScreen). */
|
||||||
|
invitationId: string;
|
||||||
|
/** Whether to render as a dialog overlay or a full screen. */
|
||||||
|
mode: ImportFlowMode;
|
||||||
|
/** The application service — injected, not pulled from context. */
|
||||||
|
appService: AppService;
|
||||||
|
/** Called when the flow completes or is cancelled. */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Display an error message to the user. */
|
||||||
|
showError: (message: string) => void;
|
||||||
|
/** Display an info message to the user. */
|
||||||
|
showInfo: (message: string) => void;
|
||||||
|
/** Update the global status bar. */
|
||||||
|
setStatus: (message: string) => void;
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
* Defines colors, styles, and visual constants used throughout the application.
|
* Defines colors, styles, and visual constants used throughout the application.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TextProps } from 'ink';
|
import type { TextProps } from "ink";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color type - supports Ink color names.
|
* Color type - supports Ink color names.
|
||||||
*/
|
*/
|
||||||
export type Color = TextProps['color'];
|
export type Color = TextProps["color"];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color palette for the application.
|
* Color palette for the application.
|
||||||
@@ -16,33 +16,33 @@ export type Color = TextProps['color'];
|
|||||||
*/
|
*/
|
||||||
export const colors = {
|
export const colors = {
|
||||||
// Primary colors
|
// Primary colors
|
||||||
primary: 'cyan' as Color,
|
primary: "cyan" as Color,
|
||||||
secondary: 'blue' as Color,
|
secondary: "blue" as Color,
|
||||||
accent: 'magenta' as Color,
|
accent: "magenta" as Color,
|
||||||
|
|
||||||
// Status colors
|
// Status colors
|
||||||
success: 'green' as Color,
|
success: "green" as Color,
|
||||||
warning: 'yellow' as Color,
|
warning: "yellow" as Color,
|
||||||
error: 'red' as Color,
|
error: "red" as Color,
|
||||||
info: 'cyan' as Color,
|
info: "cyan" as Color,
|
||||||
|
|
||||||
// Text colors
|
// Text colors
|
||||||
text: 'white' as Color,
|
text: "white" as Color,
|
||||||
textMuted: 'gray' as Color,
|
textMuted: "gray" as Color,
|
||||||
textHighlight: 'whiteBright' as Color,
|
textHighlight: "whiteBright" as Color,
|
||||||
|
|
||||||
// Background colors
|
// Background colors
|
||||||
bg: 'black' as Color,
|
bg: "black" as Color,
|
||||||
bgSelected: 'blue' as Color,
|
bgSelected: "blue" as Color,
|
||||||
bgHover: 'gray' as Color,
|
bgHover: "gray" as Color,
|
||||||
|
|
||||||
// Border colors
|
// Border colors
|
||||||
border: 'cyan' as Color,
|
border: "cyan" as Color,
|
||||||
borderFocused: 'yellowBright' as Color,
|
borderFocused: "yellowBright" as Color,
|
||||||
borderMuted: 'gray' as Color,
|
borderMuted: "gray" as Color,
|
||||||
|
|
||||||
// Focus highlight color (very visible)
|
// Focus highlight color (very visible)
|
||||||
focus: 'yellowBright' as Color,
|
focus: "yellowBright" as Color,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +76,7 @@ export const logo = `
|
|||||||
/**
|
/**
|
||||||
* Small logo for status bar.
|
* Small logo for status bar.
|
||||||
*/
|
*/
|
||||||
export const logoSmall = 'XO Wallet';
|
export const logoSmall = "XO Wallet";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to format satoshis for display.
|
* Helper to format satoshis for display.
|
||||||
@@ -84,7 +84,7 @@ export const logoSmall = 'XO Wallet';
|
|||||||
* @returns Formatted string with BCH amount
|
* @returns Formatted string with BCH amount
|
||||||
*/
|
*/
|
||||||
export function formatSatoshis(satoshis: bigint | number): string {
|
export function formatSatoshis(satoshis: bigint | number): string {
|
||||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
const value = typeof satoshis === "bigint" ? satoshis : BigInt(satoshis);
|
||||||
const bch = Number(value) / 100_000_000;
|
const bch = Number(value) / 100_000_000;
|
||||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ export function formatSatoshis(satoshis: bigint | number): string {
|
|||||||
*/
|
*/
|
||||||
export function truncate(str: string, maxLength: number): string {
|
export function truncate(str: string, maxLength: number): string {
|
||||||
if (str.length <= maxLength) return str;
|
if (str.length <= maxLength) return str;
|
||||||
return str.slice(0, maxLength - 3) + '...';
|
return str.slice(0, maxLength - 3) + "...";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
* Shared types for the CLI TUI.
|
* Shared types for the CLI TUI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AppService } from '../services/app.js';
|
import type { AppService } from "../services/app.js";
|
||||||
import type { AppConfig } from '../app.js';
|
import type { AppConfig } from "../app.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Screen names for navigation.
|
* Screen names for navigation.
|
||||||
*/
|
*/
|
||||||
export type ScreenName =
|
export type ScreenName =
|
||||||
| 'seed-input'
|
| "seed-input"
|
||||||
| 'wallet'
|
| "wallet"
|
||||||
| 'templates'
|
| "templates"
|
||||||
| 'wizard'
|
| "wizard"
|
||||||
| 'invitations'
|
| "invitations"
|
||||||
| 'transaction';
|
| "transaction";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation context data that can be passed between screens.
|
* Navigation context data that can be passed between screens.
|
||||||
@@ -81,7 +81,7 @@ export interface DialogState {
|
|||||||
/** Whether dialog is visible */
|
/** Whether dialog is visible */
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
/** Dialog type */
|
/** Dialog type */
|
||||||
type: 'error' | 'info' | 'confirm';
|
type: "error" | "info" | "confirm";
|
||||||
/** Dialog message */
|
/** Dialog message */
|
||||||
message: string;
|
message: string;
|
||||||
/** Callback for confirm dialog */
|
/** Callback for confirm dialog */
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
* Cross-platform clipboard utility with multiple fallback methods.
|
* Cross-platform clipboard utility with multiple fallback methods.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec } from 'child_process';
|
import clipboardy from "clipboardy";
|
||||||
import { promisify } from 'util';
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
@@ -22,24 +23,28 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||||||
|
|
||||||
// Try native commands first - they're more reliable
|
// Try native commands first - they're more reliable
|
||||||
try {
|
try {
|
||||||
if (platform === 'darwin') {
|
if (platform === "darwin") {
|
||||||
// macOS - use pbcopy directly
|
// macOS - use pbcopy directly
|
||||||
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
await execAsync(`printf '%s' '${escapedText}' | pbcopy`);
|
||||||
return;
|
return;
|
||||||
} else if (platform === 'linux') {
|
} else if (platform === "linux") {
|
||||||
// Linux - try xclip, then xsel
|
// Linux - try xclip, then xsel
|
||||||
try {
|
try {
|
||||||
await execAsync(`printf '%s' '${escapedText}' | xclip -selection clipboard`);
|
await execAsync(
|
||||||
|
`printf '%s' '${escapedText}' | xclip -selection clipboard`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await execAsync(`printf '%s' '${escapedText}' | xsel --clipboard --input`);
|
await execAsync(
|
||||||
|
`printf '%s' '${escapedText}' | xsel --clipboard --input`,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// Fall through to clipboardy
|
// Fall through to clipboardy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (platform === 'win32') {
|
} else if (platform === "win32") {
|
||||||
// Windows - use clip.exe
|
// Windows - use clip.exe
|
||||||
await execAsync(`echo|set /p="${text}" | clip`);
|
await execAsync(`echo|set /p="${text}" | clip`);
|
||||||
return;
|
return;
|
||||||
@@ -50,8 +55,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|||||||
|
|
||||||
// Fallback to clipboardy
|
// Fallback to clipboardy
|
||||||
try {
|
try {
|
||||||
const clipboard = await import('clipboardy');
|
clipboardy.writeSync(text);
|
||||||
await clipboard.default.write(text);
|
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// clipboardy also failed
|
// clipboardy also failed
|
||||||
|
|||||||
170
src/utils/bch-mnemonic-url.ts
Normal file
170
src/utils/bch-mnemonic-url.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/*
|
||||||
|
* Handles BCH Mnemonic parsing to/from URL form.
|
||||||
|
* Pulled directly from the old stack package.
|
||||||
|
*/
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export type BCHMnemonicURLRaw = {
|
||||||
|
entropy: Uint8Array;
|
||||||
|
passphrase?: string;
|
||||||
|
language?: (typeof BCHMnemonicURL.SUPPORTED_LANGUAGES)[number];
|
||||||
|
comment?: string;
|
||||||
|
path?: string;
|
||||||
|
startHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles BCHMnemonic URLs
|
||||||
|
*/
|
||||||
|
export class BCHMnemonicURL {
|
||||||
|
static PROTOCOL = "bch-mnemonic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is a valid wallet backup URL
|
||||||
|
*
|
||||||
|
* @param url The URL to check
|
||||||
|
* @returns True if the URL is a valid wallet backup URL, false otherwise
|
||||||
|
*/
|
||||||
|
public static canHandle(urlStr: string): boolean {
|
||||||
|
try {
|
||||||
|
BCHMnemonicURL.fromURL(urlStr);
|
||||||
|
return true;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a BCHMnemonic from a URL-encoded string
|
||||||
|
* @param urlStr - The URL-encoded mnemonic string
|
||||||
|
* @returns A new BCHMnemonic instance
|
||||||
|
* @throws Error if the URL format is invalid or entropy is invalid
|
||||||
|
*/
|
||||||
|
static fromURL(urlStr: string): BCHMnemonicURL {
|
||||||
|
const url = new URL(urlStr);
|
||||||
|
|
||||||
|
if (url.protocol !== `${BCHMnemonicURL.PROTOCOL}:`) {
|
||||||
|
throw new Error(`Invalid URL protocol: ${url.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the entropy.
|
||||||
|
const entropy = new Uint8Array(Buffer.from(url.pathname, "base64"));
|
||||||
|
|
||||||
|
// Pick out our encoding keys from the URL
|
||||||
|
const params = BCHMnemonicURL.schema.parse(
|
||||||
|
Object.fromEntries(url.searchParams.entries()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and return the backup with validated parameters
|
||||||
|
return BCHMnemonicURL.fromRaw({
|
||||||
|
entropy,
|
||||||
|
language: params[BCHMnemonicURL.ENCODING_KEYS.language],
|
||||||
|
comment: params[BCHMnemonicURL.ENCODING_KEYS.comment],
|
||||||
|
path: params[BCHMnemonicURL.ENCODING_KEYS.path],
|
||||||
|
startHeight: params[BCHMnemonicURL.ENCODING_KEYS.startHeight],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new WalletBackup from a raw object
|
||||||
|
*
|
||||||
|
* @param raw - The raw object to create the WalletBackup from
|
||||||
|
* @returns The created WalletBackup
|
||||||
|
*/
|
||||||
|
static fromRaw(raw: BCHMnemonicURLRaw): BCHMnemonicURL {
|
||||||
|
// Add entropy validation
|
||||||
|
if (!raw.entropy || raw.entropy.length === 0) {
|
||||||
|
throw new Error("Invalid entropy: must be non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate entropy length (typically 16, 20, 24, 28, or 32 bytes for BIP39)
|
||||||
|
const validLengths = [16, 20, 24, 28, 32];
|
||||||
|
if (!validLengths.includes(raw.entropy.length)) {
|
||||||
|
throw new Error(`Invalid entropy length: ${raw.entropy.length} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BCHMnemonicURL(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(protected raw: BCHMnemonicURLRaw) {}
|
||||||
|
|
||||||
|
toObject() {
|
||||||
|
return this.raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the backup into a URL encoding
|
||||||
|
*
|
||||||
|
* @param prefix - The prefix to use for the URL encoding
|
||||||
|
* @returns The URL encoding of the backup
|
||||||
|
*/
|
||||||
|
toURL(): string {
|
||||||
|
// Conver the mnemonic words into the entropy used to derive the mnemonic words
|
||||||
|
const entropyBase64 = Buffer.from(this.raw.entropy).toString("base64");
|
||||||
|
|
||||||
|
// Create a new URL object with the prefix and the base64 encoded mnemonic
|
||||||
|
const url = new URL(`${BCHMnemonicURL.PROTOCOL}:${entropyBase64}`);
|
||||||
|
|
||||||
|
// Add the raw values to the url encoded string. Only add the values that are defined.
|
||||||
|
if (this.raw.language !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.language,
|
||||||
|
this.raw.language,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.comment !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.comment,
|
||||||
|
this.raw.comment,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.path !== undefined) {
|
||||||
|
url.searchParams.set(BCHMnemonicURL.ENCODING_KEYS.path, this.raw.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.raw.startHeight !== undefined) {
|
||||||
|
url.searchParams.set(
|
||||||
|
BCHMnemonicURL.ENCODING_KEYS.startHeight,
|
||||||
|
this.raw.startHeight.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
static ENCODING_KEYS = {
|
||||||
|
language: "l",
|
||||||
|
passphrase: "p",
|
||||||
|
comment: "c",
|
||||||
|
path: "d",
|
||||||
|
startHeight: "h",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
static SUPPORTED_LANGUAGES = [
|
||||||
|
"en",
|
||||||
|
"zh-CN",
|
||||||
|
"zh-TW",
|
||||||
|
"ja",
|
||||||
|
"es",
|
||||||
|
"pt",
|
||||||
|
"ko",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"cs",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod schema for validating URL parameters
|
||||||
|
*/
|
||||||
|
static schema = z.object({
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.language]: z
|
||||||
|
.enum(BCHMnemonicURL.SUPPORTED_LANGUAGES)
|
||||||
|
.optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.passphrase]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.comment]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.path]: z.string().optional(),
|
||||||
|
[BCHMnemonicURL.ENCODING_KEYS.startHeight]: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ export class ExponentialBackoff {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
onError = (_error: Error) => {},
|
onError = (_error: Error) => {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
let lastError: Error = new Error('Exponential backoff: Max retries hit');
|
let lastError: Error = new Error("Exponential backoff: Max retries hit");
|
||||||
|
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* See: https://github.com/bitauth/libauth/pull/108
|
* See: https://github.com/bitauth/libauth/pull/108
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { binToHex, hexToBin } from '@bitauth/libauth';
|
import { binToHex, hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
* Replaces BigInt and Uint8Array values with their ExtJSON string representations.
|
||||||
@@ -15,7 +15,7 @@ import { binToHex, hexToBin } from '@bitauth/libauth';
|
|||||||
* @returns The replaced value as an ExtJSON string, or the original value
|
* @returns The replaced value as an ExtJSON string, or the original value
|
||||||
*/
|
*/
|
||||||
export const extendedJsonReplacer = function (value: unknown): unknown {
|
export const extendedJsonReplacer = function (value: unknown): unknown {
|
||||||
if (typeof value === 'bigint') {
|
if (typeof value === "bigint") {
|
||||||
return `<bigint: ${value.toString()}n>`;
|
return `<bigint: ${value.toString()}n>`;
|
||||||
} else if (value instanceof Uint8Array) {
|
} else if (value instanceof Uint8Array) {
|
||||||
return `<Uint8Array: ${binToHex(value)}>`;
|
return `<Uint8Array: ${binToHex(value)}>`;
|
||||||
@@ -36,7 +36,7 @@ export const extendedJsonReviver = function (value: unknown): unknown {
|
|||||||
|
|
||||||
// Only perform a check if the value is a string.
|
// Only perform a check if the value is a string.
|
||||||
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
// NOTE: We can skip all other values as all Extended JSON encoded fields WILL be a string.
|
||||||
if (typeof value === 'string') {
|
if (typeof value === "string") {
|
||||||
// Check if this value matches an Extended JSON encoded bigint.
|
// Check if this value matches an Extended JSON encoded bigint.
|
||||||
const bigintMatch = value.match(bigIntRegex);
|
const bigintMatch = value.match(bigIntRegex);
|
||||||
if (bigintMatch) {
|
if (bigintMatch) {
|
||||||
@@ -70,7 +70,7 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
// If this is an object type (and it is not null - which is technically an "object")...
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
if (
|
if (
|
||||||
typeof value === 'object' &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
!ArrayBuffer.isView(value)
|
!ArrayBuffer.isView(value)
|
||||||
) {
|
) {
|
||||||
@@ -83,7 +83,9 @@ export const encodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
const encodedObject: Record<string, unknown> = {};
|
const encodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
// Iterate through each entry and encode it to extended JSON.
|
// Iterate through each entry and encode it to extended JSON.
|
||||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, valueToEncode] of Object.entries(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
encodedObject[key] = encodeExtendedJsonObject(valueToEncode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
// If this is an object type (and it is not null - which is technically an "object")...
|
// If this is an object type (and it is not null - which is technically an "object")...
|
||||||
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
// ... and it is not an ArrayBuffer (e.g. Uint8Array) which is also technically an "object...
|
||||||
if (
|
if (
|
||||||
typeof value === 'object' &&
|
typeof value === "object" &&
|
||||||
value !== null &&
|
value !== null &&
|
||||||
!ArrayBuffer.isView(value)
|
!ArrayBuffer.isView(value)
|
||||||
) {
|
) {
|
||||||
@@ -117,7 +119,9 @@ export const decodeExtendedJsonObject = function (value: unknown): unknown {
|
|||||||
const decodedObject: Record<string, unknown> = {};
|
const decodedObject: Record<string, unknown> = {};
|
||||||
|
|
||||||
// Iterate through each entry and decode it from extended JSON.
|
// Iterate through each entry and decode it from extended JSON.
|
||||||
for (const [key, valueToEncode] of Object.entries(value as Record<string, unknown>)) {
|
for (const [key, valueToEncode] of Object.entries(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
decodedObject[key] = decodeExtendedJsonObject(valueToEncode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,259 +1,120 @@
|
|||||||
/**
|
import type {
|
||||||
* History utility functions.
|
HistoryItem,
|
||||||
*
|
HistoryInvitationItem,
|
||||||
* Pure functions for parsing and formatting wallet history data.
|
HistoryUtxoItem,
|
||||||
* These functions have no React dependencies and can be used
|
} from "../services/history.js";
|
||||||
* in both TUI and CLI contexts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { HistoryItem, HistoryItemType } from '../services/history.js';
|
export type HistoryColorName =
|
||||||
|
| "info"
|
||||||
|
| "warning"
|
||||||
|
| "success"
|
||||||
|
| "error"
|
||||||
|
| "muted"
|
||||||
|
| "text";
|
||||||
|
|
||||||
/**
|
export type HistoryRowType =
|
||||||
* Color names for history item types.
|
| "invitation"
|
||||||
* These are semantic color names that can be mapped to actual colors
|
| "invitation_input"
|
||||||
* by the consuming application (TUI or CLI).
|
| "invitation_output"
|
||||||
*/
|
| "utxo";
|
||||||
export type HistoryColorName = 'info' | 'warning' | 'success' | 'error' | 'muted' | 'text';
|
|
||||||
|
|
||||||
/**
|
export interface HistoryDisplayRow {
|
||||||
* Formatted history list item data.
|
id: string;
|
||||||
*/
|
type: HistoryRowType;
|
||||||
export interface FormattedHistoryItem {
|
|
||||||
/** The display label for the history item */
|
|
||||||
label: string;
|
label: string;
|
||||||
/** Optional secondary description */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** The formatted date string */
|
timestamp?: number;
|
||||||
dateStr?: string;
|
isNested: boolean;
|
||||||
/** The semantic color name for this item type */
|
utxo?: HistoryUtxoItem;
|
||||||
color: HistoryColorName;
|
invitation?: HistoryInvitationItem;
|
||||||
/** The history item type */
|
|
||||||
type: HistoryItemType;
|
|
||||||
/** Whether the item data is valid */
|
|
||||||
isValid: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the semantic color name for a history item type.
|
|
||||||
*
|
|
||||||
* @param type - The history item type
|
|
||||||
* @param isSelected - Whether the item is currently selected
|
|
||||||
* @returns A semantic color name
|
|
||||||
*/
|
|
||||||
export function getHistoryItemColorName(type: HistoryItemType, isSelected: boolean = false): HistoryColorName {
|
|
||||||
if (isSelected) return 'info'; // Use focus color when selected
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return 'text';
|
|
||||||
case 'utxo_reserved':
|
|
||||||
return 'warning';
|
|
||||||
case 'utxo_received':
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a satoshi value for display.
|
|
||||||
*
|
|
||||||
* @param satoshis - The value in satoshis
|
|
||||||
* @returns Formatted string with BCH amount
|
|
||||||
*/
|
|
||||||
export function formatSatoshisValue(satoshis: bigint | number): string {
|
|
||||||
const value = typeof satoshis === 'bigint' ? satoshis : BigInt(satoshis);
|
|
||||||
const bch = Number(value) / 100_000_000;
|
|
||||||
return `${bch.toFixed(8)} BCH (${value.toLocaleString()} sats)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a timestamp for display.
|
|
||||||
*
|
|
||||||
* @param timestamp - Unix timestamp in milliseconds
|
|
||||||
* @returns Formatted date string or undefined
|
|
||||||
*/
|
|
||||||
export function formatHistoryDate(timestamp?: number): string | undefined {
|
export function formatHistoryDate(timestamp?: number): string | undefined {
|
||||||
if (!timestamp) return undefined;
|
if (!timestamp) return undefined;
|
||||||
return new Date(timestamp).toLocaleDateString();
|
return new Date(timestamp).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function buildHistoryDisplayRows(
|
||||||
* Format a history item for display in a list.
|
|
||||||
*
|
|
||||||
* @param item - The history item to format
|
|
||||||
* @param isSelected - Whether the item is currently selected
|
|
||||||
* @returns Formatted item data for display
|
|
||||||
*/
|
|
||||||
export function formatHistoryListItem(
|
|
||||||
item: HistoryItem | null | undefined,
|
|
||||||
isSelected: boolean = false
|
|
||||||
): FormattedHistoryItem {
|
|
||||||
if (!item) {
|
|
||||||
return {
|
|
||||||
label: '',
|
|
||||||
description: undefined,
|
|
||||||
dateStr: undefined,
|
|
||||||
color: 'muted',
|
|
||||||
type: 'utxo_received',
|
|
||||||
isValid: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateStr = formatHistoryDate(item.timestamp);
|
|
||||||
const color = getHistoryItemColorName(item.type, isSelected);
|
|
||||||
|
|
||||||
switch (item.type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return {
|
|
||||||
label: `[Invitation] ${item.description}`,
|
|
||||||
description: undefined,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
case 'utxo_reserved': {
|
|
||||||
const satsStr = item.valueSatoshis !== undefined
|
|
||||||
? formatSatoshisValue(item.valueSatoshis)
|
|
||||||
: 'Unknown amount';
|
|
||||||
return {
|
|
||||||
label: `[Reserved] ${satsStr}`,
|
|
||||||
description: item.description,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'utxo_received': {
|
|
||||||
const satsStr = item.valueSatoshis !== undefined
|
|
||||||
? formatSatoshisValue(item.valueSatoshis)
|
|
||||||
: 'Unknown amount';
|
|
||||||
const reservedTag = item.reserved ? ' [Reserved]' : '';
|
|
||||||
return {
|
|
||||||
label: satsStr,
|
|
||||||
description: `${item.description}${reservedTag}`,
|
|
||||||
dateStr,
|
|
||||||
color,
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: `${item.type}: ${item.description}`,
|
|
||||||
description: undefined,
|
|
||||||
dateStr,
|
|
||||||
color: 'text',
|
|
||||||
type: item.type,
|
|
||||||
isValid: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a type label for display.
|
|
||||||
*
|
|
||||||
* @param type - The history item type
|
|
||||||
* @returns Human-readable type label
|
|
||||||
*/
|
|
||||||
export function getHistoryTypeLabel(type: HistoryItemType): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'invitation_created':
|
|
||||||
return 'Invitation';
|
|
||||||
case 'utxo_reserved':
|
|
||||||
return 'Reserved';
|
|
||||||
case 'utxo_received':
|
|
||||||
return 'Received';
|
|
||||||
default:
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate scrolling window indices for a list.
|
|
||||||
*
|
|
||||||
* @param selectedIndex - Currently selected index
|
|
||||||
* @param totalItems - Total number of items
|
|
||||||
* @param maxVisible - Maximum visible items
|
|
||||||
* @returns Start and end indices for the visible window
|
|
||||||
*/
|
|
||||||
export function calculateScrollWindow(
|
|
||||||
selectedIndex: number,
|
|
||||||
totalItems: number,
|
|
||||||
maxVisible: number
|
|
||||||
): { startIndex: number; endIndex: number } {
|
|
||||||
const halfWindow = Math.floor(maxVisible / 2);
|
|
||||||
let startIndex = Math.max(0, selectedIndex - halfWindow);
|
|
||||||
const endIndex = Math.min(totalItems, startIndex + maxVisible);
|
|
||||||
|
|
||||||
// Adjust start if we're near the end
|
|
||||||
if (endIndex - startIndex < maxVisible) {
|
|
||||||
startIndex = Math.max(0, endIndex - maxVisible);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startIndex, endIndex };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a history item is a UTXO-related event.
|
|
||||||
*
|
|
||||||
* @param item - The history item to check
|
|
||||||
* @returns True if the item is UTXO-related
|
|
||||||
*/
|
|
||||||
export function isUtxoEvent(item: HistoryItem): boolean {
|
|
||||||
return item.type === 'utxo_received' || item.type === 'utxo_reserved';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter history items by type.
|
|
||||||
*
|
|
||||||
* @param items - Array of history items
|
|
||||||
* @param types - Types to include
|
|
||||||
* @returns Filtered array
|
|
||||||
*/
|
|
||||||
export function filterHistoryByType(
|
|
||||||
items: HistoryItem[],
|
items: HistoryItem[],
|
||||||
types: HistoryItemType[]
|
): HistoryDisplayRow[] {
|
||||||
): HistoryItem[] {
|
const rows: HistoryDisplayRow[] = [];
|
||||||
return items.filter(item => types.includes(item.type));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get summary statistics for history items.
|
|
||||||
*
|
|
||||||
* @param items - Array of history items
|
|
||||||
* @returns Summary statistics
|
|
||||||
*/
|
|
||||||
export function getHistorySummary(items: HistoryItem[]): {
|
|
||||||
totalReceived: bigint;
|
|
||||||
totalReserved: bigint;
|
|
||||||
invitationCount: number;
|
|
||||||
utxoCount: number;
|
|
||||||
} {
|
|
||||||
let totalReceived = 0n;
|
|
||||||
let totalReserved = 0n;
|
|
||||||
let invitationCount = 0;
|
|
||||||
let utxoCount = 0;
|
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
switch (item.type) {
|
if (item.kind === "invitation") {
|
||||||
case 'invitation_created':
|
rows.push({
|
||||||
invitationCount++;
|
id: item.id,
|
||||||
break;
|
type: "invitation",
|
||||||
case 'utxo_reserved':
|
label: item.description,
|
||||||
totalReserved += item.valueSatoshis ?? 0n;
|
timestamp: item.createdAtTimestamp,
|
||||||
break;
|
isNested: false,
|
||||||
case 'utxo_received':
|
invitation: item,
|
||||||
totalReceived += item.valueSatoshis ?? 0n;
|
});
|
||||||
utxoCount++;
|
|
||||||
break;
|
for (const input of item.inputs) {
|
||||||
|
const satsPrefix =
|
||||||
|
input.valueSatoshis !== undefined
|
||||||
|
? `${input.valueSatoshis.toLocaleString()} sats `
|
||||||
|
: "";
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-input-${input.id}`,
|
||||||
|
type: "invitation_input",
|
||||||
|
label: `${satsPrefix}${input.outpoint.txid}:${input.outpoint.index}`,
|
||||||
|
description: input.description,
|
||||||
|
isNested: true,
|
||||||
|
utxo: input,
|
||||||
|
invitation: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const output of item.outputs) {
|
||||||
|
rows.push({
|
||||||
|
id: `${item.id}-output-${output.id}`,
|
||||||
|
type: "invitation_output",
|
||||||
|
label:
|
||||||
|
output.valueSatoshis !== undefined
|
||||||
|
? `${output.valueSatoshis.toLocaleString()} sats`
|
||||||
|
: "Output",
|
||||||
|
description: output.description,
|
||||||
|
isNested: true,
|
||||||
|
utxo: output,
|
||||||
|
invitation: item,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
id: item.id,
|
||||||
|
type: "utxo",
|
||||||
|
label:
|
||||||
|
item.valueSatoshis !== undefined
|
||||||
|
? `${item.valueSatoshis.toLocaleString()} sats`
|
||||||
|
: "UTXO",
|
||||||
|
description: item.description,
|
||||||
|
isNested: false,
|
||||||
|
utxo: item,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { totalReceived, totalReserved, invitationCount, utxoCount };
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHistoryItemColorName(
|
||||||
|
row: HistoryDisplayRow,
|
||||||
|
isSelected: boolean = false,
|
||||||
|
): HistoryColorName {
|
||||||
|
if (isSelected) return "info";
|
||||||
|
switch (row.type) {
|
||||||
|
case "invitation":
|
||||||
|
return "text";
|
||||||
|
case "invitation_input":
|
||||||
|
return "error";
|
||||||
|
case "invitation_output":
|
||||||
|
return "success";
|
||||||
|
case "utxo":
|
||||||
|
return row.utxo?.reserved ? "warning" : "success";
|
||||||
|
default:
|
||||||
|
return "text";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/utils/invitation-flow.ts
Normal file
177
src/utils/invitation-flow.ts
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import type { XOTemplate, XOTemplateTransactionOutput } from "@xo-cash/types";
|
||||||
|
import type { Invitation } from "../services/invitation.js";
|
||||||
|
|
||||||
|
export interface SelectableUtxoLike {
|
||||||
|
outpointTransactionHash: string;
|
||||||
|
outpointIndex: number;
|
||||||
|
valueSatoshis: bigint;
|
||||||
|
lockingBytecode?: string;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasMissingRequirements = (missingRequirements: {
|
||||||
|
variables?: string[];
|
||||||
|
inputs?: string[];
|
||||||
|
outputs?: string[];
|
||||||
|
roles?: Record<string, unknown>;
|
||||||
|
}): boolean => {
|
||||||
|
return (
|
||||||
|
(missingRequirements.variables?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.inputs?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.outputs?.length ?? 0) > 0 ||
|
||||||
|
(missingRequirements.roles !== undefined &&
|
||||||
|
Object.keys(missingRequirements.roles).length > 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isInvitationRequirementsComplete = async (
|
||||||
|
invitation: Invitation,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const missingRequirements = await invitation.getMissingRequirements();
|
||||||
|
return !hasMissingRequirements(missingRequirements);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveActionRoles = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
rolesFromNavigation?: string[],
|
||||||
|
): string[] => {
|
||||||
|
if (rolesFromNavigation && rolesFromNavigation.length > 0) {
|
||||||
|
return [...new Set(rolesFromNavigation)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template || !actionIdentifier) return [];
|
||||||
|
const starts = template.start ?? [];
|
||||||
|
const roleIds = starts
|
||||||
|
.filter((entry) => entry.action === actionIdentifier)
|
||||||
|
.map((entry) => entry.role);
|
||||||
|
|
||||||
|
return [...new Set(roleIds)];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleRequiresInputs = (
|
||||||
|
template: XOTemplate | undefined,
|
||||||
|
actionIdentifier: string | undefined,
|
||||||
|
roleIdentifier: string | undefined,
|
||||||
|
): boolean => {
|
||||||
|
if (!template || !actionIdentifier || !roleIdentifier) return false;
|
||||||
|
const action = template.actions?.[actionIdentifier];
|
||||||
|
if (!action) return false;
|
||||||
|
|
||||||
|
const actionRole = action.roles?.[roleIdentifier];
|
||||||
|
const roleSlotsMin = actionRole?.requirements?.slots?.min ?? 0;
|
||||||
|
if (roleSlotsMin > 0) return true;
|
||||||
|
|
||||||
|
// Some templates specify slot/input requirements at action.requirements.roles
|
||||||
|
// instead of role.requirements. Respect those as well.
|
||||||
|
const roleRequirement = action.requirements?.roles?.find(
|
||||||
|
(requirement) => requirement.role === roleIdentifier,
|
||||||
|
);
|
||||||
|
const actionLevelSlotsMin = roleRequirement?.slots?.min ?? 0;
|
||||||
|
if (actionLevelSlotsMin > 0) return true;
|
||||||
|
|
||||||
|
const transactionIdentifier = action.transaction;
|
||||||
|
const transaction = transactionIdentifier
|
||||||
|
? template.transactions?.[transactionIdentifier]
|
||||||
|
: undefined;
|
||||||
|
const roleInputs = transaction?.roles?.[roleIdentifier]?.inputs;
|
||||||
|
|
||||||
|
return (roleInputs?.length ?? 0) > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTransactionOutputIdentifier = (
|
||||||
|
output: XOTemplateTransactionOutput,
|
||||||
|
): string | undefined => {
|
||||||
|
if (typeof output === "string") return output;
|
||||||
|
if (
|
||||||
|
output &&
|
||||||
|
typeof output === "object" &&
|
||||||
|
"output" in output &&
|
||||||
|
typeof output.output === "string"
|
||||||
|
) {
|
||||||
|
return output.output;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeLockingBytecodeHex = (value: string): string =>
|
||||||
|
value.trim().replace(/^0x/i, "");
|
||||||
|
|
||||||
|
export const resolveProvidedLockingBytecodeHex = (
|
||||||
|
template: XOTemplate,
|
||||||
|
outputIdentifier: string,
|
||||||
|
variableValues: Record<string, string>,
|
||||||
|
): string | undefined => {
|
||||||
|
const outputDefinition = template.outputs?.[outputIdentifier];
|
||||||
|
if (!outputDefinition || typeof outputDefinition.lockscript !== "string")
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const lockingScriptDefinition = (
|
||||||
|
template.lockingScripts as Record<string, unknown> | undefined
|
||||||
|
)?.[outputDefinition.lockscript] as { lockingScript?: string } | undefined;
|
||||||
|
const scriptIdentifier = lockingScriptDefinition?.lockingScript;
|
||||||
|
if (!scriptIdentifier) return undefined;
|
||||||
|
|
||||||
|
const scriptExpression = (
|
||||||
|
template.scripts as Record<string, unknown> | undefined
|
||||||
|
)?.[scriptIdentifier];
|
||||||
|
if (typeof scriptExpression !== "string") return undefined;
|
||||||
|
|
||||||
|
const directVariableMatch = scriptExpression.match(
|
||||||
|
/^<\s*([A-Za-z0-9_]+)\s*>$/,
|
||||||
|
);
|
||||||
|
if (!directVariableMatch) return undefined;
|
||||||
|
|
||||||
|
const variableIdentifier = directVariableMatch[1];
|
||||||
|
if (!variableIdentifier) return undefined;
|
||||||
|
|
||||||
|
const providedValue = variableValues[variableIdentifier];
|
||||||
|
if (!providedValue) return undefined;
|
||||||
|
|
||||||
|
return normalizeLockingBytecodeHex(providedValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapUnspentOutputsToSelectable = (
|
||||||
|
unspentOutputs: any[],
|
||||||
|
): SelectableUtxoLike[] => {
|
||||||
|
return unspentOutputs.map((utxo: any) => ({
|
||||||
|
outpointTransactionHash: utxo.outpointTransactionHash,
|
||||||
|
outpointIndex: utxo.outpointIndex,
|
||||||
|
valueSatoshis: BigInt(utxo.valueSatoshis),
|
||||||
|
lockingBytecode: utxo.lockingBytecode
|
||||||
|
? typeof utxo.lockingBytecode === "string"
|
||||||
|
? utxo.lockingBytecode
|
||||||
|
: Buffer.from(utxo.lockingBytecode).toString("hex")
|
||||||
|
: undefined,
|
||||||
|
selected: false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoSelectGreedyUtxos = (
|
||||||
|
utxos: SelectableUtxoLike[],
|
||||||
|
requiredWithFee: bigint,
|
||||||
|
): SelectableUtxoLike[] => {
|
||||||
|
let accumulated = 0n;
|
||||||
|
const seenLockingBytecodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const utxo of utxos) {
|
||||||
|
if (
|
||||||
|
utxo.lockingBytecode &&
|
||||||
|
seenLockingBytecodes.has(utxo.lockingBytecode)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (utxo.lockingBytecode) {
|
||||||
|
seenLockingBytecodes.add(utxo.lockingBytecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
utxo.selected = true;
|
||||||
|
accumulated += utxo.valueSatoshis;
|
||||||
|
|
||||||
|
if (accumulated >= requiredWithFee) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return utxos;
|
||||||
|
};
|
||||||
@@ -6,15 +6,15 @@
|
|||||||
* in both TUI and CLI contexts.
|
* in both TUI and CLI contexts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Invitation } from '../services/invitation.js';
|
import type { Invitation } from "../services/invitation.js";
|
||||||
import type { XOTemplate } from '@xo-cash/types';
|
import type { XOTemplate } from "@xo-cash/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Color names for invitation states.
|
* Color names for invitation states.
|
||||||
* These are semantic color names that can be mapped to actual colors
|
* These are semantic color names that can be mapped to actual colors
|
||||||
* by the consuming application (TUI or CLI).
|
* by the consuming application (TUI or CLI).
|
||||||
*/
|
*/
|
||||||
export type StateColorName = 'info' | 'warning' | 'success' | 'error' | 'muted';
|
export type StateColorName = "info" | "warning" | "success" | "error" | "muted";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Input data extracted from invitation commits.
|
* Input data extracted from invitation commits.
|
||||||
@@ -77,21 +77,22 @@ export function getInvitationState(invitation: Invitation): string {
|
|||||||
*/
|
*/
|
||||||
export function getStateColorName(state: string): StateColorName {
|
export function getStateColorName(state: string): StateColorName {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'created':
|
case "created":
|
||||||
case 'published':
|
case "published":
|
||||||
return 'info';
|
return "info";
|
||||||
case 'pending':
|
case "pending":
|
||||||
return 'warning';
|
return "warning";
|
||||||
case 'ready':
|
case "ready":
|
||||||
case 'signed':
|
case "signed":
|
||||||
case 'broadcast':
|
case "complete":
|
||||||
case 'completed':
|
case "broadcast":
|
||||||
return 'success';
|
case "completed":
|
||||||
case 'expired':
|
return "success";
|
||||||
case 'error':
|
case "expired":
|
||||||
return 'error';
|
case "error":
|
||||||
|
return "error";
|
||||||
default:
|
default:
|
||||||
return 'muted';
|
return "muted";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +122,9 @@ export function getInvitationInputs(invitation: Invitation): InvitationInput[] {
|
|||||||
* @param invitation - The invitation to extract outputs from
|
* @param invitation - The invitation to extract outputs from
|
||||||
* @returns Array of output data
|
* @returns Array of output data
|
||||||
*/
|
*/
|
||||||
export function getInvitationOutputs(invitation: Invitation): InvitationOutput[] {
|
export function getInvitationOutputs(
|
||||||
|
invitation: Invitation,
|
||||||
|
): InvitationOutput[] {
|
||||||
const outputs: InvitationOutput[] = [];
|
const outputs: InvitationOutput[] = [];
|
||||||
for (const commit of invitation.data.commits || []) {
|
for (const commit of invitation.data.commits || []) {
|
||||||
for (const output of commit.data?.outputs || []) {
|
for (const output of commit.data?.outputs || []) {
|
||||||
@@ -142,7 +145,9 @@ export function getInvitationOutputs(invitation: Invitation): InvitationOutput[]
|
|||||||
* @param invitation - The invitation to extract variables from
|
* @param invitation - The invitation to extract variables from
|
||||||
* @returns Array of variable data
|
* @returns Array of variable data
|
||||||
*/
|
*/
|
||||||
export function getInvitationVariables(invitation: Invitation): InvitationVariable[] {
|
export function getInvitationVariables(
|
||||||
|
invitation: Invitation,
|
||||||
|
): InvitationVariable[] {
|
||||||
const variables: InvitationVariable[] = [];
|
const variables: InvitationVariable[] = [];
|
||||||
for (const commit of invitation.data.commits || []) {
|
for (const commit of invitation.data.commits || []) {
|
||||||
for (const variable of commit.data?.variables || []) {
|
for (const variable of commit.data?.variables || []) {
|
||||||
@@ -164,7 +169,10 @@ export function getInvitationVariables(invitation: Invitation): InvitationVariab
|
|||||||
* @param userEntityId - The user's entity identifier
|
* @param userEntityId - The user's entity identifier
|
||||||
* @returns The role identifier if found, null otherwise
|
* @returns The role identifier if found, null otherwise
|
||||||
*/
|
*/
|
||||||
export function getUserRole(invitation: Invitation, userEntityId: string | null): string | null {
|
export function getUserRole(
|
||||||
|
invitation: Invitation,
|
||||||
|
userEntityId: string | null,
|
||||||
|
): string | null {
|
||||||
if (!userEntityId) return null;
|
if (!userEntityId) return null;
|
||||||
|
|
||||||
for (const commit of invitation.data.commits || []) {
|
for (const commit of invitation.data.commits || []) {
|
||||||
@@ -195,7 +203,7 @@ export function getUserRole(invitation: Invitation, userEntityId: string | null)
|
|||||||
*/
|
*/
|
||||||
export function formatInvitationListItem(
|
export function formatInvitationListItem(
|
||||||
invitation: Invitation,
|
invitation: Invitation,
|
||||||
template?: XOTemplate | null
|
template?: XOTemplate | null,
|
||||||
): FormattedInvitationItem {
|
): FormattedInvitationItem {
|
||||||
// Validate that we have the minimum required data
|
// Validate that we have the minimum required data
|
||||||
const invitationId = invitation?.data?.invitationIdentifier;
|
const invitationId = invitation?.data?.invitationIdentifier;
|
||||||
@@ -203,15 +211,15 @@ export function formatInvitationListItem(
|
|||||||
|
|
||||||
if (!invitationId || !actionId) {
|
if (!invitationId || !actionId) {
|
||||||
return {
|
return {
|
||||||
label: '',
|
label: "",
|
||||||
status: 'error',
|
status: "error",
|
||||||
statusColor: 'error',
|
statusColor: "error",
|
||||||
isValid: false,
|
isValid: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = getInvitationState(invitation);
|
const state = getInvitationState(invitation);
|
||||||
const templateName = template?.name ?? 'Unknown';
|
const templateName = template?.name ?? "Unknown";
|
||||||
const shortId = formatInvitationId(invitationId, 8);
|
const shortId = formatInvitationId(invitationId, 8);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -258,7 +266,10 @@ export function getInvitationParticipants(invitation: Invitation): string[] {
|
|||||||
* @param userEntityId - The user's entity identifier
|
* @param userEntityId - The user's entity identifier
|
||||||
* @returns True if the user has made at least one commit
|
* @returns True if the user has made at least one commit
|
||||||
*/
|
*/
|
||||||
export function isUserParticipant(invitation: Invitation, userEntityId: string | null): boolean {
|
export function isUserParticipant(
|
||||||
|
invitation: Invitation,
|
||||||
|
userEntityId: string | null,
|
||||||
|
): boolean {
|
||||||
if (!userEntityId) return false;
|
if (!userEntityId) return false;
|
||||||
return getInvitationParticipants(invitation).includes(userEntityId);
|
return getInvitationParticipants(invitation).includes(userEntityId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ export class Logger {
|
|||||||
private readonly path: string,
|
private readonly path: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
send(level: 'log' | 'error' | 'warn' | 'info', message: string, ...metadata: unknown[]) {
|
send(
|
||||||
|
level: "log" | "error" | "warn" | "info",
|
||||||
|
message: string,
|
||||||
|
...metadata: unknown[]
|
||||||
|
) {
|
||||||
const data = {
|
const data = {
|
||||||
level,
|
level,
|
||||||
message: `${this.path}: ${message}`,
|
message: `${this.path}: ${message}`,
|
||||||
@@ -13,31 +17,31 @@ export class Logger {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetch(`${this.endpoint}`, {
|
fetch(`${this.endpoint}`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
'x-api-key': this.token,
|
"x-api-key": this.token,
|
||||||
},
|
},
|
||||||
}).catch(error => {
|
}).catch((error) => {
|
||||||
console.error('Failed to send log to logger:', error);
|
console.error("Failed to send log to logger:", error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log(message: string, ...metadata: unknown[]) {
|
log(message: string, ...metadata: unknown[]) {
|
||||||
this.send('log', message, ...metadata);
|
this.send("log", message, ...metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
error(message: string, ...metadata: unknown[]) {
|
error(message: string, ...metadata: unknown[]) {
|
||||||
this.send('error', message, ...metadata);
|
this.send("error", message, ...metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, ...metadata: unknown[]) {
|
warn(message: string, ...metadata: unknown[]) {
|
||||||
this.send('warn', message, ...metadata);
|
this.send("warn", message, ...metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
info(message: string, ...metadata: unknown[]) {
|
info(message: string, ...metadata: unknown[]) {
|
||||||
this.send('info', message, ...metadata);
|
this.send("info", message, ...metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
child(path: string): Logger {
|
child(path: string): Logger {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { ExponentialBackoff } from './exponential-backoff.js';
|
import { ExponentialBackoff } from "./exponential-backoff.js";
|
||||||
|
|
||||||
// Type declarations for browser environment (not available in Node.js)
|
// Type declarations for browser environment (not available in Node.js)
|
||||||
declare const document: {
|
declare const document:
|
||||||
visibilityState: 'visible' | 'hidden';
|
| {
|
||||||
addEventListener: (event: string, handler: (event: Event) => void) => void;
|
visibilityState: "visible" | "hidden";
|
||||||
removeEventListener: (event: string, handler: (event: Event) => void) => void;
|
addEventListener: (
|
||||||
} | undefined;
|
event: string,
|
||||||
|
handler: (event: Event) => void,
|
||||||
|
) => void;
|
||||||
|
removeEventListener: (
|
||||||
|
event: string,
|
||||||
|
handler: (event: Event) => void,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Server-Sent Events client implementation using fetch API.
|
* A Server-Sent Events client implementation using fetch API.
|
||||||
@@ -51,14 +59,14 @@ export class SSESession {
|
|||||||
this.options = {
|
this.options = {
|
||||||
// Use default fetch function.
|
// Use default fetch function.
|
||||||
fetch: (...args) => fetch(...args),
|
fetch: (...args) => fetch(...args),
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'text/event-stream',
|
Accept: "text/event-stream",
|
||||||
'Cache-Control': 'no-cache',
|
"Cache-Control": "no-cache",
|
||||||
},
|
},
|
||||||
onConnected: () => {},
|
onConnected: () => {},
|
||||||
onMessage: () => {},
|
onMessage: () => {},
|
||||||
onError: (error) => console.error('SSESession error:', error),
|
onError: (error) => console.error("SSESession error:", error),
|
||||||
onDisconnected: () => {},
|
onDisconnected: () => {},
|
||||||
onReconnect: (options) => Promise.resolve(options),
|
onReconnect: (options) => Promise.resolve(options),
|
||||||
|
|
||||||
@@ -71,10 +79,10 @@ export class SSESession {
|
|||||||
this.controller = new AbortController();
|
this.controller = new AbortController();
|
||||||
|
|
||||||
// Set up visibility change handling if in mobile browser environment
|
// Set up visibility change handling if in mobile browser environment
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== "undefined") {
|
||||||
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
|
this.visibilityChangeHandler = this.handleVisibilityChange.bind(this);
|
||||||
document.addEventListener(
|
document.addEventListener(
|
||||||
'visibilitychange',
|
"visibilitychange",
|
||||||
this.visibilityChangeHandler,
|
this.visibilityChangeHandler,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,16 +93,16 @@ export class SSESession {
|
|||||||
*/
|
*/
|
||||||
private async handleVisibilityChange(): Promise<void> {
|
private async handleVisibilityChange(): Promise<void> {
|
||||||
// Guard for Node.js environment where document is undefined
|
// Guard for Node.js environment where document is undefined
|
||||||
if (typeof document === 'undefined') return;
|
if (typeof document === "undefined") return;
|
||||||
|
|
||||||
// When going to background, close the current connection cleanly
|
// When going to background, close the current connection cleanly
|
||||||
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
|
// This allows us to reconnect mobile devices when they come back after leaving the tab or browser app.
|
||||||
if (document.visibilityState === 'hidden') {
|
if (document.visibilityState === "hidden") {
|
||||||
this.controller.abort();
|
this.controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When coming back to foreground, attempt to reconnect if not connected
|
// When coming back to foreground, attempt to reconnect if not connected
|
||||||
if (document.visibilityState === 'visible' && !this.connected) {
|
if (document.visibilityState === "visible" && !this.connected) {
|
||||||
await this.connect();
|
await this.connect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +123,7 @@ export class SSESession {
|
|||||||
headers: headers || {},
|
headers: headers || {},
|
||||||
body: body || null,
|
body: body || null,
|
||||||
signal: this.controller.signal,
|
signal: this.controller.signal,
|
||||||
cache: 'no-store',
|
cache: "no-store",
|
||||||
};
|
};
|
||||||
|
|
||||||
const exponentialBackoff = ExponentialBackoff.from({
|
const exponentialBackoff = ExponentialBackoff.from({
|
||||||
@@ -144,7 +152,7 @@ export class SSESession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.body) {
|
if (!res.body) {
|
||||||
throw new Error('Response body is null');
|
throw new Error("Response body is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.body.getReader();
|
return res.body.getReader();
|
||||||
@@ -228,10 +236,10 @@ export class SSESession {
|
|||||||
const line = lines[i];
|
const line = lines[i];
|
||||||
|
|
||||||
// Empty line signals the end of an event
|
// Empty line signals the end of an event
|
||||||
if (line === '') {
|
if (line === "") {
|
||||||
if (currentEvent.data) {
|
if (currentEvent.data) {
|
||||||
// Remove trailing newline if present
|
// Remove trailing newline if present
|
||||||
currentEvent.data = currentEvent.data.replace(/\n$/, '');
|
currentEvent.data = currentEvent.data.replace(/\n$/, "");
|
||||||
events.push(currentEvent as SSEvent);
|
events.push(currentEvent as SSEvent);
|
||||||
currentEvent = {};
|
currentEvent = {};
|
||||||
completeEventCount = i + 1;
|
completeEventCount = i + 1;
|
||||||
@@ -242,24 +250,24 @@ export class SSESession {
|
|||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
|
|
||||||
// Parse field: value format
|
// Parse field: value format
|
||||||
const colonIndex = line.indexOf(':');
|
const colonIndex = line.indexOf(":");
|
||||||
if (colonIndex === -1) continue;
|
if (colonIndex === -1) continue;
|
||||||
|
|
||||||
const field = line.slice(0, colonIndex);
|
const field = line.slice(0, colonIndex);
|
||||||
// Skip initial space after colon if present
|
// Skip initial space after colon if present
|
||||||
const valueStartIndex =
|
const valueStartIndex =
|
||||||
colonIndex + 1 + (line[colonIndex + 1] === ' ' ? 1 : 0);
|
colonIndex + 1 + (line[colonIndex + 1] === " " ? 1 : 0);
|
||||||
const value = line.slice(valueStartIndex);
|
const value = line.slice(valueStartIndex);
|
||||||
|
|
||||||
if (field === 'data') {
|
if (field === "data") {
|
||||||
currentEvent.data = currentEvent.data
|
currentEvent.data = currentEvent.data
|
||||||
? currentEvent.data + '\n' + value
|
? currentEvent.data + "\n" + value
|
||||||
: value;
|
: value;
|
||||||
} else if (field === 'event') {
|
} else if (field === "event") {
|
||||||
currentEvent.event = value;
|
currentEvent.event = value;
|
||||||
} else if (field === 'id') {
|
} else if (field === "id") {
|
||||||
currentEvent.id = value;
|
currentEvent.id = value;
|
||||||
} else if (field === 'retry') {
|
} else if (field === "retry") {
|
||||||
const retryMs = parseInt(value, 10);
|
const retryMs = parseInt(value, 10);
|
||||||
if (!isNaN(retryMs)) {
|
if (!isNaN(retryMs)) {
|
||||||
currentEvent.retry = retryMs;
|
currentEvent.retry = retryMs;
|
||||||
@@ -268,7 +276,7 @@ export class SSESession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the remainder of the buffer for the next chunk
|
// Store the remainder of the buffer for the next chunk
|
||||||
const remainder = lines.slice(completeEventCount).join('\n');
|
const remainder = lines.slice(completeEventCount).join("\n");
|
||||||
this.messageBuffer = this.textEncoder.encode(remainder);
|
this.messageBuffer = this.textEncoder.encode(remainder);
|
||||||
|
|
||||||
return events;
|
return events;
|
||||||
@@ -291,9 +299,9 @@ export class SSESession {
|
|||||||
this.controller.abort();
|
this.controller.abort();
|
||||||
|
|
||||||
// Remove the visibility handler (This is only required on browsers)
|
// Remove the visibility handler (This is only required on browsers)
|
||||||
if (this.visibilityChangeHandler && typeof document !== 'undefined') {
|
if (this.visibilityChangeHandler && typeof document !== "undefined") {
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
'visibilitychange',
|
"visibilitychange",
|
||||||
this.visibilityChangeHandler,
|
this.visibilityChangeHandler,
|
||||||
);
|
);
|
||||||
this.visibilityChangeHandler = null;
|
this.visibilityChangeHandler = null;
|
||||||
@@ -348,7 +356,7 @@ export interface SSESessionOptions {
|
|||||||
/**
|
/**
|
||||||
* HTTP method to use (GET or POST).
|
* HTTP method to use (GET or POST).
|
||||||
*/
|
*/
|
||||||
method: 'GET' | 'POST';
|
method: "GET" | "POST";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP headers to send with the request.
|
* HTTP headers to send with the request.
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import type { XOInvitation } from "@xo-cash/types";
|
import type { XOInvitation } from "@xo-cash/types";
|
||||||
import { EventEmitter } from "./event-emitter.js";
|
import { EventEmitter } from "./event-emitter.js";
|
||||||
import { SSESession, type SSEvent } from "./sse-client.js";
|
import { SSESession, type SSEvent } from "./sse-client.js";
|
||||||
import { decodeExtendedJson, decodeExtendedJsonObject, encodeExtendedJson, encodeExtendedJsonObject } from "./ext-json.js";
|
import { decodeExtendedJson, encodeExtendedJson } from "./ext-json.js";
|
||||||
|
|
||||||
export type SyncServerEventMap = {
|
export type SyncServerEventMap = {
|
||||||
'connected': void;
|
connected: void;
|
||||||
'disconnected': void;
|
disconnected: void;
|
||||||
'error': Error;
|
error: Error;
|
||||||
'message': SSEvent;
|
message: SSEvent;
|
||||||
}
|
};
|
||||||
|
|
||||||
export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
||||||
static async from(baseUrl: string, invitationIdentifier: string): Promise<SyncServer> {
|
static async from(
|
||||||
|
baseUrl: string,
|
||||||
|
invitationIdentifier: string,
|
||||||
|
): Promise<SyncServer> {
|
||||||
const server = new SyncServer(baseUrl, invitationIdentifier);
|
const server = new SyncServer(baseUrl, invitationIdentifier);
|
||||||
await server.connect();
|
await server.connect();
|
||||||
return server;
|
return server;
|
||||||
@@ -19,22 +22,32 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
|
|
||||||
private sse: SSESession;
|
private sse: SSESession;
|
||||||
|
|
||||||
constructor(private readonly baseUrl: string, private readonly invitationIdentifier: string) {
|
constructor(
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly invitationIdentifier: string,
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// Create an SSE Session
|
// Create an SSE Session
|
||||||
this.sse = new SSESession(`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`, {
|
this.sse = new SSESession(
|
||||||
method: 'GET',
|
`${baseUrl}/invitations?invitationIdentifier=${invitationIdentifier}`,
|
||||||
headers: {
|
{
|
||||||
'Accept': 'text/event-stream',
|
method: "GET",
|
||||||
},
|
headers: {
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
},
|
||||||
|
|
||||||
// Create our event bubblers
|
// Create our event bubblers
|
||||||
onMessage: (event: SSEvent) => this.emit('message', event),
|
onMessage: (event: SSEvent) => this.emit("message", event),
|
||||||
onError: (error: unknown) => this.emit('error', error instanceof Error ? error : new Error(String(error))),
|
onError: (error: unknown) =>
|
||||||
onDisconnected: () => this.emit('disconnected', undefined),
|
this.emit(
|
||||||
onConnected: () => this.emit('connected', undefined),
|
"error",
|
||||||
});
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
),
|
||||||
|
onDisconnected: () => this.emit("disconnected", undefined),
|
||||||
|
onConnected: () => this.emit("connected", undefined),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,13 +73,17 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
*/
|
*/
|
||||||
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
async getInvitation(identifier: string): Promise<XOInvitation | undefined> {
|
||||||
// Send a GET request to the sync server
|
// Send a GET request to the sync server
|
||||||
const response = await fetch(`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`);
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/invitations?invitationIdentifier=${identifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
if(!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
throw new Error(`Failed to get invitation: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitation = decodeExtendedJson(await response.text()) as XOInvitation | undefined;
|
const invitation = decodeExtendedJson(await response.text()) as
|
||||||
|
| XOInvitation
|
||||||
|
| undefined;
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,10 +95,10 @@ export class SyncServer extends EventEmitter<SyncServerEventMap> {
|
|||||||
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
async publishInvitation(invitation: XOInvitation): Promise<XOInvitation> {
|
||||||
// Send a POST request to the sync server
|
// Send a POST request to the sync server
|
||||||
const response = await fetch(`${this.baseUrl}/invitations`, {
|
const response = await fetch(`${this.baseUrl}/invitations`, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: encodeExtendedJson(invitation),
|
body: encodeExtendedJson(invitation),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* in both TUI and CLI contexts.
|
* in both TUI and CLI contexts.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { XOTemplate, XOTemplateAction } from '@xo-cash/types';
|
import type { XOTemplate, XOTemplateAction } from "@xo-cash/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formatted template list item data.
|
* Formatted template list item data.
|
||||||
@@ -64,18 +64,18 @@ export interface TemplateRole {
|
|||||||
*/
|
*/
|
||||||
export function formatTemplateListItem(
|
export function formatTemplateListItem(
|
||||||
template: XOTemplate | null | undefined,
|
template: XOTemplate | null | undefined,
|
||||||
index?: number
|
index?: number,
|
||||||
): FormattedTemplateItem {
|
): FormattedTemplateItem {
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return {
|
return {
|
||||||
label: '',
|
label: "",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
isValid: false,
|
isValid: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = template.name || 'Unnamed Template';
|
const name = template.name || "Unnamed Template";
|
||||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
const prefix = index !== undefined ? `${index + 1}. ` : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${prefix}${name}`,
|
label: `${prefix}${name}`,
|
||||||
@@ -97,11 +97,11 @@ export function formatActionListItem(
|
|||||||
actionId: string,
|
actionId: string,
|
||||||
action: XOTemplateAction | null | undefined,
|
action: XOTemplateAction | null | undefined,
|
||||||
roleCount: number = 1,
|
roleCount: number = 1,
|
||||||
index?: number
|
index?: number,
|
||||||
): FormattedActionItem {
|
): FormattedActionItem {
|
||||||
if (!actionId) {
|
if (!actionId) {
|
||||||
return {
|
return {
|
||||||
label: '',
|
label: "",
|
||||||
description: undefined,
|
description: undefined,
|
||||||
roleCount: 0,
|
roleCount: 0,
|
||||||
isValid: false,
|
isValid: false,
|
||||||
@@ -109,8 +109,8 @@ export function formatActionListItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = action?.name || actionId;
|
const name = action?.name || actionId;
|
||||||
const prefix = index !== undefined ? `${index + 1}. ` : '';
|
const prefix = index !== undefined ? `${index + 1}. ` : "";
|
||||||
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : '';
|
const roleSuffix = roleCount > 1 ? ` (${roleCount} roles)` : "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: `${prefix}${name}${roleSuffix}`,
|
label: `${prefix}${name}${roleSuffix}`,
|
||||||
@@ -131,7 +131,7 @@ export function formatActionListItem(
|
|||||||
*/
|
*/
|
||||||
export function deduplicateStartingActions(
|
export function deduplicateStartingActions(
|
||||||
template: XOTemplate,
|
template: XOTemplate,
|
||||||
startingActions: Array<{ action: string; role: string }>
|
startingActions: Array<{ action: string; role: string }>,
|
||||||
): UniqueStartingAction[] {
|
): UniqueStartingAction[] {
|
||||||
const actionMap = new Map<string, UniqueStartingAction>();
|
const actionMap = new Map<string, UniqueStartingAction>();
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
|||||||
|
|
||||||
return Object.entries(template.roles).map(([roleId, role]) => {
|
return Object.entries(template.roles).map(([roleId, role]) => {
|
||||||
// Handle case where role might be a string instead of object
|
// Handle case where role might be a string instead of object
|
||||||
const roleObj = typeof role === 'object' ? role : null;
|
const roleObj = typeof role === "object" ? role : null;
|
||||||
return {
|
return {
|
||||||
roleId,
|
roleId,
|
||||||
name: roleObj?.name || roleId,
|
name: roleObj?.name || roleId,
|
||||||
@@ -181,14 +181,15 @@ export function getTemplateRoles(template: XOTemplate): TemplateRole[] {
|
|||||||
*/
|
*/
|
||||||
export function getRolesForAction(
|
export function getRolesForAction(
|
||||||
template: XOTemplate,
|
template: XOTemplate,
|
||||||
actionIdentifier: string
|
actionIdentifier: string,
|
||||||
): TemplateRole[] {
|
): TemplateRole[] {
|
||||||
const startEntries = (template.start ?? [])
|
const startEntries = (template.start ?? []).filter(
|
||||||
.filter((s) => s.action === actionIdentifier);
|
(s) => s.action === actionIdentifier,
|
||||||
|
);
|
||||||
|
|
||||||
return startEntries.map((entry) => {
|
return startEntries.map((entry) => {
|
||||||
const roleDef = template.roles?.[entry.role];
|
const roleDef = template.roles?.[entry.role];
|
||||||
const roleObj = typeof roleDef === 'object' ? roleDef : null;
|
const roleObj = typeof roleDef === "object" ? roleDef : null;
|
||||||
return {
|
return {
|
||||||
roleId: entry.role,
|
roleId: entry.role,
|
||||||
name: roleObj?.name || entry.role,
|
name: roleObj?.name || entry.role,
|
||||||
@@ -203,8 +204,10 @@ export function getRolesForAction(
|
|||||||
* @param template - The template
|
* @param template - The template
|
||||||
* @returns The template name or a default
|
* @returns The template name or a default
|
||||||
*/
|
*/
|
||||||
export function getTemplateName(template: XOTemplate | null | undefined): string {
|
export function getTemplateName(
|
||||||
return template?.name || 'Unknown Template';
|
template: XOTemplate | null | undefined,
|
||||||
|
): string {
|
||||||
|
return template?.name || "Unknown Template";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,7 +216,9 @@ export function getTemplateName(template: XOTemplate | null | undefined): string
|
|||||||
* @param template - The template
|
* @param template - The template
|
||||||
* @returns The template description or undefined
|
* @returns The template description or undefined
|
||||||
*/
|
*/
|
||||||
export function getTemplateDescription(template: XOTemplate | null | undefined): string | undefined {
|
export function getTemplateDescription(
|
||||||
|
template: XOTemplate | null | undefined,
|
||||||
|
): string | undefined {
|
||||||
return template?.description;
|
return template?.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +231,7 @@ export function getTemplateDescription(template: XOTemplate | null | undefined):
|
|||||||
*/
|
*/
|
||||||
export function getActionName(
|
export function getActionName(
|
||||||
template: XOTemplate | null | undefined,
|
template: XOTemplate | null | undefined,
|
||||||
actionId: string
|
actionId: string,
|
||||||
): string {
|
): string {
|
||||||
return template?.actions?.[actionId]?.name || actionId;
|
return template?.actions?.[actionId]?.name || actionId;
|
||||||
}
|
}
|
||||||
@@ -240,7 +245,7 @@ export function getActionName(
|
|||||||
*/
|
*/
|
||||||
export function getActionDescription(
|
export function getActionDescription(
|
||||||
template: XOTemplate | null | undefined,
|
template: XOTemplate | null | undefined,
|
||||||
actionId: string
|
actionId: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
return template?.actions?.[actionId]?.description;
|
return template?.actions?.[actionId]?.description;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,7 +222,14 @@ export function resolveTemplateReferences(
|
|||||||
const resolved = structuredClone(template);
|
const resolved = structuredClone(template);
|
||||||
|
|
||||||
for (const rule of RESOLUTION_RULES) {
|
for (const rule of RESOLUTION_RULES) {
|
||||||
applyRule(resolved, resolved, rule.path.split("."), 0, rule.from, rule.mode);
|
applyRule(
|
||||||
|
resolved,
|
||||||
|
resolved,
|
||||||
|
rule.path.split("."),
|
||||||
|
0,
|
||||||
|
rule.from,
|
||||||
|
rule.mode,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolved as unknown as ResolvedXOTemplate;
|
return resolved as unknown as ResolvedXOTemplate;
|
||||||
@@ -357,16 +364,10 @@ interface ResolvedStartEntry {
|
|||||||
|
|
||||||
// ─── The full resolved template ──────────────────────────────────
|
// ─── The full resolved template ──────────────────────────────────
|
||||||
|
|
||||||
interface ResolvedXOTemplate
|
interface ResolvedXOTemplate extends Omit<
|
||||||
extends Omit<
|
XOTemplate,
|
||||||
XOTemplate,
|
"actions" | "transactions" | "outputs" | "inputs" | "lockingScripts" | "start"
|
||||||
| "actions"
|
> {
|
||||||
| "transactions"
|
|
||||||
| "outputs"
|
|
||||||
| "inputs"
|
|
||||||
| "lockingScripts"
|
|
||||||
| "start"
|
|
||||||
> {
|
|
||||||
start: ResolvedStartEntry[];
|
start: ResolvedStartEntry[];
|
||||||
actions: Record<string, ResolvedActionDefinition>;
|
actions: Record<string, ResolvedActionDefinition>;
|
||||||
transactions: Record<string, ResolvedTransactionDefinition>;
|
transactions: Record<string, ResolvedTransactionDefinition>;
|
||||||
|
|||||||
Reference in New Issue
Block a user