Files
frontend/src/screens/message/components/EmbeddedChat.tsx
lafay 775deeb9c4
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m18s
Frontend CI / ota-android (push) Successful in 12m16s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(database): unify database connections and add retry logic
Refactor LocalDataSource to reuse the shared database connection from database.ts, avoiding OPFS file handle conflicts. Add retry logic with exponential backoff for database open operations, particularly important for Web/OPFS environments. Also add dynamic theme support to chat input and message bubble styles, using theme colors instead of hardcoded values for better light/dark mode support.

BREAKING CHANGE: Database initialization now requires userId to be passed explicitly for user-specific databases
2026-04-04 00:02:22 +08:00

1039 lines
32 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.
/**
* EmbeddedChat.tsx
* 嵌入式聊天组件 - 用于桌面端双栏布局右侧
*/
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
View,
FlatList,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
TextInput,
Dimensions,
Animated,
} from 'react-native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../../../theme';
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
import { useAuthStore, messageManager, useMessageStore } from '../../../stores';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { extractTextFromSegments } from '../../../types/dto';
import { useBreakpointGTE } from '../../../hooks/useResponsive';
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
import { messageService } from '../../../services';
import * as hrefs from '../../../navigation/hrefs';
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
import { EmojiPanel } from './ChatScreen/EmojiPanel';
import { MorePanel } from './ChatScreen/MorePanel';
const { width: screenWidth } = Dimensions.get('window');
const PANEL_WIDTH = 360;
// 时间分隔间隔(毫秒)- 5分钟
const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000;
function createEmbeddedChatStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.background.default,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
height: 56,
},
headerButton: {
padding: spacing.xs,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerCenter: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
justifyContent: 'center',
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: colors.text.primary,
marginLeft: spacing.sm,
maxWidth: 200,
},
messageList: {
flex: 1,
backgroundColor: colors.background.default,
},
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
},
emptyText: {
marginTop: spacing.md,
fontSize: 16,
color: colors.text.secondary,
},
emptySubtext: {
marginTop: spacing.xs,
fontSize: 14,
color: colors.text.disabled,
},
listContent: {
padding: spacing.md,
paddingBottom: spacing.xl,
},
messageRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.md,
maxWidth: screenWidth * 0.7,
},
messageRowLeft: {
alignSelf: 'flex-start',
},
messageRowRight: {
alignSelf: 'flex-end',
justifyContent: 'flex-end',
alignItems: 'flex-end',
},
avatarWrapper: {
marginHorizontal: 2,
},
messageContent: {
maxWidth: screenWidth * 0.5,
marginHorizontal: spacing.xs,
alignItems: 'flex-start',
},
messageBubble: {
maxWidth: screenWidth * 0.5,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: 18,
marginHorizontal: spacing.sm,
},
messageBubbleMe: {
backgroundColor: colors.chat.replyTint,
borderTopRightRadius: 4,
},
messageBubbleOther: {
backgroundColor: colors.chat.bubbleIncoming,
borderTopLeftRadius: 4,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 0.5 },
shadowOpacity: 0.04,
shadowRadius: 1,
elevation: 1,
},
senderName: {
fontSize: 14,
color: colors.text.secondary,
marginBottom: 4,
marginLeft: 4,
fontWeight: '600',
},
messageText: {
fontSize: 15,
lineHeight: 20,
},
messageTextMe: {
color: colors.chat.textPrimary,
},
messageTextOther: {
color: colors.chat.textPrimary,
},
inputArea: {
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
},
iconButton: {
padding: spacing.xs,
width: 44,
height: 44,
alignItems: 'center',
justifyContent: 'center',
},
inputContainer: {
flex: 1,
backgroundColor: colors.chat.surfaceInput,
borderRadius: 20,
paddingHorizontal: spacing.md,
minHeight: 40,
justifyContent: 'center',
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
textInput: {
fontSize: 15,
color: colors.text.primary,
padding: 0,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
},
sendButtonDisabled: {
backgroundColor: colors.background.disabled,
},
messageTextRecalled: {
fontStyle: 'italic',
color: colors.text.secondary,
},
imageGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 4,
},
messageImage: {
borderRadius: 8,
marginRight: 4,
marginBottom: 4,
},
imagePlaceholder: {
width: 120,
height: 120,
borderRadius: 8,
backgroundColor: colors.background.disabled,
justifyContent: 'center',
alignItems: 'center',
marginRight: 4,
marginBottom: 4,
},
imagePlaceholderText: {
color: colors.text.secondary,
fontSize: 12,
marginTop: 4,
},
// 时间分隔样式
timeContainer: {
alignItems: 'center',
marginVertical: spacing.md,
},
timeText: {
color: 'rgba(142, 142, 147, 0.95)',
fontSize: 11,
backgroundColor: 'rgba(142, 142, 147, 0.1)',
paddingHorizontal: spacing.sm + 4,
paddingVertical: 5,
borderRadius: 12,
fontWeight: '500',
overflow: 'hidden',
letterSpacing: 0.2,
},
// 系统通知样式(如"某某加入了群聊"
systemNoticeContainer: {
alignItems: 'center',
marginVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
systemNoticeText: {
fontSize: 12,
color: colors.chat.textSecondary,
backgroundColor: 'rgba(142, 142, 147, 0.12)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: 12,
overflow: 'hidden',
},
// 面板容器样式
panelContainer: {
backgroundColor: colors.background.paper,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.divider,
},
});
}
interface EmbeddedChatProps {
conversation: ConversationResponse;
onBack: () => void;
}
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
const colors = useAppColors();
const styles = useMemo(() => createEmbeddedChatStyles(colors), [colors]);
const router = useRouter();
const currentUser = useAuthStore(state => state.currentUser);
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
const isWideScreen = useBreakpointGTE('lg');
// 使用 markAsRead hook
const { markAsRead } = useMarkAsRead(String(conversation.id));
// 状态 - 使用 Zustand selector 直接订阅消息
const messages = useMessageStore(state => state.messagesMap.get(String(conversation.id)) || []);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [inputText, setInputText] = useState('');
const flatListRef = useRef<FlatList>(null);
const inputRef = useRef<TextInput>(null);
// 图片预览状态
const [imageGalleryVisible, setImageGalleryVisible] = useState(false);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [galleryImages, setGalleryImages] = useState<{ url: string }[]>([]);
// 右键菜单状态
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState<MenuPosition | undefined>();
const [selectedMessageForMenu, setSelectedMessageForMenu] = useState<GroupMessage | null>(null);
// 回复状态
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
// 面板显示状态
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
const [showMorePanel, setShowMorePanel] = useState(false);
// 切换表情面板
const toggleEmojiPanel = useCallback(() => {
setShowEmojiPanel(prev => !prev);
setShowMorePanel(false);
if (!showEmojiPanel) {
inputRef.current?.blur();
}
}, [showEmojiPanel]);
// 切换更多功能面板
const toggleMorePanel = useCallback(() => {
setShowMorePanel(prev => !prev);
setShowEmojiPanel(false);
if (!showMorePanel) {
inputRef.current?.blur();
}
}, [showMorePanel]);
// 关闭所有面板
const closePanels = useCallback(() => {
setShowEmojiPanel(false);
setShowMorePanel(false);
}, []);
// 插入表情
const handleInsertEmoji = useCallback((emoji: string) => {
setInputText(prev => prev + emoji);
}, []);
// 发送自定义表情
const handleSendSticker = useCallback(async (stickerUrl: string) => {
if (!conversation.id) return;
try {
setSending(true);
const segments: MessageSegment[] = [{
type: 'image',
data: { url: stickerUrl, thumbnail_url: stickerUrl }
}];
await messageManager.sendMessage(String(conversation.id), segments);
closePanels();
} catch (error) {
console.error('[EmbeddedChat] 发送表情失败:', error);
} finally {
setSending(false);
}
}, [conversation.id, closePanels]);
// 处理更多功能
const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) {
case 'image':
// TODO: 实现选择图片功能
console.log('选择图片');
break;
case 'camera':
// TODO: 实现相机功能
console.log('相机');
break;
case 'file':
// TODO: 实现文件功能
console.log('文件');
break;
case 'location':
// TODO: 实现位置功能
console.log('位置');
break;
}
closePanels();
}, [closePanels]);
// 群信息面板动画
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
const fadeAnim = useRef(new Animated.Value(0)).current;
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
// 群成员列表
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
// 格式化时间
const formatTime = useCallback((dateString: string): string => {
try {
const date = new Date(dateString);
const now = new Date();
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
if (diffInHours < 24 && date.getDate() === now.getDate()) {
// 当天消息显示 "上午/下午 HH:mm" 格式
const hours = date.getHours();
const period = hours < 12 ? '上午' : '下午';
const displayHours = hours % 12 || 12;
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${period} ${displayHours}:${minutes}`;
}
return formatDistanceToNow(date, {
addSuffix: true,
locale: zhCN,
});
} catch {
return '';
}
}, []);
// 检查是否需要显示时间分隔
const shouldShowTime = useCallback((index: number): boolean => {
if (index === 0) return true;
const currentMsg = messages[index];
const prevMsg = messages[index - 1];
if (!currentMsg || !prevMsg) return false;
const currentTime = new Date(currentMsg.created_at).getTime();
const prevTime = new Date(prevMsg.created_at).getTime();
return (currentTime - prevTime) > TIME_SEPARATOR_INTERVAL;
}, [messages]);
// 打开/关闭群信息面板
const toggleGroupInfoPanel = useCallback(() => {
if (showGroupInfoPanel) {
Animated.parallel([
Animated.timing(slideAnim, {
toValue: PANEL_WIDTH,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start(() => {
setShowGroupInfoPanel(false);
});
} else {
setShowGroupInfoPanel(true);
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
]).start();
}
}, [showGroupInfoPanel, slideAnim, fadeAnim]);
// 获取会话信息
const isGroupChat = conversation.type === 'group';
const chatTitle = isGroupChat
? conversation.group?.name || '群聊'
: conversation.participants?.[0]?.nickname || conversation.participants?.[0]?.username || '聊天';
const chatAvatar = isGroupChat
? conversation.group?.avatar
: conversation.participants?.[0]?.avatar;
// 加载消息
const loadMessages = useCallback(async () => {
try {
setLoading(true);
await messageManager.fetchMessages(String(conversation.id));
} catch (error: any) {
// 检查是否是权限错误或会话不存在错误
const errorMessage = error?.message || String(error);
if (
errorMessage.includes('not found') ||
errorMessage.includes('no permission') ||
error?.code === 'NOT_FOUND' ||
error?.code === 'FORBIDDEN'
) {
console.warn('[EmbeddedChat] 会话不存在或无权限访问:', conversation.id);
} else {
console.error('[EmbeddedChat] 加载消息失败:', error);
}
} finally {
setLoading(false);
}
}, [conversation.id]);
// 初始加载
useEffect(() => {
loadMessages();
}, [conversation.id, loadMessages]);
// 自动标记已读 - 当有新消息时
useEffect(() => {
if (messages.length === 0) return;
const maxSeq = Math.max(...messages.map(m => m.seq), 0);
if (maxSeq > 0) {
markAsRead(maxSeq).catch(error => {
console.error('[EmbeddedChat] 自动标记已读失败:', error);
});
}
}, [messages, markAsRead]);
// 发送消息
const handleSend = useCallback(async () => {
if (!inputText.trim() || sending) return;
const text = inputText.trim();
setInputText('');
try {
setSending(true);
const segments: MessageSegment[] = [{ type: 'text', data: { text } }];
await messageManager.sendMessage(String(conversation.id), segments);
} catch (error) {
console.error('[EmbeddedChat] 发送消息失败:', error);
} finally {
setSending(false);
}
}, [inputText, sending, conversation.id]);
// 导航到完整聊天页面
const handleNavigateToFullChat = () => {
if (isGroupChat && conversation.group) {
router.push(
hrefs.hrefChat({
conversationId: String(conversation.id),
groupId: String(conversation.group.id),
groupName: conversation.group.name,
isGroupChat: true,
}) as any
);
} else {
const currentUserId = currentUser?.id;
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
router.push(
hrefs.hrefChat({
conversationId: String(conversation.id),
userId: otherUser ? String(otherUser.id) : undefined,
isGroupChat: false,
}) as any
);
}
};
// 处理图片点击预览
const handleImagePress = (images: { url: string }[], index: number) => {
setGalleryImages(images);
setCurrentImageIndex(index);
setImageGalleryVisible(true);
};
// 处理右键点击消息
const handleMessageRightPress = (message: MessageResponse, event: any) => {
// 获取鼠标右键位置
const pageX = event?.nativeEvent?.pageX || 0;
const pageY = event?.nativeEvent?.pageY || 0;
setSelectedMessageForMenu(message as GroupMessage);
setContextMenuPosition({
x: pageX,
y: pageY,
width: 0,
height: 0,
pressX: pageX,
pressY: pageY,
});
setContextMenuVisible(true);
};
// 关闭右键菜单
const handleContextMenuClose = () => {
setContextMenuVisible(false);
setSelectedMessageForMenu(null);
setContextMenuPosition(undefined);
};
// 处理回复消息
const handleReply = (message: GroupMessage) => {
setReplyingToMessage(message);
handleContextMenuClose();
};
// 处理撤回消息
const handleRecall = async (messageId: string) => {
try {
await messageService.recallMessage(messageId);
handleContextMenuClose();
} catch (error) {
console.error('[EmbeddedChat] 撤回消息失败:', error);
}
};
// 处理删除消息
const handleDeleteMessage = async (messageId: string) => {
try {
await messageService.deleteMessage(messageId);
handleContextMenuClose();
} catch (error) {
console.error('[EmbeddedChat] 删除消息失败:', error);
}
};
// 渲染消息内容
const renderMessageContent = (item: MessageResponse) => {
// 撤回消息
if (item.status === 'recalled') {
return (
<Text style={[styles.messageText, styles.messageTextRecalled]}>
</Text>
);
}
// 获取消息 segments
const segments = item.segments || [];
// 检查是否有图片消息
const imageSegments = segments
.filter(s => s.type === 'image')
.map(s => s.data as ImageSegmentData);
// 提取文本内容
const textContent = extractTextFromSegments(segments);
// 渲染多个内容(文本 + 图片)
return (
<View>
{/* 文本内容 */}
{textContent && textContent !== '[图片]' && (
<Text style={[styles.messageText, item.sender_id === String(currentUser?.id) ? styles.messageTextMe : styles.messageTextOther]}>
{textContent}
</Text>
)}
{/* 图片内容 */}
{imageSegments.length > 0 && (
<View style={styles.imageGrid}>
{imageSegments.map((imgData, idx) => {
const imageUrl = imgData.thumbnail_url || imgData.url;
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
if (!hasValidUrl) {
// 无效URL显示占位符
return (
<View key={`img-${item.id}-${idx}`} style={styles.imagePlaceholder}>
<MaterialCommunityIcons name="image" size={24} color="#999" />
<Text style={styles.imagePlaceholderText}></Text>
</View>
);
}
// 计算图片尺寸
let width = 180;
let height = 180;
if (imgData.width && imgData.height) {
const aspectRatio = imgData.width / imgData.height;
if (aspectRatio > 1) {
height = width / aspectRatio;
} else {
width = height * aspectRatio;
}
}
return (
<TouchableOpacity
key={`img-${item.id}-${idx}`}
onPress={() => handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)}
activeOpacity={0.8}
>
<ExpoImage
source={{ uri: imageUrl }}
style={[styles.messageImage, { width, height: Math.round(height) }]}
contentFit="cover"
cachePolicy="disk"
/>
</TouchableOpacity>
);
})}
</View>
)}
{/* 如果没有文本也没有图片,显示空消息提示 */}
{!textContent && imageSegments.length === 0 && (
<Text style={[styles.messageText, styles.messageTextRecalled, { opacity: 0.5 }]}>
[]
</Text>
)}
</View>
);
};
// 渲染消息
const renderMessage = ({ item, index }: { item: MessageResponse; index: number }) => {
const isMe = String(item.sender_id) === String(currentUser?.id);
const showTime = shouldShowTime(index);
// 系统通知消息特殊处理
// 1. is_system_notice 字段WebSocket 推送时设置)
// 2. category === 'notification'(从数据库加载时使用)
// 3. sender_id === '10000'(系统发送者兜底)
const isSystemNotice = (item as any).is_system_notice || item.category === 'notification' || item.sender_id === '10000';
// 系统通知消息渲染(如"某某加入了群聊"
if (isSystemNotice) {
return (
<View>
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(item.created_at)}
</Text>
</View>
)}
<View style={styles.systemNoticeContainer}>
<Text style={styles.systemNoticeText}>
{(item as any).notice_content || extractTextFromSegments(item.segments)}
</Text>
</View>
</View>
);
}
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
const handlePointerDown = (e: any) => {
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
const isRightClick = e.nativeEvent?.button === 2;
if (isRightClick) {
// 阻止默认右键菜单
e.preventDefault?.();
e.stopPropagation?.();
handleMessageRightPress(item, e);
}
};
return (
<View>
{/* 时间分隔 */}
{showTime && (
<View style={styles.timeContainer}>
<Text style={styles.timeText}>
{formatTime(item.created_at)}
</Text>
</View>
)}
<View
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
// @ts-ignore - React Native for Web 支持 pointer events
onPointerDown={handlePointerDown}
>
{/* 对方头像(左侧) */}
{!isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={item.sender?.avatar}
size={36}
name={item.sender?.nickname || item.sender?.username}
/>
</View>
)}
{/* 消息内容区域 */}
<View style={styles.messageContent}>
{/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */}
{isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{renderMessageContent(item)}
</View>
</View>
{/* 自己的头像(右侧) */}
{isMe && (
<View style={styles.avatarWrapper}>
<Avatar
source={currentUser?.avatar}
size={36}
name={currentUser?.nickname || currentUser?.username}
/>
</View>
)}
</View>
</View>
);
};
return (
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
{/* 大屏幕(>= 768px时隐藏返回按钮 */}
{!isWideScreen ? (
<AppBackButton onPress={onBack} style={styles.headerButton} iconColor={colors.text.secondary} />
) : (
<View style={styles.headerButton} />
)}
<View style={styles.headerCenter}>
<Avatar source={chatAvatar} size={36} name={chatTitle} />
<Text style={styles.headerTitle} numberOfLines={1}>
{chatTitle}
</Text>
</View>
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
{isWideScreen && isGroupChat ? (
<TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}>
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.secondary} />
</TouchableOpacity>
) : (
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-expand" size={22} color={colors.text.secondary} />
</TouchableOpacity>
)}
</View>
{/* 消息列表 */}
<View style={styles.messageList}>
{loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : messages.length === 0 ? (
<View style={styles.centerContainer}>
<MaterialCommunityIcons name="message-text-outline" size={48} color={colors.text.disabled} />
<Text style={styles.emptyText}></Text>
<Text style={styles.emptySubtext}></Text>
</View>
) : (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={true}
onLayout={() => {
if (messages.length > 0) {
flatListRef.current?.scrollToEnd({ animated: false });
}
}}
/>
)}
</View>
{/* 输入框区域 */}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
>
<View style={styles.inputArea}>
<View style={styles.inputRow}>
<TouchableOpacity style={styles.iconButton} onPress={toggleMorePanel}>
<MaterialCommunityIcons
name={showMorePanel ? "close-circle-outline" : "plus-circle-outline"}
size={28}
color={showMorePanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<View style={styles.inputContainer}>
<TextInput
ref={inputRef}
style={styles.textInput}
placeholder="发送消息..."
placeholderTextColor={colors.text.hint}
value={inputText}
onChangeText={setInputText}
multiline={false}
returnKeyType="send"
onSubmitEditing={handleSend}
blurOnSubmit={false}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
</View>
<TouchableOpacity style={styles.iconButton} onPress={toggleEmojiPanel}>
<MaterialCommunityIcons
name={showEmojiPanel ? "emoticon-happy" : "emoticon-outline"}
size={28}
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity
style={[styles.sendButton, (!inputText.trim() || sending) && styles.sendButtonDisabled]}
onPress={handleSend}
disabled={!inputText.trim() || sending}
>
{sending ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<MaterialCommunityIcons name="send" size={20} color={colors.text.inverse} />
)}
</TouchableOpacity>
</View>
</View>
{/* 表情面板 */}
{showEmojiPanel && (
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
<EmojiPanel
onInsertEmoji={handleInsertEmoji}
onInsertSticker={handleSendSticker}
onClose={closePanels}
/>
</View>
)}
{/* 更多功能面板 */}
{showMorePanel && (
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
<MorePanel
onAction={handleMoreAction}
/>
</View>
)}
</KeyboardAvoidingView>
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
{isWideScreen && isGroupChat && (
<>
{/* 遮罩层 */}
{showGroupInfoPanel && (
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{
backgroundColor: 'rgba(0, 0, 0, 0.3)',
opacity: fadeAnim,
zIndex: 100,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
onPress={toggleGroupInfoPanel}
activeOpacity={1}
/>
</Animated.View>
)}
{/* 侧边栏面板 */}
<Animated.View
style={[
{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: PANEL_WIDTH,
backgroundColor: '#FFF',
zIndex: 101,
...shadows.lg,
transform: [{ translateX: slideAnim }],
},
]}
>
<GroupInfoPanel
visible={showGroupInfoPanel}
groupId={conversation.group ? String(conversation.group.id) : undefined}
groupInfo={conversation.group || null}
currentUserId={currentUser?.id}
onClose={toggleGroupInfoPanel}
onTogglePin={(pinned) => {
// TODO: 实现置顶功能
console.log('Toggle pin:', pinned);
}}
onLeaveGroup={() => {
// TODO: 实现退出群聊功能
console.log('Leave group');
}}
onInviteMembers={() => {
// TODO: 实现邀请新成员功能
console.log('Invite members');
}}
onViewAllMembers={() => {
// TODO: 实现查看全部成员功能
console.log('View all members');
}}
/>
</Animated.View>
</>
)}
{/* 右键菜单 */}
<LongPressMenu
visible={contextMenuVisible}
message={selectedMessageForMenu}
currentUserId={currentUser?.id || ''}
position={contextMenuPosition}
onClose={handleContextMenuClose}
onReply={handleReply}
onRecall={handleRecall}
onDelete={handleDeleteMessage}
/>
{/* 图片查看器 */}
<ImageGallery
visible={imageGalleryVisible}
images={galleryImages.map((img, idx) => ({
id: String(idx),
url: img.url || ''
}))}
initialIndex={currentImageIndex}
onClose={() => setImageGalleryVisible(false)}
enableSave
/>
</View>
);
};