/** * 聊天页 ChatScreen * 威友 - 私信/群聊聊天界面 * 高级现代化设计 * 支持群聊功能:显示发送者头像和昵称、@提及功能 * 支持响应式布局(桌面端宽屏优化) * * 重构说明:将原2264行的大文件拆分为多个模块化组件 * - types.ts: 类型定义 * - constants.ts: 常量定义 * - styles.ts: 样式定义 * - useChatScreen.ts: 自定义Hook,管理所有状态和逻辑 * - EmojiPanel.tsx: 表情面板组件 * - MorePanel.tsx: 更多功能面板组件 * - MentionPanel.tsx: @成员选择 面板组件 * - LongPressMenu.tsx: 长按菜单组件 * - ChatHeader.tsx: 聊天头部组件 * - MessageBubble.tsx: 消息气泡组件 * - ChatInput.tsx: 输入框组件 */ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, ActivityIndicator, KeyboardAvoidingView, Platform, TouchableOpacity, BackHandler, } from 'react-native'; import { FlashList, ListRenderItem } from '@shopify/flash-list'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { useNavigation, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text, ImageGallery, ImageGridItem } from '../../components/common'; import { useAppColors, useResolvedColorScheme } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; import { messageManager, callStore } from '../../stores'; import { useBreakpointGTE } from '../../hooks/useResponsive'; import { useChatScreen, useChatScreenStyles, EmojiPanel, MorePanel, MentionPanel, LongPressMenu, ChatHeader, MessageBubble, ChatInput, GroupInfoPanel, PANEL_HEIGHTS, ChatScreenProps, } from './components/ChatScreen'; import { GroupMessage } from './components/ChatScreen/types'; export const ChatScreen: React.FC = (props) => { const navigation = useNavigation(); const router = useRouter(); const insets = useSafeAreaInsets(); const colors = useAppColors(); const resolved = useResolvedColorScheme(); const styles = useChatScreenStyles(); // 响应式布局 const isWideScreen = useBreakpointGTE('lg'); // 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页(嵌入式模式下不跳转) useEffect(() => { if (isWideScreen && !props.isEmbedded) { router.replace(hrefs.hrefHome()); } }, [isWideScreen, router, props.isEmbedded]); // 输入框区域高度(用于定位浮动 mention 面板) const [inputWrapperHeight, setInputWrapperHeight] = useState(60); const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false); const replyTargetMessageIdRef = useRef(null); const replyHighlightTimerRef = useRef | null>(null); const isPreloadingRef = useRef(false); const lastScrollYRef = useRef(0); const isUserDraggingRef = useRef(false); const historyAnchorRef = useRef<{ active: boolean; beforeY: number; beforeHeight: number }>({ active: false, beforeY: 0, beforeHeight: 0, }); const preloadCooldownTimerRef = useRef | null>(null); const showJumpToLatestRef = useRef(false); const [showJumpToLatest, setShowJumpToLatest] = useState(false); const containerStyle = useMemo(() => ([ styles.container, isWideScreen && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null, ]), [isWideScreen, styles.container, props.isEmbedded]); const listContentStyle = useMemo(() => ({ paddingHorizontal: isWideScreen && !props.isEmbedded ? 24 : 16, ...(isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const } : {}), paddingTop: 12, paddingBottom: 24, }), [isWideScreen, props.isEmbedded]); const inputWrapperStyle = useMemo(() => ([ 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); const [chatImages, setChatImages] = useState([]); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // 图片点击处理函数 const handleImagePress = (images: ImageGridItem[], index: number) => { setChatImages(images); setSelectedImageIndex(index); setShowImageViewer(true); }; // 关闭图片查看器 const handleCloseImageViewer = () => { setShowImageViewer(false); }; // 群信息面板状态 const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false); // 打开群信息面板 const handleGroupInfoPress = useCallback(() => { setShowGroupInfoPanel(true); }, []); // 关闭群信息面板 const handleCloseGroupInfoPanel = useCallback(() => { setShowGroupInfoPanel(false); }, []); const { // 状态 messages, inputText, otherUser, currentUser, currentUserId, keyboardHeight, loading, isComposerBusy, activePanel, sendingImage, uploadingAttachments, pendingAttachments, replyingTo, longPressMenuVisible, selectedMessage, selectedMessageId, setSelectedMessageId, menuPosition, isGroupChat, groupInfo, groupMembers, currentUserRole, mentionQuery, isMuted, muteAll, followRestrictionHint, canSendPrivateImage, routeGroupId, routeGroupName, otherUserLastReadSeq, messageMap, loadingMore, hasMoreHistory, conversationId, // Refs flatListRef, textInputRef, scrollPositionRef, // 方法 formatTime, shouldShowTime, handleInputChange, handleSend, removePendingAttachment, handleMoreAction, handleInsertEmoji, handleSendSticker, toggleEmojiPanel, toggleMorePanel, closePanel, handleRecall, handleLongPressMessage, hideLongPressMenu, handleDeleteMessage, handleReplyMessage, handleCancelReply, handleAvatarPress, handleAvatarLongPress, handleSelectMention, handleMentionAll, getSenderInfo, getTypingHint, getInputBottom, handleDismiss, navigateToInfo, navigateToChatSettings, loadMoreHistory, handleMessageListContentSizeChange, handleReachLatestEdge, jumpToLatestMessages, setBrowsingHistory, } = useChatScreen(props); const displayMessages = useMemo(() => [...messages].reverse(), [messages]); const longPressMenuMemberMap = 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]); useEffect(() => { if (!loadingMore && showEdgeLoadingIndicator) { setShowEdgeLoadingIndicator(false); } }, [loadingMore, showEdgeLoadingIndicator]); useEffect(() => { if (loading) { showJumpToLatestRef.current = false; setShowJumpToLatest(false); } }, [loading]); useEffect(() => { showJumpToLatestRef.current = false; setShowJumpToLatest(false); }, [conversationId]); useEffect(() => { return () => { if (preloadCooldownTimerRef.current) { clearTimeout(preloadCooldownTimerRef.current); } if (replyHighlightTimerRef.current) { clearTimeout(replyHighlightTimerRef.current); } }; }, []); const handleReplyPreviewPress = useCallback((messageId: string) => { const targetId = String(messageId); const targetIndex = displayMessages.findIndex(msg => String(msg.id) === targetId); if (targetIndex < 0) return; replyTargetMessageIdRef.current = targetId; setBrowsingHistory(true); flatListRef.current?.scrollToIndex({ index: targetIndex, animated: true, viewPosition: 0.5, }); }, [displayMessages, flatListRef, setBrowsingHistory]); // 监听返回事件:面板/键盘打开时先关闭它们,否则刷新会话列表后返回 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', (e) => { const hasPanelOpen = activePanel !== 'none'; const hasKeyboardOpen = keyboardHeight > 0; if (hasPanelOpen || hasKeyboardOpen) { e.preventDefault(); if (hasKeyboardOpen) { handleDismiss(); } else { closePanel(); } return; } // 真正返回时刷新会话列表 messageManager.refreshConversations(true, 'chat-before-remove'); }); return unsubscribe; }, [navigation, activePanel, keyboardHeight, closePanel, handleDismiss]); // Android 物理返回键:面板/键盘打开时先关闭它们 useEffect(() => { const backHandler = BackHandler.addEventListener('hardwareBackPress', () => { const hasPanelOpen = activePanel !== 'none'; const hasKeyboardOpen = keyboardHeight > 0; if (hasPanelOpen || hasKeyboardOpen) { if (hasKeyboardOpen) { handleDismiss(); } else { closePanel(); } return true; // 阻止默认返回行为 } return false; }); return () => backHandler.remove(); }, [activePanel, keyboardHeight, closePanel, handleDismiss]); // 渲染消息气泡 const messageIndexMap = useMemo(() => { const map = new Map(); messages.forEach((msg, idx) => { map.set(String(msg.id), idx); }); return map; }, [messages]); const renderMessage = useCallback>(({ item, index }) => { // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; return ( ); }, [ messageIndexMap, currentUserId, currentUser, otherUser, isGroupChat, groupMembers, otherUserLastReadSeq, selectedMessageId, messageMap, handleLongPressMessage, handleAvatarPress, handleAvatarLongPress, formatTime, shouldShowTime, handleImagePress, handleReplyMessage, handleReplyPreviewPress, ]); const keyExtractor = useCallback((item: GroupMessage) => String(item.id), []); // 获取正在输入提示 const typingHint = getTypingHint(); const handleMessageListScroll = useCallback((event: any) => { const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { contentHeight: contentSize.height, scrollY: contentOffset.y, viewportHeight: layoutMeasurement.height, }; // inverted:offset 越大离最新消息端越远,与 setBrowsingHistory(120) 阈值一致 const shouldShowJump = contentOffset.y > 120 && !loading && messages.length > 0; if (showJumpToLatestRef.current !== shouldShowJump) { showJumpToLatestRef.current = shouldShowJump; setShowJumpToLatest(shouldShowJump); } // 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底 if (contentOffset.y > 120) { setBrowsingHistory(true); } // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; if ( isUserDraggingRef.current && !loadingMore && isScrollingTowardLatest && contentOffset.y <= 40 ) { handleReachLatestEdge(); } // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); lastScrollYRef.current = contentOffset.y; // 预加载阈值:按屏幕高度动态提前(至少 420),确保边界前开始加载 const PRELOAD_HISTORY_THRESHOLD = Math.max(420, layoutMeasurement.height * 1.2); // 仅在真正接近边界时才显示旋转提示 const EDGE_LOADING_INDICATOR_THRESHOLD = 60; const shouldShowEdgeIndicator = loadingMore && distanceToHistoryEdge <= EDGE_LOADING_INDICATOR_THRESHOLD; if (shouldShowEdgeIndicator !== showEdgeLoadingIndicator) { setShowEdgeLoadingIndicator(shouldShowEdgeIndicator); } if ( distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && hasMoreHistory && !loadingMore && !loading && !isPreloadingRef.current ) { isPreloadingRef.current = true; loadMoreHistory().finally(() => { if (preloadCooldownTimerRef.current) { clearTimeout(preloadCooldownTimerRef.current); } preloadCooldownTimerRef.current = setTimeout(() => { isPreloadingRef.current = false; }, 350); }); } }, [ scrollPositionRef, hasMoreHistory, loadingMore, loading, messages.length, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory, ]); const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { handleMessageListContentSizeChange(contentWidth, contentHeight); }, [handleMessageListContentSizeChange]); return ( {!props.isEmbedded && } {/* 顶部栏 */} router.back()} onTitlePress={navigateToInfo} onMorePress={navigateToChatSettings} onGroupInfoPress={handleGroupInfoPress} isWideScreen={isWideScreen} /> {/* 消息列表 */} { scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; }} onTouchEnd={handleDismiss} > {loading ? ( ) : ( { isUserDraggingRef.current = true; handleDismiss(); }} onScrollEndDrag={() => { isUserDraggingRef.current = false; }} onMomentumScrollEnd={() => { isUserDraggingRef.current = false; }} scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} /> )} {showEdgeLoadingIndicator && ( )} {showJumpToLatest && ( 最新消息 )} {/* 底部区域:输入框 + 面板 */} 0 ? keyboardHeight : 0 }}> {/* 输入框区域 */} 0 && { paddingBottom: insets.bottom }]} onLayout={e => setInputWrapperHeight(e.nativeEvent.layout.height)} > { // 输入框获得焦点时,关闭其他面板(但不要关闭键盘) if (activePanel !== 'none' && activePanel !== 'mention') { closePanel(); } }} currentUser={currentUser} otherUser={otherUser} getSenderInfo={getSenderInfo} /> {/* 表情面板 */} {activePanel === 'emoji' && keyboardHeight === 0 && ( textInputRef.current?.focus()} /> )} {/* 更多功能面板 */} {activePanel === 'more' && keyboardHeight === 0 && ( )} {/* 发送图片加载遮罩 */} {(sendingImage || uploadingAttachments) && ( {uploadingAttachments ? '正在上传图片…' : '发送图片中…'} )} {/* @成员选择浮层 - 绝对定位在输入框上方,覆盖消息列表 */} {activePanel === 'mention' && ( 0 ? keyboardHeight + inputWrapperHeight : inputWrapperHeight + (insets.bottom > 0 ? insets.bottom : 0), height: PANEL_HEIGHTS.mention, }, ]} > )} {/* 长按菜单 */} {/* 图片查看器 */} ({ id: img.id || img.url || String(Math.random()), url: img.url || img.uri || '' }))} initialIndex={selectedImageIndex} onClose={handleCloseImageViewer} enableSave /> {/* 群信息侧边栏面板 - 仅大屏幕显示 */} { // 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'); }} /> ); }; export default ChatScreen;