diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx index 52f8436..04687ed 100644 --- a/client-app/app/(app)/chats/[id].tsx +++ b/client-app/app/(app)/chats/[id].tsx @@ -107,7 +107,7 @@ export default function ChatScreen() { const [selectedIds, setSelectedIds] = useState>(new Set()); const selectionMode = selectedIds.size > 0; - useMessages(contact?.x25519Pub ?? ''); + useMessages(contact?.x25519Pub ?? '', contact?.address); // ── Typing indicator от peer'а ───────────────────────────────────────── useEffect(() => { diff --git a/client-app/hooks/useGlobalInbox.ts b/client-app/hooks/useGlobalInbox.ts index f24d9dc..aff6df1 100644 --- a/client-app/hooks/useGlobalInbox.ts +++ b/client-app/hooks/useGlobalInbox.ts @@ -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; diff --git a/client-app/hooks/useMessages.ts b/client-app/hooks/useMessages.ts index 5163f9d..833d63b 100644 --- a/client-app/hooks/useMessages.ts +++ b/client-app/hooks/useMessages.ts @@ -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(); }); diff --git a/client-app/lib/api.ts b/client-app/lib/api.ts index 53c42ba..d3e1e84 100644 --- a/client-app/lib/api.ts +++ b/client-app/lib/api.ts @@ -262,14 +262,17 @@ export async function getTxHistory(pubkey: string, limit = 50): Promise { const resp = await get(`/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, })); } diff --git a/client-app/lib/types.ts b/client-app/lib/types.ts index 45ba605..a1b2493 100644 --- a/client-app/lib/types.ts +++ b/client-app/lib/types.ts @@ -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 diff --git a/cmd/node/main.go b/cmd/node/main.go index bd325ca..50775b7 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -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) }) diff --git a/desktop/package.json b/desktop/package.json index 5d45b9a..e32e8e1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "dchain-desktop", - "version": "2.2.0-alpha4", + "version": "2.2.0-alpha5", "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", diff --git a/desktop/src/auth/Pair.tsx b/desktop/src/auth/Pair.tsx new file mode 100644 index 0000000..7db1892 --- /dev/null +++ b/desktop/src/auth/Pair.tsx @@ -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(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 | 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 ( +
+
+ + +

+ Pair with your other device +

+

+ On a device where you're already signed in, open + Settings → Devices → Link new device and + enter these two values. +

+ + {/* Code */} + +
+ {session.code.slice(0, 3)} {session.code.slice(3)} +
+ copy(session.code)}>Copy code +
+ + {/* Device key */} + +
+ {session.x25519Pub} +
+ copy(session.x25519Pub)}>Copy key +
+ + {/* Status */} +
+ {status === 'waiting' + ? 'Waiting for your other device…' + : 'Paired. Opening your chats…'} +
+
+
+ ); +} + +function Card({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+ {title} +
+ {children} +
+ ); +} + +function CopyLink({ children, onClick }: { + children: React.ReactNode; onClick: () => void; +}) { + return ( + + ); +} diff --git a/desktop/src/auth/Welcome.tsx b/desktop/src/auth/Welcome.tsx index 6f52da5..699953a 100644 --- a/desktop/src/auth/Welcome.tsx +++ b/desktop/src/auth/Welcome.tsx @@ -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(null); + const [screen, setScreen] = useState<'welcome' | 'pair'>('welcome'); + + if (screen === 'pair') return 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 ( diff --git a/desktop/src/hooks/useInboxPoll.ts b/desktop/src/hooks/useInboxPoll.ts new file mode 100644 index 0000000..d47f61b --- /dev/null +++ b/desktop/src/hooks/useInboxPoll.ts @@ -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>(new Set()); + const activeChatRef = useRef(activeChat); + useEffect(() => { activeChatRef.current = activeChat; }, [activeChat]); + + useEffect(() => { + if (!keyFile) return; + let cancelled = false; + let timer: ReturnType | 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); + } +} diff --git a/desktop/src/lib/crypto.ts b/desktop/src/lib/crypto.ts new file mode 100644 index 0000000..cee4426 --- /dev/null +++ b/desktop/src/lib/crypto.ts @@ -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)}`; +} diff --git a/desktop/src/lib/relay.ts b/desktop/src/lib/relay.ts new file mode 100644 index 0000000..7f48388 --- /dev/null +++ b/desktop/src/lib/relay.ts @@ -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= → 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 { + const resp = await get(`/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 { + 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] : []; +} diff --git a/desktop/src/lib/storage.ts b/desktop/src/lib/storage.ts index a66b823..0f4bd7d 100644 --- a/desktop/src/lib/storage.ts +++ b/desktop/src/lib/storage.ts @@ -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 { @@ -105,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'; diff --git a/desktop/src/lib/store.ts b/desktop/src/lib/store.ts index c0b6fdf..e2f90ed 100644 --- a/desktop/src/lib/store.ts +++ b/desktop/src/lib/store.ts @@ -1,10 +1,11 @@ -// 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'; @@ -14,6 +15,12 @@ interface State { 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; + /** Unread counters keyed by contact.address; 0 (or absent) = nothing pending. */ + unread: Record; setBooted: (v: boolean) => void; setKeyFile: (k: KeyFile | null) => void; @@ -21,14 +28,23 @@ 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; } export const useStore = create((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 +60,25 @@ export const useStore = create((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 }; + }), })); diff --git a/desktop/src/lib/tx.ts b/desktop/src/lib/tx.ts new file mode 100644 index 0000000..d6c8cba --- /dev/null +++ b/desktop/src/lib/tx.ts @@ -0,0 +1,145 @@ +// Transaction builders + submission. +// +// Mirrors the handful of builders we actually use from client-app/lib/api.ts +// (Transfer, Link/UnlinkDevice for now; more will follow as sections land). +// Canonical bytes and wire format are identical to the mobile client — +// both talk to the same Go node, so any divergence here is a bug. + +import { bytesToBase64, signBase64 } from './crypto'; +import { post } from './api'; + +const MIN_TX_FEE = 1_000; +const _encoder = new TextEncoder(); + +/** + * Transaction as sent to /api/tx — maps 1-to-1 to blockchain.Transaction + * JSON. `payload` and `signature` are base64 because Go's json.Marshal + * encodes []byte that way; `timestamp` is RFC3339 because Go's time.Time + * does the same. + */ +export interface RawTx { + id: string; + type: string; + from: string; + to: string; + amount: number; + fee: number; + memo?: string; + payload: string; + signature: string; + timestamp: string; +} + +function rfc3339Now(): string { + const d = new Date(); + d.setMilliseconds(0); + return d.toISOString().replace('.000Z', 'Z'); +} + +function newTxID(): string { + return `tx-${Date.now()}${Math.floor(Math.random() * 1_000_000)}`; +} + +/** + * Canonical bytes the node re-derives to verify tx.signature. Order of + * keys matches Go's field order in identity.txSignBytes — JS object + * literals preserve insertion order so JSON.stringify is enough. + */ +function canonicalBytes(tx: { + id: string; type: string; from: string; to: string; + amount: number; fee: number; payload: string; timestamp: string; +}): Uint8Array { + return _encoder.encode(JSON.stringify({ + id: tx.id, + type: tx.type, + from: tx.from, + to: tx.to, + amount: tx.amount, + fee: tx.fee, + payload: tx.payload, + timestamp: tx.timestamp, + })); +} + +function strToBase64(s: string): string { + return bytesToBase64(_encoder.encode(s)); +} + +export async function submitTx(tx: RawTx): Promise<{ id: string; status: string }> { + return post<{ id: string; status: string }>('/api/tx', tx); +} + +// ─── Builders ──────────────────────────────────────────────────────────── + +export function buildTransferTx(p: { + from: string; to: string; amount: number; fee: number; + privKey: string; memo?: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify(p.memo ? { memo: p.memo } : {})); + const canon = canonicalBytes({ + id, type: 'TRANSFER', from: p.from, to: p.to, + amount: p.amount, fee: p.fee, payload, timestamp, + }); + return { + id, type: 'TRANSFER', from: p.from, to: p.to, + amount: p.amount, fee: p.fee, memo: p.memo, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +export function buildLinkDeviceTx(p: { + from: string; x25519Pub: string; deviceName: string; privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify({ + x25519_pub_key: p.x25519Pub, + device_name: p.deviceName, + })); + const canon = canonicalBytes({ + id, type: 'LINK_DEVICE', from: p.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + return { + id, type: 'LINK_DEVICE', from: p.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +export function buildUnlinkDeviceTx(p: { + from: string; x25519Pub: string; privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payload = strToBase64(JSON.stringify({ x25519_pub_key: p.x25519Pub })); + const canon = canonicalBytes({ + id, type: 'UNLINK_DEVICE', from: p.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + return { + id, type: 'UNLINK_DEVICE', from: p.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canon, p.privKey), + }; +} + +/** + * humanizeTxError unwraps the server's `{"error":"…"}` shape and common + * message wrappers into a one-line user-facing string. Same helper the + * mobile client exposes from lib/api.ts; copied here to keep the two + * codebases independent until we factor into a shared package. + */ +export function humanizeTxError(err: unknown): string { + const raw = err instanceof Error ? err.message : String(err); + const m = /→\s*({[^}]+})/.exec(raw); + if (m) { + try { + const parsed = JSON.parse(m[1]); + if (parsed.error) return parsed.error; + } catch { /* fall through */ } + } + return raw; +} diff --git a/desktop/src/sections/messages/ChatList.tsx b/desktop/src/sections/messages/ChatList.tsx new file mode 100644 index 0000000..3b56d6b --- /dev/null +++ b/desktop/src/sections/messages/ChatList.tsx @@ -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 ( +
+ No conversations yet. Messages from pairing devices or contacts + will appear here. +
+ ); + } + + return ( +
+ {sorted.map(({ c, last }) => ( + { + setActive(c.address); + useStore.getState().clearUnread(c.address); + }} + /> + ))} +
+ ); +} + +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 ( +
{ if (!active) (e.currentTarget as HTMLDivElement).style.background = '#0a0a0a'; }} + onMouseLeave={e => { if (!active) (e.currentTarget as HTMLDivElement).style.background = 'transparent'; }} + > + +
+
+
+ {name} +
+ {time && ( +
+ {time} +
+ )} +
+
+
+ {last ? preview(last) : 'Tap to start'} +
+ {unread > 0 && ( +
+ {unread > 99 ? '99+' : unread} +
+ )} +
+
+
+ ); +} + +function Avatar({ name }: { name: string }) { + const letter = name.replace(/^@/, '').charAt(0).toUpperCase() || '?'; + return ( +
{letter}
+ ); +} + +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(); +} diff --git a/desktop/src/sections/messages/Conversation.tsx b/desktop/src/sections/messages/Conversation.tsx new file mode 100644 index 0000000..a354191 --- /dev/null +++ b/desktop/src/sections/messages/Conversation.tsx @@ -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(null); + + const scrollRef = useRef(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) => { + 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 ( +
+ {/* Header */} +
+
+ {isSelf ? '★' : name.replace(/^@/, '').charAt(0).toUpperCase()} +
+
+
{name}
+
+ {shortAddr(address, 6)} +
+
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+ {isSelf + ? 'Notes to self. Messages here stay on this device only.' + : 'No messages yet. Type below to send the first one.'} +
+ ) : ( +
+ {messages.map(m => )} +
+ )} +
+ + {/* Composer */} +
+