From 4a416427e95f3d62b11b45290af206049c17accd Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Mon, 27 Jul 2026 09:04:49 -0400 Subject: [PATCH] fix(web): robust date parsing and merge strategy in message store --- web/src/stores/message.ts | 280 ++++++++++++++++++++++---------------- 1 file changed, 166 insertions(+), 114 deletions(-) diff --git a/web/src/stores/message.ts b/web/src/stores/message.ts index 834dc05..30c29df 100644 --- a/web/src/stores/message.ts +++ b/web/src/stores/message.ts @@ -1,6 +1,13 @@ import { create } from "zustand"; import { api } from "../lib/api.ts"; +function parseDate(iso: string): number { + if (!iso) return 0; + const normalized = iso.includes("T") ? iso : iso.replace(" ", "T"); + const t = new Date(normalized).getTime(); + return isNaN(t) ? 0 : t; +} + export interface MessageEmbed { id?: string; url: string; @@ -59,7 +66,7 @@ export interface MessageState { messagesByChannel: Record; pinnedMessagesByChannel: Record; searchResultsByChannel: Record; - selectedMessageIds: Record>; // ponytail: per-channel bulk selection + selectedMessageIds: Record>; isLoading: boolean; isLoadingOlder: boolean; hasMoreByChannel: Record; @@ -95,24 +102,34 @@ export const useMessageStore = create((set, get) => ({ 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/${channelId}/messages${params}`, + `/channels/${chId}/messages${params}`, ); const list = Array.isArray(messages) ? messages : []; - set((state) => ({ - messagesByChannel: { - ...state.messagesByChannel, - [channelId]: list, - }, - hasMoreByChannel: { - ...state.hasMoreByChannel, - [channelId]: list.length >= 50, - }, - isLoading: false, - })); + 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, @@ -125,116 +142,136 @@ export const useMessageStore = create((set, get) => ({ }, fetchOlderMessages: async (channelId) => { + const chId = channelId.toLowerCase(); const state = get(); - if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return; - const existing = state.messagesByChannel[channelId] || []; + if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return; + const existing = state.messagesByChannel[chId] || []; if (existing.length === 0) return; - // ponytail: existing is now oldest-first, so existing[0] is the true oldest const oldestId = existing[0].id; set({ isLoadingOlder: true }); try { const older = await api.get( - `/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`, + `/channels/${chId}/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, - })); + 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/${channelId}/messages/search?q=${encodeURIComponent(query)}`, + `/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`, ); + const list = Array.isArray(results) ? results : []; 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; - // Add locally so the message appears immediately even if WS lags. - // addMessage dedupes, so a late WS event won't double it. - const message = await api.post( - `/channels/${channelId}/messages`, - body, - ); - // ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves - get().addMessage(message); - 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, + [chId]: list, }, })); return list; }, - addMessage: (message) => + 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[message.channel_id] || []; - if (list.some((m) => m.id === message.id)) { + const list = state.messagesByChannel[chId] || []; + if (list.some((m) => m.id === normalizedMessage.id)) { return state; } return { messagesByChannel: { ...state.messagesByChannel, - [message.channel_id]: [...list, message], + [chId]: [...list, normalizedMessage].sort( + (a, b) => parseDate(a.created_at) - parseDate(b.created_at) + ), }, }; - }), + }); + }, - updateMessage: (message) => + updateMessage: (message) => { + const chId = (message.channel_id || "").toLowerCase(); + const normalizedMessage = { ...message, channel_id: chId }; set((state) => { - const list = state.messagesByChannel[message.channel_id] || []; + const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => - m.id === message.id ? message : m, + m.id === normalizedMessage.id ? normalizedMessage : m, ); - const pinnedList = state.pinnedMessagesByChannel[message.channel_id] || []; + const pinnedList = state.pinnedMessagesByChannel[chId] || []; 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() + 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 === message.id ? message : m)); + updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)); } } else { - updatedPinned = pinnedList.filter((m) => m.id !== message.id); + updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id); } if (updatedPinned.length > 5) { updatedPinned = updatedPinned.slice(0, 5); @@ -243,34 +280,38 @@ export const useMessageStore = create((set, get) => ({ return { messagesByChannel: { ...state.messagesByChannel, - [message.channel_id]: updatedList, + [chId]: updatedList, }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, - [message.channel_id]: updatedPinned, + [chId]: updatedPinned, }, }; - }), + }); + }, - removeMessage: (channelId, messageId) => + removeMessage: (channelId, messageId) => { + const chId = channelId.toLowerCase(); set((state) => { - const list = state.messagesByChannel[channelId] || []; - const pinnedList = state.pinnedMessagesByChannel[channelId] || []; + const list = state.messagesByChannel[chId] || []; + const pinnedList = state.pinnedMessagesByChannel[chId] || []; return { messagesByChannel: { ...state.messagesByChannel, - [channelId]: list.filter((m) => m.id !== messageId), + [chId]: list.filter((m) => m.id !== messageId), }, pinnedMessagesByChannel: { ...state.pinnedMessagesByChannel, - [channelId]: pinnedList.filter((m) => m.id !== messageId), + [chId]: pinnedList.filter((m) => m.id !== messageId), }, }; - }), + }); + }, - addReaction: (channelId, messageId, emoji, userId) => + addReaction: (channelId, messageId, emoji, userId) => { + const chId = channelId.toLowerCase(); set((state) => { - const list = state.messagesByChannel[channelId] || []; + const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => { if (m.id !== messageId) return m; const reactions = m.reactions ? [...m.reactions] : []; @@ -288,14 +329,16 @@ export const useMessageStore = create((set, get) => ({ return { messagesByChannel: { ...state.messagesByChannel, - [channelId]: updatedList, + [chId]: updatedList, }, }; - }), + }); + }, - removeReaction: (channelId, messageId, emoji, userId) => + removeReaction: (channelId, messageId, emoji, userId) => { + const chId = channelId.toLowerCase(); set((state) => { - const list = state.messagesByChannel[channelId] || []; + const list = state.messagesByChannel[chId] || []; const updatedList = list.map((m) => { if (m.id !== messageId) return m; if (!m.reactions) return m; @@ -311,25 +354,27 @@ export const useMessageStore = create((set, get) => ({ return { messagesByChannel: { ...state.messagesByChannel, - [channelId]: updatedList, + [chId]: updatedList, }, }; - }), + }); + }, bulkDeleteMessages: async (channelId, messageIds) => { + const chId = channelId.toLowerCase(); const res = await api.post<{ deleted: number }>( - `/channels/${channelId}/messages/bulk-delete`, + `/channels/${chId}/messages/bulk-delete`, { messages: messageIds }, ); set((state) => { - const list = state.messagesByChannel[channelId] || []; + const list = state.messagesByChannel[chId] || []; const ids = new Set(messageIds); const selected = { ...state.selectedMessageIds }; - delete selected[channelId]; + delete selected[chId]; return { messagesByChannel: { ...state.messagesByChannel, - [channelId]: list.filter((m) => !ids.has(m.id)), + [chId]: list.filter((m) => !ids.has(m.id)), }, selectedMessageIds: selected, }; @@ -337,9 +382,10 @@ export const useMessageStore = create((set, get) => ({ return res; }, - toggleSelectedMessage: (channelId, messageId) => + toggleSelectedMessage: (channelId, messageId) => { + const chId = channelId.toLowerCase(); set((state) => { - const current = state.selectedMessageIds[channelId] || new Set(); + const current = state.selectedMessageIds[chId] || new Set(); const next = new Set(current); if (next.has(messageId)) { next.delete(messageId); @@ -349,21 +395,25 @@ export const useMessageStore = create((set, get) => ({ return { selectedMessageIds: { ...state.selectedMessageIds, - [channelId]: next, + [chId]: next, }, }; - }), + }); + }, - clearSelectedMessages: (channelId) => + clearSelectedMessages: (channelId) => { + const chId = channelId.toLowerCase(); set((state) => { const next = { ...state.selectedMessageIds }; - delete next[channelId]; + delete next[chId]; return { selectedMessageIds: next }; - }), + }); + }, createPoll: async (channelId, question, options) => { + const chId = channelId.toLowerCase(); const resp = await api.post("/polls", { - channel_id: channelId, + channel_id: chId, question, options, }); @@ -374,9 +424,10 @@ export const useMessageStore = create((set, get) => ({ await api.post(`/polls/${pollId}/vote`, { option_id: optionId }); }, - updatePoll: (channelId, poll) => + updatePoll: (channelId, poll) => { + const chId = channelId.toLowerCase(); set((state) => { - const messages = state.messagesByChannel[channelId]; + const messages = state.messagesByChannel[chId]; if (!messages) return state; const updated = messages.map((m) => m.poll?.id === poll.id ? { ...m, poll } : m, @@ -384,8 +435,9 @@ export const useMessageStore = create((set, get) => ({ return { messagesByChannel: { ...state.messagesByChannel, - [channelId]: updated, + [chId]: updated, }, }; - }), + }); + }, }));