fix(web): normalize conversation IDs to lowercase and bind DMChat messages selector
This commit is contained in:
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user