Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal file
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 消息气泡组件
|
||||
* 支持消息链(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={(userId) => {
|
||||
// TODO: 跳转到用户资料页
|
||||
console.log('At pressed:', userId);
|
||||
}}
|
||||
onReplyPress={(messageId) => {
|
||||
// TODO: 滚动到被回复的消息
|
||||
console.log('Reply pressed:', messageId);
|
||||
}}
|
||||
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={(url) => {
|
||||
console.log('Link pressed:', url);
|
||||
}}
|
||||
/>
|
||||
</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;
|
||||
Reference in New Issue
Block a user