2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 长按菜单组件 - 气泡旁边弹出形式(类似手机QQ)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useEffect } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
View,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
TouchableOpacity,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
Pressable,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
Animated,
|
|
|
|
|
|
Alert,
|
|
|
|
|
|
Clipboard,
|
|
|
|
|
|
Dimensions,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
Platform,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} from 'react-native';
|
|
|
|
|
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
|
|
|
|
import { chatScreenStyles as styles } from './styles';
|
|
|
|
|
|
import { LongPressMenuProps } from './types';
|
|
|
|
|
|
import { RECALL_TIME_LIMIT } from './constants';
|
|
|
|
|
|
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
|
|
|
|
|
|
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
|
|
|
|
|
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const blurActiveElementOnWeb = () => {
|
|
|
|
|
|
if (Platform.OS !== 'web') return;
|
|
|
|
|
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
|
|
|
|
|
active?.blur?.();
|
|
|
|
|
|
};
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 显示动画 - 缩放弹出
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (visible) {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
blurActiveElementOnWeb();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
Animated.parallel([
|
|
|
|
|
|
Animated.spring(scaleAnimation, {
|
|
|
|
|
|
toValue: 1,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
friction: 8,
|
|
|
|
|
|
tension: 100,
|
|
|
|
|
|
}),
|
|
|
|
|
|
Animated.timing(opacityAnimation, {
|
|
|
|
|
|
toValue: 1,
|
|
|
|
|
|
duration: 150,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}),
|
|
|
|
|
|
]).start();
|
|
|
|
|
|
} else {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
blurActiveElementOnWeb();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
Animated.parallel([
|
|
|
|
|
|
Animated.timing(scaleAnimation, {
|
|
|
|
|
|
toValue: 0,
|
|
|
|
|
|
duration: 150,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}),
|
|
|
|
|
|
Animated.timing(opacityAnimation, {
|
|
|
|
|
|
toValue: 0,
|
|
|
|
|
|
duration: 150,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useNativeDriver: Platform.OS !== 'web',
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}),
|
|
|
|
|
|
]).start();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [visible, scaleAnimation, opacityAnimation]);
|
|
|
|
|
|
|
|
|
|
|
|
if (!message) return null;
|
|
|
|
|
|
|
|
|
|
|
|
const isMyMessage = message.sender_id === currentUserId;
|
|
|
|
|
|
const messageTime = new Date(message.created_at).getTime();
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const canRecall = isMyMessage &&
|
|
|
|
|
|
message.status !== 'recalled' &&
|
|
|
|
|
|
(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);
|
|
|
|
|
|
if (text) {
|
|
|
|
|
|
Clipboard.setString(text);
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 多选功能(这里简化为复制全部)
|
|
|
|
|
|
const handleMultiSelect = () => {
|
|
|
|
|
|
const text = extractTextFromSegments(message.segments);
|
|
|
|
|
|
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"
|
2026-03-23 23:06:19 +08:00
|
|
|
|
onShow={blurActiveElementOnWeb}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onRequestClose={onClose}
|
|
|
|
|
|
>
|
2026-03-23 23:06:19 +08:00
|
|
|
|
<Pressable
|
|
|
|
|
|
onPress={() => {
|
|
|
|
|
|
blurActiveElementOnWeb();
|
|
|
|
|
|
onClose();
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={styles.qqMenuOverlay}
|
|
|
|
|
|
>
|
|
|
|
|
|
<View>
|
|
|
|
|
|
<Pressable onPress={() => {}}>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<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>
|
2026-03-23 23:06:19 +08:00
|
|
|
|
</Pressable>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</View>
|
2026-03-23 23:06:19 +08:00
|
|
|
|
</Pressable>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</Modal>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default LongPressMenu;
|