Two dev-only seed modules removed now that the app talks to a real
backend:
- lib/devSeed.ts — fake 15+ contacts with mock chat histories,
mounted via useDevSeed() in (app)/_layout.tsx on empty store.
Was useful during client-first development; now it fights real
contact sync and confuses operators bringing up fresh nodes
("why do I see NBA scores and a dchain_updates channel in my
chat list?").
- lib/devSeedFeed.ts — 12 synthetic feed posts surfaced when the
real API returned empty. Same reasoning: operator imports genesis
key on a fresh node, opens Feed, sees 12 mock posts that aren't on
their chain. "Test data" that looks real is worse than an honest
empty state.
Feed screen now shows its proper empty state ("Пока нет
рекомендаций", etc.) when the API returns zero items OR on network
error. Chat screen starts empty until real contacts + messages
arrive via WS / storage cache.
Also cleaned a stale comment in chats/[id].tsx that referenced
devSeed.
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 } 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={() => router.back()} />}
|
||
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>
|
||
);
|
||
}
|