Files
frontend/src/screens/message/components/ChatScreen/MessageBubble.tsx

499 lines
15 KiB
TypeScript
Raw Normal View History

/**
*
* Segment数组
*
*/
import React, { useRef, useMemo, useCallback } from 'react';
import {
View,
TouchableOpacity,
Image,
GestureResponderEvent,
StyleSheet,
Dimensions,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
import { chatScreenStyles as baseStyles } from './styles';
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
import { MessageSegmentsRenderer } from './SegmentRenderer';
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
import {
getBubbleBorderRadius,
getMessageGroupPosition,
shouldShowSenderInfo,
bubbleColors,
bubbleStyles,
} from './bubbleStyles';
// 获取屏幕宽度
const { width: SCREEN_WIDTH } = Dimensions.get('window');
// 消息内容最大宽度比例
const MAX_WIDTH_RATIO = {
mobile: 0.75,
tablet: 0.65,
desktop: 0.55,
};
export const MessageBubble: React.FC<MessageBubbleProps> = ({
message,
index,
currentUserId,
currentUser,
otherUser,
isGroupChat,
groupMembers,
otherUserLastReadSeq,
selectedMessageId,
messageMap,
onLongPress,
onAvatarPress,
onAvatarLongPress,
formatTime,
shouldShowTime,
onImagePress,
onReply,
onReplyPress,
}) => {
const bubbleRef = useRef<View>(null);
const pressPositionRef = useRef({ x: 0, y: 0 });
// 响应式布局
const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg');
const isTablet = useBreakpointGTE('md');
// 计算消息内容最大宽度
const maxContentWidth = useMemo(() => {
const ratio = isWideScreen ? MAX_WIDTH_RATIO.desktop :
isTablet ? MAX_WIDTH_RATIO.tablet :
MAX_WIDTH_RATIO.mobile;
return Math.min(SCREEN_WIDTH * ratio, 600);
}, [isWideScreen, isTablet]);
// 合并样式
const styles = useMemo(() => {
return StyleSheet.create({
...baseStyles,
messageContent: {
...baseStyles.messageContent,
maxWidth: maxContentWidth,
},
messageImage: {
...baseStyles.messageImage,
width: isWideScreen ? 280 : 220,
height: isWideScreen ? 280 : 220,
},
});
}, [maxContentWidth, isWideScreen]);
// 系统通知消息特殊处理
// 支持两种判断方式:
// 1. is_system_notice 字段WebSocket 推送时设置)
// 2. category === 'notification'(从数据库加载时使用)
// 3. sender_id === '10000'(系统发送者兜底)
const isSystemNotice = message.is_system_notice || message.category === 'notification' || message.sender_id === '10000';
const isMe = message.sender_id === currentUserId;
const showTime = shouldShowTime(index);
const isRecalled = message.status === 'recalled';
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
// 检查是否有消息链(必须使用 segments 格式)
// 安全检查:确保 segments 是数组
const segments = Array.isArray(message.segments) ? message.segments : [];
const hasSegments = segments.length > 0;
// 检查是否是图片消息
const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image');
// 检查是否是纯图片消息只有图片segment没有文本等其他内容
const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type);
// 提取所有图片 segments
const imageSegments = segments
.filter(s => s.type === 'image')
.map((s, idx) => {
const data = s.data as ImageSegmentData;
return {
id: `img-${message.id}-${idx}`,
url: data.url,
thumbnail_url: data.thumbnail_url,
width: data.width,
height: data.height,
};
});
// 检查当前消息是否被选中
const isSelected = selectedMessageId === String(message.id);
// 获取发送者信息(群聊)- 供子组件使用
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
if (senderId === currentUserId) {
return {
nickname: currentUser?.nickname || '我',
avatar: currentUser?.avatar || null,
userId: currentUserId,
};
}
// 兜底:从群成员列表查找
const member = groupMembers.find(m => m.user_id === senderId);
if (member) {
return {
nickname: member.nickname || member.user?.nickname || '用户',
avatar: member.user?.avatar || null,
userId: member.user_id,
};
}
return {
nickname: '用户',
avatar: null,
userId: senderId,
};
}, [currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
// 使用useMemo确保senderInfo在message.sender变化时重新计算
const senderInfo = React.useMemo((): SenderInfo | null => {
if (!isGroupChat) return null;
const senderId = message.sender_id;
if (senderId === currentUserId) {
return {
nickname: currentUser?.nickname || '我',
avatar: currentUser?.avatar || null,
userId: currentUserId,
};
}
// 优先使用消息对象中携带的sender信息MessageManager 已填充)
if (message.sender && (message.sender.avatar || message.sender.nickname)) {
return {
nickname: message.sender.nickname || message.sender.username || '用户',
avatar: message.sender.avatar || null,
userId: senderId,
};
}
// 兜底:从群成员列表查找
const member = groupMembers.find(m => m.user_id === senderId);
if (member) {
return {
nickname: member.nickname || member.user?.nickname || '用户',
avatar: member.user?.avatar || null,
userId: member.user_id,
};
}
return {
nickname: '用户',
avatar: null,
userId: senderId,
};
}, [isGroupChat, message.sender_id, message.sender, currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
// 创建成员映射(用于@高亮)
const memberMap = useMemo(() => {
const map = new Map<string, { nickname: string; user_id: string }>();
groupMembers.forEach(m => {
map.set(m.user_id, {
nickname: m.nickname || m.user?.nickname || '用户',
user_id: m.user_id,
});
});
return map;
}, [groupMembers]);
// 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
pressPositionRef.current = { x: pageX, y: pageY };
};
// 处理长按并获取位置
const handleLongPress = () => {
if (bubbleRef.current) {
bubbleRef.current.measureInWindow((x, y, width, height) => {
const position: MenuPosition = {
x,
y,
width,
height,
pressX: pressPositionRef.current.x,
pressY: pressPositionRef.current.y,
};
onLongPress(message, position);
});
} else {
onLongPress(message);
}
};
// 渲染消息内容
const renderMessageContent = () => {
// 撤回消息
if (isRecalled) {
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
styles.recalledBubble,
]}>
<Text
style={[
isMe ? styles.myBubbleText : styles.theirBubbleText,
styles.recalledText,
]}
selectable={false}
>
</Text>
</View>
);
}
// 使用消息链渲染(必须使用 segments 格式)
// 从 segments 中获取被回复消息的 ID 和 seq然后从 messageMap 中查找
const getReplyMessage = (): GroupMessage | undefined => {
const replySegment = segments.find(s => s.type === 'reply');
if (replySegment && messageMap) {
const replyData = replySegment.data as { id: string; seq?: number };
const replyId = String(replyData.id);
// 首先尝试通过 ID 查找
let found = messageMap.get(replyId);
// 如果通过 ID 找不到,尝试通过 seq 查找
if (!found && replyData.seq !== undefined) {
found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq);
}
return found;
}
return undefined;
};
// 如果没有 segments显示空内容或错误提示
if (!hasSegments) {
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
]}>
<Text
style={[
isMe ? styles.myBubbleText : styles.theirBubbleText,
{ opacity: 0.5 },
]}
selectable={false}
>
[]
</Text>
</View>
);
}
// 检查是否有回复,用于调整气泡样式
const hasReply = segments.some(s => s.type === 'reply');
return (
<View style={[
styles.messageBubble,
isMe ? styles.myBubble : styles.theirBubble,
hasReply && segmentStyles.replyBubble,
isPureImageMessage && segmentStyles.pureImageBubble,
isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble),
]}>
<MessageSegmentsRenderer
segments={segments}
isMe={isMe}
currentUserId={currentUserId}
memberMap={memberMap}
replyMessage={getReplyMessage()}
getSenderInfo={getSenderInfo}
onAtPress={() => undefined}
onReplyPress={onReplyPress}
onImagePress={(url) => {
// 查找点击的图片索引
const clickIndex = imageSegments.findIndex(img => img.url === url);
if (clickIndex !== -1 && onImagePress) {
onImagePress(imageSegments, clickIndex);
}
}}
onImageLongPress={(position?: { x: number; y: number }) => {
// 图片长按触发和消息气泡一样的菜单
// 如果有位置信息,直接使用;否则使用气泡位置
if (position) {
onLongPress(message, {
x: 0,
y: 0,
width: 0,
height: 0,
pressX: position.x,
pressY: position.y,
});
} else {
handleLongPress();
}
}}
onLinkPress={() => undefined}
/>
</View>
);
};
// 处理滑动回复
const handleSwipeReply = useCallback(() => {
if (onReply) {
onReply(message);
}
}, [onReply, message]);
// 系统通知消息渲染
if (isSystemNotice) {
return (
<View>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(message.created_at)}
</Text>
</View>
)}
<View style={styles.systemNoticeContainer}>
<Text style={styles.systemNoticeText}>{message.notice_content || extractTextFromSegments(message.segments)}</Text>
</View>
</View>
);
}
// 消息内容渲染
const messageContent = (
<TouchableOpacity
onPressIn={handlePressIn}
onLongPress={handleLongPress}
activeOpacity={1}
>
<View ref={bubbleRef}>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(message.created_at)}
</Text>
</View>
)}
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
{/* 群聊模式:显示发送者头像和昵称 */}
{isGroupChat && !isMe && senderInfo && (
<TouchableOpacity
style={styles.groupAvatarWrapper}
onPress={() => senderInfo?.userId && onAvatarPress(senderInfo.userId)}
onLongPress={() => onAvatarLongPress(message.sender_id, senderInfo.nickname)}
delayLongPress={500}
>
<Avatar
source={senderInfo.avatar}
size={36}
name={senderInfo.nickname}
/>
</TouchableOpacity>
)}
{/* 私聊模式:显示对方头像 */}
{!isGroupChat && !isMe && (
<TouchableOpacity
style={styles.avatarWrapper}
onPress={() => otherUser?.id && onAvatarPress(String(otherUser.id))}
>
<Avatar
source={otherUser?.avatar || null}
size={36}
name={otherUser?.nickname || ''}
/>
</TouchableOpacity>
)}
<View style={styles.messageContent}>
{/* 群聊模式:显示发送者昵称 */}
{isGroupChat && !isMe && senderInfo && (
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
)}
{renderMessageContent()}
{/* 私聊模式:显示已读标记 */}
{isLastReadMessage && (
<View style={styles.readStatusContainer}>
<MaterialCommunityIcons
name="check-all"
size={14}
color="#34C759"
/>
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
</Text>
</View>
)}
</View>
{/* 自己的头像 */}
{isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={currentUser?.avatar || null}
size={36}
name={currentUser?.nickname || ''}
/>
</View>
)}
</View>
</View>
</TouchableOpacity>
);
// 使用 SwipeableMessageBubble 包裹消息内容
return (
<SwipeableMessageBubble
isMe={isMe}
onReply={handleSwipeReply}
enabled={!!onReply}
>
{messageContent}
</SwipeableMessageBubble>
);
};
// Segment 消息的特殊样式 - 自适应宽度
const segmentStyles = StyleSheet.create({
replyBubble: {
// 有回复消息时,使用自适应宽度
minWidth: 120,
},
pureImageBubble: {
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
paddingHorizontal: 0,
paddingVertical: 0,
overflow: 'hidden',
},
});
// 使用 React.memo 优化渲染性能
export default React.memo(MessageBubble, (prevProps, nextProps) => {
// 自定义比较逻辑:只比较关键属性
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.status === nextProps.message.status &&
prevProps.selectedMessageId === nextProps.selectedMessageId &&
prevProps.currentUserId === nextProps.currentUserId &&
prevProps.isGroupChat === nextProps.isGroupChat &&
prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq &&
prevProps.index === nextProps.index
);
});