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.
214 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|