- **Post sync optimization**: Clear store immediately when params change to prevent flashing old posts; replace instead of merge on refresh - **ImageGallery fix**: Use idempotent download option, migrate to Asset.create() API, fix Android file path requirement, ensure proper cleanup - **UserProfileScreen**: Add ImageGallery for post image viewing with consistent implementation - **SearchScreen**: Add entrance animation and empty state - **ChatScreen**: Remove unused state variables (lastSeq, firstSeq, isProgrammaticScrollRef) and dead imports
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
import {
|
|
PostIdentifier,
|
|
PostUpdate,
|
|
PostUpdateType,
|
|
PostDiffConfig,
|
|
} from '../infrastructure/diff/postTypes';
|
|
import {
|
|
PostDiffCalculator,
|
|
createPostDiffCalculator,
|
|
} from '../infrastructure/diff/PostDiffCalculator';
|
|
import {
|
|
PostUpdateBatcher,
|
|
PostBatcherOptions,
|
|
createPostUpdateBatcher,
|
|
} from '../infrastructure/diff/PostUpdateBatcher';
|
|
import { postSyncService } from '@/services/post';
|
|
import type { PostsState } from '@/services/post';
|
|
import { usePostListStore } from '../stores/post/postListStore';
|
|
import type { Post } from '../core/entities/Post';
|
|
|
|
export interface UseDifferentialPostsOptions<T extends PostIdentifier> {
|
|
diffConfig?: PostDiffConfig;
|
|
batcherOptions?: PostBatcherOptions;
|
|
enableDiff?: boolean;
|
|
enableBatching?: boolean;
|
|
maxPostCount?: number;
|
|
listKey?: string;
|
|
autoSubscribe?: boolean;
|
|
}
|
|
|
|
export interface DiffUpdatesInfo {
|
|
addedCount: number;
|
|
updatedCount: number;
|
|
deletedCount: number;
|
|
lastUpdateTime: number;
|
|
}
|
|
|
|
export interface UseDifferentialPostsResult<T extends PostIdentifier> {
|
|
posts: T[];
|
|
loading: boolean;
|
|
isInitialLoading: boolean;
|
|
isLoadingMore: boolean;
|
|
refreshing: boolean;
|
|
error: string | null;
|
|
hasMore: boolean;
|
|
pendingUpdateCount: number;
|
|
isProcessing: boolean;
|
|
refresh: () => Promise<void>;
|
|
loadMore: () => Promise<void>;
|
|
flush: () => void;
|
|
reset: () => void;
|
|
forceUpdate: (posts: T[]) => void;
|
|
getDiffStats: () => { totalBatches: number; totalUpdates: number; averageBatchSize: number };
|
|
diffUpdates: DiffUpdatesInfo;
|
|
}
|
|
|
|
export function useDifferentialPosts<T extends PostIdentifier = Post>(
|
|
initialPosts: T[] = [],
|
|
options: UseDifferentialPostsOptions<T> = {}
|
|
): UseDifferentialPostsResult<T> {
|
|
const {
|
|
diffConfig,
|
|
batcherOptions,
|
|
enableDiff = true,
|
|
enableBatching = true,
|
|
maxPostCount = 10000,
|
|
listKey = 'default',
|
|
autoSubscribe = true,
|
|
} = options;
|
|
|
|
const [posts, setPosts] = useState<T[]>(initialPosts);
|
|
const [loading, setLoading] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const [pendingUpdateCount, setPendingUpdateCount] = useState(0);
|
|
const [diffUpdates, setDiffUpdates] = useState<DiffUpdatesInfo>({
|
|
addedCount: 0,
|
|
updatedCount: 0,
|
|
deletedCount: 0,
|
|
lastUpdateTime: 0,
|
|
});
|
|
|
|
const calculatorRef = useRef<PostDiffCalculator<T> | null>(null);
|
|
const batcherRef = useRef<PostUpdateBatcher | null>(null);
|
|
const previousPostsRef = useRef<T[]>(initialPosts);
|
|
|
|
const handleBatchUpdates = useCallback((updates: PostUpdate[]) => {
|
|
setIsProcessing(true);
|
|
|
|
try {
|
|
setPosts(currentPosts => {
|
|
let newPosts = [...currentPosts];
|
|
let addedCount = 0;
|
|
let updatedCount = 0;
|
|
let deletedCount = 0;
|
|
|
|
for (const update of updates) {
|
|
switch (update.type) {
|
|
case PostUpdateType.ADD: {
|
|
const addUpdate = update as any;
|
|
const index = addUpdate.index ?? newPosts.length;
|
|
newPosts.splice(index, 0, addUpdate.post as T);
|
|
addedCount++;
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.BATCH_ADD: {
|
|
const batchAddUpdate = update as any;
|
|
const postsToAdd = batchAddUpdate.posts || [];
|
|
const startIndex = batchAddUpdate.startIndex ?? newPosts.length;
|
|
newPosts.splice(startIndex, 0, ...postsToAdd as T[]);
|
|
addedCount += postsToAdd.length;
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.UPDATE: {
|
|
const updatePost = update as any;
|
|
const index = newPosts.findIndex(p => p.id === updatePost.postId);
|
|
if (index !== -1) {
|
|
newPosts[index] = { ...newPosts[index], ...updatePost.updates };
|
|
updatedCount++;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.BATCH_UPDATE: {
|
|
const batchUpdate = update as any;
|
|
const updatesList = batchUpdate.updates || [];
|
|
for (const { postId, changes } of updatesList) {
|
|
const index = newPosts.findIndex(p => p.id === postId);
|
|
if (index !== -1) {
|
|
newPosts[index] = { ...newPosts[index], ...changes };
|
|
updatedCount++;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.DELETE: {
|
|
const deleteUpdate = update as any;
|
|
const prevLength = newPosts.length;
|
|
newPosts = newPosts.filter(p => p.id !== deleteUpdate.postId);
|
|
if (newPosts.length < prevLength) deletedCount++;
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.BATCH_DELETE: {
|
|
const batchDelete = update as any;
|
|
const idsToDelete = new Set(batchDelete.postIds || []);
|
|
const prevLength = newPosts.length;
|
|
newPosts = newPosts.filter(p => !idsToDelete.has(p.id));
|
|
deletedCount += prevLength - newPosts.length;
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.MOVE: {
|
|
const moveUpdate = update as any;
|
|
const fromIndex = newPosts.findIndex(p => p.id === moveUpdate.postId);
|
|
if (fromIndex !== -1) {
|
|
const [movedPost] = newPosts.splice(fromIndex, 1);
|
|
newPosts.splice(moveUpdate.toIndex, 0, movedPost);
|
|
}
|
|
break;
|
|
}
|
|
|
|
case PostUpdateType.RESET: {
|
|
const resetUpdate = update as any;
|
|
newPosts = resetUpdate.posts || [];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (addedCount > 0 || updatedCount > 0 || deletedCount > 0) {
|
|
setDiffUpdates(prev => ({
|
|
addedCount: prev.addedCount + addedCount,
|
|
updatedCount: prev.updatedCount + updatedCount,
|
|
deletedCount: prev.deletedCount + deletedCount,
|
|
lastUpdateTime: Date.now(),
|
|
}));
|
|
}
|
|
|
|
return newPosts;
|
|
});
|
|
} finally {
|
|
setIsProcessing(false);
|
|
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (enableDiff) {
|
|
calculatorRef.current = createPostDiffCalculator<T>(diffConfig);
|
|
}
|
|
return () => { calculatorRef.current = null; };
|
|
}, [enableDiff, diffConfig]);
|
|
|
|
useEffect(() => {
|
|
if (!enableBatching) return;
|
|
batcherRef.current = createPostUpdateBatcher(batcherOptions);
|
|
const unsubscribe = batcherRef.current.subscribe(handleBatchUpdates);
|
|
return () => {
|
|
unsubscribe();
|
|
batcherRef.current?.destroy();
|
|
batcherRef.current = null;
|
|
};
|
|
}, [enableBatching, batcherOptions, handleBatchUpdates]);
|
|
|
|
const syncFromStore = useCallback((state: PostsState) => {
|
|
setLoading(state.isLoading);
|
|
setRefreshing(state.isRefreshing);
|
|
setError(state.error);
|
|
setHasMore(state.hasMore);
|
|
|
|
batcherRef.current?.clearPending();
|
|
calculatorRef.current?.reset();
|
|
if (state.posts && state.posts.length > 0) {
|
|
const next = state.posts as unknown as T[];
|
|
if (next.length > maxPostCount) {
|
|
console.warn(`[useDifferentialPosts] Post count (${next.length}) exceeds limit (${maxPostCount})`);
|
|
}
|
|
setPosts(next);
|
|
previousPostsRef.current = next;
|
|
} else {
|
|
setPosts([]);
|
|
previousPostsRef.current = [];
|
|
}
|
|
}, [maxPostCount]);
|
|
|
|
useEffect(() => {
|
|
if (!autoSubscribe) return;
|
|
|
|
const currentState = usePostListStore.getState().getPostsState(listKey);
|
|
syncFromStore(currentState);
|
|
|
|
// listKey 切换时,如果目标列表在 store 中还没有有效数据(首次访问),
|
|
// 立即进入 loading 态,避免先渲染空列表再切到 loading 造成闪烁。
|
|
// syncFromStore 会用 store 的 isLoading 覆盖,因此在其之后补设。
|
|
// 如果已有数据(曾访问过),则保留旧数据,由后续 fetch 静默刷新。
|
|
if (!currentState.posts || currentState.posts.length === 0) {
|
|
setLoading(true);
|
|
}
|
|
|
|
const unsubscribe = usePostListStore.subscribe(state => {
|
|
const postsState = state.postsStateMap.get(listKey);
|
|
if (postsState) syncFromStore(postsState);
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [autoSubscribe, listKey, syncFromStore]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setRefreshing(true);
|
|
setError(null);
|
|
try {
|
|
await postSyncService.refreshPosts(listKey);
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : '刷新失败';
|
|
setError(errorMsg);
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}, [listKey]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (hasMore && !loading) {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
await postSyncService.loadMorePosts(listKey);
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : '加载更多失败';
|
|
setError(errorMsg);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
}, [listKey, hasMore, loading]);
|
|
|
|
const flush = useCallback(() => { batcherRef.current?.flush(); }, []);
|
|
|
|
const reset = useCallback(() => {
|
|
calculatorRef.current?.reset();
|
|
batcherRef.current?.clearPending();
|
|
// 同步清空 store 中对应 listKey 的数据,避免 subscribe 回调
|
|
// 在切换 listKey 时把上一个 key 的残留数据重新同步回本地造成闪烁
|
|
usePostListStore.getState().updatePostsState(listKey, {
|
|
posts: [],
|
|
cursor: null,
|
|
currentPage: 1,
|
|
hasMore: true,
|
|
isLoading: false,
|
|
isRefreshing: false,
|
|
error: null,
|
|
lastParams: undefined,
|
|
});
|
|
setPosts([]);
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
setError(null);
|
|
setHasMore(true);
|
|
previousPostsRef.current = [];
|
|
setDiffUpdates({ addedCount: 0, updatedCount: 0, deletedCount: 0, lastUpdateTime: 0 });
|
|
}, [listKey]);
|
|
|
|
const forceUpdate = useCallback((newPosts: T[]) => {
|
|
setPosts(newPosts);
|
|
previousPostsRef.current = newPosts;
|
|
}, []);
|
|
|
|
const getDiffStats = useCallback(() => {
|
|
const batcherStats = batcherRef.current?.getStats();
|
|
return {
|
|
totalBatches: batcherStats?.totalBatches ?? 0,
|
|
totalUpdates: batcherStats?.totalUpdates ?? 0,
|
|
averageBatchSize: batcherStats?.averageBatchSize ?? 0,
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!enableBatching || !batcherRef.current) return;
|
|
const interval = setInterval(() => {
|
|
setPendingUpdateCount(batcherRef.current?.getPendingCount() ?? 0);
|
|
}, 50);
|
|
return () => clearInterval(interval);
|
|
}, [enableBatching]);
|
|
|
|
return {
|
|
posts,
|
|
loading,
|
|
isInitialLoading: loading && posts.length === 0 && !refreshing,
|
|
isLoadingMore: loading && posts.length > 0 && !refreshing,
|
|
refreshing,
|
|
error,
|
|
hasMore,
|
|
pendingUpdateCount,
|
|
isProcessing,
|
|
refresh,
|
|
loadMore,
|
|
flush,
|
|
reset,
|
|
forceUpdate,
|
|
getDiffStats,
|
|
diffUpdates,
|
|
};
|
|
}
|
|
|
|
export default useDifferentialPosts;
|