// 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 */}