Files
dchain/client-app/app/(app)/chats/[id].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

574 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Chat detail screen — верстка по референсу (X-style Messages).
*
* Структура:
* [Header: back + avatar + name + typing-status | ⋯]
* [FlatList: MessageBubble + DaySeparator, group-aware]
* [Composer: floating, supports edit/reply banner]
*
* Весь presentational код вынесен в components/chat/*:
* - MessageBubble (own/peer rendering)
* - DaySeparator (day label между группами)
* - buildRows (чистая функция группировки)
* Date-форматирование — lib/dates.ts.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import {
View, Text, FlatList, KeyboardAvoidingView, Platform, Alert, Pressable,
} from 'react-native';
import { router, useLocalSearchParams } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Clipboard from 'expo-clipboard';
import { useStore } from '@/lib/store';
import { useMessages } from '@/hooks/useMessages';
import { encryptMessage } from '@/lib/crypto';
import { sendEnvelope, resolveRecipientKeys } from '@/lib/api';
import { getWSClient } from '@/lib/ws';
import { appendMessage, loadMessages } from '@/lib/storage';
import { randomId, safeBack } from '@/lib/utils';
import type { Message } from '@/lib/types';
import { Avatar } from '@/components/Avatar';
import { Header } from '@/components/Header';
import { IconButton } from '@/components/IconButton';
import { Composer, ComposerMode } from '@/components/Composer';
import { AttachmentMenu } from '@/components/chat/AttachmentMenu';
import { VideoCircleRecorder } from '@/components/chat/VideoCircleRecorder';
import { clearContactNotifications } from '@/hooks/useNotifications';
import { MessageBubble } from '@/components/chat/MessageBubble';
import { DaySeparator } from '@/components/chat/DaySeparator';
import { buildRows, Row } from '@/components/chat/rows';
import type { Attachment } from '@/lib/types';
function shortAddr(a: string, n = 6) {
if (!a) return '—';
return a.length <= n * 2 + 1 ? a : `${a.slice(0, n)}${a.slice(-n)}`;
}
export default function ChatScreen() {
const { id: contactAddress } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const keyFile = useStore(s => s.keyFile);
const contacts = useStore(s => s.contacts);
const messages = useStore(s => s.messages);
const setMsgs = useStore(s => s.setMessages);
const appendMsg = useStore(s => s.appendMessage);
const clearUnread = useStore(s => s.clearUnread);
// При открытии чата: сбрасываем unread-счётчик и dismiss'им банер.
useEffect(() => {
if (!contactAddress) return;
clearUnread(contactAddress);
clearContactNotifications(contactAddress);
}, [contactAddress, clearUnread]);
const upsertContact = useStore(s => s.upsertContact);
const isSavedMessages = !!keyFile && contactAddress === keyFile.pub_key;
// Auto-materialise the Saved Messages contact the first time the user
// opens chat-with-self. The contact is stored locally only — no on-chain
// CONTACT_REQUEST needed, since both ends are the same key pair.
useEffect(() => {
if (!isSavedMessages || !keyFile) return;
const existing = contacts.find(c => c.address === keyFile.pub_key);
if (existing) return;
upsertContact({
address: keyFile.pub_key,
x25519Pub: keyFile.x25519_pub,
alias: 'Saved Messages',
addedAt: Date.now(),
});
}, [isSavedMessages, keyFile, contacts, upsertContact]);
const contact = contacts.find(c => c.address === contactAddress);
const chatMsgs = messages[contactAddress ?? ''] ?? [];
const listRef = useRef<FlatList>(null);
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
const [peerTyping, setPeerTyping] = useState(false);
const [composeMode, setComposeMode] = useState<ComposerMode>({ kind: 'new' });
const [pendingAttach, setPendingAttach] = useState<Attachment | null>(null);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [videoCircleOpen, setVideoCircleOpen] = useState(false);
/**
* ID сообщения, которое сейчас подсвечено (после jump-to-reply). На
* ~2 секунды backgroundColor bubble'а мерцает accent-цветом.
* `null` — ничего не подсвечено.
*/
const [highlightedId, setHighlightedId] = useState<string | null>(null);
const highlightClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── Selection mode ───────────────────────────────────────────────────
// Активируется первым long-press'ом на bubble'е. Header меняется на
// toolbar с Forward/Delete/Cancel. Tap по bubble'у в selection mode
// toggle'ит принадлежность к выборке. Cancel сбрасывает всё.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
useMessages(contact?.x25519Pub ?? '', contact?.address);
// ── Typing indicator от peer'а ─────────────────────────────────────────
useEffect(() => {
if (!keyFile?.x25519_pub) return;
const ws = getWSClient();
let timer: ReturnType<typeof setTimeout> | null = null;
const off = ws.subscribe('typing:' + keyFile.x25519_pub, (frame) => {
if (frame.event !== 'typing') return;
const d = frame.data as { from?: string } | undefined;
if (!contact?.x25519Pub || d?.from !== contact.x25519Pub) return;
setPeerTyping(true);
if (timer) clearTimeout(timer);
timer = setTimeout(() => setPeerTyping(false), 3_000);
});
return () => { off(); if (timer) clearTimeout(timer); };
}, [keyFile?.x25519_pub, contact?.x25519Pub]);
// Throttled типinginisi-ping собеседнику.
const lastTypingSent = useRef(0);
const onChange = useCallback((t: string) => {
setText(t);
if (!contact?.x25519Pub || !t.trim()) return;
const now = Date.now();
if (now - lastTypingSent.current < 2_000) return;
lastTypingSent.current = now;
getWSClient().sendTyping(contact.x25519Pub);
}, [contact?.x25519Pub]);
// Восстановить сообщения из persistent-storage при первом заходе в чат.
//
// Важно: НЕ перезаписываем store пустым массивом — это стёрло бы
// содержимое, которое уже лежит в zustand (только что полученные по
// WS сообщения пока монтировались). Если в кэше что-то есть — мержим:
// берём max(cached, in-store) по id.
useEffect(() => {
if (!contactAddress) return;
loadMessages(contactAddress).then(cached => {
if (!cached || cached.length === 0) return; // кэш пуст → оставляем store
const existing = useStore.getState().messages[contactAddress] ?? [];
const byId = new Map<string, Message>();
for (const m of cached as Message[]) byId.set(m.id, m);
for (const m of existing) byId.set(m.id, m); // store-версия свежее
const merged = Array.from(byId.values()).sort((a, b) => a.timestamp - b.timestamp);
setMsgs(contactAddress, merged);
});
}, [contactAddress, setMsgs]);
const name = isSavedMessages
? 'Saved Messages'
: contact?.username
? `@${contact.username}`
: contact?.alias ?? shortAddr(contactAddress ?? '');
// ── Compose actions ────────────────────────────────────────────────────
const cancelCompose = useCallback(() => {
setComposeMode({ kind: 'new' });
setText('');
setPendingAttach(null);
}, []);
// buildRows выдаёт chronological [old → new]. FlatList работает
// inverted, поэтому reverse'им: newest = data[0] = снизу экрана.
// Определено тут (не позже) чтобы handlers типа onJumpToReply могли
// искать индексы по id без forward-declaration.
const rows = useMemo(() => {
const chrono = buildRows(chatMsgs);
return [...chrono].reverse();
}, [chatMsgs]);
/**
* Core send logic. Принимает явные text + attachment чтобы избегать
* race'а со state updates при моментальной отправке голоса/видео.
* Если передано null/undefined — берём из текущего state.
*/
const sendCore = useCallback(async (
textArg: string | null = null,
attachArg: Attachment | null | undefined = undefined,
) => {
if (!keyFile || !contact) return;
const actualText = textArg !== null ? textArg : text;
const actualAttach = attachArg !== undefined ? attachArg : pendingAttach;
const hasText = !!actualText.trim();
const hasAttach = !!actualAttach;
if (!hasText && !hasAttach) return;
if (!isSavedMessages && !contact.x25519Pub) {
Alert.alert('No encryption key yet', 'The contact has not published their key. Try later.');
return;
}
if (composeMode.kind === 'edit') {
const target = chatMsgs.find(m => m.text === composeMode.text && m.mine);
if (!target) { cancelCompose(); return; }
const updated: Message = { ...target, text: actualText.trim(), edited: true };
setMsgs(contact.address, chatMsgs.map(m => m.id === target.id ? updated : m));
cancelCompose();
return;
}
setSending(true);
try {
// Saved Messages short-circuits the relay entirely — the message never
// leaves the device, so no encryption/fee/network round-trip is needed.
// Regular chats still go through the NaCl + relay pipeline below.
if (hasText && !isSavedMessages) {
// Multi-device fan-out (v2.2.0): resolve the recipient's active
// device X25519 pubs via /api/devices. Legacy identities (no
// devices registered) fall back to their published identity
// x25519 pub, preserving the pre-v2.2.0 single-device path.
// `contact.x25519Pub` stays the floor — if both network calls
// fail we still attempt delivery to the cached pub so a flaky
// connection doesn't block outgoing messages.
let recipientPubs = await resolveRecipientKeys(contact.address);
if (recipientPubs.length === 0 && contact.x25519Pub) {
recipientPubs = [contact.x25519Pub];
}
if (recipientPubs.length === 0) {
throw new Error('recipient has no encryption key published');
}
// One sealed envelope per recipient device. Parallel — slow
// relays don't block each other; any individual failure
// rejects the whole send (user retries).
await Promise.all(recipientPubs.map(async (rpub) => {
const { nonce, ciphertext } = encryptMessage(
actualText.trim(), keyFile.x25519_priv, rpub,
);
await sendEnvelope({
senderPub: keyFile.x25519_pub,
recipientPub: rpub,
senderEd25519Pub: keyFile.pub_key,
nonce, ciphertext,
});
}));
}
const msg: Message = {
id: randomId(),
from: keyFile.x25519_pub,
text: actualText.trim(),
timestamp: Math.floor(Date.now() / 1000),
mine: true,
read: false,
edited: false,
attachment: actualAttach ?? undefined,
replyTo: composeMode.kind === 'reply'
? { id: composeMode.msgId, text: composeMode.preview, author: composeMode.author }
: undefined,
};
appendMsg(contact.address, msg);
await appendMessage(contact.address, msg);
setText('');
setPendingAttach(null);
setComposeMode({ kind: 'new' });
} catch (e: any) {
Alert.alert('Send failed', e?.message ?? 'Unknown error');
} finally {
setSending(false);
}
}, [
text, keyFile, contact, composeMode, chatMsgs, isSavedMessages,
setMsgs, cancelCompose, appendMsg, pendingAttach,
]);
// UI send button
const send = useCallback(() => sendCore(), [sendCore]);
// ── Selection handlers ───────────────────────────────────────────────
// Long-press — входим в selection mode и сразу отмечаем это сообщение.
const onMessageLongPress = useCallback((m: Message) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.add(m.id);
return next;
});
}, []);
// Tap в selection mode — toggle принадлежности.
const onMessageTap = useCallback((m: Message) => {
if (!selectionMode) return;
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(m.id)) next.delete(m.id); else next.add(m.id);
return next;
});
}, [selectionMode]);
const cancelSelection = useCallback(() => setSelectedIds(new Set()), []);
// ── Swipe-to-reply ──────────────────────────────────────────────────
const onMessageReply = useCallback((m: Message) => {
if (selectionMode) return;
setComposeMode({
kind: 'reply',
msgId: m.id,
author: m.mine ? 'You' : name,
preview: m.text || (m.attachment ? `(${m.attachment.kind})` : ''),
});
}, [name, selectionMode]);
// ── Profile navigation (tap на аватарке / имени peer'а) ──────────────
const onOpenPeerProfile = useCallback(() => {
if (!contactAddress) return;
router.push(`/(app)/profile/${contactAddress}` as never);
}, [contactAddress]);
// ── Jump to reply: tap по quoted-блоку в bubble'е ────────────────────
// Скроллим FlatList к оригинальному сообщению и зажигаем highlight
// на ~2 секунды (highlightedId state + useEffect-driven анимация в
// MessageBubble.highlightAnim).
const onJumpToReply = useCallback((originalId: string) => {
const idx = rows.findIndex(r => r.kind === 'msg' && r.msg.id === originalId);
if (idx < 0) {
// Сообщение не найдено (возможно удалено или ушло за пагинацию).
// Silently no-op.
return;
}
try {
listRef.current?.scrollToIndex({
index: idx,
animated: true,
viewPosition: 0.3, // оригинал — чуть выше середины экрана, не прямо в центре
});
} catch {
// scrollToIndex может throw'нуть если индекс за пределами рендера;
// fallback: scrollToOffset на приблизительную позицию.
}
setHighlightedId(originalId);
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
highlightClearTimer.current = setTimeout(() => {
setHighlightedId(null);
highlightClearTimer.current = null;
}, 2000);
}, [rows]);
useEffect(() => {
return () => {
if (highlightClearTimer.current) clearTimeout(highlightClearTimer.current);
};
}, []);
// ── Selection actions ────────────────────────────────────────────────
const deleteSelected = useCallback(() => {
if (selectedIds.size === 0 || !contact) return;
Alert.alert(
`Delete ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}?`,
'This removes them from your device. Other participants keep their copies.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
setMsgs(contact.address, chatMsgs.filter(m => !selectedIds.has(m.id)));
setSelectedIds(new Set());
},
},
],
);
}, [selectedIds, contact, chatMsgs, setMsgs]);
const forwardSelected = useCallback(() => {
// Forward UI ещё не реализован — показываем stub. Пример потока:
// 1. открыть "Forward to…" screen со списком контактов
// 2. для каждого выбранного контакта — sendEnvelope с оригинальным
// текстом, timestamp=now
Alert.alert(
`Forward ${selectedIds.size} message${selectedIds.size > 1 ? 's' : ''}`,
'Contact-picker screen is coming in the next iteration. For now, copy the text and paste.',
[{ text: 'OK' }],
);
}, [selectedIds]);
// Copy доступен только когда выделено ровно одно сообщение.
const copySelected = useCallback(async () => {
if (selectedIds.size !== 1) return;
const id = [...selectedIds][0];
const msg = chatMsgs.find(m => m.id === id);
if (!msg) return;
await Clipboard.setStringAsync(msg.text);
setSelectedIds(new Set());
}, [selectedIds, chatMsgs]);
// В group-чатах над peer-сообщениями рисуется имя отправителя и его
// аватар (group = несколько участников). В DM (direct) и каналах
// отправитель ровно один, поэтому имя/аватар не нужны — убираем.
const withSenderMeta = contact?.kind === 'group';
const renderRow = ({ item }: { item: Row }) => {
if (item.kind === 'sep') return <DaySeparator label={item.label} />;
return (
<MessageBubble
msg={item.msg}
peerName={name}
peerAddress={contactAddress}
withSenderMeta={withSenderMeta}
showName={item.showName}
showAvatar={item.showAvatar}
onReply={onMessageReply}
onLongPress={onMessageLongPress}
onTap={onMessageTap}
onOpenProfile={onOpenPeerProfile}
onJumpToReply={onJumpToReply}
selectionMode={selectionMode}
selected={selectedIds.has(item.msg.id)}
highlighted={highlightedId === item.msg.id}
/>
);
};
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: '#000000' }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
// Увеличенный offset: composer поднимается выше клавиатуры с заметным
// зазором (20px на iOS, 10px на Android) — пользователь не видит
// прилипания к верхнему краю клавиатуры.
keyboardVerticalOffset={Platform.OS === 'ios' ? 20 : 10}
>
{/* Header — использует общий компонент <Header>, чтобы соблюдать
правила шапки приложения (left slot / centered title / right slot). */}
<View style={{ paddingTop: insets.top, backgroundColor: '#000000' }}>
{selectionMode ? (
<Header
divider
left={<IconButton icon="close" size={36} onPress={cancelSelection} />}
title={`${selectedIds.size} selected`}
right={
<>
{selectedIds.size === 1 && (
<IconButton icon="copy-outline" size={36} onPress={copySelected} />
)}
<IconButton icon="arrow-redo-outline" size={36} onPress={forwardSelected} />
<IconButton icon="trash-outline" size={36} onPress={deleteSelected} />
</>
}
/>
) : (
<Header
divider
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
title={
<Pressable
onPress={onOpenPeerProfile}
hitSlop={4}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, maxWidth: '100%' }}
>
<Avatar name={name} address={contactAddress} size={28} saved={isSavedMessages} />
<View style={{ minWidth: 0, flexShrink: 1 }}>
<Text
numberOfLines={1}
style={{
color: '#ffffff',
fontSize: 15,
fontWeight: '700',
letterSpacing: -0.2,
}}
>
{name}
</Text>
{peerTyping && (
<Text style={{ color: '#1d9bf0', fontSize: 11, fontWeight: '500' }}>
typing
</Text>
)}
{!peerTyping && !isSavedMessages && !contact?.x25519Pub && (
<Text style={{ color: '#f0b35a', fontSize: 11 }}>
waiting for key
</Text>
)}
</View>
</Pressable>
}
right={<IconButton icon="ellipsis-horizontal" size={36} />}
/>
)}
</View>
{/* Messages — inverted: data[0] рендерится снизу, последующее —
выше. Это стандартный chat-паттерн: FlatList сразу монтируется
с "scroll position at bottom" без ручного scrollToEnd, и новые
сообщения (добавляемые в начало reversed-массива) появляются
внизу естественно. Никаких jerk'ов при открытии. */}
{rows.length === 0 ? (
// Empty state is rendered as a plain View instead of
// ListEmptyComponent on an inverted FlatList — the previous
// `transform: [{ scaleY: -1 }]` un-flip trick was rendering
// text mirrored on some Android builds (RTL-aware layout),
// giving us the "say hi…" backwards bug.
<View style={{
flex: 1, alignItems: 'center', justifyContent: 'center',
paddingHorizontal: 32, gap: 10,
}}>
<Avatar
name={name}
address={contactAddress}
size={72}
saved={isSavedMessages}
/>
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
{isSavedMessages ? 'Notes to self' : `Say hi to ${name}`}
</Text>
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
{isSavedMessages
? 'Anything you send here stays on your device — use it as a scratchpad for links, drafts, or files.'
: 'Your messages are end-to-end encrypted.'}
</Text>
</View>
) : (
<FlatList
ref={listRef}
data={rows}
inverted
keyExtractor={r => r.kind === 'sep' ? r.id : r.msg.id}
renderItem={renderRow}
contentContainerStyle={{ paddingVertical: 10 }}
showsVerticalScrollIndicator={false}
// Lazy render: only mount ~1.5 screens of bubbles initially,
// render further batches as the user scrolls older. Keeps
// initial paint fast on chats with thousands of messages.
initialNumToRender={25}
maxToRenderPerBatch={12}
windowSize={10}
removeClippedSubviews
/>
)}
{/* Composer — floating, прибит к низу. */}
<View style={{ paddingBottom: Math.max(insets.bottom, 4) + 6, backgroundColor: '#000000' }}>
<Composer
mode={composeMode}
onCancelMode={cancelCompose}
text={text}
onChangeText={onChange}
onSend={send}
sending={sending}
onAttach={() => setAttachMenuOpen(true)}
attachment={pendingAttach}
onClearAttach={() => setPendingAttach(null)}
onFinishVoice={(att) => {
// Voice отправляется сразу — sendCore получает attachment
// явным аргументом, минуя state-задержку.
sendCore('', att);
}}
onStartVideoCircle={() => setVideoCircleOpen(true)}
/>
</View>
<AttachmentMenu
visible={attachMenuOpen}
onClose={() => setAttachMenuOpen(false)}
onPick={(att) => setPendingAttach(att)}
/>
<VideoCircleRecorder
visible={videoCircleOpen}
onClose={() => setVideoCircleOpen(false)}
onFinish={(att) => {
// Video-circle тоже отправляется сразу.
sendCore('', att);
}}
/>
</KeyboardAvoidingView>
);
}