22 Commits

Author SHA1 Message Date
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
vsecoder
a75cbcd224 feat: resource caps, Saved Messages, author walls, docs for node bring-up
Node flags (cmd/node/main.go):
  --max-cpu / --max-ram-mb — Go runtime caps (GOMAXPROCS / GOMEMLIMIT)
  --feed-disk-limit-mb — hard 507 refusal for new post bodies over quota
  --chain-disk-limit-mb — advisory watcher (can't reject blocks without
  breaking consensus; logs WARN every minute)

Client — Saved Messages (self-chat):
  - Auto-created on sign-in, pinned top of chat list, blue bookmark avatar
  - Send short-circuits the relay (no encrypt, no fee, no mailbox hop)
  - Empty state rendered outside inverted FlatList — fixes the mirrored
    "say hi…" on Android RTL-aware layout builds
  - PostCard shows "You" for own posts instead of the self-contact alias

Client — user walls:
  - New route /(app)/feed/author/[pub] with infinite-scroll via
    `created_at` cursor and pull-to-refresh
  - Profile screen gains "View posts" button (universal) next to
    "Open chat" (contact-only)

Feed pipeline:
  - Bump client JPEG quality 0.5 → 0.75 to match server scrubber (Q=75),
    so a 60 KiB compose doesn't balloon past 256 KiB after server re-encode
  - ErrPostTooLarge now wraps with the actual size vs cap, errors.Is
    preserved in the HTTP layer
  - FeedMailbox quota + DiskUsage surface — supports new CLI flag

README:
  - Step-by-step "first node / joiner" section on the landing page,
    full flag tables incl. the new resource-cap group, minimal
    checklists for open/private/low-end deployments
2026-04-19 13:14:47 +03:00
vsecoder
e6f3d2bcf8 feat(client): transaction detail screen (wallet history → tap to inspect)
Users can now tap any row in the wallet history and see the full
transaction detail, matching what the block explorer shows for the
same tx. Covers every visible activity — transfers, contact
requests, likes, posts, follows, relay proofs, contract calls.

Components

  lib/api.ts
    - New TxDetail interface mirroring node/api_explorer.go's
      txDetail JSON (id, type, from/to + their DC addresses, µT
      amount + display string, fee, block coords, gas, payload,
      signature hex).
    - getTxDetail(txID) with 404→null handling.

  app/(app)/tx/[id].tsx — new screen
    - Hero row: icon + type label + local-time timestamp
    - Big amount pill (only for txs that move tokens) — signed by
      the viewer's perspective (+ when you received, − when you
      paid, neutral when it's someone else's tx or a non-transfer)
    - Info card rows with tap-to-copy on hashes and addresses:
      Tx ID, From (highlighted "you" when it's the signed-in user),
      To (same), Block, Fee, Gas used (when > 0), Memo (when set)
    - Collapsible Payload section — renders JSON with 2-space
      indent if the node could decode it, otherwise the raw hex
    - Signature copy row at the bottom (useful for debugging / audits)
    - txMeta() covers all EventTypes from blockchain/types.go
      (TRANSFER, CONTACT_REQUEST/ACCEPT/BLOCK, REGISTER_KEY/RELAY,
      BIND_WALLET, RELAY_PROOF, BLOCK_REWARD, HEARTBEAT, CREATE_POST,
      DELETE_POST, LIKE_POST/UNLIKE_POST, FOLLOW/UNFOLLOW,
      CALL_CONTRACT, DEPLOY_CONTRACT, STAKE/UNSTAKE) with
      distinct icons + in/out/neutral tone.
    - Nested Stack layout so router.back() pops to the caller;
      safeBack() fallback when entered via deep link.

  app/(app)/wallet.tsx
    - TxTile's outer Pressable was a no-op onPress handler; now
      router.push(`/(app)/tx/${tx.hash}`). Entire row is the
      touch target (icon + type + addr + time + amount).

  app/(app)/_layout.tsx
    - /tx/* added to hideNav regex so the detail screen is
      full-screen without the 5-icon bar at the bottom.

Translation quirk

  The screen is English to match the rest of the UI (what the user
  just asked for in the previous commit). Handles copying via
  expo-clipboard — tapping an address/hash shows "Copied" for 1.5s
  with a green check, then reverts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:49:33 +03:00
vsecoder
e62b72b5be fix(client): safeBack helper + prevent self-contact-request
1. GO_BACK warning & stuck screens

   When a deep link or direct push put the user on /feed/[id],
   /profile/[address], /compose, or /settings without any prior stack
   entry, tapping the header chevron emitted:
     "ERROR The action 'GO_BACK' was not handled by any navigator"
   and did nothing — user was stuck.

   New helper lib/utils.safeBack(fallback = '/(app)/chats') wraps
   router.canGoBack() — when there's history it pops; otherwise it
   replace-navigates to a sensible fallback (chats list by default,
   '/' for auth screens so we land back at the onboarding).

   Applied to every header chevron and back-from-detail flow:
   app/(app)/chats/[id], app/(app)/compose, app/(app)/feed/[id]
   (header + onDeleted), app/(app)/feed/tag/[tag],
   app/(app)/profile/[address], app/(app)/new-contact (header + OK
   button on request-sent alert), app/(app)/settings,
   app/(auth)/create, app/(auth)/import.

2. Prevent self-contact-request

   new-contact.tsx now compares the resolved address against
   keyFile.pub_key at two points:
     - right after resolveUsername + getIdentity in search() — before
       the profile card even renders, so the user doesn't see the
       "Send request" CTA for themselves.
     - inside sendRequest() as a belt-and-braces guard in case the
       check was somehow bypassed.
   The search path shows an inline error ("That's you. You can't
   send a contact request to yourself."); sendRequest falls back to
   an Alert with the same meaning. Both compare case-insensitively
   against the pubkey hex so mixed-case pastes work.

   Technically the server would still accept a self-request (the
   chain stores it under contact_in:<self>:<self>), but it's a dead-
   end UX-wise — the user can't chat with themselves — so the client
   blocks it preemptively instead of letting users pay the fee for
   nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:19 +03:00
vsecoder
f7a849ddcb chore(client): translate all user-visible strings to English
Mixed-language UI was confusing — onboarding said "Why DChain / How it
works / Your keys" in English headings but feature descriptions and
CTAs were in Russian; compose's confirm dialog was Russian; feed tabs
were Russian; error messages in humanizeTxError were Russian.
Everything user-facing is now English.

Files touched (only string literals, not comments):
  app/index.tsx              onboarding slides + CTA buttons
  app/(app)/compose.tsx      composer alerts, header button, placeholder,
                             attachment-size hint
  app/(app)/feed/index.tsx   tab labels (Following/For you/Trending),
                             empty-state hints, retry button
  app/(app)/feed/[id].tsx    post detail header + stats rows (Views,
                             Likes, Size, Paid to publish, Hosted on,
                             Hashtags)
  app/(app)/feed/tag/[tag].tsx  empty-state copy
  app/(app)/profile/[address].tsx  Profile header, Follow/Following,
                             Edit, Open chat, Address, Copied, Encryption,
                             Added, Members, unknown-contact hint
  app/(app)/new-contact.tsx  Search title, placeholder, Search button,
                             empty-state hint, E2E-ready indicator,
                             Intro label + placeholder, fee-tier labels
                             (Min / Standard / Priority), Send request,
                             Insufficient-balance alert, Request-sent
                             alert
  app/(app)/requests.tsx     Notifications title, empty-state, Accept /
                             Decline buttons, decline-confirm alert,
                             "wants to add you" line
  components/SearchBar.tsx   default placeholder
  components/feed/PostCard.tsx  long-press menu (Delete post, confirm,
                             Actions / Cancel)
  components/feed/ShareSheet.tsx  sheet title, contact-search placeholder,
                             empty state, Select contacts / Send button,
                             plural helper rewritten for English
  components/chat/PostRefCard.tsx  "POST" ribbon, "photo" indicator
  lib/api.ts                 humanizeTxError (rate-limit, clock skew,
                             bad signature, 400/5xx/network-error
                             messages)
  lib/dates.ts               dateBucket now returns Today/Yesterday/
                             "Jun 17, 2025"; month array switched to
                             English short forms

Code comments left in Russian intentionally — they're developer
context, not user-facing. This commit is purely display-string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:38 +03:00
vsecoder
060ac6c2c9 fix(contact): fee-tier pills lose background via Pressable style-fn
Same Pressable dynamic-style bug that keeps reappearing: on some RN
builds the style function is re-evaluated during render in a way that
drops properties (previously hit on the FAB, then on PostCard, now on
the anti-spam fee selector). User saw three bare text labels on black
stuck together instead of distinct white/grey pills.

Fix: move visual properties (backgroundColor, borderWidth, padding,
border) to a static inner <View>. Pressable keeps only the opacity-
based press feedback which is stable because no other properties need
to flip on press. Functionally identical UX, layout guaranteed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:22:11 +03:00
vsecoder
516940fa8e fix(client): contact-request endpoint path + search screen polish
1. Contact requests silently 404'd
   fetchContactRequests hit /api/relay/contacts, but the server mounts
   the whole /relay/* group at root (no /api prefix). Result: every
   poll returned 404, the catch swallowed it, and the notifications
   tab stayed empty even after the user sent themselves a CONTACT_
   REQUEST on-chain. Fixed the client path to /relay/contacts — same
   pattern as sendEnvelope / fetchInbox in the v1.0.x relay cleanup.

2. Search screen was half-finished
   SearchBar used a dual-state hack (idle-centered Text overlaid with
   an invisible TextInput) that broke focus + alignment on Android and
   sometimes ate taps. Rewrote as a plain single-row pill: icon +
   TextInput + optional clear button. Fewer moving parts, predictable
   focus, proper placeholder styling.

   new-contact.tsx cleaned up:
   - Title "New chat" → "Поиск" (matches the NavBar tab label and the
     rest of the Russian UI).
   - All labels localised: "Accept/Decline", "Intro", "Anti-spam fee",
     fee-tier names, error messages, final CTA.
   - Proper empty-state hint when query is empty (icon + headline +
     explanation of @username / hex / DC prefix) instead of just a
     floating helper text.
   - Search button hidden until user types something — the empty-
     state stands alone, no dead grey button under it.
   - onClear handler on SearchBar resets the resolved profile too.

   requests.tsx localised: title, empty-state, Accept/Decline button
   copy, confirmation Alert text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:19:03 +03:00
vsecoder
3e9ddc1a43 chore(client): remove dev-seed for chats + feed
Two dev-only seed modules removed now that the app talks to a real
backend:

- lib/devSeed.ts — fake 15+ contacts with mock chat histories,
  mounted via useDevSeed() in (app)/_layout.tsx on empty store.
  Was useful during client-first development; now it fights real
  contact sync and confuses operators bringing up fresh nodes
  ("why do I see NBA scores and a dchain_updates channel in my
  chat list?").

- lib/devSeedFeed.ts — 12 synthetic feed posts surfaced when the
  real API returned empty. Same reasoning: operator imports genesis
  key on a fresh node, opens Feed, sees 12 mock posts that aren't on
  their chain. "Test data" that looks real is worse than an honest
  empty state.

Feed screen now shows its proper empty state ("Пока нет
рекомендаций", etc.) when the API returns zero items OR on network
error. Chat screen starts empty until real contacts + messages
arrive via WS / storage cache.

Also cleaned a stale comment in chats/[id].tsx that referenced
devSeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:07:42 +03:00
vsecoder
6ed4e7ca50 fix(node): fail loudly when key file exists but is unreadable
Operator hit this in the wild: keys/node.json mounted into a container
as 600 root:root while the node process runs as an unprivileged user.
os.ReadFile returned a permission error, loadOrCreateIdentity fell
through to "generate a new identity", and genesis allocation (21M
tokens) was credited to the auto-generated key — which then vanished
when the container restarted because the read-only mount also
couldn't be written.

The symptom was a 0-balance import: operators extracted node.json
from the host keys dir, imported it into the mobile client, and
wondered why the genesis validator's wallet was empty.

Fix: distinguish "file doesn't exist" (first boot, generate) from
"file exists but can't be read" (operator error, log.Fatalf with a
hint about permissions / read-only mount). Also fail loudly on JSON
parse errors and decode errors instead of silently generating.

When the new-identity path is taken and the save fails (read-only
mount), the warning now explicitly says the key is ephemeral and the
node's identity will change on restart — operators can catch this
before genesis commits to a throwaway key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:04:53 +03:00
vsecoder
f726587ac6 fix(docker): pre-create /data as dchain user so named volumes inherit ownership
Running Dockerfile.slim with a fresh named volume crashed on startup:

  [NODE] open chain: open badger: Error Creating Dir: "/data/chain"
    error: mkdir /data/chain: permission denied

Docker copies the mount-point's directory ownership (from the image)
into a new named volume at first attach. In the previous Dockerfile
/data was created implicitly by the VOLUME directive, which means it
was owned by root — but the container runs as the unprivileged
`dchain` user, so it couldn't `mkdir /data/chain` on first boot.

Fix: explicitly `mkdir /data && chown dchain:dchain /data` in the
same RUN that creates the user, before the VOLUME directive. Fresh
volumes now inherit dchain:dchain ownership automatically; no
operator-side `docker run --user root chown` workaround needed.

Operators already running with a root-owned volume from before this
fix need to chown once manually:

  docker run --rm -v dchain_data:/data --user root alpine \
    sh -c 'chown -R 100:101 /data'

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:43:31 +03:00
vsecoder
1e7f4d8da4 fix(docker): bump build-stage Go to 1.25 (matches go.mod)
When v2.0.0 added the golang.org/x/image/webp dependency (used by the
media scrubber for WebP decoding), go mod tidy bumped the module's
minimum Go version in go.mod:

  module go-blockchain
  go 1.25.0

The three Dockerfiles in the repo were still pinned to older images:

  /Dockerfile              FROM golang:1.24-alpine
  /deploy/prod/Dockerfile.slim  FROM golang:1.24-alpine
  /docker/media-sidecar/Dockerfile  FROM golang:1.22-alpine

Result: `docker build` on any of them fails at `go mod download` with
  go: go.mod requires go >= 1.25.0 (running go 1.24.13; GOTOOLCHAIN=local)
because Alpine's golang image pins GOTOOLCHAIN=local to keep the
toolchain reproducible.

Fix: bump all three to golang:1.25-alpine. The media-sidecar module
doesn't actually need 1.25 (it's self-contained and only uses stdlib),
but keeping all three in sync avoids surprise the next time somebody
adds a dep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:22 +03:00
vsecoder
29e95485fa Merge 'feature/feed' into main — v2.0.0
Two minor-version cycles and one major:
  v1.0.1 — relay hardening (canonical envelope ID, server SentAt,
           /relay/send marked non-E2E)
  v1.0.2 — further relay hardening (RELAY_PROOF dedup, DELETE auth,
           rate limits, WS soft-fail close)
  v2.0.0 — social feed replacing channels:
             • on-chain CREATE_POST / DELETE_POST / FOLLOW / LIKE
             • relay feed-mailbox with EXIF-scrub pipeline
             • Twitter-style client (Feed tab + post detail +
               compose + profile Follow + share-to-chat)
             • infinite scroll + lazy render on both feed and chat
             • docs: full API reference + architecture update
2026-04-18 22:06:18 +03:00
105 changed files with 16101 additions and 1048 deletions

View File

@@ -1,5 +1,5 @@
# ---- build stage ----
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app

155
README.md
View File

@@ -22,6 +22,7 @@
## Содержание
- [Быстрый старт](#быстрый-старт)
- [Поднятие ноды — пошагово](#поднятие-ноды--пошагово)
- [Продакшен деплой](#продакшен-деплой)
- [Архитектура](#архитектура)
- [REST / WebSocket API](#rest--websocket-api)
@@ -66,6 +67,160 @@ curl -s http://localhost:8080/api/well-known-version | jq .
3-node dev-кластер (для тестов PBFT кворума, slashing, federation): `docker compose up --build -d` — см. [`docs/quickstart.md`](docs/quickstart.md).
## Поднятие ноды — пошагово
Ниже — полный минимум для двух сценариев, которые покрывают 99% случаев:
**первая нода сети** (genesis) и **присоединение к существующей сети**.
Все флаги читаются также из соответствующего `DCHAIN_*` env-var (CLI > env > default).
### Шаг 1. Ключи
```bash
# Ключ identity ноды (Ed25519 — подпись блоков + tx)
./client keygen --out keys/node.json
# relay-ключ (X25519 — E2E-mailbox) создаётся нодой сам при первом старте,
# но можно задать путь заранее через --relay-key.
```
### Шаг 2a. Первая нода (genesis)
Поднимает новую сеть с одним валидатором. `--genesis=true` **только** для самой первой ноды и **только один раз** — если блок 0 уже есть в `--db`, флаг игнорируется.
```bash
./node \
--genesis=true \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
`--announce` **обязателен** для любой ноды смотрящей в интернет (VPS / внешний IP / Docker с проброшенным портом). Без него libp2p пытается UPnP/NAT-PMP и чаще всего промахивается.
### Шаг 2b. Вторая и последующие ноды
Нужен **один** из двух способов узнать первую ноду. Второй удобнее.
**Через HTTP URL живой ноды** (рекомендуется — нода сама заберёт multiaddr через `/api/network-info`, проверит genesis_hash и синхронизирует цепь):
```bash
./node \
--join=https://first-node.example.com \
--key=keys/node.json \
--db=./chaindata \
--mailbox-db=./mailboxdata \
--feed-db=./feeddata \
--listen=/ip4/0.0.0.0/tcp/4001 \
--announce=/ip4/<ВАШ-ПУБЛИЧНЫЙ-IP>/tcp/4001 \
--stats-addr=:8080 \
--register-relay=true \
--relay-fee=1000
```
**Через libp2p multiaddr** (если есть прямой мульти-адрес):
```bash
./node \
--peers=/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW... \
# остальные флаги как выше
```
**Автоприсоединение к validator set** происходит не само: после того как нода синхронизируется, действующий validator должен вызвать `client add-validator --target <your-pub> --cosigs ...` (multi-sig admit). До этого новая нода живёт как **observer** — читает и гоняет tx, но не голосует. Запустить ноду **явно** как observer (никогда не проситься в validator set): `--observer=true`.
### Все флаги `node`
CLI / env / default. Группы:
**Storage**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--db` | `DCHAIN_DB` | `./chaindata` | BadgerDB блокчейна |
| `--mailbox-db` | `DCHAIN_MAILBOX_DB` | `./mailboxdata` | E2E-конверты 1:1 чатов |
| `--feed-db` | `DCHAIN_FEED_DB` | `./feeddata` | Тела постов ленты (off-chain) |
| `--feed-ttl-days` | `DCHAIN_FEED_TTL_DAYS` | `30` | Через сколько дней тела постов auto-evict'ятся (метаданные on-chain остаются вечно) |
**Identity**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--key` | `DCHAIN_KEY` | `./node.json` | Ed25519 ключ ноды |
| `--relay-key` | `DCHAIN_RELAY_KEY` | `./relay.json` | X25519 ключ для relay-mailbox (создастся сам) |
| `--wallet` | `DCHAIN_WALLET` | — | Отдельный payout-кошелёк (опционально) |
| `--wallet-pass` | `DCHAIN_WALLET_PASS` | — | Парольная фраза для wallet-файла |
**Network**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--listen` | `DCHAIN_LISTEN` | `/ip4/0.0.0.0/tcp/4001` | libp2p listen multiaddr |
| `--announce` | `DCHAIN_ANNOUNCE` | — | Multiaddr который нода рассказывает пирам (обязателен на VPS/внешнем IP) |
| `--peers` | `DCHAIN_PEERS` | — | Bootstrap multiaddrs, comma-separated |
| `--join` | `DCHAIN_JOIN` | — | HTTP URL живой ноды для авто-дискавери — получает peers и genesis_hash |
| `--allow-genesis-mismatch` | — | `false` | Отключить защиту, падающую при расхождении локального и seed'ового genesis (только для явной миграции) |
**Consensus & role**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--genesis` | `DCHAIN_GENESIS` | `false` | Создать блок 0 (только для самой первой ноды сети) |
| `--validators` | `DCHAIN_VALIDATORS` | — | Исходный validator set (CSV pub-keys) — применяется только при genesis |
| `--observer` | `DCHAIN_OBSERVER` | `false` | Observer-режим: синхронизируется и отдаёт API, но не голосует и не предлагает блоки |
| `--heartbeat` | `DCHAIN_HEARTBEAT` | `true` | Периодический HEARTBEAT-tx (нужен для liveness-детекции валидаторов) |
**Relay / mailbox**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--register-relay` | `DCHAIN_REGISTER_RELAY` | `false` | Отправить `REGISTER_RELAY` tx на старте (объявить ноду публичным relay'ем) |
| `--relay-fee` | `DCHAIN_RELAY_FEE` | `1000` | Плата за доставку одного сообщения в µT (1000 = 0.001 T). `0` = бесплатный relay |
**Media scrubber (feed)**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--media-sidecar-url` | `DCHAIN_MEDIA_SIDECAR_URL` | — | URL FFmpeg-сайдкара для видео-скраба. Пустой = только картинки |
| `--allow-unscrubbed-video` | `DCHAIN_ALLOW_UNSCRUBBED_VIDEO` | `false` | Принимать видео **без** серверного скраба (опасно — EXIF/GPS/автор-теги останутся) |
**HTTP API**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--stats-addr` | `DCHAIN_STATS_ADDR` | `:8080` | Адрес HTTP/WS сервера |
| `--api-token` | `DCHAIN_API_TOKEN` | — | Bearer-токен для submit tx. Пустой = публичная нода |
| `--api-private` | `DCHAIN_API_PRIVATE` | `false` | Требовать токен также на чтение |
| `--disable-ui` | `DCHAIN_DISABLE_UI` | `false` | Отключить HTML-explorer (JSON API остаётся) |
| `--disable-swagger` | `DCHAIN_DISABLE_SWAGGER` | `false` | Отключить `/swagger*` |
**Resource caps** (новое в v2.1.0)
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--max-cpu` | `DCHAIN_MAX_CPU` | `0` | Сколько CPU-ядер Go-runtime'у (`GOMAXPROCS`). `0` = все |
| `--max-ram-mb` | `DCHAIN_MAX_RAM_MB` | `0` | Soft-лимит Go-хипа в MiB (`GOMEMLIMIT`). `0` = без лимита. *Не OOM-kill'ит — усиливает GC при приближении* |
| `--feed-disk-limit-mb` | `DCHAIN_FEED_DISK_LIMIT_MB` | `0` | Жёсткая квота на feed-БД. При превышении `/feed/publish` отвечает 507. Существующие посты продолжают отдаваться |
| `--chain-disk-limit-mb` | `DCHAIN_CHAIN_DISK_LIMIT_MB` | `0` | Advisory-квота на блокчейн-БД. Превышение → `WARN` в лог раз в минуту (жёстко не отказываем — сломали бы консенсус) |
Для реального sandboxing (hard-kill при OOM, hard CPU throttling) используйте `docker run --cpus --memory` или systemd `CPUQuota` / `MemoryMax` поверх этих флагов.
**Update / versioning**
| Флаг | Env | Default | Назначение |
|------|-----|---------|-----------|
| `--update-source-url` | `DCHAIN_UPDATE_SOURCE_URL` | — | Gitea `/api/v1/repos/{owner}/{repo}/releases/latest` для `/api/update-check` |
| `--update-source-token` | `DCHAIN_UPDATE_SOURCE_TOKEN` | — | PAT для приватного репо |
| `--log-format` | `DCHAIN_LOG_FORMAT` | `text` | `text` (human) или `json` (Loki/ELK) |
| `--governance-contract` | `DCHAIN_GOVERNANCE_CONTRACT` | — | ID governance-контракта для динамических параметров |
| `--version` | — | — | Печатает версию и выходит |
### Минимальные чек-листы
**Первая нода (открытая):** `--genesis=true` + `--key` + `--announce` на внешний IP + `--stats-addr` + опционально `--register-relay=true --relay-fee=...` чтобы сразу монетизировать relay-трафик.
**Joiner:** `--join=<url-любой-живой-ноды>` + `--key` + `--announce` + `--stats-addr`. После синка попросите действующего валидатора поднять `add-validator` (иначе остаётесь observer'ом до принятия — это нормально и безопасно).
**Приватная/домашняя нода** без публичного эксплорера: добавьте `--api-token=<random>`, `--api-private=true`, `--disable-ui=true`, `--disable-swagger=true`. Clients передают `Authorization: Bearer <token>`.
**Слабое железо:** `--max-cpu=2 --max-ram-mb=1024 --feed-disk-limit-mb=2048 --chain-disk-limit-mb=10240`.
Docker-обёртка с теми же флагами — в [`deploy/single/README.md`](deploy/single/README.md).
## Продакшен деплой
Два варианта, по масштабу.

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';
@@ -23,9 +23,15 @@ import { useWellKnownContracts } from '@/hooks/useWellKnownContracts';
import { useNotifications } from '@/hooks/useNotifications';
import { useGlobalInbox } from '@/hooks/useGlobalInbox';
import { getWSClient } from '@/lib/ws';
import { useDevSeed } from '@/lib/devSeed';
import { NavBar } from '@/components/NavBar';
import { AnimatedSlot } from '@/components/AnimatedSlot';
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);
@@ -37,24 +43,115 @@ export default function AppLayout() {
// - chat detail
// - compose (new post modal)
// - feed sub-routes (post detail, hashtag search)
// - tx detail
const hideNav =
/^\/chats\/[^/]+/.test(pathname) ||
pathname === '/compose' ||
/^\/feed\/.+/.test(pathname);
/^\/feed\/.+/.test(pathname) ||
/^\/tx\/.+/.test(pathname);
useBalance();
useContacts();
useWellKnownContracts();
useDevSeed();
useNotifications(); // permission + tap-handler
useGlobalInbox(); // global inbox listener → notifications on new peer msg
// Ensure the Saved Messages (self-chat) contact exists as soon as the user
// is signed in, so it shows up in the chat list without any prior action.
const contacts = useStore(s => s.contacts);
const upsertContact = useStore(s => s.upsertContact);
useEffect(() => {
if (!keyFile) return;
if (contacts.some(c => c.address === keyFile.pub_key)) return;
const saved = {
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
};
upsertContact(saved);
saveContact(saved).catch(() => { /* best-effort — re-added next boot anyway */ });
}, [keyFile, contacts, upsertContact]);
useEffect(() => {
const ws = getWSClient();
if (keyFile) ws.setAuthCreds({ pubKey: keyFile.pub_key, privKey: keyFile.priv_key });
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,10 +23,10 @@ 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 } from '@/lib/utils';
import { randomId, safeBack } from '@/lib/utils';
import type { Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
@@ -63,6 +63,24 @@ export default function ChatScreen() {
clearContactNotifications(contactAddress);
}, [contactAddress, clearUnread]);
const upsertContact = useStore(s => s.upsertContact);
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
// Auto-materialise the Saved Messages contact the first time the user
// opens chat-with-self. The contact is stored locally only — no on-chain
// CONTACT_REQUEST needed, since both ends are the same key pair.
useEffect(() => {
if (!isSavedMessages || !keyFile) return;
const existing = contacts.find(c => c.address === keyFile.pub_key);
if (existing) return;
upsertContact({
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
});
}, [isSavedMessages, keyFile, contacts, upsertContact]);
const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null);
@@ -89,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(() => {
@@ -121,9 +139,9 @@ export default function ChatScreen() {
// Восстановить сообщения из persistent-storage при первом заходе в чат.
//
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
// содержимое, которое уже лежит в zustand (например, из devSeed или
// только что полученные по WS сообщения пока монтировались). Если
// в кэше что-то есть — мержим: берём max(cached, in-store) по id.
// содержимое, которое уже лежит в zustand (только что полученные по
// WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
// берём max(cached, in-store) по id.
useEffect(() => {
if (!contactAddress) return;
loadMessages(contactAddress).then(cached => {
@@ -137,7 +155,9 @@ export default function ChatScreen() {
});
}, [contactAddress, setMsgs]);
const name = contact?.username
const name = isSavedMessages
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
@@ -172,7 +192,7 @@ export default function ChatScreen() {
const hasText = !!actualText.trim();
const hasAttach = !!actualAttach;
if (!hasText && !hasAttach) return;
if (!contact.x25519Pub) {
if (!isSavedMessages && !contact.x25519Pub) {
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
return;
}
@@ -188,16 +208,38 @@ export default function ChatScreen() {
setSending(true);
try {
if (hasText) {
// Saved Messages short-circuits the relay entirely — the message never
// 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 = {
@@ -224,7 +266,7 @@ export default function ChatScreen() {
setSending(false);
}
}, [
text, keyFile, contact, composeMode, chatMsgs,
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
setMsgs, cancelCompose, appendMsg, pendingAttach,
]);
@@ -404,14 +446,14 @@ export default function ChatScreen() {
) : (
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={onOpenPeerProfile}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={name} address={contactAddress} size={28} />
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
@@ -429,7 +471,7 @@ export default function ChatScreen() {
typing
</Text>
)}
{!peerTyping && !contact?.x25519Pub && (
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
waiting for key
</Text>
@@ -447,6 +489,32 @@ export default function ChatScreen() {
с "scroll position at bottom" без ручного scrollToEnd, и новые
сообщения (добавляемые в начало reversed-массива) появляются
внизу естественно. Никаких jerk'ов при открытии. */}
{rows.length === 0 ? (
// Empty state is rendered as a plain View instead of
// ListEmptyComponent on an inverted FlatList — the previous
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
// text mirrored on some Android builds (RTL-aware layout),
// giving us the "say hi…" backwards bug.
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
}}>
<Avatar
name={name}
address={contactAddress}
size={72}
saved={isSavedMessages}
/>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
{isSavedMessages
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
: 'Your messages are end-to-end encrypted.'}
</Text>
</View>
) : (
<FlatList
ref={listRef}
data={rows}
@@ -462,22 +530,8 @@ export default function ChatScreen() {
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
ListEmptyComponent={() => (
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
}}>
<Avatar name={name} address={contactAddress} size={72} />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Say hi to {name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
Your messages are end-to-end encrypted.
</Text>
</View>
)}
/>
)}
{/* Composer — floating, прибит к низу. */}
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>

View File

@@ -28,6 +28,7 @@ export default function ChatsScreen() {
const insets = useSafeAreaInsets();
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const keyFile = useStore(s => s.keyFile);
// Статус подключения: online / connecting / offline.
// Название шапки и цвет pip'а на аватаре зависят от него.
@@ -48,9 +49,14 @@ export default function ChatsScreen() {
return msgs && msgs.length ? msgs[msgs.length - 1] : null;
};
// Сортировка по последней активности.
// Сортировка по последней активности. Saved Messages (self-chat) всегда
// закреплён сверху — это "Избранное", бессмысленно конкурировать с ним
// по recency'и обычным чатам.
const selfAddr = keyFile?.pub_key;
const sorted = useMemo(() => {
return [...contacts]
const saved = selfAddr ? contacts.find(c => c.address === selfAddr) : undefined;
const rest = contacts
.filter(c => c.address !== selfAddr)
.map(c => ({ c, last: lastOf(c) }))
.sort((a, b) => {
const ka = a.last ? a.last.timestamp : a.c.addedAt / 1000;
@@ -58,7 +64,8 @@ export default function ChatsScreen() {
return kb - ka;
})
.map(x => x.c);
}, [contacts, messages]);
return saved ? [saved, ...rest] : rest;
}, [contacts, messages, selfAddr]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
@@ -72,6 +79,7 @@ export default function ChatsScreen() {
<ChatTile
contact={item}
lastMessage={lastOf(item)}
saved={item.address === selfAddr}
onPress={() => router.push(`/(app)/chats/${item.address}` as never)}
/>
)}

View File

@@ -37,11 +37,23 @@ import { useStore } from '@/lib/store';
import { Avatar } from '@/components/Avatar';
import { publishAndCommit, formatFee } from '@/lib/feed';
import { humanizeTxError, getBalance } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const MAX_CONTENT_LENGTH = 4000;
const MAX_POST_BYTES = 256 * 1024; // must match server's MaxPostSize
const IMAGE_MAX_DIM = 1080;
const IMAGE_QUALITY = 0.5; // JPEG Q=50 — small, still readable
// Match the server scrubber's JPEG quality (media/scrub.go:ImageJPEGQuality
// = 75). If the client re-encodes at a LOWER quality the server re-encode
// at 75 inflates the bytes, often 2-3× — so a 60 KiB upload silently blows
// past MaxPostSize = 256 KiB mid-flight and `/feed/publish` rejects with
// "post body exceeds maximum allowed size". Using the same Q for both
// passes keeps the final footprint ~the same as what the user sees in
// the composer.
const IMAGE_QUALITY = 0.75;
// Safety margin on the pre-upload check: the server pass is near-idempotent
// at matching Q but not exactly — reserve ~8 KiB for JPEG header / metadata
// scaffolding differences so we don't flirt with the hard cap.
const IMAGE_BUDGET_BYTES = MAX_POST_BYTES - 8 * 1024;
interface Attachment {
uri: string;
@@ -98,11 +110,11 @@ export default function ComposeScreen() {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(
'Нужен доступ к фото',
'Откройте настройки и разрешите доступ к галерее.',
'Photo access required',
'Please enable photo library access in Settings.',
[
{ text: 'Отмена' },
{ text: 'Настройки', onPress: () => Linking.openSettings() },
{ text: 'Cancel' },
{ text: 'Settings', onPress: () => Linking.openSettings() },
],
);
return;
@@ -130,10 +142,10 @@ export default function ComposeScreen() {
});
const bytes = base64ToBytes(b64);
if (bytes.length > MAX_POST_BYTES - 512) {
if (bytes.length > IMAGE_BUDGET_BYTES) {
Alert.alert(
'Слишком большое',
`Картинка ${Math.round(bytes.length / 1024)} KB — лимит ${MAX_POST_BYTES / 1024} KB. Попробуйте выбрать поменьше.`,
'Image too large',
`Image is ${Math.round(bytes.length / 1024)} KB but the post limit is ${MAX_POST_BYTES / 1024} KB (after server re-encode). Try a smaller picture.`,
);
return;
}
@@ -147,7 +159,7 @@ export default function ComposeScreen() {
height: manipulated.height,
});
} catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e));
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setPicking(false);
}
@@ -159,19 +171,19 @@ export default function ComposeScreen() {
// Balance guard.
if (balance !== null && balance < estimatedFee) {
Alert.alert(
'Недостаточно средств',
`Нужно ${formatFee(estimatedFee)}, на балансе ${formatFee(balance)}.`,
'Insufficient balance',
`Need ${formatFee(estimatedFee)}, have ${formatFee(balance)}.`,
);
return;
}
Alert.alert(
'Опубликовать пост?',
`Цена: ${formatFee(estimatedFee)}\nРазмер: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
'Publish post?',
`Cost: ${formatFee(estimatedFee)}\nSize: ${Math.round(totalBytes / 1024 * 10) / 10} KB`,
[
{ text: 'Отмена', style: 'cancel' },
{ text: 'Cancel', style: 'cancel' },
{
text: 'Опубликовать',
text: 'Publish',
onPress: async () => {
setBusy(true);
try {
@@ -185,7 +197,7 @@ export default function ComposeScreen() {
// Close composer and open the new post.
router.replace(`/(app)/feed/${postID}` as never);
} catch (e: any) {
Alert.alert('Не удалось опубликовать', humanizeTxError(e));
Alert.alert('Failed to publish', humanizeTxError(e));
} finally {
setBusy(false);
}
@@ -212,7 +224,7 @@ export default function ComposeScreen() {
borderBottomColor: '#141414',
}}
>
<Pressable onPress={() => router.back()} hitSlop={8}>
<Pressable onPress={() => safeBack()} hitSlop={8}>
<Ionicons name="close" size={26} color="#ffffff" />
</Pressable>
<View style={{ flex: 1 }} />
@@ -235,7 +247,7 @@ export default function ComposeScreen() {
fontSize: 14,
}}
>
Опубликовать
Publish
</Text>
)}
</Pressable>
@@ -251,7 +263,7 @@ export default function ComposeScreen() {
<TextInput
value={content}
onChangeText={setContent}
placeholder="Что происходит?"
placeholder="What's happening?"
placeholderTextColor="#5a5a5a"
multiline
maxLength={MAX_CONTENT_LENGTH}
@@ -328,7 +340,7 @@ export default function ComposeScreen() {
</Pressable>
</View>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6 }}>
{Math.round(attach.size / 1024)} KB · метаданные удалят на сервере
{Math.round(attach.size / 1024)} KB · metadata stripped on server
</Text>
</View>
)}

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

@@ -31,6 +31,7 @@ import {
fetchPost, fetchStats, bumpView, formatCount, formatFee,
type FeedPostItem, type PostStats,
} from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function PostDetailScreen() {
const insets = useSafeAreaInsets();
@@ -71,15 +72,15 @@ export default function PostDetailScreen() {
const onDeleted = useCallback(() => {
// Go back to feed — the post is gone.
router.back();
safeBack();
}, []);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
title="Пост"
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title="Post"
/>
{loading ? (
@@ -94,7 +95,7 @@ export default function PostDetailScreen() {
<View style={{ padding: 24, alignItems: 'center' }}>
<Ionicons name="trash-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#8b8b8b', marginTop: 10 }}>
Пост удалён или больше недоступен
Post deleted or no longer available
</Text>
</View>
) : (
@@ -131,18 +132,18 @@ export default function PostDetailScreen() {
textTransform: 'uppercase',
marginBottom: 10,
}}>
Информация о посте
Post details
</Text>
<DetailRow label="Просмотров" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Лайков" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Размер" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow label="Views" value={formatCount(stats?.views ?? post.views)} />
<DetailRow label="Likes" value={formatCount(stats?.likes ?? post.likes)} />
<DetailRow label="Size" value={`${Math.round(post.size / 1024 * 10) / 10} KB`} />
<DetailRow
label="Стоимость публикации"
label="Paid to publish"
value={formatFee(1000 + post.size)}
/>
<DetailRow
label="Хостинг"
label="Hosted on"
value={shortAddr(post.hosting_relay)}
mono
/>
@@ -151,7 +152,7 @@ export default function PostDetailScreen() {
<>
<View style={{ height: 1, backgroundColor: '#1f1f1f', marginVertical: 10 }} />
<Text style={{ color: '#5a5a5a', fontSize: 11, marginBottom: 6 }}>
Хештеги
Hashtags
</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{post.hashtags.map(tag => (

View File

@@ -0,0 +1,249 @@
/**
* Author wall — timeline of every post by a single author, newest first.
*
* Route: /(app)/feed/author/[pub]
*
* Entry points:
* - Profile screen "View posts" button.
* - Tapping the author name/avatar inside a PostCard.
*
* Backend: GET /feed/author/{pub}?limit=N[&before=ts]
* — chain-authoritative, returns FeedPostItem[] ordered newest-first.
*
* Pagination: infinite-scroll via onEndReached → appends the next page
* anchored on the oldest timestamp we've seen. Safe to over-fetch because
* the relay caps `limit`.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View, Text, FlatList, RefreshControl, ActivityIndicator, Pressable,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Avatar } from '@/components/Avatar';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchAuthorPosts, fetchStats, type FeedPostItem } from '@/lib/feed';
import { getIdentity, type IdentityInfo } from '@/lib/api';
import { safeBack } from '@/lib/utils';
const PAGE = 30;
function shortAddr(a: string, n = 6): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function AuthorWallScreen() {
const insets = useSafeAreaInsets();
const { pub } = useLocalSearchParams<{ pub: string }>();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const contact = contacts.find(c => c.address === pub);
const isMe = !!keyFile && keyFile.pub_key === pub;
const [posts, setPosts] = useState<FeedPostItem[]>([]);
const [likedSet, setLikedSet] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [exhausted, setExhausted] = useState(false);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const seq = useRef(0);
// Identity — for the header's username / avatar seed. Best-effort; the
// screen still works without it.
useEffect(() => {
if (!pub) return;
let cancelled = false;
getIdentity(pub).then(id => { if (!cancelled) setIdentity(id); }).catch(() => {});
return () => { cancelled = true; };
}, [pub]);
const loadLikedFor = useCallback(async (items: FeedPostItem[]) => {
if (!keyFile) return new Set<string>();
const liked = new Set<string>();
for (const p of items) {
const s = await fetchStats(p.post_id, keyFile.pub_key);
if (s?.liked_by_me) liked.add(p.post_id);
}
return liked;
}, [keyFile]);
const load = useCallback(async (isRefresh = false) => {
if (!pub) return;
if (isRefresh) setRefreshing(true);
else setLoading(true);
const id = ++seq.current;
try {
const items = await fetchAuthorPosts(pub, { limit: PAGE });
if (id !== seq.current) return;
setPosts(items);
setExhausted(items.length < PAGE);
const liked = await loadLikedFor(items);
if (id !== seq.current) return;
setLikedSet(liked);
} catch {
if (id !== seq.current) return;
setPosts([]);
setExhausted(true);
} finally {
if (id !== seq.current) return;
setLoading(false);
setRefreshing(false);
}
}, [pub, loadLikedFor]);
useEffect(() => { load(false); }, [load]);
const loadMore = useCallback(async () => {
if (!pub || loadingMore || exhausted || loading) return;
const oldest = posts[posts.length - 1];
if (!oldest) return;
setLoadingMore(true);
try {
const more = await fetchAuthorPosts(pub, { limit: PAGE, before: oldest.created_at });
// De-dup by post_id — defensive against boundary overlap.
const known = new Set(posts.map(p => p.post_id));
const fresh = more.filter(p => !known.has(p.post_id));
if (fresh.length === 0) { setExhausted(true); return; }
setPosts(prev => [...prev, ...fresh]);
if (more.length < PAGE) setExhausted(true);
const liked = await loadLikedFor(fresh);
setLikedSet(set => {
const next = new Set(set);
liked.forEach(v => next.add(v));
return next;
});
} catch {
// Swallow — user can pull-to-refresh to recover.
} finally {
setLoadingMore(false);
}
}, [pub, posts, loadingMore, exhausted, loading, loadLikedFor]);
const onStatsChanged = useCallback(async (postID: string) => {
if (!keyFile) return;
const s = await fetchStats(postID, keyFile.pub_key);
if (!s) return;
setPosts(ps => ps.map(p => p.post_id === postID
? { ...p, likes: s.likes, views: s.views } : p));
setLikedSet(set => {
const next = new Set(set);
if (s.liked_by_me) next.add(postID); else next.delete(postID);
return next;
});
}, [keyFile]);
// "Saved Messages" is a messaging-app label and has no place on a public
// wall — for self we fall back to the real handle (@username if claimed,
// else short-addr), same as any other author.
const displayName = isMe
? (identity?.nickname ? `@${identity.nickname}` : 'You')
: contact?.username
? `@${contact.username}`
: (contact?.alias && contact.alias !== 'Saved Messages')
? contact.alias
: (identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6));
const handle = identity?.nickname ? `@${identity.nickname}` : shortAddr(pub ?? '', 6);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={() => pub && router.push(`/(app)/profile/${pub}` as never)}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={displayName} address={pub} size={28} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff', fontSize: 15, fontWeight: '700', letterSpacing: -0.2,
}}
>
{displayName}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 11 }} numberOfLines={1}>
{handle !== displayName ? handle : 'Wall'}
</Text>
</View>
</Pressable>
}
/>
<FlatList
data={posts}
keyExtractor={p => p.post_id}
renderItem={({ item }) => (
<PostCard
post={item}
likedByMe={likedSet.has(item.post_id)}
onStatsChanged={onStatsChanged}
/>
)}
ItemSeparatorComponent={PostSeparator}
initialNumToRender={10}
maxToRenderPerBatch={8}
windowSize={7}
removeClippedSubviews
onEndReachedThreshold={0.6}
onEndReached={loadMore}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => load(true)}
tintColor="#1d9bf0"
/>
}
ListFooterComponent={
loadingMore ? (
<View style={{ paddingVertical: 18 }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : null
}
ListEmptyComponent={
loading ? (
<View style={{ paddingTop: 80, alignItems: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : (
<View style={{
flex: 1,
alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, paddingVertical: 80,
}}>
<Ionicons name="document-text-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
{isMe ? "You haven't posted yet" : 'No posts yet'}
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
{isMe
? 'Tap the compose button on the feed tab to publish your first post.'
: 'This user hasn\'t published any posts on this chain.'}
</Text>
</View>
)
}
contentContainerStyle={
posts.length === 0
? { flexGrow: 1 }
: { paddingTop: 8, paddingBottom: 24 }
}
/>
</View>
);
}

View File

@@ -27,14 +27,13 @@ import {
fetchTimeline, fetchForYou, fetchTrending, fetchStats, bumpView,
type FeedPostItem,
} from '@/lib/feed';
import { getDevSeedFeed } from '@/lib/devSeedFeed';
type TabKey = 'following' | 'foryou' | 'trending';
const TAB_LABELS: Record<TabKey, string> = {
following: 'Подписки',
foryou: 'Для вас',
trending: 'В тренде',
following: 'Following',
foryou: 'For you',
trending: 'Trending',
};
export default function FeedScreen() {
@@ -78,12 +77,6 @@ export default function FeedScreen() {
}
if (seq !== requestRef.current) return; // stale response
// Dev-only fallback: if the node has no real posts yet, surface
// synthetic ones so we can scroll + tap. Stripped from production.
if (items.length === 0) {
items = getDevSeedFeed();
}
setPosts(items);
// If the server returned fewer than PAGE_SIZE, we already have
// everything — disable further paginated fetches.
@@ -105,11 +98,11 @@ export default function FeedScreen() {
} catch (e: any) {
if (seq !== requestRef.current) return;
const msg = String(e?.message ?? e);
// Network / 404 is benign — node just unreachable or empty. In __DEV__
// fall back to synthetic seed posts so the scroll / tap UI stays
// testable; in production this path shows the empty state.
// Network / 404 is benign — node just unreachable or empty. Show
// the empty-state; the catch block above already cleared error
// on benign messages. Production treats this identically.
if (/Network request failed|→\s*404/.test(msg)) {
setPosts(getDevSeedFeed());
setPosts([]);
setExhausted(true);
} else {
setError(msg);
@@ -208,15 +201,15 @@ export default function FeedScreen() {
const emptyHint = useMemo(() => {
switch (tab) {
case 'following': return 'Подпишитесь на кого-нибудь, чтобы увидеть их посты здесь.';
case 'foryou': return 'Пока нет рекомендаций — возвращайтесь позже.';
case 'trending': return 'В этой ленте пока тихо.';
case 'following': return 'Follow someone to see their posts here.';
case 'foryou': return 'No recommendations yet — come back later.';
case 'trending': return 'Nothing trending yet.';
}
}, [tab]);
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<TabHeader title="Лента" />
<TabHeader title="Feed" />
{/* Tab strip — три таба, равномерно распределены по ширине
(justifyContent: space-between). Каждый Pressable hug'ает
@@ -312,14 +305,14 @@ export default function FeedScreen() {
) : error ? (
<EmptyState
icon="alert-circle-outline"
title="Не удалось загрузить ленту"
title="Couldn't load feed"
subtitle={error}
onRetry={() => loadPosts(false)}
/>
) : (
<EmptyState
icon="newspaper-outline"
title="Здесь пока пусто"
title="Nothing to show yet"
subtitle={emptyHint}
/>
)
@@ -416,7 +409,7 @@ function EmptyState({
})}
>
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Попробовать снова
Try again
</Text>
</Pressable>
)}

View File

@@ -17,6 +17,7 @@ import { IconButton } from '@/components/IconButton';
import { PostCard, PostSeparator } from '@/components/feed/PostCard';
import { useStore } from '@/lib/store';
import { fetchHashtag, fetchStats, type FeedPostItem } from '@/lib/feed';
import { safeBack } from '@/lib/utils';
export default function HashtagScreen() {
const insets = useSafeAreaInsets();
@@ -80,7 +81,7 @@ export default function HashtagScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={`#${tag}`}
/>
@@ -119,10 +120,10 @@ export default function HashtagScreen() {
}}>
<Ionicons name="pricetag-outline" size={32} color="#6a6a6a" />
<Text style={{ color: '#ffffff', fontWeight: '700', marginTop: 10 }}>
Пока нет постов с этим тегом
No posts with this tag yet
</Text>
<Text style={{ color: '#8b8b8b', textAlign: 'center', fontSize: 13, marginTop: 6 }}>
Будьте первым напишите пост с #{tag}
Be the first write a post with #{tag}
</Text>
</View>
)

View File

@@ -18,7 +18,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useStore } from '@/lib/store';
import { getIdentity, buildContactRequestTx, submitTx, resolveUsername, humanizeTxError } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
@@ -64,9 +64,21 @@ export default function NewContactScreen() {
if (!addr) { setError(`@${name} is not registered on this chain`); return; }
address = addr;
}
// Self-lookup: skip the contact-request dance entirely and jump straight
// to Saved Messages (self-chat). No CONTACT_REQUEST tx is needed — the
// chat-with-self flow is purely local storage.
if (keyFile && address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
const identity = await getIdentity(address);
const resolvedAddr = identity?.pub_key ?? address;
if (keyFile && resolvedAddr.toLowerCase() === keyFile.pub_key.toLowerCase()) {
router.replace(`/(app)/chats/${keyFile.pub_key}` as never);
return;
}
setResolved({
address: identity?.pub_key ?? address,
address: resolvedAddr,
nickname: identity?.nickname || undefined,
x25519: identity?.x25519_pub || undefined,
});
@@ -79,8 +91,12 @@ export default function NewContactScreen() {
async function sendRequest() {
if (!resolved || !keyFile) return;
if (resolved.address.toLowerCase() === keyFile.pub_key.toLowerCase()) {
Alert.alert('Can\'t message yourself', "This is your own address.");
return;
}
if (balance < fee + 1000) {
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (fee + network).`);
Alert.alert('Insufficient balance', `Need ${formatAmount(fee + 1000)} (request fee + network).`);
return;
}
setSending(true); setError(null);
@@ -96,7 +112,7 @@ export default function NewContactScreen() {
Alert.alert(
'Request sent',
`A contact request has been sent to ${resolved.nickname ? '@' + resolved.nickname : shortAddr(resolved.address)}.`,
[{ text: 'OK', onPress: () => router.back() }],
[{ text: 'OK', onPress: () => safeBack() }],
);
} catch (e: any) {
setError(humanizeTxError(e));
@@ -112,34 +128,32 @@ export default function NewContactScreen() {
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="New chat"
title="Search"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 120 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, lineHeight: 19, marginBottom: 14 }}>
Enter a <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text>, a
hex pubkey or a <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
<SearchBar
value={query}
onChangeText={setQuery}
placeholder="@alice or hex / DC address"
placeholder="@alice, hex pubkey or DC address"
onSubmitEditing={search}
autoFocus
onClear={() => { setResolved(null); setError(null); }}
/>
{query.trim().length > 0 && (
<Pressable
onPress={search}
disabled={searching || !query.trim()}
disabled={searching}
style={({ pressed }) => ({
flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
paddingVertical: 11, borderRadius: 999, marginTop: 12,
backgroundColor: !query.trim() || searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
backgroundColor: searching ? '#1a1a1a' : pressed ? '#1a8cd8' : '#1d9bf0',
})}
>
{searching ? (
@@ -148,6 +162,31 @@ export default function NewContactScreen() {
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 14 }}>Search</Text>
)}
</Pressable>
)}
{/* Empty-state hint — показываем когда ничего не введено и нет результата */}
{query.trim().length === 0 && !resolved && (
<View style={{ marginTop: 28, alignItems: 'center', paddingHorizontal: 16 }}>
<View
style={{
width: 56, height: 56, borderRadius: 16,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginBottom: 12,
}}
>
<Ionicons name="person-add-outline" size={24} color="#6a6a6a" />
</View>
<Text style={{ color: '#ffffff', fontSize: 15, fontWeight: '700', marginBottom: 6 }}>
Find someone to message
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 19 }}>
Enter an <Text style={{ color: '#ffffff', fontWeight: '600' }}>@username</Text> if
the person registered one, or paste a full hex pubkey or <Text style={{ color: '#ffffff', fontWeight: '600' }}>DC</Text> address.
</Text>
</View>
)}
{error && (
<View style={{
@@ -195,7 +234,7 @@ export default function NewContactScreen() {
color: resolved.x25519 ? '#3ba55d' : '#f0b35a',
fontSize: 11, fontWeight: '500',
}}>
{resolved.x25519 ? 'E2E-ready' : 'Key not published yet'}
{resolved.x25519 ? 'E2E ready' : 'Key not published yet'}
</Text>
</View>
</View>
@@ -227,8 +266,16 @@ export default function NewContactScreen() {
{/* Fee tier */}
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 14, marginBottom: 6 }}>
Anti-spam fee (goes to recipient)
Anti-spam fee (goes to the recipient)
</Text>
{/* Fee-tier pills.
Layout (background, border, padding) lives on a static
inner View — Pressable's dynamic style-function has been
observed to drop backgroundColor between renders on
some RN/Android versions, which is what made these
chips look like three bare text labels on black
instead of distinct pills. Press feedback via opacity
on the Pressable itself, which is stable. */}
<View style={{ flexDirection: 'row', gap: 8 }}>
{FEE_TIERS.map(t => {
const active = fee === t.value;
@@ -238,12 +285,18 @@ export default function NewContactScreen() {
onPress={() => setFee(t.value)}
style={({ pressed }) => ({
flex: 1,
opacity: pressed ? 0.7 : 1,
})}
>
<View
style={{
alignItems: 'center',
paddingVertical: 10,
borderRadius: 10,
backgroundColor: active ? '#ffffff' : pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: active ? '#ffffff' : '#1f1f1f',
})}
backgroundColor: active ? '#ffffff' : '#111111',
borderWidth: 1,
borderColor: active ? '#ffffff' : '#1f1f1f',
}}
>
<Text style={{
color: active ? '#000' : '#ffffff',
@@ -257,6 +310,7 @@ export default function NewContactScreen() {
}}>
{formatAmount(t.value)}
</Text>
</View>
</Pressable>
);
})}

View File

@@ -13,7 +13,7 @@
* push stack, so tapping Back returns the user to whatever screen
* pushed them here (feed card tap, chat header tap, etc.).
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
View, Text, ScrollView, Pressable, ActivityIndicator,
} from 'react-native';
@@ -27,7 +27,11 @@ import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { followUser, unfollowUser } from '@/lib/feed';
import { humanizeTxError } from '@/lib/api';
import {
humanizeTxError, getBalance, getIdentity, getRelayFor,
type IdentityInfo, type RegisteredRelayInfo,
} from '@/lib/api';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 10): string {
if (!a) return '—';
@@ -45,10 +49,35 @@ export default function ProfileScreen() {
const [followingBusy, setFollowingBusy] = useState(false);
const [copied, setCopied] = useState(false);
// On-chain enrichment — fetched once per address mount.
const [balanceUT, setBalanceUT] = useState<number | null>(null);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [relay, setRelay] = useState<RegisteredRelayInfo | null>(null);
const [loadingChain, setLoadingChain] = useState(true);
const isMe = !!keyFile && keyFile.pub_key === address;
const displayName = contact?.username
useEffect(() => {
if (!address) return;
let cancelled = false;
setLoadingChain(true);
Promise.all([
getBalance(address).catch(() => 0),
getIdentity(address).catch(() => null),
getRelayFor(address).catch(() => null),
]).then(([bal, id, rel]) => {
if (cancelled) return;
setBalanceUT(bal);
setIdentity(id);
setRelay(rel);
}).finally(() => { if (!cancelled) setLoadingChain(false); });
return () => { cancelled = true; };
}, [address]);
const displayName = isMe
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? (isMe ? 'Вы' : shortAddr(address ?? '', 6));
: contact?.alias ?? shortAddr(address ?? '', 6);
const copyAddress = async () => {
if (!address) return;
@@ -85,15 +114,15 @@ export default function ProfileScreen() {
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Профиль"
title="Profile"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}>
{/* ── Hero: avatar + Follow button ──────────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'flex-end', marginBottom: 12 }}>
<Avatar name={displayName} address={address} size={72} />
<Avatar name={displayName} address={address} size={72} saved={isMe} />
<View style={{ flex: 1 }} />
{!isMe ? (
<Pressable
@@ -124,22 +153,24 @@ export default function ProfileScreen() {
fontSize: 13,
}}
>
{following ? 'Вы подписаны' : 'Подписаться'}
{following ? 'Following' : 'Follow'}
</Text>
)}
</Pressable>
) : (
<Pressable
onPress={() => router.push('/(app)/settings' as never)}
onPress={() => keyFile && router.push(`/(app)/chats/${keyFile.pub_key}` as never)}
style={({ pressed }) => ({
paddingHorizontal: 18, paddingVertical: 9,
paddingHorizontal: 16, paddingVertical: 9,
borderRadius: 999,
flexDirection: 'row', alignItems: 'center', gap: 6,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="bookmark" size={13} color="#f0b35a" />
<Text style={{ color: '#ffffff', fontWeight: '700', fontSize: 13 }}>
Редактировать
Saved Messages
</Text>
</Pressable>
)}
@@ -158,13 +189,35 @@ export default function ProfileScreen() {
)}
</View>
{/* Open chat — single CTA, full width, icon inline with text.
Only when we know this is a contact (direct chat exists). */}
{/* Action row — View posts is universal (anyone can have a wall,
even non-contacts). Open chat appears alongside only when this
address is already a direct-chat contact. */}
<View style={{ flexDirection: 'row', gap: 8, marginTop: 14 }}>
<Pressable
onPress={() => address && router.push(`/(app)/feed/author/${address}` as never)}
style={({ pressed }) => ({
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 11,
borderRadius: 999,
backgroundColor: pressed ? '#1a1a1a' : '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
})}
>
<Ionicons name="document-text-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
View posts
</Text>
</Pressable>
{!isMe && contact && (
<Pressable
onPress={openChat}
style={({ pressed }) => ({
marginTop: 14,
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@@ -177,10 +230,11 @@ export default function ProfileScreen() {
>
<Ionicons name="chatbubble-outline" size={15} color="#ffffff" />
<Text style={{ color: '#ffffff', fontWeight: '600', fontSize: 14 }}>
Открыть чат
Open chat
</Text>
</Pressable>
)}
</View>
{/* ── Info card ───────────────────────────────────────────────── */}
<View
@@ -203,7 +257,7 @@ export default function ProfileScreen() {
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>
Адрес
Address
</Text>
<Text
style={{
@@ -214,7 +268,7 @@ export default function ProfileScreen() {
}}
numberOfLines={1}
>
{copied ? 'Скопировано' : shortAddr(address ?? '')}
{copied ? 'Copied' : shortAddr(address ?? '')}
</Text>
<Ionicons
name={copied ? 'checkmark' : 'copy-outline'}
@@ -224,22 +278,79 @@ export default function ProfileScreen() {
/>
</Pressable>
{/* Username — shown if the on-chain identity record has one.
Different from contact.username (which may be a local alias). */}
{identity?.nickname ? (
<>
<Divider />
<InfoRow
label="Username"
value={`@${identity.nickname}`}
icon="at-outline"
/>
</>
) : null}
{/* DC address — the human-readable form of the pub key. */}
{identity?.address ? (
<>
<Divider />
<InfoRow
label="DC address"
value={identity.address}
icon="pricetag-outline"
/>
</>
) : null}
{/* Balance — always shown once fetched. */}
<Divider />
<InfoRow
label="Balance"
value={loadingChain
? '…'
: `${formatAmount(balanceUT ?? 0)} UT`}
icon="wallet-outline"
/>
{/* Relay node — shown only if this address is a registered relay. */}
{relay && (
<>
<Divider />
<InfoRow
label="Relay node"
value={`${formatAmount(relay.relay.fee_per_msg_ut)} UT / msg`}
icon="radio-outline"
/>
{relay.last_heartbeat ? (
<>
<Divider />
<InfoRow
label="Last seen"
value={new Date(relay.last_heartbeat * 1000).toLocaleString()}
icon="pulse-outline"
/>
</>
) : null}
</>
)}
{/* Encryption status */}
{contact && (
<>
<Divider />
<InfoRow
label="Шифрование"
label="Encryption"
value={contact.x25519Pub
? 'end-to-end (NaCl)'
: 'ключ ещё не опубликован'}
: 'key not published yet'}
danger={!contact.x25519Pub}
icon={contact.x25519Pub ? 'lock-closed' : 'lock-open'}
/>
<Divider />
<InfoRow
label="Добавлен"
label="Added"
value={new Date(contact.addedAt).toLocaleDateString()}
icon="calendar-outline"
/>
@@ -251,7 +362,7 @@ export default function ProfileScreen() {
<>
<Divider />
<InfoRow
label="Участников"
label="Members"
value="—"
icon="people-outline"
/>
@@ -270,7 +381,7 @@ export default function ProfileScreen() {
paddingHorizontal: 24,
lineHeight: 17,
}}>
Этот пользователь пока не в ваших контактах. Нажмите «Подписаться», чтобы видеть его посты в ленте, или добавьте в чаты через @username.
This user isn't in your contacts yet. Tap "Follow" to see their posts in your feed, or add them as a chat contact via @username.
</Text>
)}
</ScrollView>

View File

@@ -91,7 +91,7 @@ export default function RequestsScreen() {
{name}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
wants to message you · {relativeTime(req.timestamp)}
wants to add you as a contact · {relativeTime(req.timestamp)}
</Text>
{req.intro ? (
<Text

View File

@@ -32,7 +32,7 @@ import {
humanizeTxError,
} from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import { formatAmount } from '@/lib/utils';
import { formatAmount, safeBack } from '@/lib/utils';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
@@ -335,7 +335,7 @@ export default function SettingsScreen() {
<Header
title="Settings"
divider={false}
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
<ScrollView
contentContainerStyle={{ paddingBottom: 120 }}
@@ -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,427 @@
/**
* Transaction detail screen — shows everything the block explorer
* does for a single tx, so the user can audit any action they took
* (transfer, post, like, contact request) without leaving the app.
*
* Route: /(app)/tx/[id]
*
* Triggered from: wallet history (TxTile tap). Will also be reachable
* from post detail / profile timestamp once we wire those up (Phase
* v2.1 idea).
*
* Layout matches the style of the profile info card:
* [back] Transaction
*
* [ICON] <TYPE>
* <relative time> · <status>
*
* [amount pill, big, signed ± + tone colour] (for TRANSFER-ish)
*
* Info card rows:
* ID <hash> (tap → copy)
* From <addr> (tap → copy)
* To <addr> (tap → copy)
* Block #N
* Time <human>
* Fee 0.001 T
* Gas 1234 (if CALL_CONTRACT)
* Memo (if set)
*
* [payload section, collapsible — raw JSON or hex]
*/
import React, { useCallback, useEffect, useState } from 'react';
import {
View, Text, ScrollView, ActivityIndicator, Pressable,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { getTxDetail, type TxDetail } from '@/lib/api';
import { useStore } from '@/lib/store';
import { safeBack, formatAmount } from '@/lib/utils';
function shortAddr(a: string, n = 8): string {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
// Copy of the tx-type metadata used by wallet.tsx — keeps the icon +
// label consistent whichever screen surfaces the tx.
function txMeta(type: string): {
icon: React.ComponentProps<typeof Ionicons>['name'];
label: string;
tone: 'in' | 'out' | 'neutral';
} {
switch (type) {
case 'TRANSFER': return { icon: 'swap-horizontal', label: 'Transfer', tone: 'neutral' };
case 'CONTACT_REQUEST': return { icon: 'person-add', label: 'Contact request', tone: 'out' };
case 'ACCEPT_CONTACT': return { icon: 'checkmark-circle', label: 'Accepted contact', tone: 'neutral' };
case 'BLOCK_CONTACT': return { icon: 'ban', label: 'Blocked contact', tone: 'neutral' };
case 'REGISTER_KEY': return { icon: 'key', label: 'Identity registered', tone: 'neutral' };
case 'REGISTER_RELAY': return { icon: 'globe', label: 'Relay registered', tone: 'neutral' };
case 'BIND_WALLET': return { icon: 'wallet', label: 'Wallet bound', tone: 'neutral' };
case 'RELAY_PROOF': return { icon: 'receipt', label: 'Relay proof', tone: 'in' };
case 'BLOCK_REWARD': return { icon: 'trophy', label: 'Block reward', tone: 'in' };
case 'HEARTBEAT': return { icon: 'pulse', label: 'Heartbeat', tone: 'neutral' };
case 'CREATE_POST': return { icon: 'newspaper', label: 'Post published', tone: 'out' };
case 'DELETE_POST': return { icon: 'trash', label: 'Post deleted', tone: 'neutral' };
case 'LIKE_POST': return { icon: 'heart', label: 'Like', tone: 'neutral' };
case 'UNLIKE_POST': return { icon: 'heart-dislike', label: 'Unlike', tone: 'neutral' };
case 'FOLLOW': return { icon: 'person-add', label: 'Follow', tone: 'neutral' };
case 'UNFOLLOW': return { icon: 'person-remove', label: 'Unfollow', tone: 'neutral' };
case 'CALL_CONTRACT': return { icon: 'terminal', label: 'Contract call', tone: 'neutral' };
case 'DEPLOY_CONTRACT': return { icon: 'cube', label: 'Contract deployed', tone: 'neutral' };
case 'STAKE': return { icon: 'lock-closed', label: 'Stake', tone: 'out' };
case 'UNSTAKE': return { icon: 'lock-open', label: 'Unstake', tone: 'in' };
default: return { icon: 'document', label: type || 'Transaction', tone: 'neutral' };
}
}
function toneColor(tone: 'in' | 'out' | 'neutral'): string {
if (tone === 'in') return '#3ba55d';
if (tone === 'out') return '#f4212e';
return '#ffffff';
}
export default function TxDetailScreen() {
const insets = useSafeAreaInsets();
const { id } = useLocalSearchParams<{ id: string }>();
const keyFile = useStore(s => s.keyFile);
const [tx, setTx] = useState<TxDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [payloadOpen, setPayloadOpen] = useState(false);
useEffect(() => {
if (!id) return;
let cancelled = false;
setLoading(true);
setError(null);
getTxDetail(id)
.then(res => { if (!cancelled) setTx(res); })
.catch(e => { if (!cancelled) setError(String(e?.message ?? e)); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [id]);
const copy = useCallback(async (field: string, value: string) => {
await Clipboard.setStringAsync(value);
setCopied(field);
setTimeout(() => setCopied(f => (f === field ? null : f)), 1500);
}, []);
const meta = tx ? txMeta(tx.type) : null;
const mine = keyFile?.pub_key ?? '';
const isMineOut = tx ? tx.from === mine && tx.to !== mine : false;
const isMineIn = tx ? tx.to === mine && tx.from !== mine : false;
const showAmount = tx ? tx.amount_ut > 0 : false;
// Sign based on perspective: money leaving my wallet → minus, coming in → plus.
const sign = isMineOut ? '' : isMineIn ? '+' : '';
const amountColor =
isMineOut ? '#f4212e'
: isMineIn ? '#3ba55d'
: '#ffffff';
return (
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Transaction"
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
/>
{loading ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator color="#1d9bf0" />
</View>
) : error ? (
<View style={{ padding: 24 }}>
<Text style={{ color: '#f4212e', fontSize: 14 }}>{error}</Text>
</View>
) : !tx ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32 }}>
<Ionicons name="help-circle-outline" size={40} color="#3a3a3a" />
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
Not found
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', marginTop: 6 }}>
No transaction with this ID on this chain.
</Text>
</View>
) : (
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
{/* ── Hero row: icon + type + time ───────────────────────── */}
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 16 }}>
<View
style={{
width: 48, height: 48, borderRadius: 14,
backgroundColor: '#111111',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
marginRight: 12,
}}
>
<Ionicons name={meta!.icon} size={22} color="#ffffff" />
</View>
<View style={{ flex: 1, minWidth: 0 }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
{meta!.label}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
{new Date(tx.time).toLocaleString()}
</Text>
</View>
</View>
{/* ── Amount big number — only for txs that move tokens ── */}
{showAmount && (
<View
style={{
alignItems: 'center',
paddingVertical: 18,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
marginBottom: 14,
}}
>
<Text style={{
color: amountColor,
fontSize: 32,
fontWeight: '800',
letterSpacing: -0.8,
}}>
{sign}{formatAmount(tx.amount_ut)}
</Text>
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
{tx.amount}
</Text>
</View>
)}
{/* ── Info card ───────────────────────────────────────────── */}
<View
style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<CopyRow
label="Tx ID"
value={shortAddr(tx.id, 8)}
rawValue={tx.id}
field="id"
copied={copied}
onCopy={copy}
mono
/>
<Divider />
<CopyRow
label="From"
value={shortAddr(tx.from, 8)}
rawValue={tx.from}
field="from"
copied={copied}
onCopy={copy}
mono
highlight={tx.from === mine ? 'you' : undefined}
/>
{tx.to && (
<>
<Divider />
<CopyRow
label="To"
value={shortAddr(tx.to, 8)}
rawValue={tx.to}
field="to"
copied={copied}
onCopy={copy}
mono
highlight={tx.to === mine ? 'you' : undefined}
/>
</>
)}
<Divider />
<InfoRow label="Block" value={`#${tx.block_index}`} />
<Divider />
<InfoRow label="Fee" value={formatAmount(tx.fee_ut)} />
{tx.gas_used ? (
<>
<Divider />
<InfoRow label="Gas used" value={String(tx.gas_used)} />
</>
) : null}
{tx.memo ? (
<>
<Divider />
<InfoRow label="Memo" value={tx.memo} />
</>
) : null}
</View>
{/* ── Payload expand ─────────────────────────────────────── */}
{(tx.payload || tx.payload_hex) && (
<View style={{ marginTop: 14 }}>
<Pressable
onPress={() => setPayloadOpen(o => !o)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
paddingHorizontal: 14,
backgroundColor: pressed ? '#0f0f0f' : '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
borderRadius: 14,
})}
>
<Ionicons name="code-slash" size={14} color="#8b8b8b" />
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600', marginLeft: 8, flex: 1 }}>
Payload
</Text>
<Ionicons
name={payloadOpen ? 'chevron-up' : 'chevron-down'}
size={14}
color="#6a6a6a"
/>
</Pressable>
{payloadOpen && (
<View
style={{
marginTop: 8,
padding: 12,
borderRadius: 12,
backgroundColor: '#050505',
borderWidth: 1, borderColor: '#1f1f1f',
}}
>
<Text
selectable
style={{
color: '#d0d0d0',
fontSize: 11,
fontFamily: 'monospace',
lineHeight: 16,
}}
>
{tx.payload
? JSON.stringify(tx.payload, null, 2)
: `hex: ${tx.payload_hex}`}
</Text>
</View>
)}
</View>
)}
{/* Signature as a final copyable row, small */}
{tx.signature_hex && (
<View
style={{
marginTop: 14,
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
overflow: 'hidden',
}}
>
<CopyRow
label="Signature"
value={shortAddr(tx.signature_hex, 10)}
rawValue={tx.signature_hex}
field="signature"
copied={copied}
onCopy={copy}
mono
/>
</View>
)}
</ScrollView>
)}
</View>
);
}
// ── Rows ──────────────────────────────────────────────────────────────
function Divider() {
return <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />;
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
}}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '600' }} numberOfLines={1}>
{value}
</Text>
</View>
);
}
function CopyRow({
label, value, rawValue, field, copied, onCopy, mono, highlight,
}: {
label: string;
value: string;
rawValue: string;
field: string;
copied: string | null;
onCopy: (field: string, value: string) => void;
mono?: boolean;
highlight?: 'you';
}) {
const isCopied = copied === field;
return (
<Pressable
onPress={() => onCopy(field, rawValue)}
style={({ pressed }) => ({
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 14,
paddingVertical: 12,
backgroundColor: pressed ? '#0f0f0f' : 'transparent',
})}
>
<Text style={{ color: '#8b8b8b', fontSize: 13, flex: 1 }}>{label}</Text>
<Text
style={{
color: isCopied
? '#3ba55d'
: highlight === 'you'
? '#1d9bf0'
: '#ffffff',
fontSize: 13,
fontFamily: mono ? 'monospace' : undefined,
fontWeight: '600',
marginRight: 8,
}}
numberOfLines={1}
>
{isCopied
? 'Copied'
: highlight === 'you'
? `${value} (you)`
: value}
</Text>
<Ionicons
name={isCopied ? 'checkmark' : 'copy-outline'}
size={13}
color={isCopied ? '#3ba55d' : '#6a6a6a'}
/>
</Pressable>
);
}

View File

@@ -0,0 +1,19 @@
/**
* Tx detail layout — native Stack so router.back() pops back to the
* screen that pushed us (wallet history, chat tx link, etc.) instead
* of falling through to the outer Slot-level root.
*/
import React from 'react';
import { Stack } from 'expo-router';
export default function TxLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: '#000000' },
animation: 'slide_from_right',
}}
/>
);
}

View File

@@ -17,6 +17,7 @@ import {
View, Text, ScrollView, Modal, Alert, RefreshControl, Pressable, TextInput, ActivityIndicator,
} from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@@ -346,7 +347,7 @@ function TxTile({
const color = toneColor(m.tone);
return (
<Pressable>
<Pressable onPress={() => router.push(`/(app)/tx/${tx.hash}` as never)}>
<View
style={{
flexDirection: 'row',

View File

@@ -10,6 +10,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { generateKeyFile } from '@/lib/crypto';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
@@ -37,7 +38,7 @@ export default function CreateAccountScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Create account"
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/>
<ScrollView contentContainerStyle={{ padding: 14, paddingBottom: 40 }}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700', marginBottom: 4 }}>

View File

@@ -15,6 +15,7 @@ import * as DocumentPicker from 'expo-document-picker';
import * as Clipboard from 'expo-clipboard';
import { saveKeyFile } from '@/lib/storage';
import { useStore } from '@/lib/store';
import { safeBack } from '@/lib/utils';
import type { KeyFile } from '@/lib/types';
import { Header } from '@/components/Header';
@@ -96,7 +97,7 @@ export default function ImportKeyScreen() {
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
<Header
title="Import key"
left={<IconButton icon="chevron-back" size={36} onPress={() => router.back()} />}
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack('/')} />}
/>
<ScrollView
contentContainerStyle={{ padding: 14, paddingBottom: 40 }}

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

@@ -187,17 +187,17 @@ export default function WelcomeScreen() {
<FeatureRow
icon="lock-closed"
title="End-to-end encryption"
text="X25519 + NaCl на каждом сообщении. Даже релей-нода не может прочитать переписку."
text="X25519 + NaCl on every message. Not even the relay node can read your conversations."
/>
<FeatureRow
icon="key"
title="Твои ключи — твой аккаунт"
text="Без телефона, email и серверных паролей. Ключи никогда не покидают устройство."
title="Your keys, your account"
text="No phone, email, or server passwords. Keys never leave your device."
/>
<FeatureRow
icon="git-network"
title="Decentralised"
text="Любой может поднять свою ноду. Нет единой точки отказа и цензуры."
text="Anyone can run a node. No single point of failure or censorship."
/>
</ScrollView>
@@ -206,7 +206,7 @@ export default function WelcomeScreen() {
flexDirection: 'row', justifyContent: 'flex-end',
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTAPrimary label="Продолжить" onPress={() => goToPage(1)} />
<CTAPrimary label="Continue" onPress={() => goToPage(1)} />
</View>
</View>
@@ -223,22 +223,22 @@ export default function WelcomeScreen() {
keyboardShouldPersistTaps="handled"
>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5 }}>
Как это работает
How it works
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, marginBottom: 22 }}>
Сообщения проходят через релей-ноду в зашифрованном виде.
Выбери публичную или подключи свою.
Messages travel through a relay node in encrypted form.
Pick a public one or run your own.
</Text>
<OptionCard
icon="globe"
title="Публичная нода"
text="Удобно и быстро — нода хостится комьюнити, небольшая комиссия за каждое отправленное сообщение."
title="Public node"
text="Quick and easy — community-hosted relay, small fee per delivered message."
/>
<OptionCard
icon="hardware-chip"
title="Своя нода"
text="Максимальный контроль. Исходники открыты — подними на своём сервере за 5 минут."
title="Self-hosted"
text="Maximum control. Source is open — spin up your own in five minutes."
/>
<Text style={{
@@ -302,11 +302,11 @@ export default function WelcomeScreen() {
paddingHorizontal: 24, paddingBottom: 8,
}}>
<CTASecondary
label="Исходники"
label="Source"
icon="logo-github"
onPress={() => Linking.openURL(GITEA_URL).catch(() => {})}
/>
<CTAPrimary label="Продолжить" onPress={() => goToPage(2)} />
<CTAPrimary label="Continue" onPress={() => goToPage(2)} />
</View>
</View>
@@ -334,11 +334,11 @@ export default function WelcomeScreen() {
<Ionicons name="key" size={44} color="#1d9bf0" />
</View>
<Text style={{ color: '#ffffff', fontSize: 24, fontWeight: '800', letterSpacing: -0.5, textAlign: 'center' }}>
Твой аккаунт
Your account
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 14, lineHeight: 20, marginTop: 8, textAlign: 'center', maxWidth: 280 }}>
Создай новую пару ключей или импортируй существующую.
Ключи хранятся только на этом устройстве.
Generate a fresh keypair or import an existing one.
Keys stay on this device only.
</Text>
</View>
</ScrollView>
@@ -346,14 +346,19 @@ 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="Импорт"
label="Pair"
icon="link"
onPress={() => router.push('/(auth)/pair' as never)}
/>
<CTASecondary
label="Import"
onPress={() => router.push('/(auth)/import' as never)}
/>
<CTAPrimary
label="Создать аккаунт"
label="Create account"
onPress={() => router.push('/(auth)/create' as never)}
/>
</View>

View File

@@ -6,6 +6,7 @@
*/
import React from 'react';
import { View, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface AvatarProps {
/** Имя / @username — берём первый символ для placeholder. */
@@ -18,6 +19,11 @@ export interface AvatarProps {
dotColor?: string;
/** Класс для обёртки (position: relative кадр). */
className?: string;
/**
* Saved Messages variant — blue circle with a bookmark glyph, Telegram-style.
* When set, `name`/`address` are ignored for the visual.
*/
saved?: boolean;
}
/** Простое хэширование имени → один из 6 оттенков серого для разнообразия. */
@@ -28,10 +34,10 @@ function pickBg(seed: string): string {
return shades[h % shades.length];
}
export function Avatar({ name, address, size = 48, dotColor, className }: AvatarProps) {
export function Avatar({ name, address, size = 48, dotColor, className, saved }: AvatarProps) {
const seed = (name ?? address ?? '?').replace(/^@/, '');
const initial = seed.charAt(0).toUpperCase() || '?';
const bg = pickBg(seed);
const bg = saved ? '#1d9bf0' : pickBg(seed);
return (
<View className={className} style={{ width: size, height: size, position: 'relative' }}>
@@ -45,6 +51,9 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
justifyContent: 'center',
}}
>
{saved ? (
<Ionicons name="bookmark" size={size * 0.5} color="#ffffff" />
) : (
<Text
style={{
color: '#d0d0d0',
@@ -55,6 +64,7 @@ export function Avatar({ name, address, size = 48, dotColor, className }: Avatar
>
{initial}
</Text>
)}
</View>
{dotColor && (
<View

View File

@@ -57,10 +57,12 @@ export interface ChatTileProps {
contact: Contact;
lastMessage: Message | null;
onPress: () => void;
/** Render as the Saved Messages tile (blue bookmark avatar, fixed name). */
saved?: boolean;
}
export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
const name = displayName(c);
export function ChatTile({ contact: c, lastMessage, onPress, saved }: ChatTileProps) {
const name = saved ? 'Saved Messages' : displayName(c);
const last = lastMessage;
// Визуальный маркер типа чата.
@@ -92,7 +94,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
name={name}
address={c.address}
size={44}
dotColor={c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
saved={saved}
dotColor={!saved && c.x25519Pub && (!c.kind || c.kind === 'direct') ? '#3ba55d' : undefined}
/>
<View style={{ flex: 1, marginLeft: 12, minWidth: 0 }}>
@@ -143,6 +146,8 @@ export function ChatTile({ contact: c, lastMessage, onPress }: ChatTileProps) {
>
{last
? lastPreview(last)
: saved
? 'Your personal notes & files'
: c.x25519Pub
? 'Tap to start encrypted chat'
: 'Waiting for identity…'}

View File

@@ -1,12 +1,11 @@
/**
* SearchBar — серый блок, в состоянии idle текст с иконкой 🔍 отцентрированы.
*
* Когда пользователь тапает/фокусирует — поле становится input-friendly, но
* визуально рестайл не нужен: при наличии текста placeholder скрыт и
* пользовательский ввод выравнивается влево автоматически (multiline off).
* SearchBar — single-TextInput pill. Icon + input в одном ряду, без
* idle/focused двойного состояния (раньше был хак с невидимым
* TextInput поверх отцентрированного Text — ломал focus и выравнивание
* на Android).
*/
import React, { useState } from 'react';
import { View, TextInput, Text } from 'react-native';
import React from 'react';
import { View, TextInput, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
export interface SearchBarProps {
@@ -15,73 +14,55 @@ export interface SearchBarProps {
placeholder?: string;
autoFocus?: boolean;
onSubmitEditing?: () => void;
onClear?: () => void;
}
export function SearchBar({
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing,
value, onChangeText, placeholder = 'Search', autoFocus, onSubmitEditing, onClear,
}: SearchBarProps) {
const [focused, setFocused] = useState(false);
// Placeholder центрируется пока нет фокуса И нет значения.
// Как только юзер фокусируется или начинает печатать — иконка+текст
// прыгают к левому краю, чтобы не мешать вводу.
const centered = !focused && !value;
return (
<View
style={{
backgroundColor: '#1a1a1a',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#111111',
borderWidth: 1,
borderColor: '#1f1f1f',
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
minHeight: 36,
justifyContent: 'center',
gap: 8,
}}
>
{centered ? (
// ── Idle state — только текст+icon, отцентрированы.
// Невидимый TextInput поверх ловит tap, чтобы не дергать focus вручную.
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 6 }} />
<Text style={{ color: '#8b8b8b', fontSize: 14 }}>{placeholder}</Text>
<TextInput
value={value}
onChangeText={onChangeText}
autoFocus={autoFocus}
onFocus={() => setFocused(true)}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
position: 'absolute', left: 0, right: 0, top: 0, bottom: 0,
color: 'transparent',
// Скрываем cursor в idle-режиме; при focus компонент перерисуется.
}}
/>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Ionicons name="search" size={14} color="#8b8b8b" style={{ marginRight: 8 }} />
<Ionicons name="search" size={16} color="#6a6a6a" />
<TextInput
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor="#8b8b8b"
placeholderTextColor="#5a5a5a"
autoCapitalize="none"
autoCorrect={false}
autoFocus
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
autoFocus={autoFocus}
onSubmitEditing={onSubmitEditing}
returnKeyType="search"
style={{
flex: 1,
color: '#ffffff',
fontSize: 14,
paddingVertical: 10,
padding: 0,
includeFontPadding: false,
}}
/>
</View>
{value.length > 0 && (
<Pressable
onPress={() => {
onChangeText('');
onClear?.();
}}
hitSlop={8}
>
<Ionicons name="close-circle" size={16} color="#6a6a6a" />
</Pressable>
)}
</View>
);

View File

@@ -90,7 +90,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
letterSpacing: 1.2,
}}
>
ПОСТ
POST
</Text>
</View>
@@ -132,7 +132,7 @@ export function PostRefCard({ postID, author, excerpt, hasImage, own }: PostRefC
>
<Ionicons name="image-outline" size={11} color={subColor} />
<Text style={{ color: subColor, fontSize: 11 }}>
с фото
photo
</Text>
</View>
)}

View File

@@ -80,11 +80,16 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Find a display-friendly name for the author. If it's a known contact
// with @username, use that; otherwise short-addr.
//
// `mine` takes precedence over the contact lookup: our own pub key has
// a self-contact entry with alias "Saved Messages" (that's how the
// self-chat tile is rendered), but that label is wrong in the feed —
// posts there should read as "You", not as a messaging-app affordance.
const displayName = useMemo(() => {
if (mine) return 'You';
const c = contacts.find(x => x.address === post.author);
if (c?.username) return `@${c.username}`;
if (c?.alias) return c.alias;
if (mine) return 'You';
return shortAddr(post.author);
}, [contacts, post.author, mine]);
@@ -109,7 +114,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
// Roll back optimistic update.
setLocalLiked(wasLiked);
setLocalLikeCount(c => c + (wasLiked ? 1 : -1));
Alert.alert('Не удалось', String(e?.message ?? e));
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setBusy(false);
}
@@ -128,13 +133,13 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
const options: Array<{ label: string; destructive?: boolean; onPress: () => void }> = [];
if (mine) {
options.push({
label: 'Удалить пост',
label: 'Delete post',
destructive: true,
onPress: () => {
Alert.alert('Удалить пост?', 'Это действие нельзя отменить.', [
{ text: 'Отмена', style: 'cancel' },
Alert.alert('Delete post?', 'This action cannot be undone.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Удалить',
text: 'Delete',
style: 'destructive',
onPress: async () => {
try {
@@ -145,7 +150,7 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
});
onDeleted?.(post.post_id);
} catch (e: any) {
Alert.alert('Ошибка', String(e?.message ?? e));
Alert.alert('Error', String(e?.message ?? e));
}
},
},
@@ -160,9 +165,9 @@ function PostCardInner({ post, likedByMe, onStatsChanged, onDeleted, compact }:
style: (o.destructive ? 'destructive' : 'default') as 'default' | 'destructive',
onPress: o.onPress,
})),
{ text: 'Отмена', style: 'cancel' as const },
{ text: 'Cancel', style: 'cancel' as const },
];
Alert.alert('Действия', '', buttons);
Alert.alert('Actions', '', buttons);
}, [keyFile, mine, post.post_id, onDeleted]);
// Attachment preview URL — native Image can stream straight from the

View File

@@ -74,14 +74,14 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
post, contacts: targets, keyFile,
});
if (failed > 0) {
Alert.alert('Готово', `Отправлено в ${ok} из ${ok + failed} чат${plural(ok + failed)}.`);
Alert.alert('Done', `Sent to ${ok} of ${ok + failed} ${plural(ok + failed)}.`);
}
// Close + reset regardless — done is done.
setPicked(new Set());
setQuery('');
onClose();
} catch (e: any) {
Alert.alert('Не удалось', String(e?.message ?? e));
Alert.alert('Failed', String(e?.message ?? e));
} finally {
setSending(false);
}
@@ -136,7 +136,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
paddingHorizontal: 16, marginBottom: 10,
}}>
<Text style={{ color: '#ffffff', fontSize: 17, fontWeight: '700' }}>
Поделиться постом
Share post
</Text>
<View style={{ flex: 1 }} />
<Pressable onPress={closeAndReset} hitSlop={8}>
@@ -158,7 +158,7 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Поиск по контактам"
placeholder="Search contacts"
placeholderTextColor="#5a5a5a"
style={{
flex: 1,
@@ -196,8 +196,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
<Ionicons name="people-outline" size={28} color="#5a5a5a" />
<Text style={{ color: '#8b8b8b', fontSize: 13, marginTop: 10 }}>
{query.length > 0
? 'Нет контактов по такому запросу'
: 'Контакты с ключами шифрования отсутствуют'}
? 'No contacts match this search'
: 'No contacts with encryption keys yet'}
</Text>
</View>
}
@@ -227,8 +227,8 @@ export function ShareSheet({ visible, post, onClose }: ShareSheetProps) {
fontSize: 14,
}}>
{picked.size === 0
? 'Выберите контакты'
: `Отправить (${picked.size})`}
? 'Select contacts'
: `Send (${picked.size})`}
</Text>
)}
</Pressable>
@@ -298,10 +298,5 @@ function shortAddr(a: string, n = 6): string {
}
function plural(n: number): string {
const mod100 = n % 100;
const mod10 = n % 10;
if (mod100 >= 11 && mod100 <= 19) return 'ов';
if (mod10 === 1) return '';
if (mod10 >= 2 && mod10 <= 4) return 'а';
return 'ов';
return n === 1 ? 'chat' : 'chats';
}

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

@@ -79,23 +79,23 @@ async function post<T>(path: string, body: unknown): Promise<T> {
export function humanizeTxError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw.startsWith('429')) {
return 'Слишком много запросов к ноде. Подождите пару секунд и попробуйте снова.';
return 'Too many requests to the node. Wait a couple of seconds and try again.';
}
if (raw.startsWith('400') && raw.includes('timestamp')) {
return 'Часы устройства не синхронизированы с нодой. Проверьте время на телефоне (±1 час).';
return 'Device clock is out of sync with the node. Check the time on your phone (±1 hour).';
}
if (raw.startsWith('400') && raw.includes('signature')) {
return 'Подпись транзакции невалидна. Попробуйте ещё раз; если не помогает — вероятна несовместимость версий клиента и ноды.';
return 'Transaction signature is invalid. Try again; if this persists the client and node versions may be incompatible.';
}
if (raw.startsWith('400')) {
return `Нода отклонила транзакцию: ${raw.replace(/^400:\s*/, '')}`;
return `Node rejected transaction: ${raw.replace(/^400:\s*/, '')}`;
}
if (raw.startsWith('5')) {
return `Ошибка ноды (${raw}). Попробуйте позже.`;
return `Node error (${raw}). Please try again later.`;
}
// Network-level
if (raw.toLowerCase().includes('network request failed')) {
return 'Нет связи с нодой. Проверьте URL в настройках и доступность сервера.';
return 'Cannot reach the node. Check the URL in settings and that the server is online.';
}
return raw;
}
@@ -191,6 +191,43 @@ export async function submitTx(tx: RawTx): Promise<{ id: string; status: string
}
}
/**
* Full transaction detail as returned by GET /api/tx/{id}. Matches the
* explorer's txDetail wire format. Payload is JSON-decoded when the
* node recognises the tx type, otherwise payload_hex is set.
*/
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; // ISO-8601 UTC
block_index: number;
block_hash: string;
block_time: string; // ISO-8601 UTC
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
/** Fetch full tx detail by hash/id. Returns null on 404. */
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e: any) {
if (/→\s*404\b/.test(String(e?.message))) return null;
throw e;
}
}
export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord[]> {
const data = await get<AddrResponse>(`/api/address/${pubkey}?limit=${limit}`);
return (data.transactions ?? []).map(tx => ({
@@ -227,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;
@@ -291,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)),
@@ -302,7 +343,7 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
// ─── Contact requests (on-chain) ─────────────────────────────────────────────
/**
* Maps blockchain.ContactInfo returned by GET /api/relay/contacts?pub=...
* Maps blockchain.ContactInfo returned by GET /relay/contacts?pub=...
* The response shape is { pub, count, contacts: ContactInfo[] }.
*/
export interface ContactRequestRaw {
@@ -316,7 +357,7 @@ export interface ContactRequestRaw {
}
export async function fetchContactRequests(edPubHex: string): Promise<ContactRequestRaw[]> {
const data = await get<{ contacts: ContactRequestRaw[] }>(`/api/relay/contacts?pub=${edPubHex}`);
const data = await get<{ contacts: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPubHex}`);
return data.contacts ?? [];
}
@@ -328,6 +369,58 @@ 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
}
/**
* Relay registration info for a node pub key, as returned by
* /api/relays (which comes back as an array of RegisteredRelayInfo).
* We don't wrap the individual lookup on the server — just filter the
* full list client-side. It's bounded (N nodes in the network) and
* cached heavily enough that this is cheaper than a new endpoint.
*/
export interface RegisteredRelayInfo {
pub_key: string;
address: string;
relay: {
x25519_pub_key: string;
fee_per_msg_ut: number;
multiaddr?: string;
};
last_heartbeat?: number; // unix seconds
}
/** GET /api/relays — all relay nodes registered on-chain. */
export async function getRelays(): Promise<RegisteredRelayInfo[]> {
try {
return await get<RegisteredRelayInfo[]>('/api/relays');
} catch {
return [];
}
}
/** Find relay entry for a specific pub key. null if the address isn't a relay. */
export async function getRelayFor(pubKey: string): Promise<RegisteredRelayInfo | null> {
const all = await getRelays();
return all.find(r => r.pub_key === pubKey) ?? null;
}
/** Fetch identity info for any pubkey or DC address. Returns null on 404. */
@@ -339,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 ─────────────────────────────────────────────────────────────
/**
@@ -642,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

@@ -5,10 +5,10 @@
* который тоже в секундах). Для ms-таймштампов делаем нормализацию внутри.
*/
// ─── Русские месяцы (genitive для "17 июня 2025") ────────────────────────────
const RU_MONTHS_GEN = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря',
// English short month names ("Jun 17, 2025").
const MONTHS_SHORT = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
];
function sameDay(a: Date, b: Date): boolean {
@@ -20,8 +20,8 @@ function sameDay(a: Date, b: Date): boolean {
}
/**
* Day-bucket label для сепараторов внутри чата.
* "Сегодня" / "Вчера" / "17 июня 2025"
* Day-bucket label for chat separators.
* "Today" / "Yesterday" / "Jun 17, 2025"
*
* @param ts unix-seconds
*/
@@ -29,9 +29,9 @@ export function dateBucket(ts: number): string {
const d = new Date(ts * 1000);
const now = new Date();
const yday = new Date(); yday.setDate(now.getDate() - 1);
if (sameDay(d, now)) return 'Сегодня';
if (sameDay(d, yday)) return 'Вчера';
return `${d.getDate()} ${RU_MONTHS_GEN[d.getMonth()]} ${d.getFullYear()}`;
if (sameDay(d, now)) return 'Today';
if (sameDay(d, yday)) return 'Yesterday';
return `${MONTHS_SHORT[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
/**

View File

@@ -1,444 +0,0 @@
/**
* Dev seed — заполняет store фейковыми контактами и сообщениями для UI-теста.
*
* Запускается один раз при монтировании layout'а если store пустой
* (useDevSeed). Реальные контакты через WS/HTTP приходят позже —
* `upsertContact` перезаписывает mock'и по address'у.
*
* Цели seed'а:
* 1. Показать все три типа чатов (direct / group / channel) с разным
* поведением sender-meta.
* 2. Наполнить список чатов до скролла (15+ контактов).
* 3. В каждом чате — ≥15 сообщений для скролла в chat view.
* 4. Продемонстрировать "staircase" (run'ы одного отправителя
* внутри 1h-окна) и переключения между отправителями.
*/
import { useEffect } from 'react';
import { useStore } from './store';
import type { Contact, Message } from './types';
// ─── Детерминированные «pubkey» (64 hex символа) ───────────────────
function fakeHex(seed: number): string {
let h = '';
let x = seed;
for (let i = 0; i < 32; i++) {
x = (x * 1103515245 + 12345) & 0xffffffff;
h += (x & 0xff).toString(16).padStart(2, '0');
}
return h;
}
const now = () => Math.floor(Date.now() / 1000);
const MINE = fakeHex(9999);
// ─── Контакты ──────────────────────────────────────────────────────
// 16 штук: 5 DM + 6 групп + 5 каналов. Поле `addedAt` задаёт порядок в
// списке когда нет messages — ordering-fallback.
const mockContacts: Contact[] = [
// ── DM ──────────────────────────────────────────────────────────
{ address: fakeHex(1001), x25519Pub: fakeHex(2001),
username: 'jordan', addedAt: Date.now() - 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1002), x25519Pub: fakeHex(2002),
alias: 'Myles Wagner', addedAt: Date.now() - 2 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1010), x25519Pub: fakeHex(2010),
username: 'sarah_k', addedAt: Date.now() - 3 * 60 * 60 * 1_000, kind: 'direct',
unread: 2 },
{ address: fakeHex(1011), x25519Pub: fakeHex(2011),
alias: 'Mom', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'direct' },
{ address: fakeHex(1012), x25519Pub: fakeHex(2012),
username: 'alex_dev', addedAt: Date.now() - 6 * 60 * 60 * 1_000, kind: 'direct' },
// ── Groups ─────────────────────────────────────────────────────
{ address: fakeHex(1003), x25519Pub: fakeHex(2003),
alias: 'Tahoe weekend 🌲', addedAt: Date.now() - 4 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1004), x25519Pub: fakeHex(2004),
alias: 'Knicks tickets', addedAt: Date.now() - 5 * 60 * 60 * 1_000, kind: 'group',
unread: 3 },
{ address: fakeHex(1020), x25519Pub: fakeHex(2020),
alias: 'Family', addedAt: Date.now() - 8 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1021), x25519Pub: fakeHex(2021),
alias: 'Work eng', addedAt: Date.now() - 12 * 60 * 60 * 1_000, kind: 'group',
unread: 7 },
{ address: fakeHex(1022), x25519Pub: fakeHex(2022),
alias: 'Book club', addedAt: Date.now() - 24 * 60 * 60 * 1_000, kind: 'group' },
{ address: fakeHex(1023), x25519Pub: fakeHex(2023),
alias: 'Tuesday D&D 🎲', addedAt: Date.now() - 30 * 60 * 60 * 1_000, kind: 'group' },
// (Channel seeds removed in v2.0.0 — channels replaced by the social feed.)
];
// ─── Генератор сообщений ───────────────────────────────────────────
// Альт-отправители для group-чатов — нужны только как идентификатор `from`.
const P_TYRA = fakeHex(3001);
const P_MYLES = fakeHex(3002);
const P_NATE = fakeHex(3003);
const P_TYLER = fakeHex(3004);
const P_MOM = fakeHex(3005);
const P_DAD = fakeHex(3006);
const P_SIS = fakeHex(3007);
const P_LEAD = fakeHex(3008);
const P_PM = fakeHex(3009);
const P_QA = fakeHex(3010);
const P_DESIGN= fakeHex(3011);
const P_ANNA = fakeHex(3012);
const P_DM_PEER = fakeHex(3013);
type Msg = Omit<Message, 'id'>;
function list(prefix: string, list: Msg[]): Message[] {
return list.map((m, i) => ({ ...m, id: `${prefix}_${i}` }));
}
function mockMessagesFor(contact: Contact): Message[] {
const peer = contact.x25519Pub;
// ── DM: @jordan ────────────────────────────────────────────────
if (contact.username === 'jordan') {
// Важно: id'ы сообщений используются в replyTo.id, поэтому
// указываем их явно где нужно сшить thread.
const msgs: Message[] = list('jordan', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 22, text: 'Hey, have a sec later today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 21, read: true, text: 'yep around 4pm' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'cool, coffee at the corner spot?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 19, read: true, text: 'works' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'just parked 🚗' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'see you in 5' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 3, read: true, text: "that was a great catchup" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: "totally — thanks for the book rec" },
{ from: peer, mine: false, timestamp: now() - 60 * 40, text: 'Hey Jordan - Got tickets to the Knicks game tomorrow, let me know if you want to come!' },
{ from: peer, mine: false, timestamp: now() - 60 * 39, text: "we've got floor seats 🔥" },
{ from: peer, mine: false, timestamp: now() - 60 * 38, text: "starts at 7, pregame at the bar across the street" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: 'Ah sadly I already have plans' },
{ from: MINE, mine: true, timestamp: now() - 60 * 13, read: true, text: 'maybe next time?' },
{ from: peer, mine: false, timestamp: now() - 60 * 5, text: "no worries — enjoy whatever you're up to" },
{ from: peer, mine: false, timestamp: now() - 60 * 2, text: "wish you could make it tho 🏀" },
]);
// Пришьём reply: MINE-сообщение "Ah sadly…" отвечает на "Hey Jordan - Got tickets…"
const target = msgs.find(m => m.text.startsWith('Hey Jordan - Got tickets'));
const mine = msgs.find(m => m.text === 'Ah sadly I already have plans');
if (target && mine) {
mine.replyTo = {
id: target.id,
author: '@jordan',
text: target.text,
};
}
return msgs;
}
// ── DM: Myles Wagner ───────────────────────────────────────────
if (contact.alias === 'Myles Wagner') {
return list('myles', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'saw the draft, left a bunch of comments' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 29, read: true, text: 'thx, going through them now' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 29, text: 'no rush — tomorrow is fine' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'lunch today?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: "can't, stuck in reviews" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 4, read: true, text: 'tomorrow?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: '✅ tomorrow' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: '',
attachment: {
kind: 'voice',
uri: 'voice-demo://myles-1',
duration: 17,
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'the dchain repo finally built for me' },
{ from: peer, mine: false, timestamp: now() - 60 * 25, text: 'docker weirdness was the issue' },
{ from: MINE, mine: true, timestamp: now() - 60 * 21, read: true, text: "nice, told you the WSL path would do it" },
{ from: peer, mine: false, timestamp: now() - 60 * 20, text: 'So good!' },
]);
}
// ── DM: @sarah_k (с unread=2) ──────────────────────────────────
if (contact.username === 'sarah_k') {
return list('sarah', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: "hey! been a while" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 28, read: true, text: 'yeah, finally surfaced after the launch crunch' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 27, text: 'how did it go?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 27, read: true, text: "pretty well actually 🙏" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'btw drinks on friday?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'that new wine bar' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'around 7 if you can make it' },
]);
}
// ── DM: Mom ────────────────────────────────────────────────────
if (contact.alias === 'Mom') {
return list('mom', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Did you see the photos from the trip?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 47, read: true, text: 'not yet, send them again?' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 47, text: 'ok' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 46, text: '',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: 'wow, grandma looks great' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'she asked about you!' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'call later?' },
]);
}
// ── DM: @alex_dev ──────────────────────────────────────────────
if (contact.username === 'alex_dev') {
return list('alex', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'did you try the new WASM build?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 11, read: true, text: 'yeah, loader error on start' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 11, text: 'path encoding issue again?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 10, read: true, text: 'probably, checking now' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 8, read: true, text: 'yep, was the trailing slash' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 8, text: 'classic 😅' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 7, text: 'PR for that incoming tomorrow' },
]);
}
// ── Group: Tahoe weekend 🌲 ────────────────────────────────────
if (contact.alias === 'Tahoe weekend 🌲') {
const msgs: Message[] = list('tahoe', [
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: "who's in for Tahoe this weekend?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 49, text: "me!" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 48, read: true, text: "count me in" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: "woohoo 🎉" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 47, text: "planning friday night → sunday evening yeah?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 46, text: "yep, maybe leave friday after lunch" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 1: Eagle Falls hike" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 2: Emerald bay kayak" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: "Day 3: lazy breakfast then drive back" },
{
from: P_MYLES, mine: false, timestamp: now() - 60 * 60 * 30, text: '',
attachment: {
kind: 'file',
uri: 'https://example.com/Lake_Tahoe_Itinerary.pdf',
name: 'Lake_Tahoe_Itinerary.pdf',
size: 97_280, // ~95 KB
mime: 'application/pdf',
},
},
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 24, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "Eagle falls was stunning last year!" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 31, text: "who's excited for Tahoe this weekend?" },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 30, text: "I've been checking the forecast — sun all weekend 🌞" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 22, text: "I made this itinerary with Grok, what do you think?" },
{ from: P_MYLES, mine: false, timestamp: now() - 60 * 21, text: "Day 1 we can hit Eagle Falls" },
{ from: MINE, mine: true, timestamp: now() - 60 * 14, read: true, edited: true, text: "Love it — Eagle falls looks insane" },
{
from: P_TYRA, mine: false, timestamp: now() - 60 * 3, text: 'pic from my last trip 😍',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1505245208761-ba872912fac0?w=800',
width: 800,
height: 1000,
mime: 'image/jpeg',
},
},
]);
// Thread: mine "Love it — Eagle falls looks insane" — ответ на
// Myles'овский itinerary-PDF. Берём ПЕРВЫЙ match "Day 1 we can hit
// Eagle Falls" и пришиваем его к первому mine-bubble'у.
const target = msgs.find(m => m.text === 'Day 1 we can hit Eagle Falls');
const reply = msgs.find(m => m.text === 'Love it — Eagle falls looks insane' && m.mine);
if (target && reply) {
reply.replyTo = {
id: target.id,
author: 'Myles Wagner',
text: target.text,
};
}
return msgs;
}
// ── Group: Knicks tickets ──────────────────────────────────────
if (contact.alias === 'Knicks tickets') {
return list('knicks', [
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 20, text: "quick group update — got 5 tickets for thursday" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'wow nice' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 60 * 19, text: 'where are we seated?' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 60 * 19, text: 'section 102, row 12' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 18, read: true, text: 'thats a great spot' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 18, text: "can someone venmo nate 🙏" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 17, read: true, text: 'sending now' },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 32, text: "Ok who's in for tomorrow's game?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 31, text: 'Got 2 extra tickets, first-come-first-served' },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 27, text: "I'm in!" },
{ from: P_TYLER, mine: false, timestamp: now() - 60 * 26, text: 'What time does it start?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 20, read: true, text: "Let's meet at the bar around 6?" },
{ from: P_NATE, mine: false, timestamp: now() - 60 * 15, text: 'Sounds good' },
]);
}
// ── Group: Family ──────────────────────────────────────────────
if (contact.alias === 'Family') {
return list('family', [
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 36, text: 'remember grandma birthday on sunday' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 36, text: 'noted 🎂' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 35, text: 'who is bringing the cake?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 35, text: "I'll get it from the bakery" },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 34, read: true, text: 'I can pick up flowers' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 34, text: 'perfect' },
{ from: P_DAD, mine: false, timestamp: now() - 60 * 60 * 8, text: 'forecast is rain sunday — backup plan?' },
{ from: P_MOM, mine: false, timestamp: now() - 60 * 60 * 8, text: "we'll move indoors, the living room works" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 7, text: 'works!' },
]);
}
// ── Group: Work eng (unread=7) ─────────────────────────────────
if (contact.alias === 'Work eng') {
return list('work', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 16, text: 'standup at 10 moved to 11 today btw' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 16, text: 'thanks!' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 15, text: "the staging deploy broke again 🙃" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 15, text: "ugh, looking" },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 14, text: 'fixed — migration was stuck' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 13, read: true, text: 'Worked for me now 👍' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 5, text: 'reminder: demo tomorrow, slides by eod' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Ill handle the technical half' },
{ from: P_DESIGN,mine: false,timestamp: now() - 60 * 60 * 4, text: 'just posted the v2 mocks in figma' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 60 * 3, text: 'chatting with sales — 3 new trials this week' },
{ from: P_QA, mine: false, timestamp: now() - 60 * 60 * 2, text: 'flaky test on CI — investigating' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 30, text: 'okay seems like CI is green now' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 28, text: 'retry passed' },
{ from: P_PM, mine: false, timestamp: now() - 60 * 20, text: "we're good for release" },
]);
}
// ── Group: Book club ───────────────────────────────────────────
if (contact.alias === 'Book club') {
return list('book', [
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 96, text: 'next month: "Project Hail Mary"?' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 95, read: true, text: '👍' },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 94, text: 'yes please' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'halfway through — so good' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 48, text: 'love the linguistics angle' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 30, read: true, text: "rocky is my favourite character in years" },
{ from: P_SIS, mine: false, timestamp: now() - 60 * 60 * 28, text: 'agreed' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 24, text: "let's meet sunday 4pm?" },
]);
}
// ── Group: Tuesday D&D 🎲 ──────────────────────────────────────
if (contact.alias === 'Tuesday D&D 🎲') {
return list('dnd', [
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 72, text: 'Session 14 recap up on the wiki' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 72, text: '🙏' },
{ from: P_TYRA, mine: false, timestamp: now() - 60 * 60 * 50, text: 'can we start 30min late next tuesday? commute issue' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 50, text: 'sure' },
{ from: MINE, mine: true, timestamp: now() - 60 * 60 * 49, read: true, text: 'works for me' },
{ from: P_LEAD, mine: false, timestamp: now() - 60 * 60 * 32, text: 'we pick up where we left — in the dragons cave' },
{ from: P_ANNA, mine: false, timestamp: now() - 60 * 60 * 32, text: 'excited 🐉' },
]);
}
// ── Channel: dchain_updates ────────────────────────────────────
if (contact.username === 'dchain_updates') {
return list('dchain_updates', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 96, text: '🔨 v0.0.1-alpha tagged on Gitea' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 72, text: 'PBFT equivocation-detection тесты зелёные' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'New: /api/peers теперь включает peer-version info' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: '📘 Docs overhaul merged: docs/README.md' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 36, text: 'Schema migration scaffold landed (no-op для текущей версии)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: '🚀 v0.0.1 released' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 10, text: 'Includes: auto-update from Gitea, peer-version gossip, schema migrations' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20 + 20, text: 'Check /api/well-known-version for the full feature list' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 12, text: 'Thanks to all testers — feedback drives the roadmap 🙏' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'v0.0.2 roadmap published: https://git.vsecoder.vodka/vsecoder/dchain' },
{ from: peer, mine: false, timestamp: now() - 60 * 30, text: 'quick heads-up: nightly builds switching to new docker-slim base' },
]);
}
// ── Channel: Relay broadcasts ──────────────────────────────────
if (contact.alias === '⚡ Relay broadcasts') {
return list('relay_bc', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Relay fleet snapshot: 12 active, 3 inactive' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Relay #3 came online in US-east' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Validator set updated: 3→4' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'PBFT view-change детектирован и отработан на блоке 184120' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 15, text: 'Mailbox eviction ran — 42 stale envelopes' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 5, text: 'Relay #8 slashed for equivocation — evidence at block 184202' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 2, text: 'Relay #12 came online in EU-west, registering now…' },
]);
}
// ── Channel: Tech news ────────────────────────────────────────
if (contact.alias === '📰 Tech news') {
return list('tech_news', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Rust 1.78 released — new lints for raw pointers' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Go 1.23 ships range-over-func officially' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Expo SDK 54 drops — new-architecture default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: 'CVE-2026-1337 patched in libsodium (update your keys)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Matrix protocol adds post-quantum handshakes' },
{
from: peer, mine: false, timestamp: now() - 60 * 60 * 30, text: 'Data-center aerial view — new hyperscaler in Iceland',
attachment: {
kind: 'image',
uri: 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=800',
width: 800, height: 533, mime: 'image/jpeg',
},
},
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'IETF draft: "DNS-over-blockchain"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 6, text: 'GitHub tightens 2FA defaults for orgs' },
]);
}
// ── Channel: Design inspo (unread=12) ──────────────────────────
if (contact.alias === '🎨 Design inspo') {
return list('design_inspo', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Weekly pick: Linear UI v3 breakdown' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 140, text: 'Figma file of the week: "Command bar patterns"' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 120, text: 'Motion study: Stripe checkout shake-error animation' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: "10 great empty-state illustrations (blogpost)" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 80, text: 'Tool: Hatch — colour-palette extractor from photos' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 60, text: '🔮 Trend watch: glassmorphism is back (again)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 40, text: 'Twitter thread: why rounded buttons are the default' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 20, text: 'Framer templates — black friday sale' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 3, text: 'New typeface: "Grotesk Pro" — free for personal use' },
]);
}
// ── Channel: NBA scores ────────────────────────────────────────
if (contact.alias === '🏀 NBA scores') {
return list('nba', [
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 160, text: 'Lakers 112 — Warriors 108 (OT)' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 130, text: 'Celtics 128 — Heat 115' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 100, text: 'Nuggets 119 — Thunder 102' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 70, text: "Knicks 101 — Bulls 98" },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 48, text: 'Mavericks 130 — Kings 127' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 24, text: 'Bucks 114 — Sixers 110' },
{ from: peer, mine: false, timestamp: now() - 60 * 60 * 4, text: 'Live: Lakers leading 78-72 at half' },
]);
}
return [];
}
// ─── Hook ──────────────────────────────────────────────────────────
export function useDevSeed() {
const contacts = useStore(s => s.contacts);
const setContacts = useStore(s => s.setContacts);
const setMessages = useStore(s => s.setMessages);
useEffect(() => {
if (contacts.length > 0) return;
setContacts(mockContacts);
for (const c of mockContacts) {
const msgs = mockMessagesFor(c);
if (msgs.length > 0) setMessages(c.address, msgs);
}
}, [contacts.length, setContacts, setMessages]);
}

View File

@@ -1,180 +0,0 @@
/**
* Dev-only mock posts for the feed.
*
* Why: in __DEV__ before any real posts exist on the node, the timeline/
* for-you/trending tabs come back empty. Empty state is fine visually but
* doesn't let you test scrolling, like animations, view-counter bumps,
* navigation to post detail, etc. This module injects a small set of
* synthetic posts so the UI has something to chew on.
*
* Gating:
* - Only active when __DEV__ === true (stripped from production builds).
* - Only surfaces when the REAL API returns an empty array. If the node
* is returning actual posts, we trust those and skip the mocks.
*
* These posts have made-up post_ids — tapping on them to open detail
* WILL 404 against the real backend. That's intentional — the mock is
* purely for scroll / tap-feedback testing.
*/
import type { FeedPostItem } from './feed';
// Fake hex-like pubkeys so Avatar's colour hash still looks varied.
function fakeAddr(seed: number): string {
const h = (seed * 2654435761).toString(16).padStart(8, '0');
return (h + h + h + h).slice(0, 64);
}
function fakePostID(n: number): string {
return `dev${String(n).padStart(29, '0')}`;
}
const NOW = Math.floor(Date.now() / 1000);
// Small curated pool of posts covering the render surface we care about:
// plain text, hashtag variety, different lengths, likes / views spread,
// reply/quote references, one with an attachment marker.
const SEED_POSTS: FeedPostItem[] = [
{
post_id: fakePostID(1),
author: fakeAddr(1),
content: 'Добро пожаловать в ленту DChain. Это #DEV-посты — они видны только пока реальная лента пустая.',
created_at: NOW - 60,
size: 200,
hosting_relay: fakeAddr(100),
views: 127, likes: 42,
has_attachment: false,
hashtags: ['dev'],
},
{
post_id: fakePostID(2),
author: fakeAddr(2),
content: 'Пробую новую ленту #twitter-style. Лайки, просмотры, подписки — всё on-chain, тела постов — off-chain в mailbox релея.',
created_at: NOW - 540,
size: 310,
hosting_relay: fakeAddr(100),
views: 89, likes: 23,
has_attachment: false,
hashtags: ['twitter'],
},
{
post_id: fakePostID(3),
author: fakeAddr(3),
content: 'Сжатие изображений — максимальное на клиенте (WebP Q=50 @1080p), плюс серверный EXIF-скраб через stdlib re-encode. GPS-координаты из EXIF больше никогда не утекают. #privacy',
created_at: NOW - 1200,
size: 420,
hosting_relay: fakeAddr(100),
views: 312, likes: 78,
has_attachment: true,
hashtags: ['privacy'],
},
{
post_id: fakePostID(4),
author: fakeAddr(4),
content: 'Короткий пост.',
created_at: NOW - 3600,
size: 128,
hosting_relay: fakeAddr(100),
views: 12, likes: 3,
has_attachment: false,
},
{
post_id: fakePostID(5),
author: fakeAddr(1),
content: 'Отвечаю сам себе — фича threads пока через reply_to только, без UI thread-виджета.',
created_at: NOW - 7200,
size: 220,
hosting_relay: fakeAddr(100),
views: 45, likes: 11,
has_attachment: false,
reply_to: fakePostID(1),
},
{
post_id: fakePostID(6),
author: fakeAddr(5),
content: '#golang + #badgerdb + #libp2p = DChain бэкенд. Пять package в test suite, все зелёные.',
created_at: NOW - 10800,
size: 180,
hosting_relay: fakeAddr(100),
views: 201, likes: 66,
has_attachment: false,
hashtags: ['golang', 'badgerdb', 'libp2p'],
},
{
post_id: fakePostID(7),
author: fakeAddr(6),
content: 'Feed-mailbox хранит тела постов до 30 дней (настраиваемо через DCHAIN_FEED_TTL_DAYS). Потом BadgerDB выселяет автоматически — chain-метаданные остаются навсегда.',
created_at: NOW - 14400,
size: 380,
hosting_relay: fakeAddr(100),
views: 156, likes: 48,
has_attachment: false,
},
{
post_id: fakePostID(8),
author: fakeAddr(7),
content: 'Pricing: BasePostFee = 1000 µT (0.001 T) + 1 µT за каждый байт. Уходит владельцу релея, принявшего пост.',
created_at: NOW - 21600,
size: 250,
hosting_relay: fakeAddr(100),
views: 78, likes: 22,
has_attachment: false,
},
{
post_id: fakePostID(9),
author: fakeAddr(8),
content: 'Twitter-like, но без миллиардов долларов на инфраструктуру — каждый оператор ноды платит за свой кусок хостинга и зарабатывает на публикациях. #decentralised #messaging',
created_at: NOW - 43200,
size: 340,
hosting_relay: fakeAddr(100),
views: 412, likes: 103,
has_attachment: false,
hashtags: ['decentralised', 'messaging'],
},
{
post_id: fakePostID(10),
author: fakeAddr(9),
content: 'Короче. Лайк = on-chain tx с fee 1000 µT. Дорого для спама, дёшево для реального лайка. Пока без батчинга, но в плане. #design',
created_at: NOW - 64800,
size: 200,
hosting_relay: fakeAddr(100),
views: 92, likes: 29,
has_attachment: false,
hashtags: ['design'],
},
{
post_id: fakePostID(11),
author: fakeAddr(2),
content: 'Follow граф на chain: двусторонний индекс (forward + inbound), так что Followers() и Following() — оба O(M).',
created_at: NOW - 86400 - 1000,
size: 230,
hosting_relay: fakeAddr(100),
views: 61, likes: 14,
has_attachment: false,
},
{
post_id: fakePostID(12),
author: fakeAddr(10),
content: 'Рекомендации (For You): берём последние 48ч постов, фильтруем подписки + уже лайкнутые + свои, ранжируем по likes × 3 + views. Версия 1 — будет умнее. #recsys',
created_at: NOW - 129600,
size: 290,
hosting_relay: fakeAddr(100),
views: 189, likes: 58,
has_attachment: false,
hashtags: ['recsys'],
},
];
/**
* Returns the dev-seed post list. Only returns actual items in dev
* builds; release bundles return an empty array so fake posts never
* appear in production.
*
* We use the runtime `globalThis.__DEV__` lookup rather than the typed
* `__DEV__` global because some builds can have the TS typing
* out-of-sync with the actual injected value.
*/
export function getDevSeedFeed(): FeedPostItem[] {
const g = globalThis as unknown as { __DEV__?: boolean };
if (g.__DEV__ !== true) return [];
return SEED_POSTS;
}

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

@@ -1,5 +1,23 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { router } from 'expo-router';
/**
* Navigate back, or fall back to a sensible default route if there's
* no screen to pop to.
*
* Without this, an opened route entered via deep link / direct push
* (profile, feed/[id], etc.) would emit the "action 'GO_BACK' was not
* handled by any navigator" dev warning and do nothing — user ends up
* stuck. Default fallback is the chats list (root of the app).
*/
export function safeBack(fallback: string = '/(app)/chats'): void {
if (router.canGoBack()) {
router.back();
} else {
router.replace(fallback as never);
}
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));

View File

@@ -29,6 +29,8 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/debug"
"strings"
"sync"
"syscall"
@@ -114,6 +116,16 @@ func main() {
// only for intentional migrations (e.g. importing data from another chain
// into this network) — very dangerous.
allowGenesisMismatch := flag.Bool("allow-genesis-mismatch", false, "skip the safety check that aborts when the local genesis hash differs from the seed's. Use only for explicit chain migration.")
// ── Resource caps ───────────────────────────────────────────────────────
// All four accept 0 meaning "no limit". Enforcement model:
// * CPU — runtime.GOMAXPROCS(n): Go runtime won't use more than n OS threads for Go code.
// * RAM — debug.SetMemoryLimit: soft limit, the GC works harder as the heap approaches it.
// * Feed disk — hard refuse of new post bodies once the cap is crossed (existing posts keep serving).
// * Chain disk — warn-only periodic check; we can't hard-reject new blocks without breaking consensus.
maxCPU := flag.Int("max-cpu", int(envUint64Or("DCHAIN_MAX_CPU", 0)), "max CPU cores the node may use (GOMAXPROCS). 0 = all (env: DCHAIN_MAX_CPU)")
maxRAMMB := flag.Uint64("max-ram-mb", envUint64Or("DCHAIN_MAX_RAM_MB", 0), "soft Go heap limit in MiB (GOMEMLIMIT). 0 = unlimited (env: DCHAIN_MAX_RAM_MB)")
feedDiskMB := flag.Uint64("feed-disk-limit-mb", envUint64Or("DCHAIN_FEED_DISK_LIMIT_MB", 0), "disk quota for post bodies in MiB; new posts are refused with 507 once crossed. 0 = unlimited (env: DCHAIN_FEED_DISK_LIMIT_MB)")
chainDiskMB := flag.Uint64("chain-disk-limit-mb", envUint64Or("DCHAIN_CHAIN_DISK_LIMIT_MB", 0), "advisory disk cap for the chain DB dir in MiB; exceeding it logs a loud WARN every minute. 0 = unlimited (env: DCHAIN_CHAIN_DISK_LIMIT_MB)")
showVersion := flag.Bool("version", false, "print version info and exit")
flag.Parse()
@@ -128,6 +140,10 @@ func main() {
// so subsequent logs inherit the format.
setupLogging(*logFormat)
// Apply CPU / RAM caps before anything else spins up so the runtime
// picks them up at first goroutine/heap allocation.
applyResourceCaps(*maxCPU, *maxRAMMB)
// Wire API access-control. A non-empty token gates writes; adding
// --api-private also gates reads. Logged up-front so the operator
// sees what mode they're in.
@@ -641,12 +657,24 @@ func main() {
// --- Feed mailbox (social-feed post bodies, v2.0.0) ---
feedTTL := time.Duration(*feedTTLDays) * 24 * time.Hour
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL)
feedQuotaBytes := int64(*feedDiskMB) * 1024 * 1024
feedMailbox, err := relay.OpenFeedMailbox(*feedDB, feedTTL, feedQuotaBytes)
if err != nil {
log.Fatalf("[NODE] feed mailbox: %v", err)
}
defer feedMailbox.Close()
log.Printf("[NODE] feed mailbox: %s (TTL %d days)", *feedDB, *feedTTLDays)
if feedQuotaBytes > 0 {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, disk quota %d MiB)", *feedDB, *feedTTLDays, *feedDiskMB)
} else {
log.Printf("[NODE] feed mailbox: %s (TTL %d days, no disk quota)", *feedDB, *feedTTLDays)
}
// Advisory chain-disk watcher. We can't refuse new blocks (consensus
// would stall), so instead we walk the chain DB dir every minute and
// log a loud WARN if the operator's budget is exceeded. Zero = disabled.
if *chainDiskMB > 0 {
go watchChainDisk(*dbPath, int64(*chainDiskMB)*1024*1024)
}
// Push-notify bus consumers whenever a fresh envelope lands in the
// mailbox. Clients subscribed to `inbox:<my_x25519>` (via WS) get the
@@ -656,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)
@@ -850,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 {
@@ -1291,11 +1326,38 @@ type keyJSON struct {
}
func loadOrCreateIdentity(keyFile string) *identity.Identity {
if data, err := os.ReadFile(keyFile); err == nil {
// Key-file handling has a silent-failure mode that cost a genesis
// validator 21M tokens in the wild: if the file exists but we can't
// read it (e.g. mounted read-only under a different UID), ReadFile
// returns an error, we fall through to "generate", and the operator
// ends up with an ephemeral key whose pubkey doesn't match what's in
// keys/node.json on disk. Genesis allocation then lands on the
// ephemeral key that vanishes on restart.
//
// Distinguish "file doesn't exist" (normal — first boot, create)
// from "file exists but unreadable" (operator error — fail loudly).
if info, err := os.Stat(keyFile); err == nil {
// File is there. Any read failure now is an operator problem,
// not a bootstrap case.
_ = info
data, err := os.ReadFile(keyFile)
if err != nil {
log.Fatalf("[NODE] key file %s exists but can't be read: %v\n"+
"\thint: check file perms (should be readable by the node user) "+
"and that the mount isn't unexpectedly read-only.",
keyFile, err)
}
var kj keyJSON
if err := json.Unmarshal(data, &kj); err == nil {
if id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv); err == nil {
// If the file is missing X25519 keys, backfill and re-save.
if err := json.Unmarshal(data, &kj); err != nil {
log.Fatalf("[NODE] key file %s is not valid JSON: %v", keyFile, err)
}
id, err := identity.FromHexFull(kj.PubKey, kj.PrivKey, kj.X25519Pub, kj.X25519Priv)
if err != nil {
log.Fatalf("[NODE] key file %s is valid JSON but identity decode failed: %v",
keyFile, err)
}
// If the file is missing X25519 keys, backfill and re-save (best-effort,
// ignore write failure on read-only mounts).
if kj.X25519Pub == "" {
kj.X25519Pub = id.X25519PubHex()
kj.X25519Priv = id.X25519PrivHex()
@@ -1305,9 +1367,12 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
}
log.Printf("[NODE] loaded identity from %s", keyFile)
return id
} else if !os.IsNotExist(err) {
// Something other than "file not found" — permission on the
// containing directory, broken symlink, etc. Also fail loudly.
log.Fatalf("[NODE] stat %s: %v", keyFile, err)
}
}
}
// File genuinely doesn't exist — first boot. Generate + save.
id, err := identity.Generate()
if err != nil {
log.Fatalf("generate identity: %v", err)
@@ -1320,7 +1385,9 @@ func loadOrCreateIdentity(keyFile string) *identity.Identity {
}
data, _ := json.MarshalIndent(kj, "", " ")
if err := os.WriteFile(keyFile, data, 0600); err != nil {
log.Printf("[NODE] warning: could not save key: %v", err)
log.Printf("[NODE] warning: could not save key to %s: %v "+
"(ephemeral key in use — this node's identity will change on restart!)",
keyFile, err)
} else {
log.Printf("[NODE] new identity saved to %s", keyFile)
}
@@ -1440,6 +1507,61 @@ func shortKeys(keys []string) []string {
// "text" (default) is handler-default human-readable format, same as bare
// log.Printf. "json" emits one JSON object per line with `time/level/msg`
// + any key=value attrs — what Loki/ELK ingest natively.
// applyResourceCaps wires the --max-cpu and --max-ram-mb flags into the Go
// runtime. Both are soft-ish: CPU clamps GOMAXPROCS (Go scheduler won't use
// more OS threads for Go code, though blocking syscalls can still spawn
// more); RAM sets GOMEMLIMIT (the GC tightens its collection schedule as
// the heap approaches the cap but cannot *force* a kernel OOM-free). Use
// container limits (cgroup / Docker --memory / --cpus) alongside these
// for a real ceiling — this is "please play nice", not "hard sandbox".
func applyResourceCaps(maxCPU int, maxRAMMB uint64) {
if maxCPU > 0 {
prev := runtime.GOMAXPROCS(maxCPU)
log.Printf("[NODE] CPU cap: GOMAXPROCS %d → %d", prev, maxCPU)
}
if maxRAMMB > 0 {
bytes := int64(maxRAMMB) * 1024 * 1024
debug.SetMemoryLimit(bytes)
log.Printf("[NODE] RAM cap: GOMEMLIMIT = %d MiB (soft, GC-enforced)", maxRAMMB)
}
}
// watchChainDisk periodically walks the chain BadgerDB directory and logs
// a WARN line whenever its size exceeds `limitBytes`. Runs forever — the
// process lifetime bounds it. We deliberately do *not* stop block
// production when the cap is crossed: a validator that refuses to apply
// blocks stalls consensus for everyone on the chain, which is worse than
// using more disk than the operator wanted. Treat this as a monitoring
// signal, e.g. feed it to Prometheus via an alertmanager scrape.
func watchChainDisk(dir string, limitBytes int64) {
tick := time.NewTicker(60 * time.Second)
defer tick.Stop()
for ; ; <-tick.C {
used := dirSize(dir)
if used > limitBytes {
log.Printf("[NODE] WARN chain disk over quota: %d MiB used > %d MiB limit at %s",
used>>20, limitBytes>>20, dir)
}
}
}
// dirSize returns the total byte size of all regular files under root,
// recursively. Errors on individual entries are ignored — this is an
// advisory metric, not a filesystem audit.
func dirSize(root string) int64 {
var total int64
_ = filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total
}
func setupLogging(format string) {
var handler slog.Handler
switch strings.ToLower(format) {

View File

@@ -13,7 +13,7 @@
# testnet validator.
# ---- build stage ----
FROM golang:1.24-alpine AS builder
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
@@ -46,7 +46,15 @@ RUN apk add --no-cache ca-certificates tzdata
# Run as unprivileged user by default. Operators can override with --user root
# if they need to bind privileged ports (shouldn't be necessary behind Caddy).
RUN addgroup -S dchain && adduser -S -G dchain dchain
#
# IMPORTANT: /data must exist + be owned by dchain BEFORE the VOLUME
# directive. Docker copies the directory ownership of the mount point
# into any fresh named volume at first-attach time; skip this and
# operators get "mkdir: permission denied" when the node tries to
# create /data/chain as the dchain user.
RUN addgroup -S dchain && adduser -S -G dchain dchain \
&& mkdir -p /data \
&& chown dchain:dchain /data
COPY --from=builder /bin/node /usr/local/bin/node
COPY --from=builder /bin/client /usr/local/bin/client

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>

7341
desktop/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
desktop/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "dchain-desktop",
"version": "2.2.0-alpha6",
"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": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1",
"zustand": "^5.0.3"
},
"devDependencies": {
"@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",
"files": [
"dist/**/*",
"dist-electron/**/*"
],
"mac": { "target": ["dmg"] },
"win": { "target": ["nsis"] },
"linux": { "target": ["AppImage", "deb"] }
}
}

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

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

@@ -0,0 +1,201 @@
// 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;
}
}
/** 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);
}
}
}

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

@@ -0,0 +1,122 @@
// 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 };
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;
}
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 }),
}));

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

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

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,43 @@
import React from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
import { useStore } from '@/lib/store';
export function ProfileList(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
return (
<div style={{ padding: 14 }}>
<div style={{
padding: 14, borderRadius: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
width: 48, height: 48, borderRadius: 24,
background: '#1d9bf0', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#fff', fontWeight: 800, fontSize: 20,
}}>
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
</div>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
You
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all',
}}>
{keyFile?.pub_key}
</div>
</div>
</div>
);
}
export function ProfileDetail(): React.ReactElement {
return (
<SectionPlaceholder
title="Your profile"
note="Balance, username, devices — coming soon."
centered
/>
);
}

View File

@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { saveSettings } from '@/lib/storage';
import { setNodeUrl, getNetStats } from '@/lib/api';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
export function SettingsList(): React.ReactElement {
return (
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 14 }}>
<GroupLabel>Node</GroupLabel>
<NodeCard />
<GroupLabel>Identity</GroupLabel>
<IdentityCard />
<GroupLabel>About</GroupLabel>
<AboutCard />
</div>
);
}
export function SettingsDetail(): React.ReactElement {
return (
<SectionPlaceholder
title="Settings"
note="Pick a setting from the list. Devices, notifications, privacy — coming soon."
centered
/>
);
}
function GroupLabel({ children }: { children: React.ReactNode }) {
return (
<div style={{
color: '#5a5a5a', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase',
}}>
{children}
</div>
);
}
function NodeCard(): React.ReactElement {
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const [url, setUrl] = useState(settings.nodeUrl);
const [ok, setOk] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => { setUrl(settings.nodeUrl); }, [settings.nodeUrl]);
const apply = async () => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setBusy(true); setOk(null);
setNodeUrl(clean);
try {
await getNetStats();
setOk(true);
setSettings({ nodeUrl: clean });
saveSettings({ nodeUrl: clean });
} catch {
setOk(false);
} finally {
setBusy(false);
}
};
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a', display: 'flex', flexDirection: 'column', gap: 8,
}}>
<label style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
NODE URL
</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
<input
value={url}
onChange={e => { setUrl(e.target.value); setOk(null); }}
onBlur={apply}
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
placeholder="http://node.example:8080"
spellCheck={false}
style={{
flex: 1, background: '#000',
border: '1px solid #1f1f1f', borderRadius: 8,
padding: '8px 10px', color: '#fff', fontSize: 13,
fontFamily: 'monospace',
}}
/>
{busy && <span style={{ fontSize: 11, color: '#8b8b8b' }}></span>}
</div>
</div>
);
}
function IdentityCard(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
if (!keyFile) return <></>;
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a',
}}>
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, letterSpacing: 1 }}>
PUB KEY
</div>
<div className="selectable" style={{
color: '#fff', fontSize: 11, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.pub_key}
</div>
</div>
);
}
function AboutCard(): React.ReactElement {
const [v, setV] = useState<string>('dev');
useEffect(() => {
window.dchain?.app.version().then(setV).catch(() => {});
}, []);
return (
<div style={{
border: '1px solid #1f1f1f', borderRadius: 12, padding: 12,
background: '#0a0a0a', color: '#8b8b8b', fontSize: 12,
}}>
DChain Desktop v{v}
</div>
);
}

View File

@@ -0,0 +1,52 @@
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
import React from 'react';
import { useStore } from '@/lib/store';
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [copied, setCopied] = React.useState(false);
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 className="selectable" style={{
marginTop: 14, 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,72 @@
// 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 { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
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];
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>
<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>
);
}

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

View File

@@ -5,7 +5,7 @@
# Compose: see docker-compose.yml; node points DCHAIN_MEDIA_SIDECAR_URL at it.
#
# Stage 1 — build a tiny static Go binary.
FROM golang:1.22-alpine AS build
FROM golang:1.25-alpine AS build
WORKDIR /src
# Copy only what we need (the sidecar main is self-contained, no module
# deps on the rest of the repo, so this is a cheap, cache-friendly build).

227
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,227 @@
# 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: Electron + Vite + React + TS,
frame-less window, 3-panel shell, nav + status bar, safeStorage
for keyfile via IPC, Welcome + Create/Import auth flow, section
stubs that the rest of the alphas will fill in.
- [ ] **v2.2.0-alpha5** — Messages section (chat list + conversation)
using the same fan-out semantics as mobile. Pairing flow wired up
(new-device poll loop + primary-device modal reused from mobile).
- [ ] **v2.2.0-alpha6** — Feed + Wallet real content (reuse feed.ts /
tx builders from client-app via a shared workspace package).
- [ ] **v2.2.0-rc1** — Contacts + Settings → Devices + Profile,
polish pass (keybinds, focus, drag-drop attachments).
- [ ] **v2.2.0** — Auto-update through the same `/api/update-check`
pipeline nodes use; `electron-builder``.dmg`, `.exe`,
`.AppImage`, `.deb`.
### Открытые вопросы (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

@@ -34,6 +34,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -249,10 +250,16 @@ func feedPublish(cfg FeedConfig) http.HandlerFunc {
}
hashtags, err := cfg.Mailbox.Store(post, req.Ts)
if err != nil {
if err == relay.ErrPostTooLarge {
if errors.Is(err, relay.ErrPostTooLarge) {
jsonErr(w, err, 413)
return
}
if errors.Is(err, relay.ErrFeedQuotaExceeded) {
// 507 Insufficient Storage — the client should try
// another relay (or wait for TTL-driven eviction here).
jsonErr(w, err, 507)
return
}
jsonErr(w, err, 500)
return
}

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

Some files were not shown because too many files have changed in this diff Show More