1. GO_BACK warning & stuck screens
When a deep link or direct push put the user on /feed/[id],
/profile/[address], /compose, or /settings without any prior stack
entry, tapping the header chevron emitted:
"ERROR The action 'GO_BACK' was not handled by any navigator"
and did nothing — user was stuck.
New helper lib/utils.safeBack(fallback = '/(app)/chats') wraps
router.canGoBack() — when there's history it pops; otherwise it
replace-navigates to a sensible fallback (chats list by default,
'/' for auth screens so we land back at the onboarding).
Applied to every header chevron and back-from-detail flow:
app/(app)/chats/[id], app/(app)/compose, app/(app)/feed/[id]
(header + onDeleted), app/(app)/feed/tag/[tag],
app/(app)/profile/[address], app/(app)/new-contact (header + OK
button on request-sent alert), app/(app)/settings,
app/(auth)/create, app/(auth)/import.
2. Prevent self-contact-request
new-contact.tsx now compares the resolved address against
keyFile.pub_key at two points:
- right after resolveUsername + getIdentity in search() — before
the profile card even renders, so the user doesn't see the
"Send request" CTA for themselves.
- inside sendRequest() as a belt-and-braces guard in case the
check was somehow bypassed.
The search path shows an inline error ("That's you. You can't
send a contact request to yourself."); sendRequest falls back to
an Alert with the same meaning. Both compare case-insensitively
against the pubkey hex so mixed-case pastes work.
Technically the server would still accept a self-request (the
chain stores it under contact_in:<self>:<self>), but it's a dead-
end UX-wise — the user can't chat with themselves — so the client
blocks it preemptively instead of letting users pay the fee for
nothing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
520 lines
22 KiB
TypeScript
520 lines
22 KiB
TypeScript
/**
|
||
* 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 } 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 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 ?? '');
|
||
|
||
// ── 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 = 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 (!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 {
|
||
if (hasText) {
|
||
const { nonce, ciphertext } = encryptMessage(
|
||
actualText.trim(), keyFile.x25519_priv, contact.x25519Pub,
|
||
);
|
||
await sendEnvelope({
|
||
senderPub: keyFile.x25519_pub,
|
||
recipientPub: contact.x25519Pub,
|
||
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,
|
||
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} />
|
||
<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 && !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'ов при открытии. */}
|
||
<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
|
||
ListEmptyComponent={() => (
|
||
<View style={{
|
||
flex: 1, alignItems: 'center', justifyContent: 'center',
|
||
paddingHorizontal: 32, gap: 10,
|
||
transform: [{ scaleY: -1 }], // inverted flips cells; un-flip empty state
|
||
}}>
|
||
<Avatar name={name} address={contactAddress} size={72} />
|
||
<Text style={{ color: '#ffffff', fontSize: 16, fontWeight: '700', marginTop: 10 }}>
|
||
Say hi to {name}
|
||
</Text>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 13, textAlign: 'center', lineHeight: 20 }}>
|
||
Your messages are end-to-end encrypted.
|
||
</Text>
|
||
</View>
|
||
)}
|
||
/>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|