Two bugs reported by the user:
1. After accepting a contact request on the desktop, the requester's
"Send message" call errored with "no encryption key published" for
the newly-accepted contact. Root cause: desktop never ran the
device-registry bootstrap (mobile does it from _layout.tsx on
sign-in) — so the desktop's X25519 pub was never published via
LINK_DEVICE, and resolveRecipientKeys returned an empty list.
2. On the accepting device, the new chat didn't appear in Messages
after tapping Accept — accept wrote the contact to store + disk
but didn't switch sections, so the user was stuck in Contacts
watching nothing happen.
Fixes:
* hooks/useDeviceBootstrap — direct port of mobile's _layout.tsx
bootstrap effect. On every sign-in:
- fetchDevices(master) → if our X25519 is listed, mark local
registered flag.
- not listed + was registered before → REVOKED → wipe state +
bounce to Welcome.
- not listed + never registered → submit LINK_DEVICE. Tx may
bounce if balance is zero; next launch retries.
Mounted from App.tsx so it runs once per authenticated session.
* RequestsList.accept — after submitting ACCEPT_CONTACT, check if
OUR X25519 is in the on-chain registry. If not, submit LINK_DEVICE
immediately (balance is now covered by the contact fee the peer
paid us). This closes the window where the peer couldn't encrypt
to us because our key wasn't published yet.
Also: after a successful accept, setSection('messages') +
setActiveChat(requester_pub), matching mobile's
router.replace('/chats/<pub>') flow.
* Conversation.send — nicer error copy when
resolveRecipientKeys returns []. Was: "recipient has no
encryption key published". Now: actionable text asking the peer
to re-open their app so the LINK_DEVICE tx commits.
237 lines
8.3 KiB
TypeScript
237 lines
8.3 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';
|
|
|
|
// A module-level stable reference for "no messages yet". Without this the
|
|
// selector `s.messages[address] ?? []` allocates a fresh empty array on
|
|
// every render when the conversation has no cached entries, which zustand
|
|
// sees as a changed value → triggers another render → new empty array
|
|
// again → "Maximum update depth exceeded". Returning the exact same
|
|
// reference every time breaks the cycle.
|
|
const EMPTY_MESSAGES: Message[] = [];
|
|
|
|
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] ?? EMPTY_MESSAGES);
|
|
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) {
|
|
// Most common cause: the peer's device hasn't published a
|
|
// LINK_DEVICE yet (they accepted just now and haven't had the
|
|
// fee debited, or they haven't re-opened the app). Clearer
|
|
// copy than "recipient has no encryption key".
|
|
throw new Error(
|
|
'Recipient has no device key published on-chain yet. ' +
|
|
'Ask them to re-open their app so the LINK_DEVICE tx commits, ' +
|
|
'then try again.',
|
|
);
|
|
}
|
|
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)) || shortAddr(address || '', 8);
|
|
const firstLetter = (name || '?').replace(/^@/, '').charAt(0).toUpperCase() || '?';
|
|
|
|
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 ? '★' : firstLetter}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{
|
|
color: '#fff', fontSize: 14, fontWeight: 700,
|
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
}}>{name}</div>
|
|
<div style={{
|
|
color: '#6a6a6a', fontSize: 11, fontFamily: 'monospace',
|
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
|
}}>
|
|
{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>
|
|
);
|
|
}
|