Files
frontend/src/screens/message/components/ChatScreen/SegmentRenderer.tsx

938 lines
24 KiB
TypeScript
Raw Normal View History

/**
* Segment
* Segment
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Linking,
Image,
GestureResponderEvent,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { VideoPlayerModal } from '../../../../components/common/VideoPlayerModal';
import {
MessageSegment,
AtSegmentData,
ReplySegmentData,
ImageSegmentData,
VoiceSegmentData,
VideoSegmentData,
FileSegmentData,
FaceSegmentData,
LinkSegmentData,
TextSegmentData,
MessageResponse,
UserDTO,
extractTextFromSegments,
} from '../../../../types/dto';
import { colors, spacing } from '../../../../theme';
import { SenderInfo } from './types';
const IMAGE_MAX_WIDTH = 200;
const IMAGE_MAX_HEIGHT = 260;
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
// Segment 渲染器 Props
export interface SegmentRendererProps {
segment: MessageSegment;
// 当前用户ID用于判断@是否高亮
currentUserId?: string;
// 群成员列表,用于获取@用户的昵称
memberMap?: Map<string, { nickname: string; user_id: string }>;
// 是否是自己发送的消息
isMe: boolean;
// @点击回调
onAtPress?: (userId: string) => void;
// 回复点击回调
onReplyPress?: (messageId: string) => void;
// 图片点击回调
onImagePress?: (url: string) => void;
// 图片长按回调(触发消息气泡菜单)
onImageLongPress?: (position?: { x: number; y: number }) => void;
// 链接点击回调
onLinkPress?: (url: string) => void;
}
// 回复预览 Props
export interface ReplyPreviewSegmentProps {
replyData: ReplySegmentData;
replyMessage?: MessageResponse;
isMe: boolean;
onPress?: () => void;
getSenderInfo?: (senderId: string) => SenderInfo;
}
/**
* Segment
*/
export const renderSegment = (
segment: MessageSegment,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { isMe } = props;
switch (segment.type) {
case 'text':
return renderTextSegment(segment.data as TextSegmentData, isMe);
case 'image':
return renderImageSegment(segment.data as ImageSegmentData, props);
case 'at':
return renderAtSegment(segment.data as AtSegmentData, props);
case 'reply':
return null; // reply segment 单独处理
case 'face':
return renderFaceSegment(segment.data as FaceSegmentData, isMe);
case 'voice':
return renderVoiceSegment(segment.data as VoiceSegmentData, isMe);
case 'video':
return renderVideoSegment(segment.data as VideoSegmentData, isMe);
case 'file':
return renderFileSegment(segment.data as FileSegmentData, isMe);
case 'link':
return renderLinkSegment(segment.data as LinkSegmentData, props);
default:
return null;
}
};
/**
* Segment
*/
const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => {
// 兼容 content 和 text 两种字段
const text = data.content ?? data.text ?? '';
return (
<Text
key={`text-${text.substring(0, 10)}`}
style={[styles.textContent, isMe ? styles.textMe : styles.textOther]}
selectable={false}
>
{text}
</Text>
);
};
/**
* Segment
*/
// 图片Segment组件 - 使用Hook获取实际尺寸
const ImageSegment: React.FC<{
data: ImageSegmentData;
isMe: boolean;
onImagePress?: (url: string) => void;
onImageLongPress?: (position?: { x: number; y: number }) => void;
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
const pressPositionRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const imageUrl = data.thumbnail_url || data.url;
useEffect(() => {
// 如果已经有宽高信息,直接使用
if (data.width && data.height) {
const aspectRatio = data.width / data.height;
let width = data.width;
let height = data.height;
if (width > IMAGE_MAX_WIDTH) {
width = IMAGE_MAX_WIDTH;
height = width / aspectRatio;
}
if (height > IMAGE_MAX_HEIGHT) {
height = IMAGE_MAX_HEIGHT;
width = height * aspectRatio;
}
setDimensions({ width: Math.round(width), height: Math.round(height) });
return;
}
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => {
if (!dimensions) {
setDimensions(IMAGE_FALLBACK_SIZE);
}
}, 3000);
// 否则从图片获取实际尺寸
Image.getSize(
imageUrl,
(width, height) => {
clearTimeout(timeoutId);
const aspectRatio = width / height;
let newWidth = width;
let newHeight = height;
if (newWidth > IMAGE_MAX_WIDTH) {
newWidth = IMAGE_MAX_WIDTH;
newHeight = newWidth / aspectRatio;
}
if (newHeight > IMAGE_MAX_HEIGHT) {
newHeight = IMAGE_MAX_HEIGHT;
newWidth = newHeight * aspectRatio;
}
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
},
(error) => {
clearTimeout(timeoutId);
// 获取失败时使用默认 4:3 比例
setDimensions(IMAGE_FALLBACK_SIZE);
}
);
return () => clearTimeout(timeoutId);
}, [imageUrl, data.width, data.height]);
// 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => {
const { pageX, pageY } = event.nativeEvent;
pressPositionRef.current = { x: pageX, y: pageY };
};
// 处理长按
const handleLongPress = () => {
onImageLongPress?.(pressPositionRef.current);
};
// 初始加载时显示占位
if (!dimensions) {
return (
<TouchableOpacity
key={`image-${data.url}`}
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
onPressIn={handlePressIn}
onPress={() => onImagePress?.(data.url)}
onLongPress={handleLongPress}
delayLongPress={500}
activeOpacity={0.9}
>
<View
style={{
width: IMAGE_FALLBACK_SIZE.width,
height: IMAGE_FALLBACK_SIZE.height,
borderRadius: 8,
backgroundColor: 'rgba(0,0,0,0.1)',
}}
/>
</TouchableOpacity>
);
}
return (
<TouchableOpacity
key={`image-${data.url}`}
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
onPressIn={handlePressIn}
onPress={() => onImagePress?.(data.url)}
onLongPress={handleLongPress}
delayLongPress={500}
activeOpacity={0.9}
>
<ExpoImage
source={{ uri: imageUrl }}
style={{
width: dimensions.width,
height: dimensions.height,
borderRadius: 8,
}}
contentFit="cover"
cachePolicy="disk"
priority="normal"
/>
</TouchableOpacity>
);
};
const renderImageSegment = (
data: ImageSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { onImagePress, onImageLongPress, isMe } = props;
return <ImageSegment data={data} isMe={isMe} onImagePress={onImagePress} onImageLongPress={onImageLongPress} />;
};
/**
* @ Segment
*/
const renderAtSegment = (
data: AtSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { currentUserId, memberMap, onAtPress, isMe } = props;
// 判断是否@当前用户
const isAtMe = data.user_id === currentUserId || data.user_id === 'all';
// 优先从 memberMap 中查找昵称,兜底使用 data.nickname兼容旧消息再兜底显示"用户"
let displayName: string;
if (data.user_id === 'all') {
displayName = '所有人';
} else {
const member = memberMap?.get(data.user_id);
displayName = member?.nickname || data.nickname || '用户';
}
return (
<Text
key={`at-${data.user_id}`}
style={[
styles.atText,
isMe ? styles.atTextMe : styles.atTextOther,
isAtMe && styles.atHighlight,
]}
onPress={() => data.user_id !== 'all' && onAtPress?.(data.user_id)}
>
@{displayName}
</Text>
);
};
/**
* Segment
*/
const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => {
// 如果有自定义表情URL显示图片
if (data.url) {
return (
<ExpoImage
key={`face-${data.id}`}
source={{ uri: data.url }}
style={styles.faceImage}
contentFit="cover"
cachePolicy="memory"
/>
);
}
// 否则显示表情名称(后续可以映射到本地表情)
return (
<Text key={`face-${data.id}`} style={styles.faceText}>
[{data.name || `表情${data.id}`}]
</Text>
);
};
/**
* Segment
*/
const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => {
return (
<TouchableOpacity
key={`voice-${data.url}`}
style={[styles.voiceContainer, isMe ? styles.voiceMe : styles.voiceOther]}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="microphone"
size={20}
color={isMe ? '#FFFFFF' : '#666666'}
/>
<Text style={[styles.voiceDuration, isMe ? styles.textMe : styles.textOther]}>
{data.duration ? `${data.duration}"` : '语音'}
</Text>
</TouchableOpacity>
);
};
/**
* Segment -
*/
const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => {
const [playerVisible, setPlayerVisible] = useState(false);
const handlePress = useCallback(() => {
if (data.url) {
setPlayerVisible(true);
}
}, [data.url]);
return (
<>
<TouchableOpacity
style={[styles.videoContainer, isMe ? styles.videoMe : styles.videoOther]}
onPress={handlePress}
activeOpacity={0.7}
>
{data.thumbnail_url ? (
<ExpoImage
source={{ uri: data.thumbnail_url }}
style={styles.videoThumbnail}
contentFit="cover"
/>
) : (
<View style={styles.videoPlaceholder}>
<MaterialCommunityIcons name="video" size={32} color="#FFFFFF" />
</View>
)}
<View style={styles.videoPlayIcon}>
<MaterialCommunityIcons name="play-circle" size={40} color="#FFFFFF" />
</View>
{data.duration && (
<Text style={styles.videoDuration}>{formatDuration(data.duration)}</Text>
)}
</TouchableOpacity>
<VideoPlayerModal
visible={playerVisible}
url={data.url}
onClose={() => setPlayerVisible(false)}
/>
</>
);
};
const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactNode => {
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
};
/**
* Segment
*/
const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => {
const fileSize = data.size ? formatFileSize(data.size) : '';
return (
<TouchableOpacity
key={`file-${data.url}`}
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
activeOpacity={0.7}
>
<View style={styles.fileIcon}>
<MaterialCommunityIcons
name="file-document"
size={28}
color={isMe ? '#FFFFFF' : colors.primary.main}
/>
</View>
<View style={styles.fileInfo}>
<Text
style={[styles.fileName, isMe ? styles.textMe : styles.textOther]}
numberOfLines={1}
>
{data.name}
</Text>
{fileSize && (
<Text style={styles.fileSize}>{fileSize}</Text>
)}
</View>
</TouchableOpacity>
);
};
/**
* Segment
*/
const renderLinkSegment = (
data: LinkSegmentData,
props: Omit<SegmentRendererProps, 'segment'>
): React.ReactNode => {
const { onLinkPress, isMe } = props;
const handlePress = () => {
if (onLinkPress) {
onLinkPress(data.url);
} else {
Linking.openURL(data.url).catch(() => {});
}
};
return (
<TouchableOpacity
key={`link-${data.url}`}
style={[styles.linkContainer, isMe ? styles.linkMe : styles.linkOther]}
onPress={handlePress}
activeOpacity={0.7}
>
{data.image && (
<ExpoImage source={{ uri: data.image }} style={styles.linkImage} contentFit="cover" cachePolicy="memory" />
)}
<View style={styles.linkContent}>
<Text style={styles.linkTitle} numberOfLines={2}>
{data.title || data.url}
</Text>
{data.description && (
<Text style={styles.linkDescription} numberOfLines={2}>
{data.description}
</Text>
)}
<Text style={styles.linkUrl} numberOfLines={1}>
{new URL(data.url).hostname}
</Text>
</View>
</TouchableOpacity>
);
};
/**
*
*/
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
replyData,
replyMessage,
isMe,
onPress,
getSenderInfo,
}) => {
if (!replyMessage) {
// 如果没有引用消息详情只显示引用ID
return (
<TouchableOpacity
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.replyLine} />
<Text style={styles.replyText} numberOfLines={2}>
</Text>
</TouchableOpacity>
);
}
// 获取发送者信息 - 优先使用 replyMessage.sender
let senderName = '';
if (replyMessage.sender?.nickname) {
senderName = replyMessage.sender.nickname;
} else if (replyMessage.sender?.username) {
senderName = replyMessage.sender.username;
} else if (getSenderInfo && replyMessage.sender_id) {
const info = getSenderInfo(replyMessage.sender_id);
senderName = info.nickname;
}
// 如果还是没有昵称,显示"对方"
if (!senderName || senderName === '用户') {
senderName = '对方';
}
// 获取预览内容 - 使用 extractTextFromSegments 从 segments 提取
let previewContent = '';
if (replyMessage.segments?.length) {
// 检查是否有图片/语音/视频/文件
const hasImage = replyMessage.segments.some(s => s.type === 'image');
const hasVoice = replyMessage.segments.some(s => s.type === 'voice');
const hasVideo = replyMessage.segments.some(s => s.type === 'video');
const hasFile = replyMessage.segments.some(s => s.type === 'file');
if (hasImage) {
previewContent = '[图片]';
} else if (hasVoice) {
previewContent = '[语音]';
} else if (hasVideo) {
previewContent = '[视频]';
} else if (hasFile) {
previewContent = '[文件]';
} else {
// 从文本 segments 提取
previewContent = extractTextFromSegments(replyMessage.segments);
}
}
return (
<TouchableOpacity
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
onPress={onPress}
activeOpacity={0.7}
>
<View style={styles.replyContent}>
<Text style={styles.replySender} numberOfLines={1}>
{senderName}:
</Text>
<Text
style={styles.replyText}
numberOfLines={1}
ellipsizeMode="tail"
>
{previewContent}
</Text>
</View>
</TouchableOpacity>
);
};
/**
* Segment
*/
export const MessageSegmentsRenderer: React.FC<{
segments: MessageSegment[];
isMe: boolean;
currentUserId?: string;
memberMap?: Map<string, { nickname: string; user_id: string }>;
replyMessage?: MessageResponse;
onAtPress?: (userId: string) => void;
onReplyPress?: (messageId: string) => void;
onImagePress?: (url: string) => void;
onImageLongPress?: () => void;
onLinkPress?: (url: string) => void;
getSenderInfo?: (senderId: string) => SenderInfo;
}> = ({
segments,
isMe,
currentUserId,
memberMap,
replyMessage,
onAtPress,
onReplyPress,
onImagePress,
onImageLongPress,
onLinkPress,
getSenderInfo,
}) => {
// 找出 reply segment如果有
const replySegment = segments.find(s => s.type === 'reply');
const otherSegments = segments.filter(s => s.type !== 'reply');
const renderProps = {
isMe,
currentUserId,
memberMap,
onAtPress,
onReplyPress,
onImagePress,
onImageLongPress,
onLinkPress,
};
return (
<View style={styles.segmentsContainer}>
{/* 回复预览 */}
{replySegment && (
<ReplyPreviewSegment
replyData={(replySegment.data as ReplySegmentData)}
replyMessage={replyMessage}
isMe={isMe}
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
getSenderInfo={getSenderInfo}
/>
)}
{/* 其他 Segment 内容 */}
<View style={styles.segmentsContent}>
{otherSegments.map((segment, index) => (
<React.Fragment key={`segment-${index}-${segment.type}`}>
{renderSegment(segment, renderProps)}
</React.Fragment>
))}
</View>
</View>
);
};
// 辅助函数:格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
// 辅助函数:格式化时长
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
};
const styles = StyleSheet.create({
// 容器
segmentsContainer: {
flexDirection: 'column',
width: '100%',
},
segmentsContent: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-end',
},
// 文本 - Telegram风格统一深色字体
textContent: {
fontSize: 16,
lineHeight: 23,
letterSpacing: 0.2,
fontWeight: '400',
},
textMe: {
color: '#1A1A1A', // 统一使用深色字体
},
textOther: {
color: '#1A1A1A', // 统一使用深色字体
},
// @提及 - Telegram风格蓝色高亮
atText: {
fontSize: 16,
lineHeight: 23,
fontWeight: '600',
paddingHorizontal: 2,
},
atTextMe: {
color: '#1976D2', // 蓝色
backgroundColor: 'rgba(25, 118, 210, 0.12)',
borderRadius: 4,
},
atTextOther: {
color: '#1976D2', // 蓝色
backgroundColor: 'rgba(25, 118, 210, 0.12)',
borderRadius: 4,
},
atHighlight: {
backgroundColor: 'rgba(25, 118, 210, 0.2)',
borderRadius: 4,
paddingHorizontal: 4,
},
// 图片 - QQ风格保持原始宽高比
imageContainer: {
borderRadius: 12,
overflow: 'hidden',
marginTop: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
imageMe: {
borderBottomRightRadius: 4,
},
imageOther: {
borderBottomLeftRadius: 4,
},
// 移除固定的 image 样式,改为在组件中动态计算
// 表情 - QQ风格更大的表情
faceImage: {
width: 28,
height: 28,
},
faceText: {
fontSize: 16,
color: '#666',
},
// 语音 - QQ风格更明显的背景
voiceContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderRadius: 20,
minWidth: 100,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
},
voiceMe: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
voiceOther: {
backgroundColor: 'rgba(0, 0, 0, 0.06)',
},
voiceDuration: {
marginLeft: spacing.sm,
fontSize: 15,
fontWeight: '500',
},
// 视频 - QQ风格更大的缩略图
videoContainer: {
borderRadius: 16,
overflow: 'hidden',
width: 220,
height: 165,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.15,
shadowRadius: 4,
elevation: 4,
},
videoMe: {
borderBottomRightRadius: 6,
},
videoOther: {
borderBottomLeftRadius: 6,
},
videoThumbnail: {
width: '100%',
height: '100%',
},
videoPlaceholder: {
width: '100%',
height: '100%',
backgroundColor: 'linear-gradient(135deg, #333 0%, #555 100%)',
justifyContent: 'center',
alignItems: 'center',
},
videoPlayIcon: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -24,
marginLeft: -24,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
borderRadius: 24,
padding: 4,
},
videoDuration: {
position: 'absolute',
bottom: 10,
right: 10,
color: '#FFFFFF',
fontSize: 13,
fontWeight: '600',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
overflow: 'hidden',
},
// 文件 - QQ风格卡片式设计
fileContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
borderRadius: 16,
minWidth: 220,
maxWidth: 300,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.08,
shadowRadius: 2,
elevation: 2,
},
fileMe: {
backgroundColor: 'rgba(255, 255, 255, 0.25)',
},
fileOther: {
backgroundColor: '#F8F9FA',
},
fileIcon: {
marginRight: spacing.md,
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: 'rgba(255, 107, 53, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
fileInfo: {
flex: 1,
},
fileName: {
fontSize: 15,
fontWeight: '600',
color: '#1A1A1A',
},
fileSize: {
fontSize: 13,
color: '#8E8E93',
marginTop: 4,
fontWeight: '500',
},
// 链接 - QQ风格卡片式设计
linkContainer: {
borderRadius: 16,
overflow: 'hidden',
maxWidth: 280,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
linkMe: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
},
linkOther: {
backgroundColor: '#FFFFFF',
},
linkImage: {
width: '100%',
height: 140,
},
linkContent: {
padding: spacing.md,
},
linkTitle: {
fontSize: 15,
fontWeight: '600',
color: '#1A1A1A',
lineHeight: 20,
},
linkDescription: {
fontSize: 13,
color: '#666666',
marginTop: 6,
lineHeight: 18,
},
linkUrl: {
fontSize: 12,
color: '#8E8E93',
marginTop: 6,
fontWeight: '500',
},
// 回复预览 - Telegram风格简洁设计
replyPreview: {
flexDirection: 'row',
marginBottom: 0,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm + 2,
borderRadius: 6,
width: '100%',
minWidth: 200,
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
replyMe: {
backgroundColor: 'rgba(25, 118, 210, 0.1)',
},
replyOther: {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
replyLine: {
width: 0,
marginRight: 0,
},
replyContent: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'nowrap',
},
replySender: {
fontSize: 13,
fontWeight: '600',
color: '#1976D2',
marginRight: 6,
flexShrink: 0,
},
replyText: {
fontSize: 13,
color: '#666666',
flex: 1,
flexShrink: 1,
},
});
export default MessageSegmentsRenderer;