Files
dchain/desktop/src/sections/messages/Conversation.tsx
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

214 lines
7.2 KiB
TypeScript

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