Files
frontend/src/screens/message/components/ChatScreen/SegmentRenderer.tsx
lafay a005fb0a15 feat: add QR code login and enhance chat experience
- 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
2026-03-20 19:28:42 +08:00

999 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;