2026-03-09 21:29:03 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 聊天页 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: 输入框组件
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2026-03-20 19:28:42 +08:00
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
View,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
FlatList,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
ActivityIndicator,
|
|
|
|
|
|
KeyboardAvoidingView,
|
|
|
|
|
|
Platform,
|
|
|
|
|
|
} from 'react-native';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import { useNavigation, useRouter } from 'expo-router';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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';
|
2026-03-24 14:21:31 +08:00
|
|
|
|
import * as hrefs from '../../navigation/hrefs';
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
PANEL_HEIGHTS,
|
|
|
|
|
|
} from './components/ChatScreen';
|
|
|
|
|
|
|
|
|
|
|
|
export const ChatScreen: React.FC = () => {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
const navigation = useNavigation();
|
|
|
|
|
|
const router = useRouter();
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const insets = useSafeAreaInsets();
|
|
|
|
|
|
|
|
|
|
|
|
// 响应式布局
|
|
|
|
|
|
const isWideScreen = useBreakpointGTE('lg');
|
|
|
|
|
|
const styles = baseStyles;
|
2026-03-16 17:47:10 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (isWideScreen) {
|
2026-03-24 14:21:31 +08:00
|
|
|
|
router.replace(hrefs.hrefHome());
|
2026-03-16 17:47:10 +08:00
|
|
|
|
}
|
2026-03-24 14:21:31 +08:00
|
|
|
|
}, [isWideScreen, router]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 输入框区域高度(用于定位浮动 mention 面板)
|
|
|
|
|
|
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
2026-03-24 01:19:09 +08:00
|
|
|
|
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const replyTargetMessageIdRef = useRef<string | null>(null);
|
|
|
|
|
|
const replyHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
2026-03-23 23:06:19 +08:00
|
|
|
|
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);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
const {
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
messages,
|
|
|
|
|
|
inputText,
|
|
|
|
|
|
otherUser,
|
|
|
|
|
|
currentUser,
|
|
|
|
|
|
currentUserId,
|
|
|
|
|
|
keyboardHeight,
|
|
|
|
|
|
loading,
|
|
|
|
|
|
sending,
|
|
|
|
|
|
activePanel,
|
|
|
|
|
|
sendingImage,
|
|
|
|
|
|
replyingTo,
|
|
|
|
|
|
longPressMenuVisible,
|
|
|
|
|
|
selectedMessage,
|
|
|
|
|
|
selectedMessageId,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
setSelectedMessageId,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
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,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 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,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
handleReachLatestEdge,
|
2026-03-24 01:19:09 +08:00
|
|
|
|
setBrowsingHistory,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
} = useChatScreen();
|
2026-03-24 04:23:13 +08:00
|
|
|
|
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
2026-03-24 01:19:09 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!loadingMore && showEdgeLoadingIndicator) {
|
|
|
|
|
|
setShowEdgeLoadingIndicator(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [loadingMore, showEdgeLoadingIndicator]);
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (preloadCooldownTimerRef.current) {
|
|
|
|
|
|
clearTimeout(preloadCooldownTimerRef.current);
|
|
|
|
|
|
}
|
2026-03-24 04:23:13 +08:00
|
|
|
|
if (replyHighlightTimerRef.current) {
|
|
|
|
|
|
clearTimeout(replyHighlightTimerRef.current);
|
|
|
|
|
|
}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-24 04:23:13 +08:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-03-24 14:21:31 +08:00
|
|
|
|
}, [displayMessages, flatListRef, setBrowsingHistory]);
|
2026-03-24 04:23:13 +08:00
|
|
|
|
|
2026-03-09 21:29:03 +08:00
|
|
|
|
// 监听返回事件,刷新会话列表
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
|
|
|
|
|
// 刷新会话列表,确保已读状态正确显示
|
2026-03-24 04:23:13 +08:00
|
|
|
|
messageManager.requestConversationListRefresh('chat-before-remove', {
|
|
|
|
|
|
force: true,
|
|
|
|
|
|
allowDefer: false,
|
|
|
|
|
|
});
|
2026-03-09 21:29:03 +08:00
|
|
|
|
});
|
|
|
|
|
|
return unsubscribe;
|
|
|
|
|
|
}, [navigation]);
|
|
|
|
|
|
|
|
|
|
|
|
// 渲染消息气泡
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const messageIndexMap = useMemo(() => {
|
|
|
|
|
|
const map = new Map<string, number>();
|
|
|
|
|
|
messages.forEach((msg, idx) => {
|
|
|
|
|
|
map.set(String(msg.id), idx);
|
|
|
|
|
|
});
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
2026-03-24 01:19:09 +08:00
|
|
|
|
const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 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}
|
2026-03-24 04:23:13 +08:00
|
|
|
|
onReplyPress={handleReplyPreviewPress}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-03-24 01:19:09 +08:00
|
|
|
|
}, [
|
|
|
|
|
|
messageIndexMap,
|
|
|
|
|
|
currentUserId,
|
|
|
|
|
|
currentUser,
|
|
|
|
|
|
otherUser,
|
|
|
|
|
|
isGroupChat,
|
|
|
|
|
|
groupMembers,
|
|
|
|
|
|
otherUserLastReadSeq,
|
|
|
|
|
|
selectedMessageId,
|
|
|
|
|
|
messageMap,
|
|
|
|
|
|
handleLongPressMessage,
|
|
|
|
|
|
handleAvatarPress,
|
|
|
|
|
|
handleAvatarLongPress,
|
|
|
|
|
|
formatTime,
|
|
|
|
|
|
shouldShowTime,
|
|
|
|
|
|
handleImagePress,
|
|
|
|
|
|
handleReplyMessage,
|
2026-03-24 04:23:13 +08:00
|
|
|
|
handleReplyPreviewPress,
|
2026-03-24 01:19:09 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const keyExtractor = useCallback((item: any) => String(item.id), []);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取正在输入提示
|
|
|
|
|
|
const typingHint = getTypingHint();
|
|
|
|
|
|
const handleMessageListScroll = useCallback((event: any) => {
|
2026-03-23 23:06:19 +08:00
|
|
|
|
const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent;
|
2026-03-09 21:29:03 +08:00
|
|
|
|
scrollPositionRef.current = {
|
|
|
|
|
|
contentHeight: contentSize.height,
|
|
|
|
|
|
scrollY: contentOffset.y,
|
2026-03-23 23:06:19 +08:00
|
|
|
|
viewportHeight: layoutMeasurement.height,
|
2026-03-09 21:29:03 +08:00
|
|
|
|
};
|
2026-03-23 23:06:19 +08:00
|
|
|
|
|
2026-03-24 01:19:09 +08:00
|
|
|
|
// 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底
|
|
|
|
|
|
if (contentOffset.y > 120) {
|
|
|
|
|
|
setBrowsingHistory(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2026-03-24 01:19:09 +08:00
|
|
|
|
// 预加载阈值:按屏幕高度动态提前(至少 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-23 23:06:19 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-03-24 01:19:09 +08:00
|
|
|
|
}, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]);
|
2026-03-23 23:06:19 +08:00
|
|
|
|
|
|
|
|
|
|
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
|
|
|
|
|
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
|
|
|
|
|
}, [handleMessageListContentSizeChange]);
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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}
|
2026-03-24 14:21:31 +08:00
|
|
|
|
onBack={() => router.back()}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
onTitlePress={navigateToInfo}
|
|
|
|
|
|
onMorePress={navigateToChatSettings}
|
2026-03-16 17:47:10 +08:00
|
|
|
|
onGroupInfoPress={handleGroupInfoPress}
|
|
|
|
|
|
isWideScreen={isWideScreen}
|
2026-03-09 21:29:03 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 消息列表 */}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
<View
|
|
|
|
|
|
style={styles.messageListContainer}
|
|
|
|
|
|
onLayout={e => {
|
|
|
|
|
|
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
|
|
|
|
|
|
}}
|
2026-03-24 01:19:09 +08:00
|
|
|
|
onTouchEnd={handleDismiss}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
>
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<View style={styles.loadingContainer}>
|
|
|
|
|
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
|
|
|
|
|
</View>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<FlatList
|
|
|
|
|
|
ref={flatListRef}
|
|
|
|
|
|
inverted
|
|
|
|
|
|
data={displayMessages}
|
|
|
|
|
|
renderItem={renderMessage}
|
2026-03-24 01:19:09 +08:00
|
|
|
|
keyExtractor={keyExtractor}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
contentContainerStyle={listContentStyle as any}
|
|
|
|
|
|
showsVerticalScrollIndicator={false}
|
|
|
|
|
|
keyboardShouldPersistTaps="handled"
|
2026-03-24 01:19:09 +08:00
|
|
|
|
initialNumToRender={14}
|
|
|
|
|
|
maxToRenderPerBatch={10}
|
|
|
|
|
|
updateCellsBatchingPeriod={50}
|
2026-03-24 14:21:31 +08:00
|
|
|
|
windowSize={15}
|
|
|
|
|
|
removeClippedSubviews={false}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
onScroll={handleMessageListScroll}
|
|
|
|
|
|
onScrollBeginDrag={() => {
|
|
|
|
|
|
isUserDraggingRef.current = true;
|
|
|
|
|
|
handleDismiss();
|
|
|
|
|
|
}}
|
|
|
|
|
|
onScrollEndDrag={() => {
|
|
|
|
|
|
isUserDraggingRef.current = false;
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMomentumScrollEnd={() => {
|
|
|
|
|
|
isUserDraggingRef.current = false;
|
|
|
|
|
|
}}
|
|
|
|
|
|
scrollEventThrottle={16}
|
|
|
|
|
|
onContentSizeChange={handleContentSizeChange}
|
2026-03-24 04:23:13 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}}
|
2026-03-23 23:06:19 +08:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-03-24 01:19:09 +08:00
|
|
|
|
{showEdgeLoadingIndicator && (
|
2026-03-23 23:06:19 +08:00
|
|
|
|
<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} />
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</View>
|
2026-03-23 23:06:19 +08:00
|
|
|
|
</View>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</View>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 底部区域:输入框 + 面板 */}
|
|
|
|
|
|
<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');
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-03-09 21:29:03 +08:00
|
|
|
|
</KeyboardAvoidingView>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default ChatScreen;
|