938 lines
24 KiB
TypeScript
938 lines
24 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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;
|