Feed list padding
FlatList had no inner padding so the first post bumped against the
tab strip and the last post against the NavBar. Added paddingTop: 8
/ paddingBottom: 24 on contentContainerStyle in both /feed and
/feed/tag/[tag] — first card now has a clear top gap, last card
doesn't get hidden behind the FAB or NavBar.
Share-to-chat flow
Replaces the placeholder share button (which showed an Alert with
the post URL) with a real "forward to chats" flow modeled on VK's
shared-wall-post embed.
New modules
lib/forwardPost.ts — encodePostRef / tryParsePostRef +
forwardPostToContacts(). Serialises a
feed post into a tiny JSON payload that
rides the same encrypted envelope as any
chat message; decode side distinguishes
"post_ref" payloads from regular text by
trying JSON.parse on decrypted text.
Mirrors the sent message into the sender's
local history so they see "you shared
this" in the chat they forwarded to.
components/feed/ShareSheet.tsx
— bottom-sheet picker. Multi-select
contacts via tick-box, search by
username / alias / address prefix.
"Send (N)" dispatches N parallel
encrypted envelopes. Contacts with no
X25519 key are filtered out (can't
encrypt for them).
components/chat/PostRefCard.tsx
— compact embedded-post card for chat
bubbles. Ribbon "ПОСТ" label +
author + 3-line excerpt + "с фото"
indicator. Tap → /(app)/feed/{id} full
post detail. Palette switches between
blue-bubble-friendly and peer-bubble-
friendly depending on bubble side.
Message pipeline
lib/types.ts — Message.postRef optional field added.
text stays "" when the message is a
post-ref (nothing to render as plain text).
hooks/useMessages.ts + hooks/useGlobalInbox.ts
— post decryption of every inbound envelope
runs through tryParsePostRef; matching
messages get the postRef attached instead
of the raw JSON in .text.
components/chat/MessageBubble.tsx
— renders PostRefCard inside the bubble when
msg.postRef is set. Other bubble features
(reply quote, attachment preview, text)
still work around it.
PostCard
- share icon now opens <ShareSheet>; the full-URL placeholder is
gone. ShareSheet is embedded at the PostCard level so each card
owns its own sheet state (avoids modal-stacking issues).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
/**
|
||
* 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 <RowShell {...props} variant="own" />;
|
||
if (!props.withSenderMeta) return <RowShell {...props} variant="peer-compact" />;
|
||
return <RowShell {...props} variant="group-peer" />;
|
||
}
|
||
|
||
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<ReturnType<typeof setTimeout> | 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 = (
|
||
<Animated.View
|
||
{...panResponder.panHandlers}
|
||
style={{
|
||
transform: [{ translateX }],
|
||
maxWidth: hasAttachment ? '80%' : '85%',
|
||
minWidth: hasAttachment || hasReply ? 220 : undefined,
|
||
}}
|
||
>
|
||
<View style={bubbleStyle}>
|
||
{msg.replyTo && (
|
||
<ReplyQuote
|
||
author={msg.replyTo.author}
|
||
preview={msg.replyTo.text}
|
||
own={isMine}
|
||
onJump={() => onJumpToReply?.(msg.replyTo!.id)}
|
||
/>
|
||
)}
|
||
{msg.attachment && (
|
||
<AttachmentPreview attachment={msg.attachment} own={isMine} />
|
||
)}
|
||
{msg.postRef && (
|
||
<PostRefCard
|
||
postID={msg.postRef.postID}
|
||
author={msg.postRef.author}
|
||
excerpt={msg.postRef.excerpt}
|
||
hasImage={!!msg.postRef.hasImage}
|
||
own={isMine}
|
||
/>
|
||
)}
|
||
{msg.text.trim() ? (
|
||
<Text style={bubbleText}>{msg.text}</Text>
|
||
) : null}
|
||
<BubbleFooter
|
||
edited={!!msg.edited}
|
||
time={relTime(msg.timestamp)}
|
||
own={isMine}
|
||
read={!!msg.read}
|
||
/>
|
||
</View>
|
||
</Animated.View>
|
||
);
|
||
|
||
const contentRow =
|
||
variant === 'own' ? (
|
||
<View style={{ flexDirection: 'row', justifyContent: 'flex-end' }}>
|
||
{bubbleNode}
|
||
</View>
|
||
) : variant === 'peer-compact' ? (
|
||
<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}>
|
||
{bubbleNode}
|
||
</View>
|
||
) : (
|
||
<View>
|
||
{showName && (
|
||
<Pressable
|
||
onPress={onOpenProfile}
|
||
hitSlop={4}
|
||
style={{ marginLeft: PEER_AVATAR_SLOT, marginBottom: 3 }}
|
||
>
|
||
<Text style={{ color: '#8b8b8b', fontSize: 12 }} numberOfLines={1}>
|
||
{peerName}
|
||
</Text>
|
||
</Pressable>
|
||
)}
|
||
<View style={{ flexDirection: 'row', alignItems: 'flex-end' }}>
|
||
<View style={{ width: PEER_AVATAR_SLOT, alignItems: 'flex-start' }}>
|
||
{showAvatar ? (
|
||
<Pressable onPress={onOpenProfile} hitSlop={4}>
|
||
<Avatar name={peerName} address={peerAddress} size={26} />
|
||
</Pressable>
|
||
) : null}
|
||
</View>
|
||
{bubbleNode}
|
||
</View>
|
||
</View>
|
||
);
|
||
|
||
return (
|
||
<Animated.View
|
||
onTouchStart={onTouchStart}
|
||
onTouchMove={onTouchMove}
|
||
onTouchEnd={onTouchEnd}
|
||
onTouchCancel={() => { 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 && (
|
||
<CheckDot
|
||
selected={!!selected}
|
||
onPress={() => onTap?.(msg)}
|
||
/>
|
||
)}
|
||
</Animated.View>
|
||
);
|
||
}
|
||
|
||
// ─── Clickable check-dot ────────────────────────────────────────────
|
||
|
||
function CheckDot({ selected, onPress }: { selected: boolean; onPress: () => void }) {
|
||
return (
|
||
<Pressable
|
||
onPress={onPress}
|
||
hitSlop={12}
|
||
style={{
|
||
position: 'absolute',
|
||
right: 4,
|
||
top: 0, bottom: 0,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<View
|
||
style={{
|
||
width: 20,
|
||
height: 20,
|
||
borderRadius: 10,
|
||
backgroundColor: selected ? '#1d9bf0' : 'rgba(0,0,0,0.55)',
|
||
borderWidth: 2,
|
||
borderColor: selected ? '#1d9bf0' : '#6b6b6b',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
{selected && <Ionicons name="checkmark" size={12} color="#ffffff" />}
|
||
</View>
|
||
</Pressable>
|
||
);
|
||
}
|
||
|
||
// ─── 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 (
|
||
<View
|
||
style={{
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'flex-end',
|
||
marginTop: 2,
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{edited && (
|
||
<>
|
||
<Text style={{ color: textColor, fontSize: 11 }}>Edited</Text>
|
||
<Text style={{ color: dotColor, fontSize: 11 }}>·</Text>
|
||
</>
|
||
)}
|
||
<Text style={{ color: textColor, fontSize: 11 }}>{time}</Text>
|
||
{own && (
|
||
<Ionicons
|
||
name={read ? 'checkmark-circle' : 'checkmark-circle-outline'}
|
||
size={13}
|
||
color={read ? '#ffffff' : 'rgba(255,255,255,0.78)'}
|
||
style={{ marginLeft: 2 }}
|
||
/>
|
||
)}
|
||
</View>
|
||
);
|
||
}
|