Files
frontend/src/stores/message/store.ts
lan 48f31e6617
Some checks failed
Frontend CI / ota-android (push) Successful in 1m38s
Frontend CI / ota-ios (push) Successful in 1m38s
Frontend CI / build-and-push-web (push) Successful in 4m28s
Frontend CI / build-android-apk (push) Has been cancelled
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.
2026-05-12 18:04:32 +08:00

520 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 消息状态 Zustand Store
* 使用 zustand 进行状态管理,提供响应式状态更新
*
* 重构说明:
* - 移除了 MessageStateManager 包装层
* - 直接使用 zustand store 进行状态管理
* - 移除了 SubscriptionManager改用 Zustand selector 进行响应式订阅
*/
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>;
}
// ==================== 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;
// 设置状态
setConversations: (conversations: Map<string, ConversationResponse>) => void;
setConversationsWithUnread: (
conversations: Map<string, ConversationResponse>,
totalUnread: number,
systemUnread: number,
) => void;
updateConversation: (conversation: ConversationResponse) => void;
removeConversation: (conversationId: string) => void;
setMessages: (conversationId: string, messages: MessageResponse[]) => void;
addMessage: (conversationId: string, message: MessageResponse) => void;
updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => void;
setUnreadCount: (total: number, system: number) => 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;
setInitialized: (initialized: boolean) => void;
// 原子性更新(避免中间渲染状态)
updateConversationWithUnread: (
conversation: ConversationResponse,
totalUnread: number,
systemUnread: number,
) => void;
incrementUnreadAtomic: (conversationId: string) => 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(),
};
// ==================== 工具函数 ====================
/**
* 规范化会话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,
};
},
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;
},
// ==================== 设置状态 ====================
setConversations: (conversations: Map<string, ConversationResponse>) => {
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]),
}));
},
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 };
});
},
addMessage: (conversationId: string, message: MessageResponse) => {
const normalizedId = normalizeConversationId(conversationId);
set(state => {
const existing = state.messagesMap.get(normalizedId) || [];
const exists = existing.some(m => String(m.id) === String(message.id));
if (exists) {
return state;
}
const updated = mergeMessagesById(existing, [message]);
const newMessagesMap = new Map(state.messagesMap);
newMessagesMap.set(normalizedId, updated);
return { messagesMap: newMessagesMap };
});
},
updateMessage: (conversationId: string, messageId: string, updates: Partial<MessageResponse>) => {
const normalizedId = normalizeConversationId(conversationId);
set(state => {
const messages = state.messagesMap.get(normalizedId);
if (!messages) return state;
const updated = messages.map(m =>
String(m.id) === String(messageId) ? { ...m, ...updates } : m
);
const newMessagesMap = new Map(state.messagesMap);
newMessagesMap.set(normalizedId, updated);
return { messagesMap: newMessagesMap };
});
},
setUnreadCount: (total: number, system: number) => {
set({ totalUnreadCount: total, systemUnreadCount: system });
},
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 };
});
},
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,
};
});
},
// ==================== 重置 ====================
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 });
}
}