Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
376
src/screens/message/ChatScreen.tsx
Normal file
376
src/screens/message/ChatScreen.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 聊天页 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 } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableWithoutFeedback,
|
||||
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,
|
||||
PANEL_HEIGHTS,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
|
||||
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 {
|
||||
// 状态
|
||||
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,
|
||||
|
||||
// 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,
|
||||
} = useChatScreen();
|
||||
|
||||
// 监听返回事件,刷新会话列表
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||
// 刷新会话列表,确保已读状态正确显示
|
||||
messageManager.fetchConversations(true);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [navigation]);
|
||||
|
||||
// 渲染消息气泡
|
||||
const renderMessage = ({ item, index }: { item: any; index: number }) => (
|
||||
<MessageBubble
|
||||
message={item}
|
||||
index={index}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
// 获取正在输入提示
|
||||
const typingHint = getTypingHint();
|
||||
|
||||
const handleMessageListScroll = useCallback((event: any) => {
|
||||
const { contentSize, contentOffset } = event.nativeEvent;
|
||||
scrollPositionRef.current = {
|
||||
contentHeight: contentSize.height,
|
||||
scrollY: contentOffset.y,
|
||||
};
|
||||
}, [scrollPositionRef]);
|
||||
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<TouchableWithoutFeedback onPress={handleDismiss}>
|
||||
<View style={styles.messageListContainer}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => String(item.id)}
|
||||
contentContainerStyle={listContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshing={loadingMore}
|
||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
||||
progressViewOffset={0}
|
||||
onContentSizeChange={handleMessageListContentSizeChange}
|
||||
onScroll={handleMessageListScroll}
|
||||
scrollEventThrottle={16}
|
||||
// 优化渲染性能
|
||||
initialNumToRender={15}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{/* 底部区域:输入框 + 面板 */}
|
||||
<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
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatScreen;
|
||||
Reference in New Issue
Block a user