Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal file
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 长按菜单组件 - 气泡旁边弹出形式(类似手机QQ)
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Animated,
|
||||
Alert,
|
||||
Clipboard,
|
||||
Dimensions,
|
||||
} 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;
|
||||
|
||||
// 显示动画 - 缩放弹出
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimation, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
}),
|
||||
Animated.timing(opacityAnimation, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(scaleAnimation, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnimation, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).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"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.qqMenuOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<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>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LongPressMenu;
|
||||
Reference in New Issue
Block a user