From 4f31926eb5373a78c2e76fc9dec62acbb6436553 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 13 Apr 2026 01:52:43 +0800 Subject: [PATCH] feat(message): add embedded mode to ChatScreen for desktop dual-column layout Replace EmbeddedChat with unified ChatScreen supporting embedded mode through props. Pass conversation data, layout constraints, and navigation callbacks directly to ChatScreen instead of using separate embedded component. Consolidates dual-column layout logic into single component with conditional rendering based on isEmbedded prop. --- src/screens/message/ChatScreen.tsx | 33 +- src/screens/message/MessageListScreen.tsx | 14 +- .../components/ChatScreen/ChatHeader.tsx | 1 + .../message/components/ChatScreen/types.ts | 13 +- .../components/ChatScreen/useChatScreen.ts | 17 +- .../message/components/EmbeddedChat.tsx | 1053 ----------------- src/screens/profile/DataStorageScreen.tsx | 66 +- src/stores/chatSettingsStore.ts | 29 +- 8 files changed, 131 insertions(+), 1095 deletions(-) delete mode 100644 src/screens/message/components/EmbeddedChat.tsx diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 64c3537..ee7a732 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -49,9 +49,10 @@ import { ChatInput, GroupInfoPanel, PANEL_HEIGHTS, + ChatScreenProps, } from './components/ChatScreen'; -export const ChatScreen: React.FC = () => { +export const ChatScreen: React.FC = (props) => { const navigation = useNavigation(); const router = useRouter(); const insets = useSafeAreaInsets(); @@ -62,12 +63,12 @@ export const ChatScreen: React.FC = () => { // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); - // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页 + // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页(嵌入式模式下不跳转) useEffect(() => { - if (isWideScreen) { + if (isWideScreen && !props.isEmbedded) { router.replace(hrefs.hrefHome()); } - }, [isWideScreen, router]); + }, [isWideScreen, router, props.isEmbedded]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); @@ -88,20 +89,20 @@ export const ChatScreen: React.FC = () => { const containerStyle = useMemo(() => ([ styles.container, - isWideScreen ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null, - ]), [isWideScreen, styles.container]); + isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null, + ]), [isWideScreen, styles.container, props.isEmbedded]); const listContentStyle = useMemo(() => ([ styles.listContent, - isWideScreen + isWideScreen && !props.isEmbedded ? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const } : { paddingHorizontal: 16 }, - ]), [isWideScreen, styles.listContent]); + ]), [isWideScreen, styles.listContent, props.isEmbedded]); const inputWrapperStyle = useMemo(() => ([ styles.inputWrapper, - isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null, - ]), [isWideScreen, styles.inputWrapper]); + isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null, + ]), [isWideScreen, styles.inputWrapper, props.isEmbedded]); // 图片查看器状态 const [showImageViewer, setShowImageViewer] = useState(false); @@ -208,7 +209,7 @@ export const ChatScreen: React.FC = () => { handleReachLatestEdge, jumpToLatestMessages, setBrowsingHistory, - } = useChatScreen(); + } = useChatScreen(props); const displayMessages = useMemo(() => [...messages].reverse(), [messages]); const longPressMenuMemberMap = useMemo(() => { @@ -412,22 +413,22 @@ export const ChatScreen: React.FC = () => { }, [handleMessageListContentSizeChange]); return ( - + - + {!props.isEmbedded && } {/* 顶部栏 */} router.back()} + onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => router.back()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} onGroupInfoPress={handleGroupInfoPress} diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 2d050b5..b297ffa 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -54,8 +54,8 @@ import { import { Avatar, Text, EmptyState, ResponsiveContainer, AppBackButton } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; import * as hrefs from '../../navigation/hrefs'; -// 导入 EmbeddedChat 组件用于桌面端双栏布局 -import { EmbeddedChat } from './components/EmbeddedChat'; +// 导入 ChatScreen 组件用于桌面端双栏布局 +import { ChatScreen } from './ChatScreen'; import { ConversationListRow } from './components/ConversationListRow'; // 导入 NotificationsScreen 用于在内部显示 import { NotificationsScreen } from './NotificationsScreen'; @@ -852,9 +852,13 @@ export const MessageListScreen: React.FC = () => { setShowNotifications(false)} /> ) : selectedConversation ? ( // 显示选中会话的聊天内容 - setSelectedConversation(null)} + setSelectedConversation(null)} /> ) : ( // 默认占位符 diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 0f2ca7a..c02e718 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -51,6 +51,7 @@ export const ChatHeader: React.FC = ({ header: { ...baseStyles.header, paddingHorizontal: isWideScreen ? spacing.xl : spacing.md, + height: isWideScreen ? 60 : 52, }, headerName: { ...baseStyles.headerName, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index a33f891..e501e62 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -62,7 +62,18 @@ export interface MenuItem { // ChatScreen Props export interface ChatScreenProps { - // 路由参数通过 useRoute 获取 + /** 嵌入式模式:外部传入的会话 ID(优先于路由参数) */ + embeddedConversationId?: string; + /** 嵌入式模式:是否为群聊 */ + embeddedIsGroupChat?: boolean; + /** 嵌入式模式:群组 ID */ + embeddedGroupId?: string; + /** 嵌入式模式:群组名称 */ + embeddedGroupName?: string; + /** 嵌入式模式:返回回调 */ + onEmbeddedBack?: () => void; + /** 嵌入式模式:是否为嵌入式布局 */ + isEmbedded?: boolean; } // 消息气泡 Props diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index af849df..466c5e6 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -40,11 +40,12 @@ import { ChatRouteParams, MenuPosition, PendingChatAttachment, + ChatScreenProps, } from './types'; import { TIME_SEPARATOR_INTERVAL, MEMBERS_PAGE_SIZE, MAX_PENDING_CHAT_IMAGES } from './constants'; import { messageRepository } from '@/database'; -export const useChatScreen = () => { +export const useChatScreen = (props?: ChatScreenProps) => { const getSendErrorMessage = useCallback((error: unknown, fallback: string) => { if (error instanceof ApiError && error.message) { return error.message; @@ -60,7 +61,17 @@ export const useChatScreen = () => { groupName?: string | string[]; }>(); // 路由参数(动态段 + query);群 ID 勿用 Number(),避免超过 2^53-1 时精度丢失 + // 嵌入式模式下优先使用 props,否则从路由参数获取 const routeParams = useMemo(() => { + if (props?.embeddedConversationId) { + return { + conversationId: props.embeddedConversationId, + userId: null as string | null, + isGroupChat: props.embeddedIsGroupChat ?? false, + groupId: props.embeddedGroupId ?? null, + groupName: props.embeddedGroupName ?? null, + }; + } const isGroupFlag = firstRouteParam(rawParams.isGroupChat); return { conversationId: firstRouteParam(rawParams.conversationId) ?? null, @@ -70,6 +81,10 @@ export const useChatScreen = () => { groupName: firstRouteParam(rawParams.groupName), }; }, [ + props?.embeddedConversationId, + props?.embeddedIsGroupChat, + props?.embeddedGroupId, + props?.embeddedGroupName, rawParams.conversationId, rawParams.userId, rawParams.isGroupChat, diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx deleted file mode 100644 index 9397b5a..0000000 --- a/src/screens/message/components/EmbeddedChat.tsx +++ /dev/null @@ -1,1053 +0,0 @@ -/** - * 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 embeddedMemberMap = useMemo(() => { - const map = new Map(); - groupMembers.forEach(m => { - map.set(m.user_id, { - nickname: m.nickname || m.user?.nickname || '用户', - user_id: m.user_id, - }); - }); - return map; - }, [groupMembers]); - - // 格式化时间 - 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} - scrollEnabled={true} - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - 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 - /> - - ); -}; diff --git a/src/screens/profile/DataStorageScreen.tsx b/src/screens/profile/DataStorageScreen.tsx index d8f95a8..b490251 100644 --- a/src/screens/profile/DataStorageScreen.tsx +++ b/src/screens/profile/DataStorageScreen.tsx @@ -11,10 +11,13 @@ import { Alert, ScrollView, ActivityIndicator, + Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Image } from 'expo-image'; +import { Directory, File, Paths } from 'expo-file-system'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; import { Text } from '../../components/common'; import { useResponsive, useResponsiveSpacing } from '../../hooks'; @@ -47,11 +50,48 @@ export const DataStorageScreen: React.FC = () => { const [cacheStats, setCacheStats] = useState(null); const [asyncStorageSize, setAsyncStorageSize] = useState(0); + const [expoImageCacheSize, setExpoImageCacheSize] = useState(0); const [loading, setLoading] = useState(true); const [clearing, setClearing] = useState(false); const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; + const loadExpoImageCacheSize = useCallback(async (): Promise => { + if (Platform.OS === 'web') return 0; + try { + const cacheDir = Paths.cache; + const sdImageCacheDir = new Directory(cacheDir, 'defaultDiskCache'); + const expoImageCacheDir = new Directory(cacheDir, 'expo-image'); + + const scanDirectorySize = async (dir: Directory): Promise => { + let size = 0; + try { + if (!(await dir.exists)) return 0; + const items = dir.list(); + for (const item of items) { + try { + if (item instanceof File) { + const info = await item.info(); + if (info.exists && info.size) { + size += info.size; + } + } else if (item instanceof Directory) { + size += await scanDirectorySize(item); + } + } catch {} + } + } catch {} + return size; + }; + + const totalSize = await scanDirectorySize(sdImageCacheDir) + await scanDirectorySize(expoImageCacheDir); + return totalSize; + } catch (error) { + console.warn('读取 expo-image 缓存大小失败:', error); + return 0; + } + }, []); + const loadStats = useCallback(async () => { try { setLoading(true); @@ -59,6 +99,9 @@ export const DataStorageScreen: React.FC = () => { const stats = mediaCacheManager.getCacheStats(); setCacheStats(stats); + const imageCacheSize = await loadExpoImageCacheSize(); + setExpoImageCacheSize(imageCacheSize); + const keys = await AsyncStorage.getAllKeys(); if (keys.length > 0) { const items = await AsyncStorage.multiGet(keys); @@ -77,7 +120,7 @@ export const DataStorageScreen: React.FC = () => { } finally { setLoading(false); } - }, []); + }, [loadExpoImageCacheSize]); useEffect(() => { loadStats(); @@ -86,7 +129,7 @@ export const DataStorageScreen: React.FC = () => { const handleClearCache = () => { Alert.alert( '清除缓存', - '确定要清除所有媒体缓存吗?此操作不可撤销。', + '确定要清除所有缓存吗?此操作不可撤销。', [ { text: '取消', style: 'cancel' }, { @@ -96,6 +139,8 @@ export const DataStorageScreen: React.FC = () => { try { setClearing(true); await mediaCacheManager.clearAll(); + await Image.clearDiskCache(); + await Image.clearMemoryCache(); await loadStats(); Alert.alert('完成', '缓存已清除'); } catch (error) { @@ -139,28 +184,27 @@ export const DataStorageScreen: React.FC = () => { ); }; - const totalSize = (cacheStats?.totalSize ?? 0) + asyncStorageSize; + const totalSize = (cacheStats?.totalSize ?? 0) + asyncStorageSize + expoImageCacheSize; const storageItems: StorageStatItem[] = useMemo(() => { - if (!cacheStats) return []; return [ { icon: 'image-outline', label: '图片缓存', - value: cacheStats.imageCount > 0 ? `${cacheStats.imageCount} 个文件` : '暂无', - count: cacheStats.imageCount, + value: expoImageCacheSize > 0 ? `${formatBytes(expoImageCacheSize)}` : cacheStats && cacheStats.imageCount > 0 ? `${cacheStats.imageCount} 个文件` : '暂无', + count: expoImageCacheSize > 0 ? undefined : cacheStats?.imageCount, }, { icon: 'video-outline', label: '视频缓存', - value: cacheStats.videoCount > 0 ? `${cacheStats.videoCount} 个文件` : '暂无', - count: cacheStats.videoCount, + value: cacheStats && cacheStats.videoCount > 0 ? `${cacheStats.videoCount} 个文件` : '暂无', + count: cacheStats?.videoCount, }, { icon: 'music-note-outline', label: '音频缓存', - value: cacheStats.audioCount > 0 ? `${cacheStats.audioCount} 个文件` : '暂无', - count: cacheStats.audioCount, + value: cacheStats && cacheStats.audioCount > 0 ? `${cacheStats.audioCount} 个文件` : '暂无', + count: cacheStats?.audioCount, }, { icon: 'file-document-outline', @@ -168,7 +212,7 @@ export const DataStorageScreen: React.FC = () => { value: asyncStorageSize > 0 ? formatBytes(asyncStorageSize) : '暂无', }, ]; - }, [cacheStats, asyncStorageSize]); + }, [cacheStats, asyncStorageSize, expoImageCacheSize]); const renderContent = () => { if (loading) { diff --git a/src/stores/chatSettingsStore.ts b/src/stores/chatSettingsStore.ts index 0b9bf06..37c8d28 100644 --- a/src/stores/chatSettingsStore.ts +++ b/src/stores/chatSettingsStore.ts @@ -11,19 +11,32 @@ import { useShallow } from 'zustand/react/shallow'; // 主题配置 - 扩展包含更多颜色 export const CHAT_THEMES = [ { - id: 'default', + id: 'white', + name: '纯白', + primary: '#FF6B35', + secondary: '#F0F2F5', + bubble: '#E8E8E8', + icon: '🤍', + card: '#FFFFFF', + inputBg: '#FFFFFF', + panelBg: '#F5F5F5', + headerBg: '#FFFFFF', + textPrimary: '#1A1A1A', + textSecondary: '#666666', + }, + { + id: 'green', name: '默认绿', primary: '#4CAF50', secondary: '#E8F5E9', bubble: '#DCF8C6', icon: '🏠', - // 扩展颜色 - card: '#FFFFFF', // 卡片背景 - inputBg: '#FFFFFF', // 输入框背景 - panelBg: '#F5F5F5', // 面板背景 - headerBg: '#FFFFFF', // 头部背景 - textPrimary: '#1A1A1A', // 主要文字 - textSecondary: '#666666', // 次要文字 + card: '#FFFFFF', + inputBg: '#FFFFFF', + panelBg: '#F5F5F5', + headerBg: '#FFFFFF', + textPrimary: '#1A1A1A', + textSecondary: '#666666', }, { id: 'yellow',