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.
313 lines
8.2 KiB
TypeScript
313 lines
8.2 KiB
TypeScript
/**
|
||
* 长按菜单组件 - 气泡旁边弹出形式(类似手机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;
|