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:
@@ -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();
|
||||
|
||||
133
src/stores/conversationListSources.ts
Normal file
133
src/stores/conversationListSources.ts
Normal file
@@ -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<ConversationListPage>;
|
||||
/** 至少成功 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<ConversationListPage> {
|
||||
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<ConversationListPage> {
|
||||
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<ConversationListPage> {
|
||||
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);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
*/
|
||||
|
||||
@@ -16,10 +16,12 @@ interface UseConversationsReturn {
|
||||
conversations: ConversationResponse[];
|
||||
isLoading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话列表
|
||||
* 获取会话列表(仅消费 MessageManager;网络游标与 SQLite 回退均在 Manager 内完成,调用方无感)
|
||||
* 用于 MessageListScreen 等需要显示会话列表的组件
|
||||
*/
|
||||
export function useConversations(): UseConversationsReturn {
|
||||
@@ -27,6 +29,7 @@ export function useConversations(): UseConversationsReturn {
|
||||
() => 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<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
totalUnreadCount: number;
|
||||
systemUnreadCount: number;
|
||||
markAllAsRead: () => Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user