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

700 lines
22 KiB
TypeScript
Raw Normal View History

/**
* ChatScreen
* - /
*
* @提及功能
*
*
* 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,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
BackHandler,
} from 'react-native';
import { FlashList, ListRenderItem } from '@shopify/flash-list';
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, useResolvedColorScheme } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { messageManager, callStore } from '../../stores';
import { useBreakpointGTE } from '../../hooks/useResponsive';
import {
useChatScreen,
useChatScreenStyles,
EmojiPanel,
MorePanel,
MentionPanel,
LongPressMenu,
ChatHeader,
MessageBubble,
ChatInput,
2026-03-16 17:47:10 +08:00
GroupInfoPanel,
PANEL_HEIGHTS,
ChatScreenProps,
} from './components/ChatScreen';
import { GroupMessage } from './components/ChatScreen/types';
export const ChatScreen: React.FC<ChatScreenProps> = (props) => {
const navigation = useNavigation();
const router = useRouter();
const insets = useSafeAreaInsets();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useChatScreenStyles();
// 响应式布局
const isWideScreen = useBreakpointGTE('lg');
2026-03-16 17:47:10 +08:00
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页嵌入式模式下不跳转
2026-03-16 17:47:10 +08:00
useEffect(() => {
if (isWideScreen && !props.isEmbedded) {
router.replace(hrefs.hrefHome());
2026-03-16 17:47:10 +08:00
}
}, [isWideScreen, router, props.isEmbedded]);
// 输入框区域高度(用于定位浮动 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 && !props.isEmbedded ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.container, props.isEmbedded]);
const listContentStyle = useMemo(() => ({
paddingHorizontal: isWideScreen && !props.isEmbedded ? 24 : 16,
...(isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const } : {}),
paddingTop: 12,
paddingBottom: 24,
}), [isWideScreen, props.isEmbedded]);
const inputWrapperStyle = useMemo(() => ([
styles.inputWrapper,
isWideScreen && !props.isEmbedded ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
]), [isWideScreen, styles.inputWrapper, props.isEmbedded]);
// 图片查看器状态
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,
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,
2026-03-16 17:47:10 +08:00
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(props);
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
const longPressMenuMemberMap = useMemo(() => {
const map = new Map<string, { nickname: string; user_id: string }>();
groupMembers.forEach(m => {
map.set(m.user_id, {
nickname: m.nickname || m.user?.nickname || '用户',
user_id: m.user_id,
});
});
return map;
}, [groupMembers]);
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', (e) => {
const hasPanelOpen = activePanel !== 'none';
const hasKeyboardOpen = keyboardHeight > 0;
if (hasPanelOpen || hasKeyboardOpen) {
e.preventDefault();
if (hasKeyboardOpen) {
handleDismiss();
} else {
closePanel();
}
return;
}
// 真正返回时刷新会话列表
messageManager.refreshConversations(true, 'chat-before-remove');
});
return unsubscribe;
}, [navigation, activePanel, keyboardHeight, closePanel, handleDismiss]);
// Android 物理返回键:面板/键盘打开时先关闭它们
useEffect(() => {
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
const hasPanelOpen = activePanel !== 'none';
const hasKeyboardOpen = keyboardHeight > 0;
if (hasPanelOpen || hasKeyboardOpen) {
if (hasKeyboardOpen) {
handleDismiss();
} else {
closePanel();
}
return true; // 阻止默认返回行为
}
return false;
});
return () => backHandler.remove();
}, [activePanel, keyboardHeight, closePanel, handleDismiss]);
// 渲染消息气泡
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<ListRenderItem<GroupMessage>>(({ item, index }) => {
// 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: GroupMessage) => 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={props.isEmbedded ? [] : ['top']}>
<KeyboardAvoidingView
style={[styles.container, Platform.OS !== 'web' && { overflow: 'hidden' }]}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
{!props.isEmbedded && <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} backgroundColor={colors.background.paper} />}
{/* 顶部栏 */}
<ChatHeader
isGroupChat={isGroupChat}
groupInfo={groupInfo}
otherUser={otherUser}
routeGroupName={routeGroupName ?? undefined}
typingHint={typingHint}
onBack={props.onEmbeddedBack ? props.onEmbeddedBack : () => 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>
) : (
<FlashList
ref={flatListRef}
inverted
data={displayMessages}
renderItem={renderMessage}
keyExtractor={keyExtractor}
contentContainerStyle={listContentStyle}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
<think>Let me analyze the staged changes to generate a proper conventional commit message. Looking at the changes: 1. **New files**: - `app/(app)/(tabs)/profile/account-deletion.tsx` - account deletion screen - `app/(app)/(tabs)/profile/privacy-settings.tsx` - privacy settings screen - `src/screens/profile/AccountDeletionScreen.tsx` - account deletion implementation - `src/screens/profile/PrivacySettingsScreen.tsx` - privacy settings implementation 2. **Modified files**: - Database changes: `LocalDataSource.ts`, `DatabaseManager.ts`, `DatabaseConfig.ts`, `MigrationManager.ts` - refactoring database layer (removing LockManager, simplified initialization, added retry logic) - Auth store: `authStore.ts` - changed `initDatabase` to `switchDatabase` - Auth service: `authService.ts` - added privacy settings and account deletion methods - DTOs: `dto.ts` - added PrivacySettingsDTO, DeletionStatusDTO, VoteOptionDTO, VoteResultDTO types - Navigation: `hrefs.ts` - added new href functions for privacy settings and account deletion - Profile screens: `SettingsScreen.tsx`, `index.ts`, `_layout.tsx` - added new menu items and routes - Various message screens - added keyboard handling props - `app/_layout.tsx` - added CSS for mobile touch handling - `CreatePostScreen.tsx` - fixed vote_options format - `PostDetailScreen.tsx` - added type annotations for vote handling - `MediaCacheManager.ts` - updated to use new expo-file-system API - `VerificationSettingsScreen.tsx` - fixed type safety - `AccountSecurityScreen.tsx` - styling refactoring This is a multi-faceted change involving: Database refactoring, new privacy/account deletion features, bug fixes, and UI improvements. The database changes appear to be refactoring without new functionality. The most significant changes are the privacy settings and account deletion features, plus bug fixes like the vote options format and mobile touch handling. </think> feat(profile): add privacy settings and account deletion screens Add privacy settings and account deletion functionality with new screens and APIs. Refactor database initialization to use switchDatabase for user switching. Fix vote options format in post creation and add type safety to vote handling. Improve mobile touch handling in layout and message screens. Update media cache to use new expo-file-system API. BREAKING CHANGE: Database initialization now uses switchDatabase instead of initDatabase for user switching
2026-04-08 16:48:19 +08:00
keyboardDismissMode="on-drag"
scrollEnabled={true}
drawDistance={250}
onScroll={handleMessageListScroll}
onScrollBeginDrag={() => {
isUserDraggingRef.current = true;
handleDismiss();
}}
onScrollEndDrag={() => {
isUserDraggingRef.current = false;
}}
onMomentumScrollEnd={() => {
isUserDraggingRef.current = false;
}}
scrollEventThrottle={16}
onContentSizeChange={handleContentSizeChange}
/>
)}
{showEdgeLoadingIndicator && (
<View
pointerEvents="none"
style={{
position: 'absolute',
top: 8,
left: 0,
right: 0,
alignItems: 'center',
}}
>
<View
style={{
backgroundColor: colors.background.paper + 'E6',
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}
onFocusInput={() => textInputRef.current?.focus()}
/>
</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'] : []}
isGroupChat={isGroupChat}
/>
</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}
memberMap={longPressMenuMemberMap}
/>
{/* 图片查看器 */}
<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>
</SafeAreaView>
);
};
export default ChatScreen;