Files
frontend/src/screens/message/components/ChatScreen/LongPressMenu.tsx
lafay 6b91a7ead1
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 53s
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
fix(message): add robust NaN validation for date handling across components
Add comprehensive date validation using Number.isNaN() checks to prevent crashes when timestamps are invalid or empty. Apply defensive formatting across CommentItem, SystemMessageItem, MessageBubble, LongPressMenu, and store utilities.

Refine message bubble styling: move sender name display to both sides in group chat with distinct `mySenderName` styling, adjust bubble corner radius to top-right for consistent arrow placement, and align message rows to flex-start for improved layout. Simplify group avatar rendering in ConversationListRow.
2026-04-25 15:25:42 +08:00

313 lines
8.2 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.
/**
* 长按菜单组件 - 气泡旁边弹出形式类似手机QQ
*/
import React, { useRef, useEffect } from 'react';
import {
View,
Modal,
TouchableOpacity,
Pressable,
Animated,
Alert,
Clipboard,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useChatScreenStyles } from './styles';
import { LongPressMenuProps } from './types';
import { RECALL_TIME_LIMIT } from './constants';
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
import { isStickerExists, addStickerFromUrl } from '@/services/message';
import { blurActiveElement } from '../../../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
export const LongPressMenu: React.FC<LongPressMenuProps> = ({
visible,
message,
currentUserId,
position,
onClose,
onReply,
onRecall,
onDelete,
onAddSticker,
onReport,
memberMap,
}) => {
const styles = useChatScreenStyles();
const scaleAnimation = useRef(new Animated.Value(0)).current;
const opacityAnimation = useRef(new Animated.Value(0)).current;
// 显示动画 - 缩放弹出
useEffect(() => {
if (visible) {
blurActiveElement();
Animated.parallel([
Animated.spring(scaleAnimation, {
toValue: 1,
useNativeDriver: Platform.OS !== 'web',
friction: 8,
tension: 100,
}),
Animated.timing(opacityAnimation, {
toValue: 1,
duration: 150,
useNativeDriver: Platform.OS !== 'web',
}),
]).start();
} else {
blurActiveElement();
Animated.parallel([
Animated.timing(scaleAnimation, {
toValue: 0,
duration: 150,
useNativeDriver: Platform.OS !== 'web',
}),
Animated.timing(opacityAnimation, {
toValue: 0,
duration: 150,
useNativeDriver: Platform.OS !== 'web',
}),
]).start();
}
}, [visible, scaleAnimation, opacityAnimation]);
if (!message) return null;
const isMyMessage = message.sender_id === currentUserId;
const messageDate = new Date(message.created_at);
const messageTime = Number.isNaN(messageDate.getTime()) ? 0 : messageDate.getTime();
const now = Date.now();
const canRecall = isMyMessage &&
message.status !== 'recalled' &&
messageTime > 0 &&
(now - messageTime) < RECALL_TIME_LIMIT;
// 检查是否是图片消息
const segments = Array.isArray(message.segments) ? message.segments : [];
const imageSegment = segments.find(s => s.type === 'image');
const isImageMessage = !!imageSegment;
const imageUrl = imageSegment ? (imageSegment.data as ImageSegmentData)?.url : null;
// 复制文本到剪贴板
const handleCopy = () => {
const text = extractTextFromSegments(message.segments, memberMap);
if (text) {
Clipboard.setString(text);
onClose();
}
};
// 多选功能(这里简化为复制全部)
const handleMultiSelect = () => {
const text = extractTextFromSegments(message.segments, memberMap);
if (text) {
Clipboard.setString(text);
onClose();
}
};
const handleDelete = () => {
Alert.alert(
'确认删除',
'删除后,该消息将从你的设备上移除。确定要删除吗?',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => {
onDelete(message.id);
onClose();
},
},
]
);
};
// 添加到表情
const handleAddSticker = async () => {
if (!imageUrl) return;
onClose();
// 检查是否已存在
const exists = await isStickerExists(imageUrl);
if (exists) {
Alert.alert('提示', '该表情已存在');
return;
}
try {
const sticker = await addStickerFromUrl(imageUrl);
if (sticker) {
Alert.alert('成功', '已添加到表情');
onAddSticker?.(imageUrl);
} else {
Alert.alert('提示', '该表情已存在');
}
} catch (error) {
Alert.alert('失败', '添加表情失败,请重试');
}
};
// 菜单项配置 - 类似QQ的横向排列
const menuItems = [
{
id: 'reply',
label: '回复',
icon: 'reply',
onPress: () => {
onReply(message);
onClose();
},
show: true,
},
{
id: 'copy',
label: '复制',
icon: 'content-copy',
onPress: handleCopy,
show: !message.is_system_notice && message.status !== 'recalled',
},
{
id: 'multiSelect',
label: '多选',
icon: 'checkbox-multiple-marked-outline',
onPress: handleMultiSelect,
show: true,
},
{
id: 'addSticker',
label: '添加表情',
icon: 'emoticon-happy-outline',
onPress: handleAddSticker,
show: isImageMessage && message.status !== 'recalled',
},
{
id: 'recall',
label: '撤回',
icon: 'undo',
onPress: () => {
onRecall(message.id);
onClose();
},
show: canRecall,
},
{
id: 'delete',
label: '删除',
icon: 'delete-outline',
onPress: handleDelete,
show: true,
},
].filter(item => item.show);
// 计算菜单位置 - 紧贴手指位置显示在气泡上方
const calculatePosition = () => {
if (!position) {
// 默认居中显示
return {
top: SCREEN_HEIGHT / 2 - 50,
left: SCREEN_WIDTH / 2 - 120,
};
}
const menuWidth = Math.max(menuItems.length * 56, 180);
const menuHeight = 54;
// 使用按压点位置(手指位置)
const pressX = position.pressX ?? position.x + position.width / 2;
const pressY = position.pressY ?? position.y;
// 在手指位置上方显示菜单,留出 8px 间距
let top = pressY - menuHeight - 8;
// 水平居中于手指位置
let left = pressX - menuWidth / 2;
// 如果上方空间不足,显示在手指位置下方
if (top < 50) {
top = pressY + 8;
}
// 确保不超出屏幕左右边界
const minLeft = 8;
const maxLeft = SCREEN_WIDTH - menuWidth - 8;
if (left < minLeft) left = minLeft;
if (left > maxLeft) left = maxLeft;
return { top, left };
};
const { top, left } = calculatePosition();
return (
<Modal
visible={visible}
transparent
animationType="none"
onShow={blurActiveElement}
onRequestClose={onClose}
>
<Pressable
onPress={() => {
blurActiveElement();
onClose();
}}
style={styles.qqMenuOverlay}
>
<View>
<Pressable onPress={() => {}}>
<Animated.View
style={[
styles.qqMenuContainer,
{
top,
left,
opacity: opacityAnimation,
transform: [
{ scale: scaleAnimation },
],
},
]}
>
{/* 操作按钮横向排列 */}
<View style={styles.qqMenuItemsRow}>
{menuItems.map((item, index) => (
<TouchableOpacity
key={item.id}
style={[
styles.qqMenuItem,
index < menuItems.length - 1 && styles.qqMenuItemWithBorder,
]}
onPress={item.onPress}
activeOpacity={0.6}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color="#FFFFFF"
/>
<View style={styles.qqMenuItemLabelContainer}>
<Animated.Text style={styles.qqMenuItemLabel}>
{item.label}
</Animated.Text>
</View>
</TouchableOpacity>
))}
</View>
</Animated.View>
</Pressable>
</View>
</Pressable>
</Modal>
);
};
export default LongPressMenu;