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',