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:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 : '加载失败',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -214,6 +214,10 @@ export interface CursorPaginationState<T> {
|
|||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
/** 是否正在加载 */
|
/** 是否正在加载 */
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
/** 是否首屏加载(空列表初始化) */
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
/** 是否正在加载更多 */
|
||||||
|
isLoadingMore: boolean;
|
||||||
/** 是否正在刷新 */
|
/** 是否正在刷新 */
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
/** 错误信息 */
|
/** 错误信息 */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
) : (
|
) : (
|
||||||
// 网格模式:使用响应式网格
|
// 网格模式:使用响应式网格
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -88,11 +90,71 @@ 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}>
|
||||||
加载更多评论
|
加载更多评论
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user