13 Commits

Author SHA1 Message Date
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
vsecoder
98ac700e0a feat(desktop): Feed + Wallet sections (v2.2.0-alpha6)
Desktop client reaches full feature parity with mobile for the two
heaviest sections. Contacts + Devices screen + polish pass remain for
rc1.

Feed section (src/sections/feed/ + src/lib/feed.ts):
  * Left pane — FeedTabs: For You / Following / Trending 24h + a
    hashtag input that promotes to a tab on Enter; breadcrumb back-
    navigation when you drill into an author wall or hashtag.
  * Right pane — FeedPane: two sub-columns. Scrollable post list
    (truncated body, likes/views/hashtags footer, active highlight)
    + PostDetail with full body, hashtag links (click → hashtag tab),
    inline attachment image, like/unlike button, Delete (if mine).
    On-mount side-effects: bumpView + fetchStats for liked-by-me.
  * ComposeModal — new-post dialog. Ctrl/Cmd+N opens it; Ctrl+Enter
    submits. Byte counter against 4000 limit, live hashtag preview.
    Uses publishAndCommit (server-side image scrub happens when
    attachments land in rc1).
  * lib/feed.ts — full mirror of mobile's feed.ts:
    fetchForYou/Timeline/Trending/Author/Hashtag/Post/Stats,
    bumpView, like/unlike/delete/follow/unfollow, publishPost +
    publishAndCommit + buildCreatePostTx. Uses window.crypto.subtle
    for SHA-256 (no expo-crypto dep). Same canonical-bytes as mobile.

Wallet section (src/sections/wallet/ + new bits in src/lib/api.ts):
  * WalletOverview (left): account card (balance + shortened pub +
    Send/Receive/Refresh) and transaction history grouped by row.
    Amount colour-codes by direction; pretty tx-type labels.
  * WalletDetailPane (right): selected tx — big signed amount,
    2-column key/value grid (id, from, to, amount, fee, time, block,
    gas), collapsible JSON payload + payload_hex fallback. Mirror of
    mobile /tx/[id] layout.
  * SendModal — transfer tx with @username / DC-address / hex pub
    resolution via resolveAccount. Balance + fee preview; refuses
    self-transfer (would roundtrip through mempool for no reason).
  * ReceiveModal — pub + Copy button. QR in rc1 once we pull in a
    qrcode lib.
  * lib/api.ts: TxRow + TxDetail types, getTxHistory, getTxDetail,
    resolveAccount (handles hex/@username/DC-address).

Store adds feedTab + feedSelectedPost + walletSel so selection state
survives section-switches. FeedTab discriminated union covers the
hashtag + author sub-states so breadcrumbs know what to render.

Typecheck + renderer build both pass. Node API used as-is — no
server changes in this release.
2026-04-22 18:19:41 +03:00
vsecoder
ce11a13874 feat: desktop messaging + pairing + cross-client master-pub attribution (v2.2.0-alpha5)
Two coordinated changes:

1. Desktop client gets a functional Messages section and working pairing
flow, putting it at feature parity with mobile for the v2.2.0 line.

2. Server + both clients teach each other to use the sender's master
Ed25519 (not just their X25519) to address conversations, so a peer
writing from a different linked device still rolls into the same chat.
This is the "new API logic" the desktop scaffold was waiting on.

Server (node/api_relay.go, cmd/node/main.go):
  * /relay/inbox items now carry `sender_ed25519_pub` alongside the
    per-device `sender_pub`. Empty string for pre-v2.2.0 senders.
  * WS `inbox` push summary also includes `sender_ed25519_pub`, so the
    client can skip the refetch when the envelope plainly isn't for
    the chat they're watching.
  * Both existing tests pass.

Mobile client:
  * lib/types.ts Envelope grew `sender_ed25519_pub`; fetchInbox normalises
    it (default '') for older nodes.
  * hooks/useGlobalInbox matches contacts by (master Ed25519 OR legacy
    X25519) so an incoming message from a peer's desktop reuses the
    existing chat instead of creating a duplicate placeholder.
  * hooks/useMessages now takes an optional `contactMasterEd25519` and
    exposes a matchesChat() predicate; WS inbox handler uses it too to
    avoid spurious refetches.
  * chats/[id].tsx passes `contact.address` (master) along with x25519.

Desktop client — all new:
  * src/lib/crypto.ts — tweetnacl hex/base64 helpers, generateKeyFile,
    encryptMessage/decryptMessage, signBase64, shortAddr. Same signatures
    as the mobile lib; uses Chromium's window.crypto, no expo-crypto dep.
  * src/lib/tx.ts — buildTransferTx / buildLinkDeviceTx / buildUnlinkDeviceTx
    + submitTx + humanizeTxError, canonical-bytes identical to mobile.
  * src/lib/relay.ts — fetchInbox, sendEnvelope, resolveRecipientKeys
    (multi-device fan-out with legacy identity.x25519 fallback).
  * src/lib/store.ts — zustand state gets messages{}, unread{},
    activeChat.
  * src/lib/storage.ts — per-chat cache via localStorage (500-msg cap).
  * src/hooks/useInboxPoll — 4s polling loop, addresses conversations
    by master Ed25519, bumps unread unless chat is active.
  * src/sections/messages/* — ChatList (sorted tiles, unread badges),
    Conversation (auto-scroll messages + composer + fan-out send,
    Enter-to-send / Shift+Enter for newline), EmptyConversation.
  * src/auth/Pair.tsx — 6-digit code + device key screen, polls inbox
    for a handshake envelope, assembles the KeyFile on arrival.
  * Welcome.tsx: Pair button now actually routes to <Pair>; imports
    generateKeyFile from lib/crypto (was inlined).

docs/ROADMAP.md delta: alpha5 row flipped to done inline. Alpha6
(feed + wallet) and rc1 (contacts + devices UI + profile) still
pending.
2026-04-22 17:43:18 +03:00
vsecoder
49ad09efe7 fix(node): proper CORS middleware + preflight handling
The desktop Electron renderer runs at http://127.0.0.1:5173 (dev) or
file:// (prod); the node HTTP API is at a different origin by design.
Browsers enforce CORS, and our per-handler `Access-Control-Allow-Origin: *`
header only covered the happy path — preflight OPTIONS requests, which
browsers send before any POST with a JSON body or Authorization header,
fell through to the 404 handler without CORS headers and the subsequent
real request was blocked.

Added node/cors.go — a single middleware that:
  * Sets Access-Control-Allow-Origin / -Methods / -Headers /
    -Expose-Headers / -Max-Age on every response.
  * Short-circuits OPTIONS with 204, never invoking the mux.

Wired into stats.go:ListenAndServe so the wrapping is unconditional
(the node's security model gates writes by token + Ed25519 signature,
not by origin, so wide CORS is the correct default).

Cleaned up the now-redundant per-jsonOK/jsonErr Allow-Origin setters in
api_common.go — the middleware sets a single consistent header instead
of two collisions from handlers that both write one.

Symptom before: `net::ERR_FAILED` / "CORS policy blocked" errors in
the Electron devtools console when hitting /api/* or /relay/*.
Symptom after: clean GET/POST, preflight answers in ~1ms.
2026-04-22 17:22:39 +03:00
vsecoder
963fe062e3 fix(desktop): pin Vite + wait-on to IPv4 for Windows dev launch
On Windows, `wait-on tcp:5173` can hang forever because Vite's default
host ('localhost') binds to IPv6 (::1) while wait-on probes 127.0.0.1.
concurrently then never triggers electron:dev, leaving Vite running in
the foreground with no Electron window.

Pin both sides to IPv4:
  * `vite --host 127.0.0.1` — force the dev server off ::1.
  * `wait-on http://127.0.0.1:5173` — real HTTP GET instead of raw TCP,
    robust against the same dual-stack oddity.
  * VITE_DEV_SERVER_URL switched to the matching 127.0.0.1 so Electron
    loads the same origin the CSP checks against.

Symptom before: `npm run dev` printed Vite banner then sat there silent.
Symptom after: electron:dev line appears within a second, Electron
window opens with the Welcome screen.
2026-04-22 17:16:58 +03:00
vsecoder
3641cb113d fix(desktop): CSP via webRequest + boot error visibility
Two problems from the first alpha4 run reported as "blank window + CSP
warning in devtools":

1. CSP was set via <meta> in index.html with a strict policy (script-src
   'self'). Vite's dev server uses eval() for HMR, which the strict CSP
   blocked at module-load time, so the renderer never ran. The meta CSP
   also conflicted with Electron's own security heuristics (hence the
   warning even though *we* had a policy — Electron was looking for it
   on the HTTP response).

   Moved the CSP to electron/main.ts via session.webRequest
   .onHeadersReceived. Dev profile enables 'unsafe-eval' + ws:/wss: for
   HMR; production profile stays strict (no eval, no remote scripts,
   connect-src still wide because the user picks arbitrary node URLs).

2. When window.dchain isn't available (preload failed to load, dev
   misconfig, etc.), loadKeyFile() throws inside a useEffect. React
   swallows async-effect throws, so the app renders blank forever.

   Added:
     - requireDchain() guard in storage.ts with an explicit error.
     - App.tsx catches boot-effect errors and renders them inline.
     - ErrorBoundary.tsx for render-time throws.
     - window.addEventListener('error') in main.tsx as a last-resort
       paint for throws that escape React entirely.

Also: npm script electron:dev now rebuilds main.ts before spawning
Electron (was a silent concurrency bug — TypeScript errors in main.ts
would produce stale dist-electron/).
2026-04-22 17:13:26 +03:00
vsecoder
b55486775e feat(desktop): Electron scaffold, shell, auth + section stubs (v2.2.0-alpha4)
PR #4 of the multi-device roadmap — desktop client groundwork. The shell
compiles and runs end-to-end on top of a v2.2.0 node; sections are
placeholders that later alphas fill in with real chat / feed / wallet /
contacts / settings content shared with the mobile client-app.

Scaffold:
  * Vite + React + TypeScript renderer; Electron main/preload TS
    compiled via a separate tsconfig.
  * npm scripts — `dev` (concurrent Vite + Electron), `build`
    (installer via electron-builder), `typecheck`.
  * electron-builder targets: .dmg / .exe / .AppImage + .deb.
  * CSP pins script-src 'self'; connect-src left open so the renderer
    can hit any configured node.

Electron main + preload:
  * Frame-less window, hiddenInset on macOS, custom-overlay on Windows,
    drag region via CSS -webkit-app-region: drag on our TitleBar.
  * contextIsolation on, nodeIntegration off, sandbox off (needed for
    safeStorage in preload).
  * window.dchain.keyfile.{load,save,delete,encryptionAvailable} —
    keyfile lives in the OS keychain via Electron safeStorage, with a
    plaintext fallback for OSes without an encryption backend.
  * window.dchain.dialog.{openFile,saveFile}, .fs.{readText,writeText},
    .app.{version,platform}. Everything else still goes over plain
    fetch() in the renderer.

Shell (src/shell/):
  * TitleBar — draggable 32px strip; DChain brand.
  * NavBar — left 72px rail, six sections + Cmd+1..5 keybinds.
  * StatusBar — ● online/connecting/offline dot, node URL, current
    chain height (polls /api/netstats every 5s).
  * Shell — composes the 3 panes; picks { List, Detail } by active
    section.

Sections (all stubs — filling in alpha5+):
  * Messages, Feed, Contacts, Profile — SectionPlaceholder with notes.
  * Wallet — shows the balance reading from /api/address/{pub} as a
    first real data binding.
  * Settings — node-URL card with live ping + commit, identity card
    (shows pub key), about card (reads Electron app.version via IPC).

Auth (src/auth/Welcome.tsx):
  * Create — generates Ed25519 + X25519 via tweetnacl, saves via IPC.
  * Import — Electron dialog.openFile → parses node.json → saves.
  * Pair — stub routed; real poll loop reuses the mobile flow in
    alpha5.

Lib (src/lib/):
  * types.ts — KeyFile / Contact / Message / NodeSettings mirroring
    client-app wire formats.
  * storage.ts — keyfile via IPC, settings + contacts + device-registered
    marker via localStorage.
  * api.ts — fetch wrapper with setNodeUrl + onNodeUrlChange;
    getNetStats, getIdentity, fetchDevices, getBalance bindings.
  * store.ts — zustand { booted, keyFile, settings, contacts, section }.

docs/ROADMAP.md — desktop subsection updated with per-alpha breakdown.

Next (alpha5): Messages section wired to the relay mailbox, full
conversation view, and the pairing poll loop.
2026-04-22 17:03:06 +03:00
vsecoder
af7223b93c feat(client): device pairing flow (v2.2.0-alpha3)
Completes PR #3 of the multi-device roadmap. Two devices of the same
identity can now be linked via a six-digit code + relay envelope
handshake. Chain-level fan-out (alpha2) and self-wipe on revoke (this
release, earlier commit) already work end-to-end.

New device — app/(auth)/pair.tsx:
  * Generates fresh X25519 keypair + six-digit code locally.
  * Displays them for transcription on the primary device.
  * Polls /relay/inbox on its own x25519 pub every 2.5s.
  * Decrypts each envelope with the session priv + sender_pub.
  * Accepts only payloads where {v=1, type=pair-handshake, code matches}.
  * On success, assembles a KeyFile (master Ed25519 from envelope,
    x25519 from session) and redirects into (app).

Primary device — app/(app)/devices.tsx:
  * "Link new device" opens a modal asking for {code, device key, name}.
  * On submit: builds+submits LINK_DEVICE tx for the new pub, then
    sends a one-shot relay envelope carrying {master_pub, master_priv,
    master_x25519_pub, code} encrypted for the new device's x25519 pub.
  * Optimistic local insert so the new row appears immediately.

Entry point — app/index.tsx:
  * Third button on welcome slide 3: "Pair". Routes to /auth/pair.
    Create / Import remain unchanged for fresh identities.

Security:
  * Master Ed25519 priv leaves the source device ONLY inside an envelope
    sealed with NaCl box for the new device's X25519 pub. The relay node
    sees only ciphertext.
  * Six-digit code (~20 bits) gates acceptance — an attacker who guesses
    both a session pub AND the code is still filtered by the X25519
    decryption itself (code match is belt-and-suspenders).
  * Envelope stays in relay mailbox until TTL — no DELETE call yet;
    idempotent on our side (saveKeyFile overwrites, session pub
    never polled after redirect).

Known trade-offs:
  * Manual transcription of a 64-char hex key is ugly. Alpha4 will
    offer a QR fallback on phones with cameras; desktop keeps typing.
  * No rate limit on the polling. Fine for a 1-minute handshake, needs
    cap-on-stale if a user leaves the screen open.
2026-04-22 16:32:34 +03:00
vsecoder
8940b97cc6 feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come.

Devices screen — app/(app)/devices.tsx:
  * Lists every active device from /api/devices/{self}.
  * "THIS DEVICE" badge on our own row, Unlink button on every other.
  * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal.
  * Pull-to-refresh; empty state when balance is too low for auto-link.
  * Placeholder row for "Link new device" — wired in next commit.

Settings → Devices entry row: added under a new "Devices" section.

Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx:
  * New AsyncStorage marker `dchain_device_registered` tracks whether
    this install ever made it into the on-chain registry.
  * wipeAllLocalState() zeroes secure-store key + contacts + settings +
    chats cache + marker. Safe-idempotent.
  * Bootstrap effect in app layout splits three branches by
    (our_pub in chain's active list × marker_set):
      - in list      → mark registered, done.
      - not in list + was registered → REVOKED → wipe + redirect to auth.
      - not in list + never registered → first boot, LINK_DEVICE.
  * Network errors never trigger wipe — only an explicit "pub missing
    from chain response" decides it. Belt-and-suspenders against a
    misbehaving node spuriously dropping records.

Next: pairing flow so a second device (desktop, tablet, new phone)
can come online, show a 6-digit code, receive master priv via a
one-shot relay envelope encrypted to its fresh device X25519 pub,
then self-link.
2026-04-22 16:28:16 +03:00
vsecoder
423d307125 feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2)
PR #2 of the multi-device roadmap — wires the messenger pipeline
against the on-chain registry landed in v2.2.0-alpha1.

lib/api.ts:
  - DeviceInfo type mirroring blockchain.DeviceInfo.
  - IdentityInfo.device_count (optional; populated from /api/identity).
  - fetchDevices(masterPub) → /api/devices/{master_pub}, returns [].
    Errors swallowed so a downed endpoint doesn't block messaging.
  - resolveRecipientKeys(masterPub) — the routing primitive. Returns
    devices[] if registered, else falls back to IdentityInfo.x25519_pub
    (pre-v2.2.0 path). Empty only when recipient has published nothing.
  - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519,
    min-fee cost, canonical JSON payload matching the chain-side
    LinkDevicePayload / UnlinkDevicePayload.

app/(app)/chats/[id].tsx:
  - sendCore now fans out: encrypts once per recipient device pub
    (Promise.all, any failure rejects the batch), falls back to the
    cached contact.x25519Pub if the registry lookup returns nothing.
  - Saved Messages short-circuit preserved; no devices lookup for self.

app/(app)/_layout.tsx:
  - On every sign-in, auto-submit LINK_DEVICE for this device if its
    X25519 pub isn't already in the master's registry. Device name
    picks "iPhone" / "Android phone" / "Device" by Platform. Errors
    (insufficient balance / legacy chain without LINK_DEVICE support)
    are silent — next launch retries.

Backward compatibility: senders fall back to identity.x25519_pub when
the recipient has no registry entries, so pre-v2.2.0 clients still
receive messages. Chain-side already gates new validation on the event
types existing; old clients simply never emit LINK_DEVICE and keep
working with a single X25519.

Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side
self-wipe on revoke detection).
2026-04-22 16:24:36 +03:00
vsecoder
1d9206494a feat(chain): multi-device registry (v2.2.0-alpha1)
PR #1 of the multi-device roadmap. Adds per-device X25519 keys registered
on-chain so senders can fan out envelopes across all of a recipient's
physical devices — fixes the single-device limitation where a second
phone / desktop loses messages as soon as the first one reads them.

Chain (blockchain/):
  - New event types LINK_DEVICE / UNLINK_DEVICE, signed by the identity's
    master Ed25519.
  - LinkDevicePayload {x25519_pub_key, device_name} +
    UnlinkDevicePayload {x25519_pub_key} on the wire.
  - State: prefixDevice + x25519_pub → DeviceRecord{owner, name,
    added_at, revoked_at?}; reverse index prefixDevicesByOwner for
    O(k) listing. Revoke is a soft-delete — the row stays as a visible
    tombstone so offline clients can detect their own revocation and
    wipe local state.
  - MaxDevicesPerOwner = 10 slot cap; MaxDeviceNameLen = 64.
  - Strict lowercase-hex validation on x25519_pub so clients can't
    desync on letter case.
  - Same-owner re-link is a rename/refresh (recreates reverse index too
    — needed after a revoke).
  - Chain.DevicesOf(master_pub) returns the active records; empty slice
    for legacy identities so senders can fall back to IdentityInfo.X25519Pub.

HTTP (node/):
  - GET /api/devices/{master_pub_or_addr} — returns {master_pub, count,
    devices[]}. Revoked records filtered out.
  - /api/identity/{pub} gains `device_count` so senders can decide
    upfront whether to fan out or take the legacy path.

Tests (blockchain/devices_test.go):
  - Happy paths (1, 3 devices), foreign-owner rejection, same-owner
    refresh after revoke, unlink removes from active set,
    foreign-signer unlink rejection, idempotent double-unlink,
    malformed pub/name rejection, MaxDevices cap + recovery after
    unlink frees a slot, empty list for unknown master.

Also in this commit:
  - deploy/single/join.sh — convenience script operators have been
    iterating on in this session (joiner-node bring-up + firewall
    port patching + Caddy opt-out).
  - client-app/app.json — `usesCleartextTraffic: true` on Android so
    installed APKs can talk to http:// dev nodes without TLS.

See docs/ROADMAP.md for PRs #2..#4 (client fan-out, pairing flow,
desktop Electron shell).
2026-04-22 16:20:07 +03:00
vsecoder
217b374789 docs: add v2.2.0 multi-device + desktop Electron roadmap
Living doc that captures:
  * Multi-device plan — per-device X25519 keys + on-chain device registry
    (LINK_DEVICE / UNLINK_DEVICE tx types, sender fan-out, revoke + wipe).
    Broken into four PRs (#1 chain, #2 client fan-out, #3 pairing flow,
    #4 desktop shell) tagged v2.2.0-alphaN.
  * Desktop Electron client — 3-panel layout, section map, reused lib/,
    keybinds, packaging targets. Queued after v2.2.0-alpha3.
  * Known smaller tails — gossip dup log noise, seed self-announce
    fallback, group chats (post-v2.2.0), reputation-weighted leader.
2026-04-22 16:05:06 +03:00
86 changed files with 16727 additions and 65 deletions

View File

@@ -54,6 +54,9 @@ const (
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
// Multi-device registry (v2.2.0)
prefixDevice = "device:" // device:<x25519_pub> → DeviceRecord JSON
prefixDevicesByOwner = "devicesbyowner:" // devicesbyowner:<master_pub>:<x25519_pub> → "" (reverse index for O(k) listing)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -771,6 +774,99 @@ func PayChanCloseSigPayload(channelID string, balanceA, balanceB, nonce uint64)
return payChanCloseSigPayload(channelID, balanceA, balanceB, nonce)
}
// DevicesOf returns the active (non-revoked) device records for a master
// identity. Sender fan-out reads this to decide how many envelopes to
// produce per outgoing message. Empty slice (not error) for identities
// with no registry entries yet — caller should fall back to the legacy
// single-X25519 path via IdentityInfo.
func (c *Chain) DevicesOf(masterPub string) ([]DeviceRecord, error) {
var out []DeviceRecord
err := c.db.View(func(txn *badger.Txn) error {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
key := it.Item().KeyCopy(nil)
// key layout: devicesbyowner:<master>:<x25519> — take the suffix
x25519Pub := string(key[len(prefix):])
recItem, err := txn.Get([]byte(prefixDevice + x25519Pub))
if err != nil {
// Reverse index points to a missing record — stale entry,
// skip silently (shouldn't happen unless the DB was tampered
// with, but we don't want to fail the whole call).
continue
}
var rec DeviceRecord
if verr := recItem.Value(func(v []byte) error {
return json.Unmarshal(v, &rec)
}); verr != nil {
continue
}
if rec.RevokedAt != 0 {
continue
}
out = append(out, rec)
}
return nil
})
return out, err
}
// countActiveDevicesForOwner walks the reverse index (entries there are,
// by construction, non-revoked — UNLINK_DEVICE deletes the index row).
// O(k) where k = active device count, bounded by MaxDevicesPerOwner.
func (c *Chain) countActiveDevicesForOwner(txn *badger.Txn, masterPub string) (int, error) {
prefix := []byte(prefixDevicesByOwner + masterPub + ":")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix, PrefetchValues: false})
defer it.Close()
n := 0
for it.Rewind(); it.Valid(); it.Next() {
n++
}
return n, nil
}
// validateDevicePubKey checks the wire-level format of a device X25519
// public key: 64 hex characters (32 bytes). Does NOT check that the key
// is a valid curve point — senders learn that at encrypt time, and an
// invalid point there just yields garbage ciphertext which the device
// can't decrypt (self-punishing).
func validateDevicePubKey(hexPub string) error {
if len(hexPub) != 64 {
return fmt.Errorf("x25519_pub_key must be 64 hex chars (got %d)", len(hexPub))
}
// Enforce strict lowercase-hex to keep keys canonical across clients —
// two clients hashing the same bytes with different letter cases would
// produce different registry lookups, which breaks sender fan-out.
for _, r := range hexPub {
switch {
case r >= '0' && r <= '9':
case r >= 'a' && r <= 'f':
default:
return fmt.Errorf("x25519_pub_key must be lowercase hex")
}
}
return nil
}
// validateDeviceName enforces the UX constraint that device labels stay
// short and printable — this field gets displayed in Settings → Devices
// and has no business carrying newlines, control chars, or 2KB blobs.
func validateDeviceName(name string) error {
if name == "" {
return fmt.Errorf("device_name is required")
}
if len(name) > MaxDeviceNameLen {
return fmt.Errorf("device_name %d bytes > max %d", len(name), MaxDeviceNameLen)
}
for _, r := range name {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("device_name contains a control character")
}
}
return nil
}
// Reputation returns the reputation stats for a public key.
func (c *Chain) Reputation(pubKeyHex string) (RepStats, error) {
var r RepStats
@@ -1261,6 +1357,127 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, err
}
case EventLinkDevice:
// Master Ed25519 (= tx.From) publishes a per-device X25519 pub.
// Validation:
// 1. Payload well-formed, X25519 pub is hex(32 bytes).
// 2. Device name is short + printable.
// 3. X25519 pub isn't already registered to a DIFFERENT owner.
// 4. Same owner re-linking the same pub is a no-op refresh
// (updates device_name / added_at — useful for rename).
// 5. Owner has < MaxDevicesPerOwner active (non-revoked) devices.
//
// Fee: standard tx.Fee is debited from master's balance — cheap
// anti-spam. Block validation already enforced `tx.Fee >= MinFee`.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("LINK_DEVICE debit: %w", err)
}
var p LinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
if err := validateDeviceName(p.DeviceName); err != nil {
return 0, fmt.Errorf("%w: LINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
if item, err := txn.Get(devKey); err == nil {
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr == nil {
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: LINK_DEVICE: x25519 pub already linked to a different owner",
ErrTxFailed)
}
// Same owner — rename/refresh path. Keep AddedAt.
existing.DeviceName = p.DeviceName
existing.RevokedAt = 0 // re-link cancels a previous revoke
refreshed, _ := json.Marshal(existing)
if err := txn.Set(devKey, refreshed); err != nil {
return 0, err
}
// Restore reverse index too — UNLINK_DEVICE deletes it, so
// a re-link after revoke must recreate it or DevicesOf
// will skip the record.
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
break
}
}
// Count owner's active devices.
active, err := c.countActiveDevicesForOwner(txn, tx.From)
if err != nil {
return 0, fmt.Errorf("LINK_DEVICE count devices: %w", err)
}
if active >= MaxDevicesPerOwner {
return 0, fmt.Errorf("%w: LINK_DEVICE: owner already has %d devices (max %d)",
ErrTxFailed, active, MaxDevicesPerOwner)
}
rec := DeviceRecord{
Owner: tx.From,
X25519PubKey: p.X25519PubKey,
DeviceName: p.DeviceName,
AddedAt: tx.Timestamp.Unix(),
}
val, _ := json.Marshal(rec)
if err := txn.Set(devKey, val); err != nil {
return 0, err
}
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Set(idxKey, []byte{}); err != nil {
return 0, err
}
case EventUnlinkDevice:
// Revoke (soft-delete) a device record. Mark RevokedAt and keep
// the row — clients wake up, notice their own pub is revoked, and
// wipe local state. A permanent delete would let a sender that
// missed the revoke keep encrypting for the old pub silently.
if err := c.debitBalance(txn, tx.From, tx.Fee); err != nil {
return 0, fmt.Errorf("UNLINK_DEVICE debit: %w", err)
}
var p UnlinkDevicePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE bad payload: %v", ErrTxFailed, err)
}
if err := validateDevicePubKey(p.X25519PubKey); err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: %v", ErrTxFailed, err)
}
devKey := []byte(prefixDevice + p.X25519PubKey)
item, err := txn.Get(devKey)
if err != nil {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: device not found", ErrTxFailed)
}
var existing DeviceRecord
if verr := item.Value(func(v []byte) error {
return json.Unmarshal(v, &existing)
}); verr != nil {
return 0, fmt.Errorf("UNLINK_DEVICE decode: %w", verr)
}
if existing.Owner != tx.From {
return 0, fmt.Errorf("%w: UNLINK_DEVICE: signer is not the owner of this device",
ErrTxFailed)
}
if existing.RevokedAt != 0 {
// Already revoked — idempotent success, don't error.
break
}
existing.RevokedAt = tx.Timestamp.Unix()
updated, _ := json.Marshal(existing)
if err := txn.Set(devKey, updated); err != nil {
return 0, err
}
// Drop reverse index so countActiveDevicesForOwner is O(k_active).
idxKey := []byte(prefixDevicesByOwner + tx.From + ":" + p.X25519PubKey)
if err := txn.Delete(idxKey); err != nil {
return 0, err
}
case EventContactRequest:
var p ContactRequestPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {

413
blockchain/devices_test.go Normal file
View File

@@ -0,0 +1,413 @@
package blockchain_test
// Multi-device registry tests (v2.2.0). Cover happy-path link + list,
// unlink idempotency, ownership validation, max-devices guard, and
// backward-compat sender fall-back semantics (empty list for unknown
// master). HTTP-layer tests live in the node package; these stay at
// chain level — applyTx + state-shape only.
import (
"crypto/rand"
"encoding/hex"
"strings"
"sync/atomic"
"testing"
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
)
// uniqueTxID sidesteps the `chain_test.go:txID` race where two helper
// calls in the same nanosecond produce identical tx IDs → chain.AddBlock
// dedupes the second as "already committed". We add a strictly-monotonic
// counter so every tx built in the same test gets a fresh ID even on
// fast machines.
var devicesTxIDCounter atomic.Uint64
func uniqueTxID() string {
n := devicesTxIDCounter.Add(1)
h := make([]byte, 16)
_, _ = rand.Read(h)
return hex.EncodeToString(h) + ":" + time.Now().Format("150405.000000000") + ":" + itoa(n)
}
func itoa(n uint64) string {
// tiny local conversion to avoid importing strconv just for tests.
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
// linkDeviceRawTx / unlinkDeviceRawTx build txs with guaranteed-unique
// IDs so we can stuff many of them into a single block during tests.
func linkDeviceRawTx(master *identity.Identity, x25519Pub, name string) *blockchain.Transaction {
payload := mustJSON(blockchain.LinkDevicePayload{
X25519PubKey: x25519Pub,
DeviceName: name,
})
return &blockchain.Transaction{
ID: uniqueTxID(),
Type: blockchain.EventLinkDevice,
From: master.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
}
func unlinkDeviceRawTx(master *identity.Identity, x25519Pub string) *blockchain.Transaction {
payload := mustJSON(blockchain.UnlinkDevicePayload{X25519PubKey: x25519Pub})
return &blockchain.Transaction{
ID: uniqueTxID(),
Type: blockchain.EventUnlinkDevice,
From: master.PubKeyHex(),
Fee: blockchain.MinFee,
Payload: payload,
Timestamp: time.Now().UTC(),
}
}
// randX25519Pub returns a throw-away 32-byte hex string suitable for the
// x25519_pub_key field. We don't need the matching private key — the
// chain only stores the pub and doesn't verify it's a valid curve point.
func randX25519Pub(t *testing.T) string {
t.Helper()
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatalf("rand: %v", err)
}
return hex.EncodeToString(b[:])
}
// fundIdentity credits enough balance to cover `nFees` worth of MinFee
// payments. Every test needs this because LINK_DEVICE and UNLINK_DEVICE
// debit tx.Fee from the master's balance — without funding the first tx
// errors with "insufficient funds" and no state changes.
func fundIdentity(t *testing.T, c *blockchain.Chain, val, recipient *identity.Identity, nFees int) {
t.Helper()
amount := uint64(nFees+2) * blockchain.MinFee
tx := makeTx(blockchain.EventTransfer, val.PubKeyHex(), recipient.PubKeyHex(),
amount, blockchain.MinFee, mustJSON(blockchain.TransferPayload{}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
}
// TestLinkDeviceHappyPath: after a single LINK_DEVICE, DevicesOf returns
// exactly one record with the right owner/name/pub.
func TestLinkDeviceHappyPath(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
dev1 := randX25519Pub(t)
tx := linkDeviceRawTx(alice, dev1, "Alice's iPhone")
b := buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx})
mustAddBlock(t, c, b)
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 {
t.Fatalf("expected 1 device, got %d", len(devs))
}
got := devs[0]
if got.Owner != alice.PubKeyHex() {
t.Errorf("owner: got %s, want %s", got.Owner, alice.PubKeyHex())
}
if got.X25519PubKey != dev1 {
t.Errorf("x25519: got %s, want %s", got.X25519PubKey, dev1)
}
if got.DeviceName != "Alice's iPhone" {
t.Errorf("name: got %q, want %q", got.DeviceName, "Alice's iPhone")
}
if got.RevokedAt != 0 {
t.Errorf("freshly linked device should have RevokedAt=0, got %d", got.RevokedAt)
}
}
// TestLinkDeviceMultiple: multiple devices for the same owner all show up
// in DevicesOf, and the count matches.
func TestLinkDeviceMultiple(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
pubs := []string{randX25519Pub(t), randX25519Pub(t), randX25519Pub(t)}
var txs []*blockchain.Transaction
for i, p := range pubs {
txs = append(txs, linkDeviceRawTx(alice, p, "device-"+string(rune('A'+i))))
}
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, txs))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 3 {
t.Fatalf("expected 3 devices, got %d", len(devs))
}
}
// TestLinkDeviceRejectsForeignOwner: if Alice already linked a X25519 pub
// and Bob tries to link the SAME pub to himself, the tx is rejected.
// This protects against a malicious actor hijacking someone else's
// device-pub and trying to claim it.
func TestLinkDeviceRejectsForeignOwner(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
bob := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
fundIdentity(t, c, val, bob, 1)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
// Bob tries to claim the same pub. The chain silently drops invalid
// txs (they're non-fatal for block commit), so we verify the *state*:
// Alice still owns the pub, Bob has zero devices.
bobTx := linkDeviceRawTx(bob, dev, "bob phone")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{bobTx}))
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
if len(aliceDevs) != 1 || aliceDevs[0].DeviceName != "alice phone" {
t.Fatalf("Alice should still own the device, got %+v", aliceDevs)
}
bobDevs, _ := c.DevicesOf(bob.PubKeyHex())
if len(bobDevs) != 0 {
t.Fatalf("Bob's hijack should have been rejected, got %+v", bobDevs)
}
}
// TestLinkDeviceSameOwnerRefresh: the same owner re-linking the same
// x25519_pub is a rename/refresh (not an error), and the revoked flag
// gets cleared if it was set.
func TestLinkDeviceSameOwnerRefresh(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "old name")}))
// Unlink then re-link — should restore the record.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "new name")}))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 {
t.Fatalf("expected 1 active device after re-link, got %d", len(devs))
}
if devs[0].DeviceName != "new name" {
t.Errorf("expected refreshed name 'new name', got %q", devs[0].DeviceName)
}
}
// TestUnlinkDeviceRemovesFromActive: after UNLINK_DEVICE, DevicesOf no
// longer returns that record — senders stop fanning out to it.
func TestUnlinkDeviceRemovesFromActive(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
keep := randX25519Pub(t)
revoke := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{
linkDeviceRawTx(alice, keep, "kept"),
linkDeviceRawTx(alice, revoke, "revoked"),
}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, revoke)}))
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 1 || devs[0].X25519PubKey != keep {
t.Fatalf("expected only the kept device, got %+v", devs)
}
}
// TestUnlinkDeviceRejectsForeignSigner: Bob can't unlink Alice's device
// even if he knows the X25519 pub — only the owner's signature (= tx.From)
// authorises revoke.
func TestUnlinkDeviceRejectsForeignSigner(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
bob := newIdentity(t)
fundIdentity(t, c, val, alice, 1)
fundIdentity(t, c, val, bob, 1)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "alice phone")}))
// Bob tries to unlink Alice's device — tx is silently dropped, Alice
// still has it in her active list.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(bob, dev)}))
aliceDevs, _ := c.DevicesOf(alice.PubKeyHex())
if len(aliceDevs) != 1 || aliceDevs[0].X25519PubKey != dev {
t.Fatalf("Alice's device should still be active, got %+v", aliceDevs)
}
}
// TestUnlinkDeviceIdempotent: unlinking a device that's already revoked
// does NOT error — this is needed for recovery scenarios where a client
// retries the tx after a spotty connection.
func TestUnlinkDeviceIdempotent(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 3)
dev := randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, dev, "phone")}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
// Second unlink of the same device must apply cleanly.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, dev)}))
}
// TestLinkDeviceRejectsMalformedPub: payload.X25519PubKey must be 64-char
// lowercase hex. Garbage / short / uppercase values are rejected at
// applyTx time before any state change.
func TestLinkDeviceRejectsMalformedPub(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 5)
bad := []string{
"", // empty
"abc", // too short
strings.Repeat("x", 64), // non-hex
strings.Repeat("A", 64), // uppercase
}
for _, p := range bad {
tx := linkDeviceRawTx(alice, p, "dev")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
}
devs, _ := c.DevicesOf(alice.PubKeyHex())
if len(devs) != 0 {
t.Fatalf("expected 0 devices after %d malformed link attempts, got %d",
len(bad), len(devs))
}
}
// TestLinkDeviceRejectsBadDeviceName: empty, too long, or control-char
// names are refused — this field is rendered verbatim in clients.
func TestLinkDeviceRejectsBadDeviceName(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
fundIdentity(t, c, val, alice, 5)
cases := map[string]string{
"empty": "",
"too long": strings.Repeat("a", blockchain.MaxDeviceNameLen+1),
"control char": "device\x01name",
}
for name, devName := range cases {
t.Run(name, func(t *testing.T) {
before, _ := c.DevicesOf(alice.PubKeyHex())
tx := linkDeviceRawTx(alice, randX25519Pub(t), devName)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{tx}))
after, _ := c.DevicesOf(alice.PubKeyHex())
if len(after) != len(before) {
t.Fatalf("bad %s name should not have been linked (before=%d, after=%d)",
name, len(before), len(after))
}
})
}
}
// TestLinkDeviceMaxDevices: after MaxDevicesPerOwner active devices, any
// further LINK_DEVICE is rejected. Revoking one frees a slot.
func TestLinkDeviceMaxDevices(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
// Enough for cap + one overflow attempt + one unlink + one replacement.
fundIdentity(t, c, val, alice, blockchain.MaxDevicesPerOwner+4)
// Fill to the cap — each in its own block so timestamps differ.
pubs := make([]string, blockchain.MaxDevicesPerOwner)
for i := 0; i < blockchain.MaxDevicesPerOwner; i++ {
pubs[i] = randX25519Pub(t)
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, pubs[i], "d")}))
}
// One more — the tx is dropped, cap stays exact.
over := linkDeviceRawTx(alice, randX25519Pub(t), "overflow")
mustAddBlock(t, c, buildBlock(t, c.Tip(), val, []*blockchain.Transaction{over}))
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
t.Fatalf("overflow link should have been rejected, count=%d", len(devs))
}
// Revoke one, then a fresh link succeeds.
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{unlinkDeviceRawTx(alice, pubs[0])}))
mustAddBlock(t, c, buildBlock(t, c.Tip(), val,
[]*blockchain.Transaction{linkDeviceRawTx(alice, randX25519Pub(t), "replacement")}))
if devs, _ := c.DevicesOf(alice.PubKeyHex()); len(devs) != blockchain.MaxDevicesPerOwner {
t.Fatalf("after unlink+relink, should be %d active, got %d",
blockchain.MaxDevicesPerOwner, len(devs))
}
}
// TestDevicesOfEmptyForUnknown: unknown master pubs return an empty list
// (not an error) — this preserves the sender's ability to fall back to
// IdentityInfo.X25519Pub for legacy identities.
func TestDevicesOfEmptyForUnknown(t *testing.T) {
c := newChain(t)
val := newIdentity(t)
addGenesis(t, c, val)
alice := newIdentity(t)
devs, err := c.DevicesOf(alice.PubKeyHex())
if err != nil {
t.Fatalf("DevicesOf: %v", err)
}
if len(devs) != 0 {
t.Fatalf("expected 0 devices for unknown master, got %d", len(devs))
}
}

View File

@@ -46,6 +46,14 @@ const (
EventUnfollow EventType = "UNFOLLOW" // unfollow an author
EventLikePost EventType = "LIKE_POST" // like a post
EventUnlikePost EventType = "UNLIKE_POST" // remove a previous like
// Multi-device support (v2.2.0): each physical device of an identity gets
// its own X25519 keypair for the messenger mailbox. LINK/UNLINK_DEVICE
// publish/revoke those pubs on-chain so senders can fan-out envelopes
// across the recipient's active devices. Signed by the identity's
// master Ed25519 key.
EventLinkDevice EventType = "LINK_DEVICE"
EventUnlinkDevice EventType = "UNLINK_DEVICE"
)
// Token amounts are stored in micro-tokens (µT).
@@ -555,6 +563,70 @@ type IdentityInfo struct {
X25519Pub string `json:"x25519_pub"` // hex Curve25519 key; empty if not published
Nickname string `json:"nickname"`
Registered bool `json:"registered"` // true if REGISTER_KEY tx was committed
// DeviceCount is the number of currently-linked (non-revoked) devices in
// this identity's multi-device registry. `0` for legacy identities that
// only published a single X25519 via REGISTER_KEY — senders fall back
// to IdentityInfo.X25519Pub in that case.
DeviceCount int `json:"device_count"`
}
// ── Multi-device registry (v2.2.0) ───────────────────────────────────────
// MaxDevicesPerOwner caps how many devices a single identity can have
// linked concurrently. A sender must encrypt + relay one envelope per
// device, so the multiplier bounds the per-message cost. Ten covers
// typical users (phone + tablet + laptop + desktop + work phone + …)
// without letting an abuse case blow up mailbox traffic. Revoked devices
// don't count.
const MaxDevicesPerOwner = 10
// MaxDeviceNameLen caps the length of a human-friendly device label
// ("Alice's iPhone 14", "Work MacBook Pro"). Longer names get rejected
// at validation. Kept short to discourage using this field as a free-form
// comment/profile channel.
const MaxDeviceNameLen = 64
// LinkDevicePayload is embedded in EventLinkDevice transactions. Master
// Ed25519 (= tx.From) asserts that the given X25519 pub belongs to one
// of its physical devices, publishing it so senders can fan out envelopes.
type LinkDevicePayload struct {
// X25519PubKey is the hex-encoded Curve25519 pub the device generated
// locally. Must be unique in the device registry — senders index
// envelopes by this key in the relay mailbox.
X25519PubKey string `json:"x25519_pub_key"`
// DeviceName is a short human label shown in Settings → Devices.
// Purely informational; not used for routing.
DeviceName string `json:"device_name"`
}
// UnlinkDevicePayload is embedded in EventUnlinkDevice transactions. Signed
// by master Ed25519; marks the referenced device as revoked so senders
// stop shipping envelopes to its X25519 pub. The revoked device itself,
// once it sees its pub in the revoked list, is expected to wipe its
// local state (master Ed25519 priv + chat cache).
type UnlinkDevicePayload struct {
X25519PubKey string `json:"x25519_pub_key"`
}
// DeviceRecord is the on-chain persisted state for one device link.
// Stored at key `prefixDevice + x25519_pub`; the reverse index
// `prefixDevicesByOwner + master_pub` keeps a slice of x25519 pubs per
// owner for efficient listing.
type DeviceRecord struct {
Owner string `json:"owner"` // master Ed25519 pub hex
X25519PubKey string `json:"x25519_pub_key"` // device X25519 pub hex
DeviceName string `json:"device_name"`
AddedAt int64 `json:"added_at"` // unix seconds, from tx timestamp
RevokedAt int64 `json:"revoked_at,omitempty"` // 0 = active; >0 = revoked
}
// DeviceInfo is the public view of a DeviceRecord served by
// GET /api/devices/{master_pub}. Revoked records are not included
// in the response (intentionally — sender only needs the active set).
type DeviceInfo struct {
X25519PubKey string `json:"x25519_pub_key"`
DeviceName string `json:"device_name"`
AddedAt int64 `json:"added_at"`
}
// ConsensusMessage types used by the PBFT engine over the P2P layer.

View File

@@ -12,12 +12,14 @@
"infoPlist": {
"NSMicrophoneUsageDescription": "Allow DChain to record voice messages and video.",
"NSCameraUsageDescription": "Allow DChain to record video messages and scan QR codes.",
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library."
"NSPhotoLibraryUsageDescription": "Allow DChain to attach photos and videos from your library.",
"ITSAppUsesNonExemptEncryption": false
}
},
"android": {
"package": "com.dchain.messenger",
"softwareKeyboardLayoutMode": "pan",
"usesCleartextTraffic": true,
"permissions": [
"android.permission.RECORD_AUDIO",
"android.permission.CAMERA",

View File

@@ -13,7 +13,7 @@
* один раз; переходы между tab'ами их не перезапускают.
*/
import React, { useEffect } from 'react';
import { View } from 'react-native';
import { View, Platform } from 'react-native';
import { router, usePathname } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
@@ -25,7 +25,13 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
import { saveContact } from '@/lib/storage';
import {
saveContact,
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
} from '@/lib/storage';
import {
fetchDevices, buildLinkDeviceTx, submitTx,
} from '@/lib/api';
export default function AppLayout() {
const keyFile = useStore(s => s.keyFile);
@@ -73,6 +79,79 @@ export default function AppLayout() {
else ws.setAuthCreds(null);
}, [keyFile]);
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
//
// Three branches, by (chain list × local "was registered" flag):
//
// 1. Our pub is in the chain's active list →
// mark us registered locally (idempotent), done.
//
// 2. Our pub is NOT in the active list, AND we've registered before →
// another device issued UNLINK_DEVICE against us. Wipe ALL local
// state (master priv, contacts, chats, marker) and redirect to
// the auth screen. This is the security-critical path: without
// the wipe, a stolen phone after revoke would still decrypt
// historical messages.
//
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
// first boot on this chain; submit LINK_DEVICE so senders can
// target us. Failures (fee, offline) are swallowed; next launch
// retries.
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
let chainList;
try {
chainList = await fetchDevices(keyFile.pub_key);
} catch {
// Network unavailable — leave state unchanged; we'll resync on
// the next launch. Do NOT wipe on network error.
return;
}
if (cancelled) return;
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
const previouslyRegistered = await isDeviceRegistered();
if (cancelled) return;
if (inActive) {
// Branch #1 — ensure the local marker is set.
if (!previouslyRegistered) await markDeviceRegistered();
return;
}
if (previouslyRegistered) {
// Branch #2 — REVOKED. Self-wipe.
await wipeAllLocalState();
useStore.getState().setKeyFile(null);
// The redirect-on-null-keyFile effect below will push the user
// back to the welcome screen automatically.
return;
}
// Branch #3 — first-boot link. Best-effort.
try {
const deviceName = Platform.select({
ios: 'iPhone',
android: 'Android phone',
default: 'Device',
}) ?? 'Device';
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
deviceName,
privKey: keyFile.priv_key,
});
await submitTx(tx);
await markDeviceRegistered();
} catch {
/* next launch retries */
}
})();
return () => { cancelled = true; };
}, [keyFile]);
useEffect(() => {
if (keyFile === null) {
const t = setTimeout(() => {

View File

@@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope } from '@/lib/api';
import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils';
@@ -107,7 +107,7 @@ export default function ChatScreen() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
useMessages(contact?.x25519Pub ?? '');
useMessages(contact?.x25519Pub ?? '', contact?.address);
// ── Typing indicator от peer'а ─────────────────────────────────────────
useEffect(() => {
@@ -212,15 +212,34 @@ export default function ChatScreen() {
// leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) {
// Multi-device fan-out (v2.2.0): resolve the recipient's active
// device X25519 pubs via /api/devices. Legacy identities (no
// devices registered) fall back to their published identity
// x25519 pub, preserving the pre-v2.2.0 single-device path.
// `contact.x25519Pub` stays the floor — if both network calls
// fail we still attempt delivery to the cached pub so a flaky
// connection doesn't block outgoing messages.
let recipientPubs = await resolveRecipientKeys(contact.address);
if (recipientPubs.length === 0 && contact.x25519Pub) {
recipientPubs = [contact.x25519Pub];
}
if (recipientPubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
// One sealed envelope per recipient device. Parallel — slow
// relays don't block each other; any individual failure
// rejects the whole send (user retries).
await Promise.all(recipientPubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
actualText.trim(), keyFile.x25519_priv, rpub,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: contact.x25519Pub,
recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
}));
}
const msg: Message = {

View File

@@ -0,0 +1,490 @@
/**
* Devices screen — Settings → Linked devices.
*
* Multi-device registry (v2.2.0). Lists every X25519 device published
* on-chain under this identity's master Ed25519 key. Operators can:
* - see added-at timestamps
* - rename this device (local alias for now; rename via LINK_DEVICE
* with same pub + new name is a v2.3 polish)
* - revoke a remote device via UNLINK_DEVICE (requires fee)
* - pair a new device (Phase 3 — separate modal, stub for now)
*
* This device is NEVER listed with an Unlink button — revoking yourself
* is a footgun (you'd wipe your own state on next launch). Export/import
* your key first, then revoke from the new device.
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl,
TextInput, KeyboardAvoidingView, Platform, Modal,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import {
fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx,
sendEnvelope, humanizeTxError,
type DeviceInfo,
} from '@/lib/api';
import { encryptMessage } from '@/lib/crypto';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { safeBack } from '@/lib/utils';
function shortPub(p: string, n = 8): string {
if (!p) return '—';
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}${p.slice(-n)}`;
}
function formatDate(unixSec: number): string {
return new Date(unixSec * 1000).toLocaleString();
}
export default function DevicesScreen() {
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
const load = useCallback(async (isRefresh = false) => {
if (!keyFile) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
try {
const list = await fetchDevices(keyFile.pub_key);
setDevices(list);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [keyFile]);
useEffect(() => { load(false); }, [load]);
const onUnlink = useCallback((dev: DeviceInfo) => {
if (!keyFile) return;
Alert.alert(
'Unlink device?',
`"${dev.device_name}" will stop receiving messages sent to you. ` +
`This costs a small network fee. The revoked device wipes its ` +
`local state the next time it checks in.`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Unlink',
style: 'destructive',
onPress: async () => {
setUnlinking(dev.x25519_pub_key);
try {
const tx = buildUnlinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: dev.x25519_pub_key,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// Optimistic — drop from local list immediately; next load
// reconciles. Chain tx takes ~1 block to commit.
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
} catch (e: any) {
Alert.alert('Unlink failed', humanizeTxError(e));
} finally {
setUnlinking(null);
}
},
},
],
);
}, [keyFile]);
const meX25519 = keyFile?.x25519_pub ?? '';
// Pairing modal state — filled by the operator reading values off the
// new device's /auth/pair screen.
const [pairOpen, setPairOpen] = useState(false);
const [pairCode, setPairCode] = useState('');
const [pairKey, setPairKey] = useState('');
const [pairName, setPairName] = useState('');
const [pairBusy, setPairBusy] = useState(false);
const submitPair = useCallback(async () => {
if (!keyFile) return;
const code = pairCode.replace(/\s+/g, '').trim();
const key = pairKey.replace(/\s+/g, '').trim().toLowerCase();
if (!/^\d{6}$/.test(code)) {
Alert.alert('Invalid code', 'The pairing code is 6 digits.');
return;
}
if (!/^[0-9a-f]{64}$/.test(key)) {
Alert.alert('Invalid key', 'The device key must be 64 hex characters.');
return;
}
const name = pairName.trim() || 'New device';
setPairBusy(true);
try {
// 1. LINK_DEVICE on-chain so senders learn the new pub.
const tx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: key,
deviceName: name,
privKey: keyFile.priv_key,
});
await submitTx(tx);
// 2. Ship the handshake payload to the new device. Encrypted for
// its x25519 pub so only it can read — master priv in plaintext
// would be catastrophic, E2E is the whole point.
const payload = JSON.stringify({
v: 1,
type: 'pair-handshake',
code,
master_pub: keyFile.pub_key,
master_priv: keyFile.priv_key,
master_x25519_pub: keyFile.x25519_pub,
});
const { nonce, ciphertext } = encryptMessage(
payload, keyFile.x25519_priv, key,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: key,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
// 3. Optimistic local insert so the row shows up without waiting
// for the next pull/refresh round-trip.
setDevices(prev => {
if (prev.some(d => d.x25519_pub_key === key)) return prev;
return [...prev, {
x25519_pub_key: key,
device_name: name,
added_at: Math.floor(Date.now() / 1000),
}];
});
setPairOpen(false);
setPairCode(''); setPairKey(''); setPairName('');
Alert.alert(
'Pairing sent',
'The new device should finish pairing in a few seconds.',
);
} catch (e: any) {
Alert.alert('Pairing failed', humanizeTxError(e));
} finally {
setPairBusy(false);
}
}, [keyFile, pairCode, pairKey, pairName]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Devices"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
Every linked device has its own encryption key. Messages sent to you
are delivered to all active devices.
</Text>
{loading ? (
<View style={{ paddingTop: 60, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : devices.length === 0 ? (
<View style={{
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
}}>
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
<Text style={{
color: '#ffffff', fontSize: 15, fontWeight: '700',
marginTop: 10,
}}>
No devices registered yet
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
marginTop: 6, lineHeight: 19,
}}>
This device auto-registers when the next network-fee is available.
Top up your balance and pull to refresh.
</Text>
</View>
) : (
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}>
{devices.map((d, i) => {
const isMe = d.x25519_pub_key === meX25519;
const busy = unlinking === d.x25519_pub_key;
return (
<View key={d.x25519_pub_key}>
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
<View style={{
flexDirection: 'row', alignItems: 'center',
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
}}>
<Ionicons
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
size={22}
color={isMe ? '#1d9bf0' : '#d0d0d0'}
/>
<View style={{ flex: 1, minWidth: 0 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
numberOfLines={1}
>
{d.device_name || 'Unnamed device'}
</Text>
{isMe && (
<View style={{
paddingHorizontal: 6, paddingVertical: 1,
borderRadius: 6, backgroundColor: '#0d2540',
}}>
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
THIS DEVICE
</Text>
</View>
)}
</View>
<Text
style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 3,
}}
numberOfLines={1}
>
{shortPub(d.x25519_pub_key)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
Linked {formatDate(d.added_at)}
</Text>
</View>
{!isMe && (
<Pressable
onPress={() => onUnlink(d)}
disabled={busy}
style={({ pressed }) => ({
paddingHorizontal: 12, paddingVertical: 7,
borderRadius: 999,
borderWidth: 1, borderColor: '#3a2020',
backgroundColor: pressed ? '#2a1414' : 'transparent',
opacity: busy ? 0.5 : 1,
})}
>
{busy ? (
<ActivityIndicator size="small" color="#ff6b6b" />
) : (
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
Unlink
</Text>
)}
</Pressable>
)}
</View>
</View>
);
})}
</View>
)}
{/* Link new device — opens a modal with Code + DeviceKey inputs
that the operator transcribes from the new device's
/auth/pair screen. */}
<View style={{ marginTop: 18 }}>
<Pressable
onPress={() => setPairOpen(true)}
style={({ pressed }) => ({
paddingVertical: 13, paddingHorizontal: 16,
borderRadius: 14,
backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
flexDirection: 'row', alignItems: 'center', gap: 10,
})}
>
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
<View style={{ flex: 1 }}>
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link new device
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
Enter the 6-digit code from the new device
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
</Pressable>
</View>
</ScrollView>
{/* ── Pair new device modal ───────────────────────────────────── */}
<Modal
visible={pairOpen}
animationType="fade"
transparent
onRequestClose={() => !pairBusy && setPairOpen(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
style={{
flex: 1,
backgroundColor: 'rgba(0,0,0,0.7)',
justifyContent: 'center', alignItems: 'center',
paddingHorizontal: 20,
}}
>
<View style={{
width: '100%', maxWidth: 420,
backgroundColor: '#0a0a0a',
borderRadius: 18,
borderWidth: 1, borderColor: '#1f1f1f',
padding: 20, gap: 12,
}}>
<View style={{
flexDirection: 'row', alignItems: 'center',
justifyContent: 'space-between', marginBottom: 4,
}}>
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
Link new device
</Text>
<Pressable
onPress={() => !pairBusy && setPairOpen(false)}
hitSlop={8}
>
<Ionicons name="close" size={22} color="#8b8b8b" />
</Pressable>
</View>
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
the welcome screen, then transcribe the code + device key shown there.
</Text>
<PairInput
label="6-digit code"
value={pairCode}
onChangeText={setPairCode}
placeholder="000000"
keyboardType="number-pad"
maxLength={6}
monospace
/>
<PairInput
label="Device key"
value={pairKey}
onChangeText={setPairKey}
placeholder="64 hex chars"
autoCapitalize="none"
maxLength={64}
monospace
/>
<PairInput
label="Name for this device (optional)"
value={pairName}
onChangeText={setPairName}
placeholder="e.g. Alice's laptop"
maxLength={64}
/>
<View style={{
flexDirection: 'row', justifyContent: 'flex-end',
gap: 10, marginTop: 8,
}}>
<Pressable
onPress={() => setPairOpen(false)}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 16, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
opacity: pairBusy ? 0.5 : 1,
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
Cancel
</Text>
</Pressable>
<Pressable
onPress={submitPair}
disabled={pairBusy}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 10,
borderRadius: 999,
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
opacity: pairBusy ? 0.7 : 1,
minWidth: 90, alignItems: 'center',
})}
>
{pairBusy ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
Link
</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</View>
);
}
function PairInput({
label, value, onChangeText, placeholder, keyboardType, autoCapitalize,
maxLength, monospace,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder?: string;
keyboardType?: 'default' | 'number-pad';
autoCapitalize?: 'none' | 'sentences';
maxLength?: number;
monospace?: boolean;
}) {
return (
<View>
<Text style={{
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
}}>
{label}
</Text>
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#3a3a3a"
keyboardType={keyboardType}
autoCapitalize={autoCapitalize ?? 'sentences'}
autoCorrect={false}
maxLength={maxLength}
style={{
color: '#ffffff', fontSize: monospace ? 13 : 14,
fontFamily: monospace ? 'monospace' : undefined,
backgroundColor: '#000000',
borderRadius: 10,
paddingHorizontal: 12, paddingVertical: 10,
borderWidth: 1, borderColor: '#1f1f1f',
}}
/>
</View>
);
}

View File

@@ -492,6 +492,18 @@ export default function SettingsScreen() {
/>
</Card>
{/* ── Devices — multi-device registry (v2.2.0) ── */}
<SectionLabel>Devices</SectionLabel>
<Card>
<Row
icon="phone-portrait-outline"
label="Linked devices"
value="Manage the devices that receive your messages"
onPress={() => router.push('/(app)/devices' as never)}
first
/>
</Card>
{/* ── Account ── */}
<SectionLabel>Account</SectionLabel>
<Card>

View File

@@ -0,0 +1,300 @@
/**
* Pair — secondary-device onboarding.
*
* Flow (new device, this screen):
* 1. Generate a fresh X25519 keypair locally + random 6-digit code.
* 2. Display { code, device x25519 pub }. User enters both on their
* primary device (Settings → Devices → Link new device).
* 3. Primary device:
* - submits LINK_DEVICE tx to publish our pub under its master,
* - sends a relay envelope to our x25519 pub, encrypted with its
* own x25519 priv, containing { code, master_pub, master_priv,
* master_x25519_pub }.
* 4. We poll /relay/inbox every few seconds; when a decryptable
* envelope arrives whose payload.code matches our displayed code,
* we treat it as the handshake, assemble a KeyFile, save it, and
* redirect into (app).
*
* Security notes:
* - Master Ed25519 priv travels only via this envelope, encrypted for
* this device's X25519 pub (which only this device holds the priv
* for). Exposure is limited to one successful decrypt; we DELETE
* the envelope from the mailbox immediately.
* - The 6-digit code defends against a confused primary device paired
* with a different victim, or a race with an attacker who guesses
* our X25519 pub. Envelope without matching code → ignored.
* - Envelope is short-lived in the mailbox: we DELETE on first decrypt
* and the relay node has its own TTL.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, Pressable, ActivityIndicator, ScrollView,
} from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import nacl from 'tweetnacl';
import { useStore } from '@/lib/store';
import { bytesToHex, decryptMessage } from '@/lib/crypto';
import { fetchInbox } from '@/lib/api';
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types';
// Protocol constant — bump if payload shape changes.
const PAIR_ENVELOPE_VERSION = 1;
interface PairEnvelopePayload {
v: number;
type: 'pair-handshake';
code: string;
master_pub: string; // Ed25519 pub, hex
master_priv: string; // Ed25519 priv, hex
master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI)
}
function randomCode(): string {
// Six decimal digits. Entropy ~20 bits. Good enough for a one-shot
// rendezvous code gated by an out-of-band delivery channel (envelope
// targeted at a freshly-generated X25519 pub that only this device
// holds the priv for).
const n = Math.floor(Math.random() * 1_000_000);
return n.toString().padStart(6, '0');
}
export default function PairScreen() {
const insets = useSafeAreaInsets();
const setKeyFile = useStore(s => s.setKeyFile);
// One-shot keypair + code for this pairing session. Regenerate only on
// manual Retry (unmount+remount).
const session = useRef(genSession()).current;
const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting');
const [err, setErr] = useState<string | null>(null);
const copyCode = useCallback(async () => {
await Clipboard.setStringAsync(session.code);
}, [session.code]);
const copyPub = useCallback(async () => {
await Clipboard.setStringAsync(session.x25519Pub);
}, [session.x25519Pub]);
// Poll mailbox until a matching handshake envelope arrives, or user
// backs out. Interval 2.5s — conservative on battery, fine for a
// flow the user is staring at for a minute.
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
if (cancelled) return;
try {
const envs = await fetchInbox(session.x25519Pub);
for (const env of envs) {
// Decrypt each envelope with our session priv. We don't know
// the primary's x25519 pub up front — it's inside the envelope
// metadata. decryptMessage needs both pubs, so we pass the
// envelope's sender_pub directly.
const plain = decryptMessage(
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
);
if (!plain) continue;
let payload: PairEnvelopePayload;
try {
payload = JSON.parse(plain);
} catch { continue; }
if (
payload.v !== PAIR_ENVELOPE_VERSION ||
payload.type !== 'pair-handshake' ||
payload.code !== session.code ||
!payload.master_pub || !payload.master_priv
) continue;
// Success — materialise a KeyFile and redirect.
const kf: KeyFile = {
pub_key: payload.master_pub,
priv_key: payload.master_priv,
x25519_pub: session.x25519Pub,
x25519_priv: session.x25519Priv,
};
await saveKeyFile(kf);
// The root layout auto-links us on first boot if needed, but
// the primary device already submitted LINK_DEVICE for our
// pub as part of the pairing, so the registry is already
// correct. Mark ourselves registered so the revoke-detection
// branch doesn't spuriously wipe on next launch.
await markDeviceRegistered();
setKeyFile(kf);
// Envelope stays in mailbox until relay TTL eviction; the
// single-shot handshake is idempotent (saveKeyFile overwrites)
// and our session pub won't be polled again after redirect.
setStatus('success');
setTimeout(() => {
if (!cancelled) router.replace('/(app)/chats' as never);
}, 600);
return;
}
} catch {
/* transient — retry */
}
if (!cancelled) timer = setTimeout(tick, 2_500);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [session, setKeyFile]);
return (
<View style={{
flex: 1, backgroundColor: '#000000',
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 20),
}}>
<ScrollView
contentContainerStyle={{ padding: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Pressable
onPress={() => safeBack('/')}
hitSlop={8}
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
>
<Ionicons name="chevron-back" size={28} color="#ffffff" />
</Pressable>
<View style={{ alignItems: 'center', marginBottom: 28 }}>
<View style={{
width: 80, height: 80, borderRadius: 22,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="link" size={40} color="#1d9bf0" />
</View>
<Text style={{
color: '#ffffff', fontSize: 22, fontWeight: '800',
marginTop: 14, textAlign: 'center',
}}>
Pair with your other device
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
marginTop: 8, textAlign: 'center', maxWidth: 300,
}}>
On a device where you're already signed in,
open Settings → Devices → Link new device,
and enter these values.
</Text>
</View>
{/* Code */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 18, paddingHorizontal: 16,
marginBottom: 14, alignItems: 'center',
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
1. Code
</Text>
<Text style={{
color: '#ffffff', fontSize: 38, fontWeight: '800',
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
}}>
{session.code.slice(0, 3)} {session.code.slice(3)}
</Text>
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy code
</Text>
</Pressable>
</View>
{/* Device key */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 16, paddingHorizontal: 16,
marginBottom: 20,
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
2. Device key
</Text>
<Text
selectable
style={{
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
marginTop: 6, lineHeight: 17,
}}
>
{session.x25519Pub}
</Text>
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy key
</Text>
</Pressable>
</View>
{/* Status */}
<View style={{ alignItems: 'center', minHeight: 56 }}>
{status === 'waiting' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color="#1d9bf0" />
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
Waiting for your other device…
</Text>
</View>
)}
{status === 'success' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
Paired. Opening your chats…
</Text>
</View>
)}
{status === 'error' && (
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
{err ?? 'Something went wrong. Please retry.'}
</Text>
)}
</View>
</ScrollView>
</View>
);
}
// ─── session helper ──────────────────────────────────────────────────────
interface PairSession {
x25519Pub: string; // hex
x25519Priv: string; // hex
code: string;
}
function genSession(): PairSession {
const kp = nacl.box.keyPair();
return {
x25519Pub: bytesToHex(kp.publicKey),
x25519Priv: bytesToHex(kp.secretKey),
code: randomCode(),
};
}

View File

@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
{/* CTA — прижата к правому нижнему краю. */}
<View style={{
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
paddingHorizontal: 24, paddingBottom: 8,
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
}}>
<CTASecondary
label="Pair"
icon="link"
onPress={() => router.push('/(auth)/pair' as never)}
/>
<CTASecondary
label="Import"
onPress={() => router.push('/(auth)/import' as never)}

View File

@@ -52,10 +52,13 @@ export function useGlobalInbox() {
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Найти контакт по sender_pub — если не знакомый, игнорим
// (для MVP; в future можно показывать "unknown sender").
const c = contactsRef.current.find(
x => x.x25519Pub === env.sender_pub,
// Attribution (v2.2.0+): prefer the envelope's master Ed25519
// so messages from any of the sender's linked devices roll
// into a single chat. Fall back to legacy X25519-based lookup
// for pre-v2.2.0 senders that left the field empty.
const c = contactsRef.current.find(x =>
(env.sender_ed25519_pub && x.address === env.sender_ed25519_pub) ||
x.x25519Pub === env.sender_pub,
);
if (!c) continue;

View File

@@ -24,10 +24,26 @@ import { tryParsePostRef } from '@/lib/forwardPost';
const FALLBACK_POLL_INTERVAL = 30_000; // HTTP poll when WS is down
const WS_GRACE_BEFORE_POLLING = 15_000; // don't start polling immediately on disconnect
export function useMessages(contactX25519: string) {
/**
* useMessages — mounts per-chat inbox consumption. Accepts:
* - contactX25519: the legacy/primary X25519 for the contact.
* - contactMasterEd25519 (optional, v2.2.0+): the contact's master
* identity so we can attribute envelopes from any of their
* linked devices to this conversation.
*
* Matching rule: an envelope belongs to this chat when
* env.sender_ed25519_pub === contactMasterEd25519 (v2.2.0 path)
* OR env.sender_pub === contactX25519 (legacy path)
*/
export function useMessages(contactX25519: string, contactMasterEd25519?: string) {
const keyFile = useStore(s => s.keyFile);
const appendMsg = useStore(s => s.appendMessage);
const matchesChat = useCallback((env: { sender_pub: string; sender_ed25519_pub: string }): boolean => {
if (contactMasterEd25519 && env.sender_ed25519_pub === contactMasterEd25519) return true;
return env.sender_pub === contactX25519;
}, [contactX25519, contactMasterEd25519]);
// Подгружаем кэш сообщений из AsyncStorage при открытии чата.
// Релей держит envelope'ы всего 7 дней, поэтому без чтения кэша
// история старше недели пропадает при каждом рестарте приложения.
@@ -48,8 +64,8 @@ export function useMessages(contactX25519: string) {
try {
const envelopes = await fetchInbox(keyFile.x25519_pub);
for (const env of envelopes) {
// Only process messages from this contact
if (env.sender_pub !== contactX25519) continue;
// Only process messages that belong to this chat (see matchesChat).
if (!matchesChat(env)) continue;
const text = decryptMessage(
env.ciphertext,
@@ -130,10 +146,17 @@ export function useMessages(contactX25519: string) {
// the handler so we only render messages in THIS chat.
const offInbox = ws.subscribe('inbox:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'inbox') return;
const d = frame.data as { sender_pub?: string } | undefined;
// Optimisation: if the envelope is from a different peer, skip the
// whole refetch — we'd just drop it in the sender filter below anyway.
if (d?.sender_pub && d.sender_pub !== contactX25519) return;
const d = frame.data as {
sender_pub?: string; sender_ed25519_pub?: string;
} | undefined;
// Optimisation: if the envelope definitely isn't for this chat,
// skip the whole refetch. Multi-device aware — the peer may be
// writing from any of their linked devices (different X25519
// pubs), so we check against their master Ed25519 too.
if (d && !matchesChat({
sender_pub: d.sender_pub ?? '',
sender_ed25519_pub: d.sender_ed25519_pub ?? '',
})) return;
pullAndDecrypt();
});

View File

@@ -264,6 +264,9 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord
interface InboxItemWire {
id: string;
sender_pub: string;
/** sender_ed25519_pub was added in v2.2.0; older nodes omit it.
Default to empty string when missing. */
sender_ed25519_pub?: string;
recipient_pub: string;
fee_ut?: number;
sent_at: number;
@@ -328,6 +331,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
@@ -365,6 +369,25 @@ export interface IdentityInfo {
x25519_pub: string; // hex Curve25519 key; empty string if not published
nickname: string;
registered: boolean;
/**
* Number of active (non-revoked) devices linked to this master identity
* via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published
* a single X25519 via REGISTER_KEY — senders should fall back to
* `x25519_pub` above and skip the device fan-out path.
*/
device_count?: number;
}
/**
* One active device in an identity's multi-device registry. Returned by
* GET /api/devices/{master_pub} as part of `devices[]`. Senders use the
* list to fan out one sealed envelope per X25519 pub so all of the
* recipient's devices receive the message.
*/
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number; // unix seconds
}
/**
@@ -409,6 +432,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise<IdentityInfo |
}
}
// ─── Multi-device registry (v2.2.0) ───────────────────────────────────────
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
/**
* GET /api/devices/{master_pub} — all active (non-revoked) device records
* for the given master identity. Returns an empty array for a legacy
* identity (device_count == 0) or a network error — callers should treat
* both the same way and fall back to IdentityInfo.x25519_pub so the
* pre-v2.2.0 single-device path keeps working.
*/
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
/**
* Pick the right set of recipient X25519 pubs for a sender's fan-out.
* Two paths, in priority order:
*
* 1. New path — /api/devices returns ≥1 entry. Send to each device.
* 2. Legacy path — identity published an X25519 via REGISTER_KEY
* (pre-v2.2.0 clients). Send to just that one.
*
* Returns an empty array only when the recipient has published nothing
* at all — caller must surface "no encryption key" to the user rather
* than drop the message on the floor.
*/
export async function resolveRecipientKeys(
recipientMasterPub: string,
): Promise<string[]> {
const devs = await fetchDevices(recipientMasterPub);
if (devs.length > 0) {
return devs.map(d => d.x25519_pub_key);
}
const identity = await getIdentity(recipientMasterPub);
if (identity?.x25519_pub) {
return [identity.x25519_pub];
}
return [];
}
// ─── Contract API ─────────────────────────────────────────────────────────────
/**
@@ -712,6 +785,68 @@ export function buildTransferTx(params: {
};
}
/**
* LINK_DEVICE transaction — publish a per-device X25519 pub in the
* identity's device registry so senders can fan out envelopes across
* every active device. Signed by the master Ed25519 (= `from`).
*
* `deviceName` is a short human label shown in Settings → Devices
* (≤ 64 bytes, printable ASCII/UTF-8, no control chars).
*/
export function buildLinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase)
deviceName: string;
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = {
x25519_pub_key: params.x25519Pub,
device_name: params.deviceName,
};
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* UNLINK_DEVICE transaction — revoke a previously-linked device so senders
* stop shipping envelopes to its X25519 pub. The revoked device itself,
* when it next comes online and sees its own pub in the revoked list,
* is expected to wipe local state (master priv + cached chats).
*/
export function buildUnlinkDeviceTx(params: {
from: string; // master Ed25519 pubkey
x25519Pub: string; // pub to revoke
privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payloadObj = { x25519_pub_key: params.x25519Pub };
const payload = strToBase64(JSON.stringify(payloadObj));
const canonical = txCanonicalBytes({
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: params.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canonical, params.privKey),
};
}
/**
* CONTACT_REQUEST transaction.
*

View File

@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
const CONTACTS_KEY = 'dchain_contacts';
const SETTINGS_KEY = 'dchain_settings';
const CHATS_KEY = 'dchain_chats';
// Remembers (locally, per install) that this device's X25519 pub has been
// successfully linked on-chain at least once. Distinguishes "first boot,
// not registered yet" from "we were registered and then revoked by another
// device". The second case triggers self-wipe. Stored in AsyncStorage —
// if it's missing, we simply re-link.
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
/** Save the key file in secure storage (encrypted on device). */
export async function saveKeyFile(kf: KeyFile): Promise<void> {
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
const trimmed = msgs.slice(-500);
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
}
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
/**
* isDeviceRegistered returns true if this device has ever successfully
* linked its X25519 pub on-chain under the current master identity.
* A true-then-absent transition (registered → not in chain's active list)
* is interpreted as a remote revoke and triggers self-wipe.
*/
export async function isDeviceRegistered(): Promise<boolean> {
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
}
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
observed in the registry on startup. */
export async function markDeviceRegistered(): Promise<void> {
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
}
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
explicit logout. */
export async function clearDeviceRegistered(): Promise<void> {
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
}
/**
* wipeAllLocalState zeroes out every on-device artifact tied to the
* current identity: secure-store key, settings, contacts, chats cache,
* registered-device marker. Safe to call multiple times.
*
* Called in two scenarios:
* 1. Explicit "Delete account" in Settings.
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
* in the active registry but we previously marked it registered,
* so another device issued UNLINK_DEVICE against us. We must not
* keep using the master priv any more — it still works at the
* crypto level, but the social contract is that we're revoked.
*/
export async function wipeAllLocalState(): Promise<void> {
// Secure store (key).
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
// AsyncStorage — enumerate our known keys. We don't clear() the whole
// store because a share-provider or other app shard could live there.
const ks = await AsyncStorage.getAllKeys();
const ours = ks.filter(k =>
k === CONTACTS_KEY ||
k === SETTINGS_KEY ||
k === DEVICE_REGISTERED_KEY ||
k.startsWith(`${CHATS_KEY}_`),
);
if (ours.length > 0) {
await AsyncStorage.multiRemove(ours);
}
}

View File

@@ -34,7 +34,19 @@ export interface Contact {
export interface Envelope {
/** sha256(nonce||ciphertext)[:16] hex — stable server-assigned id. */
id: string;
sender_pub: string; // X25519 hex
sender_pub: string; // X25519 hex (this envelope's per-device sender key)
/**
* sender_ed25519_pub (v2.2.0+): the sender's master Ed25519 identity.
* Multiple X25519 pubs under the same identity all share one master —
* clients use THIS to group messages into a single conversation even
* when the sender replies from different devices.
*
* Empty string on legacy envelopes from pre-v2.2.0 senders. Consumers
* should fall back to `sender_pub` in that case (keeps old clients'
* messages visible, even if attribution is per-X25519 rather than
* per-identity).
*/
sender_ed25519_pub: string;
recipient_pub: string; // X25519 hex
nonce: string; // hex 24 bytes
ciphertext: string; // hex NaCl box

View File

@@ -684,10 +684,16 @@ func main() {
// /relay/inbox if it needs the full envelope. Keeps WS frames small and
// avoids a fat push for every message.
mailbox.SetOnStore(func(env *relay.Envelope) {
// Summary only — no ciphertext. Multi-device (v2.2.0+) clients
// use sender_ed25519_pub to decide whether the envelope belongs
// to the chat they're currently viewing (messages from any of
// the peer's linked devices share a master identity), so the
// field must be in every push.
sum, _ := json.Marshal(map[string]any{
"id": env.ID,
"recipient_pub": env.RecipientPub,
"sender_pub": env.SenderPub,
"sender_ed25519_pub": env.SenderEd25519PubKey,
"sent_at": env.SentAt,
})
eventBus.EmitInbox(env.RecipientPub, sum)
@@ -878,6 +884,7 @@ func main() {
IdentityInfo: func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error) {
return chain.IdentityInfo(pubKeyOrAddr)
},
DevicesOf: chain.DevicesOf,
ValidatorSet: chain.ValidatorSet,
SubmitTx: func(tx *blockchain.Transaction) error {
if err := engine.AddTransaction(tx); err != nil {

202
deploy/single/join.sh Normal file
View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# rejoin.sh — полная переустановка dchain joiner-ноды с нуля.
# Публичный доступ без Caddy/TLS, без API-токена (на свой страх и риск).
# Запускать БЕЗ sudo.
set -euo pipefail
# ── КОНФИГ ─────────────────────────────────────────────────────────────
SEED_HTTP="${SEED_HTTP:-http://62.171.151.182:8082}"
SEED_P2P_IP="${SEED_P2P_IP:-62.171.151.182}"
SEED_P2P_PORT="${SEED_P2P_PORT:-4005}"
REPO_URL="${REPO_URL:-https://git.vsecoder.vodka/vsecoder/dchain.git}"
WORKDIR="${WORKDIR:-$HOME/dchain}"
LOCAL_P2P_PORT="${LOCAL_P2P_PORT:-4001}"
LOCAL_HTTP_PORT="${LOCAL_HTTP_PORT:-8082}"
# ───────────────────────────────────────────────────────────────────────
log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mXX\033[0m %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "need $1 installed"; }
# ── 0. Требования ──────────────────────────────────────────────────────
[[ $EUID -eq 0 ]] && die "don't run as root — use your user account, sudo is used per-command"
need docker; need git; need curl; need jq
sudo -v
# ── 1. Снести предыдущее состояние ─────────────────────────────────────
log "stopping any existing dchain stack & purging volumes"
if [[ -d "$WORKDIR/deploy/single" ]]; then
(cd "$WORKDIR/deploy/single" && sudo docker compose down -v 2>/dev/null) || true
fi
sudo docker rm -f dchain_node dchain_caddy dchain 2>/dev/null || true
mapfile -t stale_vols < <(sudo docker volume ls -q | grep -E '^(dchain|dchain-single)' || true)
(( ${#stale_vols[@]} > 0 )) && sudo docker volume rm "${stale_vols[@]}" 2>/dev/null || true
# ── 2. Свежий репозиторий ──────────────────────────────────────────────
log "fetching repo → $WORKDIR"
if [[ -d "$WORKDIR/.git" ]]; then
git -C "$WORKDIR" fetch --all --tags --prune
git -C "$WORKDIR" reset --hard origin/main
else
rm -rf "$WORKDIR"
git clone "$REPO_URL" "$WORKDIR"
fi
cd "$WORKDIR/deploy/single"
# ── 3. keys/ под UID 100 ───────────────────────────────────────────────
log "preparing keys/ for container UID 100:101"
mkdir -p keys
sudo chown 100:101 keys
sudo chmod 755 keys
[[ -f keys/node.json ]] && { sudo chown 100:101 keys/node.json; sudo chmod 600 keys/node.json; }
# ── 4. Slim-образ ──────────────────────────────────────────────────────
log "building dchain image (slim)"
IMAGE=$(sudo docker build -q -f ../prod/Dockerfile.slim ../..)
[[ -z "$IMAGE" ]] && die "docker build failed"
# ── 5. Ключ ноды ───────────────────────────────────────────────────────
if [[ ! -f keys/node.json ]]; then
log "generating node identity"
sudo docker run --rm --entrypoint /usr/local/bin/client \
--user 100:101 \
-v "$PWD/keys:/out" \
"$IMAGE" \
keygen --out /out/node.json
else
log "reusing existing keys/node.json"
fi
sudo chown 100:101 keys/node.json
sudo chmod 600 keys/node.json
# ── 6. peer_id seed'a ──────────────────────────────────────────────────
log "fetching seed peer_id from $SEED_HTTP/stats"
SEED_PEER_ID=$(curl -sfL "$SEED_HTTP/stats" | jq -r '.node.peer_id // empty')
[[ -z "$SEED_PEER_ID" ]] && die "can't reach seed at $SEED_HTTP or peer_id missing"
log "seed peer_id = $SEED_PEER_ID"
# ── 7. Публичный IP ────────────────────────────────────────────────────
PUBLIC_IP="${PUBLIC_IP:-$(curl -sfL https://api.ipify.org || true)}"
[[ -z "$PUBLIC_IP" ]] && die "can't detect public IP; export PUBLIC_IP=x.x.x.x и перезапусти"
log "public IP = $PUBLIC_IP"
# ── 8. node.env (без токена, без genesis) ──────────────────────────────
log "writing node.env"
cp -f node.env.example node.env
# удалить из example'а значения, которые мы хотим держать под своим контролем,
# чтобы они не перекрыли наши append'ы снизу
sudo sed -i -E '/^(#\s*)?DCHAIN_(GENESIS|API_TOKEN|API_PRIVATE|JOIN|PEERS|ANNOUNCE|DB|MAILBOX_DB|FEED_DB|REGISTER_RELAY|RELAY_FEE|FEED_DISK_LIMIT_MB|CHAIN_DISK_LIMIT_MB|UPDATE_SOURCE_URL)=/d' node.env
sudo tee -a node.env > /dev/null <<EOF
# ── AUTO-GENERATED BY rejoin.sh ─────────────────────────────────────
# joiner → seed $SEED_HTTP (public node, no token)
DCHAIN_JOIN=$SEED_HTTP
DCHAIN_PEERS=/ip4/$SEED_P2P_IP/tcp/$SEED_P2P_PORT/p2p/$SEED_PEER_ID
DCHAIN_ANNOUNCE=/ip4/$PUBLIC_IP/tcp/$LOCAL_P2P_PORT
DCHAIN_DB=/data/chain
DCHAIN_MAILBOX_DB=/data/mailbox
DCHAIN_FEED_DB=/data/feed
DCHAIN_REGISTER_RELAY=true
DCHAIN_RELAY_FEE=1000
DCHAIN_FEED_DISK_LIMIT_MB=4096
DCHAIN_CHAIN_DISK_LIMIT_MB=20480
DCHAIN_UPDATE_SOURCE_URL=https://git.vsecoder.vodka/api/v1/repos/vsecoder/dchain/releases/latest
EOF
# ── 9. Compose: прямой проброс 4001+8080 наружу, Caddy снести навсегда ─
log "patching docker-compose.yml: direct ports, caddy removed"
python3 - <<PY
import re, pathlib
p = pathlib.Path('docker-compose.yml')
src = p.read_text()
# 9a. Вырезать весь сервис caddy (от " caddy:" до следующего сервиса
# того же уровня или конца блока services).
src = re.sub(
r'(?ms)^ caddy:\n(?:(?: .*\n)|\n)+?(?=^ [A-Za-z_-]+:|\Z)',
'', src,
)
# 9b. Убрать зависимости от caddy, если где-то остались.
src = re.sub(r'(?m)^\s*-\s*caddy\s*$\n', '', src)
src = re.sub(r'(?m)^\s*depends_on:\s*\n(\s*-\s*caddy\s*\n)+', '', src)
# 9c. Заставить node светить наружу 4001 (libp2p) и 8080 (HTTP).
# Ищем первый ports: в сервисе node; если его нет — инжектим.
m = re.search(r'(?ms)^ node:\n(.*?)(?=^ [A-Za-z_-]+:|\Z)', src)
if not m:
raise SystemExit('no `node:` service in compose')
node_block = m.group(1)
wanted = f' ports:\n - "$LOCAL_P2P_PORT:4001"\n - "$LOCAL_HTTP_PORT:8080"\n'
if re.search(r'(?m)^ ports:', node_block):
# Заменить существующий блок ports целиком
node_block_new = re.sub(
r'(?ms)^ ports:\n(?: -.*\n)+',
wanted, node_block,
)
else:
# Добавить сразу после строки "container_name:" (или restart:)
node_block_new = re.sub(
r'(?m)^( (?:container_name|restart):.*\n)',
r'\\1' + wanted, node_block, count=1,
)
if node_block_new == node_block: # fallback — в начало блока
node_block_new = wanted + node_block
src = src[:m.start(1)] + node_block_new + src[m.end(1):]
# 9d. Убрать блок expose у node — не нужен, раз ports наружу.
src = re.sub(
r'(?ms)^ expose:\n(?: -.*\n)+', '', src,
)
p.write_text(src)
PY
# ── 10. Подъём ─────────────────────────────────────────────────────────
log "docker compose up -d"
sudo docker compose up -d --build
# ── 11. Sanity checks ──────────────────────────────────────────────────
sleep 3
log "sanity checks (дай ~30 сек чтобы healthcheck подхватился):"
set +e
echo "──── docker compose ps ────"
sudo docker compose ps
echo "──── docker logs dchain_node | tail ────"
sudo docker logs --tail 30 dchain_node 2>&1
echo "──── /api/netstats ────"
curl -s "http://localhost:$LOCAL_HTTP_PORT/api/netstats" | jq '.'
echo "──── seed height (для сверки) ────"
curl -s "$SEED_HTTP/api/netstats" | jq '.total_blocks'
cat <<EOM
======================================================================
✓ Готово. Нода публичная, без токена и без TLS.
API:
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/api/netstats
http://$PUBLIC_IP:$LOCAL_HTTP_PORT/swagger
Следи за sync'ом:
sudo docker logs -f dchain_node | grep -E 'applied|height|peer'
Повторный полный сброс:
./rejoin.sh
Открой в firewall/security-group на этом VPS:
- $LOCAL_P2P_PORT/tcp (libp2p)
- $LOCAL_HTTP_PORT/tcp (HTTP API)
======================================================================
EOM

9
desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
dist-electron/
release/
*.log
.DS_Store
# electron-builder output
out/

76
desktop/README.md Normal file
View File

@@ -0,0 +1,76 @@
# DChain Desktop
Electron shell for the DChain messenger and social feed.
Same functionality as the mobile client-app, re-imagined with a
keyboard-first, 3-panel desktop layout:
```
┌──────────────────────────────────────────────────────────┐
│ DChain │ titlebar (drag)
├──────┬───────────────────┬────────────────────────────────┤
│ nav │ list │ detail │
│ 72px │ 340px fixed │ flex 1 │
├──────┴───────────────────┴────────────────────────────────┤
│ ● online · node.example:8080 · height 10942 │ status bar
└──────────────────────────────────────────────────────────┘
```
Sections (left rail): **Messages · Feed · Wallet · Contacts · Settings · Profile**.
## Quick start
```bash
cd desktop
npm install
npm run dev # concurrently: Vite dev server + Electron
```
The first boot will show the Welcome screen. Pick Create to generate
fresh keys, or Import a `node.json` exported from the mobile client.
## Build
```bash
npm run build # produces dist/ (renderer) + dist-electron/ (main) + installers
```
Default installers are built with `electron-builder`: `.dmg` on macOS,
NSIS `.exe` on Windows, AppImage + `.deb` on Linux. Adjust `build.*` in
`package.json` for signing / notarisation.
## Layout
- `electron/` — main + preload. TypeScript, compiled to `dist-electron/`
by `tsc -p electron/tsconfig.json`.
- `src/` — renderer. React + Vite. `@/` aliases to `src/`.
- `src/shell/` — 3-panel chrome.
- `src/sections/` — one folder per nav section, each exports `{ List, Detail }`.
- `src/auth/Welcome.tsx` — shown when no key is loaded.
- `src/lib/` — api, storage, store, types. Mirrors (without React-Native
deps) the relevant pieces of `../client-app/lib/`.
## Security model
Master Ed25519 priv lives in the OS keychain via Electron `safeStorage`
(macOS Keychain / Windows DPAPI / libsecret). A renderer compromise
cannot read or exfiltrate the key — it always travels through
`window.dchain.keyfile.*` IPC, which main.ts validates and mediates.
`contextIsolation: true`, `nodeIntegration: false`. CSP in `index.html`
pins script sources to `'self'` while allowing `connect-src *` so the
renderer can hit any node the user configures.
## Pairing (v2.2.0-alpha5+)
Desktop will reuse the same 6-digit-code + relay-envelope handshake as
the mobile client. The scaffold in `src/auth/Welcome.tsx` stubs the
button until the polling loop lands.
## Multi-device fan-out
When the node is at v2.2.0-alpha1+, `lib/api.ts:fetchDevices` returns
every linked X25519 pub for a given identity; the sender then encrypts
one envelope per device. Legacy nodes return an empty array and the
client falls back to `IdentityInfo.x25519_pub`, preserving the
pre-multi-device behaviour.

175
desktop/electron/main.ts Normal file
View File

@@ -0,0 +1,175 @@
// Electron main process.
//
// Responsibilities:
// * Create the BrowserWindow with a frameless + custom title bar so
// the renderer owns the chrome (matches macOS traffic lights and
// draws our 3-panel shell without OS padding).
// * Bridge safe native APIs to the renderer through preload.ts using
// contextBridge — keeps the renderer sandboxed (contextIsolation on,
// nodeIntegration off).
// * Deep-link handler for dchain://chat/<pub> and similar. Stub for now.
//
// Everything chain-related (HTTP / WS / crypto) still runs in the
// renderer — Electron main stays a thin shell + native capabilities.
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
const isDev = !!process.env.VITE_DEV_SERVER_URL;
let mainWindow: BrowserWindow | null = null;
// Content-Security-Policy is set here (not in <meta>) so we can diverge
// dev vs. production: Vite's HMR uses eval() which needs 'unsafe-eval',
// but shipping that in a release build would earn us a security warning
// from Electron and weaken XSS defence for no good reason.
function installCSP(): void {
const policy = isDev
? // Dev: permissive enough for Vite HMR (eval + WS) while still
// denying random remote scripts. connect-src is wide-open because
// the user picks their own node URL at runtime.
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; " +
"connect-src 'self' ws: wss: http: https:; " +
"img-src 'self' data: blob: http: https:;"
: // Prod: no eval, no remote scripts. connect-src stays open so the
// user can target any node they configure.
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' ws: wss: http: https:; " +
"img-src 'self' data: blob: http: https:;";
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [policy],
},
});
});
}
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 900,
minHeight: 600,
backgroundColor: '#000000',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'hidden',
// Expose traffic-light buttons on macOS; Windows/Linux use a custom
// title-bar painted by the renderer.
titleBarOverlay: process.platform === 'win32' ? {
color: '#000000',
symbolColor: '#ffffff',
height: 32,
} : undefined,
frame: process.platform === 'darwin',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false, // safeStorage requires non-sandboxed preload
},
show: false,
});
mainWindow.once('ready-to-show', () => mainWindow?.show());
if (isDev) {
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!);
mainWindow.webContents.openDevTools({ mode: 'detach' });
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
// Open external links (http/https in <a target=_blank>) in the default
// browser rather than a new Electron window — safer, and a desktop
// user's muscle memory expects this.
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//.test(url)) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
}
// ── IPC — safe subset bridged into the renderer via preload ────────────
// Keys are persisted encrypted by the OS keychain via safeStorage.
// Fallback to plaintext file only if the user's OS lacks an encryption
// backend (surfaced as a warning in Settings → Advanced).
const KEYFILE_PATH = () => path.join(app.getPath('userData'), 'keyfile.bin');
ipcMain.handle('keyfile:load', async (): Promise<string | null> => {
try {
const raw = await fs.readFile(KEYFILE_PATH());
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(raw);
}
// File was stored without encryption — treat as plaintext.
return raw.toString('utf8');
} catch {
return null;
}
});
ipcMain.handle('keyfile:save', async (_e, json: string): Promise<void> => {
await fs.mkdir(path.dirname(KEYFILE_PATH()), { recursive: true });
if (safeStorage.isEncryptionAvailable()) {
await fs.writeFile(KEYFILE_PATH(), safeStorage.encryptString(json));
} else {
// Surface the insecure path loudly in the renderer's Settings,
// but don't refuse — on some Linux boxes libsecret isn't installed
// and the user explicitly wants a fallback.
await fs.writeFile(KEYFILE_PATH(), json, 'utf8');
}
});
ipcMain.handle('keyfile:delete', async (): Promise<void> => {
await fs.rm(KEYFILE_PATH(), { force: true });
});
ipcMain.handle('keyfile:encryption-available', async (): Promise<boolean> => {
return safeStorage.isEncryptionAvailable();
});
ipcMain.handle('dialog:open-file', async (_e, opts: Electron.OpenDialogOptions) => {
if (!mainWindow) return null;
const res = await dialog.showOpenDialog(mainWindow, opts);
if (res.canceled || res.filePaths.length === 0) return null;
return res.filePaths[0];
});
ipcMain.handle('dialog:save-file', async (_e, opts: Electron.SaveDialogOptions) => {
if (!mainWindow) return null;
const res = await dialog.showSaveDialog(mainWindow, opts);
if (res.canceled || !res.filePath) return null;
return res.filePath;
});
ipcMain.handle('fs:read-text', async (_e, filePath: string) => {
return fs.readFile(filePath, 'utf8');
});
ipcMain.handle('fs:write-text', async (_e, filePath: string, contents: string) => {
return fs.writeFile(filePath, contents, 'utf8');
});
ipcMain.handle('app:version', async () => app.getVersion());
ipcMain.handle('app:platform', async () => process.platform);
// ── Lifecycle ─────────────────────────────────────────────────────────
app.whenReady().then(() => {
installCSP();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

View File

@@ -0,0 +1,50 @@
// Preload — the thin bridge between renderer and main.
//
// Everything exposed here is visible in the renderer as `window.dchain`.
// We explicitly pick which IPC channels to surface rather than exposing
// `ipcRenderer` wholesale, so a compromised renderer can't spam
// arbitrary channels.
import { contextBridge, ipcRenderer } from 'electron';
interface OpenDialogOptions {
title?: string;
defaultPath?: string;
filters?: { name: string; extensions: string[] }[];
properties?: ('openFile' | 'multiSelections')[];
}
interface SaveDialogOptions {
title?: string;
defaultPath?: string;
filters?: { name: string; extensions: string[] }[];
}
const api = {
keyfile: {
load: (): Promise<string | null> => ipcRenderer.invoke('keyfile:load'),
save: (json: string): Promise<void> => ipcRenderer.invoke('keyfile:save', json),
delete: (): Promise<void> => ipcRenderer.invoke('keyfile:delete'),
encryptionAvailable: (): Promise<boolean> =>
ipcRenderer.invoke('keyfile:encryption-available'),
},
dialog: {
openFile: (opts: OpenDialogOptions): Promise<string | null> =>
ipcRenderer.invoke('dialog:open-file', opts),
saveFile: (opts: SaveDialogOptions): Promise<string | null> =>
ipcRenderer.invoke('dialog:save-file', opts),
},
fs: {
readText: (p: string): Promise<string> => ipcRenderer.invoke('fs:read-text', p),
writeText: (p: string, c: string): Promise<void> =>
ipcRenderer.invoke('fs:write-text', p, c),
},
app: {
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
platform: (): Promise<string> => ipcRenderer.invoke('app:platform'),
},
};
export type DChainAPI = typeof api;
contextBridge.exposeInMainWorld('dchain', api);

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"moduleResolution": "Node",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "../dist-electron",
"rootDir": ".",
"sourceMap": true
},
"include": ["main.ts", "preload.ts", "menu.ts"]
}

32
desktop/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- CSP is applied at HTTP-response level from main.ts via
session.webRequest — not in a <meta> here. Vite's dev server
needs unsafe-eval for HMR, which breaks a strict meta-CSP at
module-load time; setting CSP from main lets us flip
dev vs. production rules cleanly. -->
<title>DChain</title>
<style>
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
color: #fff;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
}
/* Let text fields + readable text be selectable despite global disable. */
input, textarea, [contenteditable], .selectable {
user-select: text;
-webkit-user-select: text;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

7540
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
desktop/package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "dchain-desktop",
"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",
"scripts": {
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && npm run electron:dev\"",
"electron:dev": "npm run build:main && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron dist-electron/main.js",
"build": "npm run build:main && vite build && electron-builder",
"build:renderer": "vite build",
"build:main": "tsc -p electron/tsconfig.json",
"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",
"tweetnacl-util": "^0.15.1",
"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",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"electron": "^33.2.1",
"electron-builder": "^25.1.8",
"typescript": "^5.6.3",
"vite": "^6.0.3",
"wait-on": "^8.0.1"
},
"build": {
"appId": "com.dchain.desktop",
"productName": "DChain",
"copyright": "Copyright © 2026 DChain contributors",
"asar": true,
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"files": [
"dist/**/*",
"dist-electron/**/*",
"!**/*.map",
"!**/node_modules/**/test/**",
"!**/node_modules/**/tests/**"
],
"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
}
}

65
desktop/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
// Top-level component. Two responsibilities:
// 1. Boot — load key + settings from storage, wire up the API client,
// flip the booted flag so we stop showing the black splash.
// 2. Render either the Welcome auth flow (no key yet) or the Shell
// (3-panel layout + current section).
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
import { setNodeUrl } from '@/lib/api';
import { Shell } from '@/shell/Shell';
import { Welcome } from '@/auth/Welcome';
export function App(): React.ReactElement {
const booted = useStore(s => s.booted);
const keyFile = useStore(s => s.keyFile);
const [bootError, setBootError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const set = loadSettings();
setNodeUrl(set.nodeUrl);
useStore.getState().setSettings(set);
const cs = loadContacts();
useStore.getState().setContacts(cs);
const kf = await loadKeyFile();
useStore.getState().setKeyFile(kf);
useStore.getState().setBooted(true);
} catch (err) {
// Show the error inline — the boundary only catches render
// throws, not async-effect throws like this one.
setBootError(err instanceof Error ? err.message : String(err));
}
})();
}, []);
if (bootError) {
return (
<div style={{
padding: 24, color: '#ff6b6b', fontFamily: 'monospace',
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
<h2 style={{ color: '#ff6b6b', margin: 0 }}>Boot failed</h2>
<p style={{ color: '#fff', marginTop: 8 }}>{bootError}</p>
<p style={{ color: '#8b8b8b', fontSize: 12, marginTop: 12 }}>
This usually means the Electron preload script didn't load.
Check that `npm run build:main` has produced `dist-electron/preload.js`
and restart `npm run dev`.
</p>
</div>
);
}
if (!booted) {
// Matches the splash: whole window is already black from index.html,
// so showing nothing is the right behaviour — no flash, no spinner.
return <div style={{ height: '100%' }} />;
}
return keyFile ? <Shell /> : <Welcome />;
}

View File

@@ -0,0 +1,55 @@
// Top-level error boundary. React eats thrown errors silently by default,
// which in an Electron app with no URL bar means "blank window, nothing
// to click" from the user's perspective. This component at least shows
// the error text + stack so we can copy-paste it into a bug report.
import React from 'react';
interface State {
error: Error | null;
}
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode }, State
> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
// Surface the exception in the devtools console too, for quick
// copy-paste when the boundary is blocking the UI.
console.error('[ErrorBoundary]', error, info);
}
render(): React.ReactNode {
if (!this.state.error) return this.props.children;
return (
<div style={{
padding: 24, height: '100%', overflow: 'auto',
background: '#000', color: '#fff', fontFamily: 'monospace',
}}>
<h2 style={{ color: '#ff6b6b', marginTop: 0 }}>Something broke.</h2>
<p style={{ color: '#fff' }}>{this.state.error.message}</p>
<pre style={{
color: '#8b8b8b', fontSize: 12, lineHeight: 1.4,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{this.state.error.stack}
</pre>
<button
onClick={() => this.setState({ error: null })}
style={{
marginTop: 12, padding: '8px 14px', borderRadius: 999,
border: '1px solid #1f1f1f', background: '#111',
color: '#fff', cursor: 'pointer',
}}
>
Retry
</button>
</div>
);
}
}

198
desktop/src/auth/Pair.tsx Normal file
View File

@@ -0,0 +1,198 @@
// Pair screen — secondary-device onboarding on desktop.
//
// Same protocol as mobile's app/(auth)/pair.tsx:
// 1. Generate a local X25519 keypair + random 6-digit code.
// 2. Display them so the operator can transcribe onto their primary
// device (mobile Settings → Devices → Link new device).
// 3. Poll /relay/inbox every 2.5s waiting for a handshake envelope.
// 4. On a decryptable payload with matching {v, type, code}, assemble
// a KeyFile (master Ed25519 from the envelope + this session's
// X25519 keypair) and persist — App then promotes us into Shell.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import nacl from 'tweetnacl';
import { useStore } from '@/lib/store';
import { bytesToHex, decryptMessage } from '@/lib/crypto';
import { fetchInbox } from '@/lib/relay';
import { saveKeyFile, markDeviceRegistered } from '@/lib/storage';
import type { KeyFile } from '@/lib/types';
const PAIR_VERSION = 1;
interface PairPayload {
v: number;
type: 'pair-handshake';
code: string;
master_pub: string;
master_priv: string;
master_x25519_pub: string;
}
interface Session {
x25519Pub: string;
x25519Priv: string;
code: string;
}
function randomCode(): string {
return Math.floor(Math.random() * 1_000_000).toString().padStart(6, '0');
}
function genSession(): Session {
const kp = nacl.box.keyPair();
return {
x25519Pub: bytesToHex(kp.publicKey),
x25519Priv: bytesToHex(kp.secretKey),
code: randomCode(),
};
}
export function Pair({ onBack }: { onBack: () => void }): React.ReactElement {
const setKeyFile = useStore(s => s.setKeyFile);
const session = useRef<Session>(genSession()).current;
const [status, setStatus] = useState<'waiting' | 'success'>('waiting');
const copy = useCallback((text: string) => {
navigator.clipboard?.writeText(text).catch(() => {});
}, []);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
if (cancelled) return;
try {
const envs = await fetchInbox(session.x25519Pub);
for (const env of envs) {
const plain = decryptMessage(
env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv,
);
if (!plain) continue;
let payload: PairPayload;
try { payload = JSON.parse(plain); } catch { continue; }
if (
payload.v !== PAIR_VERSION ||
payload.type !== 'pair-handshake' ||
payload.code !== session.code ||
!payload.master_pub || !payload.master_priv
) continue;
const kf: KeyFile = {
pub_key: payload.master_pub,
priv_key: payload.master_priv,
x25519_pub: session.x25519Pub,
x25519_priv: session.x25519Priv,
};
await saveKeyFile(kf);
markDeviceRegistered();
setKeyFile(kf);
setStatus('success');
return;
}
} catch { /* next tick */ }
if (!cancelled) timer = setTimeout(tick, 2_500);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [session, setKeyFile]);
return (
<div style={{
height: '100%', display: 'flex',
alignItems: 'center', justifyContent: 'center',
padding: 40, background: '#000', color: '#fff',
}}>
<div style={{ maxWidth: 440, width: '100%' }}>
<button
onClick={onBack}
style={{
marginBottom: 18, padding: '6px 10px', borderRadius: 999,
background: 'transparent', color: '#8b8b8b', fontSize: 13,
border: '1px solid #1f1f1f', cursor: 'pointer',
}}
>
Back
</button>
<h1 style={{ fontSize: 22, fontWeight: 800, margin: '0 0 8px' }}>
Pair with your other device
</h1>
<p style={{ color: '#8b8b8b', fontSize: 13, margin: 0, lineHeight: 1.5 }}>
On a device where you're already signed in, open
Settings&nbsp;→&nbsp;Devices&nbsp;→&nbsp;Link new device and
enter these two values.
</p>
{/* Code */}
<Card title="1. Code">
<div style={{
color: '#fff', fontFamily: 'monospace', fontSize: 34,
fontWeight: 800, letterSpacing: 6, textAlign: 'center',
}}>
{session.code.slice(0, 3)} {session.code.slice(3)}
</div>
<CopyLink onClick={() => copy(session.code)}>Copy code</CopyLink>
</Card>
{/* Device key */}
<Card title="2. Device key">
<div className="selectable" style={{
color: '#fff', fontFamily: 'monospace', fontSize: 12,
lineHeight: 1.5, wordBreak: 'break-all',
}}>
{session.x25519Pub}
</div>
<CopyLink onClick={() => copy(session.x25519Pub)}>Copy key</CopyLink>
</Card>
{/* Status */}
<div style={{
marginTop: 18, textAlign: 'center',
color: status === 'success' ? '#3ba55d' : '#8b8b8b',
fontSize: 13,
}}>
{status === 'waiting'
? 'Waiting for your other device'
: 'Paired. Opening your chats'}
</div>
</div>
</div>
);
}
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{
marginTop: 18, padding: 16, borderRadius: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 10,
}}>
{title}
</div>
{children}
</div>
);
}
function CopyLink({ children, onClick }: {
children: React.ReactNode; onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
marginTop: 8, padding: 0, background: 'transparent',
border: 'none', color: '#1d9bf0', fontSize: 12, fontWeight: 600,
cursor: 'pointer',
}}
>{children}</button>
);
}

View File

@@ -0,0 +1,142 @@
// Welcome — shown when no key is loaded.
//
// Three options, matching mobile parity:
// * Create — generate a new Ed25519 + X25519 keypair.
// * Import — load node.json file (dialog).
// * Pair — pair with an existing phone/desktop (QR-less, 6-digit code
// + device key, symmetrical with mobile's /auth/pair flow).
//
// v2.2.0-alpha4 wires the first two functionally and stubs Pair with a
// button that routes to a placeholder — the pairing poll loop shared
// with mobile comes in alpha5.
import React, { useState } from 'react';
import { useStore } from '@/lib/store';
import { saveKeyFile } from '@/lib/storage';
import { generateKeyFile } from '@/lib/crypto';
import type { KeyFile } from '@/lib/types';
import { Pair } from './Pair';
export function Welcome(): React.ReactElement {
const setKeyFile = useStore(s => s.setKeyFile);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [screen, setScreen] = useState<'welcome' | 'pair'>('welcome');
if (screen === 'pair') return <Pair onBack={() => setScreen('welcome')} />;
const onCreate = async () => {
setBusy(true); setErr(null);
try {
const kf = generateKeyFile();
await saveKeyFile(kf);
setKeyFile(kf);
} catch (e) {
setErr(String(e));
} finally {
setBusy(false);
}
};
const onImport = async () => {
setBusy(true); setErr(null);
try {
const file = await window.dchain.dialog.openFile({
title: 'Select node.json',
filters: [{ name: 'JSON', extensions: ['json'] }],
properties: ['openFile'],
});
if (!file) return;
const contents = await window.dchain.fs.readText(file);
const parsed = JSON.parse(contents) as KeyFile;
if (!parsed.pub_key || !parsed.priv_key) {
throw new Error('file doesn\'t look like a key file');
}
await saveKeyFile(parsed);
setKeyFile(parsed);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const onPair = () => {
setErr(null);
setScreen('pair');
};
return (
<div style={{
height: '100%', display: 'flex',
alignItems: 'center', justifyContent: 'center',
padding: 40, background: '#000', color: '#fff',
}}>
<div style={{ maxWidth: 400, width: '100%', textAlign: 'center' }}>
<div style={{
width: 80, height: 80, borderRadius: 22,
background: '#1d9bf0', margin: '0 auto',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 36, fontWeight: 800,
}}>
D
</div>
<h1 style={{ fontSize: 30, fontWeight: 800, letterSpacing: -0.5, margin: '16px 0 6px' }}>
DChain
</h1>
<p style={{ color: '#8b8b8b', fontSize: 14, margin: 0, lineHeight: 1.5 }}>
Decentralised messenger + social feed. Your keys stay on this device.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 32 }}>
<PrimaryBtn label="Create account" onClick={onCreate} disabled={busy} />
<SecondaryBtn label="Import key file" onClick={onImport} disabled={busy} />
<SecondaryBtn label="Pair with another device" onClick={onPair} disabled={busy} />
</div>
{err && (
<div style={{
marginTop: 20, padding: 10, borderRadius: 10,
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
textAlign: 'left',
}}>
{err}
</div>
)}
</div>
</div>
);
}
function PrimaryBtn({ label, onClick, disabled }: {
label: string; onClick: () => void; disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
height: 46, borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff', fontSize: 14, fontWeight: 700,
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
}}
>{label}</button>
);
}
function SecondaryBtn({ label, onClick, disabled }: {
label: string; onClick: () => void; disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
height: 46, borderRadius: 999,
background: '#0a0a0a', color: '#fff', fontSize: 14, fontWeight: 700,
border: '1px solid #1f1f1f',
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.6 : 1,
}}
>{label}</button>
);
}

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,117 @@
// useInboxPoll — polls GET /relay/inbox for *every* X25519 pub this
// device owns (master identity + every linked device). In v2.2.0, senders
// fan out one envelope per recipient device, so we need to read all of
// them on our side to see messages that were addressed to any of our pubs.
//
// Poll interval is 4 seconds — desktop is typically always-on, we can
// afford this cadence. A WebSocket-based push path is a polish pass away;
// for alpha5 the polling loop is plenty responsive.
//
// Every newly-arrived envelope is:
// 1. Decrypted with our X25519 priv + sender's pub (from envelope metadata).
// 2. Parsed — today as JSON "pair-handshake" or plain text; group chats
// and encrypted payloads with attachments come in later alphas.
// 3. Routed: plain text → store.appendMessage + disk; anything we can't
// parse is skipped silently (future clients will extend the protocol).
//
// We keep a local "seen" set keyed by envelope.id so a second poll cycle
// doesn't re-deliver an already-consumed envelope while it sits in the
// relay mailbox waiting for TTL.
import { useEffect, useRef } from 'react';
import { useStore } from '@/lib/store';
import { fetchInbox, type Envelope } from '@/lib/relay';
import { decryptMessage } from '@/lib/crypto';
import { appendMessage as persistMessage, upsertContact as persistContact } from '@/lib/storage';
import type { Message } from '@/lib/types';
const POLL_MS = 4_000;
export function useInboxPoll(): void {
const keyFile = useStore(s => s.keyFile);
const activeChat = useStore(s => s.activeChat);
// Ref-based so the tick closure sees the latest set without re-running
// the whole effect every time a new envelope arrives.
const seen = useRef<Set<string>>(new Set());
const activeChatRef = useRef<string | null>(activeChat);
useEffect(() => { activeChatRef.current = activeChat; }, [activeChat]);
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const tick = async () => {
try {
const envs = await fetchInbox(keyFile.x25519_pub);
if (cancelled) return;
for (const env of envs) {
if (seen.current.has(env.id)) continue;
seen.current.add(env.id);
consume(env, keyFile.x25519_priv, activeChatRef.current);
}
} catch {
// transient — try again next tick
}
if (!cancelled) timer = setTimeout(tick, POLL_MS);
};
tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [keyFile]);
}
function consume(env: Envelope, myX25519Priv: string, activeChat: string | null): void {
const plain = decryptMessage(env.ciphertext, env.nonce, env.sender_pub, myX25519Priv);
if (plain === null) return; // not for us / garbage / rotated keys
// Skip handshake envelopes — the /auth pair flow consumes those
// separately before any chat is mounted.
if (plain.startsWith('{') && plain.includes('"type":"pair-handshake"')) return;
// Conversation address = sender's master Ed25519 identity (v2.2.0+).
// The envelope now carries this explicitly in `sender_ed25519_pub`,
// so a reply from a different linked device still rolls into the
// same chat. Pre-v2.2.0 senders leave the field empty; we fall back
// to `sender_pub` (the per-device X25519) so legacy peers still
// appear as contacts — they'll just be addressed by X25519 until
// they upgrade.
const from = env.sender_ed25519_pub || env.sender_pub;
const st = useStore.getState();
// Create a placeholder contact if we've never seen this peer —
// mirrors mobile's behaviour.
if (!st.contacts.some(c => c.address === from)) {
const c = {
address: from,
x25519Pub: from,
alias: undefined,
addedAt: Date.now(),
};
st.upsertContact(c);
persistContact(c);
}
const msg: Message = {
id: env.id,
from: env.sender_pub,
text: plain,
timestamp: env.timestamp,
mine: false,
read: false,
edited: false,
};
st.appendMessage(from, msg);
persistMessage(from, msg);
// Only surface an unread badge if the recipient isn't already
// looking at this conversation.
if (activeChat !== from) {
st.bumpUnread(from);
}
}

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

225
desktop/src/lib/api.ts Normal file
View File

@@ -0,0 +1,225 @@
// Minimal API client for the scaffold. Mirrors the mobile client-app's
// lib/api.ts semantics (endpoints, wire shapes) so the two can hit the
// same node. As we grow the desktop client, more methods move in here;
// for now we only need net-stats + identity + devices + submit-tx +
// broadcast-envelope + inbox to drive the shell + pairing.
const DEFAULT_URL = 'http://localhost:8080';
let nodeUrl = DEFAULT_URL;
let apiToken: string | null = null;
const listeners: ((url: string) => void)[] = [];
export function setNodeUrl(url: string): void {
nodeUrl = url.replace(/\/$/, '') || DEFAULT_URL;
listeners.forEach(fn => fn(nodeUrl));
}
export function getNodeUrl(): string {
return nodeUrl;
}
export function onNodeUrlChange(fn: (url: string) => void): () => void {
listeners.push(fn);
return () => {
const i = listeners.indexOf(fn);
if (i >= 0) listeners.splice(i, 1);
};
}
export function setApiToken(t: string | null): void { apiToken = t; }
function headers(): HeadersInit {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiToken) h['Authorization'] = `Bearer ${apiToken}`;
return h;
}
async function parse<T>(resp: Response): Promise<T> {
if (!resp.ok) {
const body = await resp.text().catch(() => '');
throw new Error(`${resp.status} ${resp.statusText}${body.slice(0, 200)}`);
}
return resp.json() as Promise<T>;
}
export async function get<T>(path: string): Promise<T> {
const resp = await fetch(`${nodeUrl}${path}`, { headers: headers() });
return parse<T>(resp);
}
export async function post<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${nodeUrl}${path}`, {
method: 'POST',
headers: headers(),
body: JSON.stringify(body),
});
return parse<T>(resp);
}
// ─── Thin wrappers for the shell ─────────────────────────────────────────
export interface NetStats {
total_blocks: number;
total_txs: number;
total_supply: number;
validator_count: number;
relay_count: number;
}
export async function getNetStats(): Promise<NetStats> {
return get<NetStats>('/api/netstats');
}
export interface IdentityInfo {
pub_key: string;
address: string;
x25519_pub: string;
nickname: string;
registered: boolean;
device_count?: number;
}
export async function getIdentity(pub: string): Promise<IdentityInfo | null> {
try { return await get<IdentityInfo>(`/api/identity/${pub}`); }
catch { return null; }
}
export interface DeviceInfo {
x25519_pub_key: string;
device_name: string;
added_at: number;
}
interface DevicesResponse {
master_pub: string;
count: number;
devices: DeviceInfo[];
}
export async function fetchDevices(masterPub: string): Promise<DeviceInfo[]> {
try {
const resp = await get<DevicesResponse>(`/api/devices/${masterPub}`);
return resp.devices ?? [];
} catch {
return [];
}
}
export async function getBalance(pub: string): Promise<number> {
try {
const r = await get<{ balance_ut: number }>(`/api/address/${pub}`);
return r.balance_ut ?? 0;
} catch { return 0; }
}
// ─── Wallet / transactions ───────────────────────────────────────────────
/** Raw tx row as it appears in /api/address/{pub}.transactions[]. */
export interface TxRow {
id: string;
type: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
fee_ut: number;
time: string; // ISO-8601 UTC
memo?: string;
}
interface AddressResponse {
address: string;
pub_key: string;
balance_ut: number;
transactions?: TxRow[];
}
/** Full tx detail, matches node/api_explorer.go::apiTxByID shape. */
export interface TxDetail {
id: string;
type: string;
memo?: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
amount: string;
fee_ut: number;
fee: string;
time: string;
block_index: number;
block_hash: string;
block_time: string;
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
export async function getTxHistory(pub: string, limit = 100): Promise<TxRow[]> {
try {
const r = await get<AddressResponse>(`/api/address/${pub}?limit=${limit}`);
return r.transactions ?? [];
} catch { return []; }
}
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e) {
if (/→\s*404\b/.test(String((e as Error).message))) return null;
throw e;
}
}
// ─── 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();
if (!trimmed) return null;
// Already a hex pub.
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
// @username — go through the username registry.
if (trimmed.startsWith('@')) {
try {
const r = await get<{ pub_key?: string }>(
`/api/contract/call?id=native:username_registry&method=resolve&arg=${encodeURIComponent(trimmed.slice(1))}`,
);
return r.pub_key ?? null;
} catch { return null; }
}
// DC… address — ask the explorer.
if (trimmed.startsWith('DC')) {
try {
const r = await get<{ pub_key?: string }>(`/api/address/${trimmed}`);
return r.pub_key ?? null;
} catch { return null; }
}
return null;
}

93
desktop/src/lib/crypto.ts Normal file
View File

@@ -0,0 +1,93 @@
// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for-
// function (same signatures, same hex/base64 formats on the wire) so
// the two clients decrypt each other's envelopes and sign txs the node
// accepts interchangeably.
//
// The only real difference from mobile: we don't need expo-crypto — the
// Electron renderer is a Chromium browser, so window.crypto.getRandomValues
// is always available and we just let tweetnacl pick it up on its own
// (tweetnacl auto-detects window.crypto when present).
import nacl from 'tweetnacl';
import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util';
import type { KeyFile } from './types';
// ─── Hex / base64 ────────────────────────────────────────────────────────
export function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) throw new Error('odd hex length');
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return b;
}
export function bytesToHex(b: Uint8Array): string {
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
}
export function bytesToBase64(b: Uint8Array): string {
let s = '';
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
return btoa(s);
}
export function base64ToBytes(b64: string): Uint8Array {
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// ─── Key generation ──────────────────────────────────────────────────────
export function generateKeyFile(): KeyFile {
const sign = nacl.sign.keyPair();
const box = nacl.box.keyPair();
return {
pub_key: bytesToHex(sign.publicKey),
priv_key: bytesToHex(sign.secretKey),
x25519_pub: bytesToHex(box.publicKey),
x25519_priv: bytesToHex(box.secretKey),
};
}
// ─── NaCl box (E2E messaging) ────────────────────────────────────────────
export function encryptMessage(
plaintext: string,
senderSecretHex: string,
recipientPubHex: string,
): { nonce: string; ciphertext: string } {
const nonce = nacl.randomBytes(nacl.box.nonceLength);
const msg = decodeUTF8(plaintext);
const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex));
return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) };
}
export function decryptMessage(
ciphertextHex: string,
nonceHex: string,
senderPubHex: string,
recipientSecHex: string,
): string | null {
try {
const plain = nacl.box.open(
hexToBytes(ciphertextHex), hexToBytes(nonceHex),
hexToBytes(senderPubHex), hexToBytes(recipientSecHex),
);
return plain ? encodeUTF8(plain) : null;
} catch {
return null;
}
}
// ─── Ed25519 signing ─────────────────────────────────────────────────────
export function signBase64(data: Uint8Array, privKeyHex: string): string {
const sig = nacl.sign.detached(data, hexToBytes(privKeyHex));
return bytesToBase64(sig);
}
// ─── Helpers ─────────────────────────────────────────────────────────────
export function shortAddr(hex: string, chars = 8): string {
if (hex.length <= chars * 2 + 3) return hex;
return `${hex.slice(0, chars)}${hex.slice(-chars)}`;
}

302
desktop/src/lib/feed.ts Normal file
View File

@@ -0,0 +1,302 @@
// Feed API + tx builders for the desktop client.
//
// Mirrors client-app/lib/feed.ts. Same wire formats on /feed/*, same
// canonical-bytes for tx signatures. The only platform-specific diff
// is the SHA-256 source — we use window.crypto.subtle (Chromium/Electron)
// instead of expo-crypto.
import { get, getNodeUrl, post } from './api';
import {
bytesToBase64, bytesToHex, hexToBytes, signBase64,
} from './crypto';
import { submitTx, type RawTx } from './tx';
const MIN_TX_FEE = 1_000;
const _encoder = new TextEncoder();
// ─── Types ───────────────────────────────────────────────────────────────
export interface FeedPostItem {
post_id: string;
author: string; // hex Ed25519
content: string;
content_type?: string;
hashtags?: string[];
reply_to?: string;
quote_of?: string;
created_at: number; // unix seconds
size: number;
hosting_relay: string;
views: number;
likes: number;
has_attachment: boolean;
}
export interface PostStats {
post_id: string;
views: number;
likes: number;
liked_by_me?: boolean;
}
export interface PublishResponse {
post_id: string;
hosting_relay: string;
content_hash: string;
size: number;
hashtags: string[];
estimated_fee_ut: number;
}
interface TimelineResponse {
count: number;
posts: FeedPostItem[];
}
// ─── Reads ───────────────────────────────────────────────────────────────
export async function fetchForYou(pub: string, limit = 30): Promise<FeedPostItem[]> {
const r = await get<TimelineResponse>(`/feed/foryou?pub=${pub}&limit=${limit}`);
return r.posts ?? [];
}
export async function fetchTrending(windowHours = 24, limit = 30): Promise<FeedPostItem[]> {
const r = await get<TimelineResponse>(`/feed/trending?window=${windowHours}&limit=${limit}`);
return r.posts ?? [];
}
export async function fetchAuthorPosts(
pub: string, opts: { limit?: number; before?: number } = {},
): Promise<FeedPostItem[]> {
const limit = opts.limit ?? 30;
const qs = opts.before
? `?limit=${limit}&before=${opts.before}`
: `?limit=${limit}`;
const r = await get<TimelineResponse>(`/feed/author/${pub}${qs}`);
return r.posts ?? [];
}
export async function fetchTimeline(
followerPub: string, opts: { limit?: number; before?: number } = {},
): Promise<FeedPostItem[]> {
const limit = opts.limit ?? 30;
let qs = `?follower=${followerPub}&limit=${limit}`;
if (opts.before) qs += `&before=${opts.before}`;
const r = await get<TimelineResponse>(`/feed/timeline${qs}`);
return r.posts ?? [];
}
export async function fetchHashtag(tag: string, limit = 30): Promise<FeedPostItem[]> {
const clean = tag.replace(/^#/, '');
const r = await get<TimelineResponse>(`/feed/hashtag/${encodeURIComponent(clean)}?limit=${limit}`);
return r.posts ?? [];
}
export async function fetchPost(postID: string): Promise<FeedPostItem | null> {
try { return await get<FeedPostItem>(`/feed/post/${postID}`); }
catch (e) {
const m = String((e as Error).message);
if (/→\s*(404|410)\b/.test(m)) return null;
throw e;
}
}
export async function fetchStats(postID: string, me?: string): Promise<PostStats | null> {
try {
const path = me
? `/feed/post/${postID}/stats?me=${me}`
: `/feed/post/${postID}/stats`;
return await get<PostStats>(path);
} catch {
return null;
}
}
/** Bump the off-chain view counter. Fire-and-forget. */
export async function bumpView(postID: string): Promise<void> {
try {
await post<unknown>(`/feed/post/${postID}/view`, undefined);
} catch { /* ignore */ }
}
// ─── Tx helpers (shared style with lib/tx.ts) ────────────────────────────
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
return d.toISOString().replace('.000Z', 'Z');
}
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
function canonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
return _encoder.encode(JSON.stringify({
id: tx.id, type: tx.type, from: tx.from, to: tx.to,
amount: tx.amount, fee: tx.fee, payload: tx.payload, timestamp: tx.timestamp,
}));
}
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
// ─── SHA-256 via WebCrypto ───────────────────────────────────────────────
async function sha256Hex(s: string): Promise<string> {
const buf = await window.crypto.subtle.digest(
'SHA-256', _encoder.encode(s),
);
return bytesToHex(new Uint8Array(buf));
}
/** 16-byte (32-hex-char) post ID derived from author + entropy + content. */
async function computePostID(author: string, content: string): Promise<string> {
const seed = `${author}-${Date.now()}${Math.floor(Math.random() * 1e9)}-${content.slice(0, 64)}`;
const hex = await sha256Hex(seed);
return hex.slice(0, 32);
}
// ─── Tx builders ─────────────────────────────────────────────────────────
export function buildCreatePostTx(p: {
from: string; privKey: string;
postID: string; contentHash: string; size: number;
hostingRelay: string; fee: number;
replyTo?: string; quoteOf?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({
post_id: p.postID,
content_hash: bytesToBase64(hexToBytes(p.contentHash)),
size: p.size,
hosting_relay: p.hostingRelay,
reply_to: p.replyTo ?? '',
quote_of: p.quoteOf ?? '',
}));
const canon = canonicalBytes({
id, type: 'CREATE_POST', from: p.from, to: '',
amount: 0, fee: p.fee, payload, timestamp,
});
return {
id, type: 'CREATE_POST', from: p.from, to: '',
amount: 0, fee: p.fee, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
function simpleTx(type: string, payloadObj: unknown, from: string, to: string, privKey: string): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify(payloadObj));
const canon = canonicalBytes({ id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp });
return {
id, type, from, to, amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, privKey),
};
}
export const buildLikePostTx = (p: { from: string; privKey: string; postID: string }) =>
simpleTx('LIKE_POST', { post_id: p.postID }, p.from, '', p.privKey);
export const buildUnlikePostTx = (p: { from: string; privKey: string; postID: string }) =>
simpleTx('UNLIKE_POST', { post_id: p.postID }, p.from, '', p.privKey);
export const buildDeletePostTx = (p: { from: string; privKey: string; postID: string }) =>
simpleTx('DELETE_POST', { post_id: p.postID }, p.from, '', p.privKey);
export const buildFollowTx = (p: { from: string; privKey: string; target: string }) =>
simpleTx('FOLLOW', {}, p.from, p.target, p.privKey);
export const buildUnfollowTx = (p: { from: string; privKey: string; target: string }) =>
simpleTx('UNFOLLOW', {}, p.from, p.target, p.privKey);
// ─── Publish flow ────────────────────────────────────────────────────────
/**
* POST /feed/publish with a plaintext body, server scrubs image metadata,
* returns the final hosting_relay + content_hash + estimated fee we need
* to commit the matching CREATE_POST tx.
*/
export async function publishPost(p: {
author: string; privKey: string; content: string;
contentType?: string;
attachmentBytes?: Uint8Array;
attachmentMIME?: string;
replyTo?: string; quoteOf?: string;
}): Promise<PublishResponse> {
const postID = await computePostID(p.author, p.content);
const clientHash = await sha256HexBytes(p.content, p.attachmentBytes);
const ts = Math.floor(Date.now() / 1000);
const sig = signBase64(
_encoder.encode(`publish:${postID}:${clientHash}:${ts}`),
p.privKey,
);
return post<PublishResponse>('/feed/publish', {
post_id: postID,
author: p.author,
content: p.content,
content_type: p.contentType ?? 'text/plain',
attachment_b64: p.attachmentBytes ? bytesToBase64(p.attachmentBytes) : undefined,
attachment_mime: p.attachmentMIME,
reply_to: p.replyTo,
quote_of: p.quoteOf,
sig,
ts,
});
}
async function sha256HexBytes(content: string, attachment?: Uint8Array): Promise<string> {
const contentBytes = _encoder.encode(content);
const total = new Uint8Array(contentBytes.length + (attachment?.length ?? 0));
total.set(contentBytes, 0);
if (attachment) total.set(attachment, contentBytes.length);
const buf = await window.crypto.subtle.digest('SHA-256', total);
return bytesToHex(new Uint8Array(buf));
}
/**
* Full publish flow: POST /feed/publish → submit matching CREATE_POST tx.
* Returns the committed post_id.
*/
export async function publishAndCommit(p: {
author: string; privKey: string; content: string;
attachmentBytes?: Uint8Array; attachmentMIME?: string;
replyTo?: string; quoteOf?: string;
}): Promise<string> {
const pub = await publishPost(p);
const tx = buildCreatePostTx({
from: p.author,
privKey: p.privKey,
postID: pub.post_id,
contentHash: pub.content_hash,
size: pub.size,
hostingRelay: pub.hosting_relay,
fee: pub.estimated_fee_ut,
replyTo: p.replyTo,
quoteOf: p.quoteOf,
});
await submitTx(tx);
return pub.post_id;
}
// ─── Engagement one-liners ───────────────────────────────────────────────
export async function likePost(p: { from: string; privKey: string; postID: string }) {
await submitTx(buildLikePostTx(p));
}
export async function unlikePost(p: { from: string; privKey: string; postID: string }) {
await submitTx(buildUnlikePostTx(p));
}
export async function deletePost(p: { from: string; privKey: string; postID: string }) {
await submitTx(buildDeletePostTx(p));
}
export async function followUser(p: { from: string; privKey: string; target: string }) {
await submitTx(buildFollowTx(p));
}
export async function unfollowUser(p: { from: string; privKey: string; target: string }) {
await submitTx(buildUnfollowTx(p));
}
/** URL for the post's attachment (image / video) — served by the hosting relay. */
export function attachmentURL(postID: string): string {
return `${getNodeUrl()}/feed/post/${postID}/attachment`;
}

113
desktop/src/lib/relay.ts Normal file
View File

@@ -0,0 +1,113 @@
// Relay mailbox client. Same wire format + semantics as
// client-app/lib/api.ts, narrowed to the calls the desktop actually
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
// recipient's device pubs for fan-out.
import { get, post, fetchDevices, getIdentity } from './api';
import {
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
} from './crypto';
export interface Envelope {
id: string;
sender_pub: string; // X25519 hex (per-device key)
/**
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
* Empty for legacy senders; when present, clients should use this as
* the conversation address so messages from any of the sender's
* linked devices roll into a single chat.
*/
sender_ed25519_pub: string;
recipient_pub: string;
nonce: string; // hex
ciphertext: string; // hex
timestamp: number; // unix seconds
}
// ─── Inbox ───────────────────────────────────────────────────────────────
interface InboxItemWire {
id: string;
sender_pub: string;
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
recipient_pub: string;
sent_at: number;
nonce: string; // base64 on the wire
ciphertext: string; // base64 on the wire
}
interface InboxResponseWire {
pub: string;
count: number;
has_more: boolean;
items: InboxItemWire[];
}
/**
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
* line up with what crypto.decryptMessage expects.
*/
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
const items = Array.isArray(resp?.items) ? resp.items : [];
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
timestamp: it.sent_at ?? 0,
}));
}
// ─── Broadcast ───────────────────────────────────────────────────────────
/**
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
* relays without ever reading the plaintext; only the recipient's
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
* fee-proof flows; current node ignores it when fee_ut = 0.
*/
export async function sendEnvelope(params: {
senderPub: string; // X25519 hex
recipientPub: string; // X25519 hex
nonce: string; // hex
ciphertext: string; // hex
senderEd25519Pub?: string; // optional
}): Promise<{ id: string; status: string }> {
const sentAt = Math.floor(Date.now() / 1000);
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
// Envelope.id is server-facing dedup key; first 16 bytes of the nonce
// are cryptographically random, reuse them to avoid another RNG call.
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
return post<{ id: string; status: string }>('/relay/broadcast', {
envelope: {
id,
sender_pub: params.senderPub,
recipient_pub: params.recipientPub,
sender_ed25519_pub: params.senderEd25519Pub ?? '',
fee_ut: 0,
fee_sig: null,
nonce: nonceB64,
ciphertext: ctB64,
sent_at: sentAt,
},
});
}
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
/**
* For a recipient identity, return every X25519 pub we should ship an
* envelope to. Device registry first, identity.x25519_pub as fall-back.
* Same helper lives in client-app — copied here rather than imported so
* the desktop build stays React-Native-free.
*/
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
const devs = await fetchDevices(masterPub);
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
const id = await getIdentity(masterPub);
return id?.x25519_pub ? [id.x25519_pub] : [];
}

159
desktop/src/lib/storage.ts Normal file
View File

@@ -0,0 +1,159 @@
// Persistence for the desktop shell.
//
// Two tiers, both different from the mobile client:
// * KeyFile lives in the OS keychain (via Electron safeStorage in main.ts,
// exposed as `window.dchain.keyfile`). We never touch it here from
// renderer code except through that IPC.
// * Everything else — settings, contacts, chat cache, "this device was
// registered" marker — lives in localStorage. It's synchronous,
// origin-isolated inside the renderer, and plenty durable for
// per-install state. A future polish could move chats to IndexedDB
// for streaming writes, but localStorage is fine for v2.2.0.
import type { KeyFile, NodeSettings, Contact, Message } from './types';
import type { DChainAPI } from '../../electron/preload';
declare global {
interface Window {
dchain: DChainAPI;
}
}
// ─── KeyFile (safeStorage-backed via IPC) ────────────────────────────────
//
// All keyfile operations go through window.dchain.keyfile — the preload
// script bridges them to Electron's safeStorage. If preload failed to
// load (dev misconfig, broken build), we surface a loud error rather
// than silently failing, since a missing keyfile layer means nothing
// else in the app can work.
function requireDchain() {
if (typeof window === 'undefined' || !window.dchain) {
throw new Error(
'window.dchain is not available — the Electron preload failed to ' +
'load. Check dist-electron/preload.js exists and that main.ts is ' +
'pointing at it.',
);
}
return window.dchain;
}
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await requireDchain().keyfile.load();
if (!raw) return null;
try {
return JSON.parse(raw) as KeyFile;
} catch {
return null;
}
}
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await requireDchain().keyfile.save(JSON.stringify(kf));
}
export async function deleteKeyFile(): Promise<void> {
await requireDchain().keyfile.delete();
}
// ─── Settings ─────────────────────────────────────────────────────────────
const SETTINGS_KEY = 'dchain_settings';
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8080',
contractId: '',
};
export function loadSettings(): NodeSettings {
const raw = localStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return DEFAULT_SETTINGS;
}
}
export function saveSettings(s: Partial<NodeSettings>): void {
const cur = loadSettings();
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...cur, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────
const CONTACTS_KEY = 'dchain_contacts';
export function loadContacts(): Contact[] {
const raw = localStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
try {
return JSON.parse(raw) as Contact[];
} catch {
return [];
}
}
export function saveContacts(list: Contact[]): void {
localStorage.setItem(CONTACTS_KEY, JSON.stringify(list));
}
export function upsertContact(c: Contact): void {
const cs = loadContacts();
const i = cs.findIndex(x => x.address === c.address);
if (i >= 0) cs[i] = c; else cs.push(c);
saveContacts(cs);
}
// ─── Chat cache (per-conversation, capped) ───────────────────────────────
const CHATS_PREFIX = 'dchain_chats_';
const CHAT_CAP = 500;
export function loadMessages(chatAddr: string): Message[] {
const raw = localStorage.getItem(CHATS_PREFIX + chatAddr);
if (!raw) return [];
try {
return JSON.parse(raw) as Message[];
} catch {
return [];
}
}
/**
* Append + persist. Deduplicates by id, trims to CHAT_CAP newest. Callers
* in the UI should prefer zustand's store.appendMessage for reactivity
* and call this from effects to flush to disk.
*/
export function appendMessage(chatAddr: string, m: Message): void {
const cur = loadMessages(chatAddr);
if (cur.some(x => x.id === m.id)) return;
cur.push(m);
const trimmed = cur.slice(-CHAT_CAP);
localStorage.setItem(CHATS_PREFIX + chatAddr, JSON.stringify(trimmed));
}
// ─── Multi-device bookkeeping (shared semantic with mobile client) ───────
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
export function isDeviceRegistered(): boolean {
return localStorage.getItem(DEVICE_REGISTERED_KEY) === '1';
}
export function markDeviceRegistered(): void {
localStorage.setItem(DEVICE_REGISTERED_KEY, '1');
}
export async function wipeAllLocalState(): Promise<void> {
await deleteKeyFile();
// Everything else in localStorage we control; iterate + clear our prefix.
const ours = [
SETTINGS_KEY, CONTACTS_KEY, DEVICE_REGISTERED_KEY,
];
for (const key of Object.keys(localStorage)) {
if (ours.includes(key) || key.startsWith('dchain_chats_')) {
localStorage.removeItem(key);
}
}
}

139
desktop/src/lib/store.ts Normal file
View File

@@ -0,0 +1,139 @@
// Zustand store — mirrors client-app/lib/store.ts, trimmed to what the
// desktop shell needs today. Holds identity, node settings, live chat
// state (contacts + per-chat messages + unread counters) and UI nav
// (current section + selected contact). Persistence lives in
// lib/storage.ts and hooks (auto-save on mutations).
import { create } from 'zustand';
import type { KeyFile, NodeSettings, Contact, Message } from './types';
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
/**
* FeedTab is the current filter applied to the Feed section.
* foryou — recommended (unfollowed) posts
* timeline — posts from authors we follow
* trending — top by engagement, last 24h
* hashtag — posts containing a specific tag
* author — wall of a single author
*/
export type FeedTab =
| { kind: 'foryou' }
| { kind: 'timeline' }
| { kind: 'trending' }
| { kind: 'hashtag'; tag: string }
| { kind: 'author'; pub: string };
/** Current Wallet selection — either the overview (history) or a tx. */
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;
settings: NodeSettings;
contacts: Contact[];
section: Section;
/** address of the currently-open conversation (mirrors mobile's route param). */
activeChat: string | null;
/** Messages keyed by contact.address. Each list is chronological (old → new). */
messages: Record<string, Message[]>;
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
unread: Record<string, number>;
setBooted: (v: boolean) => void;
setKeyFile: (k: KeyFile | null) => void;
setSettings: (s: Partial<NodeSettings>) => void;
setContacts: (cs: Contact[]) => void;
upsertContact: (c: Contact) => void;
setSection: (s: Section) => void;
setActiveChat: (addr: string | null) => void;
setMessages: (addr: string, msgs: Message[]) => void;
appendMessage: (addr: string, m: Message) => void;
bumpUnread: (addr: string) => void;
clearUnread: (addr: string) => void;
/** Feed state — persists across section switches within the session. */
feedTab: FeedTab;
feedSelectedPost: string | null;
setFeedTab: (t: FeedTab) => void;
setFeedSelectedPost: (id: string | null) => void;
/** 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) => ({
booted: false,
keyFile: null,
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
contacts: [],
section: 'messages',
activeChat: null,
messages: {},
unread: {},
setBooted: (v) => set({ booted: v }),
setKeyFile: (k) => set({ keyFile: k }),
setSettings: (s) => set((st) => ({ settings: { ...st.settings, ...s } })),
setContacts: (cs) => set({ contacts: cs }),
upsertContact: (c) => set((st) => {
const i = st.contacts.findIndex((x) => x.address === c.address);
if (i >= 0) {
const next = [...st.contacts];
next[i] = c;
return { contacts: next };
}
return { contacts: [...st.contacts, c] };
}),
setSection: (s) => set({ section: s }),
setActiveChat: (addr) => set({ activeChat: addr }),
setMessages: (addr, msgs) => set((st) => ({
messages: { ...st.messages, [addr]: msgs },
})),
appendMessage: (addr, m) => set((st) => {
const cur = st.messages[addr] ?? [];
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
// double-insert.
if (cur.some(x => x.id === m.id)) return {};
return { messages: { ...st.messages, [addr]: [...cur, m] } };
}),
bumpUnread: (addr) => set((st) => ({
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
})),
clearUnread: (addr) => set((st) => {
if (!(addr in st.unread)) return {};
const next = { ...st.unread };
delete next[addr];
return { unread: next };
}),
feedTab: { kind: 'foryou' },
feedSelectedPost: null,
setFeedTab: (t) => set({ feedTab: t, feedSelectedPost: null }),
setFeedSelectedPost: (id) => set({ feedSelectedPost: id }),
walletSel: { kind: 'overview' },
setWalletSel: (s) => set({ walletSel: s }),
settingsPage: 'node',
setSettingsPage: (p) => set({ settingsPage: p }),
selectedContact: null,
setSelectedContact: (addr) => set({ selectedContact: addr }),
}));

216
desktop/src/lib/tx.ts Normal file
View File

@@ -0,0 +1,216 @@
// Transaction builders + submission.
//
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
// Canonical bytes and wire format are identical to the mobile client —
// both talk to the same Go node, so any divergence here is a bug.
import { bytesToBase64, signBase64 } from './crypto';
import { post } from './api';
const MIN_TX_FEE = 1_000;
const _encoder = new TextEncoder();
/**
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
* does the same.
*/
export interface RawTx {
id: string;
type: string;
from: string;
to: string;
amount: number;
fee: number;
memo?: string;
payload: string;
signature: string;
timestamp: string;
}
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
return d.toISOString().replace('.000Z', 'Z');
}
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
/**
* Canonical bytes the node re-derives to verify tx.signature. Order of
* keys matches Go's field order in identity.txSignBytes — JS object
* literals preserve insertion order so JSON.stringify is enough.
*/
function canonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
return _encoder.encode(JSON.stringify({
id: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
payload: tx.payload,
timestamp: tx.timestamp,
}));
}
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
return post<{ id: string; status: string }>('/api/tx', tx);
}
// ─── Builders ────────────────────────────────────────────────────────────
export function buildTransferTx(p: {
from: string; to: string; amount: number; fee: number;
privKey: string; memo?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
const canon = canonicalBytes({
id, type: 'TRANSFER', from: p.from, to: p.to,
amount: p.amount, fee: p.fee, payload, timestamp,
});
return {
id, type: 'TRANSFER', from: p.from, to: p.to,
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
export function buildLinkDeviceTx(p: {
from: string; x25519Pub: string; deviceName: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({
x25519_pub_key: p.x25519Pub,
device_name: p.deviceName,
}));
const canon = canonicalBytes({
id, type: 'LINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
export function buildUnlinkDeviceTx(p: {
from: string; x25519Pub: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
const canon = canonicalBytes({
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* 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
* mobile client exposes from lib/api.ts; copied here to keep the two
* codebases independent until we factor into a shared package.
*/
export function humanizeTxError(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
const m = /→\s*({[^}]+})/.exec(raw);
if (m) {
try {
const parsed = JSON.parse(m[1]);
if (parsed.error) return parsed.error;
} catch { /* fall through */ }
}
return raw;
}

41
desktop/src/lib/types.ts Normal file
View File

@@ -0,0 +1,41 @@
// Mirrors client-app/lib/types.ts — keep wire formats identical so the
// two codebases can share a single node. Copied (not imported) on
// purpose: we want the desktop build isolated from React-Native deps,
// and the drift window between this file and the mobile one is small
// enough to hand-sync. When we consolidate into a shared package
// (post-v2.2.0), this file goes away.
export interface KeyFile {
pub_key: string; // hex Ed25519 public key (32 bytes)
priv_key: string; // hex Ed25519 secret key (64 bytes)
x25519_pub: string; // hex X25519 public key (32 bytes)
x25519_priv: string; // hex X25519 secret key (32 bytes)
}
export interface NodeSettings {
nodeUrl: string;
contractId: string;
apiToken?: string;
}
export interface Contact {
address: string; // Ed25519 master pub hex
x25519Pub: string; // legacy single-X25519; device registry superseded on v2.2.0
username?: string;
alias?: string;
addedAt: number; // unix ms
kind?: 'direct' | 'group';
unread?: number;
}
export interface Message {
id: string;
from: string; // X25519 hex (sender device)
text: string;
timestamp: number;
mine: boolean;
read: boolean;
edited: boolean;
attachment?: unknown;
replyTo?: { id: string; text: string; author: string };
}

24
desktop/src/main.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { App } from './App';
import { ErrorBoundary } from './ErrorBoundary';
// Last-resort fallback: if even rendering ErrorBoundary+App fails (say, a
// syntax error in some lazy import), paint a visible message into #root
// so the window isn't just black. window.onerror catches async errors
// that escape React's boundaries.
window.addEventListener('error', (e) => {
const root = document.getElementById('root');
if (root && !root.firstChild) {
root.innerHTML = `<pre style="color:#ff6b6b;background:#000;padding:20px;font-family:monospace;white-space:pre-wrap;">` +
`Fatal: ${String(e.error ?? e.message)}\n\n${e.error?.stack ?? ''}</pre>`;
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);

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,168 @@
// 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, submitTx, humanizeTxError,
} from '@/lib/tx';
import { upsertContact as persistContact } from '@/lib/storage';
import { getIdentity, 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 [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);
const c = {
address: req.requester_pub,
x25519Pub: identity?.x25519_pub ?? '',
username: identity?.nickname || undefined,
alias: undefined,
addedAt: Date.now(),
};
upsertContact(c);
persistContact(c);
} 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

@@ -0,0 +1,6 @@
// Contacts section.
// List pane: contact list + quick filter.
// Detail pane: selected contact's profile card + actions.
export { ContactsList } from './ContactsList';
export { ContactsDetail } from './ContactsDetail';

View File

@@ -0,0 +1,159 @@
// ComposeModal — new-post modal reachable from the Feed section header
// or the Ctrl/Cmd+N keybind. Minimal for alpha6: text-only, 4000 char
// limit, no attachments (those come with the image-picker + client-side
// scrub in rc1). Publish flow is identical to mobile — server returns
// content_hash + fee; client commits the matching CREATE_POST tx.
import React, { useEffect, useMemo, useState } from 'react';
import { useStore } from '@/lib/store';
import { publishAndCommit } from '@/lib/feed';
import { humanizeTxError } from '@/lib/tx';
const MAX_CONTENT_LEN = 4000;
export function ComposeModal({
onClose, onPublished,
}: {
onClose: () => void;
onPublished: () => void;
}): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [content, setContent] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Focus the textarea on mount; close on Escape.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !busy) onClose();
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { submit(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, busy]);
const bytes = useMemo(
() => new TextEncoder().encode(content).length,
[content],
);
const hashtags = useMemo(() => {
const m = content.match(/#[A-Za-z0-9_\u0400-\u04FF]{1,40}/g) ?? [];
return Array.from(new Set(m.map(t => t.slice(1).toLowerCase())));
}, [content]);
const canPublish = !busy && content.trim().length > 0 && bytes <= MAX_CONTENT_LEN;
const submit = async () => {
if (!keyFile || !canPublish) return;
setBusy(true); setError(null);
try {
await publishAndCommit({
author: keyFile.pub_key,
privKey: keyFile.priv_key,
content: content.trim(),
});
onPublished();
} catch (e) {
setError(humanizeTxError(e));
} finally {
setBusy(false);
}
};
return (
<div style={{
position: 'fixed', inset: 0, zIndex: 20,
background: 'rgba(0,0,0,0.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}} onClick={() => !busy && onClose()}>
<div
onClick={e => e.stopPropagation()}
style={{
width: '100%', maxWidth: 560,
background: '#0a0a0a',
borderRadius: 16, border: '1px solid #1f1f1f',
padding: 18,
}}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 10,
}}>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
New post
</div>
<button
onClick={onClose}
disabled={busy}
style={{
background: 'transparent', border: 'none',
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
}}
>×</button>
</div>
<textarea
autoFocus
value={content}
onChange={e => setContent(e.target.value)}
placeholder="What's happening?"
rows={6}
style={{
width: '100%', resize: 'vertical',
background: '#000', border: '1px solid #1f1f1f',
borderRadius: 10, padding: '12px',
color: '#fff', fontSize: 14, fontFamily: 'inherit',
outline: 'none', lineHeight: 1.5,
}}
/>
<div style={{
marginTop: 8, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', gap: 12,
}}>
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
{bytes.toLocaleString()} / {MAX_CONTENT_LEN.toLocaleString()} bytes
{hashtags.length > 0 && (
<> · <span style={{ color: '#1d9bf0' }}>
{hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
</span></>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
onClick={onClose}
disabled={busy}
style={{
padding: '8px 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={!canPublish}
style={{
padding: '8px 16px', borderRadius: 999,
border: 'none', background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700,
cursor: canPublish ? 'pointer' : 'default',
opacity: canPublish ? 1 : 0.5,
}}
>{busy ? '…' : 'Publish'}</button>
</div>
</div>
{error && (
<div style={{
marginTop: 12, padding: 10, borderRadius: 8,
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
}}>{error}</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
// FeedPane — the right pane. A two-column split: scrollable post list
// on the left (~430px), thread/post detail on the right.
import React, { useCallback, useEffect, useState } from 'react';
import { useStore, type FeedTab } from '@/lib/store';
import {
fetchForYou, fetchTrending, fetchTimeline, fetchHashtag, fetchAuthorPosts,
type FeedPostItem,
} from '@/lib/feed';
import { PostList } from './PostList';
import { PostDetail } from './PostDetail';
import { ComposeModal } from './ComposeModal';
export function FeedPane(): React.ReactElement {
const tab = useStore(s => s.feedTab);
const selected = useStore(s => s.feedSelectedPost);
const keyFile = useStore(s => s.keyFile);
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [loading, setLoading] = useState(true);
const [composing, setComposing] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const list = await fetchByTab(tab, keyFile?.pub_key);
setPosts(list);
} catch {
setPosts([]);
} finally {
setLoading(false);
}
}, [tab, keyFile]);
useEffect(() => { load(); }, [load]);
// Ctrl/Cmd+N → compose (scoped to Feed being active).
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'n') {
e.preventDefault();
setComposing(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return (
<div style={{ height: '100%', display: 'flex' }}>
<div style={{
width: 430, flexShrink: 0, borderRight: '1px solid #1f1f1f',
overflowY: 'auto', background: '#000',
}}>
{/* Header strip — tab label + compose CTA */}
<div style={{
position: 'sticky', top: 0, zIndex: 1,
padding: '10px 14px',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
borderBottom: '1px solid #1f1f1f',
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
}}>
<div style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
{titleFor(tab)}
</div>
<button
onClick={() => setComposing(true)}
style={{
padding: '6px 12px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 12, fontWeight: 700, cursor: 'pointer',
}}
title="Ctrl/Cmd+N"
>New post</button>
</div>
{loading ? (
<div style={{
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
}}>Loading</div>
) : posts.length === 0 ? (
<div style={{
padding: 40, textAlign: 'center', color: '#6a6a6a', fontSize: 13,
}}>No posts in this feed yet.</div>
) : (
<PostList posts={posts} activeID={selected} />
)}
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<PostDetail postID={selected} onDeleted={load} />
</div>
{composing && keyFile && (
<ComposeModal
onClose={() => setComposing(false)}
onPublished={() => {
setComposing(false);
// Re-pull so the new post shows up immediately.
setTimeout(load, 800);
}}
/>
)}
</div>
);
}
function titleFor(tab: FeedTab): string {
switch (tab.kind) {
case 'foryou': return 'For You';
case 'timeline': return 'Following';
case 'trending': return 'Trending 24h';
case 'hashtag': return `#${tab.tag}`;
case 'author': return 'Author wall';
}
}
async function fetchByTab(tab: FeedTab, selfPub: string | undefined): Promise<FeedPostItem[]> {
switch (tab.kind) {
case 'foryou':
if (!selfPub) return fetchTrending(24, 30);
return fetchForYou(selfPub, 30);
case 'timeline':
if (!selfPub) return [];
return fetchTimeline(selfPub, { limit: 30 });
case 'trending':
return fetchTrending(24, 30);
case 'hashtag':
return fetchHashtag(tab.tag, 30);
case 'author':
return fetchAuthorPosts(tab.pub, { limit: 30 });
}
}

View File

@@ -0,0 +1,146 @@
// FeedTabs — left-pane navigation for the Feed section.
//
// Four top-level tabs (For You / Following / Trending / Hashtag) plus
// an inline hashtag input that promotes to a dedicated tab when you
// press Enter. Sub-states — viewing a specific author's wall — are
// reachable by clicking an @handle in the post list; a breadcrumb
// appears at the top for back-navigation.
import React, { useState } from 'react';
import { useStore, type FeedTab } from '@/lib/store';
import { shortAddr } from '@/lib/crypto';
interface TabOption {
kind: FeedTab['kind'];
label: string;
hint: string;
}
const STATIC_TABS: TabOption[] = [
{ kind: 'foryou', label: 'For You', hint: 'Recommended posts from authors you don\'t follow yet' },
{ kind: 'timeline', label: 'Following', hint: 'Posts from authors you follow' },
{ kind: 'trending', label: 'Trending 24h', hint: 'Top posts by engagement in the last day' },
];
export function FeedTabs(): React.ReactElement {
const tab = useStore(s => s.feedTab);
const setTab = useStore(s => s.setFeedTab);
const [tagInput, setTagInput] = useState('');
// Sub-tab renderers reachable from list items (author wall, hashtag tab).
// Instead of hiding them in a dropdown we surface a breadcrumb so the
// operator can jump back out cleanly.
const breadcrumb = renderBreadcrumb(tab, setTab);
return (
<div style={{ padding: 10 }}>
{breadcrumb}
{STATIC_TABS.map(t => (
<TabRow
key={t.kind}
label={t.label}
hint={t.hint}
active={tab.kind === t.kind}
onClick={() => setTab({ kind: t.kind } as FeedTab)}
/>
))}
{/* Hashtag input — promotes to a tab on Enter. */}
<div style={{
marginTop: 14, padding: 10, borderRadius: 10,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 6,
}}>Hashtag</div>
<input
value={tagInput}
onChange={e => setTagInput(e.target.value.replace(/^#/, ''))}
onKeyDown={e => {
if (e.key === 'Enter' && tagInput.trim().length > 0) {
setTab({ kind: 'hashtag', tag: tagInput.trim() });
}
}}
placeholder="type a tag…"
style={{
width: '100%', background: '#000',
border: '1px solid #1f1f1f', borderRadius: 8,
padding: '8px 10px', color: '#fff', fontSize: 13,
fontFamily: 'monospace', outline: 'none',
}}
/>
</div>
</div>
);
}
function TabRow({
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, lineHeight: 1.4 }}>
{hint}
</div>
</div>
);
}
function renderBreadcrumb(tab: FeedTab, setTab: (t: FeedTab) => void): React.ReactNode | null {
if (tab.kind === 'hashtag') {
return (
<Breadcrumb
label={`#${tab.tag}`}
onClear={() => setTab({ kind: 'foryou' })}
/>
);
}
if (tab.kind === 'author') {
return (
<Breadcrumb
label={`Author: ${shortAddr(tab.pub, 6)}`}
onClear={() => setTab({ kind: 'foryou' })}
/>
);
}
return null;
}
function Breadcrumb({ label, onClear }: { label: string; onClear: () => void }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 10px', marginBottom: 10,
borderRadius: 8, background: '#0a0a0a',
border: '1px solid #1f1f1f',
}}>
<button
onClick={onClear}
style={{
background: 'transparent', border: 'none', color: '#8b8b8b',
cursor: 'pointer', padding: 0, fontSize: 14,
}}
></button>
<div style={{ color: '#fff', fontSize: 13, fontWeight: 700, flex: 1 }}>
{label}
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
// PostDetail — right-hand-inner pane showing a single post with full
// body, attachment, engagement bar, delete-if-mine.
//
// Side effects on mount:
// * bumps the view counter (off-chain)
// * refreshes stats for the liked-by-me badge
import React, { useCallback, useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import {
fetchPost, fetchStats, bumpView, likePost, unlikePost, deletePost,
attachmentURL, type FeedPostItem, type PostStats,
} from '@/lib/feed';
import { shortAddr } from '@/lib/crypto';
import { humanizeTxError } from '@/lib/tx';
interface Props {
postID: string | null;
onDeleted: () => void;
}
export function PostDetail({ postID, onDeleted }: Props): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const setTab = useStore(s => s.setFeedTab);
const [post, setPost] = useState<FeedPostItem | null>(null);
const [stats, setStats] = useState<PostStats | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Load + side-effects.
useEffect(() => {
if (!postID) { setPost(null); setStats(null); return; }
let cancelled = false;
setError(null);
fetchPost(postID).then(p => { if (!cancelled) setPost(p); }).catch(() => {});
fetchStats(postID, keyFile?.pub_key).then(s => { if (!cancelled) setStats(s); }).catch(() => {});
bumpView(postID);
return () => { cancelled = true; };
}, [postID, keyFile?.pub_key]);
const toggleLike = useCallback(async () => {
if (!keyFile || !post || busy) return;
const liked = stats?.liked_by_me ?? false;
setBusy(true); setError(null);
// Optimistic — roll back if the tx fails.
setStats(s => s ? { ...s, liked_by_me: !liked, likes: s.likes + (liked ? -1 : 1) } : s);
try {
if (liked) await unlikePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
else await likePost ({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
} catch (e) {
setStats(s => s ? { ...s, liked_by_me: liked, likes: s.likes + (liked ? 1 : -1) } : s);
setError(humanizeTxError(e));
} finally {
setBusy(false);
}
}, [keyFile, post, stats, busy]);
const onDelete = useCallback(async () => {
if (!keyFile || !post || busy) return;
if (!confirm('Delete this post? This cannot be undone.')) return;
setBusy(true); setError(null);
try {
await deletePost({ from: keyFile.pub_key, privKey: keyFile.priv_key, postID: post.post_id });
onDeleted();
useStore.getState().setFeedSelectedPost(null);
} catch (e) {
setError(humanizeTxError(e));
} finally {
setBusy(false);
}
}, [keyFile, post, busy, onDeleted]);
if (!postID) {
return (
<div style={{
height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
}}>
Select a post from the list on the left.
</div>
);
}
if (!post) {
return (
<div style={{
height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#6a6a6a', fontSize: 13,
}}>
Loading
</div>
);
}
const mine = !!keyFile && keyFile.pub_key === post.author;
return (
<div style={{
height: '100%', overflowY: 'auto',
padding: '18px 22px', background: '#000',
}}>
{/* Author line */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12,
}}>
<div style={{
width: 36, height: 36, borderRadius: 18,
background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>
{post.author.slice(0, 1).toUpperCase()}
</div>
<div style={{ flex: 1 }}>
<button
onClick={() => setTab({ kind: 'author', pub: post.author })}
style={{
background: 'transparent', border: 'none', padding: 0,
color: '#fff', fontWeight: 700, fontSize: 14, cursor: 'pointer',
fontFamily: 'monospace',
}}
>
{shortAddr(post.author, 8)}
</button>
<div style={{ color: '#8b8b8b', fontSize: 11 }}>
{new Date(post.created_at * 1000).toLocaleString()}
</div>
</div>
{mine && (
<button
onClick={onDelete}
disabled={busy}
style={{
padding: '6px 12px', borderRadius: 999,
border: '1px solid #3a2020', background: 'transparent',
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
}}
>Delete</button>
)}
</div>
{/* Body */}
<div className="selectable" style={{
color: '#fff', fontSize: 15, lineHeight: 1.55,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
marginBottom: 14,
}}>
{renderBody(post.content, setTab)}
</div>
{post.has_attachment && (
<img
src={attachmentURL(post.post_id)}
alt=""
style={{
maxWidth: '100%', maxHeight: 520, borderRadius: 14,
display: 'block', marginBottom: 14,
}}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
{/* Engagement bar */}
<div style={{
display: 'flex', gap: 16, alignItems: 'center',
padding: '10px 0', borderTop: '1px solid #1f1f1f',
}}>
<button
onClick={toggleLike}
disabled={busy || !keyFile}
style={{
background: 'transparent', border: 'none',
color: stats?.liked_by_me ? '#f4212e' : '#8b8b8b',
fontSize: 13, fontWeight: 700, cursor: keyFile ? 'pointer' : 'default',
display: 'flex', alignItems: 'center', gap: 6,
}}
>
{stats?.liked_by_me ? '❤' : '♡'} {stats?.likes ?? post.likes}
</button>
<div style={{ color: '#8b8b8b', fontSize: 13 }}>
👁 {stats?.views ?? post.views}
</div>
</div>
{error && (
<div style={{
marginTop: 12, padding: 10, borderRadius: 10,
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
}}>{error}</div>
)}
</div>
);
}
/**
* Render post body with #hashtags turned into clickable buttons that
* jump the feed tab. Basic — no markdown, no emoji polish yet.
*/
function renderBody(
text: string,
setTab: (t: { kind: 'hashtag'; tag: string }) => void,
): React.ReactNode[] {
const parts: React.ReactNode[] = [];
const re = /(#[A-Za-z0-9_\u0400-\u04FF]{1,40})/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
const tag = m[1].slice(1);
parts.push(
<button
key={`tag-${m.index}`}
onClick={() => setTab({ kind: 'hashtag', tag })}
style={{
color: '#1d9bf0', background: 'transparent', border: 'none',
padding: 0, font: 'inherit', cursor: 'pointer',
}}
>
{m[1]}
</button>,
);
last = m.index + m[1].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}

View File

@@ -0,0 +1,93 @@
// PostList — rows within the Feed middle column. Clicking a row sets
// the selected post in the store; the detail pane reacts.
import React from 'react';
import { useStore } from '@/lib/store';
import type { FeedPostItem } from '@/lib/feed';
import { shortAddr } from '@/lib/crypto';
interface Props {
posts: FeedPostItem[];
activeID: string | null;
}
export function PostList({ posts, activeID }: Props): React.ReactElement {
const select = useStore(s => s.setFeedSelectedPost);
return (
<div>
{posts.map(p => (
<PostRow
key={p.post_id}
post={p}
active={p.post_id === activeID}
onClick={() => select(p.post_id)}
/>
))}
</div>
);
}
function PostRow({ post, active, onClick }: {
post: FeedPostItem; active: boolean; onClick: () => void;
}) {
const author = shortAddr(post.author, 6);
return (
<div
onClick={onClick}
style={{
padding: '12px 14px', borderBottom: '1px solid #1f1f1f',
cursor: 'pointer',
background: active ? '#0a1a29' : '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={{
display: 'flex', alignItems: 'center', gap: 8,
color: '#8b8b8b', fontSize: 11, marginBottom: 4,
}}>
<span style={{ fontFamily: 'monospace', color: '#d0d0d0' }}>{author}</span>
<span>·</span>
<span>{formatRelative(post.created_at)}</span>
</div>
<div className="selectable" style={{
color: '#fff', fontSize: 13, lineHeight: 1.45,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
// Visual truncate; the detail pane shows the full thing.
display: '-webkit-box',
WebkitLineClamp: 4,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
} as React.CSSProperties}>
{post.content}
</div>
{post.has_attachment && (
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
🖼 attachment
</div>
)}
<div style={{
color: '#6a6a6a', fontSize: 11, marginTop: 6,
display: 'flex', gap: 12,
}}>
<span> {post.likes}</span>
<span>👁 {post.views}</span>
{post.hashtags && post.hashtags.length > 0 && (
<span style={{ color: '#1d9bf0' }}>
{post.hashtags.slice(0, 3).map(t => `#${t}`).join(' ')}
</span>
)}
</div>
</div>
);
}
function formatRelative(unixSec: number): string {
const diff = Math.floor(Date.now() / 1000) - unixSec;
if (diff < 60) return `${diff}s`;
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
const d = new Date(unixSec * 1000);
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
}

View File

@@ -0,0 +1,6 @@
// Feed section — re-exports into the Shell's PANES map. Real
// implementation lives in FeedTabs (left) + FeedPane (right); they
// share state via zustand's store.feedTab / store.feedSelectedPost.
export { FeedTabs as FeedList } from './FeedTabs';
export { FeedPane as FeedDetail } from './FeedPane';

View File

@@ -0,0 +1,165 @@
// ChatList — the Messages left-pane list of conversations.
// Rows sort by last-activity timestamp (most recent first); empty state
// renders as a full-height notice so the layout doesn't collapse.
import React from 'react';
import { useStore } from '@/lib/store';
import type { Contact, Message } from '@/lib/types';
import { shortAddr } from '@/lib/crypto';
export function ChatList(): React.ReactElement {
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const unread = useStore(s => s.unread);
const activeChat = useStore(s => s.activeChat);
const setActive = useStore(s => s.setActiveChat);
const lastOf = (c: Contact): Message | null => {
const list = messages[c.address];
return list && list.length > 0 ? list[list.length - 1] : null;
};
const sorted = [...contacts]
.map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
const kb = b.last ? b.last.timestamp : b.c.addedAt / 1000;
return kb - ka;
});
if (sorted.length === 0) {
return (
<div style={{
padding: 28, textAlign: 'center', color: '#8b8b8b', fontSize: 13,
}}>
No conversations yet. Messages from pairing devices or contacts
will appear here.
</div>
);
}
return (
<div>
{sorted.map(({ c, last }) => (
<ChatRow
key={c.address}
contact={c}
last={last}
unread={unread[c.address] ?? 0}
active={activeChat === c.address}
onClick={() => {
setActive(c.address);
useStore.getState().clearUnread(c.address);
}}
/>
))}
</div>
);
}
function ChatRow({
contact, last, unread, active, onClick,
}: {
contact: Contact;
last: Message | null;
unread: number;
active: boolean;
onClick: () => void;
}) {
const name = contact.alias || contact.username
? (contact.username ? `@${contact.username}` : contact.alias!)
: shortAddr(contact.address, 6);
const time = last
? formatWhen(last.timestamp)
: '';
return (
<div
onClick={onClick}
style={{
padding: '12px 14px',
borderBottom: '1px solid #1f1f1f',
background: active ? '#0a1a29' : 'transparent',
cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 12,
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
>
<Avatar name={name} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex', justifyContent: 'space-between',
alignItems: 'center', gap: 8,
}}>
<div style={{
color: '#fff', fontSize: 14, fontWeight: 700,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{name}
</div>
{time && (
<div style={{ color: '#6a6a6a', fontSize: 11, flexShrink: 0 }}>
{time}
</div>
)}
</div>
<div style={{
display: 'flex', alignItems: 'center', gap: 6, marginTop: 3,
}}>
<div style={{
flex: 1, color: '#8b8b8b', fontSize: 12,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{last ? preview(last) : 'Tap to start'}
</div>
{unread > 0 && (
<div style={{
minWidth: 18, height: 18, borderRadius: 9,
padding: '0 6px', background: '#1d9bf0', color: '#fff',
fontSize: 11, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{unread > 99 ? '99+' : unread}
</div>
)}
</div>
</div>
</div>
);
}
function Avatar({ name }: { name: string }) {
const letter = name.replace(/^@/, '').charAt(0).toUpperCase() || '?';
return (
<div style={{
width: 40, height: 40, borderRadius: 20, flexShrink: 0,
background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>{letter}</div>
);
}
function preview(m: Message): string {
const t = m.text.trim();
if (t.length === 0) return m.attachment ? '(attachment)' : '';
return t.length > 60 ? t.slice(0, 60) + '…' : t;
}
function formatWhen(unixSec: number): string {
const d = new Date(unixSec * 1000);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
const sameYear = d.getFullYear() === now.getFullYear();
return sameYear
? d.toLocaleDateString([], { month: 'short', day: 'numeric' })
: d.toLocaleDateString();
}

View File

@@ -0,0 +1,213 @@
// Conversation — the Messages right-pane showing one chat + composer.
//
// Responsibilities:
// * Render header with contact identity + close button.
// * Auto-scroll the message list to the bottom on new arrival.
// * Composer with Enter-to-send, Shift+Enter for newline.
// * Fan out every outgoing message across the recipient's device
// registry (falls back to legacy single-X25519 for pre-v2.2.0
// peers). One envelope per device; Promise.all, any failure
// rejects the batch so the user sees it.
import React, { useEffect, useRef, useState } from 'react';
import { useStore } from '@/lib/store';
import { encryptMessage, shortAddr } from '@/lib/crypto';
import { sendEnvelope, resolveRecipientKeys } from '@/lib/relay';
import { appendMessage as persist } from '@/lib/storage';
import type { Message } from '@/lib/types';
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 clearUnread = useStore(s => s.clearUnread);
const appendMsg = useStore(s => s.appendMessage);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Seeing a conversation drops its unread count.
useEffect(() => { clearUnread(address); }, [address, clearUnread]);
// Pin the scroll to the bottom on new messages. Only if the user
// is already near the bottom — don't yank them back if they're
// scrolling through older history.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (nearBottom) el.scrollTop = el.scrollHeight;
}, [messages.length]);
const isSelf = !!keyFile && keyFile.pub_key === address;
const send = async () => {
if (!keyFile || sending) return;
const body = text.trim();
if (!body) return;
setSending(true); setError(null);
try {
// Saved Messages path — the conversation address equals our own
// master pub. Mobile parity: append locally, skip the relay
// round-trip entirely (no fees, no ciphertext ever leaves).
if (!isSelf) {
const pubs = await resolveRecipientKeys(address);
if (pubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
await Promise.all(pubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage(
body, keyFile.x25519_priv, rpub,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
}));
}
const m: Message = {
id: `out-${Date.now()}${Math.floor(Math.random() * 1e6)}`,
from: keyFile.x25519_pub,
text: body,
timestamp: Math.floor(Date.now() / 1000),
mine: true,
read: false,
edited: false,
};
appendMsg(address, m);
persist(address, m);
setText('');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSending(false);
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
};
const name = contact?.username ? `@${contact.username}`
: contact?.alias
? contact.alias
: isSelf
? 'Saved Messages'
: shortAddr(address, 8);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* Header */}
<div style={{
padding: '12px 16px', borderBottom: '1px solid #1f1f1f',
display: 'flex', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 32, height: 32, borderRadius: 16,
background: isSelf ? '#1d9bf0' : '#1a1a1a',
color: '#fff', fontWeight: 700, fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()}
</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>
</div>
</div>
{/* Messages */}
<div
ref={scrollRef}
style={{ flex: 1, overflowY: 'auto', padding: '14px 16px' }}
>
{messages.length === 0 ? (
<div style={{
color: '#6a6a6a', fontSize: 13, textAlign: 'center',
marginTop: 40,
}}>
{isSelf
? 'Notes to self. Messages here stay on this device only.'
: 'No messages yet. Type below to send the first one.'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{messages.map(m => <Bubble key={m.id} message={m} />)}
</div>
)}
</div>
{/* Composer */}
<div style={{
borderTop: '1px solid #1f1f1f', padding: 12,
display: 'flex', gap: 10, alignItems: 'flex-end',
}}>
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Message…"
rows={1}
style={{
flex: 1, resize: 'none',
background: '#0a0a0a', border: '1px solid #1f1f1f',
borderRadius: 10, padding: '10px 12px',
color: '#fff', fontSize: 13, fontFamily: 'inherit',
outline: 'none', lineHeight: 1.4, maxHeight: 140,
}}
/>
<button
onClick={send}
disabled={sending || text.trim().length === 0}
style={{
padding: '10px 16px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff', fontSize: 13, fontWeight: 700,
cursor: sending || text.trim().length === 0 ? 'default' : 'pointer',
opacity: sending || text.trim().length === 0 ? 0.5 : 1,
}}
>
{sending ? '…' : 'Send'}
</button>
</div>
{error && (
<div style={{
padding: '6px 16px 10px', fontSize: 11, color: '#ff6b6b',
}}>
{error}
</div>
)}
</div>
);
}
function Bubble({ message }: { message: Message }) {
const mine = message.mine;
return (
<div style={{
display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start',
}}>
<div className="selectable" style={{
maxWidth: '70%',
padding: '8px 12px', borderRadius: 14,
background: mine ? '#1d9bf0' : '#1a1a1a',
color: mine ? '#fff' : '#e0e0e0',
fontSize: 13, lineHeight: 1.45,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{message.text}
</div>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
export function EmptyConversation(): React.ReactElement {
return (
<div style={{
height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 40, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
}}>
Select a conversation from the list,<br/>or wait for one to appear as messages arrive.
</div>
);
}

View File

@@ -0,0 +1,44 @@
// Messages section — full implementation. Left pane is the chat list;
// right pane mounts the active conversation or an empty-state.
import React, { useEffect, useRef } from 'react';
import { useStore } from '@/lib/store';
import { loadMessages } from '@/lib/storage';
import { useInboxPoll } from '@/hooks/useInboxPoll';
import { ChatList } from './ChatList';
import { Conversation } from './Conversation';
import { EmptyConversation } from './EmptyConversation';
export function MessagesList(): React.ReactElement {
// Warm cached messages from localStorage once per mount, so toggling
// back into Messages after visiting another section doesn't forget
// history. Zustand wins on conflict — once the hook has appended
// live messages, we don't overwrite them with stale disk snapshots.
const contacts = useStore(s => s.contacts);
const setMsgs = useStore(s => s.setMessages);
const hydrated = useRef(false);
// Kick off the inbox polling loop while Messages is mounted.
// Section-scoped for now so we don't pay the bandwidth cost when
// the user is in Feed / Wallet / etc.; a future alpha can promote
// it to the shell if we want notifications in other sections too.
useInboxPoll();
useEffect(() => {
if (hydrated.current) return;
hydrated.current = true;
const st = useStore.getState();
for (const c of contacts) {
if ((st.messages[c.address] ?? []).length > 0) continue;
const cached = loadMessages(c.address);
if (cached.length > 0) setMsgs(c.address, cached);
}
}, [contacts, setMsgs]);
return <ChatList />;
}
export function MessagesDetail(): React.ReactElement {
const activeChat = useStore(s => s.activeChat);
return activeChat ? <Conversation address={activeChat} /> : <EmptyConversation />;
}

View File

@@ -0,0 +1,218 @@
// 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: 16, borderRadius: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
textAlign: 'center',
}}>
<div style={{
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: 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 (
<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

@@ -0,0 +1,6 @@
// Settings section — two-pane.
// List pane: category nav (Node, Identity, Devices, About).
// Detail pane: selected category's content.
export { SettingsNav as SettingsList } from './SettingsNav';
export { SettingsDetail } from './SettingsDetail';

View File

@@ -0,0 +1,75 @@
// 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, { 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] = 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 () => {
try { await navigator.clipboard.writeText(keyFile.pub_key); setCopied(true); }
catch { /* ignore */ }
setTimeout(() => setCopied(false), 1400);
};
return (
<Backdrop onClose={onClose}>
<div style={{
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<Header title="Receive" onClose={onClose} busy={false} />
<div style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 1.5 }}>
Share your public key anyone can send you tokens or add you as
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: 10, padding: 14, borderRadius: 10,
background: '#000', border: '1px solid #1f1f1f',
color: '#fff', fontFamily: 'monospace', fontSize: 12,
wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.pub_key}
</div>
<div style={{
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
}}>
<button
onClick={copy}
style={primaryBtnStyle(false)}
>{copied ? 'Copied!' : 'Copy'}</button>
</div>
</div>
</Backdrop>
);
}

View File

@@ -0,0 +1,219 @@
// SendModal — a focused little dialog for Transfer tx's. Accepts a
// hex pub, DC-address, or @username and resolves to the Ed25519 pub
// before submitting. Validates amount against balance + min fee.
import React, { useEffect, useMemo, useState } from 'react';
import { useStore } from '@/lib/store';
import { getBalance, resolveAccount } from '@/lib/api';
import { buildTransferTx, submitTx, humanizeTxError } from '@/lib/tx';
const MIN_FEE_UT = 1_000;
function parseAmountT(s: string): number | null {
const n = parseFloat(s);
if (!Number.isFinite(n) || n <= 0) return null;
return Math.round(n * 1_000_000);
}
export function SendModal({
onClose, onSent,
}: {
onClose: () => void;
onSent: () => void;
}): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [toInput, setToInput] = useState('');
const [amount, setAmount] = useState('');
const [memo, setMemo] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [balance, setBalance] = useState<number | null>(null);
useEffect(() => {
if (!keyFile) return;
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
}, [keyFile]);
const amountUT = useMemo(() => parseAmountT(amount), [amount]);
const totalUT = amountUT === null ? null : amountUT + MIN_FEE_UT;
const canSend = !!keyFile && !busy && amountUT !== null
&& balance !== null && totalUT !== null && balance >= totalUT
&& toInput.trim().length > 0;
const submit = async () => {
if (!keyFile || !canSend || amountUT === null) return;
setBusy(true); setErr(null);
try {
const to = await resolveAccount(toInput);
if (!to) throw new Error('Can\'t resolve recipient');
if (to === keyFile.pub_key) throw new Error('Refusing self-transfer');
const tx = buildTransferTx({
from: keyFile.pub_key,
to,
amount: amountUT,
fee: MIN_FEE_UT,
privKey: keyFile.priv_key,
memo: memo.trim() || undefined,
});
await submitTx(tx);
onSent();
onClose();
} catch (e) {
setErr(humanizeTxError(e));
} finally {
setBusy(false);
}
};
return (
<Backdrop onClose={busy ? () => {} : onClose}>
<div style={{
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<Header title="Send" onClose={onClose} busy={busy} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Field label="To" hint="@username, DC-address or hex pubkey">
<input
value={toInput}
onChange={e => setToInput(e.target.value)}
placeholder="@alice or DC… or <hex>"
spellCheck={false}
autoFocus
style={inputStyle}
/>
</Field>
<Field label="Amount (T)">
<input
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="0.0"
inputMode="decimal"
style={inputStyle}
/>
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>
Balance: {balance === null ? '…' : `${(balance / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 })} T`}
{amountUT !== null && (
<> · Fee: {(MIN_FEE_UT / 1_000_000).toFixed(6)} T</>
)}
</div>
</Field>
<Field label="Memo (optional)">
<input
value={memo}
onChange={e => setMemo(e.target.value)}
placeholder="Invoice #42"
style={inputStyle}
/>
</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={secondaryBtnStyle(busy)}
>Cancel</button>
<button
onClick={submit}
disabled={!canSend}
style={primaryBtnStyle(!canSend)}
>{busy ? '…' : 'Send'}</button>
</div>
</div>
</Backdrop>
);
}
// ─── Shared modal primitives used by Send/Receive ────────────────────────
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 Field({ label, hint, children }: {
label: string; hint?: string; children: React.ReactNode;
}) {
return (
<div>
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 6,
}}>{label}</div>
{children}
{hint && (
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 4 }}>{hint}</div>
)}
</div>
);
}
const inputStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box',
background: '#000', border: '1px solid #1f1f1f',
borderRadius: 8, padding: '10px 12px',
color: '#fff', fontSize: 13, fontFamily: 'inherit',
outline: 'none',
};
const primaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
padding: '9px 18px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
});
const secondaryBtnStyle = (disabled: boolean): React.CSSProperties => ({
padding: '9px 14px', borderRadius: 999,
background: 'transparent', border: '1px solid #1f1f1f',
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
cursor: disabled ? 'default' : 'pointer',
});
export { Backdrop, Header, Field, inputStyle, primaryBtnStyle, secondaryBtnStyle };

View File

@@ -0,0 +1,147 @@
// WalletDetailPane — right pane of the Wallet section. Either the
// selected tx's detail or a placeholder when nothing is selected.
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getTxDetail, type TxDetail } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
function formatT(ut: number | string): string {
const n = typeof ut === 'string' ? parseInt(ut, 10) : ut;
if (!Number.isFinite(n)) return '—';
return (n / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
}
export function WalletDetailPane(): React.ReactElement {
const sel = useStore(s => s.walletSel);
const keyFile = useStore(s => s.keyFile);
const [tx, setTx] = useState<TxDetail | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (sel.kind !== 'tx') { setTx(null); return; }
let cancelled = false;
setLoading(true);
getTxDetail(sel.id)
.then(t => { if (!cancelled) setTx(t); })
.catch(() => { if (!cancelled) setTx(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [sel]);
if (sel.kind !== 'tx') {
return (
<div style={{
height: '100%', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
}}>
Pick a transaction from the list on the left to see its details.
</div>
);
}
if (loading) return <Placeholder note="Loading…" />;
if (!tx) return <Placeholder note="Transaction not found on this node." />;
const outgoing = !!keyFile && tx.from === keyFile.pub_key;
const amountUT = tx.amount_ut;
const amountColor = amountUT === 0 ? '#8b8b8b'
: outgoing ? '#f0b35a' : '#3ba55d';
return (
<div style={{
height: '100%', overflowY: 'auto',
padding: '20px 24px', background: '#000',
}}>
<div style={{ color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase' }}>
{tx.type.replace(/_/g, ' ')}
</div>
<div style={{
color: amountColor, fontSize: 30, fontWeight: 800, marginTop: 4,
}}>
{amountUT === 0 ? '—' : `${outgoing ? '' : '+'}${formatT(amountUT)} T`}
</div>
{tx.memo && (
<div style={{ color: '#e0e0e0', fontSize: 13, marginTop: 6, fontStyle: 'italic' }}>
{tx.memo}
</div>
)}
<div style={{
marginTop: 22, display: 'grid',
gridTemplateColumns: 'minmax(120px, auto) 1fr', rowGap: 10, columnGap: 20,
}}>
<Cell label="ID">{tx.id}</Cell>
<Cell label="From">{tx.from_addr ?? shortAddr(tx.from, 8)}</Cell>
{tx.to && <Cell label="To">{tx.to_addr ?? shortAddr(tx.to, 8)}</Cell>}
<Cell label="Amount">{formatT(tx.amount_ut)} T</Cell>
<Cell label="Fee">{formatT(tx.fee_ut)} T</Cell>
<Cell label="Time">{new Date(tx.time).toLocaleString()}</Cell>
<Cell label="Block">#{tx.block_index} · {shortAddr(tx.block_hash, 8)}</Cell>
{typeof tx.gas_used === 'number' && tx.gas_used > 0 && (
<Cell label="Gas used">{tx.gas_used.toLocaleString()}</Cell>
)}
</div>
{Boolean(tx.payload) && (
<details style={{
marginTop: 22, background: '#0a0a0a',
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
}}>
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
Payload
</summary>
<pre className="selectable" style={{
marginTop: 8, color: '#d0d0d0', fontSize: 11, lineHeight: 1.5,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
}}>
{JSON.stringify(tx.payload, null, 2)}
</pre>
</details>
)}
{tx.payload_hex && (
<details style={{
marginTop: 10, background: '#0a0a0a',
borderRadius: 10, border: '1px solid #1f1f1f', padding: 12,
}}>
<summary style={{ cursor: 'pointer', color: '#8b8b8b', fontSize: 12, fontWeight: 700 }}>
Payload (hex)
</summary>
<div className="selectable" style={{
marginTop: 8, color: '#d0d0d0', fontSize: 11, fontFamily: 'monospace',
wordBreak: 'break-all',
}}>
{tx.payload_hex}
</div>
</details>
)}
</div>
);
}
function Cell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<>
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase',
}}>{label}</div>
<div className="selectable" style={{
color: '#fff', fontSize: 13, fontFamily: 'monospace',
wordBreak: 'break-all',
}}>{children}</div>
</>
);
}
function Placeholder({ note }: { note: string }) {
return (
<div style={{
height: '100%', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#6a6a6a', fontSize: 13, padding: 40,
}}>{note}</div>
);
}

View File

@@ -0,0 +1,222 @@
// WalletOverview — Wallet section left pane.
//
// Top card: address + balance + primary actions (Send, Receive).
// Bottom list: tx history pulled from /api/address/{pub}?limit=100,
// clicking a row sets store.walletSel = { kind: 'tx', id } so the
// detail pane renders it.
import React, { useCallback, useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getBalance, getTxHistory, type TxRow } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { SendModal } from './SendModal';
import { ReceiveModal } from './ReceiveModal';
function formatT(ut: number): string {
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 6 });
}
export function WalletOverview(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const sel = useStore(s => s.walletSel);
const setSel = useStore(s => s.setWalletSel);
const [balance, setBalance] = useState<number | null>(null);
const [txs, setTxs] = useState<TxRow[]>([]);
const [loading, setLoading] = useState(true);
const [sendOpen, setSendOpen] = useState(false);
const [receiveOpen, setReceiveOpen] = useState(false);
const load = useCallback(async () => {
if (!keyFile) return;
setLoading(true);
try {
const [bal, rows] = await Promise.all([
getBalance(keyFile.pub_key),
getTxHistory(keyFile.pub_key, 100),
]);
setBalance(bal);
setTxs(rows);
} finally {
setLoading(false);
}
}, [keyFile]);
useEffect(() => { load(); }, [load]);
if (!keyFile) return <></>;
return (
<div style={{ padding: 14 }}>
{/* Account card */}
<div style={{
borderRadius: 14, padding: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase',
}}>Balance</div>
<div style={{ color: '#fff', fontSize: 26, fontWeight: 800, marginTop: 4 }}>
{balance === null ? '—' : `${formatT(balance)} T`}
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 8, wordBreak: 'break-all',
}}>
{keyFile.pub_key}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<PrimaryBtn label="Send" onClick={() => setSendOpen(true)} />
<SecondaryBtn label="Receive" onClick={() => setReceiveOpen(true)} />
<SecondaryBtn label="Refresh" onClick={load} />
</div>
</div>
{/* TX list */}
<div style={{ marginTop: 16 }}>
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase',
padding: '0 4px 6px',
}}>History</div>
{loading ? (
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
Loading
</div>
) : txs.length === 0 ? (
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 20, textAlign: 'center' }}>
No transactions yet.
</div>
) : (
<div style={{
borderRadius: 12, overflow: 'hidden',
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
{txs.map((t, i) => (
<TxRowView
key={t.id}
tx={t}
me={keyFile.pub_key}
active={sel.kind === 'tx' && sel.id === t.id}
first={i === 0}
onClick={() => setSel({ kind: 'tx', id: t.id })}
/>
))}
</div>
)}
</div>
{sendOpen && <SendModal onClose={() => setSendOpen(false)} onSent={load} />}
{receiveOpen && <ReceiveModal onClose={() => setReceiveOpen(false)} />}
</div>
);
}
function TxRowView({
tx, me, active, first, onClick,
}: {
tx: TxRow; me: string; active: boolean; first: boolean; onClick: () => void;
}) {
const outgoing = tx.from === me;
const amountColor = tx.amount_ut === 0 ? '#8b8b8b'
: outgoing ? '#f0b35a' : '#3ba55d';
const sign = tx.amount_ut === 0 ? '' : outgoing ? '' : '+';
const counterparty = outgoing ? (tx.to_addr || tx.to || '—')
: (tx.from_addr || tx.from);
return (
<div
onClick={onClick}
style={{
padding: '10px 12px',
borderTop: first ? undefined : '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 = '#111'; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
color: '#fff', fontSize: 13, fontWeight: 600,
}}>
{prettyType(tx.type)}
{tx.memo && (
<span style={{ color: '#6a6a6a', fontSize: 11, fontWeight: 400 }}>
· {tx.memo.slice(0, 30)}{tx.memo.length > 30 ? '…' : ''}
</span>
)}
</div>
<div style={{
color: '#8b8b8b', fontSize: 11,
fontFamily: 'monospace', marginTop: 2,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{outgoing ? 'to ' : 'from '}
{counterparty.startsWith('DC') ? counterparty : shortAddr(counterparty, 6)}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ color: amountColor, fontSize: 13, fontWeight: 700 }}>
{tx.amount_ut === 0 ? '' : `${sign}${formatT(tx.amount_ut)} T`}
</div>
<div style={{ color: '#6a6a6a', fontSize: 10 }}>
{tx.time ? new Date(tx.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
</div>
</div>
</div>
);
}
function prettyType(t: string): string {
const map: Record<string, string> = {
TRANSFER: 'Transfer',
RELAY_PROOF: 'Relay fee',
REGISTER_RELAY: 'Register relay',
HEARTBEAT: 'Heartbeat',
CONTACT_REQUEST: 'Contact request',
ACCEPT_CONTACT: 'Contact accepted',
BLOCK_CONTACT: 'Contact blocked',
REGISTER_KEY: 'Identity registered',
LINK_DEVICE: 'Device linked',
UNLINK_DEVICE: 'Device unlinked',
CREATE_POST: 'Post published',
DELETE_POST: 'Post deleted',
FOLLOW: 'Follow',
UNFOLLOW: 'Unfollow',
LIKE_POST: 'Like',
UNLIKE_POST: 'Unlike',
BLOCK_REWARD: 'Block reward',
};
return map[t] ?? t;
}
function PrimaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
padding: '8px 16px', borderRadius: 999, border: 'none',
background: '#1d9bf0', color: '#fff',
fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>{label}</button>
);
}
function SecondaryBtn({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
padding: '8px 14px', borderRadius: 999,
background: 'transparent', border: '1px solid #1f1f1f',
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>{label}</button>
);
}

View File

@@ -0,0 +1,9 @@
// Wallet section — full implementation.
//
// List pane: account card (address + balance + Send/Receive buttons)
// + transaction history, grouped by day.
// Detail pane: picked tx — full block/fee/payload details, or a
// prompt to pick one on empty selection.
export { WalletOverview as WalletList } from './WalletOverview';
export { WalletDetailPane as WalletDetail } from './WalletDetailPane';

View File

@@ -0,0 +1,122 @@
// NavBar — the left 72px rail. Six icons, one for each section.
// The active icon is drawn in accent blue; everything else is mid-grey.
// Keyboard shortcuts (Ctrl/Cmd+1..5) are registered in useKeybinds().
import React, { useEffect } from 'react';
import { useStore, type Section } from '@/lib/store';
interface Tab {
key: Section;
label: string;
icon: string;
}
// Icons are SF Symbol-ish monochrome glyphs from lucide's set, inlined as
// SVGs to avoid another runtime dependency at this stage. If the set
// grows, we'll move to a lucide-react import.
const TABS: Tab[] = [
{ key: 'messages', label: 'Messages', icon: 'chat' },
{ key: 'feed', label: 'Feed', icon: 'feed' },
{ key: 'wallet', label: 'Wallet', icon: 'wallet' },
{ key: 'contacts', label: 'Contacts', icon: 'contacts' },
{ key: 'settings', label: 'Settings', icon: 'cog' },
];
export function NavBar(): React.ReactElement {
const section = useStore(s => s.section);
const setSection = useStore(s => s.setSection);
// Global keybinds for section switch.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
if (!mod) return;
const i = Number(e.key) - 1;
if (Number.isInteger(i) && i >= 0 && i < TABS.length) {
e.preventDefault();
setSection(TABS[i].key);
} else if (e.key === ',' || e.key === '.') {
// Cmd+, is standard for Settings on macOS
e.preventDefault();
setSection('settings');
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [setSection]);
return (
<nav style={{
width: 72, flexShrink: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center',
padding: '16px 0 10px',
borderRight: '1px solid #1f1f1f',
background: '#000',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{TABS.map(t => (
<NavItem
key={t.key}
label={t.label}
icon={t.icon}
active={section === t.key}
onClick={() => setSection(t.key)}
/>
))}
</div>
<div style={{ flex: 1 }} />
<NavItem
key="profile"
label="Profile"
icon="user"
active={section === 'profile'}
onClick={() => setSection('profile')}
/>
</nav>
);
}
function NavItem({
label, icon, active, onClick,
}: { label: string; icon: string; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
title={label}
style={{
width: 56, height: 52, borderRadius: 12,
background: active ? '#0a1a29' : 'transparent',
color: active ? '#1d9bf0' : '#8b8b8b',
border: 'none', cursor: 'pointer',
display: 'flex', flexDirection: 'column', alignItems: 'center',
justifyContent: 'center', gap: 2,
}}
>
<NavGlyph icon={icon} color={active ? '#1d9bf0' : '#8b8b8b'} />
<span style={{ fontSize: 10, fontWeight: 600, letterSpacing: 0.2 }}>
{label}
</span>
</button>
);
}
function NavGlyph({ icon, color }: { icon: string; color: string }) {
const d = GLYPHS[icon] ?? GLYPHS.cog;
return (
<svg width={20} height={20} viewBox="0 0 24 24" fill="none"
stroke={color} strokeWidth={1.8}
strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
}
const GLYPHS: Record<string, string> = {
// Minimal lucide-style single-path icons.
chat: 'M21 12a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
feed: 'M4 11a9 9 0 0 1 9 9 M4 4a16 16 0 0 1 16 16 M5 19a2 2 0 1 0 0 .01',
wallet: 'M20 12V8H4a2 2 0 0 1 0-4h12 M4 6v12a2 2 0 0 0 2 2h14v-4 M18 12a2 2 0 1 0 0 4h4v-4h-4z',
contacts: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
cog: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z',
user: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
};

View File

@@ -0,0 +1,40 @@
// Simple inner placeholder used by every section until real content
// lands. Shows a title + a short note; `centered` flips the layout into
// a vertically centred message for empty-detail panes.
import React from 'react';
interface Props {
title: string;
note?: string;
centered?: boolean;
}
export function SectionPlaceholder({ title, note, centered }: Props): React.ReactElement {
if (centered) {
return (
<div style={{
height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 32,
}}>
<div style={{ textAlign: 'center', maxWidth: 360 }}>
<div style={{ color: '#d0d0d0', fontSize: 16, fontWeight: 700 }}>{title}</div>
{note && (
<div style={{ color: '#6a6a6a', fontSize: 13, lineHeight: 1.5, marginTop: 6 }}>
{note}
</div>
)}
</div>
</div>
);
}
return (
<div style={{ padding: 14 }}>
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700 }}>{title}</div>
{note && (
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 6 }}>{note}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,76 @@
// Shell — the permanent 3-panel chrome around every non-auth screen.
//
// Layout:
// ┌──────────────────────────────────────────────────────────────┐
// │ DChain [ minimise | maximise | × ] │ 32px titlebar (drag region)
// ├──────┬───────────────────┬─────────────────────────────────────┤
// │ │ │ │
// │ nav │ list │ detail │
// │ 72px │ ~340px fixed │ flex 1 │
// │ │ │ │
// ├──────┴───────────────────┴─────────────────────────────────────┤
// │ ● online · height 10942 · fee 1000 µT │ 28px status bar
// └──────────────────────────────────────────────────────────────┘
//
// Current section is driven by store.section. NavBar flips it. List +
// Detail are each decided by the section, composed from the appropriate
// module under sections/. Until sections ship their real content, they
// render simple placeholders so we can walk through the shell end-to-end.
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 { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
import { ContactsList, ContactsDetail } from '@/sections/contacts';
import { SettingsList, SettingsDetail } from '@/sections/settings';
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',
height: '100%', background: '#000',
}}>
<TitleBar />
<div style={{
flex: 1, display: 'flex', overflow: 'hidden',
borderTop: '1px solid #1f1f1f',
}}>
<NavBar />
<div style={{
width: 340, flexShrink: 0,
borderRight: '1px solid #1f1f1f',
overflowY: 'auto',
}}>
<List />
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Detail />
</div>
</div>
<UpdateBanner />
<StatusBar />
</div>
);
}
const PANES: Record<
Section,
{ List: React.ComponentType; Detail: React.ComponentType }
> = {
messages: { List: MessagesList, Detail: MessagesDetail },
feed: { List: FeedList, Detail: FeedDetail },
wallet: { List: WalletList, Detail: WalletDetail },
contacts: { List: ContactsList, Detail: ContactsDetail },
settings: { List: SettingsList, Detail: SettingsDetail },
profile: { List: ProfileList, Detail: ProfileDetail },
};

View File

@@ -0,0 +1,75 @@
// StatusBar — the 28px strip at the bottom. Surfaces the three bits of
// information an operator tends to want at a glance:
// * Connection state to the configured node (poll /api/netstats).
// * Current chain height (last successful poll).
// * Node URL (short, hover-tooltip for the full thing).
//
// Poll interval is 5 seconds — low enough to feel live, cheap enough
// that even a free-tier node won't notice.
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getNetStats, getNodeUrl, onNodeUrlChange } from '@/lib/api';
type ConnState = 'online' | 'connecting' | 'offline';
export function StatusBar(): React.ReactElement {
const nodeUrl = useStore(s => s.settings.nodeUrl);
const [conn, setConn] = useState<ConnState>('connecting');
const [height, setHeight] = useState<number | null>(null);
const [url, setUrl] = useState<string>(getNodeUrl());
useEffect(() => onNodeUrlChange(setUrl), []);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const s = await getNetStats();
if (cancelled) return;
setConn('online');
setHeight(s.total_blocks);
} catch {
if (cancelled) return;
setConn('offline');
}
};
setConn('connecting');
poll();
const t = setInterval(poll, 5_000);
return () => {
cancelled = true;
clearInterval(t);
};
}, [nodeUrl]);
const dot = conn === 'online' ? '#3ba55d' :
conn === 'connecting' ? '#f0b35a' :
'#f4212e';
const shortUrl = url
.replace(/^https?:\/\//, '')
.replace(/\/$/, '');
return (
<footer style={{
height: 28, minHeight: 28,
background: '#000',
borderTop: '1px solid #1f1f1f',
display: 'flex', alignItems: 'center', padding: '0 16px', gap: 14,
fontSize: 11, color: '#8b8b8b',
}}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span style={{
width: 8, height: 8, borderRadius: 4, background: dot,
}} />
{conn}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span title={url}>{shortUrl}</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>height {height ?? '—'}</span>
<div style={{ flex: 1 }} />
</footer>
);
}

View File

@@ -0,0 +1,42 @@
// Titlebar — draws the top 32px strip as a drag region so the user can
// move the window even though we set frame: false in main.ts.
//
// On macOS the native traffic lights show through because main.ts uses
// `titleBarStyle: 'hiddenInset'`. On Windows, `titleBarOverlay` renders
// close/min/max in their native style over our bar. On Linux we paint
// close + min + max ourselves (below).
import React from 'react';
const DRAG: React.CSSProperties = {
// @ts-expect-error webkit-only
WebkitAppRegion: 'drag',
};
const NO_DRAG: React.CSSProperties = {
// @ts-expect-error webkit-only
WebkitAppRegion: 'no-drag',
};
export function TitleBar(): React.ReactElement {
return (
<div
style={{
...DRAG,
height: 32,
minHeight: 32,
display: 'flex',
alignItems: 'center',
paddingLeft: 80, // leaves room for macOS traffic-lights area
paddingRight: 12,
background: '#000',
color: '#d0d0d0',
fontSize: 12,
fontWeight: 600,
letterSpacing: 0.2,
}}
>
<span style={{ opacity: 0.6 }}>DChain</span>
<div style={{ flex: 1, ...NO_DRAG }} />
</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>
);
}

12
desktop/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
// Vite auto-surfaces VITE_* env vars on import.meta.env. The Electron main
// process sets VITE_DEV_SERVER_URL separately at spawn time, so we only
// need to tell TS about the one variable the renderer reads.
interface ImportMetaEnv {
readonly VITE_DEV_SERVER_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

26
desktop/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

22
desktop/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
// Vite config for the renderer process. Electron main/preload build
// separately via `tsc -p electron/tsconfig.json`.
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 5173,
strictPort: true,
},
});

232
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,232 @@
# Roadmap
Living doc — «куда идём дальше». Два активных вектора: **v2.2.0 multi-device**
и **desktop Electron client**. Всё что tentative — помечено `?`; всё что
решено и принято к работе — без знаков.
---
## v2.2.0 — Multi-device (per-device X25519, on-chain device registry)
### Проблема
Сейчас у пользователя одна пара X25519 (хранится в `keyFile`), и relay-mailbox —
queue с DELETE-после-чтения. При двух устройствах:
- Конверт забирает то устройство, что первое дёрнуло `/relay/inbox` — второе
теряет сообщение навсегда.
- История чатов per-device, не синхронизируется (`AsyncStorage` на каждом
устройстве свой).
On-chain часть (posts, follows, tx, wallet) работает нормально — оба устройства
читают один чейн и видят одно и то же. Чинить надо только мессенджер.
### Решение — путь А (Signal-style): X25519 на устройство + device registry
1. **Master identity = Ed25519** (как сейчас). Подписывает tx, владеет
балансом, — один на всю identity.
2. **X25519 — per-device**. Каждое устройство при первом старте генерит
свою пару. Relay-mailbox остаётся queue'ом, но адресуется по
**устройству**, не по identity.
3. **Device registry on-chain**: новые tx-типы `LINK_DEVICE` / `UNLINK_DEVICE`,
подписываются master Ed25519, публикуют/отзывают связку
`(master_pub → device_x25519_pub, device_name)`.
4. **Sender-side fan-out**: при отправке — один envelope на каждое
активное устройство получателя. Мессадж → N конвертов.
5. **Revoke**: master подписывает `UNLINK_DEVICE`, клиент, обнаруживший
свой `x25519_pub` в revoked — wipe'ит локальную БД (zeroize master
Ed25519 + delete chats).
### План работ (PR-by-PR)
#### PR #1 — Chain-side (backend) — _v2.2.0-alpha1_
- [ ] `blockchain/types.go`: `EventLinkDevice`, `EventUnlinkDevice`
+ payload structs `LinkDevicePayload`, `UnlinkDevicePayload`.
- [ ] `blockchain/chain.go`: applyTx-ветки, state persistence:
- `prefixDevice + x25519_pub → DeviceRecord{owner, name, added_at, revoked_at?}`.
- Обратный индекс `prefixDevicesByOwner + master_pub → [x25519_pub, …]`.
- [ ] Константа `MaxDevicesPerOwner = 10`.
- [ ] Validation:
- Tx подписана master'ом; подпись matches `payload.Owner`.
- `x25519_pub` уникален в registry (не занят другим master'ом).
- `device_name` ≤ 64 символов; printable.
- При `UNLINK_DEVICE`: запись существует, owner совпадает с signer'ом.
- [ ] HTTP: `GET /api/devices/{master_pub}``[{x25519_pub, device_name, added_at}, …]`,
фильтрует revoked.
- [ ] В `/api/identity/{pub}` добавить поле `device_count`.
- [ ] Unit-тесты:
- Happy path link + list.
- Unlink → list сократился.
- Лимит `MaxDevicesPerOwner`.
- Чужая подпись → reject.
- Duplicate x25519_pub → reject.
- [ ] Swagger + `docs/api/devices.md`.
Совместимость: старые клиенты, которые не обновили identity, продолжают
работать по old-schema (envelope на published X25519 из identity). Sender
fall-back'ит на identity.x25519, если `device_count == 0`.
#### PR #2 — Client fan-out (mobile) — _v2.2.0-alpha2_
- [ ] `lib/api.ts`: `fetchDevices(masterPub): Promise<DeviceRecord[]>`,
с кэшем в zustand store + инвалидацией по WS-ивенту `tx:LINK_DEVICE|UNLINK_DEVICE`.
- [ ] `chats/[id].tsx``sendCore`: `Promise.all` по `devices[]`;
если `devices.length == 0` — fall-back на identity.x25519 (legacy path).
- [ ] При первом старте (онбординг): если identity ещё не зарегистрирована —
`PUBLISH_KEY` tx + `LINK_DEVICE` tx (**этот** девайс как первый в списке).
- [ ] UI: в chat-list tile бейдж `2/2` на своих сообщениях (доставлено
на N устройств получателя).
- [ ] Тест: два клиента на одну identity, отправляем на contact — оба
получают.
#### PR #3 — Devices screen + pairing flow (mobile + desktop) — _v2.2.0-alpha3_
- [ ] Settings → **Devices**: список активных, unlink-кнопка у каждого
(кроме `this device`).
- [ ] «Link new device» flow:
- Новое устройство генерит свой X25519, показывает QR
`{x25519_pub, device_name, nonce, rendezvous_id}`.
- Старое сканирует → Confirm → подписывает `LINK_DEVICE` tx +
шлёт через relay envelope `{master_ed25519_priv, recent_history}`
encrypted for new device's X25519, TTL ≤ 60 сек.
- Новое читает envelope, сохраняет master + history, готово.
- [ ] Self-wipe при обнаружении своего `x25519_pub` в revoked:
zeroize master, clear AsyncStorage/keychain, redirect на onboarding.
#### PR #4 — Desktop Electron shell — _v2.2.0-rc1_
См. отдельный раздел ниже.
### Нерешённые вопросы
- **QR-pairing UX на desktop'е**: у ноутбука камеры часто нет. Вариант:
master на phone шлёт invite-envelope по pre-shared secret (6-digit code,
вводится в обе стороны), без QR. Подумать после PR #2.
- **History-sync backup формат**: JSON export всей ChatStorage shreds'ов?
Ограничение по размеру? TTL у backup-конверта? — в PR #3.
- **Revoked-device-wipe race**: если устройство оффлайн 6 месяцев, потом
поднимается — первое что видит это свой revoked. Должен успеть wipe'нуться
**до** попытки послать tx с master-ключом. Продумать порядок bootstrap'а.
---
## Desktop client (Electron)
Цель: 1:1 функциональный паритет с мобильным, десктопная эргономика, keyboard-first.
### Архитектура
- **Electron** + **React (Vite)** + **TypeScript**.
- 80% `lib/` переиспользуется из `client-app/lib/` (api, feed, crypto, store, types).
- Нативные модули: `electron-store` (+ `safeStorage`) для keys, `clipboard`,
`Notification`, `dialog.showOpenDialog`.
- Custom title bar (frame-less), 3-panel layout.
### Экраны
Full design — `docs/desktop-design.md` (TODO). Sections:
- **Messages** — список чатов + conversation panel. Saved Messages pinned top.
- **Feed** — tabs (For You / Following / Trending 24h / 7d / Hashtag) +
post list + thread panel.
- **Wallet** — account overview + tx history + tx detail.
- **Contacts** — grouped list (All / Online / Blocked / Requests) + contact profile.
- **Settings** — grouped: Node, Identity, **Devices**, Privacy, Feed, Notifications,
Advanced, About.
- **Profile** (внизу nav) — self profile; чужой — в detail pane.
- **Auth/Onboarding** — Welcome / Create / Import / Pair-with-phone (QR/code).
### Стилистика (переносим)
Чёрный `#000`, карточки `#0a0a0a`, бордеры `#1f1f1f`, текст `#fff`/`#8b8b8b`.
Синий accent `#1d9bf0`, оранж warning `#f0b35a`. Синий bookmark-avatar для Saved Messages.
### Не переносим
Все React-Native-анимации (Pressable-animations, `scaleY: -1`, gesture-handler
long-press). На десктопе — правый клик, hover-states, keyboard shortcuts,
обычный scroll вместо inverted FlatList.
### Global keybinds
| Key | Action |
|---|---|
| `Ctrl/Cmd+1..5` | Переключение секций |
| `Ctrl/Cmd+N` | Новый пост / новый контакт (контекстно) |
| `Ctrl/Cmd+K` | Global search |
| `Ctrl/Cmd+F` | Search в текущей list pane |
| `Ctrl/Cmd+,` | Settings |
| `Ctrl/Cmd+Enter` | Send (chat / publish post) |
| `Esc` | Close modal / cancel selection |
### Структура папок
```
desktop/
├── electron/
│ ├── main.ts # BrowserWindow, IPC, auto-update
│ ├── preload.ts # contextBridge — safe API
│ ├── menu.ts # native menu bar
│ └── deep-link.ts # dchain:// protocol handler
├── src/ # renderer
│ ├── App.tsx
│ ├── shell/ # nav, status bar, title bar
│ ├── sections/
│ │ ├── messages/
│ │ ├── feed/
│ │ ├── wallet/
│ │ ├── contacts/
│ │ ├── settings/
│ │ └── profile/
│ ├── modals/
│ ├── auth/
│ ├── lib/ # reuse из client-app/lib
│ └── styles/
└── package.json
```
### План работ
- [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)
- **Auto-update channel**: гнать через Gitea releases (как node) или отдельный S3/Gitea-attachments?
- **Sandbox**: desktop хранит master Ed25519 — обязательно `safeStorage` (macOS Keychain, Windows DPAPI, libsecret на Linux). На Linux без libsecret — fallback на plaintext + warning.
- **Deep-link**: `dchain://chat/<pub>` для шаринга профилей/постов из браузера.
---
## Меньшие хвосты (неприоритетно)
- `prev_hash mismatch` при двойной gossip-доставке — понизить до Debug уровня,
не пугать операторов (см. history с joiner-node bring-up).
- `/api/network-info.peers` у seed'а возвращает `[]` если
`DCHAIN_ANNOUNCE` не задан — добавить fallback:
если announce пустой, публиковать *первый listen-addr + свой peer_id*
(не `127.0.0.1`), чтобы joiner'ы не упирались в `peers:[]`.
- **Groups (3+ участников)** в чатах — тип `Contact.kind == 'group'`
в клиенте есть, а на backend'е не реализован. После v2.2.0, потому что
pairwise messaging ↔ group messaging на multi-device — это другой
математический слой (MLS или Sender Keys).
- Repa как weight в selection-lider PBFT — сейчас только scoring; для
настоящего proof-of-reputation нужно переписать leader-rotation.

View File

@@ -15,13 +15,11 @@ import (
func jsonOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, err error, code int) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
}

View File

@@ -510,10 +510,63 @@ func apiIdentity(q ExplorerQuery) http.HandlerFunc {
jsonErr(w, err, 500)
return
}
// Multi-device (v2.2.0): stuff the active device count into the
// identity payload so a sender can decide upfront whether to
// fan out envelopes or fall back to the legacy single-X25519
// path (device_count == 0).
if q.DevicesOf != nil {
if devs, derr := q.DevicesOf(pubKey); derr == nil {
info.DeviceCount = len(devs)
}
}
jsonOK(w, info)
}
}
// apiDevices — GET /api/devices/{master_pub_or_addr}
//
// Returns the active (non-revoked) devices linked to a master Ed25519
// identity. Used by senders to fan out one envelope per device. Legacy
// identities (count=0) should be handled by the caller by falling back
// to IdentityInfo.X25519Pub — this endpoint is strictly the new registry.
func apiDevices(q ExplorerQuery) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
input := strings.TrimPrefix(r.URL.Path, "/api/devices/")
input = strings.Trim(input, "/")
if input == "" {
jsonErr(w, fmt.Errorf("master pubkey or address required"), 400)
return
}
if q.DevicesOf == nil {
jsonErr(w, fmt.Errorf("device registry not available"), 503)
return
}
pubKey, err := resolveAccountID(q, input)
if err != nil {
jsonErr(w, err, 404)
return
}
recs, err := q.DevicesOf(pubKey)
if err != nil {
jsonErr(w, err, 500)
return
}
out := make([]blockchain.DeviceInfo, 0, len(recs))
for _, rec := range recs {
out = append(out, blockchain.DeviceInfo{
X25519PubKey: rec.X25519PubKey,
DeviceName: rec.DeviceName,
AddedAt: rec.AddedAt,
})
}
jsonOK(w, map[string]any{
"master_pub": pubKey,
"count": len(out),
"devices": out,
})
}
}
func apiSubmitTx(q ExplorerQuery) http.HandlerFunc {
// The returned handler is wrapped in withSubmitTxGuards() by the caller:
// body size is capped at MaxTxRequestBytes and per-IP rate limiting is

View File

@@ -91,7 +91,8 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
type item struct {
ID string `json:"id"`
SenderPub string `json:"sender_pub"`
SenderPub string `json:"sender_pub"` // X25519 hex
SenderEd25519Pub string `json:"sender_ed25519_pub"` // master Ed25519 hex (optional; may be empty for legacy senders)
RecipientPub string `json:"recipient_pub"`
FeeUT uint64 `json:"fee_ut,omitempty"`
SentAt int64 `json:"sent_at"`
@@ -105,6 +106,7 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
out = append(out, item{
ID: env.ID,
SenderPub: env.SenderPub,
SenderEd25519Pub: env.SenderEd25519PubKey,
RecipientPub: env.RecipientPub,
FeeUT: env.FeeUT,
SentAt: env.SentAt,

View File

@@ -53,6 +53,10 @@ type ExplorerQuery struct {
NetStats func() (blockchain.NetStats, error)
RegisteredRelays func() ([]blockchain.RegisteredRelayInfo, error)
IdentityInfo func(pubKeyOrAddr string) (*blockchain.IdentityInfo, error)
// DevicesOf (multi-device v2.2.0) returns the identity's non-revoked
// device records. Empty slice if the identity hasn't linked any yet
// — senders fall back to IdentityInfo.X25519Pub for legacy clients.
DevicesOf func(masterPub string) ([]blockchain.DeviceRecord, error)
ValidatorSet func() ([]string, error)
SubmitTx func(tx *blockchain.Transaction) error
// ConnectedPeers (optional) returns the local libp2p view of currently
@@ -222,6 +226,7 @@ func registerExplorerAPI(mux *http.ServeMux, q ExplorerQuery) {
mux.HandleFunc("/api/node/", apiNode(q)) // GET /api/node/{pubkey|DC...}
mux.HandleFunc("/api/relays", apiRelays(q)) // GET /api/relays
mux.HandleFunc("/api/identity/", apiIdentity(q)) // GET /api/identity/{pubkey|addr}
mux.HandleFunc("/api/devices/", apiDevices(q)) // GET /api/devices/{master_pub} — multi-device registry (v2.2.0)
mux.HandleFunc("/api/validators", apiValidators(q))// GET /api/validators
mux.HandleFunc("/api/tx", withWriteTokenGuard(withSubmitTxGuards(apiSubmitTx(q)))) // POST /api/tx (body size + per-IP rate limit + optional token gate)
// Live event stream (SSE) — GET /api/events

32
node/cors.go Normal file
View File

@@ -0,0 +1,32 @@
package node
import "net/http"
// withCORS wraps any http.Handler so every response carries the CORS
// headers browser-based clients (Electron renderer, web explorer from a
// different origin, mobile webview) need. Also short-circuits OPTIONS
// preflight requests with a 204 — without this, POST /api/tx with a
// JSON body triggers a preflight that the regular handler answers as
// 404/405 and the browser refuses the follow-up.
//
// The allow-list is wide on purpose. The node's security model doesn't
// rely on same-origin — API tokens (DCHAIN_API_TOKEN + DCHAIN_API_PRIVATE)
// and Ed25519 tx signatures are what gate writes. Cross-origin access is
// a first-class feature here, not an attack vector.
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Access-Control-Allow-Origin", "*")
h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
h.Set("Access-Control-Expose-Headers", "Content-Length, Content-Type")
h.Set("Access-Control-Max-Age", "86400") // cache preflight for a day
if r.Method == http.MethodOptions {
// Preflight. Don't hand to the mux — just answer.
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -310,7 +310,10 @@ func (t *Tracker) ServeHTTP(q QueryFunc, fns ...func(*http.ServeMux)) http.Handl
}
// ListenAndServe starts the HTTP stats server on addr (e.g. ":8080").
// All responses pass through withCORS so browser + Electron clients
// get correct Access-Control-* headers and preflight OPTIONS requests
// are answered with 204 instead of falling through to the 404 handler.
func (t *Tracker) ListenAndServe(addr string, q QueryFunc, fns ...func(*http.ServeMux)) error {
handler := t.ServeHTTP(q, fns...)
handler := withCORS(t.ServeHTTP(q, fns...))
return http.ListenAndServe(addr, handler)
}