refactor(LocalDataSource, PostRepository, ChatScreen): enhance database handling and message loading
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m41s
Frontend CI / ota-android (push) Successful in 12m45s
Frontend CI / build-android-apk (push) Has been cancelled

- 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:
lafay
2026-03-23 23:06:19 +08:00
parent 7305254e11
commit c98f1917f7
9 changed files with 396 additions and 185 deletions

View File

@@ -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');
} }
} }

View File

@@ -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.enqueueWrite(async () => {
await this.localDb.run( await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[post.id, JSON.stringify(post), new Date().toISOString()] [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.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); 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.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache'); await this.localDb.run('DELETE FROM posts_cache');
});
} catch (error) { } catch (error) {
console.error('[PostRepository] 清除所有缓存失败:', error); console.error('[PostRepository] 清除所有缓存失败:', error);
} }

View File

@@ -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,10 +223,21 @@ export const ChatScreen: React.FC = () => {
}, [navigation]); }, [navigation]);
// 渲染消息气泡 // 渲染消息气泡
const renderMessage = ({ item, index }: { item: any; index: number }) => ( const messageIndexMap = useMemo(() => {
const map = new Map<string, number>();
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 (
<MessageBubble <MessageBubble
message={item} message={item}
index={index} index={logicalIndex}
currentUserId={currentUserId} currentUserId={currentUserId}
currentUser={currentUser} currentUser={currentUser}
otherUser={otherUser} otherUser={otherUser}
@@ -227,17 +255,60 @@ export const ChatScreen: React.FC = () => {
onReply={handleReplyMessage} 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}
onLayout={e => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}}
>
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary.main} /> <ActivityIndicator size="large" color={colors.primary.main} />
</View> </View>
) : ( ) : (
<FlashList <FlatList
ref={flatListRef} ref={flatListRef}
data={messages} inverted
data={displayMessages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={(item: any) => String(item.id)} keyExtractor={(item: any) => String(item.id)}
contentContainerStyle={listContentStyle as any} contentContainerStyle={listContentStyle as any}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
refreshing={loadingMore}
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
// @ts-ignore FlashList 类型定义问题estimatedItemSize 是必需属性但类型定义缺失
estimatedItemSize={80}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: undefined,
} as any}
onScroll={handleMessageListScroll} 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> </View>
</TouchableWithoutFeedback>
{/* 底部区域:输入框 + 面板 */} {/* 底部区域:输入框 + 面板 */}
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}> <View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>

View File

@@ -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) {

View File

@@ -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>
); );
}; };

View File

@@ -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 // 滚动状态机 refsTelegram/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 (
loading ||
loadingMore ||
messages.length === 0 ||
hasInitialAnchorDoneRef.current ||
scrollPositionRef.current.viewportHeight <= 0
) {
return;
}
hasInitialAnchorDoneRef.current = true;
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (shouldAutoScrollOnEnterRef.current) { scrollToLatest(false, true, 'initial-anchor');
flatListRef.current?.scrollToEnd({ animated: false }); }, 0);
}
}, 500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }, [loading, loadingMore, messages.length, scrollToLatest]);
}, [loading, loadingMore, messages.length]);
// 新消息跟随策略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 => {
// 没有新增最新消息,不重复上报
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); console.error('[ChatScreen] 自动标记已读失败:', error);
}); });
} }, [messages, conversationId, markAsRead, loading, loadingMore, isNearBottom]);
}, [messages, conversationId, markAsRead]);
// 使用 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');
if (isNearBottom()) {
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: false }); scrollToLatest(false, false, 'keyboard-show');
}, 150); }, 150);
}
}; };
const keyboardWillHide = () => { const keyboardWillHide = () => {
setKeyboardHeight(0); setKeyboardHeight(0);
if (isNearBottom()) {
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: false }); scrollToLatest(false, false, 'keyboard-hide');
}, 100); }, 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,
}; };
}; };

View File

@@ -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();
} }
}; };

View File

@@ -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);
} }

View File

@@ -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({