From c98f1917f733dc41f500aaca7351514873d2064f Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 23:06:19 +0800 Subject: [PATCH] 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. --- src/data/datasources/LocalDataSource.ts | 27 ++ src/data/repositories/PostRepository.ts | 18 +- src/screens/message/ChatScreen.tsx | 206 +++++++++++---- src/screens/message/MessageListScreen.tsx | 5 +- .../components/ChatScreen/LongPressMenu.tsx | 35 ++- .../components/ChatScreen/useChatScreen.ts | 239 ++++++++++-------- src/services/database.ts | 16 +- src/services/sseService.ts | 5 - src/stores/messageManager.ts | 30 ++- 9 files changed, 396 insertions(+), 185 deletions(-) diff --git a/src/data/datasources/LocalDataSource.ts b/src/data/datasources/LocalDataSource.ts index 7126779..a0b5e1e 100644 --- a/src/data/datasources/LocalDataSource.ts +++ b/src/data/datasources/LocalDataSource.ts @@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource { private db: SQLite.SQLiteDatabase | null = null; private dbName: string; private initialized = false; + private initializingPromise: Promise | null = null; constructor(config: LocalDataSourceConfig = {}) { // 如果提供了userId,使用用户专属数据库 @@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource { return; } + // 防止并发初始化导致连接状态竞争 + if (this.initializingPromise) { + await this.initializingPromise; + return; + } + + this.initializingPromise = this.doInitialize(); + try { + await this.initializingPromise; + } finally { + this.initializingPromise = null; + } + } + + private async doInitialize(): Promise { try { // 使用全局实例管理 if (dbInstance && currentDbName === this.dbName) { @@ -227,6 +243,17 @@ export class LocalDataSource implements ILocalDataSource { } return await db.runAsync(sql); } 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'); } } diff --git a/src/data/repositories/PostRepository.ts b/src/data/repositories/PostRepository.ts index a592c98..0c189be 100644 --- a/src/data/repositories/PostRepository.ts +++ b/src/data/repositories/PostRepository.ts @@ -195,10 +195,12 @@ export class PostRepository implements IPostRepository { private async saveToLocalCache(post: Post): Promise { try { await this.localDb.initialize(); - await this.localDb.run( - `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, - [post.id, JSON.stringify(post), new Date().toISOString()] - ); + await this.localDb.enqueueWrite(async () => { + await this.localDb.run( + `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, + [post.id, JSON.stringify(post), new Date().toISOString()] + ); + }); } catch (error) { console.error('[PostRepository] 保存本地缓存失败:', error); } @@ -210,7 +212,9 @@ export class PostRepository implements IPostRepository { private async clearLocalCache(id: string): Promise { try { 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) { console.error('[PostRepository] 清除本地缓存失败:', error); } @@ -545,7 +549,9 @@ export class PostRepository implements IPostRepository { this.memoryCache.clear(); try { 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) { console.error('[PostRepository] 清除所有缓存失败:', error); } diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 5dafe28..5211e47 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -22,12 +22,11 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { View, - TouchableWithoutFeedback, + FlatList, ActivityIndicator, KeyboardAvoidingView, Platform, } from 'react-native'; -import { FlashList } from "@shopify/flash-list"; import { useNavigation } from '@react-navigation/native'; import { StatusBar } from 'expo-status-bar'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -78,6 +77,15 @@ export const ChatScreen: React.FC = () => { // 输入框区域高度(用于定位浮动 mention 面板) 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 | null>(null); const containerStyle = useMemo(() => ([ styles.container, @@ -194,8 +202,17 @@ export const ChatScreen: React.FC = () => { navigateToChatSettings, loadMoreHistory, handleMessageListContentSizeChange, + handleReachLatestEdge, } = useChatScreen(); + useEffect(() => { + return () => { + if (preloadCooldownTimerRef.current) { + clearTimeout(preloadCooldownTimerRef.current); + } + }; + }, []); + // 监听返回事件,刷新会话列表 useEffect(() => { const unsubscribe = navigation.addListener('beforeRemove', () => { @@ -206,38 +223,92 @@ export const ChatScreen: React.FC = () => { }, [navigation]); // 渲染消息气泡 - const renderMessage = ({ item, index }: { item: any; index: number }) => ( - - ); + const messageIndexMap = useMemo(() => { + const map = new Map(); + messages.forEach((msg, idx) => { + map.set(String(msg.id), idx); + }); + return map; + }, [messages]); + + const renderMessage = ({ item, index }: { item: any; index: number }) => { + // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 + const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; + return ( + + ); + }; // 获取正在输入提示 const typingHint = getTypingHint(); + const displayMessages = useMemo(() => [...messages].reverse(), [messages]); const handleMessageListScroll = useCallback((event: any) => { - const { contentSize, contentOffset } = event.nativeEvent; + const { contentSize, contentOffset, layoutMeasurement } = event.nativeEvent; scrollPositionRef.current = { contentHeight: contentSize.height, 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 ( { /> {/* 消息列表 */} - - - {loading ? ( - - + { + scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; + }} + > + {loading ? ( + + + + ) : ( + 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 && ( + + + - ) : ( - 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} - /> - )} - - + + )} + {/* 底部区域:输入框 + 面板 */} 0 ? keyboardHeight : 0 }}> diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index 2694356..e128038 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -23,6 +23,7 @@ import { TextInput, Keyboard, BackHandler, + Platform, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useIsFocused } from '@react-navigation/native'; @@ -334,12 +335,12 @@ export const MessageListScreen: React.FC = () => { Animated.timing(scaleAnims[index], { toValue: 0.98, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(scaleAnims[index], { toValue: 1, duration: 100, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(() => { if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) { diff --git a/src/screens/message/components/ChatScreen/LongPressMenu.tsx b/src/screens/message/components/ChatScreen/LongPressMenu.tsx index 3c21ddf..1c6f901 100644 --- a/src/screens/message/components/ChatScreen/LongPressMenu.tsx +++ b/src/screens/message/components/ChatScreen/LongPressMenu.tsx @@ -7,11 +7,12 @@ import { View, Modal, TouchableOpacity, - TouchableWithoutFeedback, + Pressable, Animated, Alert, Clipboard, Dimensions, + Platform, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { chatScreenStyles as styles } from './styles'; @@ -35,34 +36,41 @@ export const LongPressMenu: React.FC = ({ }) => { const scaleAnimation = 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(() => { if (visible) { + blurActiveElementOnWeb(); Animated.parallel([ Animated.spring(scaleAnimation, { toValue: 1, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', friction: 8, tension: 100, }), Animated.timing(opacityAnimation, { toValue: 1, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } else { + blurActiveElementOnWeb(); Animated.parallel([ Animated.timing(scaleAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), Animated.timing(opacityAnimation, { toValue: 0, duration: 150, - useNativeDriver: true, + useNativeDriver: Platform.OS !== 'web', }), ]).start(); } @@ -242,11 +250,18 @@ export const LongPressMenu: React.FC = ({ visible={visible} transparent animationType="none" + onShow={blurActiveElementOnWeb} onRequestClose={onClose} > - - - + { + blurActiveElementOnWeb(); + onClose(); + }} + style={styles.qqMenuOverlay} + > + + {}}> = ({ ))} - + - + ); }; diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 77351b6..d622f94 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -114,10 +114,15 @@ export const useChatScreen = () => { const flatListRef = useRef(null); const textInputRef = useRef(null); - // 防止重复加载的 ref - const shouldAutoScrollOnEnterRef = useRef(true); - const autoScrollTimersRef = useRef[]>([]); - const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0 }); + // 滚动状态机 refs(Telegram/Element 风格:底部粘附 + 阅读锚点) + const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0, viewportHeight: 0 }); + const hasInitialAnchorDoneRef = useRef(false); + 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(null); @@ -196,10 +201,13 @@ export const useChatScreen = () => { return '对方未关注你前:仅可发送1条文字消息,且不能发送图片'; }, [followRestricted, myPrivateSentCount]); - // 【改造】同步加载状态 + // 加载态语义修正: + // isLoadingMessages 在分页加载历史时也会短暂为 true。 + // 若直接驱动 ChatScreen 的 loading,会导致消息列表被卸载重挂载,触发“回到底部”。 + // 这里只在“首屏且尚无消息”时展示 loading 占位。 useEffect(() => { - setLoading(isLoadingMessages); - }, [isLoadingMessages]); + setLoading(isLoadingMessages && messageManagerMessages.length === 0); + }, [isLoadingMessages, messageManagerMessages.length]); // 【改造】同步 hasMore 状态 useEffect(() => { @@ -263,32 +271,73 @@ export const useChatScreen = () => { }; }, [isGroupChat, otherUserId]); - // 进入新会话时重置首次自动滚动标记 + // 进入新会话时重置滚动状态 useEffect(() => { - shouldAutoScrollOnEnterRef.current = true; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + hasInitialAnchorDoneRef.current = false; + prevMessageCountRef.current = 0; + prevLatestSeqRef.current = 0; + prevMarkedReadSeqRef.current = 0; + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; }, [conversationId]); - // 组件卸载时清理定时器 - useEffect(() => { - return () => { - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; - }; - }, []); + const scrollToLatest = useCallback((animated: boolean, force: boolean = false, reason: string = 'unknown') => { + if (!force && (suppressAutoFollowRef.current || isBrowsingHistoryRef.current)) return; + // inverted 列表下,最新消息端对应 offset=0 + isProgrammaticScrollRef.current = true; + 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(() => { - if (!loading && !loadingMore && messages.length > 0 && shouldAutoScrollOnEnterRef.current) { - const timer = setTimeout(() => { - if (shouldAutoScrollOnEnterRef.current) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }, 500); - return () => clearTimeout(timer); + if ( + loading || + loadingMore || + messages.length === 0 || + hasInitialAnchorDoneRef.current || + scrollPositionRef.current.viewportHeight <= 0 + ) { + 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(() => { @@ -384,22 +433,18 @@ export const useChatScreen = () => { }; }, [routeUserId, conversationId, currentUserId, isGroupChat]); - // 【改造】加载更多历史消息 + // 加载更多历史消息(inverted 下保持阅读锚点) const loadMoreHistory = useCallback(async () => { if (!conversationId || !hasMoreHistory || loadingMore) { return; } - // 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转 - shouldAutoScrollOnEnterRef.current = false; - // 清除所有待执行的自动滚动定时器 - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + // 历史加载期间禁止“新消息自动跟随到底” + suppressAutoFollowRef.current = true; // 保存加载前的滚动位置和内容高度 const scrollYBefore = scrollPositionRef.current.scrollY; const contentHeightBefore = scrollPositionRef.current.contentHeight; - setLoadingMore(true); try { @@ -411,22 +456,8 @@ export const useChatScreen = () => { setFirstSeq(minSeq); } - // 加载完成后,恢复滚动位置 - // 使用 setTimeout 确保 FlashList 已经更新 - 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); + // inverted + maintainVisibleContentPosition 下由列表原生保持位置 + // 不做手动 scrollToOffset,避免与原生锚点冲突导致回到底部 } catch (error) { console.error('加载历史消息失败:', error); } finally { @@ -434,48 +465,44 @@ export const useChatScreen = () => { } }, [conversationId, hasMoreHistory, loadingMore, loadMoreMessages, messages]); - // 列表内容尺寸变化后触发首次自动滚动 + // 列表内容尺寸变化:仅同步内容高度,锚点补偿由 loadMoreHistory 统一处理 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; + }, []); - // 如果内容高度增加(加载了更多消息),不要自动滚动到底部 - if (prevContentHeight > 0 && contentHeight > prevContentHeight) { - return; - } + const handleReachLatestEdge = useCallback(() => { + suppressAutoFollowRef.current = false; + isBrowsingHistoryRef.current = false; + }, []); - shouldAutoScrollOnEnterRef.current = false; - autoScrollTimersRef.current.forEach(clearTimeout); - autoScrollTimersRef.current = []; + const setBrowsingHistory = useCallback((browsing: boolean) => { + isBrowsingHistoryRef.current = browsing; + }, []); - [100, 300, 500, 800, 1200].forEach(delay => { - const timer = setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, delay); - autoScrollTimersRef.current.push(timer); - }); - }, [loading, loadingMore, messages.length]); - - // 【改造】自动标记已读 - 当有新消息且是当前会话时 + // 自动标记已读(QQ/Telegram 风格): + // 仅当出现更大的 latest seq,且用户在最新端附近时才上报。 + // 历史加载/浏览历史不会触发 read。 useEffect(() => { 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); - if (maxSeq > 0) { - markAsRead(maxSeq).catch(error => { - console.error('[ChatScreen] 自动标记已读失败:', error); - }); - } - }, [messages, conversationId, markAsRead]); + const latestSeq = Math.max(...messages.map(m => m.seq), 0); + if (latestSeq <= 0) return; + + // 没有新增最新消息,不重复上报 + if (latestSeq <= prevLatestSeqRef.current) return; + prevLatestSeqRef.current = latestSeq; + + // 避免对同一 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 const groupMembersRef = useRef(groupMembers); @@ -494,16 +521,20 @@ export const useChatScreen = () => { const keyboardWillShow = (e: KeyboardEvent) => { setKeyboardHeight(e.endCoordinates.height); setActivePanel(prev => prev === 'mention' ? 'mention' : 'none'); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 150); + if (isNearBottom()) { + setTimeout(() => { + scrollToLatest(false, false, 'keyboard-show'); + }, 150); + } }; const keyboardWillHide = () => { setKeyboardHeight(0); - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); - }, 100); + if (isNearBottom()) { + setTimeout(() => { + scrollToLatest(false, false, 'keyboard-hide'); + }, 100); + } }; const showSubscription = Keyboard.addListener( @@ -519,7 +550,7 @@ export const useChatScreen = () => { showSubscription.remove(); hideSubscription.remove(); }; - }, []); + }, [scrollToLatest, isNearBottom]); // 格式化时间 const formatTime = useCallback((dateString: string): string => { @@ -773,7 +804,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-text'); }, 100); } catch (error) { console.error('发送消息失败:', error); @@ -781,7 +812,7 @@ export const useChatScreen = () => { } finally { 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) => { @@ -823,7 +854,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-image'); }, 100); } catch (error) { console.error('发送图片失败:', error); @@ -831,7 +862,7 @@ export const useChatScreen = () => { } finally { 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 () => { @@ -950,7 +981,7 @@ export const useChatScreen = () => { } setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); + scrollToLatest(false, false, 'send-sticker'); }, 100); } catch (error) { console.error('发送自定义表情失败:', error); @@ -958,35 +989,35 @@ export const useChatScreen = () => { } finally { setSendingImage(false); } - }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage]); + }, [conversationId, isGroupChat, routeGroupId, sendMessageViaManager, isMuted, muteAll, buildImageSegments, replyingTo, canSendPrivateImage, scrollToLatest]); // 切换表情面板 const toggleEmojiPanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'emoji' ? 'none' : 'emoji'; - if (newPanel !== 'none') { + if (newPanel !== 'none' && isNearBottom()) { setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); + scrollToLatest(false, false, 'open-emoji-panel'); }, 200); } return newPanel; }); - }, []); + }, [scrollToLatest, isNearBottom]); // 切换更多功能面板 const toggleMorePanel = useCallback(() => { Keyboard.dismiss(); setActivePanel(prev => { const newPanel = prev === 'more' ? 'none' : 'more'; - if (newPanel !== 'none') { + if (newPanel !== 'none' && isNearBottom()) { setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: false }); + scrollToLatest(false, false, 'open-more-panel'); }, 200); } return newPanel; }); - }, []); + }, [scrollToLatest, isNearBottom]); // 关闭面板 const closePanel = useCallback(() => { @@ -1245,5 +1276,7 @@ export const useChatScreen = () => { loadMoreHistory, handleClearConversation, handleMessageListContentSizeChange, + handleReachLatestEdge, + setBrowsingHistory, }; }; diff --git a/src/services/database.ts b/src/services/database.ts index 60dbd4e..8a5294e 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -231,10 +231,19 @@ const isRecoverableDbError = (error: unknown): boolean => { const message = String(error); return ( 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 => { + // 清理当前引用,随后用当前用户上下文重建连接 + db = null; + await initDatabase(currentDbUserId || undefined); + return getDb(); +}; + const withDbRead = async (operation: (database: SQLite.SQLiteDatabase) => Promise): Promise => { let database = await getDb(); try { @@ -244,8 +253,7 @@ const withDbRead = async (operation: (database: SQLite.SQLiteDatabase) => Pro throw error; } console.error('数据库读取异常,尝试重连后重试:', error); - db = null; - database = await getDb(); + database = await recoverDbConnection(); return operation(database); } }; @@ -259,7 +267,7 @@ const enqueueWrite = async (operation: () => Promise): Promise => { throw error; } console.error('数据库写入异常,尝试重连后重试:', error); - db = null; + await recoverDbConnection(); return operation(); } }; diff --git a/src/services/sseService.ts b/src/services/sseService.ts index 45b4fe0..867c7d1 100644 --- a/src/services/sseService.ts +++ b/src/services/sseService.ts @@ -240,11 +240,6 @@ class SSEService { } catch { payload = {}; } - console.log('[SSE] 收到消息:', { - event: eventName, - lastEventId: this.lastEventId, - payload, - }); this.dispatchEvent(eventName, payload); } diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index a4c11f0..e0dff90 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -287,6 +287,29 @@ class MessageManager { 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() { // 会话排序:置顶优先,再按最后消息时间排序 const list = Array.from(this.state.conversations.values()).sort((a, b) => { @@ -1513,7 +1536,8 @@ class MessageManager { if (localMessages.length >= limit) { // 本地有足够数据 - const formattedMessages: MessageResponse[] = localMessages.map(m => ({ + // 本地查询是 seq DESC,这里转成 ASC,匹配渲染时间序 + const formattedMessages: MessageResponse[] = [...localMessages].reverse().map(m => ({ id: m.id, conversation_id: m.conversationId, sender_id: m.senderId, @@ -1525,7 +1549,7 @@ class MessageManager { // 合并到现有消息 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.notifySubscribers({ @@ -1561,7 +1585,7 @@ class MessageManager { // 合并消息 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.notifySubscribers({