2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 消息气泡组件
|
|
|
|
|
|
* 支持消息链(Segment数组)渲染
|
|
|
|
|
|
* 支持响应式布局(宽屏下优化显示)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
import React, { useRef, useMemo, useCallback } from 'react';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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';
|
2026-03-20 19:28:42 +08:00
|
|
|
|
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getBubbleBorderRadius,
|
|
|
|
|
|
getMessageGroupPosition,
|
|
|
|
|
|
shouldShowSenderInfo,
|
|
|
|
|
|
bubbleColors,
|
|
|
|
|
|
bubbleStyles,
|
|
|
|
|
|
} from './bubbleStyles';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取屏幕宽度
|
|
|
|
|
|
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,
|
2026-03-20 19:28:42 +08:00
|
|
|
|
onReply,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
onReplyPress,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
}) => {
|
|
|
|
|
|
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) {
|
refactor: migrate post operations to use case architecture with differential updates
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} 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,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
isSelected && (isMe ? styles.mySelectedBubble : styles.theirSelectedBubble),
|
2026-03-09 21:29:03 +08:00
|
|
|
|
]}>
|
|
|
|
|
|
<MessageSegmentsRenderer
|
|
|
|
|
|
segments={segments}
|
|
|
|
|
|
isMe={isMe}
|
|
|
|
|
|
currentUserId={currentUserId}
|
|
|
|
|
|
memberMap={memberMap}
|
|
|
|
|
|
replyMessage={getReplyMessage()}
|
|
|
|
|
|
getSenderInfo={getSenderInfo}
|
2026-03-09 22:18:47 +08:00
|
|
|
|
onAtPress={() => undefined}
|
2026-03-24 04:23:13 +08:00
|
|
|
|
onReplyPress={onReplyPress}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
2026-03-09 22:18:47 +08:00
|
|
|
|
onLinkPress={() => undefined}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</View>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
// 处理滑动回复
|
|
|
|
|
|
const handleSwipeReply = useCallback(() => {
|
|
|
|
|
|
if (onReply) {
|
|
|
|
|
|
onReply(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [onReply, message]);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 系统通知消息渲染
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
// 消息内容渲染
|
|
|
|
|
|
const messageContent = (
|
2026-03-09 21:29:03 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
2026-03-20 19:28:42 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用 SwipeableMessageBubble 包裹消息内容
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SwipeableMessageBubble
|
|
|
|
|
|
isMe={isMe}
|
|
|
|
|
|
onReply={handleSwipeReply}
|
|
|
|
|
|
enabled={!!onReply}
|
|
|
|
|
|
>
|
|
|
|
|
|
{messageContent}
|
|
|
|
|
|
</SwipeableMessageBubble>
|
|
|
|
|
|
);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Segment 消息的特殊样式 - 自适应宽度
|
|
|
|
|
|
const segmentStyles = StyleSheet.create({
|
|
|
|
|
|
replyBubble: {
|
|
|
|
|
|
// 有回复消息时,使用自适应宽度
|
|
|
|
|
|
minWidth: 120,
|
|
|
|
|
|
},
|
|
|
|
|
|
pureImageBubble: {
|
|
|
|
|
|
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
|
|
|
|
|
|
paddingHorizontal: 0,
|
|
|
|
|
|
paddingVertical: 0,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
// 使用 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
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|