/** * 消息气泡组件 * 支持消息链(Segment数组)渲染 * 支持响应式布局(宽屏下优化显示) */ import React, { useRef, useMemo } from 'react'; import { View, TouchableOpacity, Image, findNodeHandle, UIManager, 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'; // 获取屏幕宽度 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 = ({ message, index, currentUserId, currentUser, otherUser, isGroupChat, groupMembers, otherUserLastReadSeq, selectedMessageId, messageMap, onLongPress, onAvatarPress, onAvatarLongPress, formatTime, shouldShowTime, onImagePress, }) => { const bubbleRef = useRef(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(); 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) { const node = findNodeHandle(bubbleRef.current); if (node) { UIManager.measureInWindow(node, (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); } } else { onLongPress(message); } }; // 渲染消息内容 const renderMessageContent = () => { // 撤回消息 if (isRecalled) { return ( 消息已撤回 ); } // 使用消息链渲染(必须使用 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 ( [消息格式错误] ); } // 检查是否有回复,用于调整气泡样式 const hasReply = segments.some(s => s.type === 'reply'); return ( undefined} onReplyPress={() => undefined} 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} /> ); }; // 系统通知消息渲染 if (isSystemNotice) { return ( {showTime && ( {formatTime(message.created_at)} )} {message.notice_content || extractTextFromSegments(message.segments)} ); } return ( {showTime && ( {formatTime(message.created_at)} )} {/* 群聊模式:显示发送者头像和昵称 */} {isGroupChat && !isMe && senderInfo && ( senderInfo?.userId && onAvatarPress(senderInfo.userId)} onLongPress={() => onAvatarLongPress(message.sender_id, senderInfo.nickname)} delayLongPress={500} > )} {/* 私聊模式:显示对方头像 */} {!isGroupChat && !isMe && ( otherUser?.id && onAvatarPress(String(otherUser.id))} > )} {/* 群聊模式:显示发送者昵称 */} {isGroupChat && !isMe && senderInfo && ( {senderInfo.nickname} )} {renderMessageContent()} {/* 私聊模式:显示已读标记 */} {isLastReadMessage && ( 已读 )} {/* 自己的头像 */} {isMe && ( )} ); }; // Segment 消息的特殊样式 - 自适应宽度 const segmentStyles = StyleSheet.create({ replyBubble: { // 有回复消息时,使用自适应宽度 minWidth: 120, }, pureImageBubble: { // 纯图片消息:去除内边距,让图片紧贴气泡边缘 paddingHorizontal: 0, paddingVertical: 0, overflow: 'hidden', }, }); export default MessageBubble;