Refactor the messaging subsystem to enhance data consistency, prevent race conditions during WebSocket reconnection, and ensure accurate unread counts.
- **WebSocket Reliability**: Implement `client_msg_id` for precise message ACK matching, preventing incorrect pending message resolution in high-frequency scenarios.
- **Sync Logic**: Update `WSMessageHandler` and `MessageManager` to use a bootstrapping state, ensuring buffered SSE events are flushed only after the initial synchronization is complete.
- **State Consistency**:
- Introduce atomic unread count increments to prevent lost updates.
- Implement conditional rollback for optimistic read receipts, ensuring that failed API calls do not overwrite newer, valid read states.
- **Resource Management**: Add reference counting to `useMessages` hook to prevent premature clearing of loading states during rapid conversation switching or React StrictMode double-invocations.
- **Data Integrity**: Update `UserCacheService` to re-read the latest message list before applying sender info enrichment, ensuring new messages arriving during the async process are correctly processed.
583 lines
19 KiB
TypeScript
583 lines
19 KiB
TypeScript
/**
|
||
* 消息状态 Zustand Store
|
||
* 使用 zustand 进行状态管理,提供响应式状态更新
|
||
*/
|
||
|
||
import { create } from 'zustand';
|
||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||
import type {
|
||
ConversationResponse,
|
||
MessageResponse,
|
||
} from '../../types/dto';
|
||
|
||
const LAST_SYSTEM_MESSAGE_AT_KEY = 'last_system_message_at';
|
||
|
||
// ==================== 状态接口 ====================
|
||
|
||
/**
|
||
* 消息状态接口(不包含订阅者,订阅者单独管理)
|
||
*/
|
||
export interface MessageState {
|
||
// 会话相关
|
||
conversations: Map<string, ConversationResponse>;
|
||
conversationList: ConversationResponse[];
|
||
|
||
// 消息相关 - 按会话ID存储
|
||
messagesMap: Map<string, MessageResponse[]>;
|
||
|
||
// 未读数
|
||
totalUnreadCount: number;
|
||
systemUnreadCount: number;
|
||
|
||
// 最后一条系统通知时间
|
||
lastSystemMessageAt: string | null;
|
||
|
||
// 连接状态
|
||
isWSConnected: boolean;
|
||
|
||
// 当前活动会话ID(用户正在查看的会话)
|
||
currentConversationId: string | null;
|
||
|
||
// 加载状态
|
||
isLoadingConversations: boolean;
|
||
loadingMessagesSet: Set<string>;
|
||
|
||
// 初始化状态
|
||
isInitialized: boolean;
|
||
|
||
// 输入状态 - 按群组ID存储正在输入的用户ID列表
|
||
typingUsersMap: Map<string, string[]>;
|
||
|
||
// 当前用户的禁言状态 - 按群组ID存储
|
||
mutedStatusMap: Map<string, boolean>;
|
||
|
||
// 会话免打扰状态 - 按会话ID存储
|
||
notificationMutedMap: Map<string, boolean>;
|
||
|
||
// 版本日志同步游标
|
||
syncVersion: number | null;
|
||
}
|
||
|
||
// ==================== Actions 接口 ====================
|
||
|
||
export interface MessageActions {
|
||
// 获取状态
|
||
getState: () => MessageState;
|
||
getConversations: () => ConversationResponse[];
|
||
getConversation: (conversationId: string) => ConversationResponse | null;
|
||
getMessages: (conversationId: string) => MessageResponse[];
|
||
getUnreadCount: () => { total: number; system: number };
|
||
isConnected: () => boolean;
|
||
isLoading: () => boolean;
|
||
isLoadingMessages: (conversationId: string) => boolean;
|
||
getTypingUsers: (groupId: string) => string[];
|
||
isMuted: (groupId: string) => boolean;
|
||
getActiveConversation: () => string | null;
|
||
isNotificationMuted: (conversationId: string) => boolean;
|
||
|
||
// 设置状态
|
||
setConversationsWithUnread: (
|
||
conversations: Map<string, ConversationResponse>,
|
||
totalUnread: number,
|
||
systemUnread: number,
|
||
) => void;
|
||
updateConversation: (conversation: ConversationResponse) => void;
|
||
removeConversation: (conversationId: string) => void;
|
||
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
|
||
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => void;
|
||
removeMessage: (conversationId: string, messageId: string) => void;
|
||
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => void;
|
||
setUnreadCount: (total: number, system: number) => void;
|
||
/**
|
||
* 原子递增系统未读数:避免外部 RMW 造成丢失
|
||
*/
|
||
incrementSystemUnreadAtomic: () => void;
|
||
setLastSystemMessageAt: (time: string | null) => void;
|
||
setSSEConnected: (connected: boolean) => void;
|
||
setCurrentConversation: (conversationId: string | null) => void;
|
||
setLoading: (loading: boolean) => void;
|
||
setLoadingMessages: (conversationId: string, loading: boolean) => void;
|
||
setTypingUsers: (groupId: string, users: string[]) => void;
|
||
setMutedStatus: (groupId: string, isMuted: boolean) => void;
|
||
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => void;
|
||
setSyncVersion: (version: number) => void;
|
||
setInitialized: (initialized: boolean) => void;
|
||
|
||
// 原子性更新(避免中间渲染状态)
|
||
updateConversationWithUnread: (
|
||
conversation: ConversationResponse,
|
||
totalUnread: number,
|
||
systemUnread: number,
|
||
) => void;
|
||
incrementUnreadAtomic: (conversationId: string) => void;
|
||
|
||
/**
|
||
* 原子回滚乐观已读:仅在当前会话的 my_last_read_seq 仍等于 expectedReadSeq 时才回滚
|
||
* 避免覆盖后续并发 markAsRead 已更新的更大值
|
||
*/
|
||
rollbackReadIfUnchanged: (
|
||
conversationId: string,
|
||
expectedReadSeq: number,
|
||
prevUnreadCount: number,
|
||
) => void;
|
||
|
||
// 重置
|
||
reset: () => void;
|
||
}
|
||
|
||
// ==================== 初始状态 ====================
|
||
|
||
const initialState: MessageState = {
|
||
conversations: new Map(),
|
||
conversationList: [],
|
||
messagesMap: new Map(),
|
||
totalUnreadCount: 0,
|
||
systemUnreadCount: 0,
|
||
lastSystemMessageAt: null,
|
||
isWSConnected: false,
|
||
currentConversationId: null,
|
||
isLoadingConversations: false,
|
||
loadingMessagesSet: new Set(),
|
||
isInitialized: false,
|
||
typingUsersMap: new Map(),
|
||
mutedStatusMap: new Map(),
|
||
notificationMutedMap: new Map(),
|
||
syncVersion: null,
|
||
};
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
/**
|
||
* 规范化会话ID
|
||
*/
|
||
export function normalizeConversationId(conversationId: string | number | null | undefined): string {
|
||
return conversationId == null ? '' : String(conversationId);
|
||
}
|
||
|
||
/**
|
||
* 更新会话列表排序
|
||
* 排序规则:置顶优先,再按最后消息时间排序
|
||
*/
|
||
function sortConversationList(conversations: Map<string, ConversationResponse>): ConversationResponse[] {
|
||
return Array.from(conversations.values()).sort((a, b) => {
|
||
const aPinned = a.is_pinned ? 1 : 0;
|
||
const bPinned = b.is_pinned ? 1 : 0;
|
||
if (aPinned !== bPinned) {
|
||
return bPinned - aPinned;
|
||
}
|
||
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
|
||
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
|
||
if (Number.isNaN(aTime) || Number.isNaN(bTime)) return 0;
|
||
return bTime - aTime;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 按 message_id 合并消息并按 seq 排序
|
||
*/
|
||
export function mergeMessagesById(base: MessageResponse[], incoming: MessageResponse[]): MessageResponse[] {
|
||
if (incoming.length === 0) return base;
|
||
const merged = new Map<string, MessageResponse>();
|
||
[...base, ...incoming].forEach(msg => {
|
||
merged.set(String(msg.id), msg);
|
||
});
|
||
return Array.from(merged.values()).sort((a, b) => a.seq - b.seq);
|
||
}
|
||
|
||
// ==================== Store 创建 ====================
|
||
|
||
export type MessageStore = MessageState & MessageActions;
|
||
|
||
export const useMessageStore = create<MessageStore>((set, get) => ({
|
||
// ==================== 初始状态 ====================
|
||
...initialState,
|
||
|
||
// ==================== 获取状态 ====================
|
||
|
||
getState: () => {
|
||
const state = get();
|
||
return {
|
||
conversations: state.conversations,
|
||
conversationList: state.conversationList,
|
||
messagesMap: state.messagesMap,
|
||
totalUnreadCount: state.totalUnreadCount,
|
||
systemUnreadCount: state.systemUnreadCount,
|
||
lastSystemMessageAt: state.lastSystemMessageAt,
|
||
isWSConnected: state.isWSConnected,
|
||
currentConversationId: state.currentConversationId,
|
||
isLoadingConversations: state.isLoadingConversations,
|
||
loadingMessagesSet: state.loadingMessagesSet,
|
||
isInitialized: state.isInitialized,
|
||
typingUsersMap: state.typingUsersMap,
|
||
mutedStatusMap: state.mutedStatusMap,
|
||
notificationMutedMap: state.notificationMutedMap,
|
||
syncVersion: state.syncVersion,
|
||
};
|
||
},
|
||
|
||
getConversations: () => {
|
||
return get().conversationList;
|
||
},
|
||
|
||
getConversation: (conversationId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
return get().conversations.get(normalizedId) || null;
|
||
},
|
||
|
||
getMessages: (conversationId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
return get().messagesMap.get(normalizedId) || [];
|
||
},
|
||
|
||
getUnreadCount: () => {
|
||
const state = get();
|
||
return {
|
||
total: state.totalUnreadCount,
|
||
system: state.systemUnreadCount,
|
||
};
|
||
},
|
||
|
||
isConnected: () => {
|
||
return get().isWSConnected;
|
||
},
|
||
|
||
isLoading: () => {
|
||
return get().isLoadingConversations;
|
||
},
|
||
|
||
isLoadingMessages: (conversationId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
return get().loadingMessagesSet.has(normalizedId);
|
||
},
|
||
|
||
getTypingUsers: (groupId: string) => {
|
||
return get().typingUsersMap.get(groupId) || [];
|
||
},
|
||
|
||
isMuted: (groupId: string) => {
|
||
return get().mutedStatusMap.get(groupId) || false;
|
||
},
|
||
|
||
getActiveConversation: () => {
|
||
return get().currentConversationId;
|
||
},
|
||
|
||
isNotificationMuted: (conversationId: string) => {
|
||
return get().notificationMutedMap.get(conversationId) || false;
|
||
},
|
||
|
||
// ==================== 设置状态 ====================
|
||
|
||
setConversationsWithUnread: (
|
||
conversations: Map<string, ConversationResponse>,
|
||
totalUnread: number,
|
||
systemUnread: number,
|
||
) => {
|
||
const conversationList = sortConversationList(conversations);
|
||
const notificationMutedMap = new Map<string, boolean>();
|
||
conversations.forEach((conv, id) => {
|
||
if (conv.notification_muted) {
|
||
notificationMutedMap.set(id, true);
|
||
}
|
||
});
|
||
set(state => ({
|
||
conversations,
|
||
conversationList,
|
||
notificationMutedMap: new Map([...state.notificationMutedMap, ...notificationMutedMap]),
|
||
totalUnreadCount: totalUnread,
|
||
systemUnreadCount: systemUnread,
|
||
}));
|
||
},
|
||
|
||
updateConversation: (conversation: ConversationResponse) => {
|
||
const id = normalizeConversationId(conversation.id);
|
||
set(state => {
|
||
const newConversations = new Map(state.conversations);
|
||
newConversations.set(id, { ...conversation, id });
|
||
const conversationList = sortConversationList(newConversations);
|
||
const newNotificationMutedMap = new Map(state.notificationMutedMap);
|
||
if (conversation.notification_muted) {
|
||
newNotificationMutedMap.set(id, true);
|
||
} else {
|
||
newNotificationMutedMap.delete(id);
|
||
}
|
||
return { conversations: newConversations, conversationList, notificationMutedMap: newNotificationMutedMap };
|
||
});
|
||
},
|
||
|
||
removeConversation: (conversationId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const target = state.conversations.get(normalizedId);
|
||
const removedUnread = target?.unread_count || 0;
|
||
|
||
const newConversations = new Map(state.conversations);
|
||
newConversations.delete(normalizedId);
|
||
|
||
const newMessagesMap = new Map(state.messagesMap);
|
||
newMessagesMap.delete(normalizedId);
|
||
|
||
const newLoadingMessagesSet = new Set(state.loadingMessagesSet);
|
||
newLoadingMessagesSet.delete(normalizedId);
|
||
|
||
const newTotalUnreadCount = Math.max(0, state.totalUnreadCount - removedUnread);
|
||
|
||
const newCurrentConversationId = state.currentConversationId === normalizedId
|
||
? null
|
||
: state.currentConversationId;
|
||
|
||
const conversationList = sortConversationList(newConversations);
|
||
|
||
return {
|
||
conversations: newConversations,
|
||
conversationList,
|
||
messagesMap: newMessagesMap,
|
||
totalUnreadCount: newTotalUnreadCount,
|
||
currentConversationId: newCurrentConversationId,
|
||
loadingMessagesSet: newLoadingMessagesSet,
|
||
};
|
||
});
|
||
},
|
||
|
||
setMessages: (conversationId: string, messages: MessageResponse[]) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const newMessagesMap = new Map(state.messagesMap);
|
||
newMessagesMap.set(normalizedId, messages);
|
||
return { messagesMap: newMessagesMap };
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 原子性合并消息到 messagesMap
|
||
* 在 zustand set 回调内完成 read-merge-write,消除并发竞态
|
||
*/
|
||
mergeMessages: (conversationId: string, incoming: MessageResponse[]) => {
|
||
if (incoming.length === 0) return;
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const existing = state.messagesMap.get(normalizedId) || [];
|
||
const merged = mergeMessagesById(existing, incoming);
|
||
const newMessagesMap = new Map(state.messagesMap);
|
||
newMessagesMap.set(normalizedId, merged);
|
||
return { messagesMap: newMessagesMap };
|
||
});
|
||
},
|
||
|
||
removeMessage: (conversationId: string, messageId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const existing = state.messagesMap.get(normalizedId);
|
||
if (!existing) return state;
|
||
const filtered = existing.filter(m => String(m.id) !== String(messageId));
|
||
if (filtered.length === existing.length) return state;
|
||
const newMessagesMap = new Map(state.messagesMap);
|
||
newMessagesMap.set(normalizedId, filtered);
|
||
return { messagesMap: newMessagesMap };
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 原子性 patch:按消息 ID 更新指定字段(如 sender、status)
|
||
* 不影响其他消息,不丢失并发写入
|
||
*/
|
||
patchMessages: (conversationId: string, patches: Map<string, Partial<MessageResponse>>) => {
|
||
if (patches.size === 0) return;
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const existing = state.messagesMap.get(normalizedId);
|
||
if (!existing) return state;
|
||
let changed = false;
|
||
const updated = existing.map(m => {
|
||
const patch = patches.get(String(m.id));
|
||
if (patch) {
|
||
changed = true;
|
||
return { ...m, ...patch };
|
||
}
|
||
return m;
|
||
});
|
||
if (!changed) return state;
|
||
const newMessagesMap = new Map(state.messagesMap);
|
||
newMessagesMap.set(normalizedId, updated);
|
||
return { messagesMap: newMessagesMap };
|
||
});
|
||
},
|
||
|
||
setUnreadCount: (total: number, system: number) => {
|
||
set({ totalUnreadCount: total, systemUnreadCount: system });
|
||
},
|
||
|
||
incrementSystemUnreadAtomic: () => {
|
||
set(state => ({
|
||
totalUnreadCount: state.totalUnreadCount + 1,
|
||
systemUnreadCount: state.systemUnreadCount + 1,
|
||
}));
|
||
},
|
||
|
||
setLastSystemMessageAt: (time: string | null) => {
|
||
set({ lastSystemMessageAt: time });
|
||
if (time) {
|
||
AsyncStorage.setItem(LAST_SYSTEM_MESSAGE_AT_KEY, time).catch(() => {});
|
||
} else {
|
||
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
|
||
}
|
||
},
|
||
|
||
setSSEConnected: (connected: boolean) => {
|
||
set({ isWSConnected: connected });
|
||
},
|
||
|
||
setCurrentConversation: (conversationId: string | null) => {
|
||
set({ currentConversationId: conversationId });
|
||
},
|
||
|
||
setLoading: (loading: boolean) => {
|
||
set({ isLoadingConversations: loading });
|
||
},
|
||
|
||
setLoadingMessages: (conversationId: string, loading: boolean) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const newLoadingMessagesSet = new Set(state.loadingMessagesSet);
|
||
if (loading) {
|
||
newLoadingMessagesSet.add(normalizedId);
|
||
} else {
|
||
newLoadingMessagesSet.delete(normalizedId);
|
||
}
|
||
return { loadingMessagesSet: newLoadingMessagesSet };
|
||
});
|
||
},
|
||
|
||
setTypingUsers: (groupId: string, users: string[]) => {
|
||
set(state => {
|
||
const newTypingUsersMap = new Map(state.typingUsersMap);
|
||
newTypingUsersMap.set(groupId, users);
|
||
return { typingUsersMap: newTypingUsersMap };
|
||
});
|
||
},
|
||
|
||
setMutedStatus: (groupId: string, isMuted: boolean) => {
|
||
set(state => {
|
||
const newMutedStatusMap = new Map(state.mutedStatusMap);
|
||
newMutedStatusMap.set(groupId, isMuted);
|
||
return { mutedStatusMap: newMutedStatusMap };
|
||
});
|
||
},
|
||
|
||
setNotificationMuted: (conversationId: string, notificationMuted: boolean) => {
|
||
set(state => {
|
||
const newNotificationMutedMap = new Map(state.notificationMutedMap);
|
||
newNotificationMutedMap.set(conversationId, notificationMuted);
|
||
return { notificationMutedMap: newNotificationMutedMap };
|
||
});
|
||
},
|
||
|
||
setSyncVersion: (version: number) => {
|
||
set({ syncVersion: version });
|
||
},
|
||
|
||
setInitialized: (initialized: boolean) => {
|
||
set({ isInitialized: initialized });
|
||
},
|
||
|
||
updateConversationWithUnread: (
|
||
conversation: ConversationResponse,
|
||
totalUnread: number,
|
||
systemUnread: number,
|
||
) => {
|
||
const id = normalizeConversationId(conversation.id);
|
||
set(state => {
|
||
const newConversations = new Map(state.conversations);
|
||
newConversations.set(id, { ...conversation, id });
|
||
const conversationList = sortConversationList(newConversations);
|
||
const newNotificationMutedMap = new Map(state.notificationMutedMap);
|
||
if (conversation.notification_muted) {
|
||
newNotificationMutedMap.set(id, true);
|
||
} else {
|
||
newNotificationMutedMap.delete(id);
|
||
}
|
||
return {
|
||
conversations: newConversations,
|
||
conversationList,
|
||
notificationMutedMap: newNotificationMutedMap,
|
||
totalUnreadCount: totalUnread,
|
||
systemUnreadCount: systemUnread,
|
||
};
|
||
});
|
||
},
|
||
|
||
incrementUnreadAtomic: (conversationId: string) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const conversation = state.conversations.get(normalizedId);
|
||
if (!conversation) return state;
|
||
|
||
const newConversations = new Map(state.conversations);
|
||
newConversations.set(normalizedId, {
|
||
...conversation,
|
||
unread_count: (conversation.unread_count || 0) + 1,
|
||
});
|
||
const conversationList = sortConversationList(newConversations);
|
||
|
||
return {
|
||
conversations: newConversations,
|
||
conversationList,
|
||
totalUnreadCount: state.totalUnreadCount + 1,
|
||
};
|
||
});
|
||
},
|
||
|
||
rollbackReadIfUnchanged: (
|
||
conversationId: string,
|
||
expectedReadSeq: number,
|
||
prevUnreadCount: number,
|
||
) => {
|
||
const normalizedId = normalizeConversationId(conversationId);
|
||
set(state => {
|
||
const conversation = state.conversations.get(normalizedId);
|
||
if (!conversation) return state;
|
||
// 仅当本地乐观已读游标仍是本次写入的值时,才回滚未读
|
||
// 否则说明已被更新的 markAsRead 覆盖,不应回滚
|
||
if (Number(conversation.my_last_read_seq || 0) !== expectedReadSeq) {
|
||
return state;
|
||
}
|
||
const newConversations = new Map(state.conversations);
|
||
newConversations.set(normalizedId, {
|
||
...conversation,
|
||
unread_count: prevUnreadCount,
|
||
});
|
||
const conversationList = sortConversationList(newConversations);
|
||
// 同步回滚 totalUnreadCount:加回本次扣减的未读数
|
||
const unreadDelta = prevUnreadCount - (conversation.unread_count || 0);
|
||
return {
|
||
conversations: newConversations,
|
||
conversationList,
|
||
totalUnreadCount: state.totalUnreadCount + unreadDelta,
|
||
};
|
||
});
|
||
},
|
||
|
||
// ==================== 重置 ====================
|
||
|
||
reset: () => {
|
||
set({
|
||
...initialState,
|
||
conversations: new Map(),
|
||
messagesMap: new Map(),
|
||
loadingMessagesSet: new Set(),
|
||
typingUsersMap: new Map(),
|
||
mutedStatusMap: new Map(),
|
||
notificationMutedMap: new Map(),
|
||
});
|
||
AsyncStorage.removeItem(LAST_SYSTEM_MESSAGE_AT_KEY).catch(() => {});
|
||
},
|
||
}));
|
||
|
||
export async function loadPersistedLastSystemMessageAt(): Promise<void> {
|
||
const time = await AsyncStorage.getItem(LAST_SYSTEM_MESSAGE_AT_KEY);
|
||
if (time) {
|
||
useMessageStore.setState({ lastSystemMessageAt: time });
|
||
}
|
||
}
|
||
|