import { create } from "zustand"; import { api } from "../lib/api.ts"; function parseDate(iso: string): number { if (!iso) return Date.now(); const normalized = iso.includes("T") ? iso : iso.replace(" ", "T"); const t = new Date(normalized).getTime(); return isNaN(t) ? Date.now() : t; } export interface MessageEmbed { id?: string; url: string; title?: string; description?: string; image_url?: string; site_name?: string; } export interface Reaction { emoji: string; count: number; users: string[]; } export interface PollOption { id: string; text: string; position: number; votes: number; voters: string[]; } export interface Poll { id: string; message_id: string; question: string; options: PollOption[]; created_at: string; } export interface Message { id: string; channel_id: string; author_id: string; author_username: string; author_display_name: string | null; author_bot?: boolean; bot_id?: string | null; bot_name?: string | null; content: string; reply_to?: string | null; embeds?: MessageEmbed[]; pinned: boolean; reactions?: Reaction[]; poll?: Poll | null; created_at: string; edited_at: string | null; } export interface SearchResultMessage extends Message { channel_name?: string; } export interface MessageState { messagesByChannel: Record; pinnedMessagesByChannel: Record; searchResultsByChannel: Record; selectedMessageIds: Record>; isLoading: boolean; isLoadingOlder: boolean; hasMoreByChannel: Record; error: string | null; fetchMessages: (channelId: string, before?: string) => Promise; fetchOlderMessages: (channelId: string) => Promise; sendMessage: (channelId: string, content: string, replyTo?: string) => Promise; searchMessages: (channelId: string, query: string) => Promise; pinMessage: (channelId: string, messageId: string) => Promise; unpinMessage: (channelId: string, messageId: string) => Promise; fetchPinnedMessages: (channelId: string) => Promise; addMessage: (message: Message) => void; updateMessage: (message: Message) => void; removeMessage: (channelId: string, messageId: string) => void; addReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void; removeReaction: (channelId: string, messageId: string, emoji: string, userId: string) => void; bulkDeleteMessages: (channelId: string, messageIds: string[]) => Promise<{ deleted: number }>; toggleSelectedMessage: (channelId: string, messageId: string) => void; clearSelectedMessages: (channelId: string) => void; createPoll: (channelId: string, question: string, options: string[]) => Promise; votePoll: (pollId: string, optionId: string) => Promise; updatePoll: (channelId: string, poll: Poll) => void; } export const useMessageStore = create((set, get) => ({ messagesByChannel: {}, pinnedMessagesByChannel: {}, searchResultsByChannel: {}, selectedMessageIds: {}, isLoading: false, isLoadingOlder: false, hasMoreByChannel: {}, error: null, fetchMessages: async (channelId, before) => { const chId = channelId.toLowerCase(); set({ isLoading: true, error: null }); try { const params = before ? `?before=${encodeURIComponent(before)}` : ""; const messages = await api.get( `/channels/${chId}/messages${params}`, ); const list = Array.isArray(messages) ? messages : []; set((state) => { const existing = state.messagesByChannel[chId] || []; const map = new Map(); existing.forEach((m) => map.set(m.id, m)); list.forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() })); const merged = Array.from(map.values()).sort( (a, b) => parseDate(a.created_at) - parseDate(b.created_at) ); return { messagesByChannel: { ...state.messagesByChannel, [chId]: merged, }, hasMoreByChannel: { ...state.hasMoreByChannel, [chId]: list.length >= 50, }, isLoading: false, }; }); } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : "Failed to fetch messages", }); } }, fetchOlderMessages: async (channelId) => { const chId = channelId.toLowerCase(); const state = get(); if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return; const existing = state.messagesByChannel[chId] || []; if (existing.length === 0) return; const oldestId = existing[0].id; set({ isLoadingOlder: true }); try { const older = await api.get( `/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`, ); const list = Array.isArray(older) ? older : []; set((state) => { const map = new Map(); [...list, ...existing].forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() })); const merged = Array.from(map.values()).sort( (a, b) => parseDate(a.created_at) - parseDate(b.created_at) ); return { messagesByChannel: { ...state.messagesByChannel, [chId]: merged, }, hasMoreByChannel: { ...state.hasMoreByChannel, [chId]: list.length >= 50, }, isLoadingOlder: false, }; }); } catch { set({ isLoadingOlder: false }); } }, searchMessages: async (channelId, query) => { const chId = channelId.toLowerCase(); const results = await api.get( `/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`, ); const list = Array.isArray(results) ? results : []; set((state) => ({ searchResultsByChannel: { ...state.searchResultsByChannel, [chId]: list, }, })); return list; }, sendMessage: async (channelId, content, replyTo) => { const chId = channelId.toLowerCase(); const body: { content: string; reply_to?: string } = { content }; if (replyTo) body.reply_to = replyTo; const message = await api.post( `/channels/${chId}/messages`, body, ); const normalizedMessage = { ...message, channel_id: (message.channel_id || chId).toLowerCase() }; get().addMessage(normalizedMessage); return normalizedMessage; }, pinMessage: async (channelId, messageId) => { const chId = channelId.toLowerCase(); const updated = await api.put(`/channels/${chId}/messages/${messageId}/pin`, {}); const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() }; get().updateMessage(normalizedMessage); }, unpinMessage: async (channelId, messageId) => { const chId = channelId.toLowerCase(); const updated = await api.delete(`/channels/${chId}/messages/${messageId}/pin`); const normalizedMessage = { ...updated, channel_id: (updated.channel_id || chId).toLowerCase() }; get().updateMessage(normalizedMessage); }, fetchPinnedMessages: async (channelId) => { const chId = channelId.toLowerCase(); const pinned = await api.get(`/channels/${chId}/messages/pinned`); const list = Array.isArray(pinned) ? pinned : []; set((state) => ({ pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [chId]: list, }, })); return list; }, addMessage: (message) => { const chId = (message.channel_id || "").toLowerCase(); const normalizedMessage = { ...message, channel_id: chId }; set((state) => { const list = state.messagesByChannel[chId] || []; if (list.some((m) => m.id === normalizedMessage.id)) { return state; } return { messagesByChannel: { ...state.messagesByChannel, [chId]: [...list, normalizedMessage].sort( (a, b) => parseDate(a.created_at) - parseDate(b.created_at) ), }, }; }); }, updateMessage: (message) => { const chId = (message.channel_id || "").toLowerCase(); const normalizedMessage = { ...message, channel_id: chId }; set((state) => { const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => m.id === normalizedMessage.id ? normalizedMessage : m, ); const pinnedList = state.pinnedMessagesByChannel[chId] || []; let updatedPinned = [...pinnedList]; if (normalizedMessage.pinned) { if (!pinnedList.some((m) => m.id === normalizedMessage.id)) { updatedPinned = [normalizedMessage, ...pinnedList].sort( (a, b) => parseDate(b.created_at) - parseDate(a.created_at) ); } else { updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)); } } else { updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id); } if (updatedPinned.length > 5) { updatedPinned = updatedPinned.slice(0, 5); } return { messagesByChannel: { ...state.messagesByChannel, [chId]: updatedList, }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [chId]: updatedPinned, }, }; }); }, removeMessage: (channelId, messageId) => { const chId = channelId.toLowerCase(); set((state) => { const list = state.messagesByChannel[chId] || []; const pinnedList = state.pinnedMessagesByChannel[chId] || []; return { messagesByChannel: { ...state.messagesByChannel, [chId]: list.filter((m) => m.id !== messageId), }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [chId]: pinnedList.filter((m) => m.id !== messageId), }, }; }); }, addReaction: (channelId, messageId, emoji, userId) => { const chId = channelId.toLowerCase(); set((state) => { const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => { if (m.id !== messageId) return m; const reactions = m.reactions ? [...m.reactions] : []; const existing = reactions.find((r) => r.emoji === emoji); if (existing) { if (!existing.users.includes(userId)) { existing.users = [...existing.users, userId]; existing.count = existing.users.length; } } else { reactions.push({ emoji, count: 1, users: [userId] }); } return { ...m, reactions }; }); return { messagesByChannel: { ...state.messagesByChannel, [chId]: updatedList, }, }; }); }, removeReaction: (channelId, messageId, emoji, userId) => { const chId = channelId.toLowerCase(); set((state) => { const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => { if (m.id !== messageId) return m; if (!m.reactions) return m; const reactions = m.reactions .map((r) => { if (r.emoji !== emoji) return r; const users = r.users.filter((id) => id !== userId); return { ...r, users, count: users.length }; }) .filter((r) => r.count > 0); return { ...m, reactions }; }); return { messagesByChannel: { ...state.messagesByChannel, [chId]: updatedList, }, }; }); }, bulkDeleteMessages: async (channelId, messageIds) => { const chId = channelId.toLowerCase(); const res = await api.post<{ deleted: number }>( `/channels/${chId}/messages/bulk-delete`, { messages: messageIds }, ); set((state) => { const list = state.messagesByChannel[chId] || []; const ids = new Set(messageIds); const selected = { ...state.selectedMessageIds }; delete selected[chId]; return { messagesByChannel: { ...state.messagesByChannel, [chId]: list.filter((m) => !ids.has(m.id)), }, selectedMessageIds: selected, }; }); return res; }, toggleSelectedMessage: (channelId, messageId) => { const chId = channelId.toLowerCase(); set((state) => { const current = state.selectedMessageIds[chId] || new Set(); const next = new Set(current); if (next.has(messageId)) { next.delete(messageId); } else { next.add(messageId); } return { selectedMessageIds: { ...state.selectedMessageIds, [chId]: next, }, }; }); }, clearSelectedMessages: (channelId) => { const chId = channelId.toLowerCase(); set((state) => { const next = { ...state.selectedMessageIds }; delete next[chId]; return { selectedMessageIds: next }; }); }, createPoll: async (channelId, question, options) => { const chId = channelId.toLowerCase(); const resp = await api.post("/polls", { channel_id: chId, question, options, }); return resp; }, votePoll: async (pollId, optionId) => { await api.post(`/polls/${pollId}/vote`, { option_id: optionId }); }, updatePoll: (channelId, poll) => { const chId = channelId.toLowerCase(); set((state) => { const messages = state.messagesByChannel[chId]; if (!messages) return state; const updated = messages.map((m) => m.poll?.id === poll.id ? { ...m, poll } : m, ); return { messagesByChannel: { ...state.messagesByChannel, [chId]: updated, }, }; }); }, }));