Spaces:
Sleeping
Sleeping
Commit ·
b4bf04d
1
Parent(s): aa61a49
đã fix rất nhiều vào tối thứ 5
Browse files- src/App.jsx +60 -0
- src/components/ChatArea.jsx +157 -88
- src/components/chatarea/ChatHeader.jsx +14 -1
- src/components/chatarea/ChatInput.jsx +7 -96
- src/components/chatarea/ChatMessages.jsx +18 -48
- src/components/chatarea/MessageActions.jsx +4 -58
- src/components/roomlist/DMList.jsx +6 -11
- src/components/settings/UserProfile.jsx +68 -32
- src/hooks/useDMList.js +18 -7
- src/pages/LoginPage.jsx +83 -28
- src/services/api.js +5 -4
- src/services/socket.service.js +14 -3
- src/store/slices/authSlice.js +2 -2
- src/store/slices/dmSlice.js +31 -4
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 {
|
| 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 |
-
//
|
| 105 |
useEffect(() => {
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
| 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 |
-
//
|
| 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 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
setSendingMessages((prev) => {
|
| 147 |
const next = { ...prev };
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 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 |
-
|
| 176 |
setSendingMessages((prev) => {
|
| 177 |
const next = { ...prev };
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
return next;
|
| 184 |
});
|
| 185 |
dispatch(
|
| 186 |
addDMMessage({
|
| 187 |
-
conversationId:
|
| 188 |
message: {
|
| 189 |
...data.message,
|
| 190 |
-
conversation_id:
|
| 191 |
},
|
| 192 |
})
|
| 193 |
);
|
|
@@ -195,7 +226,8 @@ function ChatArea({ activeView, activeRoom, onToggleRoomList, onToggleMemberList
|
|
| 195 |
};
|
| 196 |
|
| 197 |
const handleDmTyping = (data) => {
|
| 198 |
-
|
|
|
|
| 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 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 232 |
|
| 233 |
// Build messages for display
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
| 236 |
: [];
|
| 237 |
|
| 238 |
// Sort messages by created_at ascending (oldest first, newest last)
|
|
|
|
| 239 |
const sortedDmMessages = [...dmMessages].sort((a, b) => {
|
| 240 |
-
const
|
| 241 |
-
const
|
| 242 |
-
return
|
|
|
|
|
|
|
| 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
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 464 |
-
|
| 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 |
-
|
| 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 =
|
| 47 |
-
|
| 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 (
|
| 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:
|
| 527 |
-
? "var(--secondary-active, #f59e0b)"
|
| 528 |
-
: "var(--primary)",
|
| 529 |
color: isDark ? "var(--bg-surface)" : "#fff",
|
| 530 |
}}
|
| 531 |
onMouseEnter={(e) =>
|
| 532 |
-
(e.currentTarget.style.background =
|
| 533 |
-
? "var(--secondary-hover, #d97706)"
|
| 534 |
-
: "var(--primary-hover)")
|
| 535 |
}
|
| 536 |
onMouseLeave={(e) =>
|
| 537 |
-
(e.currentTarget.style.background =
|
| 538 |
-
? "var(--secondary-active, #f59e0b)"
|
| 539 |
-
: "var(--primary)")
|
| 540 |
}
|
| 541 |
onClick={handleSend}
|
| 542 |
-
title=
|
| 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 {
|
| 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:
|
| 41 |
opacity: msg.pending ? 0.6 : 1,
|
| 42 |
}}
|
| 43 |
-
onMouseEnter={() =>
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
| 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 {
|
| 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 |
-
{
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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:
|
| 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 |
-
<
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
(e
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
};
|
| 68 |
|
|
@@ -239,26 +267,53 @@ function LoginPage() {
|
|
| 239 |
{/* Form */}
|
| 240 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 241 |
{!isLogin && (
|
| 242 |
-
<
|
| 243 |
-
<
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
<
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
| 260 |
</div>
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 270 |
-
const
|
|
|
|
| 271 |
const newMessages = messages.filter((m) => !existingIds.has(m.id));
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|