From 96b347076e88fcc93d01c7c02bf3551dcb7be8eb Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 18:39:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20Contacts=20+=20Settings?= =?UTF-8?q?=E2=86=92Devices=20+=20expanded=20Profile=20+=20QR=20+=20keybin?= =?UTF-8?q?ds=20(v2.2.0-rc1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the desktop feature surface ahead of the v2.2.0 tag. Only auto-update + packaging remain. Settings — now two-paned (nav on the left, pages on the right): * NodePage — URL ping-on-commit + API token field. * IdentityPage — pub key / X25519 pub, Export (safe-save dialog) / Import (open dialog + wipe + replace) / Delete identity. * DevicesPage — full multi-device UI: list every active device with a THIS DEVICE badge; Unlink button on every other row submits UNLINK_DEVICE + optimistic local remove; Link new device modal takes {code, device key, name}, submits LINK_DEVICE, then ships the handshake envelope (master Ed25519 priv encrypted for the new X25519) — same protocol as mobile's primary-device modal. * AboutPage — version, platform, Gitea links. * store.settingsPage discriminated union keeps selection across section switches. Contacts section (now real): * ContactsList — alphabetical, filter-as-you-type; each row shows avatar letter + name + short address. * ContactsDetail — profile card (username/alias/pub) + Open chat / View posts / Copy address actions + stats grid (Balance, Devices, Encryption, Added) + Identity card with DC address, username, published X25519, device_count. * store.selectedContact persists across navigation. Profile section (expanded): * ProfileList — big avatar + pub key + contacts count. * ProfileDetail — balance hero, quick actions (My posts → feed author wall, Manage devices → Settings→Devices, Copy address), Identity card, inline Linked devices list with a THIS DEVICE badge matching the Settings page. Receive modal — canvas QR via `qrcode` (new dep, ~5 KB gzipped), white-on-transparent so it sits inside the same black modal chrome. Global keybinds (useGlobalKeybinds hook mounted in Shell): * Ctrl/Cmd+W — close the current conversation (drops activeChat, keeps section). Does NOT close the window. * Ctrl/Cmd+K — jump to Contacts. * Ctrl/Cmd+, — Settings. Each guards against being in a text field so typing `k,` in a composer / search doesn't hijack. docs/ROADMAP.md — rc1 row flipped to done; v2.2.0 narrows to auto-update + packaging + optional attachments in Compose. --- desktop/package-lock.json | 225 ++++++++++- desktop/package.json | 23 +- desktop/src/hooks/useGlobalKeybinds.ts | 62 +++ desktop/src/lib/store.ts | 17 + .../src/sections/contacts/ContactsDetail.tsx | 200 ++++++++++ .../src/sections/contacts/ContactsList.tsx | 105 ++++++ desktop/src/sections/contacts/index.tsx | 13 +- desktop/src/sections/profile/index.tsx | 215 ++++++++++- desktop/src/sections/settings/AboutPage.tsx | 59 +++ desktop/src/sections/settings/DevicesPage.tsx | 353 ++++++++++++++++++ .../src/sections/settings/IdentityPage.tsx | 159 ++++++++ desktop/src/sections/settings/NodePage.tsx | 115 ++++++ desktop/src/sections/settings/PageLayout.tsx | 33 ++ .../src/sections/settings/SettingsDetail.tsx | 18 + desktop/src/sections/settings/SettingsNav.tsx | 61 +++ desktop/src/sections/settings/index.tsx | 137 +------ desktop/src/sections/wallet/ReceiveModal.tsx | 29 +- desktop/src/shell/Shell.tsx | 2 + docs/ROADMAP.md | 24 +- 19 files changed, 1658 insertions(+), 192 deletions(-) create mode 100644 desktop/src/hooks/useGlobalKeybinds.ts create mode 100644 desktop/src/sections/contacts/ContactsDetail.tsx create mode 100644 desktop/src/sections/contacts/ContactsList.tsx create mode 100644 desktop/src/sections/settings/AboutPage.tsx create mode 100644 desktop/src/sections/settings/DevicesPage.tsx create mode 100644 desktop/src/sections/settings/IdentityPage.tsx create mode 100644 desktop/src/sections/settings/NodePage.tsx create mode 100644 desktop/src/sections/settings/PageLayout.tsx create mode 100644 desktop/src/sections/settings/SettingsDetail.tsx create mode 100644 desktop/src/sections/settings/SettingsNav.tsx diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 467e92b..2eab0cc 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,13 +1,14 @@ { "name": "dchain-desktop", - "version": "2.2.0-alpha4", + "version": "2.2.0-alpha6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dchain-desktop", - "version": "2.2.0-alpha4", + "version": "2.2.0-alpha6", "dependencies": { + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "tweetnacl": "^1.0.3", @@ -15,6 +16,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -2020,6 +2022,16 @@ "devOptional": true, "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": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", @@ -2183,7 +2195,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2193,7 +2204,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2869,6 +2879,15 @@ "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": { "version": "1.0.30001790", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", @@ -3049,7 +3068,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -3062,7 +3080,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "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": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3478,6 +3504,12 @@ "license": "MIT", "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -3873,7 +3905,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -4158,6 +4189,19 @@ "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": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -4329,7 +4373,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -4830,7 +4873,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5091,6 +5133,18 @@ "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": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -5780,6 +5834,33 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -5796,6 +5877,15 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5803,6 +5893,15 @@ "dev": true, "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -5914,6 +6013,15 @@ "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": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", @@ -6013,6 +6121,89 @@ "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": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -6137,12 +6328,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -6392,7 +6588,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, "license": "ISC" }, "node_modules/shebang-command": { @@ -6610,7 +6805,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -6641,7 +6835,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7135,6 +7328,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", diff --git a/desktop/package.json b/desktop/package.json index 315a955..67253b3 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "dchain-desktop", - "version": "2.2.0-alpha6", + "version": "2.2.0-rc1", "description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.", "private": true, "main": "dist-electron/main.js", @@ -13,6 +13,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json" }, "dependencies": { + "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "tweetnacl": "^1.0.3", @@ -20,6 +21,7 @@ "zustand": "^5.0.3" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", @@ -38,8 +40,21 @@ "dist/**/*", "dist-electron/**/*" ], - "mac": { "target": ["dmg"] }, - "win": { "target": ["nsis"] }, - "linux": { "target": ["AppImage", "deb"] } + "mac": { + "target": [ + "dmg" + ] + }, + "win": { + "target": [ + "nsis" + ] + }, + "linux": { + "target": [ + "AppImage", + "deb" + ] + } } } diff --git a/desktop/src/hooks/useGlobalKeybinds.ts b/desktop/src/hooks/useGlobalKeybinds.ts new file mode 100644 index 0000000..b7b9551 --- /dev/null +++ b/desktop/src/hooks/useGlobalKeybinds.ts @@ -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); + }, []); +} diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts index ccb1cc5..95451c7 100644 --- a/desktop/src/lib/store.ts +++ b/desktop/src/lib/store.ts @@ -29,6 +29,9 @@ export type WalletSelection = | { kind: 'overview' } | { kind: 'tx'; id: string }; +/** Which Settings subsection is visible in the detail pane. */ +export type SettingsPage = 'node' | 'identity' | 'devices' | 'about'; + interface State { booted: boolean; keyFile: KeyFile | null; @@ -64,6 +67,14 @@ interface State { /** Wallet state. */ walletSel: WalletSelection; 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((set) => ({ @@ -119,4 +130,10 @@ export const useStore = create((set) => ({ walletSel: { kind: 'overview' }, setWalletSel: (s) => set({ walletSel: s }), + + settingsPage: 'node', + setSettingsPage: (p) => set({ settingsPage: p }), + + selectedContact: null, + setSelectedContact: (addr) => set({ selectedContact: addr }), })); diff --git a/desktop/src/sections/contacts/ContactsDetail.tsx b/desktop/src/sections/contacts/ContactsDetail.tsx new file mode 100644 index 0000000..b2ac1bb --- /dev/null +++ b/desktop/src/sections/contacts/ContactsDetail.tsx @@ -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(null); + const [balance, setBalance] = useState(null); + const [deviceCount, setDeviceCount] = useState(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 ( +
+ Pick a contact on the left to view their profile. +
+ ); + } + + 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 ( +
+ {/* Header card */} +
+
{displayName.replace(/^@/, '').charAt(0).toUpperCase()}
+
+
+ {displayName} +
+
{contact.address}
+
+
+ + {/* Actions */} +
+ Open chat + View posts + copy(contact.address)}>Copy address +
+ + {/* Stats grid */} +
+ + + + +
+ + {/* Identity details */} + {identity && ( +
+
Identity
+ copy(identity.address)} /> + {identity.nickname && } + {identity.x25519_pub && ( + copy(identity.x25519_pub)} + /> + )} + {typeof identity.device_count === 'number' && ( + + )} +
+ )} +
+ ); +} + +function Btn({ children, onClick, primary }: { + children: React.ReactNode; onClick: () => void; primary?: boolean; +}) { + return ( + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function Row({ + k, v, copyable, onCopy, +}: { k: string; v: string; copyable?: boolean; onCopy?: () => void }) { + return ( +
+
{k}
+
{v}
+ {copyable && ( + + )} +
+ ); +} diff --git a/desktop/src/sections/contacts/ContactsList.tsx b/desktop/src/sections/contacts/ContactsList.tsx new file mode 100644 index 0000000..38505bb --- /dev/null +++ b/desktop/src/sections/contacts/ContactsList.tsx @@ -0,0 +1,105 @@ +// 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, { useMemo, useState } from 'react'; +import { useStore } from '@/lib/store'; +import { shortAddr } from '@/lib/crypto'; +import type { Contact } from '@/lib/types'; + +export function ContactsList(): React.ReactElement { + const contacts = useStore(s => s.contacts); + const sel = useStore(s => s.selectedContact); + const setSel = useStore(s => s.setSelectedContact); + + const [q, setQ] = useState(''); + 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 ( +
+ {/* Search */} +
+ setQ(e.target.value)} + placeholder="Filter…" + style={{ + width: '100%', boxSizing: 'border-box', + background: '#0a0a0a', border: '1px solid #1f1f1f', + borderRadius: 8, padding: '8px 10px', + color: '#fff', fontSize: 13, outline: 'none', + }} + /> +
+ + {sorted.length === 0 ? ( +
+ No contacts yet. They appear as chats start, or as peers pair + their own devices with yours. +
+ ) : ( + sorted.map(c => ( + setSel(c.address)} /> + )) + )} +
+ ); +} + +function Row({ c, active, onClick }: { + c: Contact; active: boolean; onClick: () => void; +}) { + const name = c.username ? `@${c.username}` : (c.alias ?? shortAddr(c.address, 6)); + return ( +
{ if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }} + onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }} + > +
{name.replace(/^@/, '').charAt(0).toUpperCase()}
+
+
{name}
+
{shortAddr(c.address, 8)}
+
+
+ ); +} diff --git a/desktop/src/sections/contacts/index.tsx b/desktop/src/sections/contacts/index.tsx index b5896f8..9067498 100644 --- a/desktop/src/sections/contacts/index.tsx +++ b/desktop/src/sections/contacts/index.tsx @@ -1,9 +1,6 @@ -import React from 'react'; -import { SectionPlaceholder } from '@/shell/SectionPlaceholder'; +// Contacts section. +// List pane: contact list + quick filter. +// Detail pane: selected contact's profile card + actions. -export function ContactsList(): React.ReactElement { - return ; -} -export function ContactsDetail(): React.ReactElement { - return ; -} +export { ContactsList } from './ContactsList'; +export { ContactsDetail } from './ContactsDetail'; diff --git a/desktop/src/sections/profile/index.tsx b/desktop/src/sections/profile/index.tsx index c2c59cd..1d486e9 100644 --- a/desktop/src/sections/profile/index.tsx +++ b/desktop/src/sections/profile/index.tsx @@ -1,43 +1,218 @@ -import React from 'react'; -import { SectionPlaceholder } from '@/shell/SectionPlaceholder'; +// Profile section — "You" view. List pane shows the avatar card, +// detail pane shows stats + devices summary. + +import React, { useEffect, useState } from 'react'; 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 { 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 (
- {keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'} -
-
+ width: 72, height: 72, borderRadius: 36, margin: '0 auto 10px', + background: '#1d9bf0', color: '#fff', + display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: 30, fontWeight: 800, + }}>{letter}
+
You
- {keyFile?.pub_key} -
+ marginTop: 6, wordBreak: 'break-all', + }}>{keyFile.pub_key}
+
+ +
+ {contactsCount} contact{contactsCount === 1 ? '' : 's'} stored on this device.
); } 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(null); + const [balance, setBalance] = useState(null); + const [devices, setDevices] = useState([]); + + 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 ( - +
+
+
+
Balance
+
+ {balance === null ? '—' : `${formatT(balance)} T`} +
+
+
+ My posts + Manage devices ({devices.length}) + copy(keyFile.pub_key)}>Copy address +
+
+ + {identity && ( +
+ copy(identity.address)} /> + + + +
+ )} + + {/* Devices summary */} +
+
Linked devices
+ {devices.length === 0 ? ( +
+ No devices registered yet. +
+ ) : ( + devices.map((d, i) => ( +
+
📱
+
+
+ {d.device_name} +
+
+ {shortAddr(d.x25519_pub_key, 8)} +
+
+ {d.x25519_pub_key === keyFile.x25519_pub && ( + THIS DEVICE + )} +
+ )) + )} +
+
+ ); +} + +function Action({ children, onClick }: { + children: React.ReactNode; onClick: () => void; +}) { + return ( + + ); +} + +function Row({ k, v, onCopy }: { k: string; v: string; onCopy?: () => void }) { + return ( +
+
{k}
+
{v}
+ {onCopy && ( + + )} +
); } diff --git a/desktop/src/sections/settings/AboutPage.tsx b/desktop/src/sections/settings/AboutPage.tsx new file mode 100644 index 0000000..6451d3d --- /dev/null +++ b/desktop/src/sections/settings/AboutPage.tsx @@ -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 ( + + + +
+ DChain Desktop v{version} +
+ + Running on {platform || 'unknown'} · Electron / Chromium + +
+ + + +
+ + + +
+
+
+ ); +} + +function LinkRow({ href, label }: { href: string; label: string }) { + return ( + {label} ↗ + ); +} diff --git a/desktop/src/sections/settings/DevicesPage.tsx b/desktop/src/sections/settings/DevicesPage.tsx new file mode 100644 index 0000000..a8bbc49 --- /dev/null +++ b/desktop/src/sections/settings/DevicesPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [unlinking, setUnlinking] = useState(null); + const [notice, setNotice] = useState(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 ( + + +
+ + +
+ + {loading ? ( +
Loading…
+ ) : devs.length === 0 ? ( +
+ 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. +
+ ) : ( +
+ {devs.map((d, i) => ( + onUnlink(d)} + first={i === 0} + /> + ))} +
+ )} + + {notice && ( +
{notice}
+ )} +
+ + {linkOpen && ( + setLinkOpen(false)} + onLinked={() => { setLinkOpen(false); setTimeout(load, 1000); }} + /> + )} +
+ ); +} + +// ─── Row ───────────────────────────────────────────────────────────────── + +function DeviceRow({ + d, isMe, unlinking, onUnlink, first, +}: { + d: DeviceInfo; isMe: boolean; unlinking: boolean; + onUnlink: () => void; first: boolean; +}) { + return ( +
+
📱
+
+
+ + {d.device_name || 'Unnamed device'} + + {isMe && ( + THIS DEVICE + )} +
+
+ {shortAddr(d.x25519_pub_key, 10)} +
+
+ Linked {new Date(d.added_at * 1000).toLocaleString()} +
+
+ {!isMe && ( + + )} +
+ ); +} + +// ─── 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(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 ( +
!busy && onClose()} + style={{ + position: 'fixed', inset: 0, zIndex: 20, + background: 'rgba(0,0,0,0.7)', + display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 24, + }} + > +
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 520, padding: 20, borderRadius: 16, + background: '#0a0a0a', border: '1px solid #1f1f1f', + }} + > +
+
+ Link new device +
+ +
+ + On the new device, tap Pair on the welcome screen and + transcribe the 6-digit code and device key from there into the + fields below. + + +
+ + + setCode(e.target.value)} + placeholder="000000" + inputMode="numeric" + maxLength={6} + style={{ ...inputStyle, letterSpacing: 4, textAlign: 'center' }} + /> + + + + setKey(e.target.value)} + placeholder="a1b2c3…" + spellCheck={false} + style={inputStyle} + /> + + + + setName(e.target.value)} + placeholder="e.g. Alice's laptop" + maxLength={64} + style={{ ...inputStyle, fontFamily: 'inherit' }} + /> + +
+ + {err && ( +
{err}
+ )} + +
+ + +
+
+
+ ); +} + +function Field({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/desktop/src/sections/settings/IdentityPage.tsx b/desktop/src/sections/settings/IdentityPage.tsx new file mode 100644 index 0000000..cda9cb3 --- /dev/null +++ b/desktop/src/sections/settings/IdentityPage.tsx @@ -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(null); + + if (!keyFile) return
No identity loaded.
; + + 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 ( + + + +
+ {keyFile.pub_key} +
+ + + +
+ + + +
+ {keyFile.x25519_pub} +
+ + Only this device uses this X25519 pair. Sharing the master Ed25519 + pub (above) is how contacts find you across all your devices. + +
+ + + + + + + + + Exports a JSON file compatible with the mobile client and + server's --key flag. The file is not + encrypted on disk — store it somewhere safe. + + + + + + + + + + Wipes the key, contacts, and chat cache from this device. + Without an export, this is irreversible. + + + + {notice && ( +
{notice}
+ )} +
+ ); +} + +function ActionRow({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export function Button({ + children, onClick, danger, disabled, +}: { + children: React.ReactNode; + onClick: () => void; + danger?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/desktop/src/sections/settings/NodePage.tsx b/desktop/src/sections/settings/NodePage.tsx new file mode 100644 index 0000000..56f6098 --- /dev/null +++ b/desktop/src/sections/settings/NodePage.tsx @@ -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(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 ( + + + +
+ + { 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 && } +
+ + Enter or tab-out to ping. Green dot = `/api/netstats` replied. + +
+ + + + setToken(e.target.value)} + onBlur={apply} + placeholder="paste Bearer token if node requires it" + spellCheck={false} + style={inputStyle} + /> + + Some nodes gate writes with DCHAIN_API_TOKEN; leave blank for + public ones. + + +
+ ); +} + +// ─── Reusable primitives (also imported by Identity / Devices / About) ─── + +export function Card({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} +export function Label({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} +export function Hint({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} +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%', +}; diff --git a/desktop/src/sections/settings/PageLayout.tsx b/desktop/src/sections/settings/PageLayout.tsx new file mode 100644 index 0000000..6ffacc9 --- /dev/null +++ b/desktop/src/sections/settings/PageLayout.tsx @@ -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 ( +
+
+
{title}
+ {subtitle && ( +
{subtitle}
+ )} +
+
+ {children} +
+
+ ); +} diff --git a/desktop/src/sections/settings/SettingsDetail.tsx b/desktop/src/sections/settings/SettingsDetail.tsx new file mode 100644 index 0000000..0d26c65 --- /dev/null +++ b/desktop/src/sections/settings/SettingsDetail.tsx @@ -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 ; + case 'identity': return ; + case 'devices': return ; + case 'about': return ; + } +} diff --git a/desktop/src/sections/settings/SettingsNav.tsx b/desktop/src/sections/settings/SettingsNav.tsx new file mode 100644 index 0000000..427597a --- /dev/null +++ b/desktop/src/sections/settings/SettingsNav.tsx @@ -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 ( +
+ {ROWS.map(r => ( + setPage(r.key)} + /> + ))} +
+ ); +} + +function NavEntry({ + label, hint, active, onClick, +}: { label: string; hint: string; active: boolean; onClick: () => void }) { + return ( +
{ if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }} + onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }} + > +
{label}
+
+ {hint} +
+
+ ); +} diff --git a/desktop/src/sections/settings/index.tsx b/desktop/src/sections/settings/index.tsx index 3757108..580a095 100644 --- a/desktop/src/sections/settings/index.tsx +++ b/desktop/src/sections/settings/index.tsx @@ -1,133 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { useStore } from '@/lib/store'; -import { saveSettings } from '@/lib/storage'; -import { setNodeUrl, getNetStats } from '@/lib/api'; -import { SectionPlaceholder } from '@/shell/SectionPlaceholder'; +// Settings section — two-pane. +// List pane: category nav (Node, Identity, Devices, About). +// Detail pane: selected category's content. -export function SettingsList(): React.ReactElement { - return ( -
- Node - - Identity - - About - -
- ); -} - -export function SettingsDetail(): React.ReactElement { - return ( - - ); -} - -function GroupLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -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(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 ( -
- -
- - { 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 && } -
-
- ); -} - -function IdentityCard(): React.ReactElement { - const keyFile = useStore(s => s.keyFile); - if (!keyFile) return <>; - return ( -
-
- PUB KEY -
-
- {keyFile.pub_key} -
-
- ); -} - -function AboutCard(): React.ReactElement { - const [v, setV] = useState('dev'); - useEffect(() => { - window.dchain?.app.version().then(setV).catch(() => {}); - }, []); - return ( -
- DChain Desktop v{v} -
- ); -} +export { SettingsNav as SettingsList } from './SettingsNav'; +export { SettingsDetail } from './SettingsDetail'; diff --git a/desktop/src/sections/wallet/ReceiveModal.tsx b/desktop/src/sections/wallet/ReceiveModal.tsx index 81d35ba..8700884 100644 --- a/desktop/src/sections/wallet/ReceiveModal.tsx +++ b/desktop/src/sections/wallet/ReceiveModal.tsx @@ -1,13 +1,28 @@ // 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). -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import QRCode from 'qrcode'; import { useStore } from '@/lib/store'; import { Backdrop, Header, primaryBtnStyle } from './SendModal'; export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement { const keyFile = useStore(s => s.keyFile); - const [copied, setCopied] = React.useState(false); + const [copied, setCopied] = useState(false); + const canvasRef = useRef(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 <>; const copy = async () => { @@ -29,8 +44,16 @@ export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactE a contact using this address. +
+ +
+
s.section); const { List, Detail } = PANES[section]; + useGlobalKeybinds(); return (