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.
This commit is contained in:
@@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource {
|
|||||||
private db: SQLite.SQLiteDatabase | null = null;
|
private db: SQLite.SQLiteDatabase | null = null;
|
||||||
private dbName: string;
|
private dbName: string;
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
private initializingPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
constructor(config: LocalDataSourceConfig = {}) {
|
constructor(config: LocalDataSourceConfig = {}) {
|
||||||
// 如果提供了userId,使用用户专属数据库
|
// 如果提供了userId,使用用户专属数据库
|
||||||
@@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 防止并发初始化导致连接状态竞争
|
||||||
|
if (this.initializingPromise) {
|
||||||
|
await this.initializingPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializingPromise = this.doInitialize();
|
||||||
|
try {
|
||||||
|
await this.initializingPromise;
|
||||||
|
} finally {
|
||||||
|
this.initializingPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doInitialize(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 使用全局实例管理
|
// 使用全局实例管理
|
||||||
if (dbInstance && currentDbName === this.dbName) {
|
if (dbInstance && currentDbName === this.dbName) {
|
||||||
@@ -227,6 +243,17 @@ export class LocalDataSource implements ILocalDataSource {
|
|||||||
}
|
}
|
||||||
return await db.runAsync(sql);
|
return await db.runAsync(sql);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (this.isRecoverableError(error)) {
|
||||||
|
this.db = null;
|
||||||
|
this.initialized = false;
|
||||||
|
dbInstance = null;
|
||||||
|
await this.initialize();
|
||||||
|
const db = this.ensureDb();
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
return await db.runAsync(sql, params as any);
|
||||||
|
}
|
||||||
|
return await db.runAsync(sql);
|
||||||
|
}
|
||||||
this.handleError(error, 'RUN');
|
this.handleError(error, 'RUN');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,10 +195,12 @@ export class PostRepository implements IPostRepository {
|
|||||||
private async saveToLocalCache(post: Post): Promise<void> {
|
private async saveToLocalCache(post: Post): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
await this.localDb.initialize();
|
||||||
await this.localDb.run(
|
await this.localDb.enqueueWrite(async () => {
|
||||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
await this.localDb.run(
|
||||||
[post.id, JSON.stringify(post), new Date().toISOString()]
|
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||||
);
|
[post.id, JSON.stringify(post), new Date().toISOString()]
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PostRepository] 保存本地缓存失败:', error);
|
console.error('[PostRepository] 保存本地缓存失败:', error);
|
||||||
}
|
}
|
||||||
@@ -210,7 +212,9 @@ export class PostRepository implements IPostRepository {
|
|||||||
private async clearLocalCache(id: string): Promise<void> {
|
private async clearLocalCache(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
await this.localDb.initialize();
|
||||||
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
await this.localDb.enqueueWrite(async () => {
|
||||||
|
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PostRepository] 清除本地缓存失败:', error);
|
console.error('[PostRepository] 清除本地缓存失败:', error);
|
||||||
}
|
}
|
||||||
@@ -545,7 +549,9 @@ export class PostRepository implements IPostRepository {
|
|||||||
this.memoryCache.clear();
|
this.memoryCache.clear();
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
await this.localDb.initialize();
|
||||||
await this.localDb.run('DELETE FROM posts_cache');
|
await this.localDb.enqueueWrite(async () => {
|
||||||
|
await this.localDb.run('DELETE FROM posts_cache');
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[PostRepository] 清除所有缓存失败:', error);
|
console.error('[PostRepository] 清除所有缓存失败:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,11 @@
|
|||||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TouchableWithoutFeedback,
|
FlatList,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { FlashList } from "@shopify/flash-list";
|
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
@@ -78,6 +77,15 @@ export const ChatScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
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(() => ([
|
const containerStyle = useMemo(() => ([
|
||||||
styles.container,
|
styles.container,
|
||||||
@@ -194,8 +202,17 @@ export const ChatScreen: React.FC = () => {
|
|||||||
navigateToChatSettings,
|
navigateToChatSettings,
|
||||||
loadMoreHistory,
|
loadMoreHistory,
|
||||||
handleMessageListContentSizeChange,
|
handleMessageListContentSizeChange,
|
||||||
|
handleReachLatestEdge,
|
||||||
} = useChatScreen();
|
} = useChatScreen();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (preloadCooldownTimerRef.current) {
|
||||||
|
clearTimeout(preloadCooldownTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 监听返回事件,刷新会话列表
|
// 监听返回事件,刷新会话列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||||
@@ -206,38 +223,92 @@ export const ChatScreen: React.FC = () => {
|
|||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
// 渲染消息气泡
|
// 渲染消息气泡
|
||||||
const renderMessage = ({ item, index }: { item: any; index: number }) => (
|
const messageIndexMap = useMemo(() => {
|
||||||
<MessageBubble
|
const map = new Map<string, number>();
|
||||||
message={item}
|
messages.forEach((msg, idx) => {
|
||||||
index={index}
|
map.set(String(msg.id), idx);
|
||||||
currentUserId={currentUserId}
|
});
|
||||||
currentUser={currentUser}
|
return map;
|
||||||
otherUser={otherUser}
|
}, [messages]);
|
||||||
isGroupChat={isGroupChat}
|
|
||||||
groupMembers={groupMembers}
|
const renderMessage = ({ item, index }: { item: any; index: number }) => {
|
||||||
otherUserLastReadSeq={otherUserLastReadSeq}
|
// inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引
|
||||||
selectedMessageId={selectedMessageId}
|
const logicalIndex = messageIndexMap.get(String(item.id)) ?? index;
|
||||||
messageMap={messageMap}
|
return (
|
||||||
onLongPress={handleLongPressMessage}
|
<MessageBubble
|
||||||
onAvatarPress={handleAvatarPress}
|
message={item}
|
||||||
onAvatarLongPress={handleAvatarLongPress}
|
index={logicalIndex}
|
||||||
formatTime={formatTime}
|
currentUserId={currentUserId}
|
||||||
shouldShowTime={shouldShowTime}
|
currentUser={currentUser}
|
||||||
onImagePress={handleImagePress}
|
otherUser={otherUser}
|
||||||
onReply={handleReplyMessage}
|
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 typingHint = getTypingHint();
|
||||||
|
const displayMessages = useMemo(() => [...messages].reverse(), [messages]);
|
||||||
|
|
||||||
const handleMessageListScroll = useCallback((event: any) => {
|
const handleMessageListScroll = useCallback((event: any) => {
|
||||||
const { contentSize, contentOffset } = event.nativeEvent;
|
const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent;
|
||||||
scrollPositionRef.current = {
|
scrollPositionRef.current = {
|
||||||
contentHeight: contentSize.height,
|
contentHeight: contentSize.height,
|
||||||
scrollY: contentOffset.y,
|
scrollY: contentOffset.y,
|
||||||
|
viewportHeight: layoutMeasurement.height,
|
||||||
};
|
};
|
||||||
}, [scrollPositionRef]);
|
|
||||||
|
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
||||||
|
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 (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
@@ -262,34 +333,65 @@ export const ChatScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
<TouchableWithoutFeedback onPress={handleDismiss}>
|
<View
|
||||||
<View style={styles.messageListContainer}>
|
style={styles.messageListContainer}
|
||||||
{loading ? (
|
onLayout={e => {
|
||||||
<View style={styles.loadingContainer}>
|
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
}}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
<FlashList
|
)}
|
||||||
ref={flatListRef}
|
</View>
|
||||||
data={messages}
|
|
||||||
renderItem={renderMessage}
|
|
||||||
keyExtractor={(item: any) => String(item.id)}
|
|
||||||
contentContainerStyle={listContentStyle as any}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
refreshing={loadingMore}
|
|
||||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
|
||||||
// @ts-ignore FlashList 类型定义问题:estimatedItemSize 是必需属性但类型定义缺失
|
|
||||||
estimatedItemSize={80}
|
|
||||||
maintainVisibleContentPosition={{
|
|
||||||
minIndexForVisible: 0,
|
|
||||||
autoscrollToTopThreshold: undefined,
|
|
||||||
} as any}
|
|
||||||
onScroll={handleMessageListScroll}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
|
|
||||||
{/* 底部区域:输入框 + 面板 */}
|
{/* 底部区域:输入框 + 面板 */}
|
||||||
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>
|
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
import { useNavigation, useIsFocused } from '@react-navigation/native';
|
||||||
@@ -334,12 +335,12 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
Animated.timing(scaleAnims[index], {
|
Animated.timing(scaleAnims[index], {
|
||||||
toValue: 0.98,
|
toValue: 0.98,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
}),
|
}),
|
||||||
Animated.timing(scaleAnims[index], {
|
Animated.timing(scaleAnims[index], {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 100,
|
duration: 100,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
}),
|
}),
|
||||||
]).start(() => {
|
]).start(() => {
|
||||||
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
View,
|
View,
|
||||||
Modal,
|
Modal,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
TouchableWithoutFeedback,
|
Pressable,
|
||||||
Animated,
|
Animated,
|
||||||
Alert,
|
Alert,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { chatScreenStyles as styles } from './styles';
|
import { chatScreenStyles as styles } from './styles';
|
||||||
@@ -35,34 +36,41 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
||||||
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
||||||
|
const blurActiveElementOnWeb = () => {
|
||||||
|
if (Platform.OS !== 'web') return;
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
};
|
||||||
|
|
||||||
// 显示动画 - 缩放弹出
|
// 显示动画 - 缩放弹出
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
blurActiveElementOnWeb();
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.spring(scaleAnimation, {
|
Animated.spring(scaleAnimation, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
friction: 8,
|
friction: 8,
|
||||||
tension: 100,
|
tension: 100,
|
||||||
}),
|
}),
|
||||||
Animated.timing(opacityAnimation, {
|
Animated.timing(opacityAnimation, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 150,
|
duration: 150,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
} else {
|
} else {
|
||||||
|
blurActiveElementOnWeb();
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(scaleAnimation, {
|
Animated.timing(scaleAnimation, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 150,
|
duration: 150,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
}),
|
}),
|
||||||
Animated.timing(opacityAnimation, {
|
Animated.timing(opacityAnimation, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 150,
|
duration: 150,
|
||||||
useNativeDriver: true,
|
useNativeDriver: Platform.OS !== 'web',
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
}
|
}
|
||||||
@@ -242,11 +250,18 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="none"
|
animationType="none"
|
||||||
|
onShow={blurActiveElementOnWeb}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<TouchableWithoutFeedback onPress={onClose}>
|
<Pressable
|
||||||
<View style={styles.qqMenuOverlay}>
|
onPress={() => {
|
||||||
<TouchableWithoutFeedback>
|
blurActiveElementOnWeb();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={styles.qqMenuOverlay}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Pressable onPress={() => {}}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.qqMenuContainer,
|
styles.qqMenuContainer,
|
||||||
@@ -286,9 +301,9 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
|||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</TouchableWithoutFeedback>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
</Pressable>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,10 +114,15 @@ export const useChatScreen = () => {
|
|||||||
const flatListRef = useRef<any>(null);
|
const flatListRef = useRef<any>(null);
|
||||||
const textInputRef = useRef<any>(null);
|
const textInputRef = useRef<any>(null);
|
||||||
|
|
||||||
// 防止重复加载的 ref
|
// 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点)
|
||||||
const shouldAutoScrollOnEnterRef = useRef(true);
|
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 });
|
||||||
const autoScrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
const hasInitialAnchorDoneRef = useRef(false);
|
||||||
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0 });
|
const prevMessageCountRef = useRef(0);
|
||||||
|
const prevLatestSeqRef = useRef(0);
|
||||||
|
const prevMarkedReadSeqRef = useRef(0);
|
||||||
|
const isProgrammaticScrollRef = useRef(false);
|
||||||
|
const suppressAutoFollowRef = useRef(false);
|
||||||
|
const isBrowsingHistoryRef = useRef(false);
|
||||||
|
|
||||||
// 回复消息状态
|
// 回复消息状态
|
||||||
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
const [replyingTo, setReplyingTo] = useState<GroupMessage | null>(null);
|
||||||
@@ -196,10 +201,13 @@ export const useChatScreen = () => {
|
|||||||
return '对方未关注你前:仅可发送1条文字消息,且不能发送图片';
|
return '对方未关注你前:仅可发送1条文字消息,且不能发送图片';
|
||||||
}, [followRestricted, myPrivateSentCount]);
|
}, [followRestricted, myPrivateSentCount]);
|
||||||
|
|
||||||
// 【改造】同步加载状态
|
// 加载态语义修正:
|
||||||
|
// isLoadingMessages 在分页加载历史时也会短暂为 true。
|
||||||
|
// 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。
|
||||||
|
// 这里只在“首屏且尚无消息”时展示 loading 占位。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(isLoadingMessages);
|
setLoading(isLoadingMessages && messageManagerMessages.length === 0);
|
||||||
}, [isLoadingMessages]);
|
}, [isLoadingMessages, messageManagerMessages.length]);
|
||||||
|
|
||||||
// 【改造】同步 hasMore 状态
|
// 【改造】同步 hasMore 状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -263,32 +271,73 @@ export const useChatScreen = () => {
|
|||||||
};
|
};
|
||||||
}, [isGroupChat, otherUserId]);
|
}, [isGroupChat, otherUserId]);
|
||||||
|
|
||||||
// 进入新会话时重置首次自动滚动标记
|
// 进入新会话时重置滚动状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
shouldAutoScrollOnEnterRef.current = true;
|
hasInitialAnchorDoneRef.current = false;
|
||||||
autoScrollTimersRef.current.forEach(clearTimeout);
|
prevMessageCountRef.current = 0;
|
||||||
autoScrollTimersRef.current = [];
|
prevLatestSeqRef.current = 0;
|
||||||
|
prevMarkedReadSeqRef.current = 0;
|
||||||
|
suppressAutoFollowRef.current = false;
|
||||||
|
isBrowsingHistoryRef.current = false;
|
||||||
}, [conversationId]);
|
}, [conversationId]);
|
||||||
|
|
||||||
// 组件卸载时清理定时器
|
const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => {
|
||||||
useEffect(() => {
|
if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return;
|
||||||
return () => {
|
// inverted 列表下,最新消息端对应 offset=0
|
||||||
autoScrollTimersRef.current.forEach(clearTimeout);
|
isProgrammaticScrollRef.current = true;
|
||||||
autoScrollTimersRef.current = [];
|
flatListRef.current?.scrollToOffset({
|
||||||
};
|
offset: 0,
|
||||||
}, []);
|
animated,
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
isProgrammaticScrollRef.current = false;
|
||||||
|
}, animated ? 220 : 32);
|
||||||
|
}, [flatListRef, scrollPositionRef, messages.length]);
|
||||||
|
|
||||||
// 消息加载完成后滚动到底部
|
const isNearBottom = useCallback(() => {
|
||||||
|
const scrollY = scrollPositionRef.current.scrollY || 0;
|
||||||
|
// inverted 列表下,越接近 0 越靠近最新消息端
|
||||||
|
return scrollY <= 100;
|
||||||
|
}, [scrollPositionRef]);
|
||||||
|
|
||||||
|
// 首屏加载完成后仅锚定一次到最新消息端
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !loadingMore && messages.length > 0 && shouldAutoScrollOnEnterRef.current) {
|
if (
|
||||||
const timer = setTimeout(() => {
|
loading ||
|
||||||
if (shouldAutoScrollOnEnterRef.current) {
|
loadingMore ||
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
messages.length === 0 ||
|
||||||
}
|
hasInitialAnchorDoneRef.current ||
|
||||||
}, 500);
|
scrollPositionRef.current.viewportHeight <= 0
|
||||||
return () => clearTimeout(timer);
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [loading, loadingMore, messages.length]);
|
hasInitialAnchorDoneRef.current = true;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
scrollToLatest(false, true, 'initial-anchor');
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [loading, loadingMore, messages.length, scrollToLatest]);
|
||||||
|
|
||||||
|
// 新消息跟随策略(Telegram/QQ 风格):
|
||||||
|
// 仅当“最新端消息 seq 增长”且用户在底部附近时才跟随;
|
||||||
|
// 历史加载只会增加旧消息,不会提升 latest seq,因此不会触发回底。
|
||||||
|
useEffect(() => {
|
||||||
|
const currentCount = messages.length;
|
||||||
|
const latestSeq = currentCount > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||||
|
const prevLatestSeq = prevLatestSeqRef.current;
|
||||||
|
prevMessageCountRef.current = currentCount;
|
||||||
|
prevLatestSeqRef.current = latestSeq;
|
||||||
|
|
||||||
|
if (loading || loadingMore) return;
|
||||||
|
if (latestSeq <= prevLatestSeq) return;
|
||||||
|
if (suppressAutoFollowRef.current) return;
|
||||||
|
if (!isNearBottom()) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
scrollToLatest(false, false, 'new-message-follow');
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [messages, loading, loadingMore, isNearBottom, scrollToLatest]);
|
||||||
|
|
||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -384,22 +433,18 @@ export const useChatScreen = () => {
|
|||||||
};
|
};
|
||||||
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
}, [routeUserId, conversationId, currentUserId, isGroupChat]);
|
||||||
|
|
||||||
// 【改造】加载更多历史消息
|
// 加载更多历史消息(inverted 下保持阅读锚点)
|
||||||
const loadMoreHistory = useCallback(async () => {
|
const loadMoreHistory = useCallback(async () => {
|
||||||
if (!conversationId || !hasMoreHistory || loadingMore) {
|
if (!conversationId || !hasMoreHistory || loadingMore) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转
|
// 历史加载期间禁止“新消息自动跟随到底”
|
||||||
shouldAutoScrollOnEnterRef.current = false;
|
suppressAutoFollowRef.current = true;
|
||||||
// 清除所有待执行的自动滚动定时器
|
|
||||||
autoScrollTimersRef.current.forEach(clearTimeout);
|
|
||||||
autoScrollTimersRef.current = [];
|
|
||||||
|
|
||||||
// 保存加载前的滚动位置和内容高度
|
// 保存加载前的滚动位置和内容高度
|
||||||
const scrollYBefore = scrollPositionRef.current.scrollY;
|
const scrollYBefore = scrollPositionRef.current.scrollY;
|
||||||
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -411,22 +456,8 @@ export const useChatScreen = () => {
|
|||||||
setFirstSeq(minSeq);
|
setFirstSeq(minSeq);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载完成后,恢复滚动位置
|
// inverted + maintainVisibleContentPosition 下由列表原生保持位置
|
||||||
// 使用 setTimeout 确保 FlashList 已经更新
|
// 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部
|
||||||
setTimeout(() => {
|
|
||||||
if (flatListRef.current) {
|
|
||||||
// 计算新的滚动位置,保持相对位置不变
|
|
||||||
const newContentHeight = scrollPositionRef.current.contentHeight;
|
|
||||||
const heightDiff = newContentHeight - contentHeightBefore;
|
|
||||||
const newScrollY = scrollYBefore + heightDiff;
|
|
||||||
|
|
||||||
// 滚动到计算后的位置
|
|
||||||
flatListRef.current.scrollToOffset({
|
|
||||||
offset: newScrollY,
|
|
||||||
animated: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史消息失败:', error);
|
console.error('加载历史消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -434,48 +465,44 @@ export const useChatScreen = () => {
|
|||||||
}
|
}
|
||||||
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
}, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]);
|
||||||
|
|
||||||
// 列表内容尺寸变化后触发首次自动滚动
|
// 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理
|
||||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||||
// 如果是加载更多历史消息,不要自动滚动
|
|
||||||
if (loadingMore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldAutoScrollOnEnterRef.current) return;
|
|
||||||
if (loading) return;
|
|
||||||
if (messages.length === 0) return;
|
|
||||||
|
|
||||||
const prevContentHeight = scrollPositionRef.current.contentHeight;
|
|
||||||
scrollPositionRef.current.contentHeight = contentHeight;
|
scrollPositionRef.current.contentHeight = contentHeight;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 如果内容高度增加(加载了更多消息),不要自动滚动到底部
|
const handleReachLatestEdge = useCallback(() => {
|
||||||
if (prevContentHeight > 0 && contentHeight > prevContentHeight) {
|
suppressAutoFollowRef.current = false;
|
||||||
return;
|
isBrowsingHistoryRef.current = false;
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
shouldAutoScrollOnEnterRef.current = false;
|
const setBrowsingHistory = useCallback((browsing: boolean) => {
|
||||||
autoScrollTimersRef.current.forEach(clearTimeout);
|
isBrowsingHistoryRef.current = browsing;
|
||||||
autoScrollTimersRef.current = [];
|
}, []);
|
||||||
|
|
||||||
[100, 300, 500, 800, 1200].forEach(delay => {
|
// 自动标记已读(QQ/Telegram 风格):
|
||||||
const timer = setTimeout(() => {
|
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
// 历史加载/浏览历史不会触发 read。
|
||||||
}, delay);
|
|
||||||
autoScrollTimersRef.current.push(timer);
|
|
||||||
});
|
|
||||||
}, [loading, loadingMore, messages.length]);
|
|
||||||
|
|
||||||
// 【改造】自动标记已读 - 当有新消息且是当前会话时
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!conversationId || messages.length === 0) return;
|
if (!conversationId || messages.length === 0) return;
|
||||||
|
if (loading || loadingMore) return;
|
||||||
|
if (suppressAutoFollowRef.current || isBrowsingHistoryRef.current) return;
|
||||||
|
if (!isNearBottom()) return;
|
||||||
|
|
||||||
const maxSeq = Math.max(...messages.map(m => m.seq), 0);
|
const latestSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||||
if (maxSeq > 0) {
|
if (latestSeq <= 0) return;
|
||||||
markAsRead(maxSeq).catch(error => {
|
|
||||||
console.error('[ChatScreen] 自动标记已读失败:', error);
|
// 没有新增最新消息,不重复上报
|
||||||
});
|
if (latestSeq <= prevLatestSeqRef.current) return;
|
||||||
}
|
prevLatestSeqRef.current = latestSeq;
|
||||||
}, [messages, conversationId, markAsRead]);
|
|
||||||
|
// 避免对同一 seq 重复标记
|
||||||
|
if (latestSeq <= prevMarkedReadSeqRef.current) return;
|
||||||
|
prevMarkedReadSeqRef.current = latestSeq;
|
||||||
|
|
||||||
|
markAsRead(latestSeq).catch(error => {
|
||||||
|
console.error('[ChatScreen] 自动标记已读失败:', error);
|
||||||
|
});
|
||||||
|
}, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
|
||||||
|
|
||||||
// 使用 ref 存储 groupMembers
|
// 使用 ref 存储 groupMembers
|
||||||
const groupMembersRef = useRef(groupMembers);
|
const groupMembersRef = useRef(groupMembers);
|
||||||
@@ -494,16 +521,20 @@ export const useChatScreen = () => {
|
|||||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||||
setKeyboardHeight(e.endCoordinates.height);
|
setKeyboardHeight(e.endCoordinates.height);
|
||||||
setActivePanel(prev => prev === 'mention' ? 'mention' : 'none');
|
setActivePanel(prev => prev === 'mention' ? 'mention' : 'none');
|
||||||
setTimeout(() => {
|
if (isNearBottom()) {
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
setTimeout(() => {
|
||||||
}, 150);
|
scrollToLatest(false, false, 'keyboard-show');
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const keyboardWillHide = () => {
|
const keyboardWillHide = () => {
|
||||||
setKeyboardHeight(0);
|
setKeyboardHeight(0);
|
||||||
setTimeout(() => {
|
if (isNearBottom()) {
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
setTimeout(() => {
|
||||||
}, 100);
|
scrollToLatest(false, false, 'keyboard-hide');
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const showSubscription = Keyboard.addListener(
|
const showSubscription = Keyboard.addListener(
|
||||||
@@ -519,7 +550,7 @@ export const useChatScreen = () => {
|
|||||||
showSubscription.remove();
|
showSubscription.remove();
|
||||||
hideSubscription.remove();
|
hideSubscription.remove();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [scrollToLatest, isNearBottom]);
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = useCallback((dateString: string): string => {
|
const formatTime = useCallback((dateString: string): string => {
|
||||||
@@ -773,7 +804,7 @@ export const useChatScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
scrollToLatest(false, false, 'send-text');
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送消息失败:', error);
|
console.error('发送消息失败:', error);
|
||||||
@@ -781,7 +812,7 @@ export const useChatScreen = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
}, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText]);
|
}, [inputText, conversationId, isGroupChat, routeGroupId, selectedMentions, mentionAll, sendMessageViaManager, isMuted, muteAll, buildTextSegments, replyingTo, getSendErrorMessage, canSendFirstPrivateText, scrollToLatest]);
|
||||||
|
|
||||||
// 发送图片消息
|
// 发送图片消息
|
||||||
const handleSendImage = useCallback(async (imageUri: string, mimeType?: string) => {
|
const handleSendImage = useCallback(async (imageUri: string, mimeType?: string) => {
|
||||||
@@ -823,7 +854,7 @@ export const useChatScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
scrollToLatest(false, false, 'send-image');
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送图片失败:', error);
|
console.error('发送图片失败:', error);
|
||||||
@@ -831,7 +862,7 @@ export const useChatScreen = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSendingImage(false);
|
setSendingImage(false);
|
||||||
}
|
}
|
||||||
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage]);
|
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, getSendErrorMessage, canSendPrivateImage, scrollToLatest]);
|
||||||
|
|
||||||
// 选择图片
|
// 选择图片
|
||||||
const handlePickImage = useCallback(async () => {
|
const handlePickImage = useCallback(async () => {
|
||||||
@@ -950,7 +981,7 @@ export const useChatScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: true });
|
scrollToLatest(false, false, 'send-sticker');
|
||||||
}, 100);
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('发送自定义表情失败:', error);
|
console.error('发送自定义表情失败:', error);
|
||||||
@@ -958,35 +989,35 @@ export const useChatScreen = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSendingImage(false);
|
setSendingImage(false);
|
||||||
}
|
}
|
||||||
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage]);
|
}, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]);
|
||||||
|
|
||||||
// 切换表情面板
|
// 切换表情面板
|
||||||
const toggleEmojiPanel = useCallback(() => {
|
const toggleEmojiPanel = useCallback(() => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
setActivePanel(prev => {
|
setActivePanel(prev => {
|
||||||
const newPanel = prev === 'emoji' ? 'none' : 'emoji';
|
const newPanel = prev === 'emoji' ? 'none' : 'emoji';
|
||||||
if (newPanel !== 'none') {
|
if (newPanel !== 'none' && isNearBottom()) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
scrollToLatest(false, false, 'open-emoji-panel');
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
return newPanel;
|
return newPanel;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [scrollToLatest, isNearBottom]);
|
||||||
|
|
||||||
// 切换更多功能面板
|
// 切换更多功能面板
|
||||||
const toggleMorePanel = useCallback(() => {
|
const toggleMorePanel = useCallback(() => {
|
||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
setActivePanel(prev => {
|
setActivePanel(prev => {
|
||||||
const newPanel = prev === 'more' ? 'none' : 'more';
|
const newPanel = prev === 'more' ? 'none' : 'more';
|
||||||
if (newPanel !== 'none') {
|
if (newPanel !== 'none' && isNearBottom()) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
flatListRef.current?.scrollToEnd({ animated: false });
|
scrollToLatest(false, false, 'open-more-panel');
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
return newPanel;
|
return newPanel;
|
||||||
});
|
});
|
||||||
}, []);
|
}, [scrollToLatest, isNearBottom]);
|
||||||
|
|
||||||
// 关闭面板
|
// 关闭面板
|
||||||
const closePanel = useCallback(() => {
|
const closePanel = useCallback(() => {
|
||||||
@@ -1245,5 +1276,7 @@ export const useChatScreen = () => {
|
|||||||
loadMoreHistory,
|
loadMoreHistory,
|
||||||
handleClearConversation,
|
handleClearConversation,
|
||||||
handleMessageListContentSizeChange,
|
handleMessageListContentSizeChange,
|
||||||
|
handleReachLatestEdge,
|
||||||
|
setBrowsingHistory,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -231,10 +231,19 @@ const isRecoverableDbError = (error: unknown): boolean => {
|
|||||||
const message = String(error);
|
const message = String(error);
|
||||||
return (
|
return (
|
||||||
message.includes('NativeDatabase.prepareAsync') ||
|
message.includes('NativeDatabase.prepareAsync') ||
|
||||||
message.includes('NullPointerException')
|
message.includes('NullPointerException') ||
|
||||||
|
message.includes('Cannot use shared object that was already released') ||
|
||||||
|
message.includes('NativeStatement')
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const recoverDbConnection = async (): Promise<SQLite.SQLiteDatabase> => {
|
||||||
|
// 清理当前引用,随后用当前用户上下文重建连接
|
||||||
|
db = null;
|
||||||
|
await initDatabase(currentDbUserId || undefined);
|
||||||
|
return getDb();
|
||||||
|
};
|
||||||
|
|
||||||
const withDbRead = async <T>(operation: (database: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
|
const withDbRead = async <T>(operation: (database: SQLite.SQLiteDatabase) => Promise<T>): Promise<T> => {
|
||||||
let database = await getDb();
|
let database = await getDb();
|
||||||
try {
|
try {
|
||||||
@@ -244,8 +253,7 @@ const withDbRead = async <T>(operation: (database: SQLite.SQLiteDatabase) => Pro
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error('数据库读取异常,尝试重连后重试:', error);
|
console.error('数据库读取异常,尝试重连后重试:', error);
|
||||||
db = null;
|
database = await recoverDbConnection();
|
||||||
database = await getDb();
|
|
||||||
return operation(database);
|
return operation(database);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -259,7 +267,7 @@ const enqueueWrite = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error('数据库写入异常,尝试重连后重试:', error);
|
console.error('数据库写入异常,尝试重连后重试:', error);
|
||||||
db = null;
|
await recoverDbConnection();
|
||||||
return operation();
|
return operation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -240,11 +240,6 @@ class SSEService {
|
|||||||
} catch {
|
} catch {
|
||||||
payload = {};
|
payload = {};
|
||||||
}
|
}
|
||||||
console.log('[SSE] 收到消息:', {
|
|
||||||
event: eventName,
|
|
||||||
lastEventId: this.lastEventId,
|
|
||||||
payload,
|
|
||||||
});
|
|
||||||
this.dispatchEvent(eventName, payload);
|
this.dispatchEvent(eventName, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,29 @@ class MessageManager {
|
|||||||
return Array.from(merged.values()).sort((a, b) => a.seq - b.seq);
|
return Array.from(merged.values()).sort((a, b) => a.seq - b.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史消息合并优化:
|
||||||
|
* - 常见场景:incoming 全部比 existing 更旧,直接前插,避免全量排序
|
||||||
|
* - 异常/重叠场景:回退到通用 mergeMessagesById
|
||||||
|
*/
|
||||||
|
private mergeOlderMessages(existing: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] {
|
||||||
|
if (incoming.length === 0) return existing;
|
||||||
|
|
||||||
|
const incomingAsc = [...incoming].sort((a, b) => a.seq - b.seq);
|
||||||
|
if (existing.length === 0) return incomingAsc;
|
||||||
|
|
||||||
|
const existingMinSeq = existing[0]?.seq ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const incomingMaxSeq = incomingAsc[incomingAsc.length - 1]?.seq ?? Number.MIN_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// 纯历史前插快路径:避免全量 merge + sort
|
||||||
|
if (incomingMaxSeq < existingMinSeq) {
|
||||||
|
return [...incomingAsc, ...existing];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:存在重叠/乱序时走通用路径
|
||||||
|
return this.mergeMessagesById(existing, incomingAsc);
|
||||||
|
}
|
||||||
|
|
||||||
private updateConversationList() {
|
private updateConversationList() {
|
||||||
// 会话排序:置顶优先,再按最后消息时间排序
|
// 会话排序:置顶优先,再按最后消息时间排序
|
||||||
const list = Array.from(this.state.conversations.values()).sort((a, b) => {
|
const list = Array.from(this.state.conversations.values()).sort((a, b) => {
|
||||||
@@ -1513,7 +1536,8 @@ class MessageManager {
|
|||||||
|
|
||||||
if (localMessages.length >= limit) {
|
if (localMessages.length >= limit) {
|
||||||
// 本地有足够数据
|
// 本地有足够数据
|
||||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
// 本地查询是 seq DESC,这里转成 ASC,匹配渲染时间序
|
||||||
|
const formattedMessages: MessageResponse[] = [...localMessages].reverse().map(m => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
conversation_id: m.conversationId,
|
conversation_id: m.conversationId,
|
||||||
sender_id: m.senderId,
|
sender_id: m.senderId,
|
||||||
@@ -1525,7 +1549,7 @@ class MessageManager {
|
|||||||
|
|
||||||
// 合并到现有消息
|
// 合并到现有消息
|
||||||
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
||||||
const mergedMessages = this.mergeMessagesById(existingMessages, formattedMessages);
|
const mergedMessages = this.mergeOlderMessages(existingMessages, formattedMessages);
|
||||||
this.state.messagesMap.set(conversationId, mergedMessages);
|
this.state.messagesMap.set(conversationId, mergedMessages);
|
||||||
|
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
@@ -1561,7 +1585,7 @@ class MessageManager {
|
|||||||
|
|
||||||
// 合并消息
|
// 合并消息
|
||||||
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
const existingMessages = this.state.messagesMap.get(conversationId) || [];
|
||||||
const mergedMessages = this.mergeMessagesById(existingMessages, serverMessages);
|
const mergedMessages = this.mergeOlderMessages(existingMessages, serverMessages);
|
||||||
this.state.messagesMap.set(conversationId, mergedMessages);
|
this.state.messagesMap.set(conversationId, mergedMessages);
|
||||||
|
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
|
|||||||
Reference in New Issue
Block a user