feat(message): implement incremental sync and atomic unread updates
Refactor the messaging system to improve synchronization efficiency and state consistency. - Implement incremental message synchronization using sequence numbers (seq) to reduce payload size. - Add `markAllAsRead` batch API support to optimize read receipt processing. - Introduce atomic state updates in `useMessageStore` to prevent inconsistent UI rendering during unread count changes. - Implement notification deduplication in `WSMessageHandler` to prevent duplicate unread increments during reconnection. - Optimize `MessageSyncService` to prioritize incremental sync over full snapshots when local data exists. - Refactor `ReadReceiptManager` to use optimistic updates with proper rollback mechanisms. - Fix minor UI issues in `SettingsScreen` and `PrivacySettingsScreen` related to theme picker z-index and style application.
This commit is contained in:
@@ -33,9 +33,7 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
/** 正在加载会话列表下一页 */
|
||||
private loadingMoreConversations = false;
|
||||
/** 聊天页活跃期间延迟的会话列表刷新 */
|
||||
private deferredConversationRefresh = false;
|
||||
/** fetchUnreadCount 去重:复用 in-flight promise */
|
||||
/** fetchUnreadCount 去重:复用 in-flight promise */
|
||||
private fetchUnreadCountPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -70,7 +68,6 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
}
|
||||
|
||||
if (this.shouldDeferConversationRefresh(source, forceRefresh)) {
|
||||
this.deferredConversationRefresh = true;
|
||||
if (__DEV__) {
|
||||
console.log('[MessageSyncService] defer fetchConversations', {
|
||||
source,
|
||||
@@ -208,8 +205,6 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
if (!hasInMemoryMessages) {
|
||||
try {
|
||||
const localMessages = await messageRepository.getByConversation(conversationId, 20);
|
||||
const localMaxSeq = await messageRepository.getMaxSeq(conversationId);
|
||||
baselineMaxSeq = localMaxSeq;
|
||||
|
||||
if (localMessages.length > 0) {
|
||||
const formattedMessages: MessageResponse[] = localMessages.map(m => ({
|
||||
@@ -224,7 +219,6 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
|
||||
store.setMessages(conversationId, formattedMessages);
|
||||
} else {
|
||||
// 冷启动兜底:先设置空列表
|
||||
store.setMessages(conversationId, []);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -232,32 +226,20 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端快照 + 增量同步
|
||||
try {
|
||||
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
||||
const snapshotMessages = snapshotResp?.messages || [];
|
||||
|
||||
if (snapshotMessages.length > 0) {
|
||||
const existingMessages = store.getMessages(conversationId);
|
||||
const mergedSnapshot = mergeMessagesById(existingMessages, snapshotMessages);
|
||||
store.setMessages(conversationId, mergedSnapshot);
|
||||
// 更新 baseline:合并本地DB后重新计算
|
||||
const currentMessages = store.getMessages(conversationId);
|
||||
baselineMaxSeq = currentMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||
).catch(error => {
|
||||
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// 增量补齐
|
||||
const snapshotMaxSeq = snapshotMessages.reduce((max, m) => Math.max(max, m.seq || 0), 0);
|
||||
if (snapshotMaxSeq > baselineMaxSeq) {
|
||||
// 策略:有本地数据时仅增量同步,无数据时拉快照
|
||||
if (baselineMaxSeq > 0) {
|
||||
// 增量同步:只拉 baselineMaxSeq 之后的消息
|
||||
try {
|
||||
const incrementalResp = await messageService.getMessages(conversationId, baselineMaxSeq);
|
||||
const newMessages = incrementalResp?.messages || [];
|
||||
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
const existingMessages = store.getMessages(conversationId);
|
||||
const mergedMessages = mergeMessagesById(existingMessages, newMessages);
|
||||
const existingMsgs = store.getMessages(conversationId);
|
||||
const mergedMessages = mergeMessagesById(existingMsgs, newMessages);
|
||||
store.setMessages(conversationId, mergedMessages);
|
||||
|
||||
messageRepository.saveMessagesBatch(
|
||||
@@ -266,9 +248,27 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
console.error('[MessageSyncService] 保存增量消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 增量同步失败:', error);
|
||||
}
|
||||
} else {
|
||||
// 冷启动:本地无数据,拉快照
|
||||
try {
|
||||
const snapshotResp = await messageService.getMessages(conversationId, undefined, undefined, 50);
|
||||
const snapshotMessages = snapshotResp?.messages || [];
|
||||
|
||||
if (snapshotMessages.length > 0) {
|
||||
store.setMessages(conversationId, snapshotMessages);
|
||||
|
||||
messageRepository.saveMessagesBatch(
|
||||
MessageMapper.toCachedMessages(snapshotMessages, conversationId)
|
||||
).catch(error => {
|
||||
console.error('[MessageSyncService] 保存快照消息到本地失败:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 快照同步失败:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 快照/增量同步失败:', error);
|
||||
}
|
||||
} else {
|
||||
// 指定了 afterSeq
|
||||
@@ -399,32 +399,74 @@ export class MessageSyncService implements IMessageSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端汇总未读为 0 时,仅在本地也一致时才清零(避免缓存竞态导致的抖动)
|
||||
if (totalUnread === 0) {
|
||||
const localSum = Array.from(store.getState().conversations.values())
|
||||
.reduce((sum, conv) => sum + (conv.unread_count || 0), 0);
|
||||
if (localSum > 0) {
|
||||
// 本地有未读但服务端返回 0 → 可能是后端缓存竞态,保留本地状态
|
||||
// 下一次 fetchConversations 会提供权威的 per-conversation 数据
|
||||
} else {
|
||||
// 本地也无未读,确认清零
|
||||
const currentConversations = store.getState().conversations;
|
||||
let anyCleared = false;
|
||||
const newConversations = new Map(currentConversations);
|
||||
for (const [cid, conv] of newConversations) {
|
||||
if ((conv.unread_count || 0) > 0) {
|
||||
newConversations.set(cid, { ...conv, unread_count: 0 });
|
||||
anyCleared = true;
|
||||
}
|
||||
}
|
||||
if (anyCleared) {
|
||||
store.setConversations(newConversations);
|
||||
this.persistConversationListCache();
|
||||
// 服务端汇总未读为 0:不主动清零本地未读,等 fetchConversations 提供权威数据
|
||||
// 避免后端缓存竞态导致的未读数抖动
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取未读数失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 seq 的轻量增量同步(用于重连场景,替代全量刷新)
|
||||
* 返回 true 表示同步成功,false 表示需要退化为全量刷新
|
||||
*/
|
||||
async syncBySeq(): Promise<boolean> {
|
||||
try {
|
||||
const serverItems = await messageService.getSyncData();
|
||||
if (!serverItems || serverItems.length === 0) return false;
|
||||
|
||||
const store = useMessageStore.getState();
|
||||
const localConversations = store.conversations;
|
||||
|
||||
// 没有本地数据,无法增量同步
|
||||
if (localConversations.size === 0) return false;
|
||||
|
||||
// 构建 serverMap
|
||||
const serverMap = new Map<string, { max_seq: number; last_message_at: string }>();
|
||||
for (const item of serverItems) {
|
||||
serverMap.set(normalizeConversationId(item.id), item);
|
||||
}
|
||||
|
||||
// 对比差异
|
||||
const staleIds: string[] = [];
|
||||
for (const [id, conv] of localConversations) {
|
||||
const serverItem = serverMap.get(id);
|
||||
if (!serverItem) continue;
|
||||
const localMaxSeq = Number(conv.last_seq || 0);
|
||||
const serverMaxSeq = Number(serverItem.max_seq || 0);
|
||||
if (serverMaxSeq > localMaxSeq) {
|
||||
staleIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 差异过大(>50%),退化为全量刷新
|
||||
if (staleIds.length > localConversations.size * 0.5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 对有差异的会话更新数据
|
||||
if (staleIds.length > 0) {
|
||||
const detailPromises = staleIds.map(id =>
|
||||
this.fetchConversationDetail(id).catch(() => {})
|
||||
);
|
||||
await Promise.all(detailPromises);
|
||||
|
||||
// 重新读取 store,避免 stale 引用
|
||||
const activeId = useMessageStore.getState().getActiveConversation();
|
||||
if (activeId && staleIds.includes(activeId)) {
|
||||
const localMaxSeq = useMessageStore.getState().getMessages(activeId).reduce(
|
||||
(max, m) => Math.max(max, m.seq || 0), 0
|
||||
);
|
||||
if (localMaxSeq > 0) {
|
||||
await this.fetchMessages(activeId, localMaxSeq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[MessageSyncService] 获取未读数失败:', error);
|
||||
console.error('[MessageSyncService] syncBySeq 失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user