Files
dchain/client-app/app/(app)/chats/[id].tsx
vsecoder 5b64ef2560 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>
2026-04-18 19:43:55 +03:00

513 lines
22 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 } 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>
);
}