import { create } from "zustand"; import { api } from "../lib/api.ts"; 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; 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>; // ponytail: per-channel bulk selection 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) => { set({ isLoading: true, error: null }); try { const params = before ? `?before=${encodeURIComponent(before)}` : ""; const messages = await api.get( `/channels/${channelId}/messages${params}`, ); const list = Array.isArray(messages) ? messages : []; set((state) => ({ messagesByChannel: { ...state.messagesByChannel, [channelId]: list, }, hasMoreByChannel: { ...state.hasMoreByChannel, [channelId]: list.length >= 50, }, isLoading: false, })); } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : "Failed to fetch messages", }); } }, fetchOlderMessages: async (channelId) => { const state = get(); if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return; const existing = state.messagesByChannel[channelId] || []; if (existing.length === 0) return; const oldestId = existing[0].id; set({ isLoadingOlder: true }); try { const older = await api.get( `/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`, ); const list = Array.isArray(older) ? older : []; set((state) => ({ messagesByChannel: { ...state.messagesByChannel, [channelId]: [...list, ...existing], }, hasMoreByChannel: { ...state.hasMoreByChannel, [channelId]: list.length >= 50, }, isLoadingOlder: false, })); } catch { set({ isLoadingOlder: false }); } }, searchMessages: async (channelId, query) => { const results = await api.get( `/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`, ); set((state) => ({ searchResultsByChannel: { ...state.searchResultsByChannel, [channelId]: Array.isArray(results) ? results : [], }, })); return Array.isArray(results) ? results : []; }, sendMessage: async (channelId, content, replyTo) => { const body: { content: string; reply_to?: string } = { content }; if (replyTo) body.reply_to = replyTo; // ponytail: don't append here — WS MESSAGE_CREATE broadcast is the single source of truth. // Appending in both places caused double messages when the WS event arrived before the HTTP response. const message = await api.post( `/channels/${channelId}/messages`, body, ); return message; }, pinMessage: async (channelId, messageId) => { const updated = await api.put(`/channels/${channelId}/messages/${messageId}/pin`, {}); get().updateMessage(updated); }, unpinMessage: async (channelId, messageId) => { const updated = await api.delete(`/channels/${channelId}/messages/${messageId}/pin`); get().updateMessage(updated); }, fetchPinnedMessages: async (channelId) => { const pinned = await api.get(`/channels/${channelId}/messages/pinned`); const list = Array.isArray(pinned) ? pinned : []; set((state) => ({ pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [channelId]: list, }, })); return list; }, addMessage: (message) => set((state) => { const list = state.messagesByChannel[message.channel_id] || []; if (list.some((m) => m.id === message.id)) { return state; } return { messagesByChannel: { ...state.messagesByChannel, [message.channel_id]: [...list, message], }, }; }), updateMessage: (message) => set((state) => { const list = state.messagesByChannel[message.channel_id] || []; const updatedList = list.map((m) => m.id === message.id ? message : m, ); const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || []; let updatedPinned = [...pinnedList]; if (message.pinned) { if (!pinnedList.some((m) => m.id === message.id)) { updatedPinned = [message, ...pinnedList].sort( (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() ); } else { updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m)); } } else { updatedPinned = pinnedList.filter((m) => m.id !== message.id); } if (updatedPinned.length > 5) { updatedPinned = updatedPinned.slice(0, 5); } return { messagesByChannel: { ...state.messagesByChannel, [message.channel_id]: updatedList, }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [message.channel_id]: updatedPinned, }, }; }), removeMessage: (channelId, messageId) => set((state) => { const list = state.messagesByChannel[channelId] || []; const pinnedList = state.pinnedMessagesByChannel[channelId] || []; return { messagesByChannel: { ...state.messagesByChannel, [channelId]: list.filter((m) => m.id !== messageId), }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, [channelId]: pinnedList.filter((m) => m.id !== messageId), }, }; }), addReaction: (channelId, messageId, emoji, userId) => set((state) => { const list = state.messagesByChannel[channelId] || []; 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, [channelId]: updatedList, }, }; }), removeReaction: (channelId, messageId, emoji, userId) => set((state) => { const list = state.messagesByChannel[channelId] || []; 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, [channelId]: updatedList, }, }; }), bulkDeleteMessages: async (channelId, messageIds) => { const res = await api.post<{ deleted: number }>( `/channels/${channelId}/messages/bulk-delete`, { messages: messageIds }, ); set((state) => { const list = state.messagesByChannel[channelId] || []; const ids = new Set(messageIds); const selected = { ...state.selectedMessageIds }; delete selected[channelId]; return { messagesByChannel: { ...state.messagesByChannel, [channelId]: list.filter((m) => !ids.has(m.id)), }, selectedMessageIds: selected, }; }); return res; }, toggleSelectedMessage: (channelId, messageId) => set((state) => { const current = state.selectedMessageIds[channelId] || new Set(); const next = new Set(current); if (next.has(messageId)) { next.delete(messageId); } else { next.add(messageId); } return { selectedMessageIds: { ...state.selectedMessageIds, [channelId]: next, }, }; }), clearSelectedMessages: (channelId) => set((state) => { const next = { ...state.selectedMessageIds }; delete next[channelId]; return { selectedMessageIds: next }; }), createPoll: async (channelId, question, options) => { const resp = await api.post("/polls", { channel_id: channelId, question, options, }); return resp; }, votePoll: async (pollId, optionId) => { await api.post(`/polls/${pollId}/vote`, { option_id: optionId }); }, updatePoll: (channelId, poll) => set((state) => { const messages = state.messagesByChannel[channelId]; if (!messages) return state; const updated = messages.map((m) => m.poll?.id === poll.id ? { ...m, poll } : m, ); return { messagesByChannel: { ...state.messagesByChannel, [channelId]: updated, }, }; }), }));