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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user