2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 消息气泡组件
|
|
|
|
|
|
* 支持消息链(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}
|
2026-03-09 22:18:47 +08:00
|
|
|
|
onAtPress={() => undefined}
|
|
|
|
|
|
onReplyPress={() => undefined}
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 系统通知消息渲染
|
|
|
|
|
|
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;
|