refactor(ProcessPostUseCase, useCursorPagination, useDifferentialPosts): enhance post merging and loading states
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m57s
Frontend CI / ota-android (push) Successful in 10m48s
Frontend CI / build-android-apk (push) Successful in 52m58s

- 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:
lafay
2026-03-24 01:19:09 +08:00
parent aaf53d1c3b
commit 5c9cb6acea
11 changed files with 475 additions and 101 deletions

View File

@@ -109,6 +109,8 @@ class ProcessPostUseCase {
// 当前用户ID // 当前用户ID
private currentUserId: string | null = null; private currentUserId: string | null = null;
private readonly mergePerfWarnThresholdMs = 12;
// 私有构造函数 // 私有构造函数
private constructor() { private constructor() {
this.paginationManager = new PaginationStateManager(); this.paginationManager = new PaginationStateManager();
@@ -241,6 +243,83 @@ class ProcessPostUseCase {
this.notifyStateChanged(key); this.notifyStateChanged(key);
} }
private getEntityId(item: unknown): string | null {
if (!item || typeof item !== 'object') {
return null;
}
const maybeId = (item as { id?: unknown }).id;
if (typeof maybeId === 'string' || typeof maybeId === 'number') {
return String(maybeId);
}
return null;
}
private mergePostListWithFastPath(previousInput: Post[] | null | undefined, incomingInput: Post[] | null | undefined): Post[] {
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 lastPrevId = this.getEntityId(previous[previous.length - 1]);
const firstIncomingId = this.getEntityId(incoming[0]);
if (lastPrevId && firstIncomingId && lastPrevId !== firstIncomingId) {
const prevIdSet = new Set(previous.map((item) => this.getEntityId(item)).filter(Boolean) as string[]);
if (!prevIdSet.has(firstIncomingId)) {
return [...previous, ...incoming];
}
}
const merged = [...previous];
const indexById = new Map<string, number>();
for (let i = 0; i < merged.length; i += 1) {
const id = this.getEntityId(merged[i]);
if (id) {
indexById.set(id, i);
}
}
for (const post of incoming) {
const id = this.getEntityId(post);
if (!id) {
merged.push(post);
continue;
}
const existingIndex = indexById.get(id);
if (existingIndex === undefined) {
indexById.set(id, merged.length);
merged.push(post);
} else {
merged[existingIndex] = post;
}
}
return merged;
}
private mergeRefreshWindow(previousInput: Post[] | null | undefined, latestInput: Post[] | null | undefined): Post[] {
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 latestIdSet = new Set(
latest.map((item) => this.getEntityId(item)).filter(Boolean) as string[]
);
const remaining = previous.filter((item) => {
const id = this.getEntityId(item);
return !id || !latestIdSet.has(id);
});
return [...latest, ...remaining];
}
/** /**
* 更新帖子详情状态 * 更新帖子详情状态
* @param postId 帖子ID * @param postId 帖子ID
@@ -280,8 +359,17 @@ class ProcessPostUseCase {
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
// 更新状态(保存请求参数以便加载更多时使用) // 更新状态(保存请求参数以便加载更多时使用)
const mergeStart = Date.now();
const currentState = this.getPostsState(key);
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergedPosts = this.mergeRefreshWindow(currentState.posts, incomingPosts);
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= this.mergePerfWarnThresholdMs) {
console.log('[PostPerf] fetchPosts merge cost', { key, costMs: mergeCost, prev: currentState.posts.length, next: incomingPosts.length });
}
this.updatePostsState(key, { this.updatePostsState(key, {
posts: result.posts, posts: mergedPosts,
hasMore: result.hasMore, hasMore: result.hasMore,
cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor否则设为 null 表示 page 模式 cursor: isCursorMode ? result.nextCursor : null, // cursor 模式保存 cursor否则设为 null 表示 page 模式
currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始 currentPage: isCursorMode ? undefined : 1, // page 模式从第 1 页开始
@@ -293,7 +381,7 @@ class ProcessPostUseCase {
// 通知订阅者 // 通知订阅者
this.notifySubscribers({ this.notifySubscribers({
type: 'posts_loaded', type: 'posts_loaded',
payload: { key, posts: result.posts, hasMore: result.hasMore }, payload: { key, posts: incomingPosts, hasMore: result.hasMore },
timestamp: Date.now(), timestamp: Date.now(),
}); });
@@ -338,8 +426,16 @@ class ProcessPostUseCase {
const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; const isCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
// 更新状态 // 更新状态
const mergeStart = Date.now();
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const mergedPosts = this.mergeRefreshWindow(state.posts, incomingPosts);
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= this.mergePerfWarnThresholdMs) {
console.log('[PostPerf] refreshPosts merge cost', { key, costMs: mergeCost, prev: state.posts.length, next: incomingPosts.length });
}
this.updatePostsState(key, { this.updatePostsState(key, {
posts: result.posts, posts: mergedPosts,
hasMore: result.hasMore, hasMore: result.hasMore,
cursor: isCursorMode ? result.nextCursor : null, cursor: isCursorMode ? result.nextCursor : null,
currentPage: isCursorMode ? undefined : 1, currentPage: isCursorMode ? undefined : 1,
@@ -417,7 +513,13 @@ class ProcessPostUseCase {
const result = await postRepository.getPosts(queryParams); const result = await postRepository.getPosts(queryParams);
// 合并新数据 // 合并新数据
const newPosts = [...state.posts, ...result.posts]; const mergeStart = Date.now();
const incomingPosts = Array.isArray(result.posts) ? result.posts : [];
const newPosts = this.mergePostListWithFastPath(state.posts, incomingPosts);
const mergeCost = Date.now() - mergeStart;
if (mergeCost >= this.mergePerfWarnThresholdMs) {
console.log('[PostPerf] loadMore merge cost', { key, costMs: mergeCost, prev: state.posts.length, incoming: incomingPosts.length, merged: newPosts.length });
}
// 判断响应是否为 cursor 模式(有 next_cursor 返回) // 判断响应是否为 cursor 模式(有 next_cursor 返回)
const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null; const responseIsCursorMode = result.nextCursor !== undefined && result.nextCursor !== null;
@@ -433,7 +535,7 @@ class ProcessPostUseCase {
}); });
return { return {
posts: result.posts, posts: incomingPosts,
hasMore: result.hasMore, hasMore: result.hasMore,
nextCursor: result.nextCursor, nextCursor: result.nextCursor,
}; };

View File

@@ -11,6 +11,58 @@ import { CursorPaginationResponse } from '../types/dto';
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100; const MAX_PAGE_SIZE = 100;
function getItemId(item: unknown): string | null {
if (!item || typeof item !== 'object') return null;
const maybeId = (item as { id?: unknown }).id;
if (typeof maybeId === 'string' || typeof maybeId === 'number') {
return String(maybeId);
}
return null;
}
function mergeByIdFastPath<T>(previousInput: T[] | null | undefined, incomingInput: T[] | null | undefined): T[] {
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 prevLastId = getItemId(previous[previous.length - 1]);
const incomingFirstId = getItemId(incoming[0]);
if (prevLastId && incomingFirstId && prevLastId !== incomingFirstId) {
const prevSet = new Set(previous.map((item) => getItemId(item)).filter(Boolean) as string[]);
if (!prevSet.has(incomingFirstId)) {
return [...previous, ...incoming];
}
}
const merged = [...previous];
const indexById = new Map<string, number>();
for (let i = 0; i < merged.length; i += 1) {
const id = getItemId(merged[i]);
if (id) indexById.set(id, i);
}
for (const item of incoming) {
const id = getItemId(item);
if (!id) {
merged.push(item);
continue;
}
const existingIndex = indexById.get(id);
if (existingIndex === undefined) {
indexById.set(id, merged.length);
merged.push(item);
} else {
merged[existingIndex] = item;
}
}
return merged;
}
function normalizeList<T>(value: T[] | null | undefined): T[] {
return Array.isArray(value) ? value : [];
}
/** /**
* 游标分页 Hook * 游标分页 Hook
* *
@@ -34,6 +86,8 @@ export function useCursorPagination<T, P = void>(
prevCursor: null, prevCursor: null,
hasMore: true, // 初始设为 true以便首次加载可以执行 hasMore: true, // 初始设为 true以便首次加载可以执行
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
isRefreshing: false, isRefreshing: false,
error: null, error: null,
isFirstLoad: true, isFirstLoad: true,
@@ -66,7 +120,13 @@ export function useCursorPagination<T, P = void>(
isLoadingRef.current = true; isLoadingRef.current = true;
cancelledRef.current = false; cancelledRef.current = false;
setState(prev => ({ ...prev, isLoading: true, error: null })); setState(prev => ({
...prev,
isLoading: true,
isInitialLoading: prev.list.length === 0,
isLoadingMore: prev.list.length > 0,
error: null,
}));
try { try {
const response: CursorPaginationResponse<T> = await fetchFunction({ const response: CursorPaginationResponse<T> = await fetchFunction({
@@ -84,13 +144,16 @@ export function useCursorPagination<T, P = void>(
// 标记首次加载完成 // 标记首次加载完成
isFirstLoadRef.current = false; isFirstLoadRef.current = false;
const incomingList = normalizeList(response.list);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
list: [...prev.list, ...response.list], list: mergeByIdFastPath(prev.list, incomingList),
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
isFirstLoad: false, isFirstLoad: false,
})); }));
@@ -104,6 +167,8 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
error: error instanceof Error ? error.message : '加载失败', error: error instanceof Error ? error.message : '加载失败',
})); }));
@@ -119,7 +184,7 @@ export function useCursorPagination<T, P = void>(
cancelledRef.current = false; cancelledRef.current = false;
setState(prev => ({ ...prev, isLoading: true, error: null })); setState(prev => ({ ...prev, isLoading: true, isLoadingMore: true, error: null }));
try { try {
const response: CursorPaginationResponse<T> = await fetchFunction({ const response: CursorPaginationResponse<T> = await fetchFunction({
@@ -131,14 +196,16 @@ export function useCursorPagination<T, P = void>(
if (cancelledRef.current) return; if (cancelledRef.current) return;
const incomingList = normalizeList(response.list);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
// 向前加载时,新数据放在前面 // 向前加载时,新数据放在前面
list: [...response.list, ...prev.list], list: mergeByIdFastPath(incomingList, prev.list),
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more, hasMore: response.has_more,
isLoading: false, isLoading: false,
isLoadingMore: false,
isFirstLoad: false, isFirstLoad: false,
})); }));
} catch (error) { } catch (error) {
@@ -147,6 +214,7 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
isLoadingMore: false,
error: error instanceof Error ? error.message : '加载失败', error: error instanceof Error ? error.message : '加载失败',
})); }));
} }
@@ -168,12 +236,15 @@ export function useCursorPagination<T, P = void>(
if (cancelledRef.current) return; if (cancelledRef.current) return;
const refreshedList = normalizeList(response.list);
setState({ setState({
list: response.list, list: mergeByIdFastPath([], refreshedList),
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
isRefreshing: false, isRefreshing: false,
error: null, error: null,
isFirstLoad: false, isFirstLoad: false,
@@ -201,6 +272,8 @@ export function useCursorPagination<T, P = void>(
prevCursor: null, prevCursor: null,
hasMore: true, // 重置后也设为 true以便可以重新加载 hasMore: true, // 重置后也设为 true以便可以重新加载
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
isRefreshing: false, isRefreshing: false,
error: null, error: null,
isFirstLoad: true, isFirstLoad: true,
@@ -217,10 +290,12 @@ export function useCursorPagination<T, P = void>(
) => { ) => {
setState(prev => ({ setState(prev => ({
...prev, ...prev,
list, list: mergeByIdFastPath([], normalizeList(list)),
nextCursor, nextCursor,
prevCursor, prevCursor,
hasMore, hasMore,
isInitialLoading: false,
isLoadingMore: false,
isFirstLoad: false, isFirstLoad: false,
})); }));
}, },
@@ -242,7 +317,7 @@ export function useCursorPagination<T, P = void>(
isLoadingRef.current = true; isLoadingRef.current = true;
cancelledRef.current = false; cancelledRef.current = false;
setState(prev => ({ ...prev, isLoading: true, error: null })); setState(prev => ({ ...prev, isLoading: true, isInitialLoading: prev.list.length === 0, error: null }));
try { try {
const response: CursorPaginationResponse<T> = await fetchFunction({ const response: CursorPaginationResponse<T> = await fetchFunction({
@@ -259,13 +334,16 @@ export function useCursorPagination<T, P = void>(
isFirstLoadRef.current = false; isFirstLoadRef.current = false;
const initialList = normalizeList(response.list);
setState(prev => ({ setState(prev => ({
...prev, ...prev,
list: response.list, list: mergeByIdFastPath([], initialList),
nextCursor: response.next_cursor, nextCursor: response.next_cursor,
prevCursor: response.prev_cursor, prevCursor: response.prev_cursor,
hasMore: response.has_more && response.next_cursor !== null, hasMore: response.has_more && response.next_cursor !== null,
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
isFirstLoad: false, isFirstLoad: false,
})); }));
@@ -279,6 +357,8 @@ export function useCursorPagination<T, P = void>(
setState(prev => ({ setState(prev => ({
...prev, ...prev,
isLoading: false, isLoading: false,
isInitialLoading: false,
isLoadingMore: false,
error: error instanceof Error ? error.message : '加载失败', error: error instanceof Error ? error.message : '加载失败',
})); }));

View File

@@ -70,6 +70,10 @@ export interface UseDifferentialPostsResult<T extends PostIdentifier> {
posts: T[]; posts: T[];
/** 是否正在加载 */ /** 是否正在加载 */
loading: boolean; loading: boolean;
/** 是否首屏加载 */
isInitialLoading: boolean;
/** 是否正在加载更多 */
isLoadingMore: boolean;
/** 是否正在刷新 */ /** 是否正在刷新 */
refreshing: boolean; refreshing: boolean;
/** 错误信息 */ /** 错误信息 */
@@ -514,6 +518,8 @@ export function useDifferentialPosts<T extends PostIdentifier = Post>(
return { return {
posts, posts,
loading, loading,
isInitialLoading: loading && posts.length === 0 && !refreshing,
isLoadingMore: loading && posts.length > 0 && !refreshing,
refreshing, refreshing,
error, error,
hasMore, hasMore,

View File

@@ -214,6 +214,10 @@ export interface CursorPaginationState<T> {
hasMore: boolean; hasMore: boolean;
/** 是否正在加载 */ /** 是否正在加载 */
isLoading: boolean; isLoading: boolean;
/** 是否首屏加载(空列表初始化) */
isInitialLoading: boolean;
/** 是否正在加载更多 */
isLoadingMore: boolean;
/** 是否正在刷新 */ /** 是否正在刷新 */
isRefreshing: boolean; isRefreshing: boolean;
/** 错误信息 */ /** 错误信息 */

View File

@@ -3,8 +3,8 @@
* 断点检测 - 检测当前断点 * 断点检测 - 检测当前断点
*/ */
import { useMemo } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { useWindowDimensions } from './useScreenSize'; import { Dimensions, ScaledSize } from 'react-native';
import { BREAKPOINTS, FINE_BREAKPOINTS } from './types'; import { BREAKPOINTS, FINE_BREAKPOINTS } from './types';
import type { BreakpointKey, FineBreakpointKey } from './types'; import type { BreakpointKey, FineBreakpointKey } from './types';
@@ -74,6 +74,22 @@ export function isBreakpointBetween(
// ==================== Hooks ==================== // ==================== Hooks ====================
function useWindowDimensionsLocal(): ScaledSize {
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
});
return () => {
subscription.remove();
};
}, []);
return dimensions;
}
/** /**
* 断点检测 Hook * 断点检测 Hook
* 返回当前的基础断点 * 返回当前的基础断点
@@ -85,7 +101,7 @@ export function isBreakpointBetween(
* // 'mobile' | 'tablet' | 'desktop' | 'wide' * // 'mobile' | 'tablet' | 'desktop' | 'wide'
*/ */
export function useBreakpoint(): BreakpointKey { export function useBreakpoint(): BreakpointKey {
const { width } = useWindowDimensions(); const { width } = useWindowDimensionsLocal();
return useMemo(() => { return useMemo(() => {
return getBreakpoint(width); return getBreakpoint(width);
@@ -103,7 +119,7 @@ export function useBreakpoint(): BreakpointKey {
* // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' * // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
*/ */
export function useFineBreakpoint(): FineBreakpointKey { export function useFineBreakpoint(): FineBreakpointKey {
const { width } = useWindowDimensions(); const { width } = useWindowDimensionsLocal();
return useMemo(() => { return useMemo(() => {
return getFineBreakpoint(width); return getFineBreakpoint(width);
@@ -120,7 +136,7 @@ export function useFineBreakpoint(): FineBreakpointKey {
* const isMediumUp = useBreakpointGTE('md'); * const isMediumUp = useBreakpointGTE('md');
*/ */
export function useBreakpointGTE(target: FineBreakpointKey): boolean { export function useBreakpointGTE(target: FineBreakpointKey): boolean {
const { width } = useWindowDimensions(); const { width } = useWindowDimensionsLocal();
return useMemo(() => { return useMemo(() => {
const current = getFineBreakpoint(width); const current = getFineBreakpoint(width);
@@ -138,7 +154,7 @@ export function useBreakpointGTE(target: FineBreakpointKey): boolean {
* const isMobileOnly = useBreakpointLT('lg'); * const isMobileOnly = useBreakpointLT('lg');
*/ */
export function useBreakpointLT(target: FineBreakpointKey): boolean { export function useBreakpointLT(target: FineBreakpointKey): boolean {
const { width } = useWindowDimensions(); const { width } = useWindowDimensionsLocal();
return useMemo(() => { return useMemo(() => {
const current = getFineBreakpoint(width); const current = getFineBreakpoint(width);
@@ -160,7 +176,7 @@ export function useBreakpointBetween(
min: FineBreakpointKey, min: FineBreakpointKey,
max: FineBreakpointKey max: FineBreakpointKey
): boolean { ): boolean {
const { width } = useWindowDimensions(); const { width } = useWindowDimensionsLocal();
return useMemo(() => { return useMemo(() => {
const current = getFineBreakpoint(width); const current = getFineBreakpoint(width);

View File

@@ -119,6 +119,8 @@ export const HomeScreen: React.FC = () => {
const { const {
posts, posts,
loading: isLoading, loading: isLoading,
isInitialLoading,
isLoadingMore,
refreshing: isRefreshing, refreshing: isRefreshing,
hasMore, hasMore,
error, error,
@@ -135,17 +137,23 @@ export const HomeScreen: React.FC = () => {
// 加载更多方法 // 加载更多方法
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (hasMore && !isLoading && !isLoadingMoreRef.current) { if (hasMore && !isLoadingMore && !isLoadingMoreRef.current) {
isLoadingMoreRef.current = true; isLoadingMoreRef.current = true;
const startedAt = Date.now();
try { try {
await processPostUseCase.loadMorePosts(listKey); await processPostUseCase.loadMorePosts(listKey);
console.log('[PostPerf] home loadMore', {
listKey,
costMs: Date.now() - startedAt,
totalItems: posts.length,
});
} catch (err) { } catch (err) {
console.error('加载更多失败:', err); console.error('加载更多失败:', err);
} finally { } finally {
isLoadingMoreRef.current = false; isLoadingMoreRef.current = false;
} }
} }
}, [listKey, hasMore, isLoading]); }, [posts.length, listKey, hasMore, isLoadingMore]);
// 网格模式滚动处理 - 检测是否滚动到底部 // 网格模式滚动处理 - 检测是否滚动到底部
const handleGridScroll = useCallback((event: any) => { 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 authorId = item.author?.id || '';
const isPostAuthor = currentUser?.id === authorId; const isPostAuthor = currentUser?.id === authorId;
return ( return (
@@ -394,7 +404,7 @@ export const HomeScreen: React.FC = () => {
/> />
</View> </View>
); );
}; }, [currentUser?.id, handleBookmark, handleDeletePost, handleImagePress, handleLike, handlePostPress, handleShare, handleUserPress, isMobile, listItemWidth, responsiveGap]);
// 估算帖子在瀑布流中的高度(用于均匀分配) // 估算帖子在瀑布流中的高度(用于均匀分配)
const estimatePostHeight = (post: Post, columnWidth: number): number => { const estimatePostHeight = (post: Post, columnWidth: number): number => {
@@ -558,8 +568,8 @@ export const HomeScreen: React.FC = () => {
}; };
// 渲染空状态 // 渲染空状态
const renderEmpty = () => { const renderEmpty = useCallback(() => {
if (isLoading) return null; if (isInitialLoading) return null;
return ( return (
<EmptyState <EmptyState
@@ -567,7 +577,7 @@ export const HomeScreen: React.FC = () => {
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'} description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
/> />
); );
}; }, [activeIndex, isInitialLoading]);
// 渲染列表内容 // 渲染列表内容
const renderListContent = () => { const renderListContent = () => {
@@ -581,7 +591,7 @@ export const HomeScreen: React.FC = () => {
<FlatList <FlatList
data={displayPosts} data={displayPosts}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={item => item.id} keyExtractor={keyExtractor}
contentContainerStyle={[ contentContainerStyle={[
styles.listContent, styles.listContent,
{ {
@@ -602,7 +612,12 @@ export const HomeScreen: React.FC = () => {
onEndReached={loadMore} onEndReached={loadMore}
onEndReachedThreshold={0.3} onEndReachedThreshold={0.3}
ListEmptyComponent={renderEmpty} 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 支持手势切换 Tab
<GestureDetector gesture={swipeGesture}> <GestureDetector gesture={swipeGesture}>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{viewMode === 'list' ? ( {isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
renderListContent() renderListContent()
) : ( ) : (
// 网格模式:使用响应式网格 // 网格模式:使用响应式网格
@@ -672,7 +689,9 @@ export const HomeScreen: React.FC = () => {
) : ( ) : (
// 桌面端/平板端:不使用 GestureDetector避免拦截侧边栏点击 // 桌面端/平板端:不使用 GestureDetector避免拦截侧边栏点击
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{viewMode === 'list' ? ( {isInitialLoading ? (
<Loading fullScreen />
) : viewMode === 'list' ? (
renderListContent() renderListContent()
) : ( ) : (
// 网格模式:使用响应式网格 // 网格模式:使用响应式网格

View File

@@ -67,13 +67,15 @@ export const PostDetailScreen: React.FC = () => {
const [post, setPost] = useState<Post | null>(null); const [post, setPost] = useState<Post | null>(null);
const [comments, setComments] = useState<Comment[]>([]); const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true); const [isPostInitialLoading, setIsPostInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [isPostRefreshing, setIsPostRefreshing] = useState(false);
// 使用游标分页 Hook 管理评论列表 // 使用游标分页 Hook 管理评论列表
const { const {
list: paginatedComments, list: paginatedComments,
isLoading: isCommentsLoading, isLoading: isCommentsLoading,
isInitialLoading: isCommentsInitialLoading,
isLoadingMore: isCommentsLoadingMore,
isRefreshing: isCommentsRefreshing, isRefreshing: isCommentsRefreshing,
hasMore: hasMoreComments, hasMore: hasMoreComments,
loadMore: loadMoreComments, loadMore: loadMoreComments,
@@ -89,10 +91,70 @@ export const PostDetailScreen: React.FC = () => {
{ pageSize: 20 } { 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(() => { useEffect(() => {
setComments(paginatedComments); setComments([]);
}, [paginatedComments]); }, [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 [commentText, setCommentText] = useState('');
const [showImageModal, setShowImageModal] = useState(false); const [showImageModal, setShowImageModal] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [selectedImageIndex, setSelectedImageIndex] = useState(0);
@@ -131,7 +193,7 @@ export const PostDetailScreen: React.FC = () => {
// 加载帖子详情(可选择是否记录浏览量) // 加载帖子详情(可选择是否记录浏览量)
const loadPostDetail = useCallback(async (recordView: boolean = false) => { const loadPostDetail = useCallback(async (recordView: boolean = false) => {
if (!postId) { if (!postId) {
setLoading(false); setIsPostInitialLoading(false);
return; return;
} }
@@ -188,7 +250,7 @@ export const PostDetailScreen: React.FC = () => {
} catch (error) { } catch (error) {
console.error('加载帖子详情失败:', error); console.error('加载帖子详情失败:', error);
} finally { } finally {
setLoading(false); setIsPostInitialLoading(false);
} }
}, [postId]); }, [postId]);
@@ -206,13 +268,13 @@ export const PostDetailScreen: React.FC = () => {
// 如果是从评论按钮跳转过来的,加载完成后滚动到评论区 // 如果是从评论按钮跳转过来的,加载完成后滚动到评论区
useEffect(() => { useEffect(() => {
if (shouldScrollToComments && !loading && (comments?.length ?? 0) > 0) { if (shouldScrollToComments && !isPostInitialLoading && (comments?.length ?? 0) > 0) {
// 延迟确保列表已完成渲染和布局 // 延迟确保列表已完成渲染和布局
setTimeout(() => { setTimeout(() => {
flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 }); flatListRef.current?.scrollToIndex({ index: 0, animated: true, viewPosition: 0 });
}, 500); }, 500);
} }
}, [shouldScrollToComments, loading, comments?.length]); }, [shouldScrollToComments, isPostInitialLoading, comments?.length]);
// 动态设置导航头部 // 动态设置导航头部
useEffect(() => { useEffect(() => {
@@ -280,11 +342,26 @@ export const PostDetailScreen: React.FC = () => {
// 下拉刷新 - 同时刷新帖子和评论 // 下拉刷新 - 同时刷新帖子和评论
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setRefreshing(true); const startedAt = Date.now();
setIsPostRefreshing(true);
await loadPostDetail(); await loadPostDetail();
await refreshComments(); console.log('[PostPerf] detail refresh', {
setRefreshing(false); postId,
}, [loadPostDetail, refreshComments]); 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 => { const formatDateTime = (dateString?: string | null): string => {
if (!dateString) return ''; 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 || ''; const authorId = item.author?.id || '';
// 收集当前评论的所有回复,用于根据 target_id 查找被回复用户 // 收集当前评论的所有回复,用于根据 target_id 查找被回复用户
const allReplies = item.replies || []; const allReplies = item.replies || [];
@@ -1283,10 +1362,10 @@ export const PostDetailScreen: React.FC = () => {
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
/> />
); );
}; }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]);
// 渲染空评论 - 现代化设计 // 渲染空评论 - 现代化设计
const renderEmptyComments = () => ( const renderEmptyComments = useCallback(() => (
<View style={styles.emptyCommentsContainer}> <View style={styles.emptyCommentsContainer}>
<View style={styles.emptyCommentsIconWrapper}> <View style={styles.emptyCommentsIconWrapper}>
<MaterialCommunityIcons <MaterialCommunityIcons
@@ -1302,7 +1381,7 @@ export const PostDetailScreen: React.FC = () => {
~ ~
</Text> </Text>
</View> </View>
); ), [isDesktop]);
// 渲染评论输入框 // 渲染评论输入框
const renderCommentInput = () => ( const renderCommentInput = () => (
@@ -1398,7 +1477,7 @@ export const PostDetailScreen: React.FC = () => {
ref={flatListRef} ref={flatListRef}
data={comments} data={comments}
renderItem={renderComment} renderItem={renderComment}
keyExtractor={item => item.id} keyExtractor={commentKeyExtractor}
ListEmptyComponent={renderEmptyComments} ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }} contentContainerStyle={{ paddingBottom: responsiveGap }}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
@@ -1409,23 +1488,32 @@ export const PostDetailScreen: React.FC = () => {
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing || isCommentsRefreshing} refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
colors={[colors.primary.main]} colors={[colors.primary.main]}
tintColor={colors.primary.main} tintColor={colors.primary.main}
/> />
} }
onEndReached={loadMoreComments} onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3} onEndReachedThreshold={0.3}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews
ListFooterComponent={ ListFooterComponent={
isCommentsLoading ? ( isCommentsInitialLoading ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : isCommentsLoadingMore ? (
<View style={styles.commentsLoadingFooter}> <View style={styles.commentsLoadingFooter}>
<Loading size="sm" /> <Loading size="sm" />
</View> </View>
) : hasMoreComments ? ( ) : hasMoreComments ? (
<TouchableOpacity <TouchableOpacity
style={styles.loadMoreButton} style={styles.loadMoreButton}
onPress={loadMoreComments} onPress={handleLoadMoreComments}
> >
<Text variant="caption" color={colors.primary.main}> <Text variant="caption" color={colors.primary.main}>
@@ -1456,7 +1544,7 @@ export const PostDetailScreen: React.FC = () => {
</View> </View>
); );
if (loading) { if (isPostInitialLoading) {
return <Loading fullScreen />; return <Loading fullScreen />;
} }
@@ -1489,7 +1577,7 @@ export const PostDetailScreen: React.FC = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing || isCommentsRefreshing} refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
colors={[colors.primary.main]} colors={[colors.primary.main]}
tintColor={colors.primary.main} tintColor={colors.primary.main}
@@ -1530,7 +1618,7 @@ export const PostDetailScreen: React.FC = () => {
ref={flatListRef} ref={flatListRef}
data={comments} data={comments}
renderItem={renderComment} renderItem={renderComment}
keyExtractor={item => item.id} keyExtractor={commentKeyExtractor}
ListHeaderComponent={renderPostHeader} ListHeaderComponent={renderPostHeader}
ListEmptyComponent={renderEmptyComments} ListEmptyComponent={renderEmptyComments}
contentContainerStyle={{ paddingBottom: responsiveGap }} contentContainerStyle={{ paddingBottom: responsiveGap }}
@@ -1542,23 +1630,32 @@ export const PostDetailScreen: React.FC = () => {
}} }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing || isCommentsRefreshing} refreshing={isPostRefreshing || isCommentsRefreshing}
onRefresh={onRefresh} onRefresh={onRefresh}
colors={[colors.primary.main]} colors={[colors.primary.main]}
tintColor={colors.primary.main} tintColor={colors.primary.main}
/> />
} }
onEndReached={loadMoreComments} onEndReached={handleLoadMoreComments}
onEndReachedThreshold={0.3} onEndReachedThreshold={0.3}
initialNumToRender={10}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={60}
windowSize={7}
removeClippedSubviews
ListFooterComponent={ ListFooterComponent={
isCommentsLoading ? ( isCommentsInitialLoading ? (
<View style={styles.commentsLoadingFooter}>
<Loading size="sm" />
</View>
) : isCommentsLoadingMore ? (
<View style={styles.commentsLoadingFooter}> <View style={styles.commentsLoadingFooter}>
<Loading size="sm" /> <Loading size="sm" />
</View> </View>
) : hasMoreComments ? ( ) : hasMoreComments ? (
<TouchableOpacity <TouchableOpacity
style={styles.loadMoreButton} style={styles.loadMoreButton}
onPress={loadMoreComments} onPress={handleLoadMoreComments}
> >
<Text variant="caption" color={colors.primary.main}> <Text variant="caption" color={colors.primary.main}>

View File

@@ -77,6 +77,7 @@ export const ChatScreen: React.FC = () => {
// 输入框区域高度(用于定位浮动 mention 面板) // 输入框区域高度(用于定位浮动 mention 面板)
const [inputWrapperHeight, setInputWrapperHeight] = useState(60); const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
const [showEdgeLoadingIndicator, setShowEdgeLoadingIndicator] = useState(false);
const isPreloadingRef = useRef(false); const isPreloadingRef = useRef(false);
const lastScrollYRef = useRef(0); const lastScrollYRef = useRef(0);
const isUserDraggingRef = useRef(false); const isUserDraggingRef = useRef(false);
@@ -203,8 +204,15 @@ export const ChatScreen: React.FC = () => {
loadMoreHistory, loadMoreHistory,
handleMessageListContentSizeChange, handleMessageListContentSizeChange,
handleReachLatestEdge, handleReachLatestEdge,
setBrowsingHistory,
} = useChatScreen(); } = useChatScreen();
useEffect(() => {
if (!loadingMore && showEdgeLoadingIndicator) {
setShowEdgeLoadingIndicator(false);
}
}, [loadingMore, showEdgeLoadingIndicator]);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (preloadCooldownTimerRef.current) { if (preloadCooldownTimerRef.current) {
@@ -231,7 +239,7 @@ export const ChatScreen: React.FC = () => {
return map; return map;
}, [messages]); }, [messages]);
const renderMessage = ({ item, index }: { item: any; index: number }) => { const renderMessage = useCallback(({ item, index }: { item: any; index: number }) => {
// inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引 // inverted 下 renderItem 的 index 与时间顺序不一致,需回查原始序索引
const logicalIndex = messageIndexMap.get(String(item.id)) ?? index; const logicalIndex = messageIndexMap.get(String(item.id)) ?? index;
return ( return (
@@ -255,7 +263,26 @@ export const ChatScreen: React.FC = () => {
onReply={handleReplyMessage} 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(); const typingHint = getTypingHint();
@@ -269,6 +296,11 @@ export const ChatScreen: React.FC = () => {
viewportHeight: layoutMeasurement.height, viewportHeight: layoutMeasurement.height,
}; };
// 离开最新消息端后进入“浏览历史”模式,屏蔽自动跟随到底
if (contentOffset.y > 120) {
setBrowsingHistory(true);
}
// 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁) // 仅用户手势主动回到底部时才解除历史阅读锁(避免加载阶段误解锁)
const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current; const isScrollingTowardLatest = contentOffset.y < lastScrollYRef.current;
if ( if (
@@ -282,13 +314,19 @@ export const ChatScreen: React.FC = () => {
// inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小 // inverted 下历史端在“列表尾部”,对应 offset 增大且距离尾部变小
const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height); const distanceToHistoryEdge = contentSize.height - (contentOffset.y + layoutMeasurement.height);
const isScrollingTowardHistory = contentOffset.y > lastScrollYRef.current;
lastScrollYRef.current = contentOffset.y; 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 ( if (
distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD && distanceToHistoryEdge <= PRELOAD_HISTORY_THRESHOLD &&
isScrollingTowardHistory &&
hasMoreHistory && hasMoreHistory &&
!loadingMore && !loadingMore &&
!loading && !loading &&
@@ -304,7 +342,7 @@ export const ChatScreen: React.FC = () => {
}, 350); }, 350);
}); });
} }
}, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge]); }, [scrollPositionRef, hasMoreHistory, loadingMore, loading, loadMoreHistory, handleReachLatestEdge, showEdgeLoadingIndicator, setBrowsingHistory]);
const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => { const handleContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
handleMessageListContentSizeChange(contentWidth, contentHeight); handleMessageListContentSizeChange(contentWidth, contentHeight);
@@ -338,6 +376,7 @@ export const ChatScreen: React.FC = () => {
onLayout={e => { onLayout={e => {
scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height; scrollPositionRef.current.viewportHeight = e.nativeEvent.layout.height;
}} }}
onTouchEnd={handleDismiss}
> >
{loading ? ( {loading ? (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
@@ -349,10 +388,15 @@ export const ChatScreen: React.FC = () => {
inverted inverted
data={displayMessages} data={displayMessages}
renderItem={renderMessage} renderItem={renderMessage}
keyExtractor={(item: any) => String(item.id)} keyExtractor={keyExtractor}
contentContainerStyle={listContentStyle as any} contentContainerStyle={listContentStyle as any}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
initialNumToRender={14}
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
windowSize={9}
removeClippedSubviews={Platform.OS !== 'web'}
onScroll={handleMessageListScroll} onScroll={handleMessageListScroll}
onScrollBeginDrag={() => { onScrollBeginDrag={() => {
isUserDraggingRef.current = true; isUserDraggingRef.current = true;
@@ -368,7 +412,7 @@ export const ChatScreen: React.FC = () => {
onContentSizeChange={handleContentSizeChange} onContentSizeChange={handleContentSizeChange}
/> />
)} )}
{loadingMore && ( {showEdgeLoadingIndicator && (
<View <View
pointerEvents="none" pointerEvents="none"
style={{ style={{

View File

@@ -120,6 +120,7 @@ export const useChatScreen = () => {
const prevMessageCountRef = useRef(0); const prevMessageCountRef = useRef(0);
const prevLatestSeqRef = useRef(0); const prevLatestSeqRef = useRef(0);
const prevMarkedReadSeqRef = useRef(0); const prevMarkedReadSeqRef = useRef(0);
const enterMarkedKeyRef = useRef<string>('');
const isProgrammaticScrollRef = useRef(false); const isProgrammaticScrollRef = useRef(false);
const suppressAutoFollowRef = useRef(false); const suppressAutoFollowRef = useRef(false);
const isBrowsingHistoryRef = useRef(false); const isBrowsingHistoryRef = useRef(false);
@@ -277,6 +278,7 @@ export const useChatScreen = () => {
prevMessageCountRef.current = 0; prevMessageCountRef.current = 0;
prevLatestSeqRef.current = 0; prevLatestSeqRef.current = 0;
prevMarkedReadSeqRef.current = 0; prevMarkedReadSeqRef.current = 0;
enterMarkedKeyRef.current = '';
suppressAutoFollowRef.current = false; suppressAutoFollowRef.current = false;
isBrowsingHistoryRef.current = false; isBrowsingHistoryRef.current = false;
}, [conversationId]); }, [conversationId]);
@@ -479,6 +481,29 @@ export const useChatScreen = () => {
isBrowsingHistoryRef.current = browsing; 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 风格): // 自动标记已读QQ/Telegram 风格):
// 仅当出现更大的 latest seq且用户在最新端附近时才上报。 // 仅当出现更大的 latest seq且用户在最新端附近时才上报。
// 历史加载/浏览历史不会触发 read。 // 历史加载/浏览历史不会触发 read。
@@ -521,20 +546,10 @@ 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(() => {
scrollToLatest(false, false, 'keyboard-show');
}, 150);
}
}; };
const keyboardWillHide = () => { const keyboardWillHide = () => {
setKeyboardHeight(0); setKeyboardHeight(0);
if (isNearBottom()) {
setTimeout(() => {
scrollToLatest(false, false, 'keyboard-hide');
}, 100);
}
}; };
const showSubscription = Keyboard.addListener( const showSubscription = Keyboard.addListener(
@@ -550,7 +565,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 => {
@@ -996,28 +1011,18 @@ export const useChatScreen = () => {
Keyboard.dismiss(); Keyboard.dismiss();
setActivePanel(prev => { setActivePanel(prev => {
const newPanel = prev === 'emoji' ? 'none' : 'emoji'; const newPanel = prev === 'emoji' ? 'none' : 'emoji';
if (newPanel !== 'none' && isNearBottom()) {
setTimeout(() => {
scrollToLatest(false, false, 'open-emoji-panel');
}, 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' && isNearBottom()) {
setTimeout(() => {
scrollToLatest(false, false, 'open-more-panel');
}, 200);
}
return newPanel; return newPanel;
}); });
}, [scrollToLatest, isNearBottom]); }, []);
// 关闭面板 // 关闭面板
const closePanel = useCallback(() => { const closePanel = useCallback(() => {

View File

@@ -1557,8 +1557,8 @@ class MessageManager {
payload: { conversationId, messages: mergedMessages, source: 'local_history' }, payload: { conversationId, messages: mergedMessages, source: 'local_history' },
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 异步填充 sender 信 // 性能优化:历史分页仅对新批次做 sender 补全,避免每次扫描全量消
this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); this.enrichMessagesWithSenderInfo(conversationId, formattedMessages);
return formattedMessages; return formattedMessages;
} }
@@ -1593,8 +1593,8 @@ class MessageManager {
payload: { conversationId, messages: mergedMessages, source: 'server_history' }, payload: { conversationId, messages: mergedMessages, source: 'server_history' },
timestamp: Date.now(), timestamp: Date.now(),
}); });
// 异步填充 sender 信息 // 性能优化:仅对本次服务端批次做 sender 补全
this.enrichMessagesWithSenderInfo(conversationId, mergedMessages); this.enrichMessagesWithSenderInfo(conversationId, serverMessages);
return serverMessages; return serverMessages;
} }

View File

@@ -103,6 +103,7 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
const [messages, setMessages] = useState<MessageResponse[]>([]); const [messages, setMessages] = useState<MessageResponse[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const loadMoreInFlightRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!conversationId) { if (!conversationId) {
@@ -156,23 +157,23 @@ export function useMessages(conversationId: string | null): UseMessagesReturn {
}, [conversationId]); }, [conversationId]);
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (!conversationId || isLoading || !hasMore) return; if (!conversationId || !hasMore || loadMoreInFlightRef.current) return;
const currentMessages = messageManager.getMessages(conversationId); const currentMessages = messageManager.getMessages(conversationId);
if (currentMessages.length === 0) return; if (currentMessages.length === 0) return;
// 获取最早的消息seq // 消息数组在 MessageManager 内保持 seq 升序,首项即最早消息
const minSeq = Math.min(...currentMessages.map(m => m.seq)); const minSeq = currentMessages[0]?.seq ?? Math.min(...currentMessages.map(m => m.seq));
setIsLoading(true); loadMoreInFlightRef.current = true;
const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20); const loadedMessages = await messageManager.loadMoreMessages(conversationId, minSeq, 20);
setIsLoading(false); loadMoreInFlightRef.current = false;
// 如果没有加载到新消息,说明没有更多了 // 如果没有加载到新消息,说明没有更多了
if (loadedMessages.length === 0) { if (loadedMessages.length === 0) {
setHasMore(false); setHasMore(false);
} }
}, [conversationId, isLoading, hasMore]); }, [conversationId, hasMore]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!conversationId) return; if (!conversationId) return;