feat(client): Twitter-style social feed UI (Phase C of v2.0.0)
Ships the client side of the v2.0.0 feed feature. Folds client-app/
into the monorepo (was previously .gitignored as "tracked separately"
but no separate repo ever existed — for v2.0.0 the client is
first-class).
Feed screens
app/(app)/feed.tsx — Feed tab
- Three-way tab strip: Подписки / Для вас / В тренде backed by
/feed/timeline, /feed/foryou, /feed/trending respectively
- Default landing tab is "Для вас" — surfaces discovery without
requiring the user to follow anyone first
- FlatList with pull-to-refresh + viewability-driven view counter
bump (posts visible ≥ 60% for ≥ 1s trigger POST /feed/post/…/view)
- Floating blue compose button → /compose
- Per-post liked_by_me fetched in batches of 6 after list load
app/(app)/compose.tsx — post composer modal
- Fullscreen, Twitter-like header (✕ left, Опубликовать right)
- Auto-focused multiline TextInput, 4000 char cap
- Hashtag preview chips that auto-update as you type
- expo-image-picker + expo-image-manipulator pipeline: resize to
1080px max-dim, JPEG Q=50 (client-side first-pass compression
before the mandatory server-side scrub)
- Live fee estimate + balance guard with a confirmation modal
("Опубликовать пост? Цена: 0.00X T · Размер: N KB")
- Exif: false passed to ImagePicker as an extra privacy layer
app/(app)/feed/[id].tsx — post detail
- Full PostCard rendering + detailed info panel (views, likes,
size, fee, hosting relay, hashtags as tappable chips)
- Triggers bumpView on mount
- 410 (on-chain soft-delete) routes back to the feed
app/(app)/feed/tag/[tag].tsx — hashtag feed
app/(app)/profile/[address].tsx — rebuilt
- Twitter-ish profile: avatar, name, address short-form, post count
- Posts | Инфо tab strip
- Follow / Unfollow button for non-self profiles (optimistic UI)
- Edit button on self profile → settings
- Secondary actions (chat, copy address) when viewing a known contact
Supporting library
lib/feed.ts — HTTP wrappers + tx builders for every /feed/* endpoint:
- publishPost (POST /feed/publish, signed)
- publishAndCommit (publish → on-chain CREATE_POST)
- fetchPost / fetchStats / bumpView
- fetchAuthorPosts / fetchTimeline / fetchForYou / fetchTrending /
fetchHashtag
- buildCreatePostTx / buildDeletePostTx
- buildFollowTx / buildUnfollowTx
- buildLikePostTx / buildUnlikePostTx
- likePost / unlikePost / followUser / unfollowUser / deletePost
(high-level helpers that bundle build + submitTx)
- formatFee, formatRelativeTime, formatCount — Twitter-like display
helpers
components/feed/PostCard.tsx — core card component
- Memoised for performance (N-row re-render on every like elsewhere
would cost a lot otherwise)
- Optimistic like toggle with heart-bounce spring animation
- Hashtag highlighting in body text (tappable → hashtag feed)
- Long-press context menu (Delete, owner-only)
- Views / likes / share-link / reply icons in footer row
Navigation cleanup
- NavBar: removed the SOON pill on the Feed tab (it's shipped now)
- (app)/_layout: hide NavBar on /compose and /feed/* sub-routes
- AnimatedSlot: treat /feed/<id>, /feed/tag/<t>, /compose as
sub-routes so back-swipe-right closes them
Channel removal (client side)
- lib/types.ts: ContactKind stripped to 'direct' | 'group'; legacy
'channel' flag removed. `kind` field kept for backward compat with
existing AsyncStorage records.
- lib/devSeed.ts: dropped the 5 channel seed contacts.
- components/ChatTile.tsx: removed channel kindIcon branch.
Dependencies
- expo-image-manipulator added for client-side image compression.
- expo-file-system/legacy used for readAsStringAsync (SDK 54 moved
that API to the legacy sub-path; the new streaming API isn't yet
stable).
Type check
- npx tsc --noEmit — clean, 0 errors.
Next (not in this commit)
- Direct attachment-bytes endpoint on the server so post-detail can
actually render the image (currently shows placeholder with URL)
- Cross-relay body fetch via /api/relays + hosting_relay pubkey
- Mentions (@username) with notifications
- Full-text search
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
512
client-app/app/(app)/chats/[id].tsx
Normal file
512
client-app/app/(app)/chats/[id].tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* 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 (например, из devSeed или
|
||||
// только что полученные по 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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user