10 Commits

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

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

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

Fixes:

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

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

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

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

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

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

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

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

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

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

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

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

Version bumped to 2.2.0 in package.json. docs/ROADMAP.md marks
v2.2.0 row complete; remaining work (attachments, code signing,
group chats) moved to a post-v2.2.0 bucket.
2026-04-22 18:47:19 +03:00
vsecoder
96b347076e feat(desktop): Contacts + Settings→Devices + expanded Profile + QR + keybinds (v2.2.0-rc1)
Completes the desktop feature surface ahead of the v2.2.0 tag. Only
auto-update + packaging remain.

Settings — now two-paned (nav on the left, pages on the right):
  * NodePage — URL ping-on-commit + API token field.
  * IdentityPage — pub key / X25519 pub, Export (safe-save dialog) /
    Import (open dialog + wipe + replace) / Delete identity.
  * DevicesPage — full multi-device UI: list every active device with
    a THIS DEVICE badge; Unlink button on every other row submits
    UNLINK_DEVICE + optimistic local remove; Link new device modal
    takes {code, device key, name}, submits LINK_DEVICE, then ships
    the handshake envelope (master Ed25519 priv encrypted for the
    new X25519) — same protocol as mobile's primary-device modal.
  * AboutPage — version, platform, Gitea links.
  * store.settingsPage discriminated union keeps selection across
    section switches.

Contacts section (now real):
  * ContactsList — alphabetical, filter-as-you-type; each row shows
    avatar letter + name + short address.
  * ContactsDetail — profile card (username/alias/pub) + Open chat /
    View posts / Copy address actions + stats grid
    (Balance, Devices, Encryption, Added) + Identity card with
    DC address, username, published X25519, device_count.
  * store.selectedContact persists across navigation.

Profile section (expanded):
  * ProfileList — big avatar + pub key + contacts count.
  * ProfileDetail — balance hero, quick actions (My posts →
    feed author wall, Manage devices → Settings→Devices, Copy
    address), Identity card, inline Linked devices list with a
    THIS DEVICE badge matching the Settings page.

Receive modal — canvas QR via `qrcode` (new dep, ~5 KB gzipped),
white-on-transparent so it sits inside the same black modal chrome.

Global keybinds (useGlobalKeybinds hook mounted in Shell):
  * Ctrl/Cmd+W — close the current conversation (drops activeChat,
    keeps section). Does NOT close the window.
  * Ctrl/Cmd+K — jump to Contacts.
  * Ctrl/Cmd+, — Settings.
  Each guards against being in a text field so typing `k,` in a
  composer / search doesn't hijack.

docs/ROADMAP.md — rc1 row flipped to done; v2.2.0 narrows to
auto-update + packaging + optional attachments in Compose.
2026-04-22 18:39:39 +03:00
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
63 changed files with 6065 additions and 361 deletions

View File

@@ -107,7 +107,7 @@ export default function ChatScreen() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
useMessages(contact?.x25519Pub ?? '');
useMessages(contact?.x25519Pub ?? '', contact?.address);
// ── Typing indicator от peer'а ─────────────────────────────────────────
useEffect(() => {

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

@@ -262,14 +262,17 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise<TxRecord
* совместимости с crypto.ts (decryptMessage принимает hex).
*/
interface InboxItemWire {
id: string;
sender_pub: string;
recipient_pub: string;
fee_ut?: number;
sent_at: number;
sent_at_human?: string;
nonce: string; // base64
ciphertext: string; // base64
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;
sent_at_human?: string;
nonce: string; // base64
ciphertext: string; // base64
}
interface InboxResponseWire {
@@ -326,12 +329,13 @@ export async function fetchInbox(x25519PubHex: string): Promise<Envelope[]> {
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519PubHex}`);
const items = Array.isArray(resp?.items) ? resp.items : [];
return items.map((it): Envelope => ({
id: it.id,
sender_pub: it.sender_pub,
recipient_pub: it.recipient_pub,
nonce: bytesToHex(base64ToBytes(it.nonce)),
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
timestamp: it.sent_at ?? 0,
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,
}));
}

View File

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

View File

@@ -684,11 +684,17 @@ 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,
"sent_at": env.SentAt,
"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)
})

View File

@@ -12,7 +12,7 @@
// 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 } from 'electron';
import { app, BrowserWindow, shell, ipcMain, dialog, safeStorage, session } from 'electron';
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
@@ -20,6 +20,35 @@ 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,
@@ -134,6 +163,7 @@ ipcMain.handle('app:platform', async () => process.platform);
// ── Lifecycle ─────────────────────────────────────────────────────────
app.whenReady().then(() => {
installCSP();
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();

View File

@@ -2,10 +2,20 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src * ws: wss: http: https:; img-src * data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self';" />
<!-- 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>
/* Global box-sizing — every padding+border counts toward the declared
width, not on top of it. Without this, `<input style="width:100%">`
inside a padded flex container visibly overflows its parent on
every modal / Settings card. Applied universally because almost
every per-element override was forgetting it anyway. */
*, *::before, *::after { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
@@ -20,6 +30,11 @@
user-select: text;
-webkit-user-select: text;
}
/* Form elements should never paint their own background/border over
our dark theme. Each component still sets its own explicit colours. */
input, textarea, button {
font-family: inherit;
}
</style>
</head>
<body>

View File

@@ -1,13 +1,14 @@
{
"name": "dchain-desktop",
"version": "2.2.0-alpha4",
"version": "2.2.0-alpha6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dchain-desktop",
"version": "2.2.0-alpha4",
"version": "2.2.0-alpha6",
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tweetnacl": "^1.0.3",
@@ -15,6 +16,7 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
@@ -2020,6 +2022,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
@@ -2183,7 +2195,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2193,7 +2204,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2869,6 +2879,15 @@
"node": ">= 0.4"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001790",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz",
@@ -3049,7 +3068,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3062,7 +3080,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-support": {
@@ -3353,6 +3370,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -3478,6 +3504,12 @@
"license": "MIT",
"optional": true
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dir-compare": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
@@ -3873,7 +3905,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encoding": {
@@ -4158,6 +4189,19 @@
"node": ">=10"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
@@ -4329,7 +4373,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4830,7 +4873,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5091,6 +5133,18 @@
"safe-buffer": "~5.1.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -5780,6 +5834,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-map": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
@@ -5796,6 +5877,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5803,6 +5893,15 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -5914,6 +6013,15 @@
"node": ">=10.4.0"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@@ -6013,6 +6121,89 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
@@ -6137,12 +6328,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resedit": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
@@ -6392,7 +6588,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/shebang-command": {
@@ -6610,7 +6805,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -6641,7 +6835,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7135,6 +7328,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",

View File

@@ -1,18 +1,19 @@
{
"name": "dchain-desktop",
"version": "2.2.0-alpha4",
"version": "2.2.0",
"description": "DChain desktop client — Electron shell mirroring the mobile app's functionality with a keyboard-first 3-panel layout.",
"private": true,
"main": "dist-electron/main.js",
"scripts": {
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite\" \"wait-on tcp:5173 && npm run electron:dev\"",
"electron:dev": "tsc -p electron/tsconfig.json && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron dist-electron/main.js",
"build": "tsc -p electron/tsconfig.json && vite build && electron-builder",
"dev": "concurrently -k -n vite,electron -c blue,magenta \"vite --host 127.0.0.1\" \"wait-on http://127.0.0.1:5173 && npm run electron:dev\"",
"electron:dev": "npm run build:main && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron dist-electron/main.js",
"build": "npm run build:main && vite build && electron-builder",
"build:renderer": "vite build",
"build:main": "tsc -p electron/tsconfig.json",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p electron/tsconfig.json"
},
"dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tweetnacl": "^1.0.3",
@@ -20,6 +21,7 @@
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
@@ -34,12 +36,40 @@
"build": {
"appId": "com.dchain.desktop",
"productName": "DChain",
"copyright": "Copyright © 2026 DChain contributors",
"asar": true,
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
"files": [
"dist/**/*",
"dist-electron/**/*"
"dist-electron/**/*",
"!**/*.map",
"!**/node_modules/**/test/**",
"!**/node_modules/**/tests/**"
],
"mac": { "target": ["dmg"] },
"win": { "target": ["nsis"] },
"linux": { "target": ["AppImage", "deb"] }
"directories": {
"output": "release",
"buildResources": "resources"
},
"mac": {
"target": ["dmg", "zip"],
"category": "public.app-category.social-networking",
"hardenedRuntime": true,
"gatekeeperAssess": false
},
"win": {
"target": ["nsis", "portable"]
},
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"linux": {
"target": ["AppImage", "deb"],
"category": "Network"
},
"publish": null
}
}

View File

@@ -4,33 +4,63 @@
// 2. Render either the Welcome auth flow (no key yet) or the Shell
// (3-panel layout + current section).
import React, { useEffect } from 'react';
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { loadKeyFile, loadSettings, loadContacts } from '@/lib/storage';
import { setNodeUrl } from '@/lib/api';
import { useDeviceBootstrap } from '@/hooks/useDeviceBootstrap';
import { Shell } from '@/shell/Shell';
import { Welcome } from '@/auth/Welcome';
export function App(): React.ReactElement {
const booted = useStore(s => s.booted);
const keyFile = useStore(s => s.keyFile);
const [bootError, setBootError] = useState<string | null>(null);
// Multi-device registry bootstrap — publishes THIS device on the
// chain so senders can fan out envelopes to us, and self-wipes if
// another device has since revoked us. See hooks/useDeviceBootstrap.
useDeviceBootstrap();
useEffect(() => {
(async () => {
const set = loadSettings();
setNodeUrl(set.nodeUrl);
useStore.getState().setSettings(set);
try {
const set = loadSettings();
setNodeUrl(set.nodeUrl);
useStore.getState().setSettings(set);
const cs = loadContacts();
useStore.getState().setContacts(cs);
const cs = loadContacts();
useStore.getState().setContacts(cs);
const kf = await loadKeyFile();
useStore.getState().setKeyFile(kf);
const kf = await loadKeyFile();
useStore.getState().setKeyFile(kf);
useStore.getState().setBooted(true);
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.

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

@@ -11,30 +11,19 @@
// with mobile comes in alpha5.
import React, { useState } from 'react';
import nacl from 'tweetnacl';
import { useStore } from '@/lib/store';
import { saveKeyFile } from '@/lib/storage';
import { generateKeyFile } from '@/lib/crypto';
import type { KeyFile } from '@/lib/types';
function bytesToHex(b: Uint8Array): string {
return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join('');
}
function generateKeyFile(): KeyFile {
const signKP = nacl.sign.keyPair();
const boxKP = nacl.box.keyPair();
return {
pub_key: bytesToHex(signKP.publicKey),
priv_key: bytesToHex(signKP.secretKey),
x25519_pub: bytesToHex(boxKP.publicKey),
x25519_priv: bytesToHex(boxKP.secretKey),
};
}
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);
@@ -73,7 +62,8 @@ export function Welcome(): React.ReactElement {
};
const onPair = () => {
setErr('Pair flow lands in v2.2.0-alpha5. For now use Import from a key file exported on your phone.');
setErr(null);
setScreen('pair');
};
return (

View File

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

View File

@@ -0,0 +1,62 @@
// Global keyboard shortcuts. Mounted at Shell.tsx so they work regardless
// of which section is active. The section-switching bindings
// (Ctrl/Cmd+1..5, +Settings) live in NavBar — they predate this file and
// stay there because they're tightly coupled to the nav data structure.
//
// Every shortcut below:
// * Skips itself when focus is inside a text input / textarea (so typing
// in Compose doesn't accidentally fire app-level actions).
// * preventDefault()'s to suppress the browser/Electron default (e.g.
// Ctrl+W would otherwise close the whole window).
import { useEffect } from 'react';
import { useStore } from '@/lib/store';
function inTextField(el: EventTarget | null): boolean {
const n = el as HTMLElement | null;
if (!n) return false;
const tag = n.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || n.isContentEditable === true;
}
export function useGlobalKeybinds(): void {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const mod = e.ctrlKey || e.metaKey;
// Ctrl/Cmd+W — close the current conversation (drop activeChat);
// if no chat is open, no-op. We do not close the window, because
// that's too abrupt for an app the user usually keeps running.
if (mod && e.key.toLowerCase() === 'w') {
const { section, activeChat, setActiveChat } = useStore.getState();
if (section === 'messages' && activeChat) {
e.preventDefault();
setActiveChat(null);
}
return;
}
// Ctrl/Cmd+K — jump to Contacts with the search focused. The focus
// itself is delegated to the Contacts component via a signal; for
// now we just switch the section and rely on Contacts' autofocus
// pattern (text input comes from memo'd ref in list pane).
if (mod && e.key.toLowerCase() === 'k') {
if (inTextField(e.target)) return;
e.preventDefault();
useStore.getState().setSection('contacts');
return;
}
// Ctrl/Cmd+, — Settings.
if (mod && e.key === ',') {
if (inTextField(e.target)) return;
e.preventDefault();
useStore.getState().setSection('settings');
return;
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}

View File

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

View File

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

View File

@@ -112,3 +112,114 @@ export async function getBalance(pub: string): Promise<number> {
return r.balance_ut ?? 0;
} catch { return 0; }
}
// ─── Wallet / transactions ───────────────────────────────────────────────
/** Raw tx row as it appears in /api/address/{pub}.transactions[]. */
export interface TxRow {
id: string;
type: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
fee_ut: number;
time: string; // ISO-8601 UTC
memo?: string;
}
interface AddressResponse {
address: string;
pub_key: string;
balance_ut: number;
transactions?: TxRow[];
}
/** Full tx detail, matches node/api_explorer.go::apiTxByID shape. */
export interface TxDetail {
id: string;
type: string;
memo?: string;
from: string;
from_addr?: string;
to?: string;
to_addr?: string;
amount_ut: number;
amount: string;
fee_ut: number;
fee: string;
time: string;
block_index: number;
block_hash: string;
block_time: string;
gas_used?: number;
payload?: unknown;
payload_hex?: string;
signature_hex?: string;
}
export async function getTxHistory(pub: string, limit = 100): Promise<TxRow[]> {
try {
const r = await get<AddressResponse>(`/api/address/${pub}?limit=${limit}`);
return r.transactions ?? [];
} catch { return []; }
}
export async function getTxDetail(txID: string): Promise<TxDetail | null> {
try {
return await get<TxDetail>(`/api/tx/${txID}`);
} catch (e) {
if (/→\s*404\b/.test(String((e as Error).message))) return null;
throw e;
}
}
// ─── Contact requests (on-chain, via /relay/contacts) ───────────────────
export interface ContactRequestRaw {
requester_pub: string;
requester_addr: string;
status: string; // "pending" | "accepted" | "blocked"
intro: string;
fee_ut: number;
tx_id: string;
created_at: number;
}
/**
* GET /relay/contacts?pub=<ed25519> — returns every on-chain
* CONTACT_REQUEST addressed to `pub`, regardless of status. The UI
* filters by pending before showing.
*/
export async function fetchContactRequests(edPub: string): Promise<ContactRequestRaw[]> {
try {
const r = await get<{ contacts?: ContactRequestRaw[] }>(`/relay/contacts?pub=${edPub}`);
return r.contacts ?? [];
} catch { return []; }
}
/** Resolve a DC address or @username into an Ed25519 pub (hex). */
export async function resolveAccount(input: string): Promise<string | null> {
const trimmed = input.trim();
if (!trimmed) return null;
// Already a hex pub.
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase();
// @username — go through the username registry.
if (trimmed.startsWith('@')) {
try {
const r = await get<{ pub_key?: string }>(
`/api/contract/call?id=native:username_registry&method=resolve&arg=${encodeURIComponent(trimmed.slice(1))}`,
);
return r.pub_key ?? null;
} catch { return null; }
}
// DC… address — ask the explorer.
if (trimmed.startsWith('DC')) {
try {
const r = await get<{ pub_key?: string }>(`/api/address/${trimmed}`);
return r.pub_key ?? null;
} catch { return null; }
}
return null;
}

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

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

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

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

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

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

View File

@@ -10,7 +10,7 @@
// 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 } from './types';
import type { KeyFile, NodeSettings, Contact, Message } from './types';
import type { DChainAPI } from '../../electron/preload';
declare global {
@@ -20,9 +20,26 @@ declare global {
}
// ─── 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 window.dchain.keyfile.load();
const raw = await requireDchain().keyfile.load();
if (!raw) return null;
try {
return JSON.parse(raw) as KeyFile;
@@ -32,11 +49,11 @@ export async function loadKeyFile(): Promise<KeyFile | null> {
}
export async function saveKeyFile(kf: KeyFile): Promise<void> {
await window.dchain.keyfile.save(JSON.stringify(kf));
await requireDchain().keyfile.save(JSON.stringify(kf));
}
export async function deleteKeyFile(): Promise<void> {
await window.dchain.keyfile.delete();
await requireDchain().keyfile.delete();
}
// ─── Settings ─────────────────────────────────────────────────────────────
@@ -88,6 +105,34 @@ export function upsertContact(c: Contact): void {
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';

View File

@@ -1,19 +1,49 @@
// Zustand store — same pattern as client-app/lib/store.ts, just lighter.
// Holds identity, node settings, UI nav state (current section), and the
// bootstrapped flag so the Welcome screen can redirect only once boot
// has run.
// 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 } from './types';
import type { KeyFile, NodeSettings, Contact, Message } from './types';
export type Section = 'messages' | 'feed' | 'wallet' | 'contacts' | 'settings' | 'profile';
/**
* FeedTab is the current filter applied to the Feed section.
* foryou — recommended (unfollowed) posts
* timeline — posts from authors we follow
* trending — top by engagement, last 24h
* hashtag — posts containing a specific tag
* author — wall of a single author
*/
export type FeedTab =
| { kind: 'foryou' }
| { kind: 'timeline' }
| { kind: 'trending' }
| { kind: 'hashtag'; tag: string }
| { kind: 'author'; pub: string };
/** Current Wallet selection — either the overview (history) or a tx. */
export type WalletSelection =
| { kind: 'overview' }
| { kind: 'tx'; id: string };
/** Which Settings subsection is visible in the detail pane. */
export type SettingsPage = 'node' | 'identity' | 'devices' | 'about';
interface State {
booted: boolean;
keyFile: KeyFile | null;
settings: NodeSettings;
contacts: Contact[];
section: Section;
/** address of the currently-open conversation (mirrors mobile's route param). */
activeChat: string | null;
/** Messages keyed by contact.address. Each list is chronological (old → new). */
messages: Record<string, Message[]>;
/** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */
unread: Record<string, number>;
setBooted: (v: boolean) => void;
setKeyFile: (k: KeyFile | null) => void;
@@ -21,14 +51,41 @@ interface State {
setContacts: (cs: Contact[]) => void;
upsertContact: (c: Contact) => void;
setSection: (s: Section) => void;
setActiveChat: (addr: string | null) => void;
setMessages: (addr: string, msgs: Message[]) => void;
appendMessage: (addr: string, m: Message) => void;
bumpUnread: (addr: string) => void;
clearUnread: (addr: string) => void;
/** Feed state — persists across section switches within the session. */
feedTab: FeedTab;
feedSelectedPost: string | null;
setFeedTab: (t: FeedTab) => void;
setFeedSelectedPost: (id: string | null) => void;
/** Wallet state. */
walletSel: WalletSelection;
setWalletSel: (s: WalletSelection) => void;
/** Settings state. */
settingsPage: SettingsPage;
setSettingsPage: (p: SettingsPage) => void;
/** Currently-selected contact in the Contacts section. */
selectedContact: string | null;
setSelectedContact: (addr: string | null) => void;
}
export const useStore = create<State>((set) => ({
booted: false,
keyFile: null,
settings: { nodeUrl: 'http://localhost:8080', contractId: '' },
contacts: [],
section: 'messages',
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 }),
@@ -44,4 +101,39 @@ export const useStore = create<State>((set) => ({
return { contacts: [...st.contacts, c] };
}),
setSection: (s) => set({ section: s }),
setActiveChat: (addr) => set({ activeChat: addr }),
setMessages: (addr, msgs) => set((st) => ({
messages: { ...st.messages, [addr]: msgs },
})),
appendMessage: (addr, m) => set((st) => {
const cur = st.messages[addr] ?? [];
// Idempotent — duplicate envelope deliveries (WS + HTTP race) shouldn't
// double-insert.
if (cur.some(x => x.id === m.id)) return {};
return { messages: { ...st.messages, [addr]: [...cur, m] } };
}),
bumpUnread: (addr) => set((st) => ({
unread: { ...st.unread, [addr]: (st.unread[addr] ?? 0) + 1 },
})),
clearUnread: (addr) => set((st) => {
if (!(addr in st.unread)) return {};
const next = { ...st.unread };
delete next[addr];
return { unread: next };
}),
feedTab: { kind: 'foryou' },
feedSelectedPost: null,
setFeedTab: (t) => set({ feedTab: t, feedSelectedPost: null }),
setFeedSelectedPost: (id) => set({ feedSelectedPost: id }),
walletSel: { kind: 'overview' },
setWalletSel: (s) => set({ walletSel: s }),
settingsPage: 'node',
setSettingsPage: (p) => set({ settingsPage: p }),
selectedContact: null,
setSelectedContact: (addr) => set({ selectedContact: addr }),
}));

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

@@ -0,0 +1,216 @@
// Transaction builders + submission.
//
// Mirrors the handful of builders we actually use from client-app/lib/api.ts
// (Transfer, Link/UnlinkDevice for now; more will follow as sections land).
// Canonical bytes and wire format are identical to the mobile client —
// both talk to the same Go node, so any divergence here is a bug.
import { bytesToBase64, signBase64 } from './crypto';
import { post } from './api';
const MIN_TX_FEE = 1_000;
const _encoder = new TextEncoder();
/**
* Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction
* JSON. `payload` and `signature` are base64 because Go's json.Marshal
* encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time
* does the same.
*/
export interface RawTx {
id: string;
type: string;
from: string;
to: string;
amount: number;
fee: number;
memo?: string;
payload: string;
signature: string;
timestamp: string;
}
function rfc3339Now(): string {
const d = new Date();
d.setMilliseconds(0);
return d.toISOString().replace('.000Z', 'Z');
}
function newTxID(): string {
return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`;
}
/**
* Canonical bytes the node re-derives to verify tx.signature. Order of
* keys matches Go's field order in identity.txSignBytes — JS object
* literals preserve insertion order so JSON.stringify is enough.
*/
function canonicalBytes(tx: {
id: string; type: string; from: string; to: string;
amount: number; fee: number; payload: string; timestamp: string;
}): Uint8Array {
return _encoder.encode(JSON.stringify({
id: tx.id,
type: tx.type,
from: tx.from,
to: tx.to,
amount: tx.amount,
fee: tx.fee,
payload: tx.payload,
timestamp: tx.timestamp,
}));
}
function strToBase64(s: string): string {
return bytesToBase64(_encoder.encode(s));
}
export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> {
return post<{ id: string; status: string }>('/api/tx', tx);
}
// ─── Builders ────────────────────────────────────────────────────────────
export function buildTransferTx(p: {
from: string; to: string; amount: number; fee: number;
privKey: string; memo?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {}));
const canon = canonicalBytes({
id, type: 'TRANSFER', from: p.from, to: p.to,
amount: p.amount, fee: p.fee, payload, timestamp,
});
return {
id, type: 'TRANSFER', from: p.from, to: p.to,
amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
export function buildLinkDeviceTx(p: {
from: string; x25519Pub: string; deviceName: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({
x25519_pub_key: p.x25519Pub,
device_name: p.deviceName,
}));
const canon = canonicalBytes({
id, type: 'LINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'LINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
export function buildUnlinkDeviceTx(p: {
from: string; x25519Pub: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub }));
const canon = canonicalBytes({
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'UNLINK_DEVICE', from: p.from, to: '',
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* CONTACT_REQUEST — paid first-contact tx. `amount` carries the
* anti-spam fee (≥ MinContactFee = 5000 µT on the node), credited to
* the recipient's balance as an incentive to accept; `fee` is the
* regular network fee. Optional `intro` plaintext is embedded in the
* payload so the receiver sees "who is this" before accepting.
*/
export function buildContactRequestTx(p: {
from: string;
to: string;
contactFee: number; // µT — ≥ 5000, paid to recipient
privKey: string;
intro?: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64(JSON.stringify(p.intro ? { intro: p.intro } : {}));
const canon = canonicalBytes({
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'CONTACT_REQUEST', from: p.from, to: p.to,
amount: p.contactFee, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* ACCEPT_CONTACT — recipient side, empties the pending request and
* publishes the peer's X25519 key so the requester can start sending
* encrypted envelopes. Tx.to = original requester's pub.
*/
export function buildAcceptContactTx(p: {
from: string; to: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const canon = canonicalBytes({
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'ACCEPT_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* BLOCK_CONTACT — sticky rejection. Subsequent CONTACT_REQUEST txs
* from the same sender are dropped at applyTx level on the node.
*/
export function buildBlockContactTx(p: {
from: string; to: string; privKey: string;
}): RawTx {
const id = newTxID();
const timestamp = rfc3339Now();
const payload = strToBase64('{}');
const canon = canonicalBytes({
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
});
return {
id, type: 'BLOCK_CONTACT', from: p.from, to: p.to,
amount: 0, fee: MIN_TX_FEE, payload, timestamp,
signature: signBase64(canon, p.privKey),
};
}
/**
* humanizeTxError unwraps the server's `{"error":"…"}` shape and common
* message wrappers into a one-line user-facing string. Same helper the
* mobile client exposes from lib/api.ts; copied here to keep the two
* codebases independent until we factor into a shared package.
*/
export function humanizeTxError(err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
const m = /→\s*({[^}]+})/.exec(raw);
if (m) {
try {
const parsed = JSON.parse(m[1]);
if (parsed.error) return parsed.error;
} catch { /* fall through */ }
}
return raw;
}

View File

@@ -1,9 +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>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);

View File

@@ -0,0 +1,200 @@
// Right-pane for Contacts — profile card for the selected contact.
// Shows identity, balance, device count, linked action buttons:
// Open chat (switch to Messages section), Transfer, View posts (switch
// to Feed author wall), Block (local only for now).
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getIdentity, fetchDevices, getBalance } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
import type { IdentityInfo } from '@/lib/api';
function formatT(ut: number): string {
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
}
export function ContactsDetail(): React.ReactElement {
const sel = useStore(s => s.selectedContact);
const contact = useStore(s => s.contacts.find(c => c.address === sel));
const setSection = useStore(s => s.setSection);
const setActive = useStore(s => s.setActiveChat);
const setFeedTab = useStore(s => s.setFeedTab);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [deviceCount, setDeviceCount] = useState<number | null>(null);
useEffect(() => {
if (!sel) return;
let cancelled = false;
(async () => {
const [id, bal, devs] = await Promise.all([
getIdentity(sel),
getBalance(sel),
fetchDevices(sel),
]);
if (cancelled) return;
setIdentity(id);
setBalance(bal);
setDeviceCount(devs.length);
})();
return () => { cancelled = true; };
}, [sel]);
if (!sel || !contact) {
return (
<div style={{
height: '100%', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#6a6a6a', fontSize: 13, padding: 40, textAlign: 'center',
}}>
Pick a contact on the left to view their profile.
</div>
);
}
const displayName = contact.username
? `@${contact.username}`
: (identity?.nickname ? `@${identity.nickname}` : (contact.alias ?? shortAddr(contact.address, 8)));
const openChat = () => {
setActive(contact.address);
setSection('messages');
};
const viewPosts = () => {
setFeedTab({ kind: 'author', pub: contact.address });
setSection('feed');
};
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
return (
<div style={{
height: '100%', overflowY: 'auto',
padding: '22px 26px', background: '#000',
}}>
{/* Header card */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 64, height: 64, borderRadius: 32,
background: '#1a1a1a', color: '#d0d0d0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 28, fontWeight: 700,
}}>{displayName.replace(/^@/, '').charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>
{displayName}
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 12, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all',
}}>{contact.address}</div>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 10, marginTop: 16, flexWrap: 'wrap' }}>
<Btn primary onClick={openChat}>Open chat</Btn>
<Btn onClick={viewPosts}>View posts</Btn>
<Btn onClick={() => copy(contact.address)}>Copy address</Btn>
</div>
{/* Stats grid */}
<div style={{
marginTop: 22, display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10,
}}>
<Stat label="Balance" value={balance === null ? '…' : `${formatT(balance)} T`} />
<Stat label="Devices" value={deviceCount === null ? '…' : String(deviceCount)} />
<Stat label="Encryption" value={contact.x25519Pub ? 'E2E (NaCl)' : 'no key'} />
<Stat label="Added" value={new Date(contact.addedAt).toLocaleDateString()} />
</div>
{/* Identity details */}
{identity && (
<div style={{
marginTop: 22, padding: 14, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 8,
}}>Identity</div>
<Row k="DC address" v={identity.address} copyable onCopy={() => copy(identity.address)} />
{identity.nickname && <Row k="Username" v={`@${identity.nickname}`} />}
{identity.x25519_pub && (
<Row
k="Published X25519"
v={shortAddr(identity.x25519_pub, 8)}
copyable
onCopy={() => copy(identity.x25519_pub)}
/>
)}
{typeof identity.device_count === 'number' && (
<Row k="Device count" v={String(identity.device_count)} />
)}
</div>
)}
</div>
);
}
function Btn({ children, onClick, primary }: {
children: React.ReactNode; onClick: () => void; primary?: boolean;
}) {
return (
<button
onClick={onClick}
style={{
padding: '9px 16px', borderRadius: 999,
background: primary ? '#1d9bf0' : 'transparent',
border: primary ? 'none' : '1px solid #1f1f1f',
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>{children}</button>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div style={{
padding: 12, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase',
}}>{label}</div>
<div style={{ color: '#fff', fontSize: 15, fontWeight: 700, marginTop: 4 }}>
{value}
</div>
</div>
);
}
function Row({
k, v, copyable, onCopy,
}: { k: string; v: string; copyable?: boolean; onCopy?: () => void }) {
return (
<div style={{
display: 'flex', padding: '6px 0',
borderBottom: '1px solid #141414',
alignItems: 'center', gap: 10,
}}>
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 140px' }}>{k}</div>
<div className="selectable" style={{
color: '#fff', fontSize: 13, fontFamily: 'monospace',
flex: 1, wordBreak: 'break-all',
}}>{v}</div>
{copyable && (
<button
onClick={onCopy}
style={{
background: 'transparent', border: 'none',
color: '#1d9bf0', fontSize: 11, fontWeight: 600,
cursor: 'pointer',
}}
>copy</button>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
import React from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
// Contacts section.
// List pane: contact list + quick filter.
// Detail pane: selected contact's profile card + actions.
export function ContactsList(): React.ReactElement {
return <SectionPlaceholder title="Contacts" note="All · Online · Blocked · Requests" />;
}
export function ContactsDetail(): React.ReactElement {
return <SectionPlaceholder title="Contacts" note="Pick a contact to see details." centered />;
}
export { ContactsList } from './ContactsList';
export { ContactsDetail } from './ContactsDetail';

View File

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

@@ -1,9 +1,6 @@
import React from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
// 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 function FeedList(): React.ReactElement {
return <SectionPlaceholder title="Feed" note="For You · Following · Trending · Hashtag" />;
}
export function FeedDetail(): React.ReactElement {
return <SectionPlaceholder title="Feed" note="Select a feed tab to browse posts." centered />;
}
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,236 @@
// 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';
// A module-level stable reference for "no messages yet". Without this the
// selector `s.messages[address] ?? []` allocates a fresh empty array on
// every render when the conversation has no cached entries, which zustand
// sees as a changed value → triggers another render → new empty array
// again → "Maximum update depth exceeded". Returning the exact same
// reference every time breaks the cycle.
const EMPTY_MESSAGES: Message[] = [];
export function Conversation({ address }: { address: string }): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const contact = useStore(s => s.contacts.find(c => c.address === address));
const messages = useStore(s => s.messages[address] ?? EMPTY_MESSAGES);
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) {
// Most common cause: the peer's device hasn't published a
// LINK_DEVICE yet (they accepted just now and haven't had the
// fee debited, or they haven't re-opened the app). Clearer
// copy than "recipient has no encryption key".
throw new Error(
'Recipient has no device key published on-chain yet. ' +
'Ask them to re-open their app so the LINK_DEVICE tx commits, ' +
'then try again.',
);
}
await Promise.all(pubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage(
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)) || shortAddr(address || '', 8);
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
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 ? '★' : firstLetter}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#fff', fontSize: 14, fontWeight: 700,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{name}</div>
<div style={{
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>
{shortAddr(address || '', 6)}
</div>
</div>
</div>
{/* 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

@@ -1,29 +1,44 @@
// Messages section — chat list (left pane) + conversation (detail pane).
// v2.2.0-alpha4 ships the scaffold only; real list + conversation come
// in follow-up commits sharing logic with client-app/app/(app)/chats.
// Messages section — full implementation. Left pane is the chat list;
// right pane mounts the active conversation or an empty-state.
import React from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
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 {
const contacts = useStore(s => s.contacts);
return (
<SectionPlaceholder
title="Messages"
note={contacts.length === 0
? 'No chats yet. Add a contact from the Contacts tab.'
: `${contacts.length} conversation${contacts.length === 1 ? '' : 's'} cached.`}
/>
);
// 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 {
return (
<SectionPlaceholder
title="Select a chat"
note="Pick a conversation from the list on the left, or start a new one."
centered
/>
);
const activeChat = useStore(s => s.activeChat);
return activeChat ? <Conversation address={activeChat} /> : <EmptyConversation />;
}

View File

@@ -1,43 +1,218 @@
import React from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
// Profile section — "You" view. List pane shows the avatar card,
// detail pane shows stats + devices summary.
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getBalance, getIdentity, fetchDevices, type IdentityInfo, type DeviceInfo } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
function formatT(ut: number): string {
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
}
export function ProfileList(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const contactsCount = useStore(s => s.contacts.length);
if (!keyFile) return <></>;
const letter = keyFile.pub_key.slice(0, 1).toUpperCase();
return (
<div style={{ padding: 14 }}>
<div style={{
padding: 14, borderRadius: 14,
padding: 16, borderRadius: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
textAlign: 'center',
}}>
<div style={{
width: 48, height: 48, borderRadius: 24,
background: '#1d9bf0', display: 'flex',
alignItems: 'center', justifyContent: 'center',
color: '#fff', fontWeight: 800, fontSize: 20,
}}>
{keyFile?.pub_key.slice(0, 1).toUpperCase() ?? '?'}
</div>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700, marginTop: 10 }}>
width: 72, height: 72, borderRadius: 36, margin: '0 auto 10px',
background: '#1d9bf0', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 30, fontWeight: 800,
}}>{letter}</div>
<div style={{ color: '#fff', fontSize: 18, fontWeight: 700 }}>
You
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 4, wordBreak: 'break-all',
}}>
{keyFile?.pub_key}
</div>
marginTop: 6, wordBreak: 'break-all',
}}>{keyFile.pub_key}</div>
</div>
<div style={{
marginTop: 14, padding: 12, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
color: '#8b8b8b', fontSize: 12, lineHeight: 1.5,
}}>
{contactsCount} contact{contactsCount === 1 ? '' : 's'} stored on this device.
</div>
</div>
);
}
export function ProfileDetail(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const setSection = useStore(s => s.setSection);
const setPage = useStore(s => s.setSettingsPage);
const setFeedTab = useStore(s => s.setFeedTab);
const [identity, setIdentity] = useState<IdentityInfo | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [devices, setDevices] = useState<DeviceInfo[]>([]);
useEffect(() => {
if (!keyFile) return;
let cancelled = false;
(async () => {
const [id, bal, devs] = await Promise.all([
getIdentity(keyFile.pub_key),
getBalance(keyFile.pub_key),
fetchDevices(keyFile.pub_key),
]);
if (cancelled) return;
setIdentity(id);
setBalance(bal);
setDevices(devs);
})();
return () => { cancelled = true; };
}, [keyFile]);
if (!keyFile) return <></>;
const copy = (s: string) => navigator.clipboard.writeText(s).catch(() => {});
const viewMyPosts = () => {
setFeedTab({ kind: 'author', pub: keyFile.pub_key });
setSection('feed');
};
const openDevices = () => {
setSection('settings');
setPage('devices');
};
return (
<SectionPlaceholder
title="Your profile"
note="Balance, username, devices — coming soon."
centered
/>
<div style={{
height: '100%', overflowY: 'auto',
padding: '22px 26px', background: '#000',
}}>
<div style={{
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
gap: 12, flexWrap: 'wrap',
}}>
<div>
<div style={{
color: '#8b8b8b', fontSize: 11, letterSpacing: 1, textTransform: 'uppercase',
}}>Balance</div>
<div style={{ color: '#fff', fontSize: 34, fontWeight: 800 }}>
{balance === null ? '—' : `${formatT(balance)} T`}
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Action onClick={viewMyPosts}>My posts</Action>
<Action onClick={openDevices}>Manage devices ({devices.length})</Action>
<Action onClick={() => copy(keyFile.pub_key)}>Copy address</Action>
</div>
</div>
{identity && (
<div style={{
marginTop: 24, padding: 14, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<Row k="DC address" v={identity.address} onCopy={() => copy(identity.address)} />
<Row k="Username" v={identity.nickname ? `@${identity.nickname}` : '—'} />
<Row k="Published X25519" v={shortAddr(identity.x25519_pub, 10) || '—'} />
<Row k="Registered" v={identity.registered ? 'yes' : 'no'} />
</div>
)}
{/* Devices summary */}
<div style={{
marginTop: 14, padding: 14, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1, textTransform: 'uppercase', marginBottom: 10,
}}>Linked devices</div>
{devices.length === 0 ? (
<div style={{ color: '#6a6a6a', fontSize: 13 }}>
No devices registered yet.
</div>
) : (
devices.map((d, i) => (
<div
key={d.x25519_pub_key}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 0',
borderTop: i === 0 ? undefined : '1px solid #141414',
}}
>
<div style={{
width: 28, height: 28, borderRadius: 6,
background: d.x25519_pub_key === keyFile.x25519_pub ? '#0d2540' : '#1a1a1a',
color: d.x25519_pub_key === keyFile.x25519_pub ? '#1d9bf0' : '#d0d0d0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14,
}}>📱</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: 13, fontWeight: 600 }}>
{d.device_name}
</div>
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
{shortAddr(d.x25519_pub_key, 8)}
</div>
</div>
{d.x25519_pub_key === keyFile.x25519_pub && (
<span style={{
padding: '1px 6px', borderRadius: 6,
background: '#0d2540', color: '#1d9bf0',
fontSize: 10, fontWeight: 700,
}}>THIS DEVICE</span>
)}
</div>
))
)}
</div>
</div>
);
}
function Action({ children, onClick }: {
children: React.ReactNode; onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
padding: '9px 14px', borderRadius: 999,
background: 'transparent', border: '1px solid #1f1f1f',
color: '#fff', fontSize: 13, fontWeight: 700, cursor: 'pointer',
}}
>{children}</button>
);
}
function Row({ k, v, onCopy }: { k: string; v: string; onCopy?: () => void }) {
return (
<div style={{
display: 'flex', padding: '6px 0',
borderBottom: '1px solid #141414',
alignItems: 'center', gap: 10,
}}>
<div style={{ color: '#8b8b8b', fontSize: 12, flex: '0 0 160px' }}>{k}</div>
<div className="selectable" style={{
color: '#fff', fontSize: 13, fontFamily: 'monospace',
flex: 1, wordBreak: 'break-all',
}}>{v}</div>
{onCopy && (
<button
onClick={onCopy}
style={{
background: 'transparent', border: 'none',
color: '#1d9bf0', fontSize: 11, fontWeight: 600, cursor: 'pointer',
}}
>copy</button>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
// AboutPage — version info, platform, build links. Reads app.version
// via the preload IPC bridge.
import React, { useEffect, useState } from 'react';
import { PageLayout } from './PageLayout';
import { Card, Label, Hint } from './NodePage';
export function AboutPage(): React.ReactElement {
const [version, setVersion] = useState('dev');
const [platform, setPlatform] = useState('');
useEffect(() => {
window.dchain?.app.version().then(setVersion).catch(() => {});
window.dchain?.app.platform().then(setPlatform).catch(() => {});
}, []);
return (
<PageLayout title="About">
<Card>
<Label>Build</Label>
<div style={{ color: '#fff', fontSize: 14, fontFamily: 'monospace' }}>
DChain Desktop v{version}
</div>
<Hint>
Running on {platform || 'unknown'} · Electron / Chromium
</Hint>
</Card>
<Card>
<Label>Links</Label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<LinkRow
href="https://git.vsecoder.vodka/vsecoder/dchain"
label="Source code (Gitea)"
/>
<LinkRow
href="https://git.vsecoder.vodka/vsecoder/dchain/releases"
label="Releases"
/>
<LinkRow
href="https://git.vsecoder.vodka/vsecoder/dchain/src/branch/main/docs"
label="Documentation"
/>
</div>
</Card>
</PageLayout>
);
}
function LinkRow({ href, label }: { href: string; label: string }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
style={{ color: '#1d9bf0', fontSize: 13, textDecoration: 'none' }}
>{label} </a>
);
}

View File

@@ -0,0 +1,353 @@
// DevicesPage — multi-device registry UI.
//
// Top: list of on-chain devices for this identity. Each row has:
// * badge for "this device" (cannot be unlinked from here — you'd
// wipe yourself on next boot)
// * device name + truncated X25519 pub + added-at
// * Unlink button for others (submits UNLINK_DEVICE tx)
//
// Bottom: "Link new device" modal, same protocol as mobile's
// Settings → Devices → Link new device.
import React, { useCallback, useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import {
fetchDevices, type DeviceInfo,
} from '@/lib/api';
import { buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, humanizeTxError } from '@/lib/tx';
import { sendEnvelope } from '@/lib/relay';
import { encryptMessage, shortAddr } from '@/lib/crypto';
import { PageLayout } from './PageLayout';
import { Card, Label, Hint, inputStyle } from './NodePage';
import { Button } from './IdentityPage';
export function DevicesPage(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [devs, setDevs] = useState<DeviceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [unlinking, setUnlinking] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [linkOpen, setLinkOpen] = useState(false);
const load = useCallback(async () => {
if (!keyFile) return;
setLoading(true);
try {
setDevs(await fetchDevices(keyFile.pub_key));
} finally {
setLoading(false);
}
}, [keyFile]);
useEffect(() => { load(); }, [load]);
const onUnlink = useCallback(async (d: DeviceInfo) => {
if (!keyFile) return;
if (!confirm(
`Unlink "${d.device_name}"? It will stop receiving messages sent to you. ` +
`The device itself will wipe its local state next time it checks in. ` +
`This costs a small network fee.`,
)) return;
setUnlinking(d.x25519_pub_key);
setNotice(null);
try {
const tx = buildUnlinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: d.x25519_pub_key,
privKey: keyFile.priv_key,
});
await submitTx(tx);
setDevs(prev => prev.filter(x => x.x25519_pub_key !== d.x25519_pub_key));
setNotice(`Unlinked — registry will converge in a block or two.`);
} catch (e) {
setNotice(`Unlink failed: ${humanizeTxError(e)}`);
} finally {
setUnlinking(null);
}
}, [keyFile]);
const meX25519 = keyFile?.x25519_pub ?? '';
return (
<PageLayout
title="Devices"
subtitle="Every linked device gets its own encryption key; messages sent to you are delivered to all of them."
>
<Card>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
gap: 12,
}}>
<Label>Linked devices</Label>
<Button onClick={() => setLinkOpen(true)}>Link new device</Button>
</div>
{loading ? (
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>Loading</div>
) : devs.length === 0 ? (
<div style={{ color: '#6a6a6a', fontSize: 13, padding: 12 }}>
No devices registered yet. This device auto-links once a small
network fee is available in your balance pull to refresh after
a first transfer if the list stays empty.
</div>
) : (
<div style={{ marginTop: 4 }}>
{devs.map((d, i) => (
<DeviceRow
key={d.x25519_pub_key}
d={d}
isMe={d.x25519_pub_key === meX25519}
unlinking={unlinking === d.x25519_pub_key}
onUnlink={() => onUnlink(d)}
first={i === 0}
/>
))}
</div>
)}
{notice && (
<div style={{
marginTop: 10, padding: 10, borderRadius: 8,
background: notice.startsWith('Unlink failed')
? '#2a1414' : '#0d2540',
color: notice.startsWith('Unlink failed') ? '#ff9b9b' : '#1d9bf0',
fontSize: 12,
}}>{notice}</div>
)}
</Card>
{linkOpen && (
<LinkNewDeviceModal
onClose={() => setLinkOpen(false)}
onLinked={() => { setLinkOpen(false); setTimeout(load, 1000); }}
/>
)}
</PageLayout>
);
}
// ─── Row ─────────────────────────────────────────────────────────────────
function DeviceRow({
d, isMe, unlinking, onUnlink, first,
}: {
d: DeviceInfo; isMe: boolean; unlinking: boolean;
onUnlink: () => void; first: boolean;
}) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 0',
borderTop: first ? undefined : '1px solid #1f1f1f',
}}>
<div style={{
width: 32, height: 32, borderRadius: 8,
background: isMe ? '#0d2540' : '#1a1a1a',
color: isMe ? '#1d9bf0' : '#d0d0d0',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16,
}}>📱</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
}}>
<span style={{ color: '#fff', fontSize: 14, fontWeight: 700 }}>
{d.device_name || 'Unnamed device'}
</span>
{isMe && (
<span style={{
padding: '1px 6px', borderRadius: 6,
background: '#0d2540', color: '#1d9bf0',
fontSize: 10, fontWeight: 700, letterSpacing: 0.5,
}}>THIS DEVICE</span>
)}
</div>
<div style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 3,
}}>
{shortAddr(d.x25519_pub_key, 10)}
</div>
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
Linked {new Date(d.added_at * 1000).toLocaleString()}
</div>
</div>
{!isMe && (
<button
onClick={onUnlink}
disabled={unlinking}
style={{
padding: '6px 12px', borderRadius: 999,
background: 'transparent', border: '1px solid #3a2020',
color: '#ff6b6b', fontSize: 11, fontWeight: 700,
cursor: unlinking ? 'default' : 'pointer',
opacity: unlinking ? 0.5 : 1,
}}
>{unlinking ? '…' : 'Unlink'}</button>
)}
</div>
);
}
// ─── Link New Device modal ───────────────────────────────────────────────
function LinkNewDeviceModal({
onClose, onLinked,
}: {
onClose: () => void;
onLinked: () => void;
}): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [code, setCode] = useState('');
const [key, setKey] = useState('');
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const submit = async () => {
if (!keyFile) return;
const c = code.replace(/\s+/g, '').trim();
const k = key.replace(/\s+/g, '').trim().toLowerCase();
if (!/^\d{6}$/.test(c)) { setErr('Code must be 6 digits.'); return; }
if (!/^[0-9a-f]{64}$/.test(k)) { setErr('Device key must be 64 hex chars.'); return; }
const nm = name.trim() || 'New device';
setBusy(true); setErr(null);
try {
// 1. LINK_DEVICE tx → registry learns the new pub.
const linkTx = buildLinkDeviceTx({
from: keyFile.pub_key,
x25519Pub: k,
deviceName: nm,
privKey: keyFile.priv_key,
});
await submitTx(linkTx);
// 2. Handshake envelope — encrypt master priv for the new device.
const payload = JSON.stringify({
v: 1,
type: 'pair-handshake',
code: c,
master_pub: keyFile.pub_key,
master_priv: keyFile.priv_key,
master_x25519_pub: keyFile.x25519_pub,
});
const { nonce, ciphertext } = encryptMessage(
payload, keyFile.x25519_priv, k,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: k,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
onLinked();
} catch (e) {
setErr(humanizeTxError(e));
} finally {
setBusy(false);
}
};
return (
<div
onClick={() => !busy && onClose()}
style={{
position: 'fixed', inset: 0, zIndex: 20,
background: 'rgba(0,0,0,0.7)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24,
}}
>
<div
onClick={e => e.stopPropagation()}
style={{
width: '100%', maxWidth: 520, padding: 20, borderRadius: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}
>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 12,
}}>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>
Link new device
</div>
<button
onClick={onClose} disabled={busy}
style={{
background: 'transparent', border: 'none',
color: '#8b8b8b', fontSize: 20, cursor: 'pointer',
}}
>×</button>
</div>
<Hint>
On the new device, tap <b>Pair</b> on the welcome screen and
transcribe the 6-digit code and device key from there into the
fields below.
</Hint>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
<Field>
<Label>6-digit code</Label>
<input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="000000"
inputMode="numeric"
maxLength={6}
style={{ ...inputStyle, letterSpacing: 4, textAlign: 'center' }}
/>
</Field>
<Field>
<Label>Device key (64 hex)</Label>
<input
value={key}
onChange={e => setKey(e.target.value)}
placeholder="a1b2c3…"
spellCheck={false}
style={inputStyle}
/>
</Field>
<Field>
<Label>Name (optional)</Label>
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder="e.g. Alice's laptop"
maxLength={64}
style={{ ...inputStyle, fontFamily: 'inherit' }}
/>
</Field>
</div>
{err && (
<div style={{
marginTop: 12, padding: 10, borderRadius: 8,
background: '#2a1414', color: '#ff9b9b', fontSize: 12,
}}>{err}</div>
)}
<div style={{
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
}}>
<button
onClick={onClose}
disabled={busy}
style={{
padding: '9px 14px', borderRadius: 999,
background: 'transparent', border: '1px solid #1f1f1f',
color: '#8b8b8b', fontSize: 13, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
}}
>Cancel</button>
<Button onClick={submit} disabled={busy}>{busy ? '…' : 'Link'}</Button>
</div>
</div>
</div>
);
}
function Field({ children }: { children: React.ReactNode }) {
return <div>{children}</div>;
}

View File

@@ -0,0 +1,159 @@
// Identity settings — pub key, copy, export/import key file, delete account.
import React, { useState } from 'react';
import { useStore } from '@/lib/store';
import { saveKeyFile, wipeAllLocalState } from '@/lib/storage';
import type { KeyFile } from '@/lib/types';
import { PageLayout } from './PageLayout';
import { Card, Label, Hint } from './NodePage';
export function IdentityPage(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const setKeyFile = useStore(s => s.setKeyFile);
const [notice, setNotice] = useState<string | null>(null);
if (!keyFile) return <PageLayout title="Identity"><div>No identity loaded.</div></PageLayout>;
const copy = async (s: string, label: string) => {
await navigator.clipboard.writeText(s);
setNotice(`${label} copied`);
setTimeout(() => setNotice(null), 1500);
};
const exportKey = async () => {
const target = await window.dchain.dialog.saveFile({
title: 'Export key file',
defaultPath: 'node.json',
filters: [{ name: 'JSON', extensions: ['json'] }],
});
if (!target) return;
try {
await window.dchain.fs.writeText(target, JSON.stringify(keyFile, null, 2));
setNotice('Key saved — keep it offline + backed up.');
} catch (e) {
setNotice(`Export failed: ${e}`);
}
};
const importKey = async () => {
const src = await window.dchain.dialog.openFile({
title: 'Import key file',
filters: [{ name: 'JSON', extensions: ['json'] }],
properties: ['openFile'],
});
if (!src) return;
try {
const raw = await window.dchain.fs.readText(src);
const parsed = JSON.parse(raw) as KeyFile;
if (!parsed.pub_key || !parsed.priv_key) throw new Error('not a key file');
if (!confirm('Replace the current identity with the imported one? The current identity will be wiped from this device.')) return;
await wipeAllLocalState();
await saveKeyFile(parsed);
setKeyFile(parsed);
setNotice('Imported — reload is not needed, new identity active.');
} catch (e) {
setNotice(`Import failed: ${e}`);
}
};
const deleteAccount = async () => {
if (!confirm('Delete this identity from this device? Keys are NOT recoverable from the server — export first if you want to keep them.')) return;
await wipeAllLocalState();
setKeyFile(null);
};
return (
<PageLayout title="Identity" subtitle="Your Ed25519 master key. Keep it safe — there is no password recovery.">
<Card>
<Label>Public key (Ed25519, hex)</Label>
<div className="selectable" style={{
color: '#fff', fontSize: 12, fontFamily: 'monospace',
wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.pub_key}
</div>
<ActionRow>
<Button onClick={() => copy(keyFile.pub_key, 'Pub key')}>Copy</Button>
</ActionRow>
</Card>
<Card>
<Label>Device encryption key (X25519, hex)</Label>
<div className="selectable" style={{
color: '#fff', fontSize: 12, fontFamily: 'monospace',
wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.x25519_pub}
</div>
<Hint>
Only this device uses this X25519 pair. Sharing the master Ed25519
pub (above) is how contacts find you across all your devices.
</Hint>
</Card>
<Card>
<Label>Backup</Label>
<ActionRow>
<Button onClick={exportKey}>Export key file</Button>
<Button onClick={importKey} danger>Import / replace</Button>
</ActionRow>
<Hint>
Exports a JSON file compatible with the mobile client and
server's <code>--key</code> flag. The file is <strong>not</strong>
encrypted on disk — store it somewhere safe.
</Hint>
</Card>
<Card>
<Label>Danger zone</Label>
<ActionRow>
<Button onClick={deleteAccount} danger>Delete this identity</Button>
</ActionRow>
<Hint>
Wipes the key, contacts, and chat cache from this device.
Without an export, this is irreversible.
</Hint>
</Card>
{notice && (
<div style={{
padding: 10, borderRadius: 8,
background: '#0d2540', color: '#1d9bf0', fontSize: 12,
}}>{notice}</div>
)}
</PageLayout>
);
}
function ActionRow({ children }: { children: React.ReactNode }) {
return (
<div style={{
display: 'flex', gap: 8, marginTop: 10, flexWrap: 'wrap',
}}>{children}</div>
);
}
export function Button({
children, onClick, danger, disabled,
}: {
children: React.ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: '8px 14px', borderRadius: 999,
background: danger ? 'transparent' : '#1d9bf0',
border: danger ? '1px solid #3a2020' : 'none',
color: danger ? '#ff6b6b' : '#fff',
fontSize: 12, fontWeight: 700,
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}
>{children}</button>
);
}

View File

@@ -0,0 +1,115 @@
// Node settings page — URL, connection ping-on-commit, token field.
import React, { useEffect, useState } from 'react';
import { useStore } from '@/lib/store';
import { getNetStats, setNodeUrl, setApiToken } from '@/lib/api';
import { saveSettings } from '@/lib/storage';
import { PageLayout } from './PageLayout';
export function NodePage(): React.ReactElement {
const settings = useStore(s => s.settings);
const setSettings = useStore(s => s.setSettings);
const [url, setUrl] = useState(settings.nodeUrl);
const [token, setToken] = useState(settings.apiToken ?? '');
const [ok, setOk] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => { setUrl(settings.nodeUrl); setToken(settings.apiToken ?? ''); },
[settings.nodeUrl, settings.apiToken]);
const apply = async () => {
const clean = url.trim().replace(/\/$/, '');
if (!clean) return;
setBusy(true); setOk(null);
setNodeUrl(clean);
setApiToken(token.trim() || null);
try {
await getNetStats();
setOk(true);
const next = { nodeUrl: clean, apiToken: token.trim() || undefined };
setSettings(next);
saveSettings(next);
} catch {
setOk(false);
} finally {
setBusy(false);
}
};
const dot = ok === true ? '#3ba55d' : ok === false ? '#f4212e' : '#8b8b8b';
return (
<PageLayout title="Node" subtitle="Which DChain node this client talks to">
<Card>
<Label>Node URL</Label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 7, height: 7, borderRadius: 3.5, background: dot }} />
<input
value={url}
onChange={e => { setUrl(e.target.value); setOk(null); }}
onBlur={apply}
onKeyDown={e => { if (e.key === 'Enter') apply(); }}
placeholder="http://node.example:8080"
spellCheck={false}
style={inputStyle}
/>
{busy && <span style={{ color: '#8b8b8b', fontSize: 11 }}></span>}
</div>
<Hint>
Enter or tab-out to ping. Green dot = `/api/netstats` replied.
</Hint>
</Card>
<Card>
<Label>API token (optional)</Label>
<input
type="password"
value={token}
onChange={e => setToken(e.target.value)}
onBlur={apply}
placeholder="paste Bearer token if node requires it"
spellCheck={false}
style={inputStyle}
/>
<Hint>
Some nodes gate writes with DCHAIN_API_TOKEN; leave blank for
public ones.
</Hint>
</Card>
</PageLayout>
);
}
// ─── Reusable primitives (also imported by Identity / Devices / About) ───
export function Card({ children }: { children: React.ReactNode }) {
return (
<div style={{
padding: 14, marginBottom: 14, borderRadius: 12,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>{children}</div>
);
}
export function Label({ children }: { children: React.ReactNode }) {
return (
<div style={{
color: '#8b8b8b', fontSize: 11, fontWeight: 700,
letterSpacing: 1.2, textTransform: 'uppercase', marginBottom: 8,
}}>{children}</div>
);
}
export function Hint({ children }: { children: React.ReactNode }) {
return (
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 6, lineHeight: 1.5 }}>
{children}
</div>
);
}
export const inputStyle: React.CSSProperties = {
flex: 1, boxSizing: 'border-box',
background: '#000', border: '1px solid #1f1f1f',
borderRadius: 8, padding: '10px 12px',
color: '#fff', fontSize: 13, fontFamily: 'monospace',
outline: 'none', width: '100%',
};

View File

@@ -0,0 +1,33 @@
// Shared layout for Settings subsection pages — sticky header with the
// page title + scroll body. Keeps spacing consistent across Node /
// Identity / Devices / About.
import React from 'react';
export function PageLayout({
title, subtitle, children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}): React.ReactElement {
return (
<div style={{
height: '100%', overflowY: 'auto', background: '#000',
}}>
<div style={{
position: 'sticky', top: 0, zIndex: 1,
padding: '14px 22px', borderBottom: '1px solid #1f1f1f',
background: 'rgba(0,0,0,0.9)', backdropFilter: 'blur(6px)',
}}>
<div style={{ color: '#fff', fontSize: 16, fontWeight: 800 }}>{title}</div>
{subtitle && (
<div style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>{subtitle}</div>
)}
</div>
<div style={{ padding: '18px 22px' }}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,18 @@
// Right-pane content for Settings. Renders by store.settingsPage.
import React from 'react';
import { useStore } from '@/lib/store';
import { NodePage } from './NodePage';
import { IdentityPage } from './IdentityPage';
import { DevicesPage } from './DevicesPage';
import { AboutPage } from './AboutPage';
export function SettingsDetail(): React.ReactElement {
const page = useStore(s => s.settingsPage);
switch (page) {
case 'node': return <NodePage />;
case 'identity': return <IdentityPage />;
case 'devices': return <DevicesPage />;
case 'about': return <AboutPage />;
}
}

View File

@@ -0,0 +1,61 @@
// Left-pane category list for Settings. Keeps selection in
// store.settingsPage so switching away and back preserves place.
import React from 'react';
import { useStore, type SettingsPage } from '@/lib/store';
interface Row {
key: SettingsPage;
label: string;
hint: string;
}
const ROWS: Row[] = [
{ key: 'node', label: 'Node', hint: 'URL, connection status' },
{ key: 'identity', label: 'Identity', hint: 'Your keys and address' },
{ key: 'devices', label: 'Devices', hint: 'Linked devices, pair a new one' },
{ key: 'about', label: 'About', hint: 'Version, links' },
];
export function SettingsNav(): React.ReactElement {
const page = useStore(s => s.settingsPage);
const setPage = useStore(s => s.setSettingsPage);
return (
<div style={{ padding: 10 }}>
{ROWS.map(r => (
<NavEntry
key={r.key}
label={r.label}
hint={r.hint}
active={page === r.key}
onClick={() => setPage(r.key)}
/>
))}
</div>
);
}
function NavEntry({
label, hint, active, onClick,
}: { label: string; hint: string; active: boolean; onClick: () => void }) {
return (
<div
onClick={onClick}
style={{
padding: '10px 12px', borderRadius: 10, cursor: 'pointer',
background: active ? '#0a1a29' : 'transparent',
border: active ? '1px solid #1d9bf022' : '1px solid transparent',
}}
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }}
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }}
>
<div style={{
color: active ? '#1d9bf0' : '#fff',
fontSize: 14, fontWeight: 700,
}}>{label}</div>
<div style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
{hint}
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,75 @@
// ReceiveModal — shows this wallet's pub key + a copy button. QR-code
// polish goes in rc1 (needs a deps pull for qrcode-svg or similar).
import React, { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { useStore } from '@/lib/store';
import { Backdrop, Header, primaryBtnStyle } from './SendModal';
export function ReceiveModal({ onClose }: { onClose: () => void }): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [copied, setCopied] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
// Paint the QR on mount. Skip if there's no key (shouldn't happen but
// component is safe against it).
useEffect(() => {
if (!keyFile || !canvasRef.current) return;
QRCode.toCanvas(canvasRef.current, keyFile.pub_key, {
width: 196,
margin: 1,
color: { dark: '#ffffff', light: '#00000000' },
errorCorrectionLevel: 'M',
}).catch(() => { /* fall back to text only */ });
}, [keyFile]);
if (!keyFile) return <></>;
const copy = async () => {
try { await navigator.clipboard.writeText(keyFile.pub_key); setCopied(true); }
catch { /* ignore */ }
setTimeout(() => setCopied(false), 1400);
};
return (
<Backdrop onClose={onClose}>
<div style={{
width: '100%', maxWidth: 460, padding: 20, borderRadius: 16,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<Header title="Receive" onClose={onClose} busy={false} />
<div style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 1.5 }}>
Share your public key anyone can send you tokens or add you as
a contact using this address.
</div>
<div style={{
marginTop: 14, display: 'flex', justifyContent: 'center',
padding: 16, borderRadius: 10,
background: '#000', border: '1px solid #1f1f1f',
}}>
<canvas ref={canvasRef} style={{ imageRendering: 'pixelated' }} />
</div>
<div className="selectable" style={{
marginTop: 10, padding: 14, borderRadius: 10,
background: '#000', border: '1px solid #1f1f1f',
color: '#fff', fontFamily: 'monospace', fontSize: 12,
wordBreak: 'break-all', lineHeight: 1.5,
}}>
{keyFile.pub_key}
</div>
<div style={{
marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 10,
}}>
<button
onClick={copy}
style={primaryBtnStyle(false)}
>{copied ? 'Copied!' : 'Copy'}</button>
</div>
</div>
</Backdrop>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,9 @@
import React, { useEffect, useState } from 'react';
import { SectionPlaceholder } from '@/shell/SectionPlaceholder';
import { useStore } from '@/lib/store';
import { getBalance } from '@/lib/api';
// 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.
function formatT(ut: number): string {
return (ut / 1_000_000).toLocaleString(undefined, { maximumFractionDigits: 3 });
}
export function WalletList(): React.ReactElement {
const keyFile = useStore(s => s.keyFile);
const [balance, setBalance] = useState<number | null>(null);
useEffect(() => {
if (!keyFile) return;
getBalance(keyFile.pub_key).then(setBalance).catch(() => setBalance(null));
}, [keyFile]);
return (
<div style={{ padding: 14 }}>
<div style={{
borderRadius: 14, padding: 14,
background: '#0a0a0a', border: '1px solid #1f1f1f',
}}>
<div style={{ color: '#8b8b8b', fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1 }}>
Balance
</div>
<div style={{ color: '#fff', fontSize: 22, fontWeight: 800, marginTop: 4 }}>
{balance === null ? '—' : `${formatT(balance)} T`}
</div>
<div className="selectable" style={{
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
marginTop: 6, wordBreak: 'break-all',
}}>
{keyFile?.pub_key}
</div>
</div>
</div>
);
}
export function WalletDetail(): React.ReactElement {
return <SectionPlaceholder title="Wallet" note="Transaction history — coming soon." centered />;
}
export { WalletOverview as WalletList } from './WalletOverview';
export { WalletDetailPane as WalletDetail } from './WalletDetailPane';

View File

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

View File

@@ -19,9 +19,12 @@
import React from 'react';
import { useStore, type Section } from '@/lib/store';
import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
import { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
import { UpdateBanner } from './UpdateBanner';
import { PaneBoundary } from './PaneBoundary';
import { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
@@ -32,6 +35,7 @@ import { ProfileList, ProfileDetail } from '@/sections/profile';
export function Shell(): React.ReactElement {
const section = useStore(s => s.section);
const { List, Detail } = PANES[section];
useGlobalKeybinds();
return (
<div style={{
display: 'flex', flexDirection: 'column',
@@ -48,12 +52,17 @@ export function Shell(): React.ReactElement {
borderRight: '1px solid #1f1f1f',
overflowY: 'auto',
}}>
<List />
<PaneBoundary key={`${section}-list`} sectionName={`${section} / list`}>
<List />
</PaneBoundary>
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<Detail />
<PaneBoundary key={`${section}-detail`} sectionName={`${section} / detail`}>
<Detail />
</PaneBoundary>
</div>
</div>
<UpdateBanner />
<StatusBar />
</div>
);

View File

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

View File

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

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

@@ -90,27 +90,29 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
}
type item struct {
ID string `json:"id"`
SenderPub string `json:"sender_pub"`
RecipientPub string `json:"recipient_pub"`
FeeUT uint64 `json:"fee_ut,omitempty"`
SentAt int64 `json:"sent_at"`
SentAtHuman string `json:"sent_at_human"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
ID string `json:"id"`
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"`
SentAtHuman string `json:"sent_at_human"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
}
out := make([]item, 0, len(envelopes))
for _, env := range envelopes {
out = append(out, item{
ID: env.ID,
SenderPub: env.SenderPub,
RecipientPub: env.RecipientPub,
FeeUT: env.FeeUT,
SentAt: env.SentAt,
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
Nonce: env.Nonce,
Ciphertext: env.Ciphertext,
ID: env.ID,
SenderPub: env.SenderPub,
SenderEd25519Pub: env.SenderEd25519PubKey,
RecipientPub: env.RecipientPub,
FeeUT: env.FeeUT,
SentAt: env.SentAt,
SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339),
Nonce: env.Nonce,
Ciphertext: env.Ciphertext,
})
}

32
node/cors.go Normal file
View File

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

View File

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