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:
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal file
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal file
@@ -0,0 +1,938 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user