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:
2026-03-09 21:29:03 +08:00
commit 3968660048
129 changed files with 55599 additions and 0 deletions

View 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;