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