- Add QR code login flow with scanner and confirmation screens - Implement swipe-to-reply in chat with SwipeableMessageBubble - Replace FlatList with FlashList for chat performance optimization - Add Schedule stack navigator with CourseDetail screen support - Configure deep linking for QR code login (carrotbbs://qrcode/login/:sessionId) - Update branding from 胡萝卜 to 萝卜社区 - Display dynamic app version in settings
999 lines
26 KiB
TypeScript
999 lines
26 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 [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 (
|
||
<TouchableOpacity
|
||
key={`image-${data.url || Math.random()}`}
|
||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||
onPressIn={handlePressIn}
|
||
onPress={() => hasValidUrl && 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)',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
}}
|
||
>
|
||
<MaterialCommunityIcons name="image" size={32} color="#999" />
|
||
<Text style={{ color: '#999', fontSize: 12, marginTop: 4 }}>图片</Text>
|
||
</View>
|
||
</TouchableOpacity>
|
||
);
|
||
}
|
||
|
||
// 初始加载时显示占位
|
||
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"
|
||
transition={200}
|
||
onError={() => {
|
||
setLoadError(true);
|
||
}}
|
||
/>
|
||
</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 = 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 (
|
||
<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}>
|
||
{(() => {
|
||
try {
|
||
return new URL(data.url).hostname;
|
||
} catch {
|
||
return data.url;
|
||
}
|
||
})()}
|
||
</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; |