/** * 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; // 是否是自己发送的消息 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 ): 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} ); }; /** * 渲染图片 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 [loadError, setLoadError] = useState(false); const imageUrl = data.thumbnail_url || data.url; // 如果没有有效的图片URL,显示图片占位符 const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; useEffect(() => { // 重置状态 setLoadError(false); // 如果没有有效的图片URL,设置默认尺寸用于显示占位符 if (!hasValidUrl) { setDimensions(IMAGE_FALLBACK_SIZE); return; } // 如果已经有宽高信息,直接使用 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, hasValidUrl]); // 记录按压位置 const handlePressIn = (event: GestureResponderEvent) => { const { pageX, pageY } = event.nativeEvent; pressPositionRef.current = { x: pageX, y: pageY }; }; // 处理长按 const handleLongPress = () => { onImageLongPress?.(pressPositionRef.current); }; // 没有有效URL或加载失败时显示占位符 if (!hasValidUrl || loadError) { return ( hasValidUrl && onImagePress?.(data.url)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} > 图片 ); } // 初始加载时显示占位 if (!dimensions) { return ( onImagePress?.(data.url)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} > ); } return ( onImagePress?.(data.url)} onLongPress={handleLongPress} delayLongPress={500} activeOpacity={0.9} > { setLoadError(true); }} /> ); }; const renderImageSegment = ( data: ImageSegmentData, props: Omit ): React.ReactNode => { const { onImagePress, onImageLongPress, isMe } = props; return ; }; /** * 渲染 @ Segment */ const renderAtSegment = ( data: AtSegmentData, props: Omit ): 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 ( data.user_id !== 'all' && onAtPress?.(data.user_id)} > @{displayName} ); }; /** * 渲染表情 Segment */ const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => { // 如果有自定义表情URL,显示图片 if (data.url) { return ( ); } // 否则显示表情名称(后续可以映射到本地表情) return ( [{data.name || `表情${data.id}`}] ); }; /** * 渲染语音 Segment */ const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => { return ( {data.duration ? `${data.duration}"` : '语音'} ); }; /** * 视频 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 ( <> {data.thumbnail_url ? ( ) : ( )} {data.duration && ( {formatDuration(data.duration)} )} setPlayerVisible(false)} /> ); }; const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactNode => { return ; }; /** * 渲染文件 Segment */ const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => { const fileSize = data.size ? formatFileSize(data.size) : ''; return ( {data.name} {fileSize && ( {fileSize} )} ); }; /** * 渲染链接 Segment */ const renderLinkSegment = ( data: LinkSegmentData, props: Omit ): React.ReactNode => { const { onLinkPress, isMe } = props; const handlePress = async () => { if (onLinkPress) { onLinkPress(data.url); } else { try { const supported = await Linking.canOpenURL(data.url); if (supported) { await Linking.openURL(data.url); } else { console.warn('无法打开链接:', data.url); } } catch (error) { console.warn('打开链接失败:', error); } } }; return ( {data.image && ( )} {data.title || data.url} {data.description && ( {data.description} )} {(() => { try { return new URL(data.url).hostname; } catch { return data.url; } })()} ); }; /** * 回复预览组件(用于消息气泡内显示回复引用) */ export const ReplyPreviewSegment: React.FC = ({ replyData, replyMessage, isMe, onPress, getSenderInfo, }) => { if (!replyMessage) { // 如果没有引用消息详情,只显示引用ID return ( 引用消息 ); } // 获取发送者信息 - 优先使用 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 ( {senderName}: {previewContent} ); }; /** * 渲染完整的消息链(多个 Segment 组合) */ export const MessageSegmentsRenderer: React.FC<{ segments: MessageSegment[]; isMe: boolean; currentUserId?: string; memberMap?: Map; 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 ( {/* 回复预览 */} {replySegment && ( onReplyPress?.((replySegment.data as ReplySegmentData).id)} getSenderInfo={getSenderInfo} /> )} {/* 其他 Segment 内容 */} {otherSegments.map((segment, index) => ( {renderSegment(segment, renderProps)} ))} ); }; // 辅助函数:格式化文件大小 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;