/** * 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 = ({ 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(null); const inputRef = useRef(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(); const [selectedMessageForMenu, setSelectedMessageForMenu] = useState(null); // 回复状态 const [replyingToMessage, setReplyingToMessage] = useState(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([]); // 格式化时间 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 ( 消息已撤回 ); } // 获取消息 segments const segments = item.segments || []; // 检查是否有图片消息 const imageSegments = segments .filter(s => s.type === 'image') .map(s => s.data as ImageSegmentData); // 提取文本内容 const textContent = extractTextFromSegments(segments); // 渲染多个内容(文本 + 图片) return ( {/* 文本内容 */} {textContent && textContent !== '[图片]' && ( {textContent} )} {/* 图片内容 */} {imageSegments.length > 0 && ( {imageSegments.map((imgData, idx) => { const imageUrl = imgData.thumbnail_url || imgData.url; const hasValidUrl = !!imageUrl && imageUrl.trim() !== ''; if (!hasValidUrl) { // 无效URL显示占位符 return ( 图片 ); } // 计算图片尺寸 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 ( handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)} activeOpacity={0.8} > ); })} )} {/* 如果没有文本也没有图片,显示空消息提示 */} {!textContent && imageSegments.length === 0 && ( [消息格式错误] )} ); }; // 渲染消息 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 ( {showTime && ( {formatTime(item.created_at)} )} {(item as any).notice_content || extractTextFromSegments(item.segments)} ); } // 处理指针事件(鼠标右键)- 使用 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 ( {/* 时间分隔 */} {showTime && ( {formatTime(item.created_at)} )} {/* 对方头像(左侧) */} {!isMe && ( )} {/* 消息内容区域 */} {/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */} {isGroupChat && !isMe && ( {item.sender?.nickname || item.sender?.username} )} {renderMessageContent(item)} {/* 自己的头像(右侧) */} {isMe && ( )} ); }; return ( {/* 头部 */} {/* 大屏幕(>= 768px)时隐藏返回按钮 */} {!isWideScreen ? ( ) : ( )} {chatTitle} {/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */} {isWideScreen && isGroupChat ? ( ) : ( )} {/* 消息列表 */} {loading ? ( ) : messages.length === 0 ? ( 暂无消息 发送第一条消息开始聊天 ) : ( String(item.id)} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={true} onLayout={() => { if (messages.length > 0) { flatListRef.current?.scrollToEnd({ animated: false }); } }} /> )} {/* 输入框区域 */} {sending ? ( ) : ( )} {/* 表情面板 */} {showEmojiPanel && ( )} {/* 更多功能面板 */} {showMorePanel && ( )} {/* 群信息侧边栏面板 - 仅大屏幕显示 */} {isWideScreen && isGroupChat && ( <> {/* 遮罩层 */} {showGroupInfoPanel && ( )} {/* 侧边栏面板 */} { // 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'); }} /> )} {/* 右键菜单 */} {/* 图片查看器 */} ({ id: String(idx), url: img.url || '' }))} initialIndex={currentImageIndex} onClose={() => setImageGalleryVisible(false)} enableSave /> ); };