Compare commits
5 Commits
v2.2.0-alp
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3706e38 | ||
|
|
7e6fe2c2a0 | ||
|
|
481d4d2fa8 | ||
|
|
6b7cb1c5a9 | ||
|
|
96b347076e |
@@ -9,6 +9,13 @@
|
|||||||
dev vs. production rules cleanly. -->
|
dev vs. production rules cleanly. -->
|
||||||
<title>DChain</title>
|
<title>DChain</title>
|
||||||
<style>
|
<style>
|
||||||
|
/* Global box-sizing — every padding+border counts toward the declared
|
||||||
|
width, not on top of it. Without this, `<input style="width:100%">`
|
||||||
|
inside a padded flex container visibly overflows its parent on
|
||||||
|
every modal / Settings card. Applied universally because almost
|
||||||
|
every per-element override was forgetting it anyway. */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
@@ -23,6 +30,11 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
-webkit-user-select: text;
|
-webkit-user-select: text;
|
||||||
}
|
}
|
||||||
|
/* Form elements should never paint their own background/border over
|
||||||
|
our dark theme. Each component still sets its own explicit colours. */
|
||||||
|
input, textarea, button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
225
desktop/package-lock.json
generated
225
desktop/package-lock.json
generated
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "dchain-desktop",
|
"name": "dchain-desktop",
|
||||||
"version": "2.2.0-alpha4",
|
"version": "2.2.0-alpha6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dchain-desktop",
|
"name": "dchain-desktop",
|
||||||
"version": "2.2.0-alpha4",
|
"version": "2.2.0-alpha6",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -2020,6 +2022,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.28",
|
"version": "18.3.28",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||||
@@ -2183,7 +2195,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -2193,7 +2204,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2869,6 +2879,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001790",
|
"version": "1.0.30001790",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
|
||||||
@@ -3049,7 +3068,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -3062,7 +3080,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-support": {
|
"node_modules/color-support": {
|
||||||
@@ -3353,6 +3370,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"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",
|
||||||
@@ -3478,6 +3504,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"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/dir-compare": {
|
"node_modules/dir-compare": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||||
@@ -3873,7 +3905,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
"node_modules/encoding": {
|
||||||
@@ -4158,6 +4189,19 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -4329,7 +4373,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -4830,7 +4873,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5091,6 +5133,18 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
@@ -5780,6 +5834,33 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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-locate/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-map": {
|
"node_modules/p-map": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
|
||||||
@@ -5796,6 +5877,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/package-json-from-dist": {
|
"node_modules/package-json-from-dist": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||||
@@ -5803,6 +5893,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"license": "BlueOak-1.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-is-absolute": {
|
"node_modules/path-is-absolute": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
@@ -5914,6 +6013,15 @@
|
|||||||
"node": ">=10.4.0"
|
"node": ">=10.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.10",
|
"version": "8.5.10",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||||
@@ -6013,6 +6121,89 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/qrcode/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/quick-lru": {
|
"node_modules/quick-lru": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
|
||||||
@@ -6137,12 +6328,17 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"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/resedit": {
|
"node_modules/resedit": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||||
@@ -6392,7 +6588,6 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
@@ -6610,7 +6805,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -6641,7 +6835,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -7135,6 +7328,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/wide-align": {
|
"node_modules/wide-align": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dchain-desktop",
|
"name": "dchain-desktop",
|
||||||
"version": "2.2.0-alpha6",
|
"version": "2.2.0",
|
||||||
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist-electron/main.js",
|
"main": "dist-electron/main.js",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
@@ -34,12 +36,40 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.dchain.desktop",
|
"appId": "com.dchain.desktop",
|
||||||
"productName": "DChain",
|
"productName": "DChain",
|
||||||
|
"copyright": "Copyright © 2026 DChain contributors",
|
||||||
|
"asar": true,
|
||||||
|
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist-electron/**/*"
|
"dist-electron/**/*",
|
||||||
|
"!**/*.map",
|
||||||
|
"!**/node_modules/**/test/**",
|
||||||
|
"!**/node_modules/**/tests/**"
|
||||||
],
|
],
|
||||||
"mac": { "target": ["dmg"] },
|
"directories": {
|
||||||
"win": { "target": ["nsis"] },
|
"output": "release",
|
||||||
"linux": { "target": ["AppImage", "deb"] }
|
"buildResources": "resources"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": ["dmg", "zip"],
|
||||||
|
"category": "public.app-category.social-networking",
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"gatekeeperAssess": false
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": ["nsis", "portable"]
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowElevation": true,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": ["AppImage", "deb"],
|
||||||
|
"category": "Network"
|
||||||
|
},
|
||||||
|
"publish": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
|
||||||
import { setNodeUrl } from '@/lib/api';
|
import { setNodeUrl } from '@/lib/api';
|
||||||
|
import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap';
|
||||||
import { Shell } from '@/shell/Shell';
|
import { Shell } from '@/shell/Shell';
|
||||||
import { Welcome } from '@/auth/Welcome';
|
import { Welcome } from '@/auth/Welcome';
|
||||||
|
|
||||||
@@ -16,6 +17,11 @@ export function App(): React.ReactElement {
|
|||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const [bootError, setBootError] = useState<string | null>(null);
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Multi-device registry bootstrap — publishes THIS device on the
|
||||||
|
// chain so senders can fan out envelopes to us, and self-wipes if
|
||||||
|
// another device has since revoked us. See hooks/useDeviceBootstrap.
|
||||||
|
useDeviceBootstrap();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
92
desktop/src/hooks/useDeviceBootstrap.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// Mirror of mobile's _layout.tsx bootstrap effect (v2.2.0-alpha2):
|
||||||
|
// ensures this device is visible to senders via the on-chain device
|
||||||
|
// registry, and detects remote revoke so a revoked laptop wipes its
|
||||||
|
// state the moment it sees it's no longer active.
|
||||||
|
//
|
||||||
|
// Three branches by (chain list × local "was registered" flag):
|
||||||
|
//
|
||||||
|
// 1. Our X25519 pub IS in the active list — flip the local marker
|
||||||
|
// (idempotent), done. Next sign-in is a no-op.
|
||||||
|
//
|
||||||
|
// 2. Our X25519 pub is NOT in the active list, but we had marked
|
||||||
|
// ourselves registered before → another device issued
|
||||||
|
// UNLINK_DEVICE against us. Wipe master priv + local caches and
|
||||||
|
// bounce back to the Welcome screen. Not fatal: user can import
|
||||||
|
// the key again if that was a mistake.
|
||||||
|
//
|
||||||
|
// 3. Our X25519 pub is NOT in the active list, and we've never
|
||||||
|
// registered before → first sign-in. Submit LINK_DEVICE. On
|
||||||
|
// a zero-balance wallet the tx bounces; next launch retries.
|
||||||
|
// No user-facing error — this is best-effort plumbing.
|
||||||
|
//
|
||||||
|
// Network errors never trigger the wipe path: we only act when the
|
||||||
|
// chain explicitly reports the absence.
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { fetchDevices } from '@/lib/api';
|
||||||
|
import { buildLinkDeviceTx, submitTx } from '@/lib/tx';
|
||||||
|
import {
|
||||||
|
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||||
|
} from '@/lib/storage';
|
||||||
|
|
||||||
|
export function useDeviceBootstrap(): void {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let chainList;
|
||||||
|
try {
|
||||||
|
chainList = await fetchDevices(keyFile.pub_key);
|
||||||
|
} catch {
|
||||||
|
// Network issue — leave state alone; try again next sign-in.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||||
|
const previouslyRegistered = isDeviceRegistered();
|
||||||
|
|
||||||
|
if (inActive) {
|
||||||
|
if (!previouslyRegistered) markDeviceRegistered();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslyRegistered) {
|
||||||
|
// Revoked from another device. Wipe and send the user back to
|
||||||
|
// onboarding; the App-level render branch will route to Welcome
|
||||||
|
// as soon as keyFile flips to null.
|
||||||
|
await wipeAllLocalState();
|
||||||
|
useStore.getState().setKeyFile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First boot — publish this device. Desktop is almost always a
|
||||||
|
// "second" device (paired from a phone), so a balance is normally
|
||||||
|
// available; we just need to ship the tx. Failures (insufficient
|
||||||
|
// balance, a node that doesn't grok v2.2.0) are swallowed.
|
||||||
|
try {
|
||||||
|
const platform = await window.dchain.app.platform().catch(() => 'unknown');
|
||||||
|
const deviceName = platform === 'darwin' ? 'Mac'
|
||||||
|
: platform === 'win32' ? 'Windows'
|
||||||
|
: platform === 'linux' ? 'Linux'
|
||||||
|
: 'Desktop';
|
||||||
|
const tx = buildLinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: keyFile.x25519_pub,
|
||||||
|
deviceName,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
markDeviceRegistered();
|
||||||
|
} catch {
|
||||||
|
/* next launch retries */
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [keyFile]);
|
||||||
|
}
|
||||||
62
desktop/src/hooks/useGlobalKeybinds.ts
Normal file
62
desktop/src/hooks/useGlobalKeybinds.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// Global keyboard shortcuts. Mounted at Shell.tsx so they work regardless
|
||||||
|
// of which section is active. The section-switching bindings
|
||||||
|
// (Ctrl/Cmd+1..5, +Settings) live in NavBar — they predate this file and
|
||||||
|
// stay there because they're tightly coupled to the nav data structure.
|
||||||
|
//
|
||||||
|
// Every shortcut below:
|
||||||
|
// * Skips itself when focus is inside a text input / textarea (so typing
|
||||||
|
// in Compose doesn't accidentally fire app-level actions).
|
||||||
|
// * preventDefault()'s to suppress the browser/Electron default (e.g.
|
||||||
|
// Ctrl+W would otherwise close the whole window).
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
|
||||||
|
function inTextField(el: EventTarget | null): boolean {
|
||||||
|
const n = el as HTMLElement | null;
|
||||||
|
if (!n) return false;
|
||||||
|
const tag = n.tagName;
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || n.isContentEditable === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalKeybinds(): void {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
|
||||||
|
// Ctrl/Cmd+W — close the current conversation (drop activeChat);
|
||||||
|
// if no chat is open, no-op. We do not close the window, because
|
||||||
|
// that's too abrupt for an app the user usually keeps running.
|
||||||
|
if (mod && e.key.toLowerCase() === 'w') {
|
||||||
|
const { section, activeChat, setActiveChat } = useStore.getState();
|
||||||
|
if (section === 'messages' && activeChat) {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveChat(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+K — jump to Contacts with the search focused. The focus
|
||||||
|
// itself is delegated to the Contacts component via a signal; for
|
||||||
|
// now we just switch the section and rely on Contacts' autofocus
|
||||||
|
// pattern (text input comes from memo'd ref in list pane).
|
||||||
|
if (mod && e.key.toLowerCase() === 'k') {
|
||||||
|
if (inTextField(e.target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
useStore.getState().setSection('contacts');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl/Cmd+, — Settings.
|
||||||
|
if (mod && e.key === ',') {
|
||||||
|
if (inTextField(e.target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
useStore.getState().setSection('settings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
100
desktop/src/hooks/useUpdateCheck.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// useUpdateCheck — polls the configured node's /api/update-check once
|
||||||
|
// per launch (+ every 6h while the window stays open), compares the
|
||||||
|
// Gitea release tag against this client's app version, and exposes
|
||||||
|
// { latest, url } when ours is older.
|
||||||
|
//
|
||||||
|
// Why reuse the node endpoint? The DChain node already fetches Gitea
|
||||||
|
// releases on behalf of its operator; piggybacking on the same cached
|
||||||
|
// JSON means the desktop client doesn't need a direct Gitea token or
|
||||||
|
// a separate update feed. One source of truth, no new infra.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { get } from '@/lib/api';
|
||||||
|
|
||||||
|
interface UpdateCheck {
|
||||||
|
current?: { tag?: string };
|
||||||
|
latest?: { tag?: string; commit?: string; url?: string; published_at?: string };
|
||||||
|
update_available?: boolean;
|
||||||
|
source?: string;
|
||||||
|
checked_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInfo {
|
||||||
|
latestTag: string;
|
||||||
|
url: string;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCheck(): UpdateInfo | null {
|
||||||
|
const [info, setInfo] = useState<UpdateInfo | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
// Our version (set in package.json, baked into Electron at build time).
|
||||||
|
const myVersion = (await window.dchain.app.version()).trim();
|
||||||
|
const r = await get<UpdateCheck>('/api/update-check');
|
||||||
|
if (cancelled) return;
|
||||||
|
const latest = r.latest?.tag?.trim() ?? '';
|
||||||
|
if (!latest || !r.latest?.url) { setInfo(null); return; }
|
||||||
|
// Compare semver-ish. The node's own `update_available` flag
|
||||||
|
// compares vs. the NODE's version, not ours, so we re-derive.
|
||||||
|
if (isNewer(latest, myVersion)) {
|
||||||
|
setInfo({
|
||||||
|
latestTag: latest,
|
||||||
|
url: r.latest.url,
|
||||||
|
publishedAt: r.latest.published_at ?? '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInfo(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Node doesn't have the endpoint configured, or offline — quiet fail.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const t = setInterval(tick, 6 * 60 * 60 * 1000);
|
||||||
|
return () => { cancelled = true; clearInterval(t); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isNewer — loose semver compare for strings like `v2.2.0` / `2.2.0-rc1`.
|
||||||
|
* Strips leading `v`, splits on dots and the first `-` (pre-release
|
||||||
|
* suffix), compares numerically left-to-right. Pre-release tags are
|
||||||
|
* considered OLDER than the bare version (so `2.2.0 > 2.2.0-rc1`).
|
||||||
|
* Not a full semver implementation — good enough to decide whether to
|
||||||
|
* show the "update available" badge. If our parse fails, we assume no
|
||||||
|
* update (safer than nagging users with false positives).
|
||||||
|
*/
|
||||||
|
export function isNewer(candidate: string, reference: string): boolean {
|
||||||
|
const a = parseVersion(candidate);
|
||||||
|
const b = parseVersion(reference);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
for (let i = 0; i < Math.max(a.nums.length, b.nums.length); i++) {
|
||||||
|
const x = a.nums[i] ?? 0;
|
||||||
|
const y = b.nums[i] ?? 0;
|
||||||
|
if (x !== y) return x > y;
|
||||||
|
}
|
||||||
|
// All numeric parts equal → compare pre-release. `""` (stable) beats any suffix.
|
||||||
|
if (a.pre === b.pre) return false;
|
||||||
|
if (a.pre === '') return true; // stable > prerelease
|
||||||
|
if (b.pre === '') return false; // prerelease < stable
|
||||||
|
return a.pre > b.pre; // alpha6 > alpha5 lexically, fine in practice
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVersion(v: string): { nums: number[]; pre: string } | null {
|
||||||
|
if (!v) return null;
|
||||||
|
const clean = v.trim().replace(/^v/i, '');
|
||||||
|
const dash = clean.indexOf('-');
|
||||||
|
const head = dash >= 0 ? clean.slice(0, dash) : clean;
|
||||||
|
const pre = dash >= 0 ? clean.slice(dash + 1) : '';
|
||||||
|
const nums = head.split('.').map(s => parseInt(s, 10));
|
||||||
|
if (nums.some(n => !Number.isFinite(n))) return null;
|
||||||
|
return { nums, pre };
|
||||||
|
}
|
||||||
@@ -175,6 +175,30 @@ export async function getTxDetail(txID: string): Promise<TxDetail | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Contact requests (on-chain, via /relay/contacts) ───────────────────
|
||||||
|
|
||||||
|
export interface ContactRequestRaw {
|
||||||
|
requester_pub: string;
|
||||||
|
requester_addr: string;
|
||||||
|
status: string; // "pending" | "accepted" | "blocked"
|
||||||
|
intro: string;
|
||||||
|
fee_ut: number;
|
||||||
|
tx_id: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /relay/contacts?pub=<ed25519> — returns every on-chain
|
||||||
|
* CONTACT_REQUEST addressed to `pub`, regardless of status. The UI
|
||||||
|
* filters by pending before showing.
|
||||||
|
*/
|
||||||
|
export async function fetchContactRequests(edPub: string): Promise<ContactRequestRaw[]> {
|
||||||
|
try {
|
||||||
|
const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`);
|
||||||
|
return r.contacts ?? [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
|
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
|
||||||
export async function resolveAccount(input: string): Promise<string | null> {
|
export async function resolveAccount(input: string): Promise<string | null> {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ export type WalletSelection =
|
|||||||
| { kind: 'overview' }
|
| { kind: 'overview' }
|
||||||
| { kind: 'tx'; id: string };
|
| { kind: 'tx'; id: string };
|
||||||
|
|
||||||
|
/** Which Settings subsection is visible in the detail pane. */
|
||||||
|
export type SettingsPage = 'node' | 'identity' | 'devices' | 'about';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
booted: boolean;
|
booted: boolean;
|
||||||
keyFile: KeyFile | null;
|
keyFile: KeyFile | null;
|
||||||
@@ -64,6 +67,14 @@ interface State {
|
|||||||
/** Wallet state. */
|
/** Wallet state. */
|
||||||
walletSel: WalletSelection;
|
walletSel: WalletSelection;
|
||||||
setWalletSel: (s: WalletSelection) => void;
|
setWalletSel: (s: WalletSelection) => void;
|
||||||
|
|
||||||
|
/** Settings state. */
|
||||||
|
settingsPage: SettingsPage;
|
||||||
|
setSettingsPage: (p: SettingsPage) => void;
|
||||||
|
|
||||||
|
/** Currently-selected contact in the Contacts section. */
|
||||||
|
selectedContact: string | null;
|
||||||
|
setSelectedContact: (addr: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useStore = create<State>((set) => ({
|
export const useStore = create<State>((set) => ({
|
||||||
@@ -119,4 +130,10 @@ export const useStore = create<State>((set) => ({
|
|||||||
|
|
||||||
walletSel: { kind: 'overview' },
|
walletSel: { kind: 'overview' },
|
||||||
setWalletSel: (s) => set({ walletSel: s }),
|
setWalletSel: (s) => set({ walletSel: s }),
|
||||||
|
|
||||||
|
settingsPage: 'node',
|
||||||
|
setSettingsPage: (p) => set({ settingsPage: p }),
|
||||||
|
|
||||||
|
selectedContact: null,
|
||||||
|
setSelectedContact: (addr) => set({ selectedContact: addr }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -126,6 +126,77 @@ export function buildUnlinkDeviceTx(p: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONTACT_REQUEST — paid first-contact tx. `amount` carries the
|
||||||
|
* anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to
|
||||||
|
* the recipient's balance as an incentive to accept; `fee` is the
|
||||||
|
* regular network fee. Optional `intro` plaintext is embedded in the
|
||||||
|
* payload so the receiver sees "who is this" before accepting.
|
||||||
|
*/
|
||||||
|
export function buildContactRequestTx(p: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
contactFee: number; // µT — ≥ 5000, paid to recipient
|
||||||
|
privKey: string;
|
||||||
|
intro?: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {}));
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||||
|
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
|
||||||
|
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ACCEPT_CONTACT — recipient side, empties the pending request and
|
||||||
|
* publishes the peer's X25519 key so the requester can start sending
|
||||||
|
* encrypted envelopes. Tx.to = original requester's pub.
|
||||||
|
*/
|
||||||
|
export function buildAcceptContactTx(p: {
|
||||||
|
from: string; to: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64('{}');
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs
|
||||||
|
* from the same sender are dropped at applyTx level on the node.
|
||||||
|
*/
|
||||||
|
export function buildBlockContactTx(p: {
|
||||||
|
from: string; to: string; privKey: string;
|
||||||
|
}): RawTx {
|
||||||
|
const id = newTxID();
|
||||||
|
const timestamp = rfc3339Now();
|
||||||
|
const payload = strToBase64('{}');
|
||||||
|
const canon = canonicalBytes({
|
||||||
|
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
|
||||||
|
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
|
||||||
|
signature: signBase64(canon, p.privKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
|
||||||
* message wrappers into a one-line user-facing string. Same helper the
|
* message wrappers into a one-line user-facing string. Same helper the
|
||||||
|
|||||||
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
200
desktop/src/sections/contacts/ContactsDetail.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// Right-pane for Contacts — profile card for the selected contact.
|
||||||
|
// Shows identity, balance, device count, linked action buttons:
|
||||||
|
// Open chat (switch to Messages section), Transfer, View posts (switch
|
||||||
|
// to Feed author wall), Block (local only for now).
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getIdentity, fetchDevices, getBalance } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import type { IdentityInfo } from '@/lib/api';
|
||||||
|
|
||||||
|
function formatT(ut: number): string {
|
||||||
|
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactsDetail(): React.ReactElement {
|
||||||
|
const sel = useStore(s => s.selectedContact);
|
||||||
|
const contact = useStore(s => s.contacts.find(c => c.address === sel));
|
||||||
|
const setSection = useStore(s => s.setSection);
|
||||||
|
const setActive = useStore(s => s.setActiveChat);
|
||||||
|
const setFeedTab = useStore(s => s.setFeedTab);
|
||||||
|
|
||||||
|
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
const [deviceCount, setDeviceCount] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sel) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [id, bal, devs] = await Promise.all([
|
||||||
|
getIdentity(sel),
|
||||||
|
getBalance(sel),
|
||||||
|
fetchDevices(sel),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setIdentity(id);
|
||||||
|
setBalance(bal);
|
||||||
|
setDeviceCount(devs.length);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [sel]);
|
||||||
|
|
||||||
|
if (!sel || !contact) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
Pick a contact on the left to view their profile.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = contact.username
|
||||||
|
? `@${contact.username}`
|
||||||
|
: (identity?.nickname ? `@${identity.nickname}` : (contact.alias ?? shortAddr(contact.address, 8)));
|
||||||
|
|
||||||
|
const openChat = () => {
|
||||||
|
setActive(contact.address);
|
||||||
|
setSection('messages');
|
||||||
|
};
|
||||||
|
const viewPosts = () => {
|
||||||
|
setFeedTab({ kind: 'author', pub: contact.address });
|
||||||
|
setSection('feed');
|
||||||
|
};
|
||||||
|
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', overflowY: 'auto',
|
||||||
|
padding: '22px 26px', background: '#000',
|
||||||
|
}}>
|
||||||
|
{/* Header card */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: 32,
|
||||||
|
background: '#1a1a1a', color: '#d0d0d0',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 28, fontWeight: 700,
|
||||||
|
}}>{displayName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>
|
||||||
|
{displayName}
|
||||||
|
</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#8b8b8b', fontSize: 12, fontFamily: 'monospace',
|
||||||
|
marginTop: 4, wordBreak: 'break-all',
|
||||||
|
}}>{contact.address}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 16, flexWrap: 'wrap' }}>
|
||||||
|
<Btn primary onClick={openChat}>Open chat</Btn>
|
||||||
|
<Btn onClick={viewPosts}>View posts</Btn>
|
||||||
|
<Btn onClick={() => copy(contact.address)}>Copy address</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats grid */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 22, display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10,
|
||||||
|
}}>
|
||||||
|
<Stat label="Balance" value={balance === null ? '…' : `${formatT(balance)} T`} />
|
||||||
|
<Stat label="Devices" value={deviceCount === null ? '…' : String(deviceCount)} />
|
||||||
|
<Stat label="Encryption" value={contact.x25519Pub ? 'E2E (NaCl)' : 'no key'} />
|
||||||
|
<Stat label="Added" value={new Date(contact.addedAt).toLocaleDateString()} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Identity details */}
|
||||||
|
{identity && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 22, padding: 14, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8,
|
||||||
|
}}>Identity</div>
|
||||||
|
<Row k="DC address" v={identity.address} copyable onCopy={() => copy(identity.address)} />
|
||||||
|
{identity.nickname && <Row k="Username" v={`@${identity.nickname}`} />}
|
||||||
|
{identity.x25519_pub && (
|
||||||
|
<Row
|
||||||
|
k="Published X25519"
|
||||||
|
v={shortAddr(identity.x25519_pub, 8)}
|
||||||
|
copyable
|
||||||
|
onCopy={() => copy(identity.x25519_pub)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof identity.device_count === 'number' && (
|
||||||
|
<Row k="Device count" v={String(identity.device_count)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Btn({ children, onClick, primary }: {
|
||||||
|
children: React.ReactNode; onClick: () => void; primary?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '9px 16px', borderRadius: 999,
|
||||||
|
background: primary ? '#1d9bf0' : 'transparent',
|
||||||
|
border: primary ? 'none' : '1px solid #1f1f1f',
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{children}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 12, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1, textTransform: 'uppercase',
|
||||||
|
}}>{label}</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700, marginTop: 4 }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({
|
||||||
|
k, v, copyable, onCopy,
|
||||||
|
}: { k: string; v: string; copyable?: boolean; onCopy?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', padding: '6px 0',
|
||||||
|
borderBottom: '1px solid #141414',
|
||||||
|
alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 140px' }}>{k}</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
flex: 1, wordBreak: 'break-all',
|
||||||
|
}}>{v}</div>
|
||||||
|
{copyable && (
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#1d9bf0', fontSize: 11, fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>copy</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
desktop/src/sections/contacts/ContactsList.tsx
Normal file
198
desktop/src/sections/contacts/ContactsList.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
// Left-pane of Contacts — flat alphabetical list with a text filter.
|
||||||
|
// Richer grouping (Online / Blocked / Requests) arrives once we have
|
||||||
|
// WS presence + request inbox plumbing; placeholder headers are left
|
||||||
|
// in the UI so the shape is visible.
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
import { fetchContactRequests, type ContactRequestRaw } from '@/lib/api';
|
||||||
|
import type { Contact } from '@/lib/types';
|
||||||
|
import { NewContactModal } from './NewContactModal';
|
||||||
|
import { RequestsList } from './RequestsList';
|
||||||
|
|
||||||
|
export function ContactsList(): React.ReactElement {
|
||||||
|
const contacts = useStore(s => s.contacts);
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const sel = useStore(s => s.selectedContact);
|
||||||
|
const setSel = useStore(s => s.setSelectedContact);
|
||||||
|
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [tab, setTab] = useState<'list' | 'requests'>('list');
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [requests, setRequests] = useState<ContactRequestRaw[]>([]);
|
||||||
|
|
||||||
|
// Load pending contact requests (on-chain inbox). Refreshes when the
|
||||||
|
// tab is opened and after a new request is sent so the counter moves.
|
||||||
|
const refreshRequests = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const list = await fetchContactRequests(keyFile.pub_key);
|
||||||
|
// Filter to pending only — accepted ones turn into contacts.
|
||||||
|
const knownContacts = new Set(contacts.map(c => c.address));
|
||||||
|
setRequests(list.filter(r =>
|
||||||
|
r.status === 'pending' && !knownContacts.has(r.requester_pub),
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { refreshRequests(); const t = setInterval(refreshRequests, 15_000); return () => clearInterval(t); },
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[keyFile, contacts]);
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return contacts;
|
||||||
|
return contacts.filter(c =>
|
||||||
|
(c.username ?? '').toLowerCase().includes(needle) ||
|
||||||
|
(c.alias ?? '').toLowerCase().includes(needle) ||
|
||||||
|
c.address.toLowerCase().includes(needle),
|
||||||
|
);
|
||||||
|
}, [contacts, q]);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
const an = (a.username ?? a.alias ?? a.address).toLowerCase();
|
||||||
|
const bn = (b.username ?? b.alias ?? b.address).toLowerCase();
|
||||||
|
return an.localeCompare(bn);
|
||||||
|
});
|
||||||
|
}, [filtered]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Sticky header: tab switcher + search / action row */}
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky', top: 0, zIndex: 1,
|
||||||
|
background: '#000', borderBottom: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', padding: '8px 10px 0', gap: 4,
|
||||||
|
}}>
|
||||||
|
<TabBtn
|
||||||
|
label="Contacts"
|
||||||
|
active={tab === 'list'}
|
||||||
|
onClick={() => setTab('list')}
|
||||||
|
/>
|
||||||
|
<TabBtn
|
||||||
|
label="Requests"
|
||||||
|
active={tab === 'requests'}
|
||||||
|
badge={requests.length}
|
||||||
|
onClick={() => setTab('requests')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tab === 'list' && (
|
||||||
|
<div style={{ padding: 10, display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={e => setQ(e.target.value)}
|
||||||
|
placeholder="Filter…"
|
||||||
|
style={{
|
||||||
|
flex: 1, boxSizing: 'border-box',
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 8, padding: '8px 10px',
|
||||||
|
color: '#fff', fontSize: 13, outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setNewOpen(true)}
|
||||||
|
title="Send contact request"
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', borderRadius: 8, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>+ New</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'requests' ? (
|
||||||
|
<RequestsList
|
||||||
|
requests={requests}
|
||||||
|
onChanged={refreshRequests}
|
||||||
|
/>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<div style={{
|
||||||
|
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
No contacts yet. Tap <b>+ New</b> above to send a contact request,
|
||||||
|
or pair another of your own devices via Settings → Devices.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sorted.map(c => (
|
||||||
|
<Row key={c.address} c={c} active={c.address === sel} onClick={() => setSel(c.address)} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{newOpen && (
|
||||||
|
<NewContactModal
|
||||||
|
onClose={() => setNewOpen(false)}
|
||||||
|
onSent={() => { setNewOpen(false); refreshRequests(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabBtn({
|
||||||
|
label, active, onClick, badge,
|
||||||
|
}: {
|
||||||
|
label: string; active: boolean; onClick: () => void; badge?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', borderRadius: 8,
|
||||||
|
border: 'none', background: 'transparent',
|
||||||
|
color: active ? '#1d9bf0' : '#8b8b8b',
|
||||||
|
fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
position: 'relative',
|
||||||
|
borderBottom: active ? '2px solid #1d9bf0' : '2px solid transparent',
|
||||||
|
marginBottom: -2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: 6, padding: '0 6px', height: 16,
|
||||||
|
borderRadius: 8, background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 10, fontWeight: 700,
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>{badge > 99 ? '99+' : badge}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ c, active, onClick }: {
|
||||||
|
c: Contact; active: boolean; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const name = c.username ? `@${c.username}` : (c.alias ?? shortAddr(c.address, 6));
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 14px', borderBottom: '1px solid #1f1f1f',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>{name.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{name}</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>{shortAddr(c.address, 8)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
323
desktop/src/sections/contacts/NewContactModal.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// NewContactModal — send an on-chain CONTACT_REQUEST to a new peer.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Enter @username / DC / hex → resolve into an Ed25519 pub.
|
||||||
|
// 2. Optional intro + fee-tier pick (5k / 10k / 50k µT).
|
||||||
|
// 3. Submit CONTACT_REQUEST tx with amount = contactFee.
|
||||||
|
// The peer sees the request in their Contacts → Requests tab and can
|
||||||
|
// Accept / Reject. After acceptance an encrypted chat becomes possible
|
||||||
|
// via the existing /relay/broadcast pipeline.
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
resolveAccount, getIdentity, getBalance,
|
||||||
|
type IdentityInfo,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { buildContactRequestTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
const FEE_TIERS = [
|
||||||
|
{ value: 5_000, label: 'Min', hint: 'enough for a low-spam node' },
|
||||||
|
{ value: 10_000, label: 'Standard', hint: 'default' },
|
||||||
|
{ value: 50_000, label: 'Priority', hint: 'more attention-grabbing' },
|
||||||
|
];
|
||||||
|
const MIN_NETWORK_FEE = 1_000;
|
||||||
|
|
||||||
|
export function NewContactModal({ onClose, onSent }: {
|
||||||
|
onClose: () => void;
|
||||||
|
onSent: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [resolved, setResolved] = useState<{
|
||||||
|
pub: string; identity: IdentityInfo | null;
|
||||||
|
} | null>(null);
|
||||||
|
const [intro, setIntro] = useState('');
|
||||||
|
const [fee, setFee] = useState<number>(FEE_TIERS[1].value);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const totalCost = fee + MIN_NETWORK_FEE;
|
||||||
|
const insufficient = balance !== null && balance < totalCost;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q) return;
|
||||||
|
setSearching(true); setErr(null); setResolved(null);
|
||||||
|
try {
|
||||||
|
const pub = await resolveAccount(q);
|
||||||
|
if (!pub) { setErr(`Couldn't resolve "${q}"`); return; }
|
||||||
|
if (keyFile && pub.toLowerCase() === keyFile.pub_key.toLowerCase()) {
|
||||||
|
setErr('That\'s you — open Saved Messages in the chat list instead.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = await getIdentity(pub);
|
||||||
|
setResolved({ pub, identity: id });
|
||||||
|
} catch (e) {
|
||||||
|
setErr(String(e));
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!keyFile || !resolved || sending) return;
|
||||||
|
setSending(true); setErr(null);
|
||||||
|
try {
|
||||||
|
const tx = buildContactRequestTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: resolved.pub,
|
||||||
|
contactFee: fee,
|
||||||
|
intro: intro.trim() || undefined,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
onSent();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const peerName = useMemo(() => {
|
||||||
|
if (!resolved) return '';
|
||||||
|
if (resolved.identity?.nickname) return `@${resolved.identity.nickname}`;
|
||||||
|
return shortAddr(resolved.pub, 8);
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Backdrop onClose={sending ? () => {} : onClose}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', maxWidth: 480, padding: 20, borderRadius: 16,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<Header title="Send contact request" onClose={onClose} busy={sending} />
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Label>Who</Label>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') search(); }}
|
||||||
|
placeholder="@username, DC-address, or hex pub"
|
||||||
|
spellCheck={false}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
flex: 1, background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 8, padding: '10px 12px',
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={search}
|
||||||
|
disabled={searching || query.trim().length === 0}
|
||||||
|
style={{
|
||||||
|
padding: '9px 14px', borderRadius: 8, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: searching ? 'default' : 'pointer',
|
||||||
|
opacity: searching || query.trim().length === 0 ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{searching ? '…' : 'Find'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resolved peer preview */}
|
||||||
|
{resolved && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: 12, borderRadius: 10,
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>{peerName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700 }}>
|
||||||
|
{peerName}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}>
|
||||||
|
{resolved.pub}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: resolved.identity?.x25519_pub ? '#3ba55d' : '#f0b35a',
|
||||||
|
fontSize: 11, marginTop: 3,
|
||||||
|
}}>
|
||||||
|
{resolved.identity?.x25519_pub
|
||||||
|
? '✓ has encryption key published'
|
||||||
|
: '⚠ no encryption key on chain yet (messaging disabled until they register)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intro */}
|
||||||
|
{resolved && (
|
||||||
|
<>
|
||||||
|
<Label style={{ marginTop: 14 }}>Intro (optional)</Label>
|
||||||
|
<textarea
|
||||||
|
value={intro}
|
||||||
|
onChange={e => setIntro(e.target.value)}
|
||||||
|
placeholder="Hey — we met at …"
|
||||||
|
rows={2}
|
||||||
|
maxLength={280}
|
||||||
|
style={{
|
||||||
|
width: '100%', boxSizing: 'border-box',
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 8, padding: '10px 12px',
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'inherit',
|
||||||
|
outline: 'none', resize: 'vertical',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fee tiers */}
|
||||||
|
{resolved && (
|
||||||
|
<>
|
||||||
|
<Label style={{ marginTop: 14 }}>Anti-spam fee (paid to recipient)</Label>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{FEE_TIERS.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
onClick={() => setFee(t.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1, minWidth: 120,
|
||||||
|
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
background: fee === t.value ? '#0a1a29' : '#000',
|
||||||
|
border: fee === t.value ? '1px solid #1d9bf0' : '1px solid #1f1f1f',
|
||||||
|
color: '#fff', textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
color: fee === t.value ? '#1d9bf0' : '#fff',
|
||||||
|
}}>{t.label}</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#8b8b8b', marginTop: 2 }}>
|
||||||
|
{(t.value / 1_000_000).toFixed(3)} T · {t.hint}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary + actions */}
|
||||||
|
{resolved && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, color: '#8b8b8b', fontSize: 11, lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
Cost: <span style={{ color: '#fff' }}>
|
||||||
|
{(totalCost / 1_000_000).toFixed(3)} T
|
||||||
|
</span> ({(fee / 1_000_000).toFixed(3)} to recipient · {(MIN_NETWORK_FEE / 1_000_000).toFixed(3)} network fee)
|
||||||
|
{balance !== null && (
|
||||||
|
<> · Balance: <span style={{
|
||||||
|
color: insufficient ? '#f4212e' : '#fff',
|
||||||
|
}}>{(balance / 1_000_000).toFixed(3)} T</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: 10, borderRadius: 8,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||||
|
}}>{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={sending}
|
||||||
|
style={{
|
||||||
|
padding: '9px 14px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #1f1f1f',
|
||||||
|
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: sending ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={send}
|
||||||
|
disabled={!resolved || insufficient || sending}
|
||||||
|
style={{
|
||||||
|
padding: '9px 18px', borderRadius: 999, border: 'none',
|
||||||
|
background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: (!resolved || insufficient || sending) ? 'default' : 'pointer',
|
||||||
|
opacity: (!resolved || insufficient || sending) ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{sending ? '…' : 'Send request'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Backdrop>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── small shared primitives (private to this file — Contacts is the only caller)
|
||||||
|
|
||||||
|
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 20,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header({ title, onClose, busy }: {
|
||||||
|
title: string; onClose: () => void; busy: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
marginBottom: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
|
||||||
|
...style,
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
desktop/src/sections/contacts/RequestsList.tsx
Normal file
203
desktop/src/sections/contacts/RequestsList.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
// RequestsList — pending contact requests inbox.
|
||||||
|
//
|
||||||
|
// Each row shows the requester (identity if known + DC address + fee paid)
|
||||||
|
// and their intro message. Accept publishes ACCEPT_CONTACT on-chain,
|
||||||
|
// adds the peer to the local contacts store, and optimistically drops
|
||||||
|
// the row. Reject (Block) publishes BLOCK_CONTACT; subsequent requests
|
||||||
|
// from the same sender are refused by the node.
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
buildAcceptContactTx, buildBlockContactTx, buildLinkDeviceTx,
|
||||||
|
submitTx, humanizeTxError,
|
||||||
|
} from '@/lib/tx';
|
||||||
|
import { upsertContact as persistContact, markDeviceRegistered, isDeviceRegistered } from '@/lib/storage';
|
||||||
|
import { getIdentity, fetchDevices, type ContactRequestRaw } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
export function RequestsList({
|
||||||
|
requests, onChanged,
|
||||||
|
}: {
|
||||||
|
requests: ContactRequestRaw[];
|
||||||
|
onChanged: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
if (requests.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
No pending requests. Inbound CONTACT_REQUEST txs will show up here
|
||||||
|
for you to accept or block.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{requests.map(r => (
|
||||||
|
<RequestRow key={r.tx_id} req={r} onChanged={onChanged} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestRow({
|
||||||
|
req, onChanged,
|
||||||
|
}: { req: ContactRequestRaw; onChanged: () => void }) {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const upsertContact = useStore(s => s.upsertContact);
|
||||||
|
const setSection = useStore(s => s.setSection);
|
||||||
|
const setActiveChat = useStore(s => s.setActiveChat);
|
||||||
|
|
||||||
|
const [busy, setBusy] = useState<'accept' | 'block' | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const act = async (kind: 'accept' | 'block') => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setBusy(kind); setErr(null);
|
||||||
|
try {
|
||||||
|
if (kind === 'accept') {
|
||||||
|
// Need the requester's X25519 so a local contact is created
|
||||||
|
// with encryption enabled out of the gate — without it the
|
||||||
|
// first outgoing message would surface "no key" until we
|
||||||
|
// refetched via resolveRecipientKeys.
|
||||||
|
const identity = await getIdentity(req.requester_pub);
|
||||||
|
const tx = buildAcceptContactTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: req.requester_pub,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
|
||||||
|
// Make sure OUR device is published on-chain too. The
|
||||||
|
// useDeviceBootstrap effect tries this on sign-in, but if the
|
||||||
|
// user had zero balance then the tx bounced; now that the
|
||||||
|
// incoming CONTACT_REQUEST has paid us the contact fee, we
|
||||||
|
// have the µT needed. Without this, the peer couldn't encrypt
|
||||||
|
// to us — they'd see "recipient has no encryption key" even
|
||||||
|
// though we just accepted.
|
||||||
|
try {
|
||||||
|
const ownDevices = await fetchDevices(keyFile.pub_key);
|
||||||
|
const alreadyLinked = ownDevices.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||||
|
if (!alreadyLinked && !isDeviceRegistered()) {
|
||||||
|
const platform = await window.dchain.app.platform().catch(() => 'unknown');
|
||||||
|
const deviceName = platform === 'darwin' ? 'Mac'
|
||||||
|
: platform === 'win32' ? 'Windows'
|
||||||
|
: platform === 'linux' ? 'Linux'
|
||||||
|
: 'Desktop';
|
||||||
|
const linkTx = buildLinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: keyFile.x25519_pub,
|
||||||
|
deviceName,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(linkTx);
|
||||||
|
markDeviceRegistered();
|
||||||
|
}
|
||||||
|
} catch { /* best-effort — next sign-in retries */ }
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
address: req.requester_pub,
|
||||||
|
x25519Pub: identity?.x25519_pub ?? '',
|
||||||
|
username: identity?.nickname || undefined,
|
||||||
|
alias: undefined,
|
||||||
|
addedAt: Date.now(),
|
||||||
|
};
|
||||||
|
upsertContact(c);
|
||||||
|
persistContact(c);
|
||||||
|
// Jump the user straight into the new chat — mirrors mobile's
|
||||||
|
// router.replace(/chats/<pub>) after accept.
|
||||||
|
setActiveChat(req.requester_pub);
|
||||||
|
setSection('messages');
|
||||||
|
} else {
|
||||||
|
const tx = buildBlockContactTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
to: req.requester_pub,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
}
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 14, borderBottom: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: '#d0d0d0', fontWeight: 700,
|
||||||
|
}}>{shortAddr(req.requester_pub, 1).charAt(0).toUpperCase()}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 700,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
{shortAddr(req.requester_pub, 8)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
{req.requester_addr}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#f0b35a', fontSize: 11, fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
+{(req.fee_ut / 1_000_000).toFixed(3)} T
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{req.intro && (
|
||||||
|
<div className="selectable" style={{
|
||||||
|
padding: 10, borderRadius: 8,
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
color: '#e0e0e0', fontSize: 12, lineHeight: 1.5,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{req.intro}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => act('block')}
|
||||||
|
disabled={!!busy}
|
||||||
|
style={{
|
||||||
|
padding: '7px 12px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #3a2020',
|
||||||
|
color: '#ff6b6b', fontSize: 12, fontWeight: 700,
|
||||||
|
cursor: busy ? 'default' : 'pointer',
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{busy === 'block' ? '…' : 'Block'}</button>
|
||||||
|
<button
|
||||||
|
onClick={() => act('accept')}
|
||||||
|
disabled={!!busy}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 999,
|
||||||
|
border: 'none', background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
cursor: busy ? 'default' : 'pointer',
|
||||||
|
opacity: busy ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{busy === 'accept' ? '…' : 'Accept'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 8, padding: 8, borderRadius: 6,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 11,
|
||||||
|
}}>{err}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
import React from 'react';
|
// Contacts section.
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
// List pane: contact list + quick filter.
|
||||||
|
// Detail pane: selected contact's profile card + actions.
|
||||||
|
|
||||||
export function ContactsList(): React.ReactElement {
|
export { ContactsList } from './ContactsList';
|
||||||
return <SectionPlaceholder title="Contacts" note="All · Online · Blocked · Requests" />;
|
export { ContactsDetail } from './ContactsDetail';
|
||||||
}
|
|
||||||
export function ContactsDetail(): React.ReactElement {
|
|
||||||
return <SectionPlaceholder title="Contacts" note="Pick a contact to see details." centered />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
|
|||||||
import { appendMessage as persist } from '@/lib/storage';
|
import { appendMessage as persist } from '@/lib/storage';
|
||||||
import type { Message } from '@/lib/types';
|
import type { Message } from '@/lib/types';
|
||||||
|
|
||||||
|
// A module-level stable reference for "no messages yet". Without this the
|
||||||
|
// selector `s.messages[address] ?? []` allocates a fresh empty array on
|
||||||
|
// every render when the conversation has no cached entries, which zustand
|
||||||
|
// sees as a changed value → triggers another render → new empty array
|
||||||
|
// again → "Maximum update depth exceeded". Returning the exact same
|
||||||
|
// reference every time breaks the cycle.
|
||||||
|
const EMPTY_MESSAGES: Message[] = [];
|
||||||
|
|
||||||
export function Conversation({ address }: { address: string }): React.ReactElement {
|
export function Conversation({ address }: { address: string }): React.ReactElement {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
const contact = useStore(s => s.contacts.find(c => c.address === address));
|
||||||
const messages = useStore(s => s.messages[address] ?? []);
|
const messages = useStore(s => s.messages[address] ?? EMPTY_MESSAGES);
|
||||||
const clearUnread = useStore(s => s.clearUnread);
|
const clearUnread = useStore(s => s.clearUnread);
|
||||||
const appendMsg = useStore(s => s.appendMessage);
|
const appendMsg = useStore(s => s.appendMessage);
|
||||||
|
|
||||||
@@ -57,7 +65,15 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
|||||||
if (!isSelf) {
|
if (!isSelf) {
|
||||||
const pubs = await resolveRecipientKeys(address);
|
const pubs = await resolveRecipientKeys(address);
|
||||||
if (pubs.length === 0) {
|
if (pubs.length === 0) {
|
||||||
throw new Error('recipient has no encryption key published');
|
// Most common cause: the peer's device hasn't published a
|
||||||
|
// LINK_DEVICE yet (they accepted just now and haven't had the
|
||||||
|
// fee debited, or they haven't re-opened the app). Clearer
|
||||||
|
// copy than "recipient has no encryption key".
|
||||||
|
throw new Error(
|
||||||
|
'Recipient has no device key published on-chain yet. ' +
|
||||||
|
'Ask them to re-open their app so the LINK_DEVICE tx commits, ' +
|
||||||
|
'then try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(pubs.map(async (rpub) => {
|
await Promise.all(pubs.map(async (rpub) => {
|
||||||
const { nonce, ciphertext } = encryptMessage(
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
@@ -98,12 +114,13 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const name = contact?.username ? `@${contact.username}`
|
const name = (contact?.username ? `@${contact.username}`
|
||||||
: contact?.alias
|
: contact?.alias
|
||||||
? contact.alias
|
? contact.alias
|
||||||
: isSelf
|
: isSelf
|
||||||
? 'Saved Messages'
|
? 'Saved Messages'
|
||||||
: shortAddr(address, 8);
|
: shortAddr(address || '', 8)) || shortAddr(address || '', 8);
|
||||||
|
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -118,12 +135,18 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
|
|||||||
color: '#fff', fontWeight: 700, fontSize: 14,
|
color: '#fff', fontWeight: 700, fontSize: 14,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
}}>
|
}}>
|
||||||
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
|
{isSelf ? '★' : firstLetter}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
|
<div style={{
|
||||||
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
|
color: '#fff', fontSize: 14, fontWeight: 700,
|
||||||
{shortAddr(address, 6)}
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>{name}</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
|
}}>
|
||||||
|
{shortAddr(address || '', 6)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,43 +1,218 @@
|
|||||||
import React from 'react';
|
// Profile section — "You" view. List pane shows the avatar card,
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
// detail pane shows stats + devices summary.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getBalance, getIdentity, fetchDevices, type IdentityInfo, type DeviceInfo } from '@/lib/api';
|
||||||
|
import { shortAddr } from '@/lib/crypto';
|
||||||
|
|
||||||
|
function formatT(ut: number): string {
|
||||||
|
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
}
|
||||||
|
|
||||||
export function ProfileList(): React.ReactElement {
|
export function ProfileList(): React.ReactElement {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const contactsCount = useStore(s => s.contacts.length);
|
||||||
|
if (!keyFile) return <></>;
|
||||||
|
const letter = keyFile.pub_key.slice(0, 1).toUpperCase();
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 14 }}>
|
<div style={{ padding: 14 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: 14, borderRadius: 14,
|
padding: 16, borderRadius: 14,
|
||||||
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
textAlign: 'center',
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 48, height: 48, borderRadius: 24,
|
width: 72, height: 72, borderRadius: 36, margin: '0 auto 10px',
|
||||||
background: '#1d9bf0', display: 'flex',
|
background: '#1d9bf0', color: '#fff',
|
||||||
alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: '#fff', fontWeight: 800, fontSize: 20,
|
fontSize: 30, fontWeight: 800,
|
||||||
}}>
|
}}>{letter}</div>
|
||||||
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
|
<div style={{ color: '#fff', fontSize: 18, fontWeight: 700 }}>
|
||||||
</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
|
|
||||||
You
|
You
|
||||||
</div>
|
</div>
|
||||||
<div className="selectable" style={{
|
<div className="selectable" style={{
|
||||||
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
marginTop: 4, wordBreak: 'break-all',
|
marginTop: 6, wordBreak: 'break-all',
|
||||||
}}>
|
}}>{keyFile.pub_key}</div>
|
||||||
{keyFile?.pub_key}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, padding: 12, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
color: '#8b8b8b', fontSize: 12, lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{contactsCount} contact{contactsCount === 1 ? '' : 's'} stored on this device.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProfileDetail(): React.ReactElement {
|
export function ProfileDetail(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setSection = useStore(s => s.setSection);
|
||||||
|
const setPage = useStore(s => s.setSettingsPage);
|
||||||
|
const setFeedTab = useStore(s => s.setFeedTab);
|
||||||
|
|
||||||
|
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
|
||||||
|
const [balance, setBalance] = useState<number | null>(null);
|
||||||
|
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [id, bal, devs] = await Promise.all([
|
||||||
|
getIdentity(keyFile.pub_key),
|
||||||
|
getBalance(keyFile.pub_key),
|
||||||
|
fetchDevices(keyFile.pub_key),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setIdentity(id);
|
||||||
|
setBalance(bal);
|
||||||
|
setDevices(devs);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
if (!keyFile) return <></>;
|
||||||
|
|
||||||
|
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
|
||||||
|
|
||||||
|
const viewMyPosts = () => {
|
||||||
|
setFeedTab({ kind: 'author', pub: keyFile.pub_key });
|
||||||
|
setSection('feed');
|
||||||
|
};
|
||||||
|
const openDevices = () => {
|
||||||
|
setSection('settings');
|
||||||
|
setPage('devices');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionPlaceholder
|
<div style={{
|
||||||
title="Your profile"
|
height: '100%', overflowY: 'auto',
|
||||||
note="Balance, username, devices — coming soon."
|
padding: '22px 26px', background: '#000',
|
||||||
centered
|
}}>
|
||||||
/>
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||||||
|
gap: 12, flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase',
|
||||||
|
}}>Balance</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 34, fontWeight: 800 }}>
|
||||||
|
{balance === null ? '—' : `${formatT(balance)} T`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Action onClick={viewMyPosts}>My posts</Action>
|
||||||
|
<Action onClick={openDevices}>Manage devices ({devices.length})</Action>
|
||||||
|
<Action onClick={() => copy(keyFile.pub_key)}>Copy address</Action>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{identity && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 24, padding: 14, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<Row k="DC address" v={identity.address} onCopy={() => copy(identity.address)} />
|
||||||
|
<Row k="Username" v={identity.nickname ? `@${identity.nickname}` : '—'} />
|
||||||
|
<Row k="Published X25519" v={shortAddr(identity.x25519_pub, 10) || '—'} />
|
||||||
|
<Row k="Registered" v={identity.registered ? 'yes' : 'no'} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Devices summary */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, padding: 14, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10,
|
||||||
|
}}>Linked devices</div>
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13 }}>
|
||||||
|
No devices registered yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
devices.map((d, i) => (
|
||||||
|
<div
|
||||||
|
key={d.x25519_pub_key}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
padding: '8px 0',
|
||||||
|
borderTop: i === 0 ? undefined : '1px solid #141414',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
width: 28, height: 28, borderRadius: 6,
|
||||||
|
background: d.x25519_pub_key === keyFile.x25519_pub ? '#0d2540' : '#1a1a1a',
|
||||||
|
color: d.x25519_pub_key === keyFile.x25519_pub ? '#1d9bf0' : '#d0d0d0',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 14,
|
||||||
|
}}>📱</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{d.device_name}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
|
||||||
|
{shortAddr(d.x25519_pub_key, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{d.x25519_pub_key === keyFile.x25519_pub && (
|
||||||
|
<span style={{
|
||||||
|
padding: '1px 6px', borderRadius: 6,
|
||||||
|
background: '#0d2540', color: '#1d9bf0',
|
||||||
|
fontSize: 10, fontWeight: 700,
|
||||||
|
}}>THIS DEVICE</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Action({ children, onClick }: {
|
||||||
|
children: React.ReactNode; onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '9px 14px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #1f1f1f',
|
||||||
|
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>{children}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ k, v, onCopy }: { k: string; v: string; onCopy?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', padding: '6px 0',
|
||||||
|
borderBottom: '1px solid #141414',
|
||||||
|
alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 160px' }}>{k}</div>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
flex: 1, wordBreak: 'break-all',
|
||||||
|
}}>{v}</div>
|
||||||
|
{onCopy && (
|
||||||
|
<button
|
||||||
|
onClick={onCopy}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#1d9bf0', fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>copy</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
59
desktop/src/sections/settings/AboutPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
// AboutPage — version info, platform, build links. Reads app.version
|
||||||
|
// via the preload IPC bridge.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { PageLayout } from './PageLayout';
|
||||||
|
import { Card, Label, Hint } from './NodePage';
|
||||||
|
|
||||||
|
export function AboutPage(): React.ReactElement {
|
||||||
|
const [version, setVersion] = useState('dev');
|
||||||
|
const [platform, setPlatform] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.dchain?.app.version().then(setVersion).catch(() => {});
|
||||||
|
window.dchain?.app.platform().then(setPlatform).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout title="About">
|
||||||
|
<Card>
|
||||||
|
<Label>Build</Label>
|
||||||
|
<div style={{ color: '#fff', fontSize: 14, fontFamily: 'monospace' }}>
|
||||||
|
DChain Desktop v{version}
|
||||||
|
</div>
|
||||||
|
<Hint>
|
||||||
|
Running on {platform || 'unknown'} · Electron / Chromium
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Label>Links</Label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<LinkRow
|
||||||
|
href="https://git.vsecoder.vodka/vsecoder/dchain"
|
||||||
|
label="Source code (Gitea)"
|
||||||
|
/>
|
||||||
|
<LinkRow
|
||||||
|
href="https://git.vsecoder.vodka/vsecoder/dchain/releases"
|
||||||
|
label="Releases"
|
||||||
|
/>
|
||||||
|
<LinkRow
|
||||||
|
href="https://git.vsecoder.vodka/vsecoder/dchain/src/branch/main/docs"
|
||||||
|
label="Documentation"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkRow({ href, label }: { href: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{ color: '#1d9bf0', fontSize: 13, textDecoration: 'none' }}
|
||||||
|
>{label} ↗</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
353
desktop/src/sections/settings/DevicesPage.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
// DevicesPage — multi-device registry UI.
|
||||||
|
//
|
||||||
|
// Top: list of on-chain devices for this identity. Each row has:
|
||||||
|
// * badge for "this device" (cannot be unlinked from here — you'd
|
||||||
|
// wipe yourself on next boot)
|
||||||
|
// * device name + truncated X25519 pub + added-at
|
||||||
|
// * Unlink button for others (submits UNLINK_DEVICE tx)
|
||||||
|
//
|
||||||
|
// Bottom: "Link new device" modal, same protocol as mobile's
|
||||||
|
// Settings → Devices → Link new device.
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
fetchDevices, type DeviceInfo,
|
||||||
|
} from '@/lib/api';
|
||||||
|
import { buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, humanizeTxError } from '@/lib/tx';
|
||||||
|
import { sendEnvelope } from '@/lib/relay';
|
||||||
|
import { encryptMessage, shortAddr } from '@/lib/crypto';
|
||||||
|
import { PageLayout } from './PageLayout';
|
||||||
|
import { Card, Label, Hint, inputStyle } from './NodePage';
|
||||||
|
import { Button } from './IdentityPage';
|
||||||
|
|
||||||
|
export function DevicesPage(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [devs, setDevs] = useState<DeviceInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [unlinking, setUnlinking] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [linkOpen, setLinkOpen] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setDevs(await fetchDevices(keyFile.pub_key));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const onUnlink = useCallback(async (d: DeviceInfo) => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
if (!confirm(
|
||||||
|
`Unlink "${d.device_name}"? It will stop receiving messages sent to you. ` +
|
||||||
|
`The device itself will wipe its local state next time it checks in. ` +
|
||||||
|
`This costs a small network fee.`,
|
||||||
|
)) return;
|
||||||
|
setUnlinking(d.x25519_pub_key);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const tx = buildUnlinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: d.x25519_pub_key,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(tx);
|
||||||
|
setDevs(prev => prev.filter(x => x.x25519_pub_key !== d.x25519_pub_key));
|
||||||
|
setNotice(`Unlinked — registry will converge in a block or two.`);
|
||||||
|
} catch (e) {
|
||||||
|
setNotice(`Unlink failed: ${humanizeTxError(e)}`);
|
||||||
|
} finally {
|
||||||
|
setUnlinking(null);
|
||||||
|
}
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
|
const meX25519 = keyFile?.x25519_pub ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
title="Devices"
|
||||||
|
subtitle="Every linked device gets its own encryption key; messages sent to you are delivered to all of them."
|
||||||
|
>
|
||||||
|
<Card>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
}}>
|
||||||
|
<Label>Linked devices</Label>
|
||||||
|
<Button onClick={() => setLinkOpen(true)}>Link new device</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>Loading…</div>
|
||||||
|
) : devs.length === 0 ? (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>
|
||||||
|
No devices registered yet. This device auto-links once a small
|
||||||
|
network fee is available in your balance — pull to refresh after
|
||||||
|
a first transfer if the list stays empty.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
{devs.map((d, i) => (
|
||||||
|
<DeviceRow
|
||||||
|
key={d.x25519_pub_key}
|
||||||
|
d={d}
|
||||||
|
isMe={d.x25519_pub_key === meX25519}
|
||||||
|
unlinking={unlinking === d.x25519_pub_key}
|
||||||
|
onUnlink={() => onUnlink(d)}
|
||||||
|
first={i === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, padding: 10, borderRadius: 8,
|
||||||
|
background: notice.startsWith('Unlink failed')
|
||||||
|
? '#2a1414' : '#0d2540',
|
||||||
|
color: notice.startsWith('Unlink failed') ? '#ff9b9b' : '#1d9bf0',
|
||||||
|
fontSize: 12,
|
||||||
|
}}>{notice}</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{linkOpen && (
|
||||||
|
<LinkNewDeviceModal
|
||||||
|
onClose={() => setLinkOpen(false)}
|
||||||
|
onLinked={() => { setLinkOpen(false); setTimeout(load, 1000); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DeviceRow({
|
||||||
|
d, isMe, unlinking, onUnlink, first,
|
||||||
|
}: {
|
||||||
|
d: DeviceInfo; isMe: boolean; unlinking: boolean;
|
||||||
|
onUnlink: () => void; first: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
padding: '12px 0',
|
||||||
|
borderTop: first ? undefined : '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 8,
|
||||||
|
background: isMe ? '#0d2540' : '#1a1a1a',
|
||||||
|
color: isMe ? '#1d9bf0' : '#d0d0d0',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 16,
|
||||||
|
}}>📱</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
|
||||||
|
{d.device_name || 'Unnamed device'}
|
||||||
|
</span>
|
||||||
|
{isMe && (
|
||||||
|
<span style={{
|
||||||
|
padding: '1px 6px', borderRadius: 6,
|
||||||
|
background: '#0d2540', color: '#1d9bf0',
|
||||||
|
fontSize: 10, fontWeight: 700, letterSpacing: 0.5,
|
||||||
|
}}>THIS DEVICE</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
||||||
|
marginTop: 3,
|
||||||
|
}}>
|
||||||
|
{shortAddr(d.x25519_pub_key, 10)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||||
|
Linked {new Date(d.added_at * 1000).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isMe && (
|
||||||
|
<button
|
||||||
|
onClick={onUnlink}
|
||||||
|
disabled={unlinking}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #3a2020',
|
||||||
|
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
|
||||||
|
cursor: unlinking ? 'default' : 'pointer',
|
||||||
|
opacity: unlinking ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{unlinking ? '…' : 'Unlink'}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Link New Device modal ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LinkNewDeviceModal({
|
||||||
|
onClose, onLinked,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onLinked: () => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
|
||||||
|
const [code, setCode] = useState('');
|
||||||
|
const [key, setKey] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!keyFile) return;
|
||||||
|
const c = code.replace(/\s+/g, '').trim();
|
||||||
|
const k = key.replace(/\s+/g, '').trim().toLowerCase();
|
||||||
|
if (!/^\d{6}$/.test(c)) { setErr('Code must be 6 digits.'); return; }
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(k)) { setErr('Device key must be 64 hex chars.'); return; }
|
||||||
|
const nm = name.trim() || 'New device';
|
||||||
|
setBusy(true); setErr(null);
|
||||||
|
try {
|
||||||
|
// 1. LINK_DEVICE tx → registry learns the new pub.
|
||||||
|
const linkTx = buildLinkDeviceTx({
|
||||||
|
from: keyFile.pub_key,
|
||||||
|
x25519Pub: k,
|
||||||
|
deviceName: nm,
|
||||||
|
privKey: keyFile.priv_key,
|
||||||
|
});
|
||||||
|
await submitTx(linkTx);
|
||||||
|
// 2. Handshake envelope — encrypt master priv for the new device.
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
type: 'pair-handshake',
|
||||||
|
code: c,
|
||||||
|
master_pub: keyFile.pub_key,
|
||||||
|
master_priv: keyFile.priv_key,
|
||||||
|
master_x25519_pub: keyFile.x25519_pub,
|
||||||
|
});
|
||||||
|
const { nonce, ciphertext } = encryptMessage(
|
||||||
|
payload, keyFile.x25519_priv, k,
|
||||||
|
);
|
||||||
|
await sendEnvelope({
|
||||||
|
senderPub: keyFile.x25519_pub,
|
||||||
|
recipientPub: k,
|
||||||
|
senderEd25519Pub: keyFile.pub_key,
|
||||||
|
nonce, ciphertext,
|
||||||
|
});
|
||||||
|
onLinked();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(humanizeTxError(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => !busy && onClose()}
|
||||||
|
style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 20,
|
||||||
|
background: 'rgba(0,0,0,0.7)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: '100%', maxWidth: 520, padding: 20, borderRadius: 16,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
|
||||||
|
Link new device
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose} disabled={busy}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
<Hint>
|
||||||
|
On the new device, tap <b>Pair</b> on the welcome screen and
|
||||||
|
transcribe the 6-digit code and device key from there into the
|
||||||
|
fields below.
|
||||||
|
</Hint>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<Field>
|
||||||
|
<Label>6-digit code</Label>
|
||||||
|
<input
|
||||||
|
value={code}
|
||||||
|
onChange={e => setCode(e.target.value)}
|
||||||
|
placeholder="000000"
|
||||||
|
inputMode="numeric"
|
||||||
|
maxLength={6}
|
||||||
|
style={{ ...inputStyle, letterSpacing: 4, textAlign: 'center' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Device key (64 hex)</Label>
|
||||||
|
<input
|
||||||
|
value={key}
|
||||||
|
onChange={e => setKey(e.target.value)}
|
||||||
|
placeholder="a1b2c3…"
|
||||||
|
spellCheck={false}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Name (optional)</Label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Alice's laptop"
|
||||||
|
maxLength={64}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'inherit' }}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: 10, borderRadius: 8,
|
||||||
|
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
|
||||||
|
}}>{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
style={{
|
||||||
|
padding: '9px 14px', borderRadius: 999,
|
||||||
|
background: 'transparent', border: '1px solid #1f1f1f',
|
||||||
|
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
|
||||||
|
cursor: busy ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
>Cancel</button>
|
||||||
|
<Button onClick={submit} disabled={busy}>{busy ? '…' : 'Link'}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div>{children}</div>;
|
||||||
|
}
|
||||||
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
159
desktop/src/sections/settings/IdentityPage.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
// Identity settings — pub key, copy, export/import key file, delete account.
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { saveKeyFile, wipeAllLocalState } from '@/lib/storage';
|
||||||
|
import type { KeyFile } from '@/lib/types';
|
||||||
|
import { PageLayout } from './PageLayout';
|
||||||
|
import { Card, Label, Hint } from './NodePage';
|
||||||
|
|
||||||
|
export function IdentityPage(): React.ReactElement {
|
||||||
|
const keyFile = useStore(s => s.keyFile);
|
||||||
|
const setKeyFile = useStore(s => s.setKeyFile);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!keyFile) return <PageLayout title="Identity"><div>No identity loaded.</div></PageLayout>;
|
||||||
|
|
||||||
|
const copy = async (s: string, label: string) => {
|
||||||
|
await navigator.clipboard.writeText(s);
|
||||||
|
setNotice(`${label} copied`);
|
||||||
|
setTimeout(() => setNotice(null), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportKey = async () => {
|
||||||
|
const target = await window.dchain.dialog.saveFile({
|
||||||
|
title: 'Export key file',
|
||||||
|
defaultPath: 'node.json',
|
||||||
|
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||||
|
});
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await window.dchain.fs.writeText(target, JSON.stringify(keyFile, null, 2));
|
||||||
|
setNotice('Key saved — keep it offline + backed up.');
|
||||||
|
} catch (e) {
|
||||||
|
setNotice(`Export failed: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importKey = async () => {
|
||||||
|
const src = await window.dchain.dialog.openFile({
|
||||||
|
title: 'Import key file',
|
||||||
|
filters: [{ name: 'JSON', extensions: ['json'] }],
|
||||||
|
properties: ['openFile'],
|
||||||
|
});
|
||||||
|
if (!src) return;
|
||||||
|
try {
|
||||||
|
const raw = await window.dchain.fs.readText(src);
|
||||||
|
const parsed = JSON.parse(raw) as KeyFile;
|
||||||
|
if (!parsed.pub_key || !parsed.priv_key) throw new Error('not a key file');
|
||||||
|
if (!confirm('Replace the current identity with the imported one? The current identity will be wiped from this device.')) return;
|
||||||
|
await wipeAllLocalState();
|
||||||
|
await saveKeyFile(parsed);
|
||||||
|
setKeyFile(parsed);
|
||||||
|
setNotice('Imported — reload is not needed, new identity active.');
|
||||||
|
} catch (e) {
|
||||||
|
setNotice(`Import failed: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
if (!confirm('Delete this identity from this device? Keys are NOT recoverable from the server — export first if you want to keep them.')) return;
|
||||||
|
await wipeAllLocalState();
|
||||||
|
setKeyFile(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout title="Identity" subtitle="Your Ed25519 master key. Keep it safe — there is no password recovery.">
|
||||||
|
<Card>
|
||||||
|
<Label>Public key (Ed25519, hex)</Label>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{keyFile.pub_key}
|
||||||
|
</div>
|
||||||
|
<ActionRow>
|
||||||
|
<Button onClick={() => copy(keyFile.pub_key, 'Pub key')}>Copy</Button>
|
||||||
|
</ActionRow>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Label>Device encryption key (X25519, hex)</Label>
|
||||||
|
<div className="selectable" style={{
|
||||||
|
color: '#fff', fontSize: 12, fontFamily: 'monospace',
|
||||||
|
wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
}}>
|
||||||
|
{keyFile.x25519_pub}
|
||||||
|
</div>
|
||||||
|
<Hint>
|
||||||
|
Only this device uses this X25519 pair. Sharing the master Ed25519
|
||||||
|
pub (above) is how contacts find you across all your devices.
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Label>Backup</Label>
|
||||||
|
<ActionRow>
|
||||||
|
<Button onClick={exportKey}>Export key file</Button>
|
||||||
|
<Button onClick={importKey} danger>Import / replace</Button>
|
||||||
|
</ActionRow>
|
||||||
|
<Hint>
|
||||||
|
Exports a JSON file compatible with the mobile client and
|
||||||
|
server's <code>--key</code> flag. The file is <strong>not</strong>
|
||||||
|
encrypted on disk — store it somewhere safe.
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Label>Danger zone</Label>
|
||||||
|
<ActionRow>
|
||||||
|
<Button onClick={deleteAccount} danger>Delete this identity</Button>
|
||||||
|
</ActionRow>
|
||||||
|
<Hint>
|
||||||
|
Wipes the key, contacts, and chat cache from this device.
|
||||||
|
Without an export, this is irreversible.
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<div style={{
|
||||||
|
padding: 10, borderRadius: 8,
|
||||||
|
background: '#0d2540', color: '#1d9bf0', fontSize: 12,
|
||||||
|
}}>{notice}</div>
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionRow({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
children, onClick, danger, disabled,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
padding: '8px 14px', borderRadius: 999,
|
||||||
|
background: danger ? 'transparent' : '#1d9bf0',
|
||||||
|
border: danger ? '1px solid #3a2020' : 'none',
|
||||||
|
color: danger ? '#ff6b6b' : '#fff',
|
||||||
|
fontSize: 12, fontWeight: 700,
|
||||||
|
cursor: disabled ? 'default' : 'pointer',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>{children}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
desktop/src/sections/settings/NodePage.tsx
Normal file
115
desktop/src/sections/settings/NodePage.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Node settings page — URL, connection ping-on-commit, token field.
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { getNetStats, setNodeUrl, setApiToken } from '@/lib/api';
|
||||||
|
import { saveSettings } from '@/lib/storage';
|
||||||
|
import { PageLayout } from './PageLayout';
|
||||||
|
|
||||||
|
export function NodePage(): React.ReactElement {
|
||||||
|
const settings = useStore(s => s.settings);
|
||||||
|
const setSettings = useStore(s => s.setSettings);
|
||||||
|
|
||||||
|
const [url, setUrl] = useState(settings.nodeUrl);
|
||||||
|
const [token, setToken] = useState(settings.apiToken ?? '');
|
||||||
|
const [ok, setOk] = useState<boolean | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { setUrl(settings.nodeUrl); setToken(settings.apiToken ?? ''); },
|
||||||
|
[settings.nodeUrl, settings.apiToken]);
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
const clean = url.trim().replace(/\/$/, '');
|
||||||
|
if (!clean) return;
|
||||||
|
setBusy(true); setOk(null);
|
||||||
|
setNodeUrl(clean);
|
||||||
|
setApiToken(token.trim() || null);
|
||||||
|
try {
|
||||||
|
await getNetStats();
|
||||||
|
setOk(true);
|
||||||
|
const next = { nodeUrl: clean, apiToken: token.trim() || undefined };
|
||||||
|
setSettings(next);
|
||||||
|
saveSettings(next);
|
||||||
|
} catch {
|
||||||
|
setOk(false);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout title="Node" subtitle="Which DChain node this client talks to">
|
||||||
|
<Card>
|
||||||
|
<Label>Node URL</Label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
||||||
|
<input
|
||||||
|
value={url}
|
||||||
|
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
||||||
|
onBlur={apply}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
||||||
|
placeholder="http://node.example:8080"
|
||||||
|
spellCheck={false}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
{busy && <span style={{ color: '#8b8b8b', fontSize: 11 }}>…</span>}
|
||||||
|
</div>
|
||||||
|
<Hint>
|
||||||
|
Enter or tab-out to ping. Green dot = `/api/netstats` replied.
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Label>API token (optional)</Label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={e => setToken(e.target.value)}
|
||||||
|
onBlur={apply}
|
||||||
|
placeholder="paste Bearer token if node requires it"
|
||||||
|
spellCheck={false}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<Hint>
|
||||||
|
Some nodes gate writes with DCHAIN_API_TOKEN; leave blank for
|
||||||
|
public ones.
|
||||||
|
</Hint>
|
||||||
|
</Card>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Reusable primitives (also imported by Identity / Devices / About) ───
|
||||||
|
|
||||||
|
export function Card({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 14, marginBottom: 14, borderRadius: 12,
|
||||||
|
background: '#0a0a0a', border: '1px solid #1f1f1f',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Label({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 8,
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Hint({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6, lineHeight: 1.5 }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export const inputStyle: React.CSSProperties = {
|
||||||
|
flex: 1, boxSizing: 'border-box',
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
borderRadius: 8, padding: '10px 12px',
|
||||||
|
color: '#fff', fontSize: 13, fontFamily: 'monospace',
|
||||||
|
outline: 'none', width: '100%',
|
||||||
|
};
|
||||||
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
33
desktop/src/sections/settings/PageLayout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// Shared layout for Settings subsection pages — sticky header with the
|
||||||
|
// page title + scroll body. Keeps spacing consistent across Node /
|
||||||
|
// Identity / Devices / About.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function PageLayout({
|
||||||
|
title, subtitle, children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
height: '100%', overflowY: 'auto', background: '#000',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'sticky', top: 0, zIndex: 1,
|
||||||
|
padding: '14px 22px', borderBottom: '1px solid #1f1f1f',
|
||||||
|
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#fff', fontSize: 16, fontWeight: 800 }}>{title}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '18px 22px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
18
desktop/src/sections/settings/SettingsDetail.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Right-pane content for Settings. Renders by store.settingsPage.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore } from '@/lib/store';
|
||||||
|
import { NodePage } from './NodePage';
|
||||||
|
import { IdentityPage } from './IdentityPage';
|
||||||
|
import { DevicesPage } from './DevicesPage';
|
||||||
|
import { AboutPage } from './AboutPage';
|
||||||
|
|
||||||
|
export function SettingsDetail(): React.ReactElement {
|
||||||
|
const page = useStore(s => s.settingsPage);
|
||||||
|
switch (page) {
|
||||||
|
case 'node': return <NodePage />;
|
||||||
|
case 'identity': return <IdentityPage />;
|
||||||
|
case 'devices': return <DevicesPage />;
|
||||||
|
case 'about': return <AboutPage />;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
61
desktop/src/sections/settings/SettingsNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
// Left-pane category list for Settings. Keeps selection in
|
||||||
|
// store.settingsPage so switching away and back preserves place.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useStore, type SettingsPage } from '@/lib/store';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
key: SettingsPage;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: Row[] = [
|
||||||
|
{ key: 'node', label: 'Node', hint: 'URL, connection status' },
|
||||||
|
{ key: 'identity', label: 'Identity', hint: 'Your keys and address' },
|
||||||
|
{ key: 'devices', label: 'Devices', hint: 'Linked devices, pair a new one' },
|
||||||
|
{ key: 'about', label: 'About', hint: 'Version, links' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsNav(): React.ReactElement {
|
||||||
|
const page = useStore(s => s.settingsPage);
|
||||||
|
const setPage = useStore(s => s.setSettingsPage);
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 10 }}>
|
||||||
|
{ROWS.map(r => (
|
||||||
|
<NavEntry
|
||||||
|
key={r.key}
|
||||||
|
label={r.label}
|
||||||
|
hint={r.hint}
|
||||||
|
active={page === r.key}
|
||||||
|
onClick={() => setPage(r.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavEntry({
|
||||||
|
label, hint, active, onClick,
|
||||||
|
}: { label: string; hint: string; active: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
|
||||||
|
background: active ? '#0a1a29' : 'transparent',
|
||||||
|
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
|
||||||
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
color: active ? '#1d9bf0' : '#fff',
|
||||||
|
fontSize: 14, fontWeight: 700,
|
||||||
|
}}>{label}</div>
|
||||||
|
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
||||||
|
{hint}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,133 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
// Settings section — two-pane.
|
||||||
import { useStore } from '@/lib/store';
|
// List pane: category nav (Node, Identity, Devices, About).
|
||||||
import { saveSettings } from '@/lib/storage';
|
// Detail pane: selected category's content.
|
||||||
import { setNodeUrl, getNetStats } from '@/lib/api';
|
|
||||||
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
|
|
||||||
|
|
||||||
export function SettingsList(): React.ReactElement {
|
export { SettingsNav as SettingsList } from './SettingsNav';
|
||||||
return (
|
export { SettingsDetail } from './SettingsDetail';
|
||||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<GroupLabel>Node</GroupLabel>
|
|
||||||
<NodeCard />
|
|
||||||
<GroupLabel>Identity</GroupLabel>
|
|
||||||
<IdentityCard />
|
|
||||||
<GroupLabel>About</GroupLabel>
|
|
||||||
<AboutCard />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SettingsDetail(): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<SectionPlaceholder
|
|
||||||
title="Settings"
|
|
||||||
note="Pick a setting from the list. Devices, notifications, privacy — coming soon."
|
|
||||||
centered
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GroupLabel({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
|
|
||||||
letterSpacing: 1.2, textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NodeCard(): React.ReactElement {
|
|
||||||
const settings = useStore(s => s.settings);
|
|
||||||
const setSettings = useStore(s => s.setSettings);
|
|
||||||
const [url, setUrl] = useState(settings.nodeUrl);
|
|
||||||
const [ok, setOk] = useState<boolean | null>(null);
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { setUrl(settings.nodeUrl); }, [settings.nodeUrl]);
|
|
||||||
|
|
||||||
const apply = async () => {
|
|
||||||
const clean = url.trim().replace(/\/$/, '');
|
|
||||||
if (!clean) return;
|
|
||||||
setBusy(true); setOk(null);
|
|
||||||
setNodeUrl(clean);
|
|
||||||
try {
|
|
||||||
await getNetStats();
|
|
||||||
setOk(true);
|
|
||||||
setSettings({ nodeUrl: clean });
|
|
||||||
saveSettings({ nodeUrl: clean });
|
|
||||||
} catch {
|
|
||||||
setOk(false);
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
|
||||||
background: '#0a0a0a', display: 'flex', flexDirection: 'column', gap: 8,
|
|
||||||
}}>
|
|
||||||
<label style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
|
||||||
NODE URL
|
|
||||||
</label>
|
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
||||||
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
|
|
||||||
<input
|
|
||||||
value={url}
|
|
||||||
onChange={e => { setUrl(e.target.value); setOk(null); }}
|
|
||||||
onBlur={apply}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
|
|
||||||
placeholder="http://node.example:8080"
|
|
||||||
spellCheck={false}
|
|
||||||
style={{
|
|
||||||
flex: 1, background: '#000',
|
|
||||||
border: '1px solid #1f1f1f', borderRadius: 8,
|
|
||||||
padding: '8px 10px', color: '#fff', fontSize: 13,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{busy && <span style={{ fontSize: 11, color: '#8b8b8b' }}>…</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IdentityCard(): React.ReactElement {
|
|
||||||
const keyFile = useStore(s => s.keyFile);
|
|
||||||
if (!keyFile) return <></>;
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
|
||||||
background: '#0a0a0a',
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
|
|
||||||
PUB KEY
|
|
||||||
</div>
|
|
||||||
<div className="selectable" style={{
|
|
||||||
color: '#fff', fontSize: 11, fontFamily: 'monospace',
|
|
||||||
marginTop: 4, wordBreak: 'break-all', lineHeight: 1.5,
|
|
||||||
}}>
|
|
||||||
{keyFile.pub_key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AboutCard(): React.ReactElement {
|
|
||||||
const [v, setV] = useState<string>('dev');
|
|
||||||
useEffect(() => {
|
|
||||||
window.dchain?.app.version().then(setV).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
|
|
||||||
background: '#0a0a0a', color: '#8b8b8b', fontSize: 12,
|
|
||||||
}}>
|
|
||||||
DChain Desktop v{v}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
|
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
|
||||||
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
|
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
import { useStore } from '@/lib/store';
|
import { useStore } from '@/lib/store';
|
||||||
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
|
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
|
||||||
|
|
||||||
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
|
||||||
const keyFile = useStore(s => s.keyFile);
|
const keyFile = useStore(s => s.keyFile);
|
||||||
const [copied, setCopied] = React.useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
// Paint the QR on mount. Skip if there's no key (shouldn't happen but
|
||||||
|
// component is safe against it).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyFile || !canvasRef.current) return;
|
||||||
|
QRCode.toCanvas(canvasRef.current, keyFile.pub_key, {
|
||||||
|
width: 196,
|
||||||
|
margin: 1,
|
||||||
|
color: { dark: '#ffffff', light: '#00000000' },
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
}).catch(() => { /* fall back to text only */ });
|
||||||
|
}, [keyFile]);
|
||||||
|
|
||||||
if (!keyFile) return <></>;
|
if (!keyFile) return <></>;
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
@@ -29,8 +44,16 @@ export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactE
|
|||||||
a contact using this address.
|
a contact using this address.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, display: 'flex', justifyContent: 'center',
|
||||||
|
padding: 16, borderRadius: 10,
|
||||||
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
|
}}>
|
||||||
|
<canvas ref={canvasRef} style={{ imageRendering: 'pixelated' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="selectable" style={{
|
<div className="selectable" style={{
|
||||||
marginTop: 14, padding: 14, borderRadius: 10,
|
marginTop: 10, padding: 14, borderRadius: 10,
|
||||||
background: '#000', border: '1px solid #1f1f1f',
|
background: '#000', border: '1px solid #1f1f1f',
|
||||||
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
color: '#fff', fontFamily: 'monospace', fontSize: 12,
|
||||||
wordBreak: 'break-all', lineHeight: 1.5,
|
wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
|||||||
62
desktop/src/shell/PaneBoundary.tsx
Normal file
62
desktop/src/shell/PaneBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// PaneBoundary — ErrorBoundary scoped to one Shell pane. A crash in
|
||||||
|
// the Conversation component shouldn't black-out the whole window; it
|
||||||
|
// should leave NavBar + List + StatusBar usable so the operator can
|
||||||
|
// switch sections and report the bug. Resets when the keyed section
|
||||||
|
// changes.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** Used as React key at the callsite; also shown in the panic copy. */
|
||||||
|
sectionName: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PaneBoundary extends React.Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error(`[PaneBoundary:${this.props.sectionName}]`, error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: 20, height: '100%', overflow: 'auto',
|
||||||
|
background: '#000', color: '#fff', fontFamily: 'monospace',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
color: '#ff6b6b', fontSize: 14, fontWeight: 700, marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{this.props.sectionName} crashed
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: 13, marginBottom: 8 }}>
|
||||||
|
{this.state.error.message}
|
||||||
|
</div>
|
||||||
|
<pre style={{
|
||||||
|
color: '#8b8b8b', fontSize: 11, lineHeight: 1.4,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||||
|
}}>
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => this.setState({ error: null })}
|
||||||
|
style={{
|
||||||
|
marginTop: 10, padding: '6px 12px', borderRadius: 999,
|
||||||
|
border: '1px solid #1f1f1f', background: '#111',
|
||||||
|
color: '#fff', fontSize: 12, cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>Retry</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,12 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useStore, type Section } from '@/lib/store';
|
import { useStore, type Section } from '@/lib/store';
|
||||||
|
import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
|
||||||
import { TitleBar } from './TitleBar';
|
import { TitleBar } from './TitleBar';
|
||||||
import { NavBar } from './NavBar';
|
import { NavBar } from './NavBar';
|
||||||
import { StatusBar } from './StatusBar';
|
import { StatusBar } from './StatusBar';
|
||||||
|
import { UpdateBanner } from './UpdateBanner';
|
||||||
|
import { PaneBoundary } from './PaneBoundary';
|
||||||
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
import { MessagesList, MessagesDetail } from '@/sections/messages';
|
||||||
import { FeedList, FeedDetail } from '@/sections/feed';
|
import { FeedList, FeedDetail } from '@/sections/feed';
|
||||||
import { WalletList, WalletDetail } from '@/sections/wallet';
|
import { WalletList, WalletDetail } from '@/sections/wallet';
|
||||||
@@ -32,6 +35,7 @@ import { ProfileList, ProfileDetail } from '@/sections/profile';
|
|||||||
export function Shell(): React.ReactElement {
|
export function Shell(): React.ReactElement {
|
||||||
const section = useStore(s => s.section);
|
const section = useStore(s => s.section);
|
||||||
const { List, Detail } = PANES[section];
|
const { List, Detail } = PANES[section];
|
||||||
|
useGlobalKeybinds();
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
@@ -48,12 +52,17 @@ export function Shell(): React.ReactElement {
|
|||||||
borderRight: '1px solid #1f1f1f',
|
borderRight: '1px solid #1f1f1f',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
}}>
|
}}>
|
||||||
|
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
|
||||||
<List />
|
<List />
|
||||||
|
</PaneBoundary>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||||
|
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
|
||||||
<Detail />
|
<Detail />
|
||||||
|
</PaneBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<UpdateBanner />
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
56
desktop/src/shell/UpdateBanner.tsx
Normal file
56
desktop/src/shell/UpdateBanner.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// UpdateBanner — appears just above the status bar when a newer release
|
||||||
|
// tag is available on Gitea. Single action: open the release page in
|
||||||
|
// the default browser. We deliberately don't auto-download — the user
|
||||||
|
// probably wants to read the changelog first, and the binary hosting
|
||||||
|
// story is still "Gitea release assets" rather than a signed feed.
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useUpdateCheck } from '@/hooks/useUpdateCheck';
|
||||||
|
|
||||||
|
export function UpdateBanner(): React.ReactElement | null {
|
||||||
|
const info = useUpdateCheck();
|
||||||
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!info) return null;
|
||||||
|
if (dismissed === info.latestTag) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#0d2540',
|
||||||
|
borderTop: '1px solid #1d9bf022',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#1d9bf0', fontSize: 16 }}>↗</span>
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
Update available: <b>{info.latestTag}</b>
|
||||||
|
{info.publishedAt && (
|
||||||
|
<span style={{ color: '#8b8b8b', marginLeft: 8 }}>
|
||||||
|
published {new Date(info.publishedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href={info.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
padding: '5px 12px', borderRadius: 999,
|
||||||
|
background: '#1d9bf0', color: '#fff',
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
textDecoration: 'none',
|
||||||
|
}}
|
||||||
|
>Download</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(info.latestTag)}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: '#8b8b8b', fontSize: 16, cursor: 'pointer',
|
||||||
|
padding: 0, lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -188,20 +188,25 @@ desktop/
|
|||||||
|
|
||||||
### План работ
|
### План работ
|
||||||
|
|
||||||
- [x] **v2.2.0-alpha4** — Boilerplate: Electron + Vite + React + TS,
|
- [x] **v2.2.0-alpha4** — Boilerplate, 3-panel shell, safeStorage IPC,
|
||||||
frame-less window, 3-panel shell, nav + status bar, safeStorage
|
Welcome / Create / Import auth, section stubs.
|
||||||
for keyfile via IPC, Welcome + Create/Import auth flow, section
|
- [x] **v2.2.0-alpha5** — Messages section + pairing poll loop; chain
|
||||||
stubs that the rest of the alphas will fill in.
|
+ clients learn to attribute conversations by master Ed25519.
|
||||||
- [ ] **v2.2.0-alpha5** — Messages section (chat list + conversation)
|
- [x] **v2.2.0-alpha6** — Feed (tabs + list + detail + compose) +
|
||||||
using the same fan-out semantics as mobile. Pairing flow wired up
|
Wallet (history + detail + Send/Receive).
|
||||||
(new-device poll loop + primary-device modal reused from mobile).
|
- [x] **v2.2.0-rc1** — Contacts section (list + profile detail + actions),
|
||||||
- [ ] **v2.2.0-alpha6** — Feed + Wallet real content (reuse feed.ts /
|
Settings → Devices (list + unlink + link-new-device modal with the
|
||||||
tx builders from client-app via a shared workspace package).
|
same protocol as mobile), expanded Profile, QR in Receive, global
|
||||||
- [ ] **v2.2.0-rc1** — Contacts + Settings → Devices + Profile,
|
keybinds (Ctrl+W close chat / Ctrl+K jump to Contacts / Ctrl+, Settings).
|
||||||
polish pass (keybinds, focus, drag-drop attachments).
|
- [x] **v2.2.0** — Contact-request flow (New contact modal + Requests
|
||||||
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
|
inbox tab with Accept/Block), auto-update banner that polls
|
||||||
pipeline nodes use; `electron-builder` → `.dmg`, `.exe`,
|
`/api/update-check` and offers the latest Gitea release,
|
||||||
`.AppImage`, `.deb`.
|
electron-builder config ready for `.dmg` / `.exe` / `.AppImage` /
|
||||||
|
`.deb` + NSIS installer + macOS hardenedRuntime.
|
||||||
|
- [ ] **post-v2.2.0** — Attachments in Compose (file picker +
|
||||||
|
client-side image resize + metadata scrub), code signing
|
||||||
|
certificates, draft group chats (multi-recipient envelopes or
|
||||||
|
MLS integration).
|
||||||
|
|
||||||
### Открытые вопросы (desktop)
|
### Открытые вопросы (desktop)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user