fix(web): normalize conversation IDs to lowercase and bind DMChat messages selector

This commit is contained in:
2026-07-27 09:01:53 -04:00
parent 065f036807
commit 3cde62bdc6
5 changed files with 70 additions and 51 deletions
+10 -7
View File
@@ -16,9 +16,10 @@ function isSelfDM(conv: { members: { id: string }[] }, currentUserId: string) {
export function ConversationList() { export function ConversationList() {
const conversations = useConversationStore((s) => s.conversations); const conversations = useConversationStore((s) => s.conversations);
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId?.toLowerCase() ?? null);
const fetchConversations = useConversationStore((s) => s.fetchConversations); const fetchConversations = useConversationStore((s) => s.fetchConversations);
const createConversation = useConversationStore((s) => s.createConversation); const createConversation = useConversationStore((s) => s.createConversation);
const setActiveConversation = useConversationStore((s) => s.setActiveConversation);
const messagesByConv = useConversationStore((s) => s.messagesByConversation); const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread); const hasConvUnread = useReadStatesStore((s) => s.hasConvUnread);
@@ -31,8 +32,9 @@ export function ConversationList() {
fetchConversations(); fetchConversations();
}, [fetchConversations]); }, [fetchConversations]);
const openConversation = (convId: string) => { const openConversation = (rawId: string) => {
// mark as read when opening const convId = rawId.toLowerCase();
setActiveConversation(convId);
const msgs = messagesByConv[convId] || []; const msgs = messagesByConv[convId] || [];
if (msgs.length > 0) { if (msgs.length > 0) {
markConvRead(convId, msgs[msgs.length - 1].id); markConvRead(convId, msgs[msgs.length - 1].id);
@@ -50,7 +52,7 @@ export function ConversationList() {
try { try {
const conv = await createConversation([]); const conv = await createConversation([]);
if (conv) { if (conv) {
navigate(`/dm/${conv.id}`); openConversation(conv.id);
} }
} catch (err) { } catch (err) {
console.error("Failed to create notes:", err); console.error("Failed to create notes:", err);
@@ -59,7 +61,7 @@ export function ConversationList() {
}; };
const getLatestMessageId = (convId: string): string | undefined => { const getLatestMessageId = (convId: string): string | undefined => {
const msgs = messagesByConv[convId] || []; const msgs = messagesByConv[convId.toLowerCase()] || [];
return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined; return msgs.length > 0 ? msgs[msgs.length - 1].id : undefined;
}; };
@@ -89,19 +91,20 @@ export function ConversationList() {
<p className="text-gb-fg-f">[no conversations]</p> <p className="text-gb-fg-f">[no conversations]</p>
)} )}
{conversations.map((conv) => { {conversations.map((conv) => {
const convId = conv.id.toLowerCase();
const self = isSelfDM(conv, currentUser?.id || ""); const self = isSelfDM(conv, currentUser?.id || "");
const name = self const name = self
? "Notes" ? "Notes"
: conv.type === "group_dm" : conv.type === "group_dm"
? conv.name || conv.members.map((m) => m.username).join(", ") ? conv.name || conv.members.map((m) => m.username).join(", ")
: otherMemberName(conv, currentUser?.id || ""); : otherMemberName(conv, currentUser?.id || "");
const unread = hasConvUnread(conv.id, getLatestMessageId(conv.id)); const unread = hasConvUnread(convId, getLatestMessageId(convId));
return ( return (
<button <button
key={conv.id} key={conv.id}
onClick={() => openConversation(conv.id)} onClick={() => openConversation(conv.id)}
className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${ className={`w-full text-left px-2 py-1 rounded-sm flex items-center gap-2 ${
conv.id === activeId convId === activeId
? "terminal-active" ? "terminal-active"
: "hover:bg-gb-bg-t text-gb-fg-s" : "hover:bg-gb-bg-t text-gb-fg-s"
}`} }`}
+6 -4
View File
@@ -152,7 +152,6 @@ export function DMChat() {
const activeId = useConversationStore((s) => s.activeConversationId); const activeId = useConversationStore((s) => s.activeConversationId);
const setActive = useConversationStore((s) => s.setActiveConversation); const setActive = useConversationStore((s) => s.setActiveConversation);
const conversations = useConversationStore((s) => s.conversations); const conversations = useConversationStore((s) => s.conversations);
const messagesByConv = useConversationStore((s) => s.messagesByConversation);
const fetchMessages = useConversationStore((s) => s.fetchMessages); const fetchMessages = useConversationStore((s) => s.fetchMessages);
const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages); const fetchOlderMessages = useConversationStore((s) => s.fetchOlderMessages);
const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder); const isLoadingOlder = useConversationStore((s) => s.isLoadingOlder);
@@ -173,9 +172,13 @@ export function DMChat() {
const markConvRead = useReadStatesStore((s) => s.markConvRead); const markConvRead = useReadStatesStore((s) => s.markConvRead);
const convStates = useReadStatesStore((s) => s.convStates); const convStates = useReadStatesStore((s) => s.convStates);
const id = conversationId || activeId; const rawId = conversationId || activeId;
const conversation = conversations.find((c) => c.id === id); const id = rawId ? rawId.toLowerCase() : undefined;
const conversation = conversations.find((c) => c.id.toLowerCase() === id);
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true); const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
const messages = useConversationStore(
useCallback((s) => (id ? s.messagesByConversation[id] || [] : []), [id])
);
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => { const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
if (!id) return; if (!id) return;
@@ -200,7 +203,6 @@ export function DMChat() {
}); });
} }
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]); }, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
const messages = id ? messagesByConv[id] || [] : [];
useEffect(() => { useEffect(() => {
fetchConversations(); fetchConversations();
+44 -33
View File
@@ -79,39 +79,42 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
createConversation: async (userIds) => { createConversation: async (userIds) => {
const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds }); const conversation = await api.post<Conversation>("/conversations", { user_ids: userIds });
const convId = conversation.id.toLowerCase();
const normalizedConv = { ...conversation, id: convId };
set((state) => ({ set((state) => ({
conversations: [conversation, ...state.conversations], conversations: [normalizedConv, ...state.conversations],
activeConversationId: conversation.id, activeConversationId: convId,
})); }));
return conversation; return normalizedConv;
}, },
setActiveConversation: (id) => set({ activeConversationId: id }), setActiveConversation: (id) => set({ activeConversationId: id ? id.toLowerCase() : null }),
fetchMessages: async (conversationId, before) => { fetchMessages: async (conversationId, before) => {
const convId = conversationId.toLowerCase();
set({ isLoading: true }); set({ isLoading: true });
try { try {
const params = before ? "?before=" + encodeURIComponent(before) : ""; const params = before ? "?before=" + encodeURIComponent(before) : "";
const messages = await api.get<ConversationMessage[]>( const messages = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages${params}`, `/conversations/${convId}/messages${params}`,
); );
const list = Array.isArray(messages) ? messages : []; const list = Array.isArray(messages) ? messages : [];
set((state) => { set((state) => {
const existing = state.messagesByConversation[conversationId] || []; const existing = state.messagesByConversation[convId] || [];
const map = new Map<string, ConversationMessage>(); const map = new Map<string, ConversationMessage>();
existing.forEach((m) => map.set(m.id, m)); existing.forEach((m) => map.set(m.id, m));
list.forEach((m) => map.set(m.id, m)); list.forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
const merged = Array.from(map.values()).sort( const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at) (a, b) => parseDate(a.created_at) - parseDate(b.created_at)
); );
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: merged, [convId]: merged,
}, },
hasMoreByConversation: { hasMoreByConversation: {
...state.hasMoreByConversation, ...state.hasMoreByConversation,
[conversationId]: list.length >= 50, [convId]: list.length >= 50,
}, },
isLoading: false, isLoading: false,
}; };
@@ -122,32 +125,32 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
}, },
fetchOlderMessages: async (conversationId) => { fetchOlderMessages: async (conversationId) => {
const convId = conversationId.toLowerCase();
const state = get(); const state = get();
if (state.isLoadingOlder || state.hasMoreByConversation[conversationId] === false) return; if (state.isLoadingOlder || state.hasMoreByConversation[convId] === false) return;
const existing = state.messagesByConversation[conversationId] || []; const existing = state.messagesByConversation[convId] || [];
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<ConversationMessage[]>( const older = await api.get<ConversationMessage[]>(
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`, `/conversations/${convId}/messages?before=${encodeURIComponent(oldestId)}`,
); );
const list = Array.isArray(older) ? older : []; const list = Array.isArray(older) ? older : [];
set((state) => { set((state) => {
const map = new Map<string, ConversationMessage>(); const map = new Map<string, ConversationMessage>();
[...list, ...existing].forEach((m) => map.set(m.id, m)); [...list, ...existing].forEach((m) => map.set(m.id, { ...m, conversation_id: (m.conversation_id || convId).toLowerCase() }));
const merged = Array.from(map.values()).sort( const merged = Array.from(map.values()).sort(
(a, b) => parseDate(a.created_at) - parseDate(b.created_at) (a, b) => parseDate(a.created_at) - parseDate(b.created_at)
); );
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: merged, [convId]: merged,
}, },
hasMoreByConversation: { hasMoreByConversation: {
...state.hasMoreByConversation, ...state.hasMoreByConversation,
[conversationId]: list.length >= 50, [convId]: list.length >= 50,
}, },
isLoadingOlder: false, isLoadingOlder: false,
}; };
@@ -158,58 +161,65 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
}, },
sendMessage: async (conversationId, content) => { sendMessage: async (conversationId, content) => {
// Add locally so the message appears immediately even if WS lags. const convId = conversationId.toLowerCase();
// addMessage dedupes, so a late WS event won't double it.
const message = await api.post<ConversationMessage>( const message = await api.post<ConversationMessage>(
`/conversations/${conversationId}/messages`, `/conversations/${convId}/messages`,
{ content }, { content },
); );
// ponytail: local append as fallback for WS MESSAGE_CREATE; remove if WS reliability improves const normalizedMessage = { ...message, conversation_id: (message.conversation_id || convId).toLowerCase() };
get().addMessage(message); get().addMessage(normalizedMessage);
return message; return normalizedMessage;
}, },
addMessage: (message) => { addMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => { set((state) => {
const existing = state.messagesByConversation[message.conversation_id] || []; const existing = state.messagesByConversation[convId] || [];
if (existing.some((m) => m.id === message.id)) { if (existing.some((m) => m.id === normalizedMessage.id)) {
return state; return state;
} }
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[message.conversation_id]: [...existing, message] [convId]: [...existing, normalizedMessage]
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)), .sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
}, },
}; };
}); });
}, },
updateMessage: (message) => { updateMessage: (message) => {
const convId = (message.conversation_id || "").toLowerCase();
const normalizedMessage = { ...message, conversation_id: convId };
set((state) => { set((state) => {
const convMsgs = state.messagesByConversation[message.conversation_id] || []; const convMsgs = state.messagesByConversation[convId] || [];
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[message.conversation_id]: convMsgs.map((m) => (m.id === message.id ? message : m)), [convId]: convMsgs.map((m) => (m.id === normalizedMessage.id ? normalizedMessage : m)),
}, },
}; };
}); });
}, },
deleteMessage: (conversationId, messageId) => { deleteMessage: (conversationId, messageId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const convMsgs = state.messagesByConversation[conversationId] || []; const convMsgs = state.messagesByConversation[convId] || [];
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: convMsgs.filter((m) => m.id !== messageId), [convId]: convMsgs.filter((m) => m.id !== messageId),
}, },
}; };
}); });
}, },
addReaction: (conversationId, messageId, emoji, userId) => { addReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByConversation[conversationId]; const messages = state.messagesByConversation[convId];
if (!messages) return state; if (!messages) return state;
const newMessages = messages.map((m) => { const newMessages = messages.map((m) => {
@@ -233,15 +243,16 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: newMessages, [convId]: newMessages,
}, },
}; };
}); });
}, },
removeReaction: (conversationId, messageId, emoji, userId) => { removeReaction: (conversationId, messageId, emoji, userId) => {
const convId = conversationId.toLowerCase();
set((state) => { set((state) => {
const messages = state.messagesByConversation[conversationId]; const messages = state.messagesByConversation[convId];
if (!messages) return state; if (!messages) return state;
const newMessages = messages.map((m) => { const newMessages = messages.map((m) => {
@@ -264,7 +275,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
return { return {
messagesByConversation: { messagesByConversation: {
...state.messagesByConversation, ...state.messagesByConversation,
[conversationId]: newMessages, [convId]: newMessages,
}, },
}; };
}); });
+4 -2
View File
@@ -38,8 +38,9 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
}, },
markConvRead: (conversationId: string, messageId: string) => { markConvRead: (conversationId: string, messageId: string) => {
const convId = conversationId.toLowerCase();
set((state) => ({ set((state) => ({
convStates: { ...state.convStates, [conversationId]: messageId }, convStates: { ...state.convStates, [convId]: messageId },
})); }));
}, },
@@ -54,8 +55,9 @@ export const useReadStatesStore = create<ReadStatesState>()((set, get) => ({
}, },
hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => { hasConvUnread: (conversationId: string, latestMessageId?: string): boolean => {
const convId = conversationId.toLowerCase();
const state = get().convStates; const state = get().convStates;
const lastRead = state[conversationId]; const lastRead = state[convId];
if (!lastRead) return !!latestMessageId; if (!lastRead) return !!latestMessageId;
if (latestMessageId && lastRead !== latestMessageId) return true; if (latestMessageId && lastRead !== latestMessageId) return true;
return false; return false;
+6 -5
View File
@@ -62,7 +62,7 @@ function extractIds(payload: UnknownPayload | undefined): { channel_id?: string;
: null; : null;
if (!messageId) return null; if (!messageId) return null;
if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId }; if (typeof payload.channel_id === 'string') return { channel_id: payload.channel_id, message_id: messageId };
if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id, message_id: messageId }; if (typeof payload.conversation_id === 'string') return { conversation_id: payload.conversation_id.toLowerCase(), message_id: messageId };
return null; return null;
} }
@@ -141,11 +141,12 @@ export const useWebSocketStore = create<WebSocketState>((set, get) => ({
if (isRecord(payload)) { if (isRecord(payload)) {
if (payload.conversation_id) { if (payload.conversation_id) {
const msg = payload as unknown as ConversationMessage; const msg = payload as unknown as ConversationMessage;
useConversationStore.getState().addMessage(msg); const normalizedMsg = { ...msg, conversation_id: (msg.conversation_id || '').toLowerCase() };
useConversationStore.getState().addMessage(normalizedMsg);
// auto-mark DM as read if this conversation is active // auto-mark DM as read if this conversation is active
const activeConvId = useConversationStore.getState().activeConversationId; const activeConvId = (useConversationStore.getState().activeConversationId || '').toLowerCase();
if (msg.conversation_id === activeConvId && document.hasFocus()) { if (normalizedMsg.conversation_id === activeConvId && document.hasFocus()) {
useReadStatesStore.getState().markConvRead(msg.conversation_id, msg.id); useReadStatesStore.getState().markConvRead(normalizedMsg.conversation_id, normalizedMsg.id);
} }
} else { } else {
const msg = payload as unknown as Message; const msg = payload as unknown as Message;