580 lines
19 KiB
TypeScript
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,
|
|
});
|
|
},
|
|
}));
|