7 Commits

Author SHA1 Message Date
vsecoder
6b7cb1c5a9 feat(desktop): contact requests, auto-update banner, packaging polish (v2.2.0)
Closes the v2.2.0 roadmap. Desktop client is feature-complete and
ready for first installer builds.

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

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

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

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

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

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

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

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

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

docs/ROADMAP.md — rc1 row flipped to done; v2.2.0 narrows to
auto-update + packaging + optional attachments in Compose.
2026-04-22 18:39:39 +03:00
vsecoder
98ac700e0a feat(desktop): Feed + Wallet sections (v2.2.0-alpha6)
Desktop client reaches full feature parity with mobile for the two
heaviest sections. Contacts + Devices screen + polish pass remain for
rc1.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also: npm script electron:dev now rebuilds main.ts before spawning
Electron (was a silent concurrency bug — TypeScript errors in main.ts
would produce stale dist-electron/).
2026-04-22 17:13:26 +03:00
61 changed files with 5828 additions and 359 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,8 +2,11 @@
<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>
html, body, #root { margin: 0; padding: 0; height: 100%; background: #000; }

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,7 +4,7 @@
// 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';
@@ -14,23 +14,47 @@ import { Welcome } from '@/auth/Welcome';
export function App(): React.ReactElement {
const booted = useStore(s => s.booted);
const keyFile = useStore(s => s.keyFile);
const [bootError, setBootError] = useState<string | null>(null);
useEffect(() => {
(async () => {
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,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,168 @@
// RequestsList — pending contact requests inbox.
//
// Each row shows the requester (identity if known + DC address + fee paid)
// and their intro message. Accept publishes ACCEPT_CONTACT on-chain,
// adds the peer to the local contacts store, and optimistically drops
// the row. Reject (Block) publishes BLOCK_CONTACT; subsequent requests
// from the same sender are refused by the node.
import React, { useState } from 'react';
import { useStore } from '@/lib/store';
import {
buildAcceptContactTx, buildBlockContactTx, submitTx, humanizeTxError,
} from '@/lib/tx';
import { upsertContact as persistContact } from '@/lib/storage';
import { getIdentity, type ContactRequestRaw } from '@/lib/api';
import { shortAddr } from '@/lib/crypto';
export function RequestsList({
requests, onChanged,
}: {
requests: ContactRequestRaw[];
onChanged: () => void;
}): React.ReactElement {
if (requests.length === 0) {
return (
<div style={{
padding: 32, color: '#6a6a6a', fontSize: 13, textAlign: 'center',
}}>
No pending requests. Inbound CONTACT_REQUEST txs will show up here
for you to accept or block.
</div>
);
}
return (
<div>
{requests.map(r => (
<RequestRow key={r.tx_id} req={r} onChanged={onChanged} />
))}
</div>
);
}
function RequestRow({
req, onChanged,
}: { req: ContactRequestRaw; onChanged: () => void }) {
const keyFile = useStore(s => s.keyFile);
const upsertContact = useStore(s => s.upsertContact);
const [busy, setBusy] = useState<'accept' | 'block' | null>(null);
const [err, setErr] = useState<string | null>(null);
const act = async (kind: 'accept' | 'block') => {
if (!keyFile) return;
setBusy(kind); setErr(null);
try {
if (kind === 'accept') {
// Need the requester's X25519 so a local contact is created
// with encryption enabled out of the gate — without it the
// first outgoing message would surface "no key" until we
// refetched via resolveRecipientKeys.
const identity = await getIdentity(req.requester_pub);
const tx = buildAcceptContactTx({
from: keyFile.pub_key,
to: req.requester_pub,
privKey: keyFile.priv_key,
});
await submitTx(tx);
const c = {
address: req.requester_pub,
x25519Pub: identity?.x25519_pub ?? '',
username: identity?.nickname || undefined,
alias: undefined,
addedAt: Date.now(),
};
upsertContact(c);
persistContact(c);
} else {
const tx = buildBlockContactTx({
from: keyFile.pub_key,
to: req.requester_pub,
privKey: keyFile.priv_key,
});
await submitTx(tx);
}
onChanged();
} catch (e) {
setErr(humanizeTxError(e));
} finally {
setBusy(null);
}
};
return (
<div style={{
padding: 14, borderBottom: '1px solid #1f1f1f',
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8,
}}>
<div style={{
width: 36, height: 36, borderRadius: 18, background: '#1a1a1a',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#d0d0d0', fontWeight: 700,
}}>{shortAddr(req.requester_pub, 1).charAt(0).toUpperCase()}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
color: '#fff', fontSize: 13, fontWeight: 700,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{shortAddr(req.requester_pub, 8)}
</div>
<div style={{ color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace' }}>
{req.requester_addr}
</div>
</div>
<div style={{
color: '#f0b35a', fontSize: 11, fontWeight: 700,
}}>
+{(req.fee_ut / 1_000_000).toFixed(3)} T
</div>
</div>
{req.intro && (
<div className="selectable" style={{
padding: 10, borderRadius: 8,
background: '#000', border: '1px solid #1f1f1f',
color: '#e0e0e0', fontSize: 12, lineHeight: 1.5,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
marginBottom: 8,
}}>
{req.intro}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={() => act('block')}
disabled={!!busy}
style={{
padding: '7px 12px', borderRadius: 999,
background: 'transparent', border: '1px solid #3a2020',
color: '#ff6b6b', fontSize: 12, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
opacity: busy ? 0.5 : 1,
}}
>{busy === 'block' ? '…' : 'Block'}</button>
<button
onClick={() => act('accept')}
disabled={!!busy}
style={{
padding: '7px 14px', borderRadius: 999,
border: 'none', background: '#1d9bf0', color: '#fff',
fontSize: 12, fontWeight: 700,
cursor: busy ? 'default' : 'pointer',
opacity: busy ? 0.5 : 1,
}}
>{busy === 'accept' ? '…' : 'Accept'}</button>
</div>
{err && (
<div style={{
marginTop: 8, padding: 8, borderRadius: 6,
background: '#2a1414', color: '#ff9b9b', fontSize: 11,
}}>{err}</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

@@ -19,9 +19,11 @@
import React from 'react';
import { useStore, type Section } from '@/lib/store';
import { useGlobalKeybinds } from '@/hooks/useGlobalKeybinds';
import { TitleBar } from './TitleBar';
import { NavBar } from './NavBar';
import { StatusBar } from './StatusBar';
import { UpdateBanner } from './UpdateBanner';
import { MessagesList, MessagesDetail } from '@/sections/messages';
import { FeedList, FeedDetail } from '@/sections/feed';
import { WalletList, WalletDetail } from '@/sections/wallet';
@@ -32,6 +34,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',
@@ -54,6 +57,7 @@ export function Shell(): React.ReactElement {
<Detail />
</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)
}