refactor(MessageManager): enhance conversation list handling and integrate local source fallback
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m34s
Frontend CI / ota-android (push) Successful in 10m57s
Frontend CI / build-android-apk (push) Has been cancelled

- 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.
This commit is contained in:
lafay
2026-03-23 13:40:48 +08:00
parent 26ae288470
commit d97df0b2d4
5 changed files with 407 additions and 89 deletions

View File

@@ -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<void> | null = null;
private activatingConversationTasks: Map<string, Promise<void>> = 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<ConversationResponse['group']> => 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<boolean> {
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<void> {
@@ -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<void> {
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<void> {
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<ConversationResponse | null> {
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<void> {
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;
}
/**
* 获取会话列表
*/