From d97df0b2d48c633f2158810ccd18e6bfad0a99d2 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 23 Mar 2026 13:40:48 +0800 Subject: [PATCH] refactor(MessageManager): enhance conversation list handling and integrate local source fallback - Update MessageManager to support both remote and local conversation list sources, improving data retrieval and offline capabilities. - Refactor hooks and components to utilize the new MessageManager structure, ensuring seamless integration of cursor pagination and local caching. - Introduce new types and methods for better management of conversation data and loading states. - Modify MessageListScreen to leverage the updated hooks for fetching conversations, enhancing user experience with improved loading and pagination handling. --- src/screens/message/MessageListScreen.tsx | 30 +-- src/stores/conversationListSources.ts | 133 ++++++++++ src/stores/index.ts | 17 +- src/stores/messageManager.ts | 291 +++++++++++++++++----- src/stores/messageManagerHooks.ts | 25 +- 5 files changed, 407 insertions(+), 89 deletions(-) create mode 100644 src/stores/conversationListSources.ts diff --git a/src/screens/message/MessageListScreen.tsx b/src/screens/message/MessageListScreen.tsx index efc2e2b..2694356 100644 --- a/src/screens/message/MessageListScreen.tsx +++ b/src/screens/message/MessageListScreen.tsx @@ -31,13 +31,19 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme'; import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto'; -import { authService, messageService } from '../../services'; +import { authService } from '../../services'; import { useUserStore, useAuthStore } from '../../stores'; -// 【新架构】使用MessageManager hooks -import { messageManager, useMessageListRefresh, useCreateConversation, useUnreadCount, useMarkAsRead } from '../../stores'; +// 【新架构】使用MessageManager hooks(会话列表数据源自 MessageManager 游标同步) +import { + messageManager, + useMessageListRefresh, + useCreateConversation, + useUnreadCount, + useMarkAsRead, + useMessageManagerConversations, +} from '../../stores'; import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive'; -import { useCursorPagination } from '../../hooks/useCursorPagination'; import { RootStackParamList, MessageStackParamList } from '../../navigation/types'; import { getUserCache } from '../../services/database'; // 导入 EmbeddedChat 组件用于桌面端双栏布局 @@ -162,24 +168,14 @@ export const MessageListScreen: React.FC = () => { const { isDesktop, isTablet, width } = useResponsive(); const isWideScreen = useBreakpointGTE('lg'); - // 【游标分页】使用 useCursorPagination hook 获取会话列表 + // 会话列表:MessageManager 统一游标拉取与合并,与已读/角标同源 const { - list: conversationList, + conversations: conversationList, isLoading, - isRefreshing, hasMore, loadMore, refresh, - error: paginationError, - } = useCursorPagination( - async ({ cursor, pageSize }) => { - return await messageService.getConversationsCursor({ - cursor, - page_size: pageSize, - }); - }, - { pageSize: 20 } - ); + } = useMessageManagerConversations(); // 使用 MessageManager 获取未读数和系统通知数 const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); diff --git a/src/stores/conversationListSources.ts b/src/stores/conversationListSources.ts new file mode 100644 index 0000000..6cece1d --- /dev/null +++ b/src/stores/conversationListSources.ts @@ -0,0 +1,133 @@ +/** + * 会话列表数据源抽象:游标、常规页码、SQLite 等实现同一契约,MessageManager 只依赖接口。 + */ + +import { ConversationResponse } from '../types/dto'; +import { messageService } from '../services/messageService'; +import { getConversationListCache } from '../services/database'; + +export const CONVERSATION_LIST_PAGE_SIZE = 20; + +/** 单次拉取结果(一页或一批) */ +export interface ConversationListPage { + items: ConversationResponse[]; + /** 在当前源上是否还能再 loadNext 一页 */ + hasMore: boolean; +} + +/** + * 分页式会话列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。 + */ +export interface IConversationListPagedSource { + restart(): void; + loadNext(): Promise; + /** 至少成功 loadNext 过一次且仍有下一页时为 true */ + readonly hasMore: boolean; +} + +/** 远端常规分页:GET /conversations?page=&page_size=(始终请求网络,避免走 getConversations 的本地短路) */ +export class NetworkOffsetConversationListPagedSource implements IConversationListPagedSource { + private nextPage = 1; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextPage = 1; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const response = await messageService.getConversations(this.nextPage, this.pageSize, true); + const items = response.list || []; + const totalPages = Math.max(0, response.total_pages ?? 0); + const currentPage = response.page ?? this.nextPage; + this.hasMoreAfterLastLoad = totalPages > 0 && currentPage < totalPages; + this.nextPage = currentPage + 1; + this.loadedOnce = true; + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** 远端游标接口 */ +export class NetworkCursorConversationListPagedSource implements IConversationListPagedSource { + private nextCursor: string | null = null; + private hasMoreAfterLastLoad = false; + private loadedOnce = false; + private readonly pageSize: number; + + constructor(pageSize: number = CONVERSATION_LIST_PAGE_SIZE) { + this.pageSize = pageSize; + } + + restart(): void { + this.nextCursor = null; + this.hasMoreAfterLastLoad = false; + this.loadedOnce = false; + } + + get hasMore(): boolean { + return this.loadedOnce && this.hasMoreAfterLastLoad; + } + + async loadNext(): Promise { + const response = await messageService.getConversationsCursor( + this.nextCursor == null + ? { page_size: this.pageSize } + : { cursor: this.nextCursor, page_size: this.pageSize } + ); + const items = response.list || []; + this.nextCursor = response.next_cursor ?? null; + this.hasMoreAfterLastLoad = Boolean(response.has_more && response.next_cursor != null); + this.loadedOnce = true; + return { items, hasMore: this.hasMoreAfterLastLoad }; + } +} + +/** SQLite 会话列表缓存(单批,无后续页) */ +export class SqliteConversationListPagedSource implements IConversationListPagedSource { + private consumed = false; + + restart(): void { + this.consumed = false; + } + + get hasMore(): boolean { + return false; + } + + async loadNext(): Promise { + if (this.consumed) { + return { items: [], hasMore: false }; + } + this.consumed = true; + try { + const items = await getConversationListCache(); + return { items, hasMore: false }; + } catch (e) { + console.warn('[SqliteConversationListPagedSource] 读取会话列表缓存失败:', e); + return { items: [], hasMore: false }; + } + } +} + +/** 未注入 remote 时,按类型创建默认远端源 */ +export type RemoteConversationListSourceKind = 'cursor' | 'offset'; + +export function createRemoteConversationListSource( + kind: RemoteConversationListSourceKind, + pageSize: number = CONVERSATION_LIST_PAGE_SIZE +): IConversationListPagedSource { + return kind === 'offset' + ? new NetworkOffsetConversationListPagedSource(pageSize) + : new NetworkCursorConversationListPagedSource(pageSize); +} diff --git a/src/stores/index.ts b/src/stores/index.ts index aebb49a..927185a 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -14,7 +14,22 @@ export { // MessageManager 新架构导出(已替换 conversationStore) export { messageManager } from './messageManager'; -export type { MessageEvent, MessageEventType, MessageSubscriber } from './messageManager'; +export type { + MessageEvent, + MessageEventType, + MessageSubscriber, + MessageManagerConversationListDeps, +} from './messageManager'; +export { + CONVERSATION_LIST_PAGE_SIZE, + type ConversationListPage, + type IConversationListPagedSource, + type RemoteConversationListSourceKind, + NetworkCursorConversationListPagedSource, + NetworkOffsetConversationListPagedSource, + SqliteConversationListPagedSource, + createRemoteConversationListSource, +} from './conversationListSources'; export { postManager } from './postManager'; export { groupManager } from './groupManager'; export { userManager } from './userManager'; diff --git a/src/stores/messageManager.ts b/src/stores/messageManager.ts index 5db8812..a4c11f0 100644 --- a/src/stores/messageManager.ts +++ b/src/stores/messageManager.ts @@ -11,6 +11,8 @@ * - 统一处理SSE消息 * - 统一处理本地数据库读写 * - 提供订阅机制供React组件使用 + * + * UI 只应订阅 MessageManager / hooks:列表数据可能来自网络游标或 SQLite 缓存回退,对界面透明。 */ import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto'; @@ -41,14 +43,24 @@ import { saveUserCache, updateMessageStatus, deleteConversation as deleteConversationFromDb, + saveConversationsCache, + saveUsersCache, + saveGroupsCache, } from '../services/database'; import { api } from '../services/api'; import { useAuthStore } from './authStore'; +import { + type IConversationListPagedSource, + SqliteConversationListPagedSource, + createRemoteConversationListSource, + CONVERSATION_LIST_PAGE_SIZE, +} from './conversationListSources'; // ==================== 类型定义 ==================== export type MessageEventType = | 'conversations_updated' + | 'conversations_loading' | 'messages_updated' | 'unread_count_updated' | 'connection_changed' @@ -113,6 +125,19 @@ interface MessageManagerState { */ const READ_STATE_PROTECTION_DELAY = 5000; // 5秒 +/** 可注入会话列表源,便于测试或替换实现 */ +export interface MessageManagerConversationListDeps { + remoteConversationListSource?: IConversationListPagedSource; + localConversationListSource?: IConversationListPagedSource; + /** + * 未传 remoteConversationListSource 时:默认 `cursor`(/conversations/cursor), + * `offset` 为常规页码(/conversations?page=&page_size=)。 + */ + remoteListKind?: 'cursor' | 'offset'; + /** 与 remoteListKind 搭配,默认与 CONVERSATION_LIST_PAGE_SIZE 一致 */ + remoteListPageSize?: number; +} + /** * 已读状态记录接口 */ @@ -138,6 +163,14 @@ class MessageManager { private initializePromise: Promise | null = null; private activatingConversationTasks: Map> = new Map(); + /** 正在加载会话列表下一页 */ + private loadingMoreConversations = false; + + /** 远端会话列表(游标或页码,由注入/remoteListKind 决定) */ + private readonly remoteConversationListSource: IConversationListPagedSource; + /** 本地会话列表缓存(SQLite,单批) */ + private readonly localConversationListSource: IConversationListPagedSource; + /** * 正在进行中的已读 API 请求集合 * 用于防止 fetchConversations 的服务器数据覆盖乐观已读状态 @@ -151,7 +184,17 @@ class MessageManager { */ private readStateVersion: number = 0; - constructor() { + constructor(deps?: MessageManagerConversationListDeps) { + const pageSize = deps?.remoteListPageSize ?? CONVERSATION_LIST_PAGE_SIZE; + this.remoteConversationListSource = + deps?.remoteConversationListSource ?? + createRemoteConversationListSource( + deps?.remoteListKind === 'offset' ? 'offset' : 'cursor', + pageSize + ); + this.localConversationListSource = + deps?.localConversationListSource ?? new SqliteConversationListPagedSource(); + this.state = { conversations: new Map(), conversationList: [], @@ -259,6 +302,85 @@ class MessageManager { this.state.conversationList = list; } + /** + * 将游标接口返回的单条会话写入 Map(统一 string id、尊重 pendingReadMap 已读保护) + */ + private applyConversationFromFetch(conv: ConversationResponse): void { + const id = this.normalizeConversationId(conv.id); + const readRecord = this.pendingReadMap.get(id); + let next: ConversationResponse; + if (readRecord) { + const shouldPreserveLocalRead = (conv.unread_count || 0) > 0; + next = shouldPreserveLocalRead + ? { ...conv, id, unread_count: 0 } + : { ...conv, id }; + } else { + next = { ...conv, id }; + } + this.state.conversations.set(id, next); + } + + /** 与会话列表同步写入本地缓存(参与者 / 群 / 列表) */ + private persistConversationListCache(): void { + const list = Array.from(this.state.conversations.values()); + saveConversationsCache(list).catch(error => { + console.error('[MessageManager] 持久化会话列表失败:', error); + }); + saveUsersCache( + list.flatMap(conv => [ + ...(conv.participants || []), + ...(conv.last_message?.sender ? [conv.last_message.sender] : []), + ]) + ).catch(error => { + console.error('[MessageManager] 持久化会话相关用户失败:', error); + }); + saveGroupsCache( + list + .map(conv => conv.group) + .filter((group): group is NonNullable => Boolean(group)) + ).catch(error => { + console.error('[MessageManager] 持久化会话相关群组失败:', error); + }); + } + + private recomputeConversationTotalUnread(): void { + this.state.totalUnreadCount = Array.from(this.state.conversations.values()).reduce( + (sum, conv) => sum + (conv.unread_count || 0), + 0 + ); + } + + private emitConversationListAndUnreadUpdates(): void { + this.persistConversationListCache(); + this.notifySubscribers({ + type: 'conversations_updated', + payload: { conversations: this.state.conversationList }, + timestamp: Date.now(), + }); + this.notifySubscribers({ + type: 'unread_count_updated', + payload: { + totalUnreadCount: this.state.totalUnreadCount, + systemUnreadCount: this.state.systemUnreadCount, + }, + timestamp: Date.now(), + }); + } + + /** 通过本地分页源灌入内存(暖机 / 网络失败回退) */ + private async hydrateConversationsFromLocalSource(): Promise { + this.localConversationListSource.restart(); + const page = await this.localConversationListSource.loadNext(); + if (!page.items.length) { + return false; + } + this.state.conversations.clear(); + page.items.forEach(conv => this.applyConversationFromFetch(conv)); + this.updateConversationList(); + this.recomputeConversationTotalUnread(); + return true; + } + // ==================== 初始化与销毁 ==================== async initialize(): Promise { @@ -328,6 +450,9 @@ class MessageManager { this.state.isInitialized = false; this.initializePromise = null; this.activatingConversationTasks.clear(); + this.remoteConversationListSource.restart(); + this.localConversationListSource.restart(); + this.loadingMoreConversations = false; } // ==================== SSE 处理 ==================== @@ -996,49 +1121,42 @@ class MessageManager { // ==================== 数据获取方法 ==================== /** - * 获取会话列表 + * 获取会话列表(首屏/下拉刷新:优先网络游标;可回退 SQLite,对 UI 透明) */ async fetchConversations(forceRefresh = false): Promise { if (this.state.isLoadingConversations && !forceRefresh) { return; } + // 非强制刷新且内存为空:先用本地源暖机,便于离线/弱网先展示列表 + if (!forceRefresh && this.state.conversations.size === 0) { + const warmed = await this.hydrateConversationsFromLocalSource(); + if (warmed) { + this.emitConversationListAndUnreadUpdates(); + } + } + this.state.isLoadingConversations = true; + const emitLoadingToUi = this.state.conversationList.length === 0; + if (emitLoadingToUi) { + this.notifySubscribers({ + type: 'conversations_loading', + payload: { loading: true }, + timestamp: Date.now(), + }); + } try { - const response = await messageService.getConversations(1, 20, forceRefresh); - const conversations = response.list || []; + this.remoteConversationListSource.restart(); + const page = await this.remoteConversationListSource.loadNext(); - // 更新会话Map:精确保护 pendingReadMap 中的会话不被服务器旧数据覆盖 - // 场景:markAsRead API 已发出但尚未完成,服务器返回的 unread_count 仍为旧值 this.state.conversations.clear(); - conversations.forEach(conv => { - const readRecord = this.pendingReadMap.get(conv.id); - if (readRecord) { - // 此会话有进行中的已读请求或保护期内,使用智能合并逻辑 - // 如果服务器返回的 unread_count > 0,但本地有更晚的已读记录,则保留本地状态 - const shouldPreserveLocalRead = conv.unread_count > 0; - if (shouldPreserveLocalRead) { - this.state.conversations.set(conv.id, { ...conv, unread_count: 0 }); - } else { - this.state.conversations.set(conv.id, conv); - } - } else { - this.state.conversations.set(conv.id, conv); - } - }); + page.items.forEach(conv => this.applyConversationFromFetch(conv)); - // 更新排序后的列表 this.updateConversationList(); - // 计算总未读数(基于保护后的会话数据) - const totalUnread = Array.from(this.state.conversations.values()) - .reduce((sum, conv) => sum + (conv.unread_count || 0), 0); - this.state.totalUnreadCount = totalUnread; + this.recomputeConversationTotalUnread(); - // 修复本地缓存:refreshConversationsFromServer 已将服务器旧数据写入 SQLite, - // 对于 pendingReadMap 中的会话,需要重新把 unread_count=0 写回本地缓存, - // 避免下次冷启动时读到旧的未读数 if (this.pendingReadMap.size > 0) { for (const convId of this.pendingReadMap.keys()) { updateConversationCacheUnreadCount(convId, 0).catch(error => { @@ -1047,24 +1165,15 @@ class MessageManager { } } - - // 通知更新 - this.notifySubscribers({ - type: 'conversations_updated', - payload: { conversations: this.state.conversationList }, - timestamp: Date.now(), - }); - - this.notifySubscribers({ - type: 'unread_count_updated', - payload: { - totalUnreadCount: this.state.totalUnreadCount, - systemUnreadCount: this.state.systemUnreadCount, - }, - timestamp: Date.now(), - }); + this.emitConversationListAndUnreadUpdates(); } catch (error) { console.error('[MessageManager] 获取会话列表失败:', error); + if (this.state.conversationList.length === 0) { + const recovered = await this.hydrateConversationsFromLocalSource(); + if (recovered) { + this.emitConversationListAndUnreadUpdates(); + } + } this.notifySubscribers({ type: 'error', payload: { error, context: 'fetchConversations' }, @@ -1072,6 +1181,47 @@ class MessageManager { }); } finally { this.state.isLoadingConversations = false; + if (emitLoadingToUi) { + this.notifySubscribers({ + type: 'conversations_loading', + payload: { loading: false }, + timestamp: Date.now(), + }); + } + } + } + + /** + * 会话列表加载下一页(游标追加,不清空已有会话) + */ + async loadMoreConversations(): Promise { + if (!this.remoteConversationListSource.hasMore) { + return; + } + if (this.loadingMoreConversations || this.state.isLoadingConversations) { + return; + } + + this.loadingMoreConversations = true; + try { + const page = await this.remoteConversationListSource.loadNext(); + + page.items.forEach(conv => this.applyConversationFromFetch(conv)); + + this.updateConversationList(); + + this.recomputeConversationTotalUnread(); + + this.emitConversationListAndUnreadUpdates(); + } catch (error) { + console.error('[MessageManager] 加载更多会话失败:', error); + this.notifySubscribers({ + type: 'error', + payload: { error, context: 'loadMoreConversations' }, + timestamp: Date.now(), + }); + } finally { + this.loadingMoreConversations = false; } } @@ -1079,25 +1229,26 @@ class MessageManager { * 获取单个会话详情 */ async fetchConversationDetail(conversationId: string): Promise { + const normalizedId = this.normalizeConversationId(conversationId); try { - const detail = await messageService.getConversationById(conversationId); + const detail = await messageService.getConversationById(normalizedId); if (detail) { // 若此会话有进行中的已读请求或处于保护期内,保留 unread_count=0 - const readRecord = this.pendingReadMap.get(conversationId); + const readRecord = this.pendingReadMap.get(normalizedId); const isPending = !!readRecord; // 使用智能合并:如果服务器返回 unread_count > 0 但本地有更晚的已读记录,保留本地状态 const shouldPreserveLocalRead = isPending && (detail.unread_count || 0) > 0; const safeDetail = shouldPreserveLocalRead - ? { ...detail as ConversationResponse, unread_count: 0 } - : detail as ConversationResponse; - this.state.conversations.set(conversationId, safeDetail); + ? { ...(detail as ConversationResponse), id: normalizedId, unread_count: 0 } + : { ...(detail as ConversationResponse), id: normalizedId }; + this.state.conversations.set(normalizedId, safeDetail); this.updateConversationList(); // getConversationById 内部会调用 saveConversationCache 写入服务器旧数据, // 若有 pending 已读请求或处于保护期内,需要修复本地缓存 if (isPending) { - updateConversationCacheUnreadCount(conversationId, 0).catch(error => { - console.error('[MessageManager] 修复本地缓存未读数失败:', conversationId, error); + updateConversationCacheUnreadCount(normalizedId, 0).catch(error => { + console.error('[MessageManager] 修复本地缓存未读数失败:', normalizedId, error); }); } @@ -1554,15 +1705,16 @@ class MessageManager { * 3. fetchConversations 使用版本号判断数据新旧 */ async markAsRead(conversationId: string, seq: number): Promise { + const normalizedId = this.normalizeConversationId(conversationId); - const conversation = this.state.conversations.get(conversationId); + const conversation = this.state.conversations.get(normalizedId); if (!conversation) { - console.warn('[MessageManager] 会话不存在:', conversationId); + console.warn('[MessageManager] 会话不存在:', normalizedId); return; } const prevUnreadCount = conversation.unread_count || 0; - const existingRecord = this.pendingReadMap.get(conversationId); + const existingRecord = this.pendingReadMap.get(normalizedId); // 使用 seq 去重:同一会话若已上报到更大/相同 seq,则跳过重复上报 if (existingRecord && seq <= existingRecord.lastReadSeq) { @@ -1580,7 +1732,7 @@ class MessageManager { // 1. 标记此会话有进行中的已读请求(防止 fetchConversations 覆盖乐观状态) // 记录时间戳和版本号,用于后续智能合并 - this.pendingReadMap.set(conversationId, { + this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), version: currentVersion, lastReadSeq: seq, @@ -1591,18 +1743,18 @@ class MessageManager { ...conversation, unread_count: 0, }; - this.state.conversations.set(conversationId, updatedConv); + this.state.conversations.set(normalizedId, updatedConv); this.state.totalUnreadCount = Math.max(0, this.state.totalUnreadCount - prevUnreadCount); this.updateConversationList(); // 3. 更新本地数据库 - markConversationAsRead(conversationId).catch(console.error); + markConversationAsRead(normalizedId).catch(console.error); // 4. 立即通知所有订阅者 this.notifySubscribers({ type: 'message_read', payload: { - conversationId, + conversationId: normalizedId, unreadCount: 0, totalUnreadCount: this.state.totalUnreadCount, }, @@ -1626,40 +1778,40 @@ class MessageManager { // 5. 调用 API,完成后设置延迟清除保护 try { - await messageService.markAsRead(conversationId, seq); + await messageService.markAsRead(normalizedId, seq); } catch (error) { console.error('[MessageManager] 标记已读API失败:', error); // 失败时回滚状态 - this.state.conversations.set(conversationId, conversation); + this.state.conversations.set(normalizedId, conversation); this.state.totalUnreadCount += prevUnreadCount; this.updateConversationList(); this.notifySubscribers({ type: 'message_read', payload: { - conversationId, + conversationId: normalizedId, unreadCount: prevUnreadCount, totalUnreadCount: this.state.totalUnreadCount, }, timestamp: Date.now(), }); // API 失败时立即清除保护,允许后续 fetch 恢复状态 - this.pendingReadMap.delete(conversationId); + this.pendingReadMap.delete(normalizedId); return; } // API 成功后,设置延迟清除保护 // 这确保在 API 完成后的一段时间内,fetchConversations 不会用服务器旧数据覆盖已读状态 const clearTimer = setTimeout(() => { - const record = this.pendingReadMap.get(conversationId); + const record = this.pendingReadMap.get(normalizedId); // 只有版本号匹配时才清除(防止清除新的已读请求的保护) if (record && record.version === currentVersion) { - this.pendingReadMap.delete(conversationId); + this.pendingReadMap.delete(normalizedId); } }, READ_STATE_PROTECTION_DELAY); // 更新记录,保存定时器ID - this.pendingReadMap.set(conversationId, { + this.pendingReadMap.set(normalizedId, { timestamp: Date.now(), version: currentVersion, lastReadSeq: seq, @@ -1980,6 +2132,13 @@ class MessageManager { // ==================== 状态查询方法 ==================== + /** + * 会话列表是否还能加载更多(由数据源响应推导,不暴露游标本身) + */ + canLoadMoreConversations(): boolean { + return this.remoteConversationListSource.hasMore; + } + /** * 获取会话列表 */ diff --git a/src/stores/messageManagerHooks.ts b/src/stores/messageManagerHooks.ts index ccf36cd..3962210 100644 --- a/src/stores/messageManagerHooks.ts +++ b/src/stores/messageManagerHooks.ts @@ -16,17 +16,20 @@ interface UseConversationsReturn { conversations: ConversationResponse[]; isLoading: boolean; refresh: () => Promise; + loadMore: () => Promise; + hasMore: boolean; } /** - * 获取会话列表 - * 用于MessageListScreen等需要显示会话列表的组件 + * 获取会话列表(仅消费 MessageManager;网络游标与 SQLite 回退均在 Manager 内完成,调用方无感) + * 用于 MessageListScreen 等需要显示会话列表的组件 */ export function useConversations(): UseConversationsReturn { const [conversations, setConversations] = useState( () => messageManager.getConversations() ); const [isLoading, setIsLoading] = useState(() => messageManager.isLoading()); + const [hasMore, setHasMore] = useState(() => messageManager.canLoadMoreConversations()); useEffect(() => { // 订阅MessageManager的事件 @@ -34,6 +37,10 @@ export function useConversations(): UseConversationsReturn { switch (event.type) { case 'conversations_updated': setConversations(messageManager.getConversations()); + setHasMore(messageManager.canLoadMoreConversations()); + break; + case 'conversations_loading': + setIsLoading(!!event.payload?.loading); break; case 'connection_changed': // 连接状态变化时可能需要刷新 @@ -60,15 +67,19 @@ export function useConversations(): UseConversationsReturn { }, []); const refresh = useCallback(async () => { - setIsLoading(true); await messageManager.fetchConversations(true); - setIsLoading(false); + }, []); + + const loadMore = useCallback(async () => { + await messageManager.loadMoreConversations(); }, []); return { conversations, isLoading, refresh, + loadMore, + hasMore, }; } @@ -672,6 +683,8 @@ interface UseMessageListReturn { conversations: ConversationResponse[]; isLoading: boolean; refresh: () => Promise; + loadMore: () => Promise; + hasMore: boolean; totalUnreadCount: number; systemUnreadCount: number; markAllAsRead: () => Promise; @@ -679,7 +692,7 @@ interface UseMessageListReturn { } export function useMessageList(): UseMessageListReturn { - const { conversations, isLoading, refresh } = useConversations(); + const { conversations, isLoading, refresh, loadMore, hasMore } = useConversations(); const { totalUnreadCount, systemUnreadCount } = useUnreadCount(); const { markAllAsRead, isMarking } = useMarkAsRead(null); @@ -687,6 +700,8 @@ export function useMessageList(): UseMessageListReturn { conversations, isLoading, refresh, + loadMore, + hasMore, totalUnreadCount, systemUnreadCount, markAllAsRead,