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.
This commit is contained in:
213
desktop/src/sections/messages/Conversation.tsx
Normal file
213
desktop/src/sections/messages/Conversation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user