This keeps chat, notification, and post interactions cleaner in production while preserving error-level visibility.
464 lines
14 KiB
TypeScript
464 lines
14 KiB
TypeScript
/**
|
||
* 消息气泡组件
|
||
* 支持消息链(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;
|