5 Commits

Author SHA1 Message Date
vsecoder
82d3706e38 fix(desktop): auto-link device on sign-in + publish key on accept
Two bugs reported by the user:

1. After accepting a contact request on the desktop, the requester's
   "Send message" call errored with "no encryption key published" for
   the newly-accepted contact. Root cause: desktop never ran the
   device-registry bootstrap (mobile does it from _layout.tsx on
   sign-in) — so the desktop's X25519 pub was never published via
   LINK_DEVICE, and resolveRecipientKeys returned an empty list.

2. On the accepting device, the new chat didn't appear in Messages
   after tapping Accept — accept wrote the contact to store + disk
   but didn't switch sections, so the user was stuck in Contacts
   watching nothing happen.

Fixes:

  * hooks/useDeviceBootstrap — direct port of mobile's _layout.tsx
    bootstrap effect. On every sign-in:
      - fetchDevices(master) → if our X25519 is listed, mark local
        registered flag.
      - not listed + was registered before → REVOKED → wipe state +
        bounce to Welcome.
      - not listed + never registered → submit LINK_DEVICE. Tx may
        bounce if balance is zero; next launch retries.
    Mounted from App.tsx so it runs once per authenticated session.

  * RequestsList.accept — after submitting ACCEPT_CONTACT, check if
    OUR X25519 is in the on-chain registry. If not, submit LINK_DEVICE
    immediately (balance is now covered by the contact fee the peer
    paid us). This closes the window where the peer couldn't encrypt
    to us because our key wasn't published yet.
    Also: after a successful accept, setSection('messages') +
    setActiveChat(requester_pub), matching mobile's
    router.replace('/chats/<pub>') flow.

  * Conversation.send — nicer error copy when
    resolveRecipientKeys returns []. Was: "recipient has no
    encryption key published". Now: actionable text asking the peer
    to re-open their app so the LINK_DEVICE tx commits.
2026-04-22 19:13:29 +03:00
vsecoder
7e6fe2c2a0 fix(desktop): infinite render loop opening a chat (Maximum update depth)
The Conversation selector `useStore(s => s.messages[address] ?? [])`
allocates a fresh empty array on every call when the chat has no cached
messages. Zustand compares selector results with `===`, so the new []
is different from the previous [], marking the slice as "changed",
which re-renders Conversation, which calls the selector again, which
produces another new []... "Maximum update depth exceeded" inside
seconds.

Fix: module-level `const EMPTY_MESSAGES: Message[] = []` returned as the
fallback. Same object reference every render, zustand's === bails
early, no re-render.

This crash only showed up after opening a chat whose messages hadn't
been cached yet — picking any entry in ChatList that hadn't received
an envelope would hang the renderer. PaneBoundary (added in the prior
commit) now catches it visibly instead of blacking out the whole
window, but we still want the real fix.
2026-04-22 18:58:57 +03:00
vsecoder
481d4d2fa8 fix(desktop): global box-sizing + per-pane error boundary + Conversation defensives
Two bugs reported after v2.2.0:

1. Input fields and textareas overflowed their container — typing in
   Settings / SendModal / NewContactModal would push the border past
   the card edge because the renderer's default box-sizing was
   content-box and `width: 100%` + padding pushed widths past parents.
   Added `*, *::before, *::after { box-sizing: border-box; }` to
   index.html. Removes the need for per-element `boxSizing: 'border-box'`
   (the existing sprinkles stay for clarity but are now redundant).

2. App went blank when opening a chat — any throw inside Conversation
   propagated up through Shell and wiped the whole window, with no way
   to navigate out. Added PaneBoundary, a React error boundary scoped
   to one Shell pane, keyed on `${section}-(list|detail)` so it resets
   when the user switches section. Now a crash shows an inline error
   card with message + stack + Retry, while NavBar + StatusBar stay
   usable.

   Also hardened Conversation against edge cases that were candidates
   for the original crash:
     * `name` always falls back to shortAddr(address) if all other
       branches produce an empty string.
     * first letter used for the avatar is computed once, guarded
       against empty input with a `?` fallback.
     * Header name + short-address line get whiteSpace/overflow/ellipsis
       so very long contacts no longer escape the 32px wide sub-column
       the way they did for the reporter.

Fonts normalised in the global CSS too — inputs/textareas/buttons now
inherit `font-family` instead of the browser default, which was
breaking the visual rhythm in the Settings cards.
2026-04-22 18:53:37 +03:00
vsecoder
6b7cb1c5a9 feat(desktop): contact requests, auto-update banner, packaging polish (v2.2.0)
Closes the v2.2.0 roadmap. Desktop client is feature-complete and
ready for first installer builds.

Contact request flow (fills a real gap flagged by the user):
  * lib/tx.ts grows buildContactRequestTx / buildAcceptContactTx /
    buildBlockContactTx with canonical bytes matching mobile.
  * lib/api.ts: fetchContactRequests + ContactRequestRaw.
  * New contact modal — sections/contacts/NewContactModal.tsx — resolves
    @username / DC-address / hex pub via resolveAccount, shows identity
    preview (incl. "has encryption key / key not published" hint),
    fee tier picker (5k / 10k / 50k µT), optional 280-char intro,
    balance guard.
  * Requests inbox — sections/contacts/RequestsList.tsx — polled every
    15 s via /relay/contacts, filters pending, Accept submits
    ACCEPT_CONTACT + adds the peer to local contacts with their
    identity.x25519_pub pre-cached, Block submits BLOCK_CONTACT.
  * ContactsList grows a two-tab header (Contacts / Requests with a
    pending-count badge) + "+ New" button next to the filter input.

Auto-update:
  * hooks/useUpdateCheck.ts — polls /api/update-check on mount and
    every 6 hours; loose semver compares the Gitea release tag
    against this build's app.version (from Electron IPC), ignores
    the node's own update_available flag (it compares vs. the node,
    not the desktop).
  * shell/UpdateBanner.tsx — thin strip above the status bar with
    the new tag, Download button (opens the release URL in the
    default browser), and a dismiss-for-this-tag × so once-seen
    updates don't nag.

Packaging — electron-builder config tightened:
  * artifactName pattern includes version + os + arch.
  * Mac: hardenedRuntime on, dmg + zip outputs, social-networking
    category.
  * Windows: NSIS (full installer, per-user or per-machine) +
    portable exe.
  * Linux: AppImage + deb.
  * Strip source maps and test folders from the asar.
  * publish: null — no auto-publisher yet; Gitea releases are
    uploaded manually for now.
  * directories.output = release/, directories.buildResources =
    resources/ so icons land in a predictable place once we add them.

Version bumped to 2.2.0 in package.json. docs/ROADMAP.md marks
v2.2.0 row complete; remaining work (attachments, code signing,
group chats) moved to a post-v2.2.0 bucket.
2026-04-22 18:47:19 +03:00
vsecoder
96b347076e 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.
2026-04-22 18:39:39 +03:00
30 changed files with 2763 additions and 205 deletions

View File

@@ -9,6 +9,13 @@
dev vs. production rules cleanly. -->
<title>DChain</title>
<style>
/* Global box-sizing — every padding+border counts toward the declared
width, not on top of it. Without this, `<input style="width:100%">`
inside a padded flex container visibly overflows its parent on
every modal / Settings card. Applied universally because almost
every per-element override was forgetting it anyway. */
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -23,6 +30,11 @@
user-select: text;
-webkit-user-select: text;
}
/* Form elements should never paint their own background/border over
our dark theme. Each component still sets its own explicit colours. */
input, textarea, button {
font-family: inherit;
}
</style>
</head>
<body>

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",
"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",
@@ -34,12 +36,40 @@
"build": {
"appId": "com.dchain.desktop",
"productName": "DChain",
"copyright": "Copyright © 2026 DChain contributors",
"asar": true,
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"files": [
"dist/**/*",
"dist-electron/**/*"
"dist-electron/**/*",
"!**/*.map",
"!**/node_modules/**/test/**",
"!**/node_modules/**/tests/**"
],
"mac": { "target": ["dmg"] },
"win": { "target": ["nsis"] },
"linux": { "target": ["AppImage", "deb"] }
"directories": {
"output": "release",
"buildResources": "resources"
},
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.social-networking",
"hardenedRuntime": true,
"gatekeeperAssess": false
},
"win": {
"target": ["nsis", "portable"]
},
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Network"
},
"publish": null
}
}

View File

@@ -8,6 +8,7 @@ import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
import { setNodeUrl } from '@/lib/api';
import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap';
import { Shell } from '@/shell/Shell';
import { Welcome } from '@/auth/Welcome';
@@ -16,6 +17,11 @@ export function App(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [bootError, setBootError] = useState<string | null>(null);
// Multi-device registry bootstrap — publishes THIS device on the
// chain so senders can fan out envelopes to us, and self-wipes if
// another device has since revoked us. See hooks/useDeviceBootstrap.
useDeviceBootstrap();
useEffect(() => {
(async () => {
try {

View File

@@ -0,0 +1,92 @@
// Mirror of mobile's _layout.tsx bootstrap effect (v2.2.0-alpha2):
// ensures this device is visible to senders via the on-chain device
// registry, and detects remote revoke so a revoked laptop wipes its
// state the moment it sees it's no longer active.
//
// Three branches by (chain list × local "was registered" flag):
//
// 1. Our X25519 pub IS in the active list — flip the local marker
// (idempotent), done. Next sign-in is a no-op.
//
// 2. Our X25519 pub is NOT in the active list, but we had marked
// ourselves registered before → another device issued
// UNLINK_DEVICE against us. Wipe master priv + local caches and
// bounce back to the Welcome screen. Not fatal: user can import
// the key again if that was a mistake.
//
// 3. Our X25519 pub is NOT in the active list, and we've never
// registered before → first sign-in. Submit LINK_DEVICE. On
// a zero-balance wallet the tx bounces; next launch retries.
// No user-facing error — this is best-effort plumbing.
//
// Network errors never trigger the wipe path: we only act when the
// chain explicitly reports the absence.
import { useEffect } from 'react';
import { useStore } from '@/lib/store';
import { fetchDevices } from '@/lib/api';
import { buildLinkDeviceTx, submitTx } from '@/lib/tx';
import {
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
} from '@/lib/storage';
export function useDeviceBootstrap(): void {
const keyFile = useStore(s => s.keyFile);
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
let chainList;
try {
chainList = await fetchDevices(keyFile.pub_key);
} catch {
// Network issue — leave state alone; try again next sign-in.
return;
}
if (cancelled) return;
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
const previouslyRegistered = isDeviceRegistered();
if (inActive) {
if (!previouslyRegistered) markDeviceRegistered();
return;
}
if (previouslyRegistered) {
// Revoked from another device. Wipe and send the user back to
// onboarding; the App-level render branch will route to Welcome
// as soon as keyFile flips to null.
await wipeAllLocalState();
useStore.getState().setKeyFile(null);
return;
}
// First boot — publish this device. Desktop is almost always a
// "second" device (paired from a phone), so a balance is normally
// available; we just need to ship the tx. Failures (insufficient
// balance, a node that doesn't grok v2.2.0) are swallowed.
try {
const platform = await window.dchain.app.platform().catch(() => 'unknown');
const deviceName = platform === 'darwin' ? 'Mac'
: platform === 'win32' ? 'Windows'
: platform === 'linux' ? 'Linux'
: 'Desktop';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
markDeviceRegistered();
} catch {
/* next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
}

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

@@ -0,0 +1,100 @@
// useUpdateCheck — polls the configured node's /api/update-check once
// per launch (+ every 6h while the window stays open), compares the
// Gitea release tag against this client's app version, and exposes
// { latest, url } when ours is older.
//
// Why reuse the node endpoint? The DChain node already fetches Gitea
// releases on behalf of its operator; piggybacking on the same cached
// JSON means the desktop client doesn't need a direct Gitea token or
// a separate update feed. One source of truth, no new infra.
import { useEffect, useState } from 'react';
import { get } from '@/lib/api';
interface UpdateCheck {
current?: { tag?: string };
latest?: { tag?: string; commit?: string; url?: string; published_at?: string };
update_available?: boolean;
source?: string;
checked_at?: string;
}
export interface UpdateInfo {
latestTag: string;
url: string;
publishedAt: string;
}
export function useUpdateCheck(): UpdateInfo | null {
const [info, setInfo] = useState<UpdateInfo | null>(null);
useEffect(() => {
let cancelled = false;
const tick = async () => {
try {
// Our version (set in package.json, baked into Electron at build time).
const myVersion = (await window.dchain.app.version()).trim();
const r = await get<UpdateCheck>('/api/update-check');
if (cancelled) return;
const latest = r.latest?.tag?.trim() ?? '';
if (!latest || !r.latest?.url) { setInfo(null); return; }
// Compare semver-ish. The node's own `update_available` flag
// compares vs. the NODE's version, not ours, so we re-derive.
if (isNewer(latest, myVersion)) {
setInfo({
latestTag: latest,
url: r.latest.url,
publishedAt: r.latest.published_at ?? '',
});
} else {
setInfo(null);
}
} catch {
// Node doesn't have the endpoint configured, or offline — quiet fail.
}
};
tick();
const t = setInterval(tick, 6 * 60 * 60 * 1000);
return () => { cancelled = true; clearInterval(t); };
}, []);
return info;
}
/**
* isNewer — loose semver compare for strings like `v2.2.0` / `2.2.0-rc1`.
* Strips leading `v`, splits on dots and the first `-` (pre-release
* suffix), compares numerically left-to-right. Pre-release tags are
* considered OLDER than the bare version (so `2.2.0 > 2.2.0-rc1`).
* Not a full semver implementation — good enough to decide whether to
* show the "update available" badge. If our parse fails, we assume no
* update (safer than nagging users with false positives).
*/
export function isNewer(candidate: string, reference: string): boolean {
const a = parseVersion(candidate);
const b = parseVersion(reference);
if (!a || !b) return false;
for (let i = 0; i < Math.max(a.nums.length, b.nums.length); i++) {
const x = a.nums[i] ?? 0;
const y = b.nums[i] ?? 0;
if (x !== y) return x > y;
}
// All numeric parts equal → compare pre-release. `""` (stable) beats any suffix.
if (a.pre === b.pre) return false;
if (a.pre === '') return true; // stable > prerelease
if (b.pre === '') return false; // prerelease < stable
return a.pre > b.pre; // alpha6 > alpha5 lexically, fine in practice
}
function parseVersion(v: string): { nums: number[]; pre: string } | null {
if (!v) return null;
const clean = v.trim().replace(/^v/i, '');
const dash = clean.indexOf('-');
const head = dash >= 0 ? clean.slice(0, dash) : clean;
const pre = dash >= 0 ? clean.slice(dash + 1) : '';
const nums = head.split('.').map(s => parseInt(s, 10));
if (nums.some(n => !Number.isFinite(n))) return null;
return { nums, pre };
}

View File

@@ -175,6 +175,30 @@ export async function getTxDetail(txID: string): Promise<TxDetail | null> {
}
}
// ─── Contact requests (on-chain, via /relay/contacts) ───────────────────
export interface ContactRequestRaw {
requester_pub: string;
requester_addr: string;
status: string; // "pending" | "accepted" | "blocked"
intro: string;
fee_ut: number;
tx_id: string;
created_at: number;
}
/**
* GET /relay/contacts?pub=<ed25519> — returns every on-chain
* CONTACT_REQUEST addressed to `pub`, regardless of status. The UI
* filters by pending before showing.
*/
export async function fetchContactRequests(edPub: string): Promise<ContactRequestRaw[]> {
try {
const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`);
return r.contacts ?? [];
} catch { return []; }
}
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
export async function resolveAccount(input: string): Promise<string | null> {
const trimmed = input.trim();

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

@@ -126,6 +126,77 @@ export function buildUnlinkDeviceTx(p: {
};
}
/**
* CONTACT_REQUEST — paid first-contact tx. `amount` carries the
* anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to
* the recipient's balance as an incentive to accept; `fee` is the
* regular network fee. Optional `intro` plaintext is embedded in the
* payload so the receiver sees "who is this" before accepting.
*/
export function buildContactRequestTx(p: {
from: string;
to: string;
contactFee: number; // µT — ≥ 5000, paid to recipient
privKey: string;
intro?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {}));
const canon = canonicalBytes({
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* ACCEPT_CONTACT — recipient side, empties the pending request and
* publishes the peer's X25519 key so the requester can start sending
* encrypted envelopes. Tx.to = original requester's pub.
*/
export function buildAcceptContactTx(p: {
from: string; to: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const canon = canonicalBytes({
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs
* from the same sender are dropped at applyTx level on the node.
*/
export function buildBlockContactTx(p: {
from: string; to: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const canon = canonicalBytes({
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
* message wrappers into a one-line user-facing string. Same helper the

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,198 @@
// Left-pane of Contacts — flat alphabetical list with a text filter.
// Richer grouping (Online / Blocked / Requests) arrives once we have
// WS presence + request inbox plumbing; placeholder headers are left
// in the UI so the shape is visible.
import React, { useEffect, useMemo, useState } from 'react';
import { useStore } from '@/lib/store';
import { shortAddr } from '@/lib/crypto';
import { fetchContactRequests, type ContactRequestRaw } from '@/lib/api';
import type { Contact } from '@/lib/types';
import { NewContactModal } from './NewContactModal';
import { RequestsList } from './RequestsList';
export function ContactsList(): React.ReactElement {
const contacts = useStore(s => s.contacts);
const keyFile = useStore(s => s.keyFile);
const sel = useStore(s => s.selectedContact);
const setSel = useStore(s => s.setSelectedContact);
const [q, setQ] = useState('');
const [tab, setTab] = useState<'list' | 'requests'>('list');
const [newOpen, setNewOpen] = useState(false);
const [requests, setRequests] = useState<ContactRequestRaw[]>([]);
// Load pending contact requests (on-chain inbox). Refreshes when the
// tab is opened and after a new request is sent so the counter moves.
const refreshRequests = async () => {
if (!keyFile) return;
const list = await fetchContactRequests(keyFile.pub_key);
// Filter to pending only — accepted ones turn into contacts.
const knownContacts = new Set(contacts.map(c => c.address));
setRequests(list.filter(r =>
r.status === 'pending' && !knownContacts.has(r.requester_pub),
));
};
useEffect(() => { refreshRequests(); const t = setInterval(refreshRequests, 15_000); return () => clearInterval(t); },
// eslint-disable-next-line react-hooks/exhaustive-deps
[keyFile, contacts]);
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) return contacts;
return contacts.filter(c =>
(c.username ?? '').toLowerCase().includes(needle) ||
(c.alias ?? '').toLowerCase().includes(needle) ||
c.address.toLowerCase().includes(needle),
);
}, [contacts, q]);
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
const an = (a.username ?? a.alias ?? a.address).toLowerCase();
const bn = (b.username ?? b.alias ?? b.address).toLowerCase();
return an.localeCompare(bn);
});
}, [filtered]);
return (
<div>
{/* Sticky header: tab switcher + search / action row */}
<div style={{
position: 'sticky', top: 0, zIndex: 1,
background: '#000', borderBottom: '1px solid #1f1f1f',
}}>
<div style={{
display: 'flex', padding: '8px 10px 0', gap: 4,
}}>
<TabBtn
label="Contacts"
active={tab === 'list'}
onClick={() => setTab('list')}
/>
<TabBtn
label="Requests"
active={tab === 'requests'}
badge={requests.length}
onClick={() => setTab('requests')}
/>
</div>
{tab === 'list' && (
<div style={{ padding: 10, display: 'flex', gap: 8 }}>
<input
value={q}
onChange={e => setQ(e.target.value)}
placeholder="Filter…"
style={{
flex: 1, boxSizing: 'border-box',
background: '#0a0a0a', border: '1px solid #1f1f1f',
borderRadius: 8, padding: '8px 10px',
color: '#fff', fontSize: 13, outline: 'none',
}}
/>
<button
onClick={() => setNewOpen(true)}
title="Send contact request"
style={{
padding: '8px 12px', borderRadius: 8, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>+ New</button>
</div>
)}
</div>
{tab === 'requests' ? (
<RequestsList
requests={requests}
onChanged={refreshRequests}
/>
) : sorted.length === 0 ? (
<div style={{
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
}}>
No contacts yet. Tap <b>+ New</b> above to send a contact request,
or pair another of your own devices via Settings Devices.
</div>
) : (
sorted.map(c => (
<Row key={c.address} c={c} active={c.address === sel} onClick={() => setSel(c.address)} />
))
)}
{newOpen && (
<NewContactModal
onClose={() => setNewOpen(false)}
onSent={() => { setNewOpen(false); refreshRequests(); }}
/>
)}
</div>
);
}
function TabBtn({
label, active, onClick, badge,
}: {
label: string; active: boolean; onClick: () => void; badge?: number;
}) {
return (
<button
onClick={onClick}
style={{
padding: '8px 12px', borderRadius: 8,
border: 'none', background: 'transparent',
color: active ? '#1d9bf0' : '#8b8b8b',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
position: 'relative',
borderBottom: active ? '2px solid #1d9bf0' : '2px solid transparent',
marginBottom: -2,
}}
>
{label}
{badge !== undefined && badge > 0 && (
<span style={{
marginLeft: 6, padding: '0 6px', height: 16,
borderRadius: 8, background: '#1d9bf0', color: '#fff',
fontSize: 10, fontWeight: 700,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
}}>{badge > 99 ? '99+' : badge}</span>
)}
</button>
);
}
function Row({ c, active, onClick }: {
c: Contact; active: boolean; onClick: () => void;
}) {
const name = c.username ? `@${c.username}` : (c.alias ?? shortAddr(c.address, 6));
return (
<div
onClick={onClick}
style={{
padding: '10px 14px', borderBottom: '1px solid #1f1f1f',
background: active ? '#0a1a29' : 'transparent',
cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 10,
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
>
<div style={{
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>{name.replace(/^@/, '').charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#fff', fontSize: 13, fontWeight: 700,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{name}</div>
<div style={{
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{shortAddr(c.address, 8)}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,323 @@
// NewContactModal — send an on-chain CONTACT_REQUEST to a new peer.
//
// Flow:
// 1. Enter @username / DC / hex → resolve into an Ed25519 pub.
// 2. Optional intro + fee-tier pick (5k / 10k / 50k µT).
// 3. Submit CONTACT_REQUEST tx with amount = contactFee.
// The peer sees the request in their Contacts → Requests tab and can
// Accept / Reject. After acceptance an encrypted chat becomes possible
// via the existing /relay/broadcast pipeline.
import React, { useMemo, useState } from 'react';
import { useStore } from '@/lib/store';
import {
resolveAccount, getIdentity, getBalance,
type IdentityInfo,
} from '@/lib/api';
import { buildContactRequestTx, submitTx, humanizeTxError } from '@/lib/tx';
import { shortAddr } from '@/lib/crypto';
const FEE_TIERS = [
{ value: 5_000, label: 'Min', hint: 'enough for a low-spam node' },
{ value: 10_000, label: 'Standard', hint: 'default' },
{ value: 50_000, label: 'Priority', hint: 'more attention-grabbing' },
];
const MIN_NETWORK_FEE = 1_000;
export function NewContactModal({ onClose, onSent }: {
onClose: () => void;
onSent: () => void;
}): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [query, setQuery] = useState('');
const [resolved, setResolved] = useState<{
pub: string; identity: IdentityInfo | null;
} | null>(null);
const [intro, setIntro] = useState('');
const [fee, setFee] = useState<number>(FEE_TIERS[1].value);
const [searching, setSearching] = useState(false);
const [sending, setSending] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const totalCost = fee + MIN_NETWORK_FEE;
const insufficient = balance !== null && balance < totalCost;
React.useEffect(() => {
if (!keyFile) return;
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
}, [keyFile]);
const search = async () => {
const q = query.trim();
if (!q) return;
setSearching(true); setErr(null); setResolved(null);
try {
const pub = await resolveAccount(q);
if (!pub) { setErr(`Couldn't resolve "${q}"`); return; }
if (keyFile && pub.toLowerCase() === keyFile.pub_key.toLowerCase()) {
setErr('That\'s you — open Saved Messages in the chat list instead.');
return;
}
const id = await getIdentity(pub);
setResolved({ pub, identity: id });
} catch (e) {
setErr(String(e));
} finally {
setSearching(false);
}
};
const send = async () => {
if (!keyFile || !resolved || sending) return;
setSending(true); setErr(null);
try {
const tx = buildContactRequestTx({
from: keyFile.pub_key,
to: resolved.pub,
contactFee: fee,
intro: intro.trim() || undefined,
privKey: keyFile.priv_key,
});
await submitTx(tx);
onSent();
} catch (e) {
setErr(humanizeTxError(e));
} finally {
setSending(false);
}
};
const peerName = useMemo(() => {
if (!resolved) return '';
if (resolved.identity?.nickname) return `@${resolved.identity.nickname}`;
return shortAddr(resolved.pub, 8);
}, [resolved]);
return (
<Backdrop onClose={sending ? () => {} : onClose}>
<div style={{
width: '100%', maxWidth: 480, padding: 20, borderRadius: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<Header title="Send contact request" onClose={onClose} busy={sending} />
{/* Search */}
<Label>Who</Label>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') search(); }}
placeholder="@username, DC-address, or hex pub"
spellCheck={false}
autoFocus
style={{
flex: 1, background: '#000', border: '1px solid #1f1f1f',
borderRadius: 8, padding: '10px 12px',
color: '#fff', fontSize: 13, fontFamily: 'monospace',
outline: 'none',
}}
/>
<button
onClick={search}
disabled={searching || query.trim().length === 0}
style={{
padding: '9px 14px', borderRadius: 8, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700,
cursor: searching ? 'default' : 'pointer',
opacity: searching || query.trim().length === 0 ? 0.5 : 1,
}}
>{searching ? '…' : 'Find'}</button>
</div>
{/* Resolved peer preview */}
{resolved && (
<div style={{
marginTop: 12, padding: 12, borderRadius: 10,
background: '#000', border: '1px solid #1f1f1f',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>{peerName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700 }}>
{peerName}
</div>
<div style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
wordBreak: 'break-all',
}}>
{resolved.pub}
</div>
<div style={{
color: resolved.identity?.x25519_pub ? '#3ba55d' : '#f0b35a',
fontSize: 11, marginTop: 3,
}}>
{resolved.identity?.x25519_pub
? '✓ has encryption key published'
: '⚠ no encryption key on chain yet (messaging disabled until they register)'}
</div>
</div>
</div>
)}
{/* Intro */}
{resolved && (
<>
<Label style={{ marginTop: 14 }}>Intro (optional)</Label>
<textarea
value={intro}
onChange={e => setIntro(e.target.value)}
placeholder="Hey — we met at …"
rows={2}
maxLength={280}
style={{
width: '100%', boxSizing: 'border-box',
background: '#000', border: '1px solid #1f1f1f',
borderRadius: 8, padding: '10px 12px',
color: '#fff', fontSize: 13, fontFamily: 'inherit',
outline: 'none', resize: 'vertical',
}}
/>
</>
)}
{/* Fee tiers */}
{resolved && (
<>
<Label style={{ marginTop: 14 }}>Anti-spam fee (paid to recipient)</Label>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{FEE_TIERS.map(t => (
<button
key={t.value}
onClick={() => setFee(t.value)}
style={{
flex: 1, minWidth: 120,
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
background: fee === t.value ? '#0a1a29' : '#000',
border: fee === t.value ? '1px solid #1d9bf0' : '1px solid #1f1f1f',
color: '#fff', textAlign: 'left',
}}
>
<div style={{
fontSize: 12, fontWeight: 700,
color: fee === t.value ? '#1d9bf0' : '#fff',
}}>{t.label}</div>
<div style={{ fontSize: 11, color: '#8b8b8b', marginTop: 2 }}>
{(t.value / 1_000_000).toFixed(3)} T · {t.hint}
</div>
</button>
))}
</div>
</>
)}
{/* Summary + actions */}
{resolved && (
<div style={{
marginTop: 14, color: '#8b8b8b', fontSize: 11, lineHeight: 1.5,
}}>
Cost: <span style={{ color: '#fff' }}>
{(totalCost / 1_000_000).toFixed(3)} T
</span> ({(fee / 1_000_000).toFixed(3)} to recipient · {(MIN_NETWORK_FEE / 1_000_000).toFixed(3)} network fee)
{balance !== null && (
<> · Balance: <span style={{
color: insufficient ? '#f4212e' : '#fff',
}}>{(balance / 1_000_000).toFixed(3)} T</span></>
)}
</div>
)}
{err && (
<div style={{
marginTop: 12, padding: 10, borderRadius: 8,
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
}}>{err}</div>
)}
<div style={{
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
}}>
<button
onClick={onClose}
disabled={sending}
style={{
padding: '9px 14px', borderRadius: 999,
background: 'transparent', border: '1px solid #1f1f1f',
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
cursor: sending ? 'default' : 'pointer',
}}
>Cancel</button>
<button
onClick={send}
disabled={!resolved || insufficient || sending}
style={{
padding: '9px 18px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700,
cursor: (!resolved || insufficient || sending) ? 'default' : 'pointer',
opacity: (!resolved || insufficient || sending) ? 0.5 : 1,
}}
>{sending ? '…' : 'Send request'}</button>
</div>
</div>
</Backdrop>
);
}
// ─── small shared primitives (private to this file — Contacts is the only caller)
function Backdrop({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0, zIndex: 20,
background: 'rgba(0,0,0,0.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
>
<div onClick={e => e.stopPropagation()} style={{ width: '100%', display: 'flex', justifyContent: 'center' }}>
{children}
</div>
</div>
);
}
function Header({ title, onClose, busy }: {
title: string; onClose: () => void; busy: boolean;
}) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 14,
}}>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>{title}</div>
<button
onClick={onClose}
disabled={busy}
style={{
background: 'transparent', border: 'none',
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
}}
>×</button>
</div>
);
}
function Label({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
...style,
}}>{children}</div>
);
}

View File

@@ -0,0 +1,203 @@
// RequestsList — pending contact requests inbox.
//
// Each row shows the requester (identity if known + DC address + fee paid)
// and their intro message. Accept publishes ACCEPT_CONTACT on-chain,
// adds the peer to the local contacts store, and optimistically drops
// the row. Reject (Block) publishes BLOCK_CONTACT; subsequent requests
// from the same sender are refused by the node.
import React, { useState } from 'react';
import { useStore } from '@/lib/store';
import {
buildAcceptContactTx, buildBlockContactTx, buildLinkDeviceTx,
submitTx, humanizeTxError,
} from '@/lib/tx';
import { upsertContact as persistContact, markDeviceRegistered, isDeviceRegistered } from '@/lib/storage';
import { getIdentity, fetchDevices, type ContactRequestRaw } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
export function RequestsList({
requests, onChanged,
}: {
requests: ContactRequestRaw[];
onChanged: () => void;
}): React.ReactElement {
if (requests.length === 0) {
return (
<div style={{
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
}}>
No pending requests. Inbound CONTACT_REQUEST txs will show up here
for you to accept or block.
</div>
);
}
return (
<div>
{requests.map(r => (
<RequestRow key={r.tx_id} req={r} onChanged={onChanged} />
))}
</div>
);
}
function RequestRow({
req, onChanged,
}: { req: ContactRequestRaw; onChanged: () => void }) {
const keyFile = useStore(s => s.keyFile);
const upsertContact = useStore(s => s.upsertContact);
const setSection = useStore(s => s.setSection);
const setActiveChat = useStore(s => s.setActiveChat);
const [busy, setBusy] = useState<'accept' | 'block' | null>(null);
const [err, setErr] = useState<string | null>(null);
const act = async (kind: 'accept' | 'block') => {
if (!keyFile) return;
setBusy(kind); setErr(null);
try {
if (kind === 'accept') {
// Need the requester's X25519 so a local contact is created
// with encryption enabled out of the gate — without it the
// first outgoing message would surface "no key" until we
// refetched via resolveRecipientKeys.
const identity = await getIdentity(req.requester_pub);
const tx = buildAcceptContactTx({
from: keyFile.pub_key,
to: req.requester_pub,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Make sure OUR device is published on-chain too. The
// useDeviceBootstrap effect tries this on sign-in, but if the
// user had zero balance then the tx bounced; now that the
// incoming CONTACT_REQUEST has paid us the contact fee, we
// have the µT needed. Without this, the peer couldn't encrypt
// to us — they'd see "recipient has no encryption key" even
// though we just accepted.
try {
const ownDevices = await fetchDevices(keyFile.pub_key);
const alreadyLinked = ownDevices.some(d => d.x25519_pub_key === keyFile.x25519_pub);
if (!alreadyLinked && !isDeviceRegistered()) {
const platform = await window.dchain.app.platform().catch(() => 'unknown');
const deviceName = platform === 'darwin' ? 'Mac'
: platform === 'win32' ? 'Windows'
: platform === 'linux' ? 'Linux'
: 'Desktop';
const linkTx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(linkTx);
markDeviceRegistered();
}
} catch { /* best-effort — next sign-in retries */ }
const c = {
address: req.requester_pub,
x25519Pub: identity?.x25519_pub ?? '',
username: identity?.nickname || undefined,
alias: undefined,
addedAt: Date.now(),
};
upsertContact(c);
persistContact(c);
// Jump the user straight into the new chat — mirrors mobile's
// router.replace(/chats/<pub>) after accept.
setActiveChat(req.requester_pub);
setSection('messages');
} else {
const tx = buildBlockContactTx({
from: keyFile.pub_key,
to: req.requester_pub,
privKey: keyFile.priv_key,
});
await submitTx(tx);
}
onChanged();
} catch (e) {
setErr(humanizeTxError(e));
} finally {
setBusy(null);
}
};
return (
<div style={{
padding: 14, borderBottom: '1px solid #1f1f1f',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
}}>
<div style={{
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>{shortAddr(req.requester_pub, 1).charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#fff', fontSize: 13, fontWeight: 700,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{shortAddr(req.requester_pub, 8)}
</div>
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
{req.requester_addr}
</div>
</div>
<div style={{
color: '#f0b35a', fontSize: 11, fontWeight: 700,
}}>
+{(req.fee_ut / 1_000_000).toFixed(3)} T
</div>
</div>
{req.intro && (
<div className="selectable" style={{
padding: 10, borderRadius: 8,
background: '#000', border: '1px solid #1f1f1f',
color: '#e0e0e0', fontSize: 12, lineHeight: 1.5,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
marginBottom: 8,
}}>
{req.intro}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => act('block')}
disabled={!!busy}
style={{
padding: '7px 12px', borderRadius: 999,
background: 'transparent', border: '1px solid #3a2020',
color: '#ff6b6b', fontSize: 12, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
opacity: busy ? 0.5 : 1,
}}
>{busy === 'block' ? '…' : 'Block'}</button>
<button
onClick={() => act('accept')}
disabled={!!busy}
style={{
padding: '7px 14px', borderRadius: 999,
border: 'none', background: '#1d9bf0', color: '#fff',
fontSize: 12, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
opacity: busy ? 0.5 : 1,
}}
>{busy === 'accept' ? '…' : 'Accept'}</button>
</div>
{err && (
<div style={{
marginTop: 8, padding: 8, borderRadius: 6,
background: '#2a1414', color: '#ff9b9b', fontSize: 11,
}}>{err}</div>
)}
</div>
);
}

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

@@ -16,10 +16,18 @@ import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
import { appendMessage as persist } from '@/lib/storage';
import type { Message } from '@/lib/types';
// A module-level stable reference for "no messages yet". Without this the
// selector `s.messages[address] ?? []` allocates a fresh empty array on
// every render when the conversation has no cached entries, which zustand
// sees as a changed value → triggers another render → new empty array
// again → "Maximum update depth exceeded". Returning the exact same
// reference every time breaks the cycle.
const EMPTY_MESSAGES: Message[] = [];
export function Conversation({ address }: { address: string }): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const contact = useStore(s => s.contacts.find(c => c.address === address));
const messages = useStore(s => s.messages[address] ?? []);
const messages = useStore(s => s.messages[address] ?? EMPTY_MESSAGES);
const clearUnread = useStore(s => s.clearUnread);
const appendMsg = useStore(s => s.appendMessage);
@@ -57,7 +65,15 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
if (!isSelf) {
const pubs = await resolveRecipientKeys(address);
if (pubs.length === 0) {
throw new Error('recipient has no encryption key published');
// Most common cause: the peer's device hasn't published a
// LINK_DEVICE yet (they accepted just now and haven't had the
// fee debited, or they haven't re-opened the app). Clearer
// copy than "recipient has no encryption key".
throw new Error(
'Recipient has no device key published on-chain yet. ' +
'Ask them to re-open their app so the LINK_DEVICE tx commits, ' +
'then try again.',
);
}
await Promise.all(pubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage(
@@ -98,12 +114,13 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
}
};
const name = contact?.username ? `@${contact.username}`
const name = (contact?.username ? `@${contact.username}`
: contact?.alias
? contact.alias
: isSelf
? 'Saved Messages'
: shortAddr(address, 8);
: shortAddr(address || '', 8)) || shortAddr(address || '', 8);
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
@@ -118,12 +135,18 @@ export function Conversation({ address }: { address: string }): React.ReactEleme
color: '#fff', fontWeight: 700, fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
{isSelf ? '★' : firstLetter}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>{name}</div>
<div style={{ color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace' }}>
{shortAddr(address, 6)}
<div style={{
color: '#fff', fontSize: 14, fontWeight: 700,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</div>
<div style={{
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{shortAddr(address || '', 6)}
</div>
</div>
</div>

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}
</div>
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

@@ -0,0 +1,62 @@
// PaneBoundary — ErrorBoundary scoped to one Shell pane. A crash in
// the Conversation component shouldn't black-out the whole window; it
// should leave NavBar + List + StatusBar usable so the operator can
// switch sections and report the bug. Resets when the keyed section
// changes.
import React from 'react';
interface Props {
/** Used as React key at the callsite; also shown in the panic copy. */
sectionName: string;
children: React.ReactNode;
}
interface State {
error: Error | null;
}
export class PaneBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error(`[PaneBoundary:${this.props.sectionName}]`, error, info);
}
render(): React.ReactNode {
if (!this.state.error) return this.props.children;
return (
<div style={{
padding: 20, height: '100%', overflow: 'auto',
background: '#000', color: '#fff', fontFamily: 'monospace',
}}>
<div style={{
color: '#ff6b6b', fontSize: 14, fontWeight: 700, marginBottom: 8,
}}>
{this.props.sectionName} crashed
</div>
<div style={{ color: '#fff', fontSize: 13, marginBottom: 8 }}>
{this.state.error.message}
</div>
<pre style={{
color: '#8b8b8b', fontSize: 11, lineHeight: 1.4,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{this.state.error.stack}
</pre>
<button
onClick={() => this.setState({ error: null })}
style={{
marginTop: 10, padding: '6px 12px', borderRadius: 999,
border: '1px solid #1f1f1f', background: '#111',
color: '#fff', fontSize: 12, cursor: 'pointer',
}}
>Retry</button>
</div>
);
}
}

View File

@@ -19,9 +19,12 @@
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';
import { UpdateBanner } from './UpdateBanner';
import { PaneBoundary } from './PaneBoundary';
import { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
@@ -32,6 +35,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',
@@ -48,12 +52,17 @@ export function Shell(): React.ReactElement {
borderRight: '1px solid #1f1f1f',
overflowY: 'auto',
}}>
<List />
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
<List />
</PaneBoundary>
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Detail />
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
<Detail />
</PaneBoundary>
</div>
</div>
<UpdateBanner />
<StatusBar />
</div>
);

View File

@@ -0,0 +1,56 @@
// UpdateBanner — appears just above the status bar when a newer release
// tag is available on Gitea. Single action: open the release page in
// the default browser. We deliberately don't auto-download — the user
// probably wants to read the changelog first, and the binary hosting
// story is still "Gitea release assets" rather than a signed feed.
import React, { useState } from 'react';
import { useUpdateCheck } from '@/hooks/useUpdateCheck';
export function UpdateBanner(): React.ReactElement | null {
const info = useUpdateCheck();
const [dismissed, setDismissed] = useState<string | null>(null);
if (!info) return null;
if (dismissed === info.latestTag) return null;
return (
<div style={{
padding: '8px 16px',
background: '#0d2540',
borderTop: '1px solid #1d9bf022',
color: '#fff',
fontSize: 12,
display: 'flex', alignItems: 'center', gap: 12,
}}>
<span style={{ color: '#1d9bf0', fontSize: 16 }}></span>
<span style={{ flex: 1 }}>
Update available: <b>{info.latestTag}</b>
{info.publishedAt && (
<span style={{ color: '#8b8b8b', marginLeft: 8 }}>
published {new Date(info.publishedAt).toLocaleDateString()}
</span>
)}
</span>
<a
href={info.url}
target="_blank"
rel="noreferrer"
style={{
padding: '5px 12px', borderRadius: 999,
background: '#1d9bf0', color: '#fff',
fontSize: 11, fontWeight: 700,
textDecoration: 'none',
}}
>Download</a>
<button
onClick={() => setDismissed(info.latestTag)}
style={{
background: 'transparent', border: 'none',
color: '#8b8b8b', fontSize: 16, cursor: 'pointer',
padding: 0, lineHeight: 1,
}}
>×</button>
</div>
);
}

View File

@@ -188,20 +188,25 @@ 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).
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
pipeline nodes use; `electron-builder``.dmg`, `.exe`,
`.AppImage`, `.deb`.
- [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).
- [x] **v2.2.0** — Contact-request flow (New contact modal + Requests
inbox tab with Accept/Block), auto-update banner that polls
`/api/update-check` and offers the latest Gitea release,
electron-builder config ready for `.dmg` / `.exe` / `.AppImage` /
`.deb` + NSIS installer + macOS hardenedRuntime.
- [ ] **post-v2.2.0** — Attachments in Compose (file picker +
client-side image resize + metadata scrub), code signing
certificates, draft group chats (multi-recipient envelopes or
MLS integration).
### Открытые вопросы (desktop)