Files
frontend/src/screens/message/ChatScreen.tsx
lafay 4ee3079b9f
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
feat(Theme): enhance theming and UI consistency across components
- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability.
- Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage.
- Introduced SystemChrome component to manage system UI background color based on theme.
- Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure.
- Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system.
- Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
2026-03-25 05:16:54 +08:00

677 lines
21 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,
TouchableOpacity,
} from 'react-native';
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 } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { messageManager } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
import {
useChatScreen,
useChatScreenStyles,
EmojiPanel,
MorePanel,
MentionPanel,
LongPressMenu,
ChatHeader,
MessageBubble,
ChatInput,
GroupInfoPanel,
PANEL_HEIGHTS,
} from './components/ChatScreen';
export const ChatScreen: React.FC = () => {
const navigation = useNavigation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useAppColors();
const styles = useChatScreenStyles();
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页
useEffect(() => {
if (isWideScreen) {
router.replace(hrefs.hrefHome());
}
}, [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 showJumpToLatestRef = useRef(false);
const [showJumpToLatest, setShowJumpToLatest] = useState(false);
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,
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();
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
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', () => {
// 刷新会话列表,确保已读状态正确显示
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,
};
// invertedoffset 越大离最新消息端越远,与 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 (
<SafeAreaView style={containerStyle} edges={['top']}>
<KeyboardAvoidingView
style={styles.container}
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}
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>
)}
{showJumpToLatest && (
<TouchableOpacity
style={styles.jumpToLatestFab}
onPress={jumpToLatestMessages}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel="回到最新消息"
>
<MaterialCommunityIcons name="chevron-down" size={20} color={colors.primary.main} />
<Text style={styles.jumpToLatestFabText}></Text>
</TouchableOpacity>
)}
</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}
isComposerBusy={isComposerBusy}
isMuted={isMuted}
isGroupChat={isGroupChat}
muteAll={muteAll}
restrictionHint={followRestrictionHint}
replyingTo={replyingTo}
pendingAttachments={pendingAttachments}
onRemovePendingAttachment={removePendingAttachment}
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 || uploadingAttachments) && (
<View style={styles.overlay}>
<View style={styles.overlayContent}>
<ActivityIndicator size="large" color={colors.primary.main} />
<Text style={styles.overlayText}>
{uploadingAttachments ? '正在上传图片…' : '发送图片中…'}
</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>
</SafeAreaView>
);
};
export default ChatScreen;