2026-03-18 12:11:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 消息状态管理器
|
|
|
|
|
|
* 只负责管理状态,不包含业务逻辑
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import type { Message, Conversation, UnreadCount } from '../../core/entities/Message';
|
|
|
|
|
|
|
|
|
|
|
|
export type MessageEventType =
|
|
|
|
|
|
| 'conversations_updated'
|
|
|
|
|
|
| 'messages_updated'
|
|
|
|
|
|
| 'unread_count_updated'
|
|
|
|
|
|
| 'connection_changed'
|
|
|
|
|
|
| 'message_sent'
|
|
|
|
|
|
| 'message_read'
|
|
|
|
|
|
| 'message_received'
|
|
|
|
|
|
| 'message_recalled'
|
|
|
|
|
|
| 'typing_status'
|
|
|
|
|
|
| 'group_notice'
|
|
|
|
|
|
| 'error';
|
|
|
|
|
|
|
|
|
|
|
|
export interface MessageEvent {
|
|
|
|
|
|
type: MessageEventType;
|
|
|
|
|
|
payload: any;
|
|
|
|
|
|
timestamp: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export type MessageSubscriber = (event: MessageEvent) => void;
|
|
|
|
|
|
|
|
|
|
|
|
export interface MessageState {
|
|
|
|
|
|
conversations: Map<string, Conversation>;
|
|
|
|
|
|
conversationList: Conversation[];
|
|
|
|
|
|
messagesMap: Map<string, Message[]>;
|
|
|
|
|
|
unreadCount: UnreadCount;
|
2026-03-19 00:56:41 +08:00
|
|
|
|
isSSEConnected: boolean;
|
2026-03-18 12:11:49 +08:00
|
|
|
|
currentConversationId: string | null;
|
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
|
typingUsersMap: Map<string, string[]>;
|
|
|
|
|
|
mutedStatusMap: Map<string, boolean>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class MessageStateManager {
|
|
|
|
|
|
private state: MessageState;
|
|
|
|
|
|
private subscribers: Set<MessageSubscriber>;
|
|
|
|
|
|
private readStateVersion: number = 0;
|
|
|
|
|
|
private pendingReadMap: Map<string, { timestamp: number; version: number }> = new Map();
|
|
|
|
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.state = {
|
|
|
|
|
|
conversations: new Map(),
|
|
|
|
|
|
conversationList: [],
|
|
|
|
|
|
messagesMap: new Map(),
|
|
|
|
|
|
unreadCount: { total: 0, system: 0 },
|
2026-03-19 00:56:41 +08:00
|
|
|
|
isSSEConnected: false,
|
2026-03-18 12:11:49 +08:00
|
|
|
|
currentConversationId: null,
|
|
|
|
|
|
isLoading: false,
|
|
|
|
|
|
typingUsersMap: new Map(),
|
|
|
|
|
|
mutedStatusMap: new Map(),
|
|
|
|
|
|
};
|
|
|
|
|
|
this.subscribers = new Set();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 状态获取 ====================
|
|
|
|
|
|
|
|
|
|
|
|
getState(): MessageState {
|
|
|
|
|
|
return this.state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getConversations(): Conversation[] {
|
|
|
|
|
|
return this.state.conversationList;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getConversation(id: string): Conversation | undefined {
|
|
|
|
|
|
return this.state.conversations.get(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getMessages(conversationId: string): Message[] {
|
|
|
|
|
|
return this.state.messagesMap.get(conversationId) || [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getUnreadCount(): UnreadCount {
|
|
|
|
|
|
return this.state.unreadCount;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getCurrentConversationId(): string | null {
|
|
|
|
|
|
return this.state.currentConversationId;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isConnected(): boolean {
|
2026-03-19 00:56:41 +08:00
|
|
|
|
return this.state.isSSEConnected;
|
2026-03-18 12:11:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getTypingUsers(groupId: string): string[] {
|
|
|
|
|
|
return this.state.typingUsersMap.get(groupId) || [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getMutedStatus(groupId: string): boolean {
|
|
|
|
|
|
return this.state.mutedStatusMap.get(groupId) || false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 状态更新 ====================
|
|
|
|
|
|
|
|
|
|
|
|
setConversations(conversations: Conversation[]): void {
|
|
|
|
|
|
this.state.conversations.clear();
|
|
|
|
|
|
this.state.conversationList = conversations;
|
|
|
|
|
|
|
|
|
|
|
|
for (const conv of conversations) {
|
|
|
|
|
|
this.state.conversations.set(conv.id, conv);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.updateUnreadCount();
|
|
|
|
|
|
this.notify({ type: 'conversations_updated', payload: conversations });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addConversation(conversation: Conversation): void {
|
|
|
|
|
|
this.state.conversations.set(conversation.id, conversation);
|
|
|
|
|
|
this.state.conversationList = Array.from(this.state.conversations.values());
|
|
|
|
|
|
this.updateUnreadCount();
|
|
|
|
|
|
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateConversation(id: string, updates: Partial<Conversation>): void {
|
|
|
|
|
|
const existing = this.state.conversations.get(id);
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
|
const updated = { ...existing, ...updates };
|
|
|
|
|
|
this.state.conversations.set(id, updated);
|
|
|
|
|
|
this.state.conversationList = Array.from(this.state.conversations.values());
|
|
|
|
|
|
this.updateUnreadCount();
|
|
|
|
|
|
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
removeConversation(id: string): void {
|
|
|
|
|
|
this.state.conversations.delete(id);
|
|
|
|
|
|
this.state.messagesMap.delete(id);
|
|
|
|
|
|
this.state.conversationList = Array.from(this.state.conversations.values());
|
|
|
|
|
|
this.updateUnreadCount();
|
|
|
|
|
|
this.notify({ type: 'conversations_updated', payload: this.state.conversationList });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMessages(conversationId: string, messages: Message[]): void {
|
|
|
|
|
|
this.state.messagesMap.set(conversationId, messages);
|
|
|
|
|
|
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addMessage(conversationId: string, message: Message): void {
|
|
|
|
|
|
const messages = this.state.messagesMap.get(conversationId) || [];
|
|
|
|
|
|
const existingIndex = messages.findIndex(m => m.id === message.id);
|
|
|
|
|
|
|
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
|
messages[existingIndex] = message;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
messages.push(message);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.state.messagesMap.set(conversationId, messages);
|
|
|
|
|
|
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
prependMessages(conversationId: string, newMessages: Message[]): void {
|
|
|
|
|
|
const existing = this.state.messagesMap.get(conversationId) || [];
|
|
|
|
|
|
const merged = [...newMessages, ...existing];
|
|
|
|
|
|
const unique = this.deduplicateMessages(merged);
|
|
|
|
|
|
this.state.messagesMap.set(conversationId, unique);
|
|
|
|
|
|
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
appendMessages(conversationId: string, newMessages: Message[]): void {
|
|
|
|
|
|
const existing = this.state.messagesMap.get(conversationId) || [];
|
|
|
|
|
|
const merged = [...existing, ...newMessages];
|
|
|
|
|
|
const unique = this.deduplicateMessages(merged);
|
|
|
|
|
|
this.state.messagesMap.set(conversationId, unique);
|
|
|
|
|
|
this.notify({ type: 'messages_updated', payload: { conversationId, messages: unique } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateMessage(conversationId: string, messageId: string, updates: Partial<Message>): void {
|
|
|
|
|
|
const messages = this.state.messagesMap.get(conversationId);
|
|
|
|
|
|
if (!messages) return;
|
|
|
|
|
|
|
|
|
|
|
|
const index = messages.findIndex(m => m.id === messageId);
|
|
|
|
|
|
if (index >= 0) {
|
|
|
|
|
|
messages[index] = { ...messages[index], ...updates };
|
|
|
|
|
|
this.notify({ type: 'messages_updated', payload: { conversationId, messages } });
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setUnreadCount(count: UnreadCount): void {
|
|
|
|
|
|
this.state.unreadCount = count;
|
|
|
|
|
|
this.notify({ type: 'unread_count_updated', payload: count });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 00:56:41 +08:00
|
|
|
|
setSSEConnected(connected: boolean): void {
|
|
|
|
|
|
this.state.isSSEConnected = connected;
|
2026-03-18 12:11:49 +08:00
|
|
|
|
this.notify({ type: 'connection_changed', payload: connected });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCurrentConversation(id: string | null): void {
|
|
|
|
|
|
this.state.currentConversationId = id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(loading: boolean): void {
|
|
|
|
|
|
this.state.isLoading = loading;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setTypingUsers(groupId: string, userIds: string[]): void {
|
|
|
|
|
|
this.state.typingUsersMap.set(groupId, userIds);
|
|
|
|
|
|
this.notify({ type: 'typing_status', payload: { groupId, userIds } });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setMutedStatus(groupId: string, muted: boolean): void {
|
|
|
|
|
|
this.state.mutedStatusMap.set(groupId, muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 已读状态保护 ====================
|
|
|
|
|
|
|
|
|
|
|
|
beginReadOperation(conversationId: string): number {
|
|
|
|
|
|
this.readStateVersion++;
|
|
|
|
|
|
this.pendingReadMap.set(conversationId, {
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
version: this.readStateVersion,
|
|
|
|
|
|
});
|
|
|
|
|
|
return this.readStateVersion;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
endReadOperation(conversationId: string): void {
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
this.pendingReadMap.delete(conversationId);
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isReadOperationPending(conversationId: string): boolean {
|
|
|
|
|
|
const record = this.pendingReadMap.get(conversationId);
|
|
|
|
|
|
if (!record) return false;
|
|
|
|
|
|
return Date.now() - record.timestamp < 5000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getReadStateVersion(): number {
|
|
|
|
|
|
return this.readStateVersion;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 订阅机制 ====================
|
|
|
|
|
|
|
|
|
|
|
|
subscribe(callback: MessageSubscriber): () => void {
|
|
|
|
|
|
this.subscribers.add(callback);
|
|
|
|
|
|
return () => this.subscribers.delete(callback);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private notify(event: Omit<MessageEvent, 'timestamp'>): void {
|
|
|
|
|
|
const fullEvent: MessageEvent = {
|
|
|
|
|
|
...event,
|
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
|
};
|
|
|
|
|
|
this.subscribers.forEach(callback => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
callback(fullEvent);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('[MessageStateManager] Subscriber error:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ==================== 工具方法 ====================
|
|
|
|
|
|
|
|
|
|
|
|
private updateUnreadCount(): void {
|
|
|
|
|
|
let total = 0;
|
|
|
|
|
|
let system = 0;
|
|
|
|
|
|
|
|
|
|
|
|
for (const conv of this.state.conversations.values()) {
|
|
|
|
|
|
total += conv.unreadCount || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.state.unreadCount = { total, system };
|
|
|
|
|
|
this.notify({ type: 'unread_count_updated', payload: this.state.unreadCount });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private deduplicateMessages(messages: Message[]): Message[] {
|
|
|
|
|
|
const seen = new Set<string>();
|
|
|
|
|
|
return messages.filter(msg => {
|
|
|
|
|
|
if (seen.has(msg.id)) return false;
|
|
|
|
|
|
seen.add(msg.id);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reset(): void {
|
|
|
|
|
|
this.state = {
|
|
|
|
|
|
conversations: new Map(),
|
|
|
|
|
|
conversationList: [],
|
|
|
|
|
|
messagesMap: new Map(),
|
|
|
|
|
|
unreadCount: { total: 0, system: 0 },
|
2026-03-19 00:56:41 +08:00
|
|
|
|
isSSEConnected: false,
|
2026-03-18 12:11:49 +08:00
|
|
|
|
currentConversationId: null,
|
|
|
|
|
|
isLoading: false,
|
|
|
|
|
|
typingUsersMap: new Map(),
|
|
|
|
|
|
mutedStatusMap: new Map(),
|
|
|
|
|
|
};
|
|
|
|
|
|
this.pendingReadMap.clear();
|
|
|
|
|
|
this.readStateVersion = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const messageStateManager = new MessageStateManager();
|