Files
frontend/src/screens/message/ChatScreen.tsx

620 lines
19 KiB
TypeScript
Raw Normal View History

/**
* ChatScreen
* BBS - /
*
* @提及功能
*
*
* 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,
FlatList,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
} from 'react-native';
import { useNavigation, useRouter } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
import { colors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
import {
useChatScreen,
chatScreenStyles as baseStyles,
EmojiPanel,
MorePanel,
MentionPanel,
LongPressMenu,
ChatHeader,
MessageBubble,
ChatInput,
2026-03-16 17:47:10 +08:00
GroupInfoPanel,
PANEL_HEIGHTS,
} from './components/ChatScreen';
export const ChatScreen: React.FC = () => {
const navigation = useNavigation();
const router = useRouter();
const insets = useSafeAreaInsets();
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles;
2026-03-16 17:47:10 +08:00
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页
useEffect(() => {
if (isWideScreen) {
router.replace(hrefs.hrefHome());
2026-03-16 17:47:10 +08:00
}
}, [isWideScreen, router]);
// 输入框区域高度(用于定位浮动 mention 面板)
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
const replyTargetMessageIdRef = useRef<string | null>(null);
const replyHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | 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<ReturnType<typeof setTimeout> | null>(null);
const containerStyle = useMemo(() => ([
styles.container,
isWideScreen ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.container]);
const listContentStyle = useMemo(() => ([
styles.listContent,
isWideScreen
? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const }
: { paddingHorizontal: 16 },
]), [isWideScreen, styles.listContent]);
const inputWrapperStyle = useMemo(() => ([
styles.inputWrapper,
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.inputWrapper]);
// 图片查看器状态
const [showImageViewer, setShowImageViewer] = useState(false);
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
// 图片点击处理函数
const handleImagePress = (images: ImageGridItem[], index: number) => {
setChatImages(images);
setSelectedImageIndex(index);
setShowImageViewer(true);
};
// 关闭图片查看器
const handleCloseImageViewer = () => {
setShowImageViewer(false);
};
2026-03-16 17:47:10 +08:00
// 群信息面板状态
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
// 打开群信息面板
const handleGroupInfoPress = useCallback(() => {
setShowGroupInfoPanel(true);
}, []);
// 关闭群信息面板
const handleCloseGroupInfoPanel = useCallback(() => {
setShowGroupInfoPanel(false);
}, []);
const {
// 状态
messages,
inputText,
otherUser,
currentUser,
currentUserId,
keyboardHeight,
loading,
sending,
activePanel,
sendingImage,
replyingTo,
longPressMenuVisible,
selectedMessage,
selectedMessageId,
setSelectedMessageId,
menuPosition,
isGroupChat,
groupInfo,
groupMembers,
currentUserRole,
mentionQuery,
isMuted,
muteAll,
followRestrictionHint,
canSendPrivateImage,
routeGroupId,
routeGroupName,
otherUserLastReadSeq,
messageMap,
loadingMore,
hasMoreHistory,
2026-03-16 17:47:10 +08:00
conversationId,
// Refs
flatListRef,
textInputRef,
scrollPositionRef,
// 方法
formatTime,
shouldShowTime,
handleInputChange,
handleSend,
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,
setBrowsingHistory,
} = useChatScreen();
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
useEffect(() => {
if (!loadingMore && showEdgeLoadingIndicator) {
setShowEdgeLoadingIndicator(false);
}
}, [loadingMore, showEdgeLoadingIndicator]);
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', () => {
// 刷新会话列表,确保已读状态正确显示
messageManager.requestConversationListRefresh('chat-before-remove', {
force: true,
allowDefer: false,
});
});
return unsubscribe;
}, [navigation]);
// 渲染消息气泡
const messageIndexMap = useMemo(() => {
const map = new Map<string, number>();
messages.forEach((msg, idx) => {
map.set(String(msg.id), idx);
});
return map;
}, [messages]);
const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => {
// inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引
const logicalIndex = messageIndexMap.get(String(item.id)) ?? index;
return (
<MessageBubble
message={item}
index={logicalIndex}
currentUserId={currentUserId}
currentUser={currentUser}
otherUser={otherUser}
isGroupChat={isGroupChat}
groupMembers={groupMembers}
otherUserLastReadSeq={otherUserLastReadSeq}
selectedMessageId={selectedMessageId}
messageMap={messageMap}
onLongPress={handleLongPressMessage}
onAvatarPress={handleAvatarPress}
onAvatarLongPress={handleAvatarLongPress}
formatTime={formatTime}
shouldShowTime={shouldShowTime}
onImagePress={handleImagePress}
onReply={handleReplyMessage}
onReplyPress={handleReplyPreviewPress}
/>
);
}, [
messageIndexMap,
currentUserId,
currentUser,
otherUser,
isGroupChat,
groupMembers,
otherUserLastReadSeq,
selectedMessageId,
messageMap,
handleLongPressMessage,
handleAvatarPress,
handleAvatarLongPress,
formatTime,
shouldShowTime,
handleImagePress,
handleReplyMessage,
handleReplyPreviewPress,
]);
const keyExtractor = useCallback((item: any) => 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,
};
// 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底
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, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]);
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
handleMessageListContentSizeChange(contentWidth, contentHeight);
}, [handleMessageListContentSizeChange]);
return (
<KeyboardAvoidingView
style={containerStyle}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<StatusBar style="dark" backgroundColor="#FFFFFF" />
{/* 顶部栏 */}
<ChatHeader
isGroupChat={isGroupChat}
groupInfo={groupInfo}
otherUser={otherUser}
routeGroupName={routeGroupName}
typingHint={typingHint}
onBack={() => router.back()}
onTitlePress={navigateToInfo}
onMorePress={navigateToChatSettings}
2026-03-16 17:47:10 +08:00
onGroupInfoPress={handleGroupInfoPress}
isWideScreen={isWideScreen}
/>
{/* 消息列表 */}
<View
style={styles.messageListContainer}
onLayout={e => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}}
onTouchEnd={handleDismiss}
>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
ref={flatListRef}
inverted
data={displayMessages}
renderItem={renderMessage}
keyExtractor={keyExtractor}
contentContainerStyle={listContentStyle as any}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
initialNumToRender={14}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
windowSize={15}
removeClippedSubviews={false}
onScroll={handleMessageListScroll}
onScrollBeginDrag={() => {
isUserDraggingRef.current = true;
handleDismiss();
}}
onScrollEndDrag={() => {
isUserDraggingRef.current = false;
}}
onMomentumScrollEnd={() => {
isUserDraggingRef.current = false;
}}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
onScrollToIndexFailed={(info) => {
const targetId = replyTargetMessageIdRef.current;
if (!targetId || !flatListRef.current) return;
flatListRef.current.scrollToOffset({
offset: Math.max(0, info.averageItemLength * info.index),
animated: true,
});
setTimeout(() => {
const retryIndex = displayMessages.findIndex(msg => String(msg.id) === targetId);
if (retryIndex >= 0) {
flatListRef.current?.scrollToIndex({
index: retryIndex,
animated: true,
viewPosition: 0.5,
});
}
}, 120);
}}
/>
)}
{showEdgeLoadingIndicator && (
<View
pointerEvents="none"
style={{
position: 'absolute',
top: 8,
left: 0,
right: 0,
alignItems: 'center',
}}
>
<View
style={{
backgroundColor: 'rgba(255,255,255,0.9)',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 6,
}}
>
<ActivityIndicator size="small" color={colors.primary.main} />
</View>
</View>
)}
</View>
{/* 底部区域:输入框 + 面板 */}
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>
{/* 输入框区域 */}
<View
style={[inputWrapperStyle, insets.bottom > 0 && { paddingBottom: insets.bottom }]}
onLayout={e => setInputWrapperHeight(e.nativeEvent.layout.height)}
>
<ChatInput
inputText={inputText}
onInputChange={handleInputChange}
onSend={handleSend}
onToggleEmoji={toggleEmojiPanel}
onToggleMore={toggleMorePanel}
activePanel={activePanel}
sending={sending}
isMuted={isMuted}
isGroupChat={isGroupChat}
muteAll={muteAll}
restrictionHint={followRestrictionHint}
replyingTo={replyingTo}
onCancelReply={handleCancelReply}
onFocus={() => {
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
if (activePanel !== 'none' && activePanel !== 'mention') {
closePanel();
}
}}
currentUser={currentUser}
otherUser={otherUser}
getSenderInfo={getSenderInfo}
/>
</View>
{/* 表情面板 */}
{activePanel === 'emoji' && keyboardHeight === 0 && (
<View style={[styles.panelWrapper, styles.emojiPanelWrapper, { height: PANEL_HEIGHTS.emoji + insets.bottom, paddingBottom: insets.bottom }]}>
<EmojiPanel
onInsertEmoji={handleInsertEmoji}
onInsertSticker={handleSendSticker}
onClose={closePanel}
/>
</View>
)}
{/* 更多功能面板 */}
{activePanel === 'more' && keyboardHeight === 0 && (
<View style={[styles.panelWrapper, { height: PANEL_HEIGHTS.more + insets.bottom, paddingBottom: insets.bottom }]}>
<MorePanel
onAction={handleMoreAction}
disabledActionIds={!isGroupChat && !canSendPrivateImage ? ['image', 'camera'] : []}
/>
</View>
)}
</View>
{/* 发送图片加载遮罩 */}
{sendingImage && (
<View style={styles.overlay}>
<View style={styles.overlayContent}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text style={styles.overlayText}>...</Text>
</View>
</View>
)}
{/* @成员选择浮层 - 绝对定位在输入框上方,覆盖消息列表 */}
{activePanel === 'mention' && (
<View
style={[
styles.mentionPanelFloat,
{
bottom: keyboardHeight > 0
? keyboardHeight + inputWrapperHeight
: inputWrapperHeight + (insets.bottom > 0 ? insets.bottom : 0),
height: PANEL_HEIGHTS.mention,
},
]}
>
<MentionPanel
members={groupMembers}
currentUserId={currentUserId}
mentionQuery={mentionQuery}
currentUserRole={currentUserRole}
onSelectMention={handleSelectMention}
onMentionAll={handleMentionAll}
onClose={closePanel}
/>
</View>
)}
{/* 长按菜单 */}
<LongPressMenu
visible={longPressMenuVisible}
message={selectedMessage}
currentUserId={currentUserId}
position={menuPosition}
onClose={hideLongPressMenu}
onReply={handleReplyMessage}
onRecall={handleRecall}
onDelete={handleDeleteMessage}
/>
{/* 图片查看器 */}
<ImageGallery
visible={showImageViewer}
images={chatImages.map(img => ({
id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || ''
}))}
initialIndex={selectedImageIndex}
onClose={handleCloseImageViewer}
enableSave
/>
2026-03-16 17:47:10 +08:00
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
<GroupInfoPanel
visible={showGroupInfoPanel}
groupId={routeGroupId ? String(routeGroupId) : undefined}
groupInfo={groupInfo as any}
conversationId={conversationId || undefined}
currentUserId={currentUserId}
isPinned={false}
onClose={handleCloseGroupInfoPanel}
onTogglePin={(pinned) => {
// TODO: 实现置顶功能
console.log('Toggle pin:', pinned);
}}
onLeaveGroup={() => {
// TODO: 实现退出群聊功能
console.log('Leave group');
}}
onInviteMembers={() => {
// TODO: 实现邀请新成员功能
console.log('Invite members');
}}
onViewAllMembers={() => {
// TODO: 实现查看全部成员功能
console.log('View all members');
}}
/>
</KeyboardAvoidingView>
);
};
export default ChatScreen;