anotherath commited on
Commit
4d3f8c3
·
1 Parent(s): 12a94f6
src/components/ChatArea.jsx CHANGED
@@ -5,10 +5,7 @@ import { ChatHeader, ChatMessages, ChatInput } from "./chatarea/index.js";
5
  import { SettingsView } from "./settings/index.js";
6
  import { CreateRoomView } from "./createroom/index.js";
7
  import { UserProfilePopup } from "./memberlist/index.js";
8
- import {
9
- setSelectedUser,
10
- clearSelectedUser,
11
- } from "../store/slices/chatSlice";
12
  import {
13
  fetchMessages,
14
  addMessage as addDMMessage,
@@ -22,12 +19,18 @@ import {
22
  import { addMessage } from "../store/slices/messageSlice";
23
  import socketService from "../services/socket.service";
24
 
25
- function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList, roomListCollapsed, memberListCollapsed, onOpenRoomSettings }) {
 
 
 
 
 
 
 
 
26
  const dispatch = useDispatch();
27
  const { isDark } = useSelector((state) => state.theme);
28
- const { selectedUser, selectedDMUser } = useSelector(
29
- (state) => state.chat,
30
- );
31
  const { user: currentUser } = useSelector((state) => state.auth);
32
  const {
33
  messages: dmMessagesMap,
@@ -51,9 +54,12 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
51
  const processedTimers = useRef([]);
52
  const isCreatingConversationRef = useRef(false);
53
 
54
- const allRoomIds = Object.values(rooms).flat().map((r) => r.id);
 
 
55
  const isBotRoom = room === "tro-ly-ai";
56
- const isDM = (view === "messages") || (room && !allRoomIds.includes(room) && !isBotRoom);
 
57
 
58
  // Move useSelector hooks to top (was at line 281)
59
  const userMessagesMap = useSelector((state) => state.message.userMessages);
@@ -115,7 +121,12 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
115
 
116
  // Join/leave DM via WebSocket - single effect
117
  useEffect(() => {
118
- if (!isDM || !activeConversationId) return;
 
 
 
 
 
119
 
120
  socketService.joinDM(activeConversationId);
121
  dispatch(markConversationAsRead(activeConversationId));
@@ -127,11 +138,26 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
127
 
128
  // Fetch messages when active conversation changes — only if not already fetched
129
  useEffect(() => {
130
- if (!isDM || !activeConversationId) return;
 
 
 
 
 
131
  const isFetched = fetchedConversations[activeConversationId];
132
- console.log("[ChatArea] Check fetch:", { activeConversationId, isFetched, fetchedConversations });
 
 
 
 
133
  if (isFetched) return; // Skip: already cached
134
- dispatch(fetchMessages({ conversationId: activeConversationId, page: 1, limit: 50 }));
 
 
 
 
 
 
135
  }, [isDM, activeConversationId, dispatch, fetchedConversations]);
136
 
137
  // Use refs to keep stable handler references and avoid duplicate listeners
@@ -153,7 +179,10 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
153
  if (!data?.id) return;
154
  if (processedMessageIds.current.has(data.id)) return;
155
  processedMessageIds.current.add(data.id);
156
- const timer = setTimeout(() => processedMessageIds.current.delete(data.id), 60000);
 
 
 
157
  processedTimers.current.push(timer);
158
 
159
  const conversationId = data.conversation_id || data.conversationId;
@@ -172,7 +201,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
172
  created_at: data.created_at || data.timestamp,
173
  sender: data.sender,
174
  },
175
- })
176
  );
177
 
178
  // Note: We do NOT mark conversation as fetched here.
@@ -187,7 +216,10 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
187
  delete next[data.tempId];
188
  } else {
189
  Object.keys(next).forEach((key) => {
190
- if (next[key].content === data.content && data.sender_id === currentUserRef.current?.id) {
 
 
 
191
  delete next[key];
192
  }
193
  });
@@ -220,7 +252,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
220
  ...data.message,
221
  conversation_id: currentConvId,
222
  },
223
- })
224
  );
225
  }
226
  };
@@ -234,7 +266,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
234
  conversationId: data.conversationId,
235
  userId: data.userId,
236
  isTyping: true,
237
- })
238
  );
239
  } else {
240
  dispatch(clearTyping(data.conversationId));
@@ -271,10 +303,9 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
271
 
272
  // Build messages for display
273
  // Use activeConversationId if available, fallback to room for existing conversations
274
- const conversationId = activeConversationId || (isDM && room && !isBotRoom ? room : null);
275
- const dmMessages = conversationId
276
- ? (dmMessagesMap[conversationId] || [])
277
- : [];
278
 
279
  // Sort messages by created_at ascending (oldest first, newest last)
280
  // Stable sort: fallback to id comparison if timestamps are equal
@@ -290,20 +321,25 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
290
  const apiMessages = sortedDmMessages.map((msg) => {
291
  const isOwn = msg.sender_id === currentUser?.id;
292
  const sender = isOwn
293
- ? (currentUser?.display_name || currentUser?.name || "Bạn")
294
- : (msg.sender?.display_name || "Unknown");
295
  const avatar = isOwn
296
- ? (currentUser?.display_name?.charAt(0).toUpperCase() || currentUser?.name?.charAt(0).toUpperCase() || "B")
297
- : (msg.sender?.display_name?.charAt(0).toUpperCase() || "?");
 
 
298
  const color = isOwn
299
- ? (currentUser?.color || null)
300
- : (msg.sender?.color || null);
301
 
302
  const timestamp = (() => {
303
  if (!msg.created_at) return "—";
304
  const date = new Date(msg.created_at);
305
  if (isNaN(date.getTime())) return msg.created_at;
306
- return date.toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" });
 
 
 
307
  })();
308
 
309
  return {
@@ -326,14 +362,16 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
326
  const mockMessages = isDM ? directMessages[room] || [] : messages[room] || [];
327
  const userMessages = userMessagesMap[room] || [];
328
 
329
- const chatMessages = isDM && activeConversationId
330
- ? apiMessages
331
- : [...mockMessages, ...userMessages];
 
332
 
333
  // Typing indicator from other user
334
- const otherTyping = isDM && activeConversationId
335
- ? typingMap[activeConversationId]?.isTyping
336
- : false;
 
337
 
338
  const placeholder =
339
  isBotRoom || (isDM && room === "studybot-dm")
@@ -343,118 +381,163 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
343
  : "Nhắn tin cho nhóm học...";
344
 
345
  // Handle send message via WebSocket
346
- const handleSend = useCallback(async (content, replyToMsg, files) => {
347
- if (!content.trim()) return;
348
-
349
- if (isDM) {
350
- // Guard: prevent chatting with self
351
- if (dmUser?.id && dmUser.id === currentUser?.id) {
352
- console.warn("Cannot send message to yourself");
353
- return;
354
- }
355
-
356
- let conversationId = activeConversationId;
357
-
358
- // Lazy create conversation if not exists
359
- if (!conversationId && dmUser?.id) {
360
- // Prevent race condition: lock creation
361
- if (isCreatingConversationRef.current) return;
362
- isCreatingConversationRef.current = true;
363
- setIsCreatingConversation(true);
364
-
365
- try {
366
- const result = await dispatch(
367
- createOrGetConversation(dmUser.id)
368
- ).unwrap();
369
- if (result) {
370
- conversationId = result.id;
371
- dispatch(setActiveConversation(result));
372
- }
373
- } catch (err) {
374
- // Failed to create conversation
375
- isCreatingConversationRef.current = false;
376
- setIsCreatingConversation(false);
377
  return;
378
  }
379
 
380
- isCreatingConversationRef.current = false;
381
- setIsCreatingConversation(false);
382
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
 
384
- if (!conversationId) return;
 
 
 
 
 
385
 
386
- // Send via WebSocket with tempId for tracking
387
- const tempId = `temp-${Date.now()}`;
388
- socketService.sendDM(conversationId, content.trim(), tempId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
- // Optimistic UI
391
- const optimisticMsg = {
392
- id: tempId,
393
- sender: currentUser?.display_name || currentUser?.name || "Bạn",
394
- avatar: currentUser?.display_name?.charAt(0).toUpperCase() || currentUser?.name?.charAt(0).toUpperCase() || "B",
395
- timestamp: new Date().toLocaleTimeString("vi-VN", {
396
- hour: "2-digit",
397
- minute: "2-digit",
398
- }),
399
- content: content.trim(),
400
- isPinned: false,
401
- replyTo: replyToMsg || null,
402
- isOwn: true,
403
- senderId: currentUser?.id,
404
- is_read: false,
405
- created_at: new Date().toISOString(),
406
- pending: true,
407
- };
408
-
409
- // Track sending message (already have tempId from above)
410
- setSendingMessages((prev) => ({
411
- ...prev,
412
- [tempId]: { content: content.trim(), timestamp: Date.now() },
413
- }));
414
 
415
- dispatch(
416
- addDMMessage({
417
- conversationId,
418
- message: {
419
- id: optimisticMsg.id,
420
- conversation_id: conversationId,
421
- sender_id: currentUser?.id,
422
- content: content.trim(),
423
- is_read: false,
424
- created_at: optimisticMsg.created_at,
425
- sender: {
426
- id: currentUser?.id,
427
- display_name: currentUser?.display_name || currentUser?.name || "Bạn",
428
- avatar_url: currentUser?.avatar || null,
 
 
 
429
  },
430
- pending: true,
431
- },
432
- })
433
- );
434
 
435
- // Stop typing
436
- socketService.dmTyping(conversationId, false);
437
- if (typingTimeoutRef.current) {
438
- clearTimeout(typingTimeoutRef.current);
439
- typingTimeoutRef.current = null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
440
  }
441
- } else {
442
- // Legacy: dispatch to Redux for non-DM
443
- const newMessage = {
444
- id: Date.now(),
445
- sender: "You",
446
- avatar: "Y",
447
- timestamp: new Date().toLocaleTimeString("vi-VN", {
448
- hour: "2-digit",
449
- minute: "2-digit",
450
- }),
451
- content,
452
- isPinned: false,
453
- replyTo: replyToMsg || null,
454
- };
455
- dispatch(addMessage({ roomId: room, message: newMessage }));
456
- }
457
- }, [isDM, activeConversationId, dmUser, currentUser, dispatch, room]);
458
 
459
  // Handle typing indicator
460
  const handleTyping = useCallback(() => {
@@ -539,11 +622,16 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
539
  sendingMessages={sendingMessages}
540
  isLoading={isDM && messagesLoading}
541
  conversationId={conversationId}
542
-
543
  onShowProfile={(senderName) => {
544
- if (isDM && dmUser && senderName !== (currentUser?.display_name || currentUser?.name)) {
 
 
 
 
545
  dispatch(setSelectedUser(dmUser));
546
- } else if (senderName !== (currentUser?.display_name || currentUser?.name)) {
 
 
547
  dispatch(
548
  setSelectedUser({
549
  id: senderName.toLowerCase(),
@@ -563,7 +651,6 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
563
  <ChatInput
564
  isDark={isDark}
565
  placeholder={placeholder}
566
-
567
  onSend={handleSend}
568
  onTyping={handleTyping}
569
  onStopTyping={handleStopTyping}
@@ -572,7 +659,14 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
572
  );
573
  }
574
 
575
- function ChatAreaWrapper({ isCreatingRoom, onCancelCreateRoom, isRoomSettingsOpen, onOpenRoomSettings, onCloseRoomSettings, ...props }) {
 
 
 
 
 
 
 
576
  const appState = useSelector((state) => state.app);
577
  const { isDark } = useSelector((state) => state.theme);
578
  const view = props.activeView || appState.activeView;
 
5
  import { SettingsView } from "./settings/index.js";
6
  import { CreateRoomView } from "./createroom/index.js";
7
  import { UserProfilePopup } from "./memberlist/index.js";
8
+ import { setSelectedUser, clearSelectedUser } from "../store/slices/chatSlice";
 
 
 
9
  import {
10
  fetchMessages,
11
  addMessage as addDMMessage,
 
19
  import { addMessage } from "../store/slices/messageSlice";
20
  import socketService from "../services/socket.service";
21
 
22
+ function ChatArea({
23
+ activeView,
24
+ activeRoom,
25
+ onToggleRoomList,
26
+ onToggleMemberList,
27
+ roomListCollapsed,
28
+ memberListCollapsed,
29
+ onOpenRoomSettings,
30
+ }) {
31
  const dispatch = useDispatch();
32
  const { isDark } = useSelector((state) => state.theme);
33
+ const { selectedUser, selectedDMUser } = useSelector((state) => state.chat);
 
 
34
  const { user: currentUser } = useSelector((state) => state.auth);
35
  const {
36
  messages: dmMessagesMap,
 
54
  const processedTimers = useRef([]);
55
  const isCreatingConversationRef = useRef(false);
56
 
57
+ const allRoomIds = Object.values(rooms)
58
+ .flat()
59
+ .map((r) => r.id);
60
  const isBotRoom = room === "tro-ly-ai";
61
+ const isDM =
62
+ view === "messages" || (room && !allRoomIds.includes(room) && !isBotRoom);
63
 
64
  // Move useSelector hooks to top (was at line 281)
65
  const userMessagesMap = useSelector((state) => state.message.userMessages);
 
121
 
122
  // Join/leave DM via WebSocket - single effect
123
  useEffect(() => {
124
+ if (
125
+ !isDM ||
126
+ !activeConversationId ||
127
+ activeConversationId.toString().startsWith("temp-conv-")
128
+ )
129
+ return;
130
 
131
  socketService.joinDM(activeConversationId);
132
  dispatch(markConversationAsRead(activeConversationId));
 
138
 
139
  // Fetch messages when active conversation changes — only if not already fetched
140
  useEffect(() => {
141
+ if (
142
+ !isDM ||
143
+ !activeConversationId ||
144
+ activeConversationId.toString().startsWith("temp-conv-")
145
+ )
146
+ return;
147
  const isFetched = fetchedConversations[activeConversationId];
148
+ console.log("[ChatArea] Check fetch:", {
149
+ activeConversationId,
150
+ isFetched,
151
+ fetchedConversations,
152
+ });
153
  if (isFetched) return; // Skip: already cached
154
+ dispatch(
155
+ fetchMessages({
156
+ conversationId: activeConversationId,
157
+ page: 1,
158
+ limit: 50,
159
+ }),
160
+ );
161
  }, [isDM, activeConversationId, dispatch, fetchedConversations]);
162
 
163
  // Use refs to keep stable handler references and avoid duplicate listeners
 
179
  if (!data?.id) return;
180
  if (processedMessageIds.current.has(data.id)) return;
181
  processedMessageIds.current.add(data.id);
182
+ const timer = setTimeout(
183
+ () => processedMessageIds.current.delete(data.id),
184
+ 60000,
185
+ );
186
  processedTimers.current.push(timer);
187
 
188
  const conversationId = data.conversation_id || data.conversationId;
 
201
  created_at: data.created_at || data.timestamp,
202
  sender: data.sender,
203
  },
204
+ }),
205
  );
206
 
207
  // Note: We do NOT mark conversation as fetched here.
 
216
  delete next[data.tempId];
217
  } else {
218
  Object.keys(next).forEach((key) => {
219
+ if (
220
+ next[key].content === data.content &&
221
+ data.sender_id === currentUserRef.current?.id
222
+ ) {
223
  delete next[key];
224
  }
225
  });
 
252
  ...data.message,
253
  conversation_id: currentConvId,
254
  },
255
+ }),
256
  );
257
  }
258
  };
 
266
  conversationId: data.conversationId,
267
  userId: data.userId,
268
  isTyping: true,
269
+ }),
270
  );
271
  } else {
272
  dispatch(clearTyping(data.conversationId));
 
303
 
304
  // Build messages for display
305
  // Use activeConversationId if available, fallback to room for existing conversations
306
+ const conversationId =
307
+ activeConversationId || (isDM && room && !isBotRoom ? room : null);
308
+ const dmMessages = conversationId ? dmMessagesMap[conversationId] || [] : [];
 
309
 
310
  // Sort messages by created_at ascending (oldest first, newest last)
311
  // Stable sort: fallback to id comparison if timestamps are equal
 
321
  const apiMessages = sortedDmMessages.map((msg) => {
322
  const isOwn = msg.sender_id === currentUser?.id;
323
  const sender = isOwn
324
+ ? currentUser?.display_name || currentUser?.name || "Bạn"
325
+ : msg.sender?.display_name || "Unknown";
326
  const avatar = isOwn
327
+ ? currentUser?.display_name?.charAt(0).toUpperCase() ||
328
+ currentUser?.name?.charAt(0).toUpperCase() ||
329
+ "B"
330
+ : msg.sender?.display_name?.charAt(0).toUpperCase() || "?";
331
  const color = isOwn
332
+ ? currentUser?.color || null
333
+ : msg.sender?.color || null;
334
 
335
  const timestamp = (() => {
336
  if (!msg.created_at) return "—";
337
  const date = new Date(msg.created_at);
338
  if (isNaN(date.getTime())) return msg.created_at;
339
+ return date.toLocaleTimeString("vi-VN", {
340
+ hour: "2-digit",
341
+ minute: "2-digit",
342
+ });
343
  })();
344
 
345
  return {
 
362
  const mockMessages = isDM ? directMessages[room] || [] : messages[room] || [];
363
  const userMessages = userMessagesMap[room] || [];
364
 
365
+ const chatMessages =
366
+ isDM && activeConversationId
367
+ ? apiMessages
368
+ : [...mockMessages, ...userMessages];
369
 
370
  // Typing indicator from other user
371
+ const otherTyping =
372
+ isDM && activeConversationId
373
+ ? typingMap[activeConversationId]?.isTyping
374
+ : false;
375
 
376
  const placeholder =
377
  isBotRoom || (isDM && room === "studybot-dm")
 
381
  : "Nhắn tin cho nhóm học...";
382
 
383
  // Handle send message via WebSocket
384
+ const handleSend = useCallback(
385
+ async (content, replyToMsg, files) => {
386
+ if (!content.trim()) return;
387
+
388
+ if (isDM) {
389
+ // Guard: prevent chatting with self
390
+ if (dmUser?.id && dmUser.id === currentUser?.id) {
391
+ console.warn("Cannot send message to yourself");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  return;
393
  }
394
 
395
+ let conversationId = activeConversationId;
396
+ const contentTrimmed = content.trim();
397
+ const msgTempId = `temp-${Date.now()}`;
398
+
399
+ // Optimistic UI for message
400
+ const optimisticMsg = {
401
+ id: msgTempId,
402
+ sender: currentUser?.display_name || currentUser?.name || "Bạn",
403
+ avatar:
404
+ currentUser?.display_name?.charAt(0).toUpperCase() ||
405
+ currentUser?.name?.charAt(0).toUpperCase() ||
406
+ "B",
407
+ timestamp: new Date().toLocaleTimeString("vi-VN", {
408
+ hour: "2-digit",
409
+ minute: "2-digit",
410
+ }),
411
+ content: contentTrimmed,
412
+ isPinned: false,
413
+ replyTo: replyToMsg || null,
414
+ isOwn: true,
415
+ senderId: currentUser?.id,
416
+ sender_id: currentUser?.id,
417
+ is_read: false,
418
+ created_at: new Date().toISOString(),
419
+ pending: true,
420
+ };
421
+
422
+ // Track sending message
423
+ setSendingMessages((prev) => ({
424
+ ...prev,
425
+ [msgTempId]: { content: contentTrimmed, timestamp: Date.now() },
426
+ }));
427
+
428
+ // Lazy create conversation if not exists
429
+ if (!conversationId && dmUser?.id) {
430
+ if (isCreatingConversationRef.current) return;
431
+ isCreatingConversationRef.current = true;
432
+ setIsCreatingConversation(true);
433
+
434
+ const tempConvId = `temp-conv-${dmUser.id}`;
435
+
436
+ // Optimistically set active conversation
437
+ const tempConv = {
438
+ id: tempConvId,
439
+ other_user: dmUser,
440
+ isTemp: true,
441
+ unread_count: 0,
442
+ };
443
+ dispatch(setActiveConversation(tempConv));
444
+
445
+ // Optimistically add message
446
+ dispatch(
447
+ addDMMessage({
448
+ conversationId: tempConvId,
449
+ message: {
450
+ ...optimisticMsg,
451
+ conversation_id: tempConvId,
452
+ sender: {
453
+ id: currentUser?.id,
454
+ display_name:
455
+ currentUser?.display_name || currentUser?.name || "Bạn",
456
+ avatar_url: currentUser?.avatar || null,
457
+ },
458
+ },
459
+ }),
460
+ );
461
 
462
+ // Stop typing optimistic
463
+ socketService.dmTyping(tempConvId, false);
464
+ if (typingTimeoutRef.current) {
465
+ clearTimeout(typingTimeoutRef.current);
466
+ typingTimeoutRef.current = null;
467
+ }
468
 
469
+ try {
470
+ const result = await dispatch(
471
+ createOrGetConversation(dmUser.id),
472
+ ).unwrap();
473
+
474
+ if (result) {
475
+ // Swap temp ID with real ID in Redux
476
+ dispatch({
477
+ type: "dm/replaceTempConversation",
478
+ payload: { tempId: tempConvId, realConversation: result },
479
+ });
480
+
481
+ // Now send via WebSocket
482
+ socketService.sendDM(result.id, contentTrimmed, msgTempId);
483
+ }
484
+ } catch (err) {
485
+ console.error("Failed to create conversation:", err);
486
+ // Optional: handle failure UI
487
+ } finally {
488
+ isCreatingConversationRef.current = false;
489
+ setIsCreatingConversation(false);
490
+ }
491
 
492
+ return;
493
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
 
495
+ if (!conversationId) return;
496
+
497
+ // Existing conversation path
498
+ socketService.sendDM(conversationId, contentTrimmed, msgTempId);
499
+
500
+ dispatch(
501
+ addDMMessage({
502
+ conversationId,
503
+ message: {
504
+ ...optimisticMsg,
505
+ conversation_id: conversationId,
506
+ sender: {
507
+ id: currentUser?.id,
508
+ display_name:
509
+ currentUser?.display_name || currentUser?.name || "Bạn",
510
+ avatar_url: currentUser?.avatar || null,
511
+ },
512
  },
513
+ }),
514
+ );
 
 
515
 
516
+ // Stop typing
517
+ socketService.dmTyping(conversationId, false);
518
+ if (typingTimeoutRef.current) {
519
+ clearTimeout(typingTimeoutRef.current);
520
+ typingTimeoutRef.current = null;
521
+ }
522
+ } else {
523
+ // Legacy: dispatch to Redux for non-DM
524
+ const newMessage = {
525
+ id: Date.now(),
526
+ sender: "You",
527
+ avatar: "Y",
528
+ timestamp: new Date().toLocaleTimeString("vi-VN", {
529
+ hour: "2-digit",
530
+ minute: "2-digit",
531
+ }),
532
+ content,
533
+ isPinned: false,
534
+ replyTo: replyToMsg || null,
535
+ };
536
+ dispatch(addMessage({ roomId: room, message: newMessage }));
537
  }
538
+ },
539
+ [isDM, activeConversationId, dmUser, currentUser, dispatch, room],
540
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
  // Handle typing indicator
543
  const handleTyping = useCallback(() => {
 
622
  sendingMessages={sendingMessages}
623
  isLoading={isDM && messagesLoading}
624
  conversationId={conversationId}
 
625
  onShowProfile={(senderName) => {
626
+ if (
627
+ isDM &&
628
+ dmUser &&
629
+ senderName !== (currentUser?.display_name || currentUser?.name)
630
+ ) {
631
  dispatch(setSelectedUser(dmUser));
632
+ } else if (
633
+ senderName !== (currentUser?.display_name || currentUser?.name)
634
+ ) {
635
  dispatch(
636
  setSelectedUser({
637
  id: senderName.toLowerCase(),
 
651
  <ChatInput
652
  isDark={isDark}
653
  placeholder={placeholder}
 
654
  onSend={handleSend}
655
  onTyping={handleTyping}
656
  onStopTyping={handleStopTyping}
 
659
  );
660
  }
661
 
662
+ function ChatAreaWrapper({
663
+ isCreatingRoom,
664
+ onCancelCreateRoom,
665
+ isRoomSettingsOpen,
666
+ onOpenRoomSettings,
667
+ onCloseRoomSettings,
668
+ ...props
669
+ }) {
670
  const appState = useSelector((state) => state.app);
671
  const { isDark } = useSelector((state) => state.theme);
672
  const view = props.activeView || appState.activeView;
src/components/chatarea/ChatMessages.jsx CHANGED
@@ -118,7 +118,6 @@ function ChatMessage({
118
  <FiPaperclip size={14} /> {msg.attachmentName}
119
  </div>
120
  ) : null}
121
-
122
  </div>
123
  </div>
124
  );
@@ -292,7 +291,7 @@ function ChatMessages({
292
  if (prevLoadingRef.current && !isLoading) {
293
  container.scrollTop = container.scrollHeight;
294
  }
295
-
296
  // Scroll to bottom when new messages added (but not when loading more)
297
  if (chatMessages.length > prevMessagesLengthRef.current && !isLoadingMore) {
298
  container.scrollTop = container.scrollHeight;
@@ -326,7 +325,7 @@ function ChatMessages({
326
  className={`flex-1 p-4 ${isEmpty ? "flex items-center justify-center overflow-hidden" : ` ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}`}
327
  onScroll={isEmpty ? undefined : handleScroll}
328
  >
329
- {isLoading ? (
330
  <div className="flex flex-col gap-2 w-full min-h-full justify-end pb-2">
331
  <MessageSkeleton isDark={isDark} width="60%" showSecondLine={false} />
332
  <MessageSkeleton isDark={isDark} width="85%" />
 
118
  <FiPaperclip size={14} /> {msg.attachmentName}
119
  </div>
120
  ) : null}
 
121
  </div>
122
  </div>
123
  );
 
291
  if (prevLoadingRef.current && !isLoading) {
292
  container.scrollTop = container.scrollHeight;
293
  }
294
+
295
  // Scroll to bottom when new messages added (but not when loading more)
296
  if (chatMessages.length > prevMessagesLengthRef.current && !isLoadingMore) {
297
  container.scrollTop = container.scrollHeight;
 
325
  className={`flex-1 p-4 ${isEmpty ? "flex items-center justify-center overflow-hidden" : ` ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}`}
326
  onScroll={isEmpty ? undefined : handleScroll}
327
  >
328
+ {isLoading && !hasMessages ? (
329
  <div className="flex flex-col gap-2 w-full min-h-full justify-end pb-2">
330
  <MessageSkeleton isDark={isDark} width="60%" showSecondLine={false} />
331
  <MessageSkeleton isDark={isDark} width="85%" />
src/store/slices/dmSlice.js CHANGED
@@ -11,13 +11,18 @@ export const fetchConversations = createAsyncThunk(
11
  console.log("[fetchConversations] Calling API...");
12
  const { data } = await dmService.getConversations({ page: 1, limit: 20 });
13
  const result = data.data || data.conversations || data || [];
14
- console.log("[fetchConversations] Result:", { count: result.length, ids: result.map(c => c.id) });
 
 
 
15
  return result;
16
  } catch (err) {
17
  console.error("[fetchConversations] Error:", err);
18
- return rejectWithValue(err.response?.data?.message || "Không thể tải danh sách trò chuyện");
 
 
19
  }
20
- }
21
  );
22
 
23
  export const createOrGetConversation = createAsyncThunk(
@@ -27,25 +32,32 @@ export const createOrGetConversation = createAsyncThunk(
27
  const { data } = await dmService.createOrGetConversation(userId);
28
  return data.data || data;
29
  } catch (err) {
30
- return rejectWithValue(err.response?.data?.message || "Không thể tạo cuộc trò chuyện");
 
 
31
  }
32
- }
33
  );
34
 
35
  export const fetchMessages = createAsyncThunk(
36
  "dm/fetchMessages",
37
  async ({ conversationId, page = 1, limit = 20 }, { rejectWithValue }) => {
38
  try {
39
- const { data } = await dmService.getMessages(conversationId, { page, limit });
 
 
 
40
  return {
41
  conversationId,
42
  messages: data.data || data.messages || data || [],
43
  meta: data.meta || null,
44
  };
45
  } catch (err) {
46
- return rejectWithValue(err.response?.data?.message || "Không thể tải tin nhắn");
 
 
47
  }
48
- }
49
  );
50
 
51
  export const markConversationAsRead = createAsyncThunk(
@@ -55,9 +67,11 @@ export const markConversationAsRead = createAsyncThunk(
55
  await dmService.markAsRead(conversationId);
56
  return conversationId;
57
  } catch (err) {
58
- return rejectWithValue(err.response?.data?.message || "Không thể đánh dấu đã đọc");
 
 
59
  }
60
- }
61
  );
62
 
63
  // ==================== Slice ====================
@@ -99,17 +113,26 @@ const dmSlice = createSlice({
99
  state.messages[conversationId] = [];
100
  }
101
  const messages = state.messages[conversationId];
102
-
103
  // Check if this is a real message replacing a pending one
104
  const pendingIndex = messages.findIndex(
105
- (m) => m.pending && m.sender_id === message.sender_id && m.content === message.content
 
 
 
106
  );
107
  if (pendingIndex !== -1) {
108
- // Replace pending message with real one
109
- messages[pendingIndex] = { ...message, pending: false };
 
 
 
 
 
 
110
  return;
111
  }
112
-
113
  // Dedupe by id
114
  const exists = messages.some((m) => m.id === message.id);
115
  if (!exists) {
@@ -122,9 +145,14 @@ const dmSlice = createSlice({
122
  if (!state.messages[conversationId]) {
123
  state.messages[conversationId] = [];
124
  }
125
- const existingIds = new Set(state.messages[conversationId].map((m) => m.id));
 
 
126
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
127
- state.messages[conversationId] = [...newMessages, ...state.messages[conversationId]];
 
 
 
128
  },
129
 
130
  updateMessage: (state, action) => {
@@ -140,7 +168,11 @@ const dmSlice = createSlice({
140
  setTyping: (state, action) => {
141
  const { conversationId, userId, isTyping } = action.payload;
142
  if (isTyping) {
143
- state.typing[conversationId] = { userId, isTyping, timestamp: Date.now() };
 
 
 
 
144
  } else {
145
  if (state.typing[conversationId]?.userId === userId) {
146
  delete state.typing[conversationId];
@@ -200,7 +232,8 @@ const dmSlice = createSlice({
200
  state.conversations[idx] = {
201
  ...state.conversations[idx],
202
  last_message: message,
203
- unread_count: unreadCount ?? state.conversations[idx].unread_count + 1,
 
204
  };
205
  // Move to top
206
  const conv = state.conversations.splice(idx, 1)[0];
@@ -232,6 +265,41 @@ const dmSlice = createSlice({
232
 
233
  resetDMState: () => initialState,
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  clearError: (state) => {
236
  state.error = null;
237
  state.messagesError = null;
@@ -284,7 +352,19 @@ const dmSlice = createSlice({
284
  state.messages[conversationId] = [];
285
  }
286
  const page = meta?.page || 1;
287
- const existing = state.messages[conversationId];
 
 
 
 
 
 
 
 
 
 
 
 
288
  const existingIds = new Set(existing.map((m) => m.id));
289
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
290
 
@@ -308,7 +388,9 @@ const dmSlice = createSlice({
308
  // markConversationAsRead
309
  .addCase(markConversationAsRead.fulfilled, (state, action) => {
310
  const conversationId = action.payload;
311
- const idx = state.conversations.findIndex((c) => c.id === conversationId);
 
 
312
  if (idx !== -1) {
313
  state.conversations[idx].unread_count = 0;
314
  }
@@ -336,6 +418,7 @@ export const {
336
  clearMessages,
337
  markConversationAsFetched,
338
  setConversationsFetched,
 
339
  resetDMState,
340
  clearError,
341
  } = dmSlice.actions;
 
11
  console.log("[fetchConversations] Calling API...");
12
  const { data } = await dmService.getConversations({ page: 1, limit: 20 });
13
  const result = data.data || data.conversations || data || [];
14
+ console.log("[fetchConversations] Result:", {
15
+ count: result.length,
16
+ ids: result.map((c) => c.id),
17
+ });
18
  return result;
19
  } catch (err) {
20
  console.error("[fetchConversations] Error:", err);
21
+ return rejectWithValue(
22
+ err.response?.data?.message || "Không thể tải danh sách trò chuyện",
23
+ );
24
  }
25
+ },
26
  );
27
 
28
  export const createOrGetConversation = createAsyncThunk(
 
32
  const { data } = await dmService.createOrGetConversation(userId);
33
  return data.data || data;
34
  } catch (err) {
35
+ return rejectWithValue(
36
+ err.response?.data?.message || "Không thể tạo cuộc trò chuyện",
37
+ );
38
  }
39
+ },
40
  );
41
 
42
  export const fetchMessages = createAsyncThunk(
43
  "dm/fetchMessages",
44
  async ({ conversationId, page = 1, limit = 20 }, { rejectWithValue }) => {
45
  try {
46
+ const { data } = await dmService.getMessages(conversationId, {
47
+ page,
48
+ limit,
49
+ });
50
  return {
51
  conversationId,
52
  messages: data.data || data.messages || data || [],
53
  meta: data.meta || null,
54
  };
55
  } catch (err) {
56
+ return rejectWithValue(
57
+ err.response?.data?.message || "Không thể tải tin nhắn",
58
+ );
59
  }
60
+ },
61
  );
62
 
63
  export const markConversationAsRead = createAsyncThunk(
 
67
  await dmService.markAsRead(conversationId);
68
  return conversationId;
69
  } catch (err) {
70
+ return rejectWithValue(
71
+ err.response?.data?.message || "Không thể đánh dấu đã đọc",
72
+ );
73
  }
74
+ },
75
  );
76
 
77
  // ==================== Slice ====================
 
113
  state.messages[conversationId] = [];
114
  }
115
  const messages = state.messages[conversationId];
116
+
117
  // Check if this is a real message replacing a pending one
118
  const pendingIndex = messages.findIndex(
119
+ (m) =>
120
+ m.pending &&
121
+ m.sender_id === message.sender_id &&
122
+ m.content === message.content,
123
  );
124
  if (pendingIndex !== -1) {
125
+ const exists = messages.some((m) => m.id === message.id);
126
+ if (exists) {
127
+ // If real message already exists, just remove the pending one
128
+ messages.splice(pendingIndex, 1);
129
+ } else {
130
+ // Replace pending message with real one
131
+ messages[pendingIndex] = { ...message, pending: false };
132
+ }
133
  return;
134
  }
135
+
136
  // Dedupe by id
137
  const exists = messages.some((m) => m.id === message.id);
138
  if (!exists) {
 
145
  if (!state.messages[conversationId]) {
146
  state.messages[conversationId] = [];
147
  }
148
+ const existingIds = new Set(
149
+ state.messages[conversationId].map((m) => m.id),
150
+ );
151
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
152
+ state.messages[conversationId] = [
153
+ ...newMessages,
154
+ ...state.messages[conversationId],
155
+ ];
156
  },
157
 
158
  updateMessage: (state, action) => {
 
168
  setTyping: (state, action) => {
169
  const { conversationId, userId, isTyping } = action.payload;
170
  if (isTyping) {
171
+ state.typing[conversationId] = {
172
+ userId,
173
+ isTyping,
174
+ timestamp: Date.now(),
175
+ };
176
  } else {
177
  if (state.typing[conversationId]?.userId === userId) {
178
  delete state.typing[conversationId];
 
232
  state.conversations[idx] = {
233
  ...state.conversations[idx],
234
  last_message: message,
235
+ unread_count:
236
+ unreadCount ?? state.conversations[idx].unread_count + 1,
237
  };
238
  // Move to top
239
  const conv = state.conversations.splice(idx, 1)[0];
 
265
 
266
  resetDMState: () => initialState,
267
 
268
+ replaceTempConversation: (state, action) => {
269
+ const { tempId, realConversation } = action.payload;
270
+
271
+ // Update conversations list
272
+ const idx = state.conversations.findIndex((c) => c.id === tempId);
273
+ if (idx !== -1) {
274
+ state.conversations[idx] = realConversation;
275
+ } else {
276
+ state.conversations.unshift(realConversation);
277
+ }
278
+
279
+ // Update active conversation if it matches
280
+ if (state.activeConversationId === tempId) {
281
+ state.activeConversationId = realConversation.id;
282
+ state.activeConversation = realConversation;
283
+ }
284
+
285
+ // Move messages from tempId to realId
286
+ if (state.messages[tempId]) {
287
+ state.messages[realConversation.id] = state.messages[tempId].map(
288
+ (m) => ({
289
+ ...m,
290
+ conversation_id: realConversation.id,
291
+ }),
292
+ );
293
+ delete state.messages[tempId];
294
+ }
295
+
296
+ // Move typing state
297
+ if (state.typing[tempId]) {
298
+ state.typing[realConversation.id] = state.typing[tempId];
299
+ delete state.typing[tempId];
300
+ }
301
+ },
302
+
303
  clearError: (state) => {
304
  state.error = null;
305
  state.messagesError = null;
 
352
  state.messages[conversationId] = [];
353
  }
354
  const page = meta?.page || 1;
355
+ let existing = state.messages[conversationId];
356
+
357
+ // Remove pending messages if they are already confirmed in the API response
358
+ existing = existing.filter((ex) => {
359
+ if (ex.pending) {
360
+ const isMatchInApi = messages.some(
361
+ (m) => m.sender_id === ex.sender_id && m.content === ex.content,
362
+ );
363
+ return !isMatchInApi;
364
+ }
365
+ return true;
366
+ });
367
+
368
  const existingIds = new Set(existing.map((m) => m.id));
369
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
370
 
 
388
  // markConversationAsRead
389
  .addCase(markConversationAsRead.fulfilled, (state, action) => {
390
  const conversationId = action.payload;
391
+ const idx = state.conversations.findIndex(
392
+ (c) => c.id === conversationId,
393
+ );
394
  if (idx !== -1) {
395
  state.conversations[idx].unread_count = 0;
396
  }
 
418
  clearMessages,
419
  markConversationAsFetched,
420
  setConversationsFetched,
421
+ replaceTempConversation,
422
  resetDMState,
423
  clearError,
424
  } = dmSlice.actions;