refactor(ProcessPostUseCase, useCursorPagination, useDifferentialPosts): enhance post merging and loading states
- Introduced new methods for merging posts and comments efficiently, improving performance during data updates. - Updated loading state management in hooks to include initial loading and loading more states for better user feedback. - Refactored HomeScreen and PostDetailScreen to utilize the new loading states and merging logic, enhancing user experience during data fetching. - Improved pagination handling in useCursorPagination and useDifferentialPosts to ensure consistent data management across components.
This commit is contained in:
@@ -119,6 +119,8 @@ export const HomeScreen: React.FC = () => {
|
||||
const {
|
||||
posts,
|
||||
loading: isLoading,
|
||||
isInitialLoading,
|
||||
isLoadingMore,
|
||||
refreshing: isRefreshing,
|
||||
hasMore,
|
||||
error,
|
||||
@@ -135,17 +137,23 @@ export const HomeScreen: React.FC = () => {
|
||||
|
||||
// 加载更多方法
|
||||
const loadMore = useCallback(async () => {
|
||||
if (hasMore && !isLoading && !isLoadingMoreRef.current) {
|
||||
if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) {
|
||||
isLoadingMoreRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await processPostUseCase.loadMorePosts(listKey);
|
||||
console.log('[PostPerf] home loadMore', {
|
||||
listKey,
|
||||
costMs: Date.now() - startedAt,
|
||||
totalItems: posts.length,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('加载更多失败:', err);
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}
|
||||
}, [listKey, hasMore, isLoading]);
|
||||
}, [posts.length, listKey, hasMore, isLoadingMore]);
|
||||
|
||||
// 网格模式滚动处理 - 检测是否滚动到底部
|
||||
const handleGridScroll = useCallback((event: any) => {
|
||||
@@ -366,7 +374,9 @@ export const HomeScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
// 渲染帖子卡片(列表模式)
|
||||
const renderPostList = ({ item }: { item: Post }) => {
|
||||
const keyExtractor = useCallback((item: Post) => item.id, []);
|
||||
|
||||
const renderPostList = useCallback(({ item }: { item: Post }) => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
@@ -394,7 +404,7 @@ export const HomeScreen: React.FC = () => {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
}, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]);
|
||||
|
||||
// 估算帖子在瀑布流中的高度(用于均匀分配)
|
||||
const estimatePostHeight = (post: Post, columnWidth: number): number => {
|
||||
@@ -558,8 +568,8 @@ export const HomeScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (isLoading) return null;
|
||||
const renderEmpty = useCallback(() => {
|
||||
if (isInitialLoading) return null;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -567,7 +577,7 @@ export const HomeScreen: React.FC = () => {
|
||||
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [activeIndex, isInitialLoading]);
|
||||
|
||||
// 渲染列表内容
|
||||
const renderListContent = () => {
|
||||
@@ -581,7 +591,7 @@ export const HomeScreen: React.FC = () => {
|
||||
<FlatList
|
||||
data={displayPosts}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={item => item.id}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
@@ -602,7 +612,12 @@ export const HomeScreen: React.FC = () => {
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
|
||||
ListFooterComponent={isLoadingMore ? <Loading size="sm" /> : null}
|
||||
initialNumToRender={8}
|
||||
maxToRenderPerBatch={8}
|
||||
updateCellsBatchingPeriod={60}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -661,7 +676,9 @@ export const HomeScreen: React.FC = () => {
|
||||
// 移动端:使用 GestureDetector 支持手势切换 Tab
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<View style={styles.contentContainer}>
|
||||
{viewMode === 'list' ? (
|
||||
{isInitialLoading ? (
|
||||
<Loading fullScreen />
|
||||
) : viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
// 网格模式:使用响应式网格
|
||||
@@ -672,7 +689,9 @@ export const HomeScreen: React.FC = () => {
|
||||
) : (
|
||||
// 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击
|
||||
<View style={styles.contentContainer}>
|
||||
{viewMode === 'list' ? (
|
||||
{isInitialLoading ? (
|
||||
<Loading fullScreen />
|
||||
) : viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
// 网格模式:使用响应式网格
|
||||
|
||||
@@ -67,13 +67,15 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
const [post, setPost] = useState<Post | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [isPostInitialLoading, setIsPostInitialLoading] = useState(true);
|
||||
const [isPostRefreshing, setIsPostRefreshing] = useState(false);
|
||||
|
||||
// 使用游标分页 Hook 管理评论列表
|
||||
const {
|
||||
list: paginatedComments,
|
||||
isLoading: isCommentsLoading,
|
||||
isInitialLoading: isCommentsInitialLoading,
|
||||
isLoadingMore: isCommentsLoadingMore,
|
||||
isRefreshing: isCommentsRefreshing,
|
||||
hasMore: hasMoreComments,
|
||||
loadMore: loadMoreComments,
|
||||
@@ -88,11 +90,71 @@ export const PostDetailScreen: React.FC = () => {
|
||||
},
|
||||
{ pageSize: 20 }
|
||||
);
|
||||
|
||||
const getCommentId = useCallback((comment: Comment) => String(comment.id), []);
|
||||
|
||||
const mergeCommentsById = useCallback((previousInput: Comment[] | null | undefined, incomingInput: Comment[] | null | undefined): Comment[] => {
|
||||
const previous = Array.isArray(previousInput) ? previousInput : [];
|
||||
const incoming = Array.isArray(incomingInput) ? incomingInput : [];
|
||||
if (incoming.length === 0) return previous;
|
||||
if (previous.length === 0) return incoming;
|
||||
|
||||
const previousSet = new Set(previous.map(getCommentId));
|
||||
const incomingFirstId = getCommentId(incoming[0]);
|
||||
if (!previousSet.has(incomingFirstId)) {
|
||||
return [...previous, ...incoming];
|
||||
}
|
||||
|
||||
const merged = [...previous];
|
||||
const indexById = new Map<string, number>();
|
||||
for (let i = 0; i < merged.length; i += 1) {
|
||||
indexById.set(getCommentId(merged[i]), i);
|
||||
}
|
||||
|
||||
for (const comment of incoming) {
|
||||
const id = getCommentId(comment);
|
||||
const existingIndex = indexById.get(id);
|
||||
if (existingIndex === undefined) {
|
||||
indexById.set(id, merged.length);
|
||||
merged.push(comment);
|
||||
} else {
|
||||
merged[existingIndex] = comment;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}, [getCommentId]);
|
||||
|
||||
const mergeCommentsRefreshWindow = useCallback((previousInput: Comment[] | null | undefined, latestInput: Comment[] | null | undefined): Comment[] => {
|
||||
const previous = Array.isArray(previousInput) ? previousInput : [];
|
||||
const latest = Array.isArray(latestInput) ? latestInput : [];
|
||||
if (latest.length === 0) return previous;
|
||||
if (previous.length === 0) return latest;
|
||||
|
||||
const latestSet = new Set(latest.map(getCommentId));
|
||||
const tail = previous.filter((item) => !latestSet.has(getCommentId(item)));
|
||||
return [...latest, ...tail];
|
||||
}, [getCommentId]);
|
||||
|
||||
// 同步分页评论到本地状态
|
||||
// 切换帖子时,先清空上一帖评论,避免跨帖残留。
|
||||
useEffect(() => {
|
||||
setComments(paginatedComments);
|
||||
}, [paginatedComments]);
|
||||
setComments([]);
|
||||
}, [postId]);
|
||||
|
||||
// 同步分页评论到本地状态(增量合并,避免全量替换)
|
||||
useEffect(() => {
|
||||
setComments((prev) => {
|
||||
if (isCommentsRefreshing) {
|
||||
return mergeCommentsRefreshWindow(prev, paginatedComments);
|
||||
}
|
||||
return mergeCommentsById(prev, paginatedComments);
|
||||
});
|
||||
}, [isCommentsRefreshing, mergeCommentsById, mergeCommentsRefreshWindow, paginatedComments]);
|
||||
|
||||
useEffect(() => {
|
||||
if (commentsError) {
|
||||
console.warn('[PostPerf] comments request error', { postId, error: commentsError });
|
||||
}
|
||||
}, [commentsError, postId]);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
@@ -131,7 +193,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
// 加载帖子详情(可选择是否记录浏览量)
|
||||
const loadPostDetail = useCallback(async (recordView: boolean = false) => {
|
||||
if (!postId) {
|
||||
setLoading(false);
|
||||
setIsPostInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,7 +250,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
} catch (error) {
|
||||
console.error('加载帖子详情失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsPostInitialLoading(false);
|
||||
}
|
||||
}, [postId]);
|
||||
|
||||
@@ -206,13 +268,13 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
|
||||
useEffect(() => {
|
||||
if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) {
|
||||
if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) {
|
||||
// 延迟确保列表已完成渲染和布局
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 });
|
||||
}, 500);
|
||||
}
|
||||
}, [shouldScrollToComments, loading, comments?.length]);
|
||||
}, [shouldScrollToComments, isPostInitialLoading, comments?.length]);
|
||||
|
||||
// 动态设置导航头部
|
||||
useEffect(() => {
|
||||
@@ -280,11 +342,26 @@ export const PostDetailScreen: React.FC = () => {
|
||||
|
||||
// 下拉刷新 - 同时刷新帖子和评论
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
const startedAt = Date.now();
|
||||
setIsPostRefreshing(true);
|
||||
await loadPostDetail();
|
||||
await refreshComments();
|
||||
setRefreshing(false);
|
||||
}, [loadPostDetail, refreshComments]);
|
||||
console.log('[PostPerf] detail refresh', {
|
||||
postId,
|
||||
costMs: Date.now() - startedAt,
|
||||
comments: comments.length,
|
||||
});
|
||||
setIsPostRefreshing(false);
|
||||
}, [comments.length, loadPostDetail, postId, refreshComments]);
|
||||
|
||||
const handleLoadMoreComments = useCallback(async () => {
|
||||
const startedAt = Date.now();
|
||||
await loadMoreComments();
|
||||
console.log('[PostPerf] detail loadMoreComments', {
|
||||
postId,
|
||||
costMs: Date.now() - startedAt,
|
||||
totalItems: comments.length,
|
||||
});
|
||||
}, [comments.length, loadMoreComments, postId]);
|
||||
|
||||
const formatDateTime = (dateString?: string | null): string => {
|
||||
if (!dateString) return '';
|
||||
@@ -1259,7 +1336,9 @@ export const PostDetailScreen: React.FC = () => {
|
||||
};
|
||||
|
||||
// 渲染评论 - 带楼层号
|
||||
const renderComment = ({ item, index }: { item: Comment; index: number }) => {
|
||||
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
|
||||
|
||||
const renderComment = useCallback(({ item, index }: { item: Comment; index: number }) => {
|
||||
const authorId = item.author?.id || '';
|
||||
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
|
||||
const allReplies = item.replies || [];
|
||||
@@ -1283,10 +1362,10 @@ export const PostDetailScreen: React.FC = () => {
|
||||
currentUserId={currentUser?.id}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]);
|
||||
|
||||
// 渲染空评论 - 现代化设计
|
||||
const renderEmptyComments = () => (
|
||||
const renderEmptyComments = useCallback(() => (
|
||||
<View style={styles.emptyCommentsContainer}>
|
||||
<View style={styles.emptyCommentsIconWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -1302,7 +1381,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
成为第一个评论的人吧~
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
), [isDesktop]);
|
||||
|
||||
// 渲染评论输入框
|
||||
const renderCommentInput = () => (
|
||||
@@ -1398,7 +1477,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
ref={flatListRef}
|
||||
data={comments}
|
||||
renderItem={renderComment}
|
||||
keyExtractor={item => item.id}
|
||||
keyExtractor={commentKeyExtractor}
|
||||
ListEmptyComponent={renderEmptyComments}
|
||||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -1409,23 +1488,32 @@ export const PostDetailScreen: React.FC = () => {
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing || isCommentsRefreshing}
|
||||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMoreComments}
|
||||
onEndReached={handleLoadMoreComments}
|
||||
onEndReachedThreshold={0.3}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={60}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
ListFooterComponent={
|
||||
isCommentsLoading ? (
|
||||
isCommentsInitialLoading ? (
|
||||
<View style={styles.commentsLoadingFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
) : isCommentsLoadingMore ? (
|
||||
<View style={styles.commentsLoadingFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
) : hasMoreComments ? (
|
||||
<TouchableOpacity
|
||||
style={styles.loadMoreButton}
|
||||
onPress={loadMoreComments}
|
||||
onPress={handleLoadMoreComments}
|
||||
>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
加载更多评论
|
||||
@@ -1456,7 +1544,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
</View>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
if (isPostInitialLoading) {
|
||||
return <Loading fullScreen />;
|
||||
}
|
||||
|
||||
@@ -1489,7 +1577,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing || isCommentsRefreshing}
|
||||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
@@ -1530,7 +1618,7 @@ export const PostDetailScreen: React.FC = () => {
|
||||
ref={flatListRef}
|
||||
data={comments}
|
||||
renderItem={renderComment}
|
||||
keyExtractor={item => item.id}
|
||||
keyExtractor={commentKeyExtractor}
|
||||
ListHeaderComponent={renderPostHeader}
|
||||
ListEmptyComponent={renderEmptyComments}
|
||||
contentContainerStyle={{ paddingBottom: responsiveGap }}
|
||||
@@ -1542,23 +1630,32 @@ export const PostDetailScreen: React.FC = () => {
|
||||
}}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing || isCommentsRefreshing}
|
||||
refreshing={isPostRefreshing || isCommentsRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMoreComments}
|
||||
onEndReached={handleLoadMoreComments}
|
||||
onEndReachedThreshold={0.3}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={60}
|
||||
windowSize={7}
|
||||
removeClippedSubviews
|
||||
ListFooterComponent={
|
||||
isCommentsLoading ? (
|
||||
isCommentsInitialLoading ? (
|
||||
<View style={styles.commentsLoadingFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
) : isCommentsLoadingMore ? (
|
||||
<View style={styles.commentsLoadingFooter}>
|
||||
<Loading size="sm" />
|
||||
</View>
|
||||
) : hasMoreComments ? (
|
||||
<TouchableOpacity
|
||||
style={styles.loadMoreButton}
|
||||
onPress={loadMoreComments}
|
||||
onPress={handleLoadMoreComments}
|
||||
>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
加载更多评论
|
||||
|
||||
@@ -77,6 +77,7 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
|
||||
const isPreloadingRef = useRef(false);
|
||||
const lastScrollYRef = useRef(0);
|
||||
const isUserDraggingRef = useRef(false);
|
||||
@@ -203,8 +204,15 @@ export const ChatScreen: React.FC = () => {
|
||||
loadMoreHistory,
|
||||
handleMessageListContentSizeChange,
|
||||
handleReachLatestEdge,
|
||||
setBrowsingHistory,
|
||||
} = useChatScreen();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingMore && showEdgeLoadingIndicator) {
|
||||
setShowEdgeLoadingIndicator(false);
|
||||
}
|
||||
}, [loadingMore, showEdgeLoadingIndicator]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preloadCooldownTimerRef.current) {
|
||||
@@ -231,7 +239,7 @@ export const ChatScreen: React.FC = () => {
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const renderMessage = ({ item, index }: { item: any; index: number }) => {
|
||||
const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => {
|
||||
// inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引
|
||||
const logicalIndex = messageIndexMap.get(String(item.id)) ?? index;
|
||||
return (
|
||||
@@ -255,7 +263,26 @@ export const ChatScreen: React.FC = () => {
|
||||
onReply={handleReplyMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}, [
|
||||
messageIndexMap,
|
||||
currentUserId,
|
||||
currentUser,
|
||||
otherUser,
|
||||
isGroupChat,
|
||||
groupMembers,
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
handleLongPressMessage,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
handleImagePress,
|
||||
handleReplyMessage,
|
||||
]);
|
||||
|
||||
const keyExtractor = useCallback((item: any) => String(item.id), []);
|
||||
|
||||
// 获取正在输入提示
|
||||
const typingHint = getTypingHint();
|
||||
@@ -269,6 +296,11 @@ export const ChatScreen: React.FC = () => {
|
||||
viewportHeight: layoutMeasurement.height,
|
||||
};
|
||||
|
||||
// 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底
|
||||
if (contentOffset.y > 120) {
|
||||
setBrowsingHistory(true);
|
||||
}
|
||||
|
||||
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
|
||||
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
|
||||
if (
|
||||
@@ -282,13 +314,19 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
// 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;
|
||||
// 预加载阈值:按屏幕高度动态提前(至少 420),确保边界前开始加载
|
||||
const PRELOAD_HISTORY_THRESHOLD = Math.max(420, layoutMeasurement.height * 1.2);
|
||||
// 仅在真正接近边界时才显示旋转提示
|
||||
const EDGE_LOADING_INDICATOR_THRESHOLD = 60;
|
||||
const shouldShowEdgeIndicator = loadingMore && distanceToHistoryEdge <= EDGE_LOADING_INDICATOR_THRESHOLD;
|
||||
if (shouldShowEdgeIndicator !== showEdgeLoadingIndicator) {
|
||||
setShowEdgeLoadingIndicator(shouldShowEdgeIndicator);
|
||||
}
|
||||
|
||||
if (
|
||||
distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD &&
|
||||
isScrollingTowardHistory &&
|
||||
hasMoreHistory &&
|
||||
!loadingMore &&
|
||||
!loading &&
|
||||
@@ -304,7 +342,7 @@ export const ChatScreen: React.FC = () => {
|
||||
}, 350);
|
||||
});
|
||||
}
|
||||
}, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge]);
|
||||
}, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]);
|
||||
|
||||
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||
handleMessageListContentSizeChange(contentWidth, contentHeight);
|
||||
@@ -338,6 +376,7 @@ export const ChatScreen: React.FC = () => {
|
||||
onLayout={e => {
|
||||
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
|
||||
}}
|
||||
onTouchEnd={handleDismiss}
|
||||
>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
@@ -349,10 +388,15 @@ export const ChatScreen: React.FC = () => {
|
||||
inverted
|
||||
data={displayMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item: any) => String(item.id)}
|
||||
keyExtractor={keyExtractor}
|
||||
contentContainerStyle={listContentStyle as any}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={14}
|
||||
maxToRenderPerBatch={10}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={9}
|
||||
removeClippedSubviews={Platform.OS !== 'web'}
|
||||
onScroll={handleMessageListScroll}
|
||||
onScrollBeginDrag={() => {
|
||||
isUserDraggingRef.current = true;
|
||||
@@ -368,7 +412,7 @@ export const ChatScreen: React.FC = () => {
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
/>
|
||||
)}
|
||||
{loadingMore && (
|
||||
{showEdgeLoadingIndicator && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={{
|
||||
|
||||
@@ -120,6 +120,7 @@ export const useChatScreen = () => {
|
||||
const prevMessageCountRef = useRef(0);
|
||||
const prevLatestSeqRef = useRef(0);
|
||||
const prevMarkedReadSeqRef = useRef(0);
|
||||
const enterMarkedKeyRef = useRef<string>('');
|
||||
const isProgrammaticScrollRef = useRef(false);
|
||||
const suppressAutoFollowRef = useRef(false);
|
||||
const isBrowsingHistoryRef = useRef(false);
|
||||
@@ -277,6 +278,7 @@ export const useChatScreen = () => {
|
||||
prevMessageCountRef.current = 0;
|
||||
prevLatestSeqRef.current = 0;
|
||||
prevMarkedReadSeqRef.current = 0;
|
||||
enterMarkedKeyRef.current = '';
|
||||
suppressAutoFollowRef.current = false;
|
||||
isBrowsingHistoryRef.current = false;
|
||||
}, [conversationId]);
|
||||
@@ -479,6 +481,29 @@ export const useChatScreen = () => {
|
||||
isBrowsingHistoryRef.current = browsing;
|
||||
}, []);
|
||||
|
||||
// 进入聊天详情后立即清未读(不依赖滚动位置)
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
if (!conversation) return;
|
||||
|
||||
const unreadCount = Number((conversation as any).unread_count || 0);
|
||||
if (unreadCount <= 0) return;
|
||||
|
||||
const latestFromConversation = Number((conversation as any).last_seq || 0);
|
||||
const latestFromMessages = messages.length > 0 ? Math.max(...messages.map(m => m.seq || 0)) : 0;
|
||||
const targetSeq = Math.max(latestFromConversation, latestFromMessages);
|
||||
if (targetSeq <= 0) return;
|
||||
|
||||
const markKey = `${conversationId}:${targetSeq}`;
|
||||
if (enterMarkedKeyRef.current === markKey) return;
|
||||
enterMarkedKeyRef.current = markKey;
|
||||
|
||||
prevMarkedReadSeqRef.current = Math.max(prevMarkedReadSeqRef.current, targetSeq);
|
||||
markAsRead(targetSeq).catch(error => {
|
||||
console.error('[ChatScreen] 进入会话清未读失败:', error);
|
||||
});
|
||||
}, [conversationId, conversation, messages, markAsRead]);
|
||||
|
||||
// 自动标记已读(QQ/Telegram 风格):
|
||||
// 仅当出现更大的 latest seq,且用户在最新端附近时才上报。
|
||||
// 历史加载/浏览历史不会触发 read。
|
||||
@@ -521,20 +546,10 @@ export const useChatScreen = () => {
|
||||
const keyboardWillShow = (e: KeyboardEvent) => {
|
||||
setKeyboardHeight(e.endCoordinates.height);
|
||||
setActivePanel(prev => prev === 'mention' ? 'mention' : 'none');
|
||||
if (isNearBottom()) {
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, 'keyboard-show');
|
||||
}, 150);
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardWillHide = () => {
|
||||
setKeyboardHeight(0);
|
||||
if (isNearBottom()) {
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, 'keyboard-hide');
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const showSubscription = Keyboard.addListener(
|
||||
@@ -550,7 +565,7 @@ export const useChatScreen = () => {
|
||||
showSubscription.remove();
|
||||
hideSubscription.remove();
|
||||
};
|
||||
}, [scrollToLatest, isNearBottom]);
|
||||
}, []);
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = useCallback((dateString: string): string => {
|
||||
@@ -996,28 +1011,18 @@ export const useChatScreen = () => {
|
||||
Keyboard.dismiss();
|
||||
setActivePanel(prev => {
|
||||
const newPanel = prev === 'emoji' ? 'none' : 'emoji';
|
||||
if (newPanel !== 'none' && isNearBottom()) {
|
||||
setTimeout(() => {
|
||||
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' && isNearBottom()) {
|
||||
setTimeout(() => {
|
||||
scrollToLatest(false, false, 'open-more-panel');
|
||||
}, 200);
|
||||
}
|
||||
return newPanel;
|
||||
});
|
||||
}, [scrollToLatest, isNearBottom]);
|
||||
}, []);
|
||||
|
||||
// 关闭面板
|
||||
const closePanel = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user