import { create } from "zustand"; import { getChats, getMessages, updateMessageStatus } from "../api/chats"; import type { Chat, DeliveryStatus, Message, MessageType } from "../chat/types"; const DRAFTS_STORAGE_KEY = "bm_drafts_v1"; function loadDraftsFromStorage(): Record { if (typeof window === "undefined") { return {}; } try { const raw = window.localStorage.getItem(DRAFTS_STORAGE_KEY); if (!raw) { return {}; } const parsed = JSON.parse(raw) as Record; const result: Record = {}; for (const [key, value] of Object.entries(parsed)) { const chatId = Number(key); if (Number.isFinite(chatId) && typeof value === "string") { result[chatId] = value; } } return result; } catch { return {}; } } function saveDraftsToStorage(drafts: Record): void { if (typeof window === "undefined") { return; } try { window.localStorage.setItem(DRAFTS_STORAGE_KEY, JSON.stringify(drafts)); } catch { return; } } function mergeDeliveryStatus( incoming: DeliveryStatus | undefined, existing: DeliveryStatus | undefined ): DeliveryStatus | undefined { const rank: Record = { sending: 1, sent: 2, delivered: 3, read: 4 }; const incomingRank = incoming ? rank[incoming] : 0; const existingRank = existing ? rank[existing] : 0; return incomingRank >= existingRank ? incoming : existing; } interface ChatState { chats: Chat[]; activeChatId: number | null; messagesByChat: Record; draftsByChat: Record; hasMoreByChat: Record; loadingMoreByChat: Record; typingByChat: Record; replyToByChat: Record; editingByChat: Record; unreadBoundaryByChat: Record; focusedMessageIdByChat: Record; loadChats: (query?: string) => Promise; setActiveChatId: (chatId: number | null) => void; loadMessages: (chatId: number) => Promise; loadMoreMessages: (chatId: number) => Promise; prependMessage: (chatId: number, message: Message) => boolean; addOptimisticMessage: (params: { chatId: number; senderId: number; type: MessageType; text: string | null; clientMessageId: string; }) => void; confirmMessageByClientId: (chatId: number, clientMessageId: string, message: Message) => void; removeOptimisticMessage: (chatId: number, clientMessageId: string) => void; setMessageDeliveryStatus: (chatId: number, messageId: number, status: DeliveryStatus) => void; setMessageDeliveryStatusUpTo: ( chatId: number, maxMessageId: number, status: DeliveryStatus, senderId: number ) => void; upsertMessage: (chatId: number, message: Message) => void; removeMessage: (chatId: number, messageId: number) => void; restoreMessages: (chatId: number, messages: Message[]) => void; clearChatMessages: (chatId: number) => void; incrementUnread: (chatId: number, hasMention?: boolean) => void; clearUnread: (chatId: number) => void; setTypingUsers: (chatId: number, userIds: number[]) => void; setReplyToMessage: (chatId: number, message: Message | null) => void; setEditingMessage: (chatId: number, message: Message | null) => void; updateChatPinnedMessage: (chatId: number, pinnedMessageId: number | null) => void; applyPresenceEvent: (chatId: number, userId: number, isOnline: boolean, lastSeenAt?: string) => void; removeChat: (chatId: number) => void; setDraft: (chatId: number, text: string) => void; clearDraft: (chatId: number) => void; setFocusedMessage: (chatId: number, messageId: number | null) => void; } export const useChatStore = create((set, get) => ({ chats: [], activeChatId: null, messagesByChat: {}, draftsByChat: loadDraftsFromStorage(), hasMoreByChat: {}, loadingMoreByChat: {}, typingByChat: {}, replyToByChat: {}, editingByChat: {}, unreadBoundaryByChat: {}, focusedMessageIdByChat: {}, loadChats: async (query) => { const chats = await getChats(query); const currentActive = get().activeChatId; const nextActive = chats.some((chat) => chat.id === currentActive) ? currentActive : (chats[0]?.id ?? null); set({ chats, activeChatId: nextActive }); }, setActiveChatId: (chatId) => set({ activeChatId: chatId }), loadMessages: async (chatId) => { const unreadCount = get().chats.find((c) => c.id === chatId)?.unread_count ?? 0; const messages = await getMessages(chatId); const sorted = [...messages].reverse(); const existingById = new Map((get().messagesByChat[chatId] ?? []).map((message) => [message.id, message])); const normalized = sorted.map((message) => { const existing = existingById.get(message.id); const deliveryStatus = mergeDeliveryStatus(message.delivery_status, existing?.delivery_status); return deliveryStatus ? { ...message, delivery_status: deliveryStatus } : message; }); set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: normalized }, unreadBoundaryByChat: { ...state.unreadBoundaryByChat, [chatId]: unreadCount }, hasMoreByChat: { ...state.hasMoreByChat, [chatId]: messages.length >= 50 }, loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: false }, chats: state.chats.map((chat) => chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat ) })); const lastMessage = normalized[normalized.length - 1]; if (lastMessage) { void updateMessageStatus(chatId, lastMessage.id, "message_read"); } }, loadMoreMessages: async (chatId) => { if (get().loadingMoreByChat[chatId]) { return; } const existing = get().messagesByChat[chatId] ?? []; if (!existing.length) { await get().loadMessages(chatId); return; } const oldestId = existing[0]?.id; if (!oldestId) { return; } set((state) => ({ loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: true } })); try { const older = await getMessages(chatId, oldestId); const olderSorted = [...older].reverse(); const knownIds = new Set(existing.map((m) => m.id)); const merged = [...olderSorted.filter((m) => !knownIds.has(m.id)), ...existing]; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: merged }, hasMoreByChat: { ...state.hasMoreByChat, [chatId]: older.length >= 50 } })); } finally { set((state) => ({ loadingMoreByChat: { ...state.loadingMoreByChat, [chatId]: false } })); } }, prependMessage: (chatId, message) => { const old = get().messagesByChat[chatId] ?? []; if (old.some((m) => m.id === message.id || (message.client_message_id && m.client_message_id === message.client_message_id))) { return false; } set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [...old, message] } })); return true; }, addOptimisticMessage: ({ chatId, senderId, type, text, clientMessageId }) => { const old = get().messagesByChat[chatId] ?? []; if (old.some((m) => m.client_message_id === clientMessageId)) { return; } const now = new Date().toISOString(); const optimistic: Message = { id: -(Date.now() + Math.floor(Math.random() * 10000)), chat_id: chatId, sender_id: senderId, type, text, created_at: now, updated_at: now, client_message_id: clientMessageId, delivery_status: "sending", is_pending: true }; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [...old, optimistic] } })); }, confirmMessageByClientId: (chatId, clientMessageId, message) => { const old = get().messagesByChat[chatId] ?? []; const idx = old.findIndex((m) => m.client_message_id === clientMessageId); if (idx === -1) { if (!old.some((m) => m.id === message.id)) { set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [...old, { ...message, client_message_id: clientMessageId, delivery_status: "sent", is_pending: false }] } })); } return; } const next = [...old]; next[idx] = { ...message, client_message_id: clientMessageId, delivery_status: "sent", is_pending: false }; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: next } })); }, removeOptimisticMessage: (chatId, clientMessageId) => { const old = get().messagesByChat[chatId] ?? []; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: old.filter((m) => m.client_message_id !== clientMessageId) } })); }, setMessageDeliveryStatus: (chatId, messageId, status) => { const old = get().messagesByChat[chatId] ?? []; const idx = old.findIndex((m) => m.id === messageId); if (idx === -1) { return; } const current = old[idx]; const order: Record = { sending: 1, sent: 2, delivered: 3, read: 4 }; const currentStatus = current.delivery_status ?? "sent"; if (order[status] <= order[currentStatus]) { return; } const next = [...old]; next[idx] = { ...current, delivery_status: status, is_pending: false }; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: next } })); }, setMessageDeliveryStatusUpTo: (chatId, maxMessageId, status, senderId) => { const old = get().messagesByChat[chatId] ?? []; if (!old.length) { return; } const order: Record = { sending: 1, sent: 2, delivered: 3, read: 4 }; let changed = false; const next = old.map((message) => { if (message.sender_id !== senderId || message.id <= 0 || message.id > maxMessageId) { return message; } const currentStatus = message.delivery_status ?? "sent"; if (order[status] <= order[currentStatus]) { return message; } changed = true; return { ...message, delivery_status: status, is_pending: false }; }); if (!changed) { return; } set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: next } })); }, upsertMessage: (chatId, message) => { const old = get().messagesByChat[chatId] ?? []; if (!old.length) { set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [message] } })); return; } const idx = old.findIndex((m) => m.id === message.id); if (idx === -1) { const next = [...old, message].sort((a, b) => a.id - b.id); set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: next } })); return; } const next = [...old]; const existing = next[idx]; const deliveryStatus = mergeDeliveryStatus(message.delivery_status, existing.delivery_status); next[idx] = deliveryStatus ? { ...message, delivery_status: deliveryStatus } : message; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: next } })); }, removeMessage: (chatId, messageId) => { const old = get().messagesByChat[chatId] ?? []; set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: old.filter((m) => m.id !== messageId) } })); }, restoreMessages: (chatId, messages) => { if (!messages.length) { return; } const old = get().messagesByChat[chatId] ?? []; const byId = new Map(); for (const message of old) { byId.set(message.id, message); } for (const message of messages) { byId.set(message.id, message); } const merged = [...byId.values()].sort((a, b) => a.id - b.id); set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: merged } })); }, clearChatMessages: (chatId) => set((state) => ({ messagesByChat: { ...state.messagesByChat, [chatId]: [] }, unreadBoundaryByChat: { ...state.unreadBoundaryByChat, [chatId]: 0 }, chats: state.chats.map((chat) => chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat ) })), incrementUnread: (chatId, hasMention = false) => set((state) => ({ chats: state.chats.map((chat) => chat.id === chatId ? { ...chat, unread_count: (chat.unread_count ?? 0) + 1, unread_mentions_count: hasMention ? (chat.unread_mentions_count ?? 0) + 1 : (chat.unread_mentions_count ?? 0) } : chat ) })), clearUnread: (chatId) => set((state) => ({ chats: state.chats.map((chat) => chat.id === chatId ? { ...chat, unread_count: 0, unread_mentions_count: 0 } : chat ), unreadBoundaryByChat: { ...state.unreadBoundaryByChat, [chatId]: 0 } })), setTypingUsers: (chatId, userIds) => set((state) => ({ typingByChat: { ...state.typingByChat, [chatId]: userIds } })), setReplyToMessage: (chatId, message) => set((state) => ({ replyToByChat: { ...state.replyToByChat, [chatId]: message } })), setEditingMessage: (chatId, message) => set((state) => ({ editingByChat: { ...state.editingByChat, [chatId]: message } })), updateChatPinnedMessage: (chatId, pinnedMessageId) => set((state) => ({ chats: state.chats.map((chat) => (chat.id === chatId ? { ...chat, pinned_message_id: pinnedMessageId } : chat)) })), applyPresenceEvent: (chatId, userId, isOnline, lastSeenAt) => set((state) => ({ chats: state.chats.map((chat) => { if (chat.id !== chatId) { return chat; } if (chat.type === "private" && chat.counterpart_user_id === userId) { return { ...chat, counterpart_is_online: isOnline, counterpart_last_seen_at: isOnline ? chat.counterpart_last_seen_at : (lastSeenAt ?? new Date().toISOString()) }; } if (chat.type === "group") { const currentOnline = chat.online_count ?? 0; const membersCount = chat.members_count ?? currentOnline; const nextOnline = isOnline ? Math.min(membersCount, currentOnline + 1) : Math.max(0, currentOnline - 1); return { ...chat, online_count: nextOnline }; } return chat; }) })), removeChat: (chatId) => set((state) => { const nextMessagesByChat = { ...state.messagesByChat }; const nextHasMoreByChat = { ...state.hasMoreByChat }; const nextLoadingMoreByChat = { ...state.loadingMoreByChat }; const nextTypingByChat = { ...state.typingByChat }; const nextReplyToByChat = { ...state.replyToByChat }; const nextEditingByChat = { ...state.editingByChat }; const nextUnreadBoundaryByChat = { ...state.unreadBoundaryByChat }; const nextFocusedMessageByChat = { ...state.focusedMessageIdByChat }; const nextDraftsByChat = { ...state.draftsByChat }; delete nextMessagesByChat[chatId]; delete nextHasMoreByChat[chatId]; delete nextLoadingMoreByChat[chatId]; delete nextTypingByChat[chatId]; delete nextReplyToByChat[chatId]; delete nextEditingByChat[chatId]; delete nextUnreadBoundaryByChat[chatId]; delete nextFocusedMessageByChat[chatId]; delete nextDraftsByChat[chatId]; saveDraftsToStorage(nextDraftsByChat); const nextChats = state.chats.filter((chat) => chat.id !== chatId); return { chats: nextChats, activeChatId: state.activeChatId === chatId ? (nextChats[0]?.id ?? null) : state.activeChatId, messagesByChat: nextMessagesByChat, hasMoreByChat: nextHasMoreByChat, loadingMoreByChat: nextLoadingMoreByChat, typingByChat: nextTypingByChat, replyToByChat: nextReplyToByChat, editingByChat: nextEditingByChat, unreadBoundaryByChat: nextUnreadBoundaryByChat, focusedMessageIdByChat: nextFocusedMessageByChat, draftsByChat: nextDraftsByChat, }; }), setDraft: (chatId, text) => set((state) => { const nextDrafts = { ...state.draftsByChat, [chatId]: text }; saveDraftsToStorage(nextDrafts); return { draftsByChat: nextDrafts }; }), clearDraft: (chatId) => set((state) => { if (!(chatId in state.draftsByChat)) { return state; } const next = { ...state.draftsByChat }; delete next[chatId]; saveDraftsToStorage(next); return { draftsByChat: next }; }), setFocusedMessage: (chatId, messageId) => set((state) => ({ focusedMessageIdByChat: { ...state.focusedMessageIdByChat, [chatId]: messageId } })) }));