Files
forkmessager/client-web/src/modules/chats/application/chatStore.ts
Халимов Рустам 852efa090e Опросы
2026-04-07 11:11:35 +03:00

580 lines
19 KiB
TypeScript

import { create } from 'zustand';
import { ChatApi } from '../infrastructure/chatApi';
import { useAuthStore } from '../../auth/application/authStore';
import type { Chat, ChatMember, Message, TypingUser } from '../../../core/domain/types';
interface ChatState {
chats: Chat[];
activeChat: string | null;
messages: Record<string, Message[]>;
pinnedMessages: Record<string, Message[]>;
typingUsers: TypingUser[];
replyTo: Message | null;
editingMessage: Message | null;
isLoadingChats: boolean;
isLoadingMessages: boolean;
searchQuery: string;
drafts: Record<string, string>;
hasMoreMessages: Record<string, boolean>;
setActiveChat: (chatId: string | null) => void;
setSearchQuery: (query: string) => void;
setDraft: (chatId: string, text: string) => void;
getDraft: (chatId: string) => string;
loadChats: () => Promise<void>;
loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise<void>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (messageId: string, chatId: string) => void;
removeMessages: (messageIds: string[], chatId: string) => void;
hideMessages: (messageIds: string[], chatId: string) => void;
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, lastReadSequenceId: number) => void;
markAllAsRead: (chatId: string) => void;
addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
setReplyTo: (message: Message | null) => void;
setEditingMessage: (message: Message | null) => void;
addChat: (chat: Chat) => void;
updateChat: (chat: Chat) => void;
removeChat: (chatId: string) => void;
clearMessages: (chatId: string) => void;
setPinnedMessage: (chatId: string, message: Message) => void;
removePinnedMessage: (chatId: string, messageId: string, newPinned?: Message[] | null) => void;
jumpToMessage: (chatId: string, sequenceId: number) => Promise<void>;
clearStore: () => void;
}
export const useChatStore = create<ChatState>((set, get) => ({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
isLoadingChats: false,
isLoadingMessages: false,
searchQuery: '',
drafts: JSON.parse(localStorage.getItem('knot_drafts') || '{}'),
hasMoreMessages: {},
setActiveChat: (chatId) => set((state) => ({
activeChat: chatId,
replyTo: null,
editingMessage: null,
chats: chatId
? state.chats.map((c) => c.id === chatId ? { ...c, unreadCount: 0 } : c)
: state.chats,
})),
setSearchQuery: (query) => set({ searchQuery: query }),
setDraft: (chatId, text) => {
set((state) => {
const drafts = { ...state.drafts };
if (text.trim()) {
drafts[chatId] = text;
} else {
delete drafts[chatId];
}
localStorage.setItem('knot_drafts', JSON.stringify(drafts));
return { drafts };
});
},
getDraft: (chatId) => {
return get().drafts[chatId] || '';
},
loadChats: async () => {
try {
set({ isLoadingChats: true });
const chats = await ChatApi.getChats();
// Auto-create favorites chat if not present
if (!chats.some((c: any) => c.type === 'favorites')) {
try {
const favChat = await ChatApi.getOrCreateFavorites();
chats.unshift(favChat);
} catch { }
}
// Extract pinned messages from chats
const pinnedMessages: Record<string, Message[]> = {};
for (const chat of chats) {
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
pinnedMessages[chat.id] = chat.pinnedMessages.map((pm: any) => pm.message);
}
}
set({ chats, pinnedMessages, isLoadingChats: false });
} catch (error: any) {
console.error('Load chats error:', error);
set({ isLoadingChats: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load chats');
}
},
loadMessages: async (chatId, reset = false, isHistory = false) => {
try {
const state = get();
if (!reset && !isHistory && typeof state.hasMoreMessages[chatId] !== 'undefined') return;
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
if (state.isLoadingMessages) return;
set({ isLoadingMessages: true });
const currentMessages = state.messages[chatId] || [];
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
const fetched = await ChatApi.getMessages(chatId, cursor);
set((state) => {
// Merge fetched messages with any that arrived via socket
const existing = reset ? [] : (state.messages[chatId] || []);
const fetchedIds = new Set(fetched.map(m => m.id));
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
const merged = [...fetched, ...socketOnly].sort((a, b) => {
const tDiff = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
return tDiff !== 0 ? tDiff : a.sequenceId - b.sequenceId;
});
return {
messages: { ...state.messages, [chatId]: merged },
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: fetched.length >= 50 },
isLoadingMessages: false,
};
});
} catch (error: any) {
console.error('Load messages error:', error);
set({ isLoadingMessages: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to load messages');
}
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
if (chatMessages.some((m) => m.id === message.id)) return state;
const updatedMessages = {
...state.messages,
[message.chatId]: [...chatMessages, message],
};
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: [message],
unreadCount: (chat.id === state.activeChat || message.senderId === userId) ? chat.unreadCount : chat.unreadCount + 1,
};
}
return chat;
});
updatedChats.sort((a, b) => {
const aPin = a.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
const bPin = b.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
if (aPin !== bPin) return bPin - aPin;
const aTime = a.messages[0]?.createdAt || a.createdAt;
const bTime = b.messages[0]?.createdAt || b.createdAt;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
return { messages: updatedMessages, chats: updatedChats };
});
},
updateMessage: (message) => {
set((state) => {
const chatMessages = state.messages[message.chatId] || [];
const updatedMessages = chatMessages.map((m) => (m.id === message.id ? { ...m, ...message } : m));
const updatedChats = state.chats.map((chat) => {
if (chat.id === message.chatId) {
return {
...chat,
messages: chat.messages?.map((m) => (m.id === message.id ? { ...m, ...message } : m)),
};
}
return chat;
});
return {
messages: {
...state.messages,
[message.chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeMessage: (messageId, chatId) => {
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
m.id === messageId ? { ...m, isDeleted: true, content: null } : m
);
// Find the latest non-deleted message to show in sidebar
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
// If the deleted message was the last message shown, replace with previous one
const currentLast = chat.messages?.[0];
if (currentLast?.id === messageId) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.map((m) =>
idsSet.has(m.id) ? { ...m, isDeleted: true, content: null } : m
);
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
hideMessages: (messageIds, chatId) => {
const idsSet = new Set(messageIds);
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updatedMessages = chatMessages.filter((m) => !idsSet.has(m.id));
const latestVisible = updatedMessages
.filter(m => !m.isDeleted)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const currentLast = chat.messages?.[0];
if (currentLast && idsSet.has(currentLast.id)) {
return {
...chat,
messages: latestVisible ? [latestVisible] : [],
};
}
}
return chat;
});
return {
messages: { ...state.messages, [chatId]: updatedMessages },
chats: updatedChats,
};
});
},
addReaction: (messageId, chatId, userId, username, emoji) => {
console.log('[ChatStore] addReaction called:', { messageId, chatId, userId, username, emoji });
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updateMsg = (m: Message) => {
if (m.id === messageId) {
const reactions = m.reactions || [];
const exists = reactions.some((r) => r.userId === userId && r.emoji === emoji);
if (exists) {
console.log('[ChatStore] Reaction already exists, skipping');
return m;
}
console.log('[ChatStore] Adding reaction to message:', m.id);
return {
...m,
reactions: [
...reactions,
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
],
};
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return {
...chat,
messages: chat.messages?.map(updateMsg),
};
}
return chat;
});
console.log('[ChatStore] State updated for chatId:', chatId);
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
removeReaction: (messageId, chatId, userId, emoji) => {
console.log('[ChatStore] removeReaction called:', { messageId, chatId, userId, emoji });
set((state) => {
const chatMessages = state.messages[chatId] || [];
const updateMsg = (m: Message) => {
if (m.id === messageId) {
console.log('[ChatStore] Removing reaction from message:', m.id);
return {
...m,
reactions: (m.reactions || []).filter((r) => !(r.userId === userId && r.emoji === emoji)),
};
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return {
...chat,
messages: chat.messages?.map(updateMsg),
};
}
return chat;
});
console.log('[ChatStore] State updated for chatId:', chatId);
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
markRead: (chatId, userId, lastReadSequenceId) => {
const currentUserId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[chatId] || [];
let newlyReadCount = 0;
const updateMsg = (m: Message) => {
if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] };
}
return m;
};
const updatedMessages = chatMessages.map(updateMsg);
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg);
if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
}
return { ...chat, messages: updatedLastMessages };
}
return chat;
});
return {
messages: {
...state.messages,
[chatId]: updatedMessages,
},
chats: updatedChats,
};
});
},
markAllAsRead: (chatId) => {
set((state) => {
const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) {
return { ...chat, unreadCount: 0 };
}
return chat;
});
return { chats: updatedChats };
});
},
addTypingUser: (chatId, userId) => {
set((state) => {
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
if (exists) return state;
return { typingUsers: [...state.typingUsers, { chatId, userId }] };
});
},
removeTypingUser: (chatId, userId) => {
set((state) => ({
typingUsers: state.typingUsers.filter((t) => !(t.chatId === chatId && t.userId === userId)),
}));
},
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
set((state) => ({
chats: state.chats.map((chat) => ({
...chat,
members: chat.members.map((m) =>
m.user.id === userId
? { ...m, user: { ...m.user, isOnline, lastSeen: lastSeen || m.user.lastSeen } }
: m
),
})),
}));
},
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
addChat: (chat) => {
set((state) => {
const existing = state.chats.find((c) => c.id === chat.id);
const messagesFromState = state.messages[chat.id] || [];
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
let unreadCount = chat.unreadCount || 0;
if (!existing && messagesFromState.length > 0) {
const userId = useAuthStore.getState().user?.id;
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
}
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
if (existing) {
return {
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...updatedChat } : c)),
};
}
return { chats: [updatedChat, ...state.chats] };
});
},
updateChat: (chat) => {
set((state) => ({
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
}));
},
removeChat: (chatId) => {
set((state) => ({
chats: state.chats.filter((c) => c.id !== chatId),
activeChat: state.activeChat === chatId ? null : state.activeChat,
messages: (() => { const m = { ...state.messages }; delete m[chatId]; return m; })(),
}));
},
clearMessages: (chatId) => {
set((state) => ({
messages: { ...state.messages, [chatId]: [] },
chats: state.chats.map((c) =>
c.id === chatId ? { ...c, messages: [] } : c
),
}));
},
setPinnedMessage: (chatId, message) => {
set((state) => {
const existing = state.pinnedMessages[chatId] || [];
if (existing.some(m => m.id === message.id)) return state;
return {
pinnedMessages: {
...state.pinnedMessages,
[chatId]: [...existing, message]
},
};
});
},
removePinnedMessage: (chatId, messageId, newPinned?) => {
set((state) => {
const updated = { ...state.pinnedMessages };
if (newPinned) {
updated[chatId] = newPinned;
} else {
const filtered = (updated[chatId] || []).filter(m => m.id !== messageId);
if (filtered.length === 0) delete updated[chatId];
else updated[chatId] = filtered;
}
return { pinnedMessages: updated };
});
},
jumpToMessage: async (chatId, sequenceId) => {
try {
set({ isLoadingMessages: true });
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
set((state) => ({
messages: { ...state.messages, [chatId]: fetched },
// Since we jumped, we assume there is more history to load above
hasMoreMessages: { ...state.hasMoreMessages, [chatId]: true },
isLoadingMessages: false,
}));
} catch (error: any) {
console.error('Jump to message error:', error);
set({ isLoadingMessages: false });
const { addNotification } = (await import('../../../core/application/stores/notificationStore')).useNotificationStore.getState();
addNotification('error', error.message || 'Failed to jump to message');
}
},
clearStore: () => {
set({
chats: [],
activeChat: null,
messages: {},
pinnedMessages: {},
typingUsers: [],
replyTo: null,
editingMessage: null,
});
},
}));