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

@@ -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<LongPressMenuProps> = ({
}) => {
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<LongPressMenuProps> = ({
visible={visible}
transparent
animationType="none"
onShow={blurActiveElementOnWeb}
onRequestClose={onClose}
>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.qqMenuOverlay}>
<TouchableWithoutFeedback>
<Pressable
onPress={() => {
blurActiveElementOnWeb();
onClose();
}}
style={styles.qqMenuOverlay}
>
<View>
<Pressable onPress={() => {}}>
<Animated.View
style={[
styles.qqMenuContainer,
@@ -286,9 +301,9 @@ export const LongPressMenu: React.FC<LongPressMenuProps> = ({
))}
</View>
</Animated.View>
</TouchableWithoutFeedback>
</Pressable>
</View>
</TouchableWithoutFeedback>
</Pressable>
</Modal>
);
};

View File

@@ -114,10 +114,15 @@ export const useChatScreen = () => {
const flatListRef = useRef<any>(null);
const textInputRef = useRef<any>(null);
// 防止重复加载的 ref
const shouldAutoScrollOnEnterRef = useRef(true);
const autoScrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
const scrollPositionRef = useRef({ contentHeight: 0, scrollY: 0 });
// 滚动状态机 refsTelegram/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<GroupMessage | null>(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,
};
};