Files
frontend/src/screens/message/ChatScreen.tsx
lafay c98f1917f7
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m41s
Frontend CI / ota-android (push) Successful in 12m45s
Frontend CI / build-android-apk (push) Has been cancelled
refactor(LocalDataSource, PostRepository, ChatScreen): enhance database handling and message loading
- Introduced a promise-based initialization mechanism in LocalDataSource to prevent concurrent initialization issues.
- Updated PostRepository to utilize an enqueueWrite method for database operations, ensuring thread-safe writes.
- Refactored ChatScreen to improve message loading logic, including preloading history and managing scroll behavior more effectively.
- Enhanced message rendering logic to maintain correct indices in inverted lists and optimize user experience during scrolling.
2026-03-23 23:06:19 +08:00

541 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天页 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 } from '@react-navigation/native';
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 { messageManager } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
import {
useChatScreen,
chatScreenStyles as baseStyles,
EmojiPanel,
MorePanel,
MentionPanel,
LongPressMenu,
ChatHeader,
MessageBubble,
ChatInput,
GroupInfoPanel,
PANEL_HEIGHTS,
} from './components/ChatScreen';
export const ChatScreen: React.FC = () => {
const navigation = useNavigation<any>();
const insets = useSafeAreaInsets();
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles;
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页
useEffect(() => {
if (isWideScreen) {
// 导航到大屏幕模式的主界面首页
navigation.reset({
index: 0,
routes: [
{
name: 'Main',
params: {
screen: 'HomeTab',
params: { screen: 'Home' }
}
}
]
});
}
}, [isWideScreen, navigation]);
// 输入框区域高度(用于定位浮动 mention 面板)
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
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);
};
// 群信息面板状态
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,
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,
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,
} = useChatScreen();
useEffect(() => {
return () => {
if (preloadCooldownTimerRef.current) {
clearTimeout(preloadCooldownTimerRef.current);
}
};
}, []);
// 监听返回事件,刷新会话列表
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', () => {
// 刷新会话列表,确保已读状态正确显示
messageManager.fetchConversations(true);
});
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 = ({ 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}
/>
);
};
// 获取正在输入提示
const typingHint = getTypingHint();
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
const handleMessageListScroll = useCallback((event: any) => {
const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent;
scrollPositionRef.current = {
contentHeight: contentSize.height,
scrollY: contentOffset.y,
viewportHeight: layoutMeasurement.height,
};
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
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);
const isScrollingTowardHistory = contentOffset.y > lastScrollYRef.current;
lastScrollYRef.current = contentOffset.y;
const PRELOAD_HISTORY_THRESHOLD = 120;
if (
distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD &&
isScrollingTowardHistory &&
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]);
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={() => navigation.goBack()}
onTitlePress={navigateToInfo}
onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress}
isWideScreen={isWideScreen}
/>
{/* 消息列表 */}
<View
style={styles.messageListContainer}
onLayout={e => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}}
>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
) : (
<FlatList
ref={flatListRef}
inverted
data={displayMessages}
renderItem={renderMessage}
keyExtractor={(item: any) => String(item.id)}
contentContainerStyle={listContentStyle as any}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
onScroll={handleMessageListScroll}
onScrollBeginDrag={() => {
isUserDraggingRef.current = true;
handleDismiss();
}}
onScrollEndDrag={() => {
isUserDraggingRef.current = false;
}}
onMomentumScrollEnd={() => {
isUserDraggingRef.current = false;
}}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
/>
)}
{loadingMore && (
<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
/>
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
<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;