/** * MessageBubble — рендер одного сообщения с gesture interactions. * * Гестуры — разведены по двум примитивам во избежание конфликта со * скроллом FlatList'а: * * 1. Swipe-left (reply): PanResponder на Animated.View обёртке * bubble'а. `onMoveShouldSetPanResponder` клеймит responder ТОЛЬКО * когда пользователь сдвинул палец > 6px влево и горизонталь * преобладает над вертикалью. Для вертикального скролла * `onMoveShouldSet` возвращает false — FlatList получает gesture. * Touchdown ничего не клеймит (onStartShouldSetPanResponder * отсутствует). * * 2. Long-press / tap: через View.onTouchStart/End. Primitive touch * events bubble'ятся независимо от responder'а. Long-press запускаем * timer'ом на 550ms, cancel при `onTouchMove` с достаточной * амплитудой. Tap — короткое касание без move в selection mode. * * 3. `selectionMode=true` — PanResponder disabled (в selection режиме * свайпы не работают). * * 4. ReplyQuote — отдельный Pressable над bubble-текстом; tap прыгает * к оригиналу через onJumpToReply. * * 5. highlight prop — bubble-row мерцает accent-blue фоном, использует * Animated.Value; управляется из ChatScreen после scrollToIndex. */ import React, { useRef, useEffect } from 'react'; import { View, Text, Pressable, ViewStyle, Animated, PanResponder, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import type { Message } from '@/lib/types'; import { relTime } from '@/lib/dates'; import { Avatar } from '@/components/Avatar'; import { AttachmentPreview } from '@/components/chat/AttachmentPreview'; import { ReplyQuote } from '@/components/chat/ReplyQuote'; import { PostRefCard } from '@/components/chat/PostRefCard'; export const PEER_AVATAR_SLOT = 34; const SWIPE_THRESHOLD = 60; const LONG_PRESS_MS = 550; const TAP_MAX_MOVEMENT = 8; const TAP_MAX_ELAPSED = 300; export interface MessageBubbleProps { msg: Message; peerName: string; peerAddress?: string; withSenderMeta?: boolean; showName: boolean; showAvatar: boolean; onReply?: (m: Message) => void; onLongPress?: (m: Message) => void; onTap?: (m: Message) => void; onOpenProfile?: () => void; onJumpToReply?: (originalId: string) => void; selectionMode?: boolean; selected?: boolean; /** Mgnt-управляемый highlight: row мерцает accent-фоном ~1-2 секунды. */ highlighted?: boolean; } // ─── Bubble styles ────────────────────────────────────────────────── const bubbleBase: ViewStyle = { borderRadius: 18, paddingHorizontal: 14, paddingTop: 8, paddingBottom: 6, }; const peerBubble: ViewStyle = { ...bubbleBase, backgroundColor: '#1a1a1a', borderBottomLeftRadius: 6, }; const ownBubble: ViewStyle = { ...bubbleBase, backgroundColor: '#1d9bf0', borderBottomRightRadius: 6, }; const bubbleText = { color: '#ffffff', fontSize: 15, lineHeight: 20 } as const; // ─── Main ─────────────────────────────────────────────────────────── export function MessageBubble(props: MessageBubbleProps) { if (props.msg.mine) return ; if (!props.withSenderMeta) return ; return ; } type Variant = 'own' | 'peer-compact' | 'group-peer'; function RowShell({ msg, peerName, peerAddress, showName, showAvatar, onReply, onLongPress, onTap, onOpenProfile, onJumpToReply, selectionMode, selected, highlighted, variant, }: MessageBubbleProps & { variant: Variant }) { const translateX = useRef(new Animated.Value(0)).current; const startTs = useRef(0); const moved = useRef(false); const lpTimer = useRef | null>(null); const clearLp = () => { if (lpTimer.current) { clearTimeout(lpTimer.current); lpTimer.current = null; } }; // Touch start — запускаем long-press timer (НЕ клеймим responder). const onTouchStart = () => { startTs.current = Date.now(); moved.current = false; clearLp(); if (onLongPress) { lpTimer.current = setTimeout(() => { if (!moved.current) onLongPress(msg); lpTimer.current = null; }, LONG_PRESS_MS); } }; const onTouchMove = (e: { nativeEvent: { pageX: number; pageY: number } }) => { // Если пользователь двигает палец — отменяем long-press timer. // Малые движения (< TAP_MAX_MOVEMENT) игнорируем — устраняют // fale-cancel от дрожания пальца. // Здесь нет точного dx/dy от gesture-системы, используем primitive // touch coords отсчитываемые по абсолютным координатам. Проще — // всегда отменяем на first move (PanResponder ниже отнимет // responder если leftward). moved.current = true; clearLp(); }; const onTouchEnd = () => { const elapsed = Date.now() - startTs.current; clearLp(); // Короткий tap без движения → в selection mode toggle. if (!moved.current && elapsed < TAP_MAX_ELAPSED && selectionMode) { onTap?.(msg); } }; // Swipe-to-reply: PanResponder клеймит ТОЛЬКО leftward-dominant move. // Для vertical scroll / rightward swipe / start-touch возвращает false, // FlatList / AnimatedSlot получают gesture. const panResponder = useRef( PanResponder.create({ onMoveShouldSetPanResponder: (_e, g) => { if (selectionMode) return false; // Leftward > 6px и горизонталь преобладает. return g.dx < -6 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5; }, onPanResponderGrant: () => { // Как только мы заклеймили gesture, отменяем long-press // (пользователь явно свайпает, не удерживает). clearLp(); moved.current = true; }, onPanResponderMove: (_e, g) => { translateX.setValue(Math.min(0, g.dx)); }, onPanResponderRelease: (_e, g) => { if (g.dx <= -SWIPE_THRESHOLD) onReply?.(msg); Animated.spring(translateX, { toValue: 0, friction: 6, tension: 80, useNativeDriver: true, }).start(); }, onPanResponderTerminate: () => { Animated.spring(translateX, { toValue: 0, friction: 6, tension: 80, useNativeDriver: true, }).start(); }, }), ).current; // Highlight fade: при переключении highlighted=true крутим короткую // анимацию "flash + fade out" через Animated.Value (0→1→0 за ~1.8s). const highlightAnim = useRef(new Animated.Value(0)).current; useEffect(() => { if (!highlighted) return; highlightAnim.setValue(0); Animated.sequence([ Animated.timing(highlightAnim, { toValue: 1, duration: 150, useNativeDriver: false }), Animated.delay(1400), Animated.timing(highlightAnim, { toValue: 0, duration: 450, useNativeDriver: false }), ]).start(); }, [highlighted, highlightAnim]); const highlightBg = highlightAnim.interpolate({ inputRange: [0, 1], outputRange: ['rgba(29,155,240,0)', 'rgba(29,155,240,0.22)'], }); const isMine = variant === 'own'; const hasAttachment = !!msg.attachment; const hasPostRef = !!msg.postRef; const hasReply = !!msg.replyTo; const attachmentOnly = hasAttachment && !msg.text.trim(); const bubbleStyle = attachmentOnly ? { ...(isMine ? ownBubble : peerBubble), padding: 4 } : (isMine ? ownBubble : peerBubble); const bubbleNode = ( {msg.replyTo && ( onJumpToReply?.(msg.replyTo!.id)} /> )} {msg.attachment && ( )} {msg.postRef && ( )} {msg.text.trim() ? ( {msg.text} ) : null} ); const contentRow = variant === 'own' ? ( {bubbleNode} ) : variant === 'peer-compact' ? ( {bubbleNode} ) : ( {showName && ( {peerName} )} {showAvatar ? ( ) : null} {bubbleNode} ); return ( { clearLp(); moved.current = true; }} style={{ paddingHorizontal: 8, marginBottom: 6, // Selection & highlight накладываются: highlight flash побеждает // когда анимация > 0, иначе статичный selection-tint. backgroundColor: selected ? 'rgba(29,155,240,0.12)' : highlightBg, position: 'relative', }} > {contentRow} {selectionMode && ( onTap?.(msg)} /> )} ); } // ─── Clickable check-dot ──────────────────────────────────────────── function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) { return ( {selected && } ); } // ─── Footer ───────────────────────────────────────────────────────── interface FooterProps { edited: boolean; time: string; own?: boolean; read?: boolean; } function BubbleFooter({ edited, time, own, read }: FooterProps) { const textColor = own ? 'rgba(255,255,255,0.78)' : '#8b8b8b'; const dotColor = own ? 'rgba(255,255,255,0.55)' : '#5a5a5a'; return ( {edited && ( <> Edited · )} {time} {own && ( )} ); }