feat(desktop): Contacts + Settings→Devices + expanded Profile + QR + keybinds (v2.2.0-rc1)

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.
This commit is contained in:
vsecoder
2026-04-22 18:39:39 +03:00
parent 98ac700e0a
commit 96b347076e
19 changed files with 1658 additions and 192 deletions

View File

@@ -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",

View File

@@ -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"
]
}
}
}

View 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);
}, []);
}

View File

@@ -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<State>((set) => ({
@@ -119,4 +130,10 @@ export const useStore = create<State>((set) => ({
walletSel: { kind: 'overview' },
setWalletSel: (s) => set({ walletSel: s }),
settingsPage: 'node',
setSettingsPage: (p) => set({ settingsPage: p }),
selectedContact: null,
setSelectedContact: (addr) => set({ selectedContact: addr }),
}));

View 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>
);
}

View File

@@ -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 (
<div>
{/* Search */}
<div style={{
position: 'sticky', top: 0, zIndex: 1,
padding: 10, background: '#000',
borderBottom: '1px solid #1f1f1f',
}}>
<input
value={q}
onChange={e => 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',
}}
/>
</div>
{sorted.length === 0 ? (
<div style={{
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
}}>
No contacts yet. They appear as chats start, or as peers pair
their own devices with yours.
</div>
) : (
sorted.map(c => (
<Row key={c.address} c={c} active={c.address === sel} onClick={() => setSel(c.address)} />
))
)}
</div>
);
}
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>
);
}

View File

@@ -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 <SectionPlaceholder title="Contacts" note="All · Online · Blocked · Requests" />;
}
export function ContactsDetail(): React.ReactElement {
return <SectionPlaceholder title="Contacts" note="Pick a contact to see details." centered />;
}
export { ContactsList } from './ContactsList';
export { ContactsDetail } from './ContactsDetail';

View File

@@ -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 (
<div style={{ padding: 14 }}>
<div style={{
padding: 14, borderRadius: 14,
padding: 16, borderRadius: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
textAlign: 'center',
}}>
<div style={{
width: 48, height: 48, borderRadius: 24,
background: '#1d9bf0', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#fff', fontWeight: 800, fontSize: 20,
}}>
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
</div>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
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}</div>
<div style={{ color: '#fff', fontSize: 18, fontWeight: 700 }}>
You
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all',
}}>
{keyFile?.pub_key}
marginTop: 6, wordBreak: 'break-all',
}}>{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>
);
}
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 (
<SectionPlaceholder
title="Your profile"
note="Balance, username, devices — coming soon."
centered
/>
<div style={{
height: '100%', overflowY: 'auto',
padding: '22px 26px', background: '#000',
}}>
<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>
);
}

View 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>
);
}

View 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>;
}

View 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>
);
}

View 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%',
};

View 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>
);
}

View 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 />;
}
}

View 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>
);
}

View File

@@ -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 (
<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>
);
}
export { SettingsNav as SettingsList } from './SettingsNav';
export { SettingsDetail } from './SettingsDetail';

View File

@@ -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<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 <></>;
const copy = async () => {
@@ -29,8 +44,16 @@ export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactE
a contact using this address.
</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={{
marginTop: 14, padding: 14, borderRadius: 10,
marginTop: 10, padding: 14, borderRadius: 10,
background: '#000', border: '1px solid #1f1f1f',
color: '#fff', fontFamily: 'monospace', fontSize: 12,
wordBreak: 'break-all', lineHeight: 1.5,

View File

@@ -19,6 +19,7 @@
import React from 'react';
import { useStore, type Section } from '@/lib/store';
import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
import { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
@@ -32,6 +33,7 @@ import { ProfileList, ProfileDetail } from '@/sections/profile';
export function Shell(): React.ReactElement {
const section = useStore(s => s.section);
const { List, Detail } = PANES[section];
useGlobalKeybinds();
return (
<div style={{
display: 'flex', flexDirection: 'column',

View File

@@ -188,20 +188,20 @@ desktop/
### План работ
- [x] **v2.2.0-alpha4** — Boilerplate: Electron + Vite + React + TS,
frame-less window, 3-panel shell, nav + status bar, safeStorage
for keyfile via IPC, Welcome + Create/Import auth flow, section
stubs that the rest of the alphas will fill in.
- [ ] **v2.2.0-alpha5**Messages section (chat list + conversation)
using the same fan-out semantics as mobile. Pairing flow wired up
(new-device poll loop + primary-device modal reused from mobile).
- [ ] **v2.2.0-alpha6** — Feed + Wallet real content (reuse feed.ts /
tx builders from client-app via a shared workspace package).
- [ ] **v2.2.0-rc1** Contacts + Settings → Devices + Profile,
polish pass (keybinds, focus, drag-drop attachments).
- [x] **v2.2.0-alpha4** — Boilerplate, 3-panel shell, safeStorage IPC,
Welcome / Create / Import auth, section stubs.
- [x] **v2.2.0-alpha5** — Messages section + pairing poll loop; chain
+ clients learn to attribute conversations by master Ed25519.
- [x] **v2.2.0-alpha6**Feed (tabs + list + detail + compose) +
Wallet (history + detail + Send/Receive).
- [x] **v2.2.0-rc1** — Contacts section (list + profile detail + actions),
Settings → Devices (list + unlink + link-new-device modal with the
same protocol as mobile), expanded Profile, QR in Receive, global
keybinds (Ctrl+W close chat / Ctrl+K jump to Contacts / Ctrl+, Settings).
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
pipeline nodes use; `electron-builder``.dmg`, `.exe`,
`.AppImage`, `.deb`.
`.AppImage`, `.deb`; optional: attachments in Compose
(file picker + client-side image resize + scrub).
### Открытые вопросы (desktop)