fix(web): robust date parsing and merge strategy in message store

This commit is contained in:
2026-07-27 09:04:49 -04:00
parent 8261555026
commit 4a416427e9
+166 -114
View File
@@ -1,6 +1,13 @@
import { create } from "zustand"; import { create } from "zustand";
import { api } from "../lib/api.ts"; 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 { export interface MessageEmbed {
id?: string; id?: string;
url: string; url: string;
@@ -59,7 +66,7 @@ export interface MessageState {
messagesByChannel: Record<string, Message[]>; messagesByChannel: Record<string, Message[]>;
pinnedMessagesByChannel: Record<string, Message[]>; pinnedMessagesByChannel: Record<string, Message[]>;
searchResultsByChannel: Record<string, SearchResultMessage[]>; searchResultsByChannel: Record<string, SearchResultMessage[]>;
selectedMessageIds: Record<string, Set<string>>; // ponytail: per-channel bulk selection selectedMessageIds: Record<string, Set<string>>;
isLoading: boolean; isLoading: boolean;
isLoadingOlder: boolean; isLoadingOlder: boolean;
hasMoreByChannel: Record<string, boolean>; hasMoreByChannel: Record<string, boolean>;
@@ -95,24 +102,34 @@ export const useMessageStore = create<MessageState>((set, get) => ({
error: null, error: null,
fetchMessages: async (channelId, before) => { fetchMessages: async (channelId, before) => {
const chId = channelId.toLowerCase();
set({ isLoading: true, error: null }); set({ isLoading: true, error: null });
try { try {
const params = before ? `?before=${encodeURIComponent(before)}` : ""; const params = before ? `?before=${encodeURIComponent(before)}` : "";
const messages = await api.get<Message[]>( const messages = await api.get<Message[]>(
`/channels/${channelId}/messages${params}`, `/channels/${chId}/messages${params}`,
); );
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
set((state) => ({ set((state) => {
messagesByChannel: { const existing = state.messagesByChannel[chId] || [];
...state.messagesByChannel, const map = new Map<string, Message>();
[channelId]: list, existing.forEach((m) => map.set(m.id, m));
}, list.forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
hasMoreByChannel: { const merged = Array.from(map.values()).sort(
...state.hasMoreByChannel, (a, b) => parseDate(a.created_at) - parseDate(b.created_at)
[channelId]: list.length >= 50, );
}, return {
isLoading: false, messagesByChannel: {
})); ...state.messagesByChannel,
[chId]: merged,
},
hasMoreByChannel: {
...state.hasMoreByChannel,
[chId]: list.length >= 50,
},
isLoading: false,
};
});
} catch (error) { } catch (error) {
set({ set({
isLoading: false, isLoading: false,
@@ -125,116 +142,136 @@ export const useMessageStore = create<MessageState>((set, get) => ({
}, },
fetchOlderMessages: async (channelId) => { fetchOlderMessages: async (channelId) => {
const chId = channelId.toLowerCase();
const state = get(); const state = get();
if (state.isLoadingOlder || state.hasMoreByChannel[channelId] === false) return; if (state.isLoadingOlder || state.hasMoreByChannel[chId] === false) return;
const existing = state.messagesByChannel[channelId] || []; const existing = state.messagesByChannel[chId] || [];
if (existing.length === 0) return; if (existing.length === 0) return;
// ponytail: existing is now oldest-first, so existing[0] is the true oldest
const oldestId = existing[0].id; const oldestId = existing[0].id;
set({ isLoadingOlder: true }); set({ isLoadingOlder: true });
try { try {
const older = await api.get<Message[]>( const older = await api.get<Message[]>(
`/channels/${channelId}/messages?before=${encodeURIComponent(oldestId)}`, `/channels/${chId}/messages?before=${encodeURIComponent(oldestId)}`,
); );
const list = Array.isArray(older) ? older : []; const list = Array.isArray(older) ? older : [];
set((state) => ({ set((state) => {
messagesByChannel: { const map = new Map<string, Message>();
...state.messagesByChannel, [...list, ...existing].forEach((m) => map.set(m.id, { ...m, channel_id: (m.channel_id || chId).toLowerCase() }));
[channelId]: [...list, ...existing], const merged = Array.from(map.values()).sort(
}, (a, b) => parseDate(a.created_at) - parseDate(b.created_at)
hasMoreByChannel: { );
...state.hasMoreByChannel, return {
[channelId]: list.length >= 50, messagesByChannel: {
}, ...state.messagesByChannel,
isLoadingOlder: false, [chId]: merged,
})); },
hasMoreByChannel: {
...state.hasMoreByChannel,
[chId]: list.length >= 50,
},
isLoadingOlder: false,
};
});
} catch { } catch {
set({ isLoadingOlder: false }); set({ isLoadingOlder: false });
} }
}, },
searchMessages: async (channelId, query) => { searchMessages: async (channelId, query) => {
const chId = channelId.toLowerCase();
const results = await api.get<SearchResultMessage[]>( const results = await api.get<SearchResultMessage[]>(
`/channels/${channelId}/messages/search?q=${encodeURIComponent(query)}`, `/channels/${chId}/messages/search?q=${encodeURIComponent(query)}`,
); );
const list = Array.isArray(results) ? results : [];
set((state) => ({ set((state) => ({
searchResultsByChannel: { searchResultsByChannel: {
...state.searchResultsByChannel, ...state.searchResultsByChannel,
[channelId]: Array.isArray(results) ? results : [], [chId]: list,
},
}));
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<Message>(
`/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<Message>(`/channels/${channelId}/messages/${messageId}/pin`, {});
get().updateMessage(updated);
},
unpinMessage: async (channelId, messageId) => {
const updated = await api.delete<Message>(`/channels/${channelId}/messages/${messageId}/pin`);
get().updateMessage(updated);
},
fetchPinnedMessages: async (channelId) => {
const pinned = await api.get<Message[]>(`/channels/${channelId}/messages/pinned`);
const list = Array.isArray(pinned) ? pinned : [];
set((state) => ({
pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel,
[channelId]: list,
}, },
})); }));
return 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<Message>(
`/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<Message>(`/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<Message>(`/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<Message[]>(`/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) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[chId] || [];
if (list.some((m) => m.id === message.id)) { if (list.some((m) => m.id === normalizedMessage.id)) {
return state; return state;
} }
return { return {
messagesByChannel: { messagesByChannel: {
...state.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) => { set((state) => {
const list = state.messagesByChannel[message.channel_id] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => 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]; let updatedPinned = [...pinnedList];
if (message.pinned) { if (normalizedMessage.pinned) {
if (!pinnedList.some((m) => m.id === message.id)) { if (!pinnedList.some((m) => m.id === normalizedMessage.id)) {
updatedPinned = [message, ...pinnedList].sort( updatedPinned = [normalizedMessage, ...pinnedList].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() (a, b) => parseDate(b.created_at) - parseDate(a.created_at)
); );
} else { } else {
updatedPinned = pinnedList.map((m) => (m.id === message.id ? message : m)); updatedPinned = pinnedList.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m));
} }
} else { } else {
updatedPinned = pinnedList.filter((m) => m.id !== message.id); updatedPinned = pinnedList.filter((m) => m.id !== normalizedMessage.id);
} }
if (updatedPinned.length > 5) { if (updatedPinned.length > 5) {
updatedPinned = updatedPinned.slice(0, 5); updatedPinned = updatedPinned.slice(0, 5);
@@ -243,34 +280,38 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[message.channel_id]: updatedList, [chId]: updatedList,
}, },
pinnedMessagesByChannel: { pinnedMessagesByChannel: {
...state.pinnedMessagesByChannel, ...state.pinnedMessagesByChannel,
[message.channel_id]: updatedPinned, [chId]: updatedPinned,
}, },
}; };
}), });
},
removeMessage: (channelId, messageId) => removeMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const pinnedList = state.pinnedMessagesByChannel[channelId] || []; const pinnedList = state.pinnedMessagesByChannel[chId] || [];
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list.filter((m) => m.id !== messageId), [chId]: list.filter((m) => m.id !== messageId),
}, },
pinnedMessagesByChannel: { pinnedMessagesByChannel: {
...state.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) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => { const updatedList = list.map((m) => {
if (m.id !== messageId) return m; if (m.id !== messageId) return m;
const reactions = m.reactions ? [...m.reactions] : []; const reactions = m.reactions ? [...m.reactions] : [];
@@ -288,14 +329,16 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updatedList, [chId]: updatedList,
}, },
}; };
}), });
},
removeReaction: (channelId, messageId, emoji, userId) => removeReaction: (channelId, messageId, emoji, userId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const updatedList = list.map((m) => { const updatedList = list.map((m) => {
if (m.id !== messageId) return m; if (m.id !== messageId) return m;
if (!m.reactions) return m; if (!m.reactions) return m;
@@ -311,25 +354,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updatedList, [chId]: updatedList,
}, },
}; };
}), });
},
bulkDeleteMessages: async (channelId, messageIds) => { bulkDeleteMessages: async (channelId, messageIds) => {
const chId = channelId.toLowerCase();
const res = await api.post<{ deleted: number }>( const res = await api.post<{ deleted: number }>(
`/channels/${channelId}/messages/bulk-delete`, `/channels/${chId}/messages/bulk-delete`,
{ messages: messageIds }, { messages: messageIds },
); );
set((state) => { set((state) => {
const list = state.messagesByChannel[channelId] || []; const list = state.messagesByChannel[chId] || [];
const ids = new Set(messageIds); const ids = new Set(messageIds);
const selected = { ...state.selectedMessageIds }; const selected = { ...state.selectedMessageIds };
delete selected[channelId]; delete selected[chId];
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: list.filter((m) => !ids.has(m.id)), [chId]: list.filter((m) => !ids.has(m.id)),
}, },
selectedMessageIds: selected, selectedMessageIds: selected,
}; };
@@ -337,9 +382,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return res; return res;
}, },
toggleSelectedMessage: (channelId, messageId) => toggleSelectedMessage: (channelId, messageId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const current = state.selectedMessageIds[channelId] || new Set<string>(); const current = state.selectedMessageIds[chId] || new Set<string>();
const next = new Set(current); const next = new Set(current);
if (next.has(messageId)) { if (next.has(messageId)) {
next.delete(messageId); next.delete(messageId);
@@ -349,21 +395,25 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
selectedMessageIds: { selectedMessageIds: {
...state.selectedMessageIds, ...state.selectedMessageIds,
[channelId]: next, [chId]: next,
}, },
}; };
}), });
},
clearSelectedMessages: (channelId) => clearSelectedMessages: (channelId) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const next = { ...state.selectedMessageIds }; const next = { ...state.selectedMessageIds };
delete next[channelId]; delete next[chId];
return { selectedMessageIds: next }; return { selectedMessageIds: next };
}), });
},
createPoll: async (channelId, question, options) => { createPoll: async (channelId, question, options) => {
const chId = channelId.toLowerCase();
const resp = await api.post<Poll>("/polls", { const resp = await api.post<Poll>("/polls", {
channel_id: channelId, channel_id: chId,
question, question,
options, options,
}); });
@@ -374,9 +424,10 @@ export const useMessageStore = create<MessageState>((set, get) => ({
await api.post(`/polls/${pollId}/vote`, { option_id: optionId }); await api.post(`/polls/${pollId}/vote`, { option_id: optionId });
}, },
updatePoll: (channelId, poll) => updatePoll: (channelId, poll) => {
const chId = channelId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByChannel[channelId]; const messages = state.messagesByChannel[chId];
if (!messages) return state; if (!messages) return state;
const updated = messages.map((m) => const updated = messages.map((m) =>
m.poll?.id === poll.id ? { ...m, poll } : m, m.poll?.id === poll.id ? { ...m, poll } : m,
@@ -384,8 +435,9 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return { return {
messagesByChannel: { messagesByChannel: {
...state.messagesByChannel, ...state.messagesByChannel,
[channelId]: updated, [chId]: updated,
}, },
}; };
}), });
},
})); }));