anotherath commited on
Commit
b4bf04d
·
1 Parent(s): aa61a49

đã fix rất nhiều vào tối thứ 5

Browse files
src/App.jsx CHANGED
@@ -13,6 +13,11 @@ import ManageAgent from "./components/createspace/ManageAgent";
13
  import ManageAgentTips from "./components/createspace/ManageAgentTips";
14
  import LoginPage from "./pages/LoginPage";
15
  import { initializeAuth } from "./store/slices/authSlice";
 
 
 
 
 
16
  import socketService from "./services/socket.service";
17
 
18
  function App() {
@@ -26,6 +31,7 @@ function App() {
26
  const [roomListCollapsed, setRoomListCollapsed] = useState(false);
27
  const [memberListCollapsed, setMemberListCollapsed] = useState(false);
28
  const [isCreatingRoom, setIsCreatingRoom] = useState(false);
 
29
 
30
  useEffect(() => {
31
  if (window.location.pathname !== "/") {
@@ -49,6 +55,57 @@ function App() {
49
  };
50
  }, [isAuthenticated]);
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const currentView = isSettings ? "settings" : activeView;
53
 
54
  if (!initialized || isLoggingOut) {
@@ -168,6 +225,9 @@ function App() {
168
  memberListCollapsed={memberListCollapsed}
169
  isCreatingRoom={isCreatingRoom}
170
  onCancelCreateRoom={() => setIsCreatingRoom(false)}
 
 
 
171
  />
172
 
173
  {/* Collapsed member list indicator */}
 
13
  import ManageAgentTips from "./components/createspace/ManageAgentTips";
14
  import LoginPage from "./pages/LoginPage";
15
  import { initializeAuth } from "./store/slices/authSlice";
16
+ import {
17
+ addMessage as addDMMessage,
18
+ markConversationAsFetched,
19
+ updateConversationLastMessage,
20
+ } from "./store/slices/dmSlice";
21
  import socketService from "./services/socket.service";
22
 
23
  function App() {
 
31
  const [roomListCollapsed, setRoomListCollapsed] = useState(false);
32
  const [memberListCollapsed, setMemberListCollapsed] = useState(false);
33
  const [isCreatingRoom, setIsCreatingRoom] = useState(false);
34
+ const [isRoomSettingsOpen, setIsRoomSettingsOpen] = useState(false);
35
 
36
  useEffect(() => {
37
  if (window.location.pathname !== "/") {
 
55
  };
56
  }, [isAuthenticated]);
57
 
58
+ // Global WebSocket listener: always cache incoming DMs regardless of current view
59
+ useEffect(() => {
60
+ if (!isAuthenticated) return;
61
+
62
+ const handleNewDM = (data) => {
63
+ if (!data?.id) return;
64
+
65
+ const conversationId = data.conversation_id || data.conversationId;
66
+ if (!conversationId) return;
67
+
68
+ // Cache the message in Redux so it's available when user opens the conversation
69
+ dispatch(
70
+ addDMMessage({
71
+ conversationId,
72
+ message: {
73
+ id: data.id,
74
+ conversation_id: conversationId,
75
+ sender_id: data.sender_id,
76
+ content: data.content,
77
+ is_read: data.is_read ?? false,
78
+ created_at: data.created_at || data.timestamp,
79
+ sender: data.sender,
80
+ },
81
+ })
82
+ );
83
+
84
+ // Note: We do NOT mark conversation as fetched here.
85
+ // Only mark as fetched after a full API fetch (page 1) so that
86
+ // opening a conversation for the first time still loads historical messages.
87
+ // WebSocket messages are cached but the conversation is not "fully fetched" yet.
88
+
89
+ // Update last message in conversation list
90
+ dispatch(
91
+ updateConversationLastMessage({
92
+ conversationId,
93
+ message: {
94
+ id: data.id,
95
+ content: data.content,
96
+ created_at: data.created_at || data.timestamp,
97
+ },
98
+ })
99
+ );
100
+ };
101
+
102
+ socketService.onNewDM(handleNewDM);
103
+
104
+ return () => {
105
+ socketService.off("newDM", handleNewDM);
106
+ };
107
+ }, [isAuthenticated, dispatch]);
108
+
109
  const currentView = isSettings ? "settings" : activeView;
110
 
111
  if (!initialized || isLoggingOut) {
 
225
  memberListCollapsed={memberListCollapsed}
226
  isCreatingRoom={isCreatingRoom}
227
  onCancelCreateRoom={() => setIsCreatingRoom(false)}
228
+ isRoomSettingsOpen={isRoomSettingsOpen}
229
+ onOpenRoomSettings={() => setIsRoomSettingsOpen(true)}
230
+ onCloseRoomSettings={() => setIsRoomSettingsOpen(false)}
231
  />
232
 
233
  {/* Collapsed member list indicator */}
src/components/ChatArea.jsx CHANGED
@@ -6,10 +6,6 @@ import { SettingsView } from "./settings/index.js";
6
  import { CreateRoomView } from "./createroom/index.js";
7
  import { UserProfilePopup } from "./memberlist/index.js";
8
  import {
9
- setReplyTo,
10
- cancelReply,
11
- setEditMessage,
12
- cancelEdit,
13
  setSelectedUser,
14
  clearSelectedUser,
15
  } from "../store/slices/chatSlice";
@@ -21,14 +17,15 @@ import {
21
  setActiveConversation,
22
  markConversationAsRead,
23
  createOrGetConversation,
 
24
  } from "../store/slices/dmSlice";
25
  import { addMessage } from "../store/slices/messageSlice";
26
  import socketService from "../services/socket.service";
27
 
28
- function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList, roomListCollapsed, memberListCollapsed }) {
29
  const dispatch = useDispatch();
30
  const { isDark } = useSelector((state) => state.theme);
31
- const { replyTo, editMessage, selectedUser, selectedDMUser } = useSelector(
32
  (state) => state.chat,
33
  );
34
  const { user: currentUser } = useSelector((state) => state.auth);
@@ -38,6 +35,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
38
  activeConversation,
39
  typing: typingMap,
40
  messagesLoading,
 
41
  } = useSelector((state) => state.dm);
42
  const appState = useSelector((state) => state.app);
43
 
@@ -47,13 +45,19 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
47
  const [dmUser, setDmUser] = useState(null);
48
  const [isTyping, setIsTypingState] = useState(false);
49
  const [sendingMessages, setSendingMessages] = useState({}); // { [tempId]: { content, timestamp } }
 
50
  const typingTimeoutRef = useRef(null);
51
  const processedMessageIds = useRef(new Set());
 
 
52
 
53
  const allRoomIds = Object.values(rooms).flat().map((r) => r.id);
54
  const isBotRoom = room === "tro-ly-ai";
55
  const isDM = (view === "messages") || (room && !allRoomIds.includes(room) && !isBotRoom);
56
 
 
 
 
57
  // Build dmUser from selectedDMUser or activeConversation
58
  useEffect(() => {
59
  if (!isDM || !room) {
@@ -101,23 +105,15 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
101
  }
102
  }, [room, isDM, selectedDMUser, activeConversation]);
103
 
104
- // When DM room changes, set active conversation and fetch messages
105
  useEffect(() => {
106
- if (!isDM || !room) return;
 
 
 
 
107
 
108
- // If we have an activeConversation matching this room, use it
109
- if (activeConversation?.id === room) {
110
- dispatch(fetchMessages({ conversationId: room, page: 1, limit: 50 }));
111
- socketService.joinDM(room);
112
- dispatch(markConversationAsRead(room));
113
- return;
114
- }
115
-
116
- // Otherwise try to find conversation by userId in conversations list
117
- // This is handled by DMList when clicking a user
118
- }, [isDM, room, activeConversation, dispatch]);
119
-
120
- // Join/leave DM via WebSocket
121
  useEffect(() => {
122
  if (!isDM || !activeConversationId) return;
123
 
@@ -129,65 +125,100 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
129
  };
130
  }, [isDM, activeConversationId, dispatch]);
131
 
132
- // Listen to WebSocket events for this DM
133
  useEffect(() => {
134
  if (!isDM || !activeConversationId) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
 
136
  const handleNewDM = (data) => {
137
- // Received newDM from server
138
  if (!data?.id) return;
139
  if (processedMessageIds.current.has(data.id)) return;
140
  processedMessageIds.current.add(data.id);
141
- setTimeout(() => processedMessageIds.current.delete(data.id), 60000);
 
142
 
143
  const conversationId = data.conversation_id || data.conversationId;
144
- if (conversationId === activeConversationId) {
145
- // Remove from sendingMessages if exists
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  setSendingMessages((prev) => {
147
  const next = { ...prev };
148
- Object.keys(next).forEach((key) => {
149
- if (next[key].content === data.content) {
150
- delete next[key];
151
- }
152
- });
 
 
 
 
153
  return next;
154
  });
155
- dispatch(
156
- addDMMessage({
157
- conversationId,
158
- message: {
159
- id: data.id,
160
- conversation_id: conversationId,
161
- sender_id: data.sender_id,
162
- content: data.content,
163
- is_read: data.is_read ?? false,
164
- created_at: data.created_at || data.timestamp,
165
- sender: data.sender,
166
- },
167
- })
168
- );
169
  }
170
  };
171
 
172
  const handleDmSent = (data) => {
173
- // Received dmSent from server
174
  if (data.success && data.message) {
175
- // Remove from sendingMessages if exists
176
  setSendingMessages((prev) => {
177
  const next = { ...prev };
178
- Object.keys(next).forEach((key) => {
179
- if (next[key].content === data.message.content) {
180
- delete next[key];
181
- }
182
- });
 
 
 
 
183
  return next;
184
  });
185
  dispatch(
186
  addDMMessage({
187
- conversationId: activeConversationId,
188
  message: {
189
  ...data.message,
190
- conversation_id: activeConversationId,
191
  },
192
  })
193
  );
@@ -195,7 +226,8 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
195
  };
196
 
197
  const handleDmTyping = (data) => {
198
- if (data.conversationId === activeConversationId) {
 
199
  if (data.isTyping) {
200
  dispatch(
201
  setTyping({
@@ -211,9 +243,16 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
211
  };
212
 
213
  const handleDmRead = (data) => {
214
- if (data.conversationId === activeConversationId) {
215
- // Update read status for messages
216
- // Backend handles this, we can refresh if needed
 
 
 
 
 
 
 
217
  }
218
  };
219
 
@@ -228,18 +267,23 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
228
  socketService.off("dmTyping", handleDmTyping);
229
  socketService.off("dmRead", handleDmRead);
230
  };
231
- }, [isDM, activeConversationId, dispatch]);
232
 
233
  // Build messages for display
234
- const dmMessages = isDM && activeConversationId
235
- ? (dmMessagesMap[activeConversationId] || [])
 
 
236
  : [];
237
 
238
  // Sort messages by created_at ascending (oldest first, newest last)
 
239
  const sortedDmMessages = [...dmMessages].sort((a, b) => {
240
- const dateA = new Date(a.created_at);
241
- const dateB = new Date(b.created_at);
242
- return dateA - dateB;
 
 
243
  });
244
 
245
  // Convert API messages to UI format
@@ -255,10 +299,12 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
255
  ? (currentUser?.color || null)
256
  : (msg.sender?.color || null);
257
 
258
- const date = new Date(msg.created_at);
259
- const timestamp = isNaN(date.getTime())
260
- ? msg.created_at
261
- : date.toLocaleTimeString("vi-VN", { hour: "2-digit", minute: "2-digit" });
 
 
262
 
263
  return {
264
  id: msg.id,
@@ -278,7 +324,6 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
278
 
279
  // Combine with mock data for non-DM or fallback
280
  const mockMessages = isDM ? directMessages[room] || [] : messages[room] || [];
281
- const userMessagesMap = useSelector((state) => state.message.userMessages);
282
  const userMessages = userMessagesMap[room] || [];
283
 
284
  const chatMessages = isDM && activeConversationId
@@ -302,10 +347,21 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
302
  if (!content.trim()) return;
303
 
304
  if (isDM) {
 
 
 
 
 
 
305
  let conversationId = activeConversationId;
306
 
307
  // Lazy create conversation if not exists
308
  if (!conversationId && dmUser?.id) {
 
 
 
 
 
309
  try {
310
  const result = await dispatch(
311
  createOrGetConversation(dmUser.id)
@@ -316,18 +372,22 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
316
  }
317
  } catch (err) {
318
  // Failed to create conversation
 
 
319
  return;
320
  }
 
 
 
321
  }
322
 
323
  if (!conversationId) return;
324
 
325
- // Send via WebSocket
326
- // Send DM via WebSocket
327
- socketService.sendDM(conversationId, content.trim());
328
 
329
  // Optimistic UI
330
- const tempId = `temp-${Date.now()}`;
331
  const optimisticMsg = {
332
  id: tempId,
333
  sender: currentUser?.display_name || currentUser?.name || "Bạn",
@@ -346,7 +406,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
346
  pending: true,
347
  };
348
 
349
- // Track sending message
350
  setSendingMessages((prev) => ({
351
  ...prev,
352
  [tempId]: { content: content.trim(), timestamp: Date.now() },
@@ -415,6 +475,23 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
415
  }, 3000);
416
  }, [isDM, activeConversationId, isTyping]);
417
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  const handleStopTyping = useCallback(() => {
419
  if (!isDM || !activeConversationId) return;
420
  setIsTypingState(false);
@@ -440,6 +517,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
440
  onToggleMemberList={onToggleMemberList}
441
  roomListCollapsed={roomListCollapsed}
442
  memberListCollapsed={memberListCollapsed}
 
443
  />
444
  {/* User Profile Popup */}
445
  {selectedUser && (
@@ -460,14 +538,8 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
460
  hasNoSelection={isDM && !dmUser}
461
  sendingMessages={sendingMessages}
462
  isLoading={isDM && messagesLoading}
463
- onReply={(msg) => {
464
- dispatch(setReplyTo(msg));
465
- dispatch(cancelEdit());
466
- }}
467
- onEdit={(msg) => {
468
- dispatch(setEditMessage(msg));
469
- dispatch(cancelReply());
470
- }}
471
  onShowProfile={(senderName) => {
472
  if (isDM && dmUser && senderName !== (currentUser?.display_name || currentUser?.name)) {
473
  dispatch(setSelectedUser(dmUser));
@@ -491,10 +563,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
491
  <ChatInput
492
  isDark={isDark}
493
  placeholder={placeholder}
494
- replyTo={replyTo}
495
- onCancelReply={() => dispatch(cancelReply())}
496
- editMessage={editMessage}
497
- onCancelEdit={() => dispatch(cancelEdit())}
498
  onSend={handleSend}
499
  onTyping={handleTyping}
500
  onStopTyping={handleStopTyping}
@@ -503,7 +572,7 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
503
  );
504
  }
505
 
506
- function ChatAreaWrapper({ isCreatingRoom, onCancelCreateRoom, ...props }) {
507
  const appState = useSelector((state) => state.app);
508
  const { isDark } = useSelector((state) => state.theme);
509
  const view = props.activeView || appState.activeView;
@@ -524,7 +593,7 @@ function ChatAreaWrapper({ isCreatingRoom, onCancelCreateRoom, ...props }) {
524
  return <SettingsView isDark={isDark} />;
525
  }
526
 
527
- return <ChatArea {...props} />;
528
  }
529
 
530
  export default ChatAreaWrapper;
 
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";
 
17
  setActiveConversation,
18
  markConversationAsRead,
19
  createOrGetConversation,
20
+ markConversationAsFetched,
21
  } from "../store/slices/dmSlice";
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);
 
35
  activeConversation,
36
  typing: typingMap,
37
  messagesLoading,
38
+ fetchedConversations,
39
  } = useSelector((state) => state.dm);
40
  const appState = useSelector((state) => state.app);
41
 
 
45
  const [dmUser, setDmUser] = useState(null);
46
  const [isTyping, setIsTypingState] = useState(false);
47
  const [sendingMessages, setSendingMessages] = useState({}); // { [tempId]: { content, timestamp } }
48
+ const [isCreatingConversation, setIsCreatingConversation] = useState(false);
49
  const typingTimeoutRef = useRef(null);
50
  const processedMessageIds = useRef(new Set());
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);
60
+
61
  // Build dmUser from selectedDMUser or activeConversation
62
  useEffect(() => {
63
  if (!isDM || !room) {
 
105
  }
106
  }, [room, isDM, selectedDMUser, activeConversation]);
107
 
108
+ // Reset processedMessageIds when conversation changes
109
  useEffect(() => {
110
+ processedMessageIds.current.clear();
111
+ // Clear pending timers
112
+ processedTimers.current.forEach((timer) => clearTimeout(timer));
113
+ processedTimers.current = [];
114
+ }, [activeConversationId]);
115
 
116
+ // Join/leave DM via WebSocket - single effect
 
 
 
 
 
 
 
 
 
 
 
 
117
  useEffect(() => {
118
  if (!isDM || !activeConversationId) return;
119
 
 
125
  };
126
  }, [isDM, activeConversationId, dispatch]);
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
138
+ const activeConversationIdRef = useRef(activeConversationId);
139
+ useEffect(() => {
140
+ activeConversationIdRef.current = activeConversationId;
141
+ }, [activeConversationId]);
142
+
143
+ const currentUserRef = useRef(currentUser);
144
+ useEffect(() => {
145
+ currentUserRef.current = currentUser;
146
+ }, [currentUser]);
147
+
148
+ // Listen to WebSocket events for this DM - register once only
149
+ useEffect(() => {
150
+ if (!isDM) return;
151
 
152
  const handleNewDM = (data) => {
 
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;
160
+ const currentConvId = activeConversationIdRef.current;
161
+
162
+ // Always cache the message regardless of which conversation is active
163
+ dispatch(
164
+ addDMMessage({
165
+ conversationId,
166
+ message: {
167
+ id: data.id,
168
+ conversation_id: conversationId,
169
+ sender_id: data.sender_id,
170
+ content: data.content,
171
+ is_read: data.is_read ?? false,
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.
179
+ // Only mark as fetched after a full API fetch (page 1) so that
180
+ // opening a conversation for the first time still loads historical messages.
181
+
182
+ // If this is the currently active conversation, also update UI state
183
+ if (conversationId === currentConvId) {
184
  setSendingMessages((prev) => {
185
  const next = { ...prev };
186
+ if (data.tempId && next[data.tempId]) {
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
+ });
194
+ }
195
  return next;
196
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  }
198
  };
199
 
200
  const handleDmSent = (data) => {
 
201
  if (data.success && data.message) {
202
+ const currentConvId = activeConversationIdRef.current;
203
  setSendingMessages((prev) => {
204
  const next = { ...prev };
205
+ if (data.tempId && next[data.tempId]) {
206
+ delete next[data.tempId];
207
+ } else {
208
+ Object.keys(next).forEach((key) => {
209
+ if (next[key].content === data.message.content) {
210
+ delete next[key];
211
+ }
212
+ });
213
+ }
214
  return next;
215
  });
216
  dispatch(
217
  addDMMessage({
218
+ conversationId: currentConvId,
219
  message: {
220
  ...data.message,
221
+ conversation_id: currentConvId,
222
  },
223
  })
224
  );
 
226
  };
227
 
228
  const handleDmTyping = (data) => {
229
+ const currentConvId = activeConversationIdRef.current;
230
+ if (data.conversationId === currentConvId) {
231
  if (data.isTyping) {
232
  dispatch(
233
  setTyping({
 
243
  };
244
 
245
  const handleDmRead = (data) => {
246
+ const currentConvId = activeConversationIdRef.current;
247
+ if (data.conversationId === currentConvId) {
248
+ dispatch({
249
+ type: "dm/updateMessage",
250
+ payload: {
251
+ conversationId: currentConvId,
252
+ messageId: data.messageId,
253
+ updates: { is_read: true },
254
+ },
255
+ });
256
  }
257
  };
258
 
 
267
  socketService.off("dmTyping", handleDmTyping);
268
  socketService.off("dmRead", handleDmRead);
269
  };
270
+ }, [isDM, dispatch]);
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
281
  const sortedDmMessages = [...dmMessages].sort((a, b) => {
282
+ const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
283
+ const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
284
+ if (timeA !== timeB) return timeA - timeB;
285
+ // Stable fallback: compare by id (string comparison for UUIDs)
286
+ return String(a.id).localeCompare(String(b.id));
287
  });
288
 
289
  // Convert API messages to UI format
 
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 {
310
  id: msg.id,
 
324
 
325
  // Combine with mock data for non-DM or fallback
326
  const mockMessages = isDM ? directMessages[room] || [] : messages[room] || [];
 
327
  const userMessages = userMessagesMap[room] || [];
328
 
329
  const chatMessages = isDM && activeConversationId
 
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)
 
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",
 
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() },
 
475
  }, 3000);
476
  }, [isDM, activeConversationId, isTyping]);
477
 
478
+ // Cleanup typing on unmount / before unload
479
+ useEffect(() => {
480
+ const handleBeforeUnload = () => {
481
+ if (isDM && activeConversationId) {
482
+ socketService.dmTyping(activeConversationId, false);
483
+ }
484
+ };
485
+ window.addEventListener("beforeunload", handleBeforeUnload);
486
+ return () => {
487
+ window.removeEventListener("beforeunload", handleBeforeUnload);
488
+ // Also clear typing when unmounting
489
+ if (isDM && activeConversationId) {
490
+ socketService.dmTyping(activeConversationId, false);
491
+ }
492
+ };
493
+ }, [isDM, activeConversationId]);
494
+
495
  const handleStopTyping = useCallback(() => {
496
  if (!isDM || !activeConversationId) return;
497
  setIsTypingState(false);
 
517
  onToggleMemberList={onToggleMemberList}
518
  roomListCollapsed={roomListCollapsed}
519
  memberListCollapsed={memberListCollapsed}
520
+ onOpenRoomSettings={onOpenRoomSettings}
521
  />
522
  {/* User Profile Popup */}
523
  {selectedUser && (
 
538
  hasNoSelection={isDM && !dmUser}
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));
 
563
  <ChatInput
564
  isDark={isDark}
565
  placeholder={placeholder}
566
+
 
 
 
567
  onSend={handleSend}
568
  onTyping={handleTyping}
569
  onStopTyping={handleStopTyping}
 
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;
 
593
  return <SettingsView isDark={isDark} />;
594
  }
595
 
596
+ return <ChatArea {...props} onOpenRoomSettings={onOpenRoomSettings} />;
597
  }
598
 
599
  export default ChatAreaWrapper;
src/components/chatarea/ChatHeader.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState } from "react";
2
- import { FiSidebar } from "react-icons/fi";
3
  import { getUserColor } from "../../utils/userColor";
4
 
5
  function getInitials(name) {
@@ -74,6 +74,7 @@ function ChatHeader({
74
  onToggleMemberList,
75
  roomListCollapsed,
76
  memberListCollapsed,
 
77
  }) {
78
  const hasNoSelection = isDM && !dmUser;
79
 
@@ -152,6 +153,18 @@ function ChatHeader({
152
  >
153
  <FiSidebar size={18} style={{ transform: "scaleX(-1)" }} />
154
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
155
  </div>
156
  </div>
157
  );
 
1
  import { useState } from "react";
2
+ import { FiSidebar, FiSettings } from "react-icons/fi";
3
  import { getUserColor } from "../../utils/userColor";
4
 
5
  function getInitials(name) {
 
74
  onToggleMemberList,
75
  roomListCollapsed,
76
  memberListCollapsed,
77
+ onOpenRoomSettings,
78
  }) {
79
  const hasNoSelection = isDM && !dmUser;
80
 
 
153
  >
154
  <FiSidebar size={18} style={{ transform: "scaleX(-1)" }} />
155
  </button>
156
+
157
+ {/* Room Settings — chỉ hiển thị khi ở room (không phải DM) */}
158
+ {!isDM && (
159
+ <button
160
+ onClick={onOpenRoomSettings}
161
+ className="p-1.5 rounded hover:opacity-70 transition-opacity cursor-pointer"
162
+ style={{ color: "var(--text-muted)" }}
163
+ title="Thiết lập room"
164
+ >
165
+ <FiSettings size={18} />
166
+ </button>
167
+ )}
168
  </div>
169
  </div>
170
  );
src/components/chatarea/ChatInput.jsx CHANGED
@@ -24,11 +24,7 @@ function ChatInput({
24
  isDark,
25
  placeholder,
26
  replyTo,
27
- onCancelReply,
28
- editMessage,
29
- onCancelEdit,
30
  onSend,
31
- onEdit,
32
  onTyping,
33
  onStopTyping,
34
  }) {
@@ -43,27 +39,9 @@ function ChatInput({
43
  const containerRef = useRef(null);
44
  const fileInputRef = useRef(null);
45
 
46
- const placeholderText = editMessage
47
- ? "Chỉnh sửa tin nhắn..."
48
- : replyTo
49
- ? `Reply to ${replyTo.sender}...`
50
- : placeholder;
51
 
52
- useEffect(() => {
53
- if (editMessage) {
54
- if (editorRef.current) {
55
- editorRef.current.innerHTML = editMessage.content;
56
- }
57
- setIsEmpty(!editMessage.content);
58
- } else {
59
- if (editorRef.current) {
60
- editorRef.current.innerHTML = "";
61
- }
62
- setMentions([]);
63
- setIsEmpty(true);
64
- }
65
- setShowMentions(false);
66
- }, [editMessage]);
67
 
68
  useEffect(() => {
69
  const handleClickOutside = (e) => {
@@ -180,10 +158,7 @@ function ChatInput({
180
  const handleSend = () => {
181
  const content = getPlainText().trim();
182
  if (content || selectedFiles.length > 0) {
183
- if (editMessage && onEdit) {
184
- onEdit(editMessage.id, content);
185
- if (onCancelEdit) onCancelEdit();
186
- } else if (onSend) {
187
  onSend(content, replyTo, selectedFiles);
188
  if (editorRef.current) {
189
  editorRef.current.innerHTML = "";
@@ -191,7 +166,6 @@ function ChatInput({
191
  setMentions([]);
192
  setSelectedFiles([]);
193
  setIsEmpty(true);
194
- if (onCancelReply) onCancelReply();
195
  }
196
  setShowMentions(false);
197
  if (onStopTyping) onStopTyping();
@@ -384,63 +358,6 @@ function ChatInput({
384
  background: "var(--bg-surface-secondary)",
385
  }}
386
  >
387
- {editMessage && (
388
- <div
389
- className="flex items-center justify-between px-3 py-2 mb-2 rounded-md text-xs"
390
- style={{
391
- background: isDark ? "var(--bg-surface-tertiary)" : "#f0f2f5",
392
- border: "1px solid var(--primary)",
393
- }}
394
- >
395
- <div className="flex items-center gap-2 min-w-0">
396
- <span style={{ color: "var(--primary)", fontWeight: "600" }}>
397
- Chỉnh sửa tin nhắn
398
- </span>
399
- <span
400
- className="truncate"
401
- style={{ color: "var(--text-secondary)" }}
402
- >
403
- {editMessage.content}
404
- </span>
405
- </div>
406
- <button
407
- className="ml-2 shrink-0 p-0.5 rounded hover:bg-opacity-20"
408
- style={{ color: "var(--text-secondary)" }}
409
- onClick={onCancelEdit}
410
- >
411
- <FiX size={14} />
412
- </button>
413
- </div>
414
- )}
415
- {replyTo && !editMessage && (
416
- <div
417
- className="flex items-center justify-between px-3 py-2 mb-2 rounded-md text-xs"
418
- style={{
419
- background: isDark ? "var(--bg-surface-tertiary)" : "#f0f2f5",
420
- border: "1px solid var(--border-primary)",
421
- }}
422
- >
423
- <div className="flex items-center gap-2 min-w-0">
424
- <span style={{ color: "var(--primary)", fontWeight: "600" }}>
425
- Replying to {replyTo.sender}
426
- </span>
427
- <span
428
- className="truncate"
429
- style={{ color: "var(--text-secondary)" }}
430
- >
431
- {replyTo.content}
432
- </span>
433
- </div>
434
- <button
435
- className="ml-2 shrink-0 p-0.5 rounded hover:bg-opacity-20"
436
- style={{ color: "var(--text-secondary)" }}
437
- onClick={onCancelReply}
438
- >
439
- <FiX size={14} />
440
- </button>
441
- </div>
442
- )}
443
-
444
  {/* File attachment preview */}
445
  {selectedFiles.length > 0 && (
446
  <FileAttachmentPreview
@@ -523,23 +440,17 @@ function ChatInput({
523
  type="button"
524
  className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors"
525
  style={{
526
- background: editMessage
527
- ? "var(--secondary-active, #f59e0b)"
528
- : "var(--primary)",
529
  color: isDark ? "var(--bg-surface)" : "#fff",
530
  }}
531
  onMouseEnter={(e) =>
532
- (e.currentTarget.style.background = editMessage
533
- ? "var(--secondary-hover, #d97706)"
534
- : "var(--primary-hover)")
535
  }
536
  onMouseLeave={(e) =>
537
- (e.currentTarget.style.background = editMessage
538
- ? "var(--secondary-active, #f59e0b)"
539
- : "var(--primary)")
540
  }
541
  onClick={handleSend}
542
- title={editMessage ? "Lưu chỉnh sửa" : "Gửi"}
543
  >
544
  <IoSend size={18} />
545
  </button>
 
24
  isDark,
25
  placeholder,
26
  replyTo,
 
 
 
27
  onSend,
 
28
  onTyping,
29
  onStopTyping,
30
  }) {
 
39
  const containerRef = useRef(null);
40
  const fileInputRef = useRef(null);
41
 
42
+ const placeholderText = placeholder;
43
+
 
 
 
44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  useEffect(() => {
47
  const handleClickOutside = (e) => {
 
158
  const handleSend = () => {
159
  const content = getPlainText().trim();
160
  if (content || selectedFiles.length > 0) {
161
+ if (onSend) {
 
 
 
162
  onSend(content, replyTo, selectedFiles);
163
  if (editorRef.current) {
164
  editorRef.current.innerHTML = "";
 
166
  setMentions([]);
167
  setSelectedFiles([]);
168
  setIsEmpty(true);
 
169
  }
170
  setShowMentions(false);
171
  if (onStopTyping) onStopTyping();
 
358
  background: "var(--bg-surface-secondary)",
359
  }}
360
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  {/* File attachment preview */}
362
  {selectedFiles.length > 0 && (
363
  <FileAttachmentPreview
 
440
  type="button"
441
  className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors"
442
  style={{
443
+ background: "var(--primary)",
 
 
444
  color: isDark ? "var(--bg-surface)" : "#fff",
445
  }}
446
  onMouseEnter={(e) =>
447
+ (e.currentTarget.style.background = "var(--primary-hover)")
 
 
448
  }
449
  onMouseLeave={(e) =>
450
+ (e.currentTarget.style.background = "var(--primary)")
 
 
451
  }
452
  onClick={handleSend}
453
+ title="Gửi"
454
  >
455
  <IoSend size={18} />
456
  </button>
src/components/chatarea/ChatMessages.jsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useState, useRef, useEffect } from "react";
2
  import { FiPaperclip } from "react-icons/fi";
3
- import { MessageActions, ReplyPreview, ReactionBar } from "./MessageActions";
4
  import { renderMessageWithMentions } from "./MessageContent";
5
  import FileAttachment from "./FileAttachment";
6
  import { getUserColor } from "../../utils/userColor";
@@ -14,50 +14,19 @@ function ChatMessage({
14
  isSending,
15
  }) {
16
  const senderColor = getUserColor(msg.sender, msg.color);
17
- const [showActions, setShowActions] = useState(false);
18
- const [reactions, setReactions] = useState(msg.reactions || []);
19
- const [showPicker, setShowPicker] = useState(false);
20
  const isOwnMessage = msg.isOwn;
21
-
22
- const handleAddReaction = (emoji) => {
23
- const existing = reactions.find((r) => r.emoji === emoji);
24
- if (existing) {
25
- setReactions(
26
- reactions.map((r) =>
27
- r.emoji === emoji ? { ...r, count: r.count + 1 } : r,
28
- ),
29
- );
30
- } else {
31
- setReactions([...reactions, { emoji, count: 1, users: ["You"] }]);
32
- }
33
- setShowPicker(false);
34
- };
35
 
36
  return (
37
  <div
38
  className="flex gap-3 px-3 py-2 rounded-lg transition-colors relative group"
39
  style={{
40
- background: showActions ? "var(--hover-primary)" : "transparent",
41
  opacity: msg.pending ? 0.6 : 1,
42
  }}
43
- onMouseEnter={() => setShowActions(true)}
44
- onMouseLeave={() => {
45
- setShowActions(false);
46
- setShowPicker(false);
47
- }}
48
  >
49
- {/* Action buttons on hover */}
50
- <MessageActions
51
- show={showActions}
52
- isDark={isDark}
53
- isOwnMessage={isOwnMessage}
54
- onReply={() => onReply(msg)}
55
- onEdit={() => onEdit(msg)}
56
- onReaction={handleAddReaction}
57
- showPicker={showPicker}
58
- onTogglePicker={() => setShowPicker(!showPicker)}
59
- />
60
-
61
  <div
62
  className="w-9 h-9 rounded-lg flex items-center justify-center text-sm font-semibold shrink-0 cursor-pointer"
63
  style={{
@@ -111,8 +80,7 @@ function ChatMessage({
111
  </span>
112
  )}
113
  </div>
114
- {/* Reply preview below sender info */}
115
- <ReplyPreview replyTo={msg.replyTo} isDark={isDark} />
116
  {/* Message content */}
117
  <div
118
  className="text-sm leading-relaxed"
@@ -150,13 +118,7 @@ function ChatMessage({
150
  <FiPaperclip size={14} /> {msg.attachmentName}
151
  </div>
152
  ) : null}
153
- {reactions.length > 0 && (
154
- <ReactionBar
155
- reactions={reactions}
156
- onAddReaction={handleAddReaction}
157
- isDark={isDark}
158
- />
159
- )}
160
  </div>
161
  </div>
162
  );
@@ -308,17 +270,24 @@ function ChatMessages({
308
  hasNoSelection,
309
  sendingMessages,
310
  isLoading,
 
311
  }) {
312
  const messagesContainerRef = useRef(null);
313
  const [isLoadingMore, setIsLoadingMore] = useState(false);
314
  const prevLoadingRef = useRef(isLoading);
315
  const prevMessagesLengthRef = useRef(chatMessages.length);
 
316
 
317
- // Auto scroll to bottom when loading finishes or new messages arrive
318
  useEffect(() => {
319
  const container = messagesContainerRef.current;
320
  if (!container) return;
321
 
 
 
 
 
 
322
  // Scroll to bottom when loading finishes
323
  if (prevLoadingRef.current && !isLoading) {
324
  container.scrollTop = container.scrollHeight;
@@ -331,7 +300,8 @@ function ChatMessages({
331
 
332
  prevLoadingRef.current = isLoading;
333
  prevMessagesLengthRef.current = chatMessages.length;
334
- }, [isLoading, chatMessages.length, isLoadingMore]);
 
335
 
336
  // Handle scroll event to detect when user reaches the top
337
  const handleScroll = (e) => {
@@ -348,7 +318,7 @@ function ChatMessages({
348
 
349
  const hasMessages = chatMessages.length > 0;
350
 
351
- const isEmpty = !hasMessages && !isTyping;
352
 
353
  return (
354
  <div
 
1
  import { useState, useRef, useEffect } from "react";
2
  import { FiPaperclip } from "react-icons/fi";
3
+ import { ReplyPreview } from "./MessageActions";
4
  import { renderMessageWithMentions } from "./MessageContent";
5
  import FileAttachment from "./FileAttachment";
6
  import { getUserColor } from "../../utils/userColor";
 
14
  isSending,
15
  }) {
16
  const senderColor = getUserColor(msg.sender, msg.color);
 
 
 
17
  const isOwnMessage = msg.isOwn;
18
+ const [isHovered, setIsHovered] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  return (
21
  <div
22
  className="flex gap-3 px-3 py-2 rounded-lg transition-colors relative group"
23
  style={{
24
+ background: isHovered ? "var(--hover-primary)" : "transparent",
25
  opacity: msg.pending ? 0.6 : 1,
26
  }}
27
+ onMouseEnter={() => setIsHovered(true)}
28
+ onMouseLeave={() => setIsHovered(false)}
 
 
 
29
  >
 
 
 
 
 
 
 
 
 
 
 
 
30
  <div
31
  className="w-9 h-9 rounded-lg flex items-center justify-center text-sm font-semibold shrink-0 cursor-pointer"
32
  style={{
 
80
  </span>
81
  )}
82
  </div>
83
+
 
84
  {/* Message content */}
85
  <div
86
  className="text-sm leading-relaxed"
 
118
  <FiPaperclip size={14} /> {msg.attachmentName}
119
  </div>
120
  ) : null}
121
+
 
 
 
 
 
 
122
  </div>
123
  </div>
124
  );
 
270
  hasNoSelection,
271
  sendingMessages,
272
  isLoading,
273
+ conversationId,
274
  }) {
275
  const messagesContainerRef = useRef(null);
276
  const [isLoadingMore, setIsLoadingMore] = useState(false);
277
  const prevLoadingRef = useRef(isLoading);
278
  const prevMessagesLengthRef = useRef(chatMessages.length);
279
+ const prevConversationIdRef = useRef(conversationId);
280
 
281
+ // Auto scroll to bottom when loading finishes, new messages arrive, or conversation changes
282
  useEffect(() => {
283
  const container = messagesContainerRef.current;
284
  if (!container) return;
285
 
286
+ // Scroll to bottom when conversation changes
287
+ if (prevConversationIdRef.current !== conversationId) {
288
+ container.scrollTop = container.scrollHeight;
289
+ }
290
+
291
  // Scroll to bottom when loading finishes
292
  if (prevLoadingRef.current && !isLoading) {
293
  container.scrollTop = container.scrollHeight;
 
300
 
301
  prevLoadingRef.current = isLoading;
302
  prevMessagesLengthRef.current = chatMessages.length;
303
+ prevConversationIdRef.current = conversationId;
304
+ }, [isLoading, chatMessages.length, isLoadingMore, conversationId]);
305
 
306
  // Handle scroll event to detect when user reaches the top
307
  const handleScroll = (e) => {
 
318
 
319
  const hasMessages = chatMessages.length > 0;
320
 
321
+ const isEmpty = !hasMessages && !isTyping && !isLoading;
322
 
323
  return (
324
  <div
src/components/chatarea/MessageActions.jsx CHANGED
@@ -1,5 +1,5 @@
1
  import { useState } from "react";
2
- import { FiMessageSquare, FiCornerDownRight, FiEdit2 } from "react-icons/fi";
3
  import { getUserColor } from "../../utils/userColor";
4
 
5
  const quickReactions = ["👍", "❤️", "😂", "😮", "😢", "🔥"];
@@ -96,11 +96,6 @@ export function MessageActions({
96
  show,
97
  isDark,
98
  isOwnMessage,
99
- onReply,
100
- onEdit,
101
- onReaction,
102
- showPicker,
103
- onTogglePicker,
104
  }) {
105
  if (!show) return null;
106
 
@@ -115,58 +110,9 @@ export function MessageActions({
115
  boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
116
  }}
117
  >
118
- {/* Quick reactions */}
119
- {quickReactions.slice(0, 3).map((emoji) => (
120
- <button
121
- key={emoji}
122
- className="w-6 h-6 flex items-center justify-center rounded text-sm hover:scale-125 transition-transform"
123
- onClick={() => onReaction(emoji)}
124
- >
125
- {emoji}
126
- </button>
127
- ))}
128
- {/* Reply button */}
129
- <button
130
- className="w-7 h-7 flex items-center justify-center rounded-full transition-all"
131
- style={{
132
- color: "var(--text-secondary)",
133
- }}
134
- onClick={onReply}
135
- title="Reply"
136
- >
137
- <FiMessageSquare size={14} />
138
- </button>
139
- {/* Edit button (only for own messages) */}
140
- {isOwnMessage && onEdit && (
141
- <button
142
- className="w-7 h-7 flex items-center justify-center rounded-full transition-all"
143
- style={{
144
- color: "var(--text-secondary)",
145
- }}
146
- onClick={onEdit}
147
- title="Edit message"
148
- >
149
- <FiEdit2 size={13} />
150
- </button>
151
- )}
152
- {/* Add reaction button */}
153
- <div className="relative">
154
- <button
155
- className="w-7 h-7 flex items-center justify-center rounded-full transition-all"
156
- style={{
157
- color: "var(--text-secondary)",
158
- }}
159
- onClick={onTogglePicker}
160
- title="Add reaction"
161
- >
162
- <span className="text-sm font-bold">+</span>
163
- </button>
164
- <ReactionPicker
165
- show={showPicker}
166
- onSelect={onReaction}
167
- isDark={isDark}
168
- />
169
- </div>
170
  </div>
171
  );
172
  }
 
1
  import { useState } from "react";
2
+ import { FiCornerDownRight } from "react-icons/fi";
3
  import { getUserColor } from "../../utils/userColor";
4
 
5
  const quickReactions = ["👍", "❤️", "😂", "😮", "😢", "🔥"];
 
96
  show,
97
  isDark,
98
  isOwnMessage,
 
 
 
 
 
99
  }) {
100
  if (!show) return null;
101
 
 
110
  boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
111
  }}
112
  >
113
+ <span className="text-[10px] px-2" style={{ color: "var(--text-muted)" }}>
114
+ Tùy chọn tạm ẩn
115
+ </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  </div>
117
  );
118
  }
src/components/roomlist/DMList.jsx CHANGED
@@ -5,7 +5,7 @@ import { setActiveRoom } from "../../store/slices/appSlice";
5
  import { setSelectedDMUser } from "../../store/slices/chatSlice";
6
  import {
7
  setActiveConversation,
8
- fetchMessages,
9
  } from "../../store/slices/dmSlice";
10
  import { getUserColor } from "../../utils/userColor";
11
  import { DMListHeader, DMListSection } from "./DMListComponents";
@@ -210,7 +210,7 @@ function DMItem({ dm, isDark, isActive, onClick, onAddFriend, isOnline }) {
210
  function DMList({ activeRoom, setActiveRoom: setActiveRoomProp }) {
211
  const dispatch = useDispatch();
212
  const { isDark } = useSelector((state) => state.theme);
213
- const { loading: dmLoading } = useSelector((state) => state.dm);
214
  const [sentRequests, setSentRequests] = useState([]);
215
 
216
  const {
@@ -224,7 +224,8 @@ function DMList({ activeRoom, setActiveRoom: setActiveRoomProp }) {
224
  getUserOnlineStatus,
225
  } = useDMList();
226
 
227
- const isLoading = hookLoading || dmLoading;
 
228
 
229
  const handleNavigateToChat = async (user) => {
230
  dispatch(setSelectedDMUser(user));
@@ -242,13 +243,6 @@ function DMList({ activeRoom, setActiveRoom: setActiveRoomProp }) {
242
  // If user has a conversation object, use it directly
243
  if (user.conversation) {
244
  dispatch(setActiveConversation(user.conversation));
245
- dispatch(
246
- fetchMessages({
247
- conversationId: user.conversation.id,
248
- page: 1,
249
- limit: 50,
250
- }),
251
- );
252
  if (setActiveRoomProp) {
253
  setActiveRoomProp(user.conversation.id);
254
  } else {
@@ -257,8 +251,9 @@ function DMList({ activeRoom, setActiveRoom: setActiveRoomProp }) {
257
  return;
258
  }
259
 
260
- // Lazy create: don't create conversation yet, just set the user as active
261
  // Conversation will be created when first message is sent
 
262
  if (setActiveRoomProp) {
263
  setActiveRoomProp(user.id);
264
  } else {
 
5
  import { setSelectedDMUser } from "../../store/slices/chatSlice";
6
  import {
7
  setActiveConversation,
8
+ clearActiveConversation,
9
  } from "../../store/slices/dmSlice";
10
  import { getUserColor } from "../../utils/userColor";
11
  import { DMListHeader, DMListSection } from "./DMListComponents";
 
210
  function DMList({ activeRoom, setActiveRoom: setActiveRoomProp }) {
211
  const dispatch = useDispatch();
212
  const { isDark } = useSelector((state) => state.theme);
213
+ const { loading: dmLoading, conversations, conversationsFetched } = useSelector((state) => state.dm);
214
  const [sentRequests, setSentRequests] = useState([]);
215
 
216
  const {
 
224
  getUserOnlineStatus,
225
  } = useDMList();
226
 
227
+ // Only show loading if we truly have no data yet
228
+ const isLoading = (hookLoading || dmLoading) && !conversationsFetched && conversations.length === 0;
229
 
230
  const handleNavigateToChat = async (user) => {
231
  dispatch(setSelectedDMUser(user));
 
243
  // If user has a conversation object, use it directly
244
  if (user.conversation) {
245
  dispatch(setActiveConversation(user.conversation));
 
 
 
 
 
 
 
246
  if (setActiveRoomProp) {
247
  setActiveRoomProp(user.conversation.id);
248
  } else {
 
251
  return;
252
  }
253
 
254
+ // Lazy create: clear previous conversation and set the user as active
255
  // Conversation will be created when first message is sent
256
+ dispatch(clearActiveConversation());
257
  if (setActiveRoomProp) {
258
  setActiveRoomProp(user.id);
259
  } else {
src/components/settings/UserProfile.jsx CHANGED
@@ -12,6 +12,9 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
12
  const [userName, setUserName] = useState(
13
  () => localStorage.getItem("userName") || authUser?.name || "Sinh viên",
14
  );
 
 
 
15
  const [userAvatar, setUserAvatar] = useState(
16
  () => localStorage.getItem("userAvatar") || authUser?.avatar || null,
17
  );
@@ -23,11 +26,13 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
23
  );
24
  const [nameError, setNameError] = useState("");
25
 
 
26
  useEffect(() => {
27
  if (authUser) {
28
  if (!localStorage.getItem("userName")) {
29
  setUserName(authUser.name || "Sinh viên");
30
  }
 
31
  if (!localStorage.getItem("userAvatar")) {
32
  setUserAvatar(authUser.avatar || null);
33
  }
@@ -51,13 +56,17 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
51
  }
52
  setNameError("");
53
 
 
 
54
  const trimmedBio = userBio.trim();
55
  const originalName = authUser?.name || "";
 
56
  const originalBio = authUser?.bio || "";
57
  const originalAvatar = authUser?.avatar || null;
58
 
59
  const hasChanged =
60
  trimmedName !== originalName ||
 
61
  trimmedBio !== originalBio ||
62
  userAvatar !== originalAvatar;
63
 
@@ -66,6 +75,7 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
66
  }
67
 
68
  localStorage.setItem("userName", trimmedName);
 
69
  localStorage.setItem("userAvatar", userAvatar);
70
  localStorage.setItem("userBio", trimmedBio);
71
  if (usernameColor) {
@@ -82,6 +92,7 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
82
  bio: trimmedBio || null,
83
  avatar: userAvatar || null,
84
  };
 
85
  const { data } = await authService.updateProfile(payload);
86
  if (data?.user) {
87
  dispatch(updateProfileSuccess(data.user));
@@ -116,39 +127,64 @@ const UserProfile = forwardRef(function UserProfile(_, ref) {
116
  >
117
  {userAvatar || userName.charAt(0) || "S"}
118
  </div>
119
- <div className="flex-1">
120
- <label
121
- className="text-xs font-medium mb-1 block"
122
- style={{ color: "var(--text-secondary)" }}
123
- >
124
- Tên hiển thị <span style={{ color: "var(--danger)" }}>*</span>
125
- </label>
126
- <input
127
- type="text"
128
- value={userName}
129
- onChange={(e) => {
130
- setUserName(e.target.value);
131
- if (nameError) setNameError("");
132
- }}
133
- placeholder="Nhập tên hiển thị"
134
- className="w-full px-3 py-2 rounded-md text-sm border outline-none"
135
- style={{
136
- background: "var(--input-bg)",
137
- borderColor: nameError ? "var(--danger)" : "var(--input-border)",
138
- color: "var(--input-text)",
139
- }}
140
- onFocus={(e) =>
141
- (e.currentTarget.style.borderColor = "var(--primary)")
142
- }
143
- onBlur={(e) =>
144
- (e.currentTarget.style.borderColor = nameError ? "var(--danger)" : "var(--input-border)")
145
- }
146
- />
147
- {nameError && (
148
- <div className="text-xs mt-1" style={{ color: "var(--danger)" }}>
149
- {nameError}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  </div>
151
- )}
 
 
 
152
  </div>
153
  </div>
154
  <div>
 
12
  const [userName, setUserName] = useState(
13
  () => localStorage.getItem("userName") || authUser?.name || "Sinh viên",
14
  );
15
+ const [username] = useState(
16
+ () => localStorage.getItem("username") || authUser?.username || "",
17
+ );
18
  const [userAvatar, setUserAvatar] = useState(
19
  () => localStorage.getItem("userAvatar") || authUser?.avatar || null,
20
  );
 
26
  );
27
  const [nameError, setNameError] = useState("");
28
 
29
+
30
  useEffect(() => {
31
  if (authUser) {
32
  if (!localStorage.getItem("userName")) {
33
  setUserName(authUser.name || "Sinh viên");
34
  }
35
+
36
  if (!localStorage.getItem("userAvatar")) {
37
  setUserAvatar(authUser.avatar || null);
38
  }
 
56
  }
57
  setNameError("");
58
 
59
+
60
+
61
  const trimmedBio = userBio.trim();
62
  const originalName = authUser?.name || "";
63
+ const originalUsername = authUser?.username || "";
64
  const originalBio = authUser?.bio || "";
65
  const originalAvatar = authUser?.avatar || null;
66
 
67
  const hasChanged =
68
  trimmedName !== originalName ||
69
+
70
  trimmedBio !== originalBio ||
71
  userAvatar !== originalAvatar;
72
 
 
75
  }
76
 
77
  localStorage.setItem("userName", trimmedName);
78
+ localStorage.setItem("username", username);
79
  localStorage.setItem("userAvatar", userAvatar);
80
  localStorage.setItem("userBio", trimmedBio);
81
  if (usernameColor) {
 
92
  bio: trimmedBio || null,
93
  avatar: userAvatar || null,
94
  };
95
+
96
  const { data } = await authService.updateProfile(payload);
97
  if (data?.user) {
98
  dispatch(updateProfileSuccess(data.user));
 
127
  >
128
  {userAvatar || userName.charAt(0) || "S"}
129
  </div>
130
+ <div className="flex-1 space-y-3">
131
+ <div>
132
+ <label
133
+ className="text-xs font-medium mb-1 block"
134
+ style={{ color: "var(--text-secondary)" }}
135
+ >
136
+ Tên hiển thị <span style={{ color: "var(--danger)" }}>*</span>
137
+ </label>
138
+ <input
139
+ type="text"
140
+ value={userName}
141
+ onChange={(e) => {
142
+ setUserName(e.target.value);
143
+ if (nameError) setNameError("");
144
+ }}
145
+ placeholder="Nhập tên hiển thị"
146
+ className="w-full px-3 py-2 rounded-md text-sm border outline-none"
147
+ style={{
148
+ background: "var(--input-bg)",
149
+ borderColor: nameError ? "var(--danger)" : "var(--input-border)",
150
+ color: "var(--input-text)",
151
+ }}
152
+ onFocus={(e) =>
153
+ (e.currentTarget.style.borderColor = "var(--primary)")
154
+ }
155
+ onBlur={(e) =>
156
+ (e.currentTarget.style.borderColor = nameError ? "var(--danger)" : "var(--input-border)")
157
+ }
158
+ />
159
+ {nameError && (
160
+ <div className="text-xs mt-1" style={{ color: "var(--danger)" }}>
161
+ {nameError}
162
+ </div>
163
+ )}
164
+ </div>
165
+ <div>
166
+ <label
167
+ className="text-xs font-medium mb-1 block"
168
+ style={{ color: "var(--text-secondary)" }}
169
+ >
170
+ Username
171
+ </label>
172
+ <div
173
+ className="w-full px-3 py-2 rounded-md text-sm border"
174
+ style={{
175
+ background: "var(--input-bg)",
176
+ borderColor: "var(--input-border)",
177
+ color: "var(--text-muted)",
178
+ cursor: "default",
179
+ userSelect: "text",
180
+ }}
181
+ >
182
+ {username || "Chưa có username"}
183
  </div>
184
+ <div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
185
+ Dùng để mention và phân biệt ngườidùng.
186
+ </div>
187
+ </div>
188
  </div>
189
  </div>
190
  <div>
src/hooks/useDMList.js CHANGED
@@ -42,7 +42,7 @@ function matchesStudyBot(query) {
42
 
43
  export function useDMList() {
44
  const dispatch = useDispatch();
45
- const { conversations, onlineUsers } = useSelector((state) => state.dm);
46
 
47
  const [searchResults, setSearchResults] = useState([]);
48
  const [searchQuery, setSearchQuery] = useState("");
@@ -53,10 +53,11 @@ export function useDMList() {
53
  const statusPollingRef = useRef(null);
54
  const processedMessageIds = useRef(new Set());
55
 
56
- // Fetch conversations on mount
57
  useEffect(() => {
 
58
  dispatch(fetchConversations());
59
- }, [dispatch]);
60
 
61
  // Listen to realtime updates via WebSocket
62
  useEffect(() => {
@@ -214,7 +215,7 @@ export function useDMList() {
214
  return () => {
215
  mounted = false;
216
  };
217
- }, [searchQuery]);
218
 
219
  // Polling online status for visible users
220
  const fetchStatuses = useCallback(async (userIds) => {
@@ -257,8 +258,18 @@ export function useDMList() {
257
  };
258
  }, [conversations, searchResults, searchQuery, fetchStatuses]);
259
 
260
- // Normalize conversations for UI
261
- const normalizedConversations = conversations.map((conv) => ({
 
 
 
 
 
 
 
 
 
 
262
  id: conv.id,
263
  userId: conv.other_user?.id,
264
  name: conv.other_user?.display_name || "Unknown",
@@ -271,7 +282,7 @@ export function useDMList() {
271
  email: conv.other_user?.email || "",
272
  mutualFriends: 0,
273
  conversation: conv,
274
- }));
275
 
276
  const filteredConversations = searchQuery.trim()
277
  ? normalizedConversations.filter((dm) =>
 
42
 
43
  export function useDMList() {
44
  const dispatch = useDispatch();
45
+ const { conversations, onlineUsers, conversationsFetched } = useSelector((state) => state.dm);
46
 
47
  const [searchResults, setSearchResults] = useState([]);
48
  const [searchQuery, setSearchQuery] = useState("");
 
53
  const statusPollingRef = useRef(null);
54
  const processedMessageIds = useRef(new Set());
55
 
56
+ // Fetch conversations on mount — only if not already fetched
57
  useEffect(() => {
58
+ if (conversationsFetched) return; // Skip: already cached
59
  dispatch(fetchConversations());
60
+ }, [dispatch, conversationsFetched]);
61
 
62
  // Listen to realtime updates via WebSocket
63
  useEffect(() => {
 
215
  return () => {
216
  mounted = false;
217
  };
218
+ }, [searchQuery, dispatch]);
219
 
220
  // Polling online status for visible users
221
  const fetchStatuses = useCallback(async (userIds) => {
 
258
  };
259
  }, [conversations, searchResults, searchQuery, fetchStatuses]);
260
 
261
+ // Normalize conversations for UI and sort by latest message (newest first)
262
+ const normalizedConversations = [...conversations]
263
+ .sort((a, b) => {
264
+ const timeA = a.last_message?.created_at
265
+ ? new Date(a.last_message.created_at).getTime()
266
+ : 0;
267
+ const timeB = b.last_message?.created_at
268
+ ? new Date(b.last_message.created_at).getTime()
269
+ : 0;
270
+ return timeB - timeA; // Descending: newest first
271
+ })
272
+ .map((conv) => ({
273
  id: conv.id,
274
  userId: conv.other_user?.id,
275
  name: conv.other_user?.display_name || "Unknown",
 
282
  email: conv.other_user?.email || "",
283
  mutualFriends: 0,
284
  conversation: conv,
285
+ }));
286
 
287
  const filteredConversations = searchQuery.trim()
288
  ? normalizedConversations.filter((dm) =>
src/pages/LoginPage.jsx CHANGED
@@ -4,6 +4,7 @@ import {
4
  FiMail,
5
  FiLock,
6
  FiUser,
 
7
  FiEye,
8
  FiEyeOff,
9
  FiLoader,
@@ -41,14 +42,32 @@ function LoginPage() {
41
  const [showPassword, setShowPassword] = useState(false);
42
  const [form, setForm] = useState({
43
  displayName: "",
 
44
  email: "",
45
  password: "",
46
  });
47
  const dispatch = useDispatch();
48
  const { loading, error } = useSelector((state) => state.auth);
 
49
 
50
- const handleChange = (e) =>
51
- setForm({ ...form, [e.target.name]: e.target.value });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
  const handleSubmit = async (e) => {
54
  e.preventDefault();
@@ -56,13 +75,22 @@ function LoginPage() {
56
  if (isLogin) {
57
  dispatch(login({ email: form.email, password: form.password }));
58
  } else {
59
- dispatch(
60
- register({
61
- displayName: form.displayName,
62
- email: form.email,
63
- password: form.password,
64
- }),
65
- );
 
 
 
 
 
 
 
 
 
66
  }
67
  };
68
 
@@ -239,26 +267,53 @@ function LoginPage() {
239
  {/* Form */}
240
  <form onSubmit={handleSubmit} className="space-y-4">
241
  {!isLogin && (
242
- <div className="group">
243
- <label className="block text-xs font-semibold text-slate-600 mb-1.5 ml-1">
244
- Họ tên
245
- </label>
246
- <div className="relative">
247
- <FiUser
248
- className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-indigo-500"
249
- size={18}
250
- />
251
- <input
252
- name="displayName"
253
- type="text"
254
- required={!isLogin}
255
- value={form.displayName}
256
- onChange={handleChange}
257
- className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 text-slate-800 text-sm outline-none transition-all focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200/50 focus:bg-white placeholder:text-slate-400"
258
- placeholder="Nguyễn Văn A"
259
- />
 
 
260
  </div>
261
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  )}
263
 
264
  <div className="group">
 
4
  FiMail,
5
  FiLock,
6
  FiUser,
7
+ FiAtSign,
8
  FiEye,
9
  FiEyeOff,
10
  FiLoader,
 
42
  const [showPassword, setShowPassword] = useState(false);
43
  const [form, setForm] = useState({
44
  displayName: "",
45
+ username: "",
46
  email: "",
47
  password: "",
48
  });
49
  const dispatch = useDispatch();
50
  const { loading, error } = useSelector((state) => state.auth);
51
+ const [usernameError, setUsernameError] = useState("");
52
 
53
+ const validateUsername = (value) => {
54
+ if (!value) return "";
55
+ if (value.length < 3) return "Username phải có ít nhất 3 ký tự";
56
+ if (value.length > 30) return "Username tối đa 30 ký tự";
57
+ if (!/^[a-z0-9_-]+$/.test(value)) return "Username chỉ chứa a-z, 0-9, _, -";
58
+ return "";
59
+ };
60
+
61
+ const handleChange = (e) => {
62
+ const { name, value } = e.target;
63
+ if (name === "username") {
64
+ const lowerValue = value.toLowerCase();
65
+ setForm({ ...form, [name]: lowerValue });
66
+ if (usernameError) setUsernameError("");
67
+ } else {
68
+ setForm({ ...form, [name]: value });
69
+ }
70
+ };
71
 
72
  const handleSubmit = async (e) => {
73
  e.preventDefault();
 
75
  if (isLogin) {
76
  dispatch(login({ email: form.email, password: form.password }));
77
  } else {
78
+ const usernameValidation = validateUsername(form.username);
79
+ if (usernameValidation) {
80
+ setUsernameError(usernameValidation);
81
+ return;
82
+ }
83
+ const payload = {
84
+ displayName: form.displayName,
85
+ email: form.email,
86
+ password: form.password,
87
+ };
88
+ const trimmedUsername = form.username.trim();
89
+ if (trimmedUsername) {
90
+ payload.username = trimmedUsername;
91
+ }
92
+ console.log("Register payload:", JSON.stringify(payload, null, 2));
93
+ dispatch(register(payload));
94
  }
95
  };
96
 
 
267
  {/* Form */}
268
  <form onSubmit={handleSubmit} className="space-y-4">
269
  {!isLogin && (
270
+ <>
271
+ <div className="group">
272
+ <label className="block text-xs font-semibold text-slate-600 mb-1.5 ml-1">
273
+ Họ và tên
274
+ </label>
275
+ <div className="relative">
276
+ <FiUser
277
+ className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-indigo-500"
278
+ size={18}
279
+ />
280
+ <input
281
+ name="displayName"
282
+ type="text"
283
+ required={!isLogin}
284
+ value={form.displayName}
285
+ onChange={handleChange}
286
+ className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 text-slate-800 text-sm outline-none transition-all focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200/50 focus:bg-white placeholder:text-slate-400"
287
+ placeholder="Nguyễn Văn A"
288
+ />
289
+ </div>
290
  </div>
291
+ <div className="group">
292
+ <label className="block text-xs font-semibold text-slate-600 mb-1.5 ml-1">
293
+ Username
294
+ </label>
295
+ <div className="relative">
296
+ <FiAtSign
297
+ className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400 transition-colors group-focus-within:text-indigo-500"
298
+ size={18}
299
+ />
300
+ <input
301
+ name="username"
302
+ type="text"
303
+ value={form.username}
304
+ onChange={handleChange}
305
+ className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 text-slate-800 text-sm outline-none transition-all focus:border-indigo-400 focus:ring-2 focus:ring-indigo-200/50 focus:bg-white placeholder:text-slate-400"
306
+ placeholder="nguyenvana (tùy chọn)"
307
+ />
308
+ </div>
309
+ {usernameError && (
310
+ <p className="text-xs text-red-500 mt-1 ml-1">{usernameError}</p>
311
+ )}
312
+ <p className="text-xs text-slate-400 mt-1 ml-1">
313
+ Tùy chọn. Nếu để trống, hệ thống sẽ tự tạo username từ email.
314
+ </p>
315
+ </div>
316
+ </>
317
  )}
318
 
319
  <div className="group">
src/services/api.js CHANGED
@@ -99,10 +99,7 @@ async function silentRefresh() {
99
  localStorage.setItem("refreshToken", data.refreshToken);
100
  }
101
  } catch {
102
- clearAccessToken();
103
- if (typeof window !== "undefined") {
104
- window.location.reload();
105
- }
106
  }
107
  }
108
 
@@ -129,6 +126,10 @@ export const clearAccessToken = () => {
129
 
130
  export const clearAuth = () => {
131
  clearAccessToken();
 
 
 
 
132
  if (typeof window !== "undefined") {
133
  localStorage.removeItem("refreshToken");
134
  localStorage.removeItem("auth_user");
 
99
  localStorage.setItem("refreshToken", data.refreshToken);
100
  }
101
  } catch {
102
+ clearAuth();
 
 
 
103
  }
104
  }
105
 
 
126
 
127
  export const clearAuth = () => {
128
  clearAccessToken();
129
+ if (refreshTimer) {
130
+ clearTimeout(refreshTimer);
131
+ refreshTimer = null;
132
+ }
133
  if (typeof window !== "undefined") {
134
  localStorage.removeItem("refreshToken");
135
  localStorage.removeItem("auth_user");
src/services/socket.service.js CHANGED
@@ -7,6 +7,7 @@ class SocketService {
7
  this.socket = null;
8
  this.listeners = new Map();
9
  this._connected = false;
 
10
  }
11
 
12
  // ==================== Connection ====================
@@ -45,7 +46,10 @@ class SocketService {
45
  });
46
 
47
  this.socket.on("reconnect", (attemptNumber) => {
48
- // Socket reconnected
 
 
 
49
  });
50
 
51
  this.socket.on("reconnect_error", (error) => {
@@ -81,15 +85,22 @@ class SocketService {
81
  // ==================== DM Room Events ====================
82
 
83
  joinDM(conversationId) {
 
 
84
  this.socket?.emit("joinDM", { conversationId });
85
  }
86
 
87
  leaveDM(conversationId) {
 
 
88
  this.socket?.emit("leaveDM", { conversationId });
89
  }
90
 
91
- sendDM(conversationId, content) {
92
- this.socket?.emit("sendDM", { conversationId, content });
 
 
 
93
  }
94
 
95
  dmTyping(conversationId, isTyping) {
 
7
  this.socket = null;
8
  this.listeners = new Map();
9
  this._connected = false;
10
+ this._activeDMRooms = new Set();
11
  }
12
 
13
  // ==================== Connection ====================
 
46
  });
47
 
48
  this.socket.on("reconnect", (attemptNumber) => {
49
+ // Socket reconnected - re-join active DM rooms
50
+ this._activeDMRooms.forEach((conversationId) => {
51
+ this.joinDM(conversationId);
52
+ });
53
  });
54
 
55
  this.socket.on("reconnect_error", (error) => {
 
85
  // ==================== DM Room Events ====================
86
 
87
  joinDM(conversationId) {
88
+ if (!conversationId) return;
89
+ this._activeDMRooms.add(conversationId);
90
  this.socket?.emit("joinDM", { conversationId });
91
  }
92
 
93
  leaveDM(conversationId) {
94
+ if (!conversationId) return;
95
+ this._activeDMRooms.delete(conversationId);
96
  this.socket?.emit("leaveDM", { conversationId });
97
  }
98
 
99
+ sendDM(conversationId, content, tempId) {
100
+ if (!conversationId) return;
101
+ const payload = { conversationId, content };
102
+ if (tempId) payload.tempId = tempId;
103
+ this.socket?.emit("sendDM", payload);
104
  }
105
 
106
  dmTyping(conversationId, isTyping) {
src/store/slices/authSlice.js CHANGED
@@ -86,9 +86,9 @@ export const login = createAsyncThunk(
86
 
87
  export const register = createAsyncThunk(
88
  "auth/register",
89
- async ({ displayName, email, password, avatar }, { rejectWithValue }) => {
90
  try {
91
- const payload = { displayName, email, password };
92
  if (avatar) payload.avatar = avatar;
93
  const { data } = await authService.register(payload);
94
  const user = { ...data.user, name: data.user.displayName };
 
86
 
87
  export const register = createAsyncThunk(
88
  "auth/register",
89
+ async ({ displayName, username, email, password, avatar }, { rejectWithValue }) => {
90
  try {
91
+ const payload = { displayName, username, email, password };
92
  if (avatar) payload.avatar = avatar;
93
  const { data } = await authService.register(payload);
94
  const user = { ...data.user, name: data.user.displayName };
src/store/slices/dmSlice.js CHANGED
@@ -66,6 +66,8 @@ const initialState = {
66
  typing: {}, // { [conversationId]: { userId, isTyping, timestamp } }
67
  onlineUsers: [],
68
  unreadCounts: {}, // { [conversationId]: number }
 
 
69
  loading: false,
70
  messagesLoading: false,
71
  error: null,
@@ -79,6 +81,7 @@ const dmSlice = createSlice({
79
  setActiveConversation: (state, action) => {
80
  state.activeConversationId = action.payload?.id || null;
81
  state.activeConversation = action.payload || null;
 
82
  },
83
 
84
  clearActiveConversation: (state) => {
@@ -212,6 +215,15 @@ const dmSlice = createSlice({
212
 
213
  clearMessages: (state, action) => {
214
  delete state.messages[action.payload];
 
 
 
 
 
 
 
 
 
215
  },
216
 
217
  resetDMState: () => initialState,
@@ -262,14 +274,27 @@ const dmSlice = createSlice({
262
  })
263
  .addCase(fetchMessages.fulfilled, (state, action) => {
264
  state.messagesLoading = false;
265
- const { conversationId, messages } = action.payload;
266
  if (!state.messages[conversationId]) {
267
  state.messages[conversationId] = [];
268
  }
269
- // Merge without dupes
270
- const existingIds = new Set(state.messages[conversationId].map((m) => m.id));
 
271
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
272
- state.messages[conversationId] = [...state.messages[conversationId], ...newMessages];
 
 
 
 
 
 
 
 
 
 
 
 
273
  })
274
  .addCase(fetchMessages.rejected, (state, action) => {
275
  state.messagesLoading = false;
@@ -304,6 +329,8 @@ export const {
304
  updateConversationLastMessage,
305
  setUnreadCount,
306
  clearMessages,
 
 
307
  resetDMState,
308
  clearError,
309
  } = dmSlice.actions;
 
66
  typing: {}, // { [conversationId]: { userId, isTyping, timestamp } }
67
  onlineUsers: [],
68
  unreadCounts: {}, // { [conversationId]: number }
69
+ fetchedConversations: {}, // { [conversationId]: boolean } Track which conversations have been fetched (page 1)
70
+ conversationsFetched: false, // Track if conversations list has been fetched at least once
71
  loading: false,
72
  messagesLoading: false,
73
  error: null,
 
81
  setActiveConversation: (state, action) => {
82
  state.activeConversationId = action.payload?.id || null;
83
  state.activeConversation = action.payload || null;
84
+ state.messagesLoading = false; // Reset loading when switching conversations
85
  },
86
 
87
  clearActiveConversation: (state) => {
 
215
 
216
  clearMessages: (state, action) => {
217
  delete state.messages[action.payload];
218
+ delete state.fetchedConversations[action.payload];
219
+ },
220
+
221
+ setConversationsFetched: (state, action) => {
222
+ state.conversationsFetched = action.payload;
223
+ },
224
+
225
+ markConversationAsFetched: (state, action) => {
226
+ state.fetchedConversations[action.payload] = true;
227
  },
228
 
229
  resetDMState: () => initialState,
 
274
  })
275
  .addCase(fetchMessages.fulfilled, (state, action) => {
276
  state.messagesLoading = false;
277
+ const { conversationId, messages, meta } = action.payload;
278
  if (!state.messages[conversationId]) {
279
  state.messages[conversationId] = [];
280
  }
281
+ const page = meta?.page || 1;
282
+ const existing = state.messages[conversationId];
283
+ const existingIds = new Set(existing.map((m) => m.id));
284
  const newMessages = messages.filter((m) => !existingIds.has(m.id));
285
+
286
+ if (page === 1) {
287
+ // Initial load: merge new messages into existing
288
+ // Keep ALL existing messages (including real ones from WebSocket)
289
+ // Only add messages from API that don't already exist
290
+ state.messages[conversationId] = [...existing, ...newMessages];
291
+ } else {
292
+ // Load more: prepend older messages
293
+ state.messages[conversationId] = [...newMessages, ...existing];
294
+ }
295
+
296
+ // Mark this conversation as fetched (page 1)
297
+ state.fetchedConversations[conversationId] = true;
298
  })
299
  .addCase(fetchMessages.rejected, (state, action) => {
300
  state.messagesLoading = false;
 
329
  updateConversationLastMessage,
330
  setUnreadCount,
331
  clearMessages,
332
+ markConversationAsFetched,
333
+ setConversationsFetched,
334
  resetDMState,
335
  clearError,
336
  } = dmSlice.actions;