Files
frontend/src/screens/message/components/ChatScreen/MessageBubble.tsx
lan 63e32b15a3 Reduce noisy runtime logging in frontend flows.
This keeps chat, notification, and post interactions cleaner in production while preserving error-level visibility.
2026-03-09 22:20:44 +08:00

464 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 消息气泡组件
* 支持消息链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<MessageBubbleProps> = ({
message,
index,
currentUserId,
currentUser,
otherUser,
isGroupChat,
groupMembers,
otherUserLastReadSeq,
selectedMessageId,
messageMap,
onLongPress,
onAvatarPress,
onAvatarLongPress,
formatTime,
shouldShowTime,
onImagePress,
}) => {
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) {
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 (
<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,
]}>
<MessageSegmentsRenderer
segments={segments}
isMe={isMe}
currentUserId={currentUserId}
memberMap={memberMap}
replyMessage={getReplyMessage()}
getSenderInfo={getSenderInfo}
onAtPress={() => 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}
/>
</View>
);
};
// 系统通知消息渲染
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>
);
}
return (
<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>
);
};
// Segment 消息的特殊样式 - 自适应宽度
const segmentStyles = StyleSheet.create({
replyBubble: {
// 有回复消息时,使用自适应宽度
minWidth: 120,
},
pureImageBubble: {
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
paddingHorizontal: 0,
paddingVertical: 0,
overflow: 'hidden',
},
});
export default MessageBubble;