Spaces:
Running
Running
Commit ·
880ab03
1
Parent(s): 57f5158
feat(ui): space icons, chat improvements, StudyBot mentions
Browse files- Add space icon id to create space payload
- Display space icon in sidebar and room list header
- Show welcome state when no room/DM selected (disable input)
- Skip loading skeleton for rooms/DMs with no last_message
- Detect StudyBot messages and render with bot styling
- Add StudyBot to @ mention suggestions
- src/App.jsx +34 -3
- src/components/ChatArea.jsx +73 -13
- src/components/Sidebar.jsx +1 -1
- src/components/chatarea/ChatHeader.jsx +12 -11
- src/components/chatarea/ChatInput.jsx +25 -13
- src/components/chatarea/ChatMessages.jsx +21 -14
- src/components/createspace/CreateSpace.jsx +27 -58
- src/components/roomlist/SpaceRoomList.jsx +36 -18
- src/store/slices/dmSlice.js +18 -0
- src/store/slices/spaceSlice.js +80 -0
src/App.jsx
CHANGED
|
@@ -27,6 +27,11 @@ import {
|
|
| 27 |
preloadAllData,
|
| 28 |
} from "./store/slices/dmSlice";
|
| 29 |
import { addMessage } from "./store/slices/messageSlice";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
import socketService from "./services/socket.service";
|
| 31 |
|
| 32 |
function App() {
|
|
@@ -233,10 +238,17 @@ function App() {
|
|
| 233 |
const senderId = data.user_id || data.senderId;
|
| 234 |
const id = data.id;
|
| 235 |
const tempId = data.tempId;
|
| 236 |
-
|
| 237 |
if (!roomId || !content) return;
|
| 238 |
-
|
| 239 |
console.log("[App] newMessage parsed:", { roomId, id, tempId, author, senderId });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
dispatch(
|
| 241 |
addMessage({
|
| 242 |
roomId,
|
|
@@ -251,11 +263,30 @@ function App() {
|
|
| 251 |
content,
|
| 252 |
created_at: data.created_at || new Date().toISOString(),
|
| 253 |
isPinned: data.is_pinned || false,
|
| 254 |
-
isOwn:
|
| 255 |
tempId,
|
| 256 |
},
|
| 257 |
}),
|
| 258 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
};
|
| 260 |
|
| 261 |
socketService.onConnected(handleConnected);
|
|
|
|
| 27 |
preloadAllData,
|
| 28 |
} from "./store/slices/dmSlice";
|
| 29 |
import { addMessage } from "./store/slices/messageSlice";
|
| 30 |
+
import {
|
| 31 |
+
updateRoomLastMessage,
|
| 32 |
+
incrementRoomUnreadCount,
|
| 33 |
+
addJoinedRoom,
|
| 34 |
+
} from "./store/slices/spaceSlice";
|
| 35 |
import socketService from "./services/socket.service";
|
| 36 |
|
| 37 |
function App() {
|
|
|
|
| 238 |
const senderId = data.user_id || data.senderId;
|
| 239 |
const id = data.id;
|
| 240 |
const tempId = data.tempId;
|
| 241 |
+
|
| 242 |
if (!roomId || !content) return;
|
| 243 |
+
|
| 244 |
console.log("[App] newMessage parsed:", { roomId, id, tempId, author, senderId });
|
| 245 |
+
|
| 246 |
+
const state = store.getState();
|
| 247 |
+
const currentActiveRoom = state.app.activeRoom;
|
| 248 |
+
const currentUserId = state.auth.user?.id;
|
| 249 |
+
const isOwnMessage = String(senderId) === String(currentUserId);
|
| 250 |
+
|
| 251 |
+
// 1. Save message to Redux
|
| 252 |
dispatch(
|
| 253 |
addMessage({
|
| 254 |
roomId,
|
|
|
|
| 263 |
content,
|
| 264 |
created_at: data.created_at || new Date().toISOString(),
|
| 265 |
isPinned: data.is_pinned || false,
|
| 266 |
+
isOwn: isOwnMessage,
|
| 267 |
tempId,
|
| 268 |
},
|
| 269 |
}),
|
| 270 |
);
|
| 271 |
+
|
| 272 |
+
// 2. Update room's last_message in spaceSlice (for RoomList display)
|
| 273 |
+
dispatch(
|
| 274 |
+
updateRoomLastMessage({
|
| 275 |
+
roomId,
|
| 276 |
+
message: {
|
| 277 |
+
id: id || Date.now(),
|
| 278 |
+
content,
|
| 279 |
+
created_at: data.created_at || new Date().toISOString(),
|
| 280 |
+
sender_id: senderId,
|
| 281 |
+
sender: author,
|
| 282 |
+
},
|
| 283 |
+
}),
|
| 284 |
+
);
|
| 285 |
+
|
| 286 |
+
// 3. Increment unread count if not viewing this room and not own message
|
| 287 |
+
if (roomId !== currentActiveRoom && !isOwnMessage) {
|
| 288 |
+
dispatch(incrementRoomUnreadCount({ roomId }));
|
| 289 |
+
}
|
| 290 |
};
|
| 291 |
|
| 292 |
socketService.onConnected(handleConnected);
|
src/components/ChatArea.jsx
CHANGED
|
@@ -20,7 +20,10 @@ import {
|
|
| 20 |
} from "../store/slices/dmSlice";
|
| 21 |
import { fetchRoomMessages } from "../store/slices/messageSlice";
|
| 22 |
import { addMessage } from "../store/slices/messageSlice";
|
| 23 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 24 |
import socketService from "../services/socket.service";
|
| 25 |
|
| 26 |
function ChatArea({
|
|
@@ -360,26 +363,45 @@ function ChatArea({
|
|
| 360 |
return String(a.id).localeCompare(String(b.id));
|
| 361 |
});
|
| 362 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
// Convert API messages to unified UI format
|
| 364 |
const chatMessages = sortedMessages.map((msg) => {
|
| 365 |
const senderId = msg.sender_id || msg.senderId || msg.sender?.id;
|
| 366 |
const isOwn = String(senderId) === String(currentUser?.id);
|
|
|
|
| 367 |
// Handle both object sender (DM/WS) and string sender (old format)
|
| 368 |
const senderName = isOwn
|
| 369 |
? currentUser?.display_name || currentUser?.name || "Bạn"
|
| 370 |
-
:
|
| 371 |
-
?
|
| 372 |
-
: (typeof msg.sender === "
|
|
|
|
|
|
|
| 373 |
const avatar = isOwn
|
| 374 |
? currentUser?.display_name?.charAt(0).toUpperCase() ||
|
| 375 |
currentUser?.name?.charAt(0).toUpperCase() ||
|
| 376 |
"B"
|
| 377 |
-
:
|
| 378 |
-
?
|
| 379 |
-
: (typeof msg.sender === "
|
|
|
|
|
|
|
| 380 |
// Get color: own message from currentUser, DM other from dmUser, space from membersMap
|
| 381 |
const color = (() => {
|
| 382 |
if (isOwn) return currentUser?.color || null;
|
|
|
|
| 383 |
if (isDM && dmUser?.id && String(dmUser.id) === String(senderId)) {
|
| 384 |
return dmUser.color || null;
|
| 385 |
}
|
|
@@ -412,6 +434,7 @@ function ChatArea({
|
|
| 412 |
isPinned: msg.is_pinned || false,
|
| 413 |
replyTo: msg.reply_to || null,
|
| 414 |
isOwn,
|
|
|
|
| 415 |
senderId,
|
| 416 |
pending: msg.pending || false,
|
| 417 |
is_read: msg.is_read,
|
|
@@ -419,7 +442,11 @@ function ChatArea({
|
|
| 419 |
};
|
| 420 |
});
|
| 421 |
|
| 422 |
-
// Join
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
const [joinedRoom, setJoinedRoom] = useState(null);
|
| 424 |
useEffect(() => {
|
| 425 |
if (isSpaceRoom && room) {
|
|
@@ -428,13 +455,17 @@ function ChatArea({
|
|
| 428 |
}).catch((err) => {
|
| 429 |
console.error("[ChatArea] Failed to join room:", err);
|
| 430 |
});
|
| 431 |
-
|
| 432 |
-
socketService.leaveRoom(room);
|
| 433 |
-
setJoinedRoom(null);
|
| 434 |
-
};
|
| 435 |
}
|
| 436 |
}, [isSpaceRoom, room]);
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
// Space members are already fetched globally in App.jsx
|
| 439 |
// No need to fetch again when entering a room
|
| 440 |
|
|
@@ -449,6 +480,31 @@ function ChatArea({
|
|
| 449 |
? Object.values(roomsMap).flat().find((r) => r.id === room)
|
| 450 |
: null;
|
| 451 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
const placeholder = isBotRoom || (isDM && room === "studybot-dm")
|
| 453 |
? "Hỏi trợ lý AI..."
|
| 454 |
: dmUser
|
|
@@ -947,6 +1003,7 @@ function ChatArea({
|
|
| 947 |
dmUser={dmUser}
|
| 948 |
roomName={currentRoomInfo?.name}
|
| 949 |
roomDescription={currentRoomInfo?.description}
|
|
|
|
| 950 |
onToggleRoomList={onToggleRoomList}
|
| 951 |
onToggleMemberList={onToggleMemberList}
|
| 952 |
roomListCollapsed={roomListCollapsed}
|
|
@@ -970,8 +1027,10 @@ function ChatArea({
|
|
| 970 |
chatMessages={chatMessages}
|
| 971 |
dmUser={dmUser}
|
| 972 |
hasNoSelection={isDM && !dmUser}
|
|
|
|
|
|
|
| 973 |
sendingMessages={sendingMessages}
|
| 974 |
-
isLoading={(isDM && messagesLoading) || (isSpaceRoom && roomMessagesLoading)}
|
| 975 |
conversationId={conversationId}
|
| 976 |
roomId={isSpaceRoom ? room : null}
|
| 977 |
onRetry={handleRetryMessage}
|
|
@@ -1007,6 +1066,7 @@ function ChatArea({
|
|
| 1007 |
onSend={handleSend}
|
| 1008 |
onTyping={handleTyping}
|
| 1009 |
onStopTyping={handleStopTyping}
|
|
|
|
| 1010 |
typingSender={
|
| 1011 |
isOtherUserTyping
|
| 1012 |
? dmUser?.name
|
|
|
|
| 20 |
} from "../store/slices/dmSlice";
|
| 21 |
import { fetchRoomMessages } from "../store/slices/messageSlice";
|
| 22 |
import { addMessage } from "../store/slices/messageSlice";
|
| 23 |
+
import {
|
| 24 |
+
createRoom,
|
| 25 |
+
clearRoomUnreadCount,
|
| 26 |
+
} from "../store/slices/spaceSlice";
|
| 27 |
import socketService from "../services/socket.service";
|
| 28 |
|
| 29 |
function ChatArea({
|
|
|
|
| 363 |
return String(a.id).localeCompare(String(b.id));
|
| 364 |
});
|
| 365 |
|
| 366 |
+
// Detect if sender is StudyBot bot
|
| 367 |
+
const isStudyBot = (msg) => {
|
| 368 |
+
const senderName = typeof msg.sender === "object"
|
| 369 |
+
? msg.sender?.display_name || msg.sender?.username || ""
|
| 370 |
+
: typeof msg.sender === "string"
|
| 371 |
+
? msg.sender
|
| 372 |
+
: "";
|
| 373 |
+
const senderUsername = typeof msg.sender === "object"
|
| 374 |
+
? msg.sender?.username || ""
|
| 375 |
+
: "";
|
| 376 |
+
return senderName === "StudyBot" || senderUsername === "studybot";
|
| 377 |
+
};
|
| 378 |
+
|
| 379 |
// Convert API messages to unified UI format
|
| 380 |
const chatMessages = sortedMessages.map((msg) => {
|
| 381 |
const senderId = msg.sender_id || msg.senderId || msg.sender?.id;
|
| 382 |
const isOwn = String(senderId) === String(currentUser?.id);
|
| 383 |
+
const msgIsBot = isStudyBot(msg);
|
| 384 |
// Handle both object sender (DM/WS) and string sender (old format)
|
| 385 |
const senderName = isOwn
|
| 386 |
? currentUser?.display_name || currentUser?.name || "Bạn"
|
| 387 |
+
: msgIsBot
|
| 388 |
+
? "StudyBot"
|
| 389 |
+
: (typeof msg.sender === "object" && msg.sender?.display_name)
|
| 390 |
+
? msg.sender.display_name
|
| 391 |
+
: (typeof msg.sender === "string" ? msg.sender : "Unknown");
|
| 392 |
const avatar = isOwn
|
| 393 |
? currentUser?.display_name?.charAt(0).toUpperCase() ||
|
| 394 |
currentUser?.name?.charAt(0).toUpperCase() ||
|
| 395 |
"B"
|
| 396 |
+
: msgIsBot
|
| 397 |
+
? "🤖"
|
| 398 |
+
: (typeof msg.sender === "object" && msg.sender?.display_name)
|
| 399 |
+
? msg.sender.display_name.charAt(0).toUpperCase()
|
| 400 |
+
: (typeof msg.sender === "string" ? msg.sender.charAt(0).toUpperCase() : "?");
|
| 401 |
// Get color: own message from currentUser, DM other from dmUser, space from membersMap
|
| 402 |
const color = (() => {
|
| 403 |
if (isOwn) return currentUser?.color || null;
|
| 404 |
+
if (msgIsBot) return null; // Bot uses tertiary color in ChatMessage component
|
| 405 |
if (isDM && dmUser?.id && String(dmUser.id) === String(senderId)) {
|
| 406 |
return dmUser.color || null;
|
| 407 |
}
|
|
|
|
| 434 |
isPinned: msg.is_pinned || false,
|
| 435 |
replyTo: msg.reply_to || null,
|
| 436 |
isOwn,
|
| 437 |
+
isBot: msgIsBot,
|
| 438 |
senderId,
|
| 439 |
pending: msg.pending || false,
|
| 440 |
is_read: msg.is_read,
|
|
|
|
| 442 |
};
|
| 443 |
});
|
| 444 |
|
| 445 |
+
// Join space room via WebSocket
|
| 446 |
+
// NOTE: We intentionally do NOT leaveRoom on cleanup.
|
| 447 |
+
// Once joined, the user stays in the room to receive real-time messages
|
| 448 |
+
// even when switching to another room, DM, or tab.
|
| 449 |
+
// This matches DM behavior where all conversations are joined permanently.
|
| 450 |
const [joinedRoom, setJoinedRoom] = useState(null);
|
| 451 |
useEffect(() => {
|
| 452 |
if (isSpaceRoom && room) {
|
|
|
|
| 455 |
}).catch((err) => {
|
| 456 |
console.error("[ChatArea] Failed to join room:", err);
|
| 457 |
});
|
| 458 |
+
// No cleanup leave — keep receiving messages for this room
|
|
|
|
|
|
|
|
|
|
| 459 |
}
|
| 460 |
}, [isSpaceRoom, room]);
|
| 461 |
|
| 462 |
+
// 🆕 Clear room unread count when opening a room
|
| 463 |
+
useEffect(() => {
|
| 464 |
+
if (isSpaceRoom && room) {
|
| 465 |
+
dispatch(clearRoomUnreadCount({ roomId: room }));
|
| 466 |
+
}
|
| 467 |
+
}, [isSpaceRoom, room, dispatch]);
|
| 468 |
+
|
| 469 |
// Space members are already fetched globally in App.jsx
|
| 470 |
// No need to fetch again when entering a room
|
| 471 |
|
|
|
|
| 480 |
? Object.values(roomsMap).flat().find((r) => r.id === room)
|
| 481 |
: null;
|
| 482 |
|
| 483 |
+
// Check if we already know this room/DM has no messages (last_message is null from API)
|
| 484 |
+
// This lets us skip loading skeleton and show empty state immediately
|
| 485 |
+
const isKnownEmpty = (() => {
|
| 486 |
+
if (isSpaceRoom && currentRoomInfo) {
|
| 487 |
+
return currentRoomInfo.last_message === null || currentRoomInfo.last_message === undefined;
|
| 488 |
+
}
|
| 489 |
+
if (isDM && activeConversation) {
|
| 490 |
+
return activeConversation.last_message === null || activeConversation.last_message === undefined;
|
| 491 |
+
}
|
| 492 |
+
return false;
|
| 493 |
+
})();
|
| 494 |
+
|
| 495 |
+
// Check if we're in a space view but no room selected yet
|
| 496 |
+
const isSpaceViewNoRoom = view === "space" && !room && appState.activeSpace;
|
| 497 |
+
const currentSpaceForWelcome = isSpaceViewNoRoom
|
| 498 |
+
? spaces.find((s) => s.id === appState.activeSpace)
|
| 499 |
+
: null;
|
| 500 |
+
|
| 501 |
+
const spaceWelcome = isSpaceViewNoRoom && currentSpaceForWelcome
|
| 502 |
+
? {
|
| 503 |
+
title: `Chào mừng đến với ${currentSpaceForWelcome.name}`,
|
| 504 |
+
description: currentSpaceForWelcome.description || "Hãy chọn một room bên trái để bắt đầu trò chuyện.",
|
| 505 |
+
}
|
| 506 |
+
: null;
|
| 507 |
+
|
| 508 |
const placeholder = isBotRoom || (isDM && room === "studybot-dm")
|
| 509 |
? "Hỏi trợ lý AI..."
|
| 510 |
: dmUser
|
|
|
|
| 1003 |
dmUser={dmUser}
|
| 1004 |
roomName={currentRoomInfo?.name}
|
| 1005 |
roomDescription={currentRoomInfo?.description}
|
| 1006 |
+
spaceWelcome={spaceWelcome}
|
| 1007 |
onToggleRoomList={onToggleRoomList}
|
| 1008 |
onToggleMemberList={onToggleMemberList}
|
| 1009 |
roomListCollapsed={roomListCollapsed}
|
|
|
|
| 1027 |
chatMessages={chatMessages}
|
| 1028 |
dmUser={dmUser}
|
| 1029 |
hasNoSelection={isDM && !dmUser}
|
| 1030 |
+
spaceWelcome={spaceWelcome}
|
| 1031 |
+
isKnownEmpty={isKnownEmpty}
|
| 1032 |
sendingMessages={sendingMessages}
|
| 1033 |
+
isLoading={!isKnownEmpty && ((isDM && messagesLoading) || (isSpaceRoom && roomMessagesLoading))}
|
| 1034 |
conversationId={conversationId}
|
| 1035 |
roomId={isSpaceRoom ? room : null}
|
| 1036 |
onRetry={handleRetryMessage}
|
|
|
|
| 1066 |
onSend={handleSend}
|
| 1067 |
onTyping={handleTyping}
|
| 1068 |
onStopTyping={handleStopTyping}
|
| 1069 |
+
disabled={!room}
|
| 1070 |
typingSender={
|
| 1071 |
isOtherUserTyping
|
| 1072 |
? dmUser?.name
|
src/components/Sidebar.jsx
CHANGED
|
@@ -41,7 +41,7 @@ function Sidebar() {
|
|
| 41 |
{spaces.map((space) => (
|
| 42 |
<SpaceIcon
|
| 43 |
key={space.id}
|
| 44 |
-
icon={space.
|
| 45 |
name={space.name}
|
| 46 |
isActive={currentView === "space" && activeSpace === space.id}
|
| 47 |
hasNotification={space.hasNotification || false}
|
|
|
|
| 41 |
{spaces.map((space) => (
|
| 42 |
<SpaceIcon
|
| 43 |
key={space.id}
|
| 44 |
+
icon={space.icon_url}
|
| 45 |
name={space.name}
|
| 46 |
isActive={currentView === "space" && activeSpace === space.id}
|
| 47 |
hasNotification={space.hasNotification || false}
|
src/components/chatarea/ChatHeader.jsx
CHANGED
|
@@ -77,26 +77,27 @@ function ChatHeader({
|
|
| 77 |
roomListCollapsed,
|
| 78 |
memberListCollapsed,
|
| 79 |
onOpenRoomSettings,
|
|
|
|
| 80 |
}) {
|
| 81 |
-
const hasNoSelection = isDM && !dmUser;
|
| 82 |
|
| 83 |
const headerTitle = hasNoSelection
|
| 84 |
? "Tin nhắn"
|
| 85 |
: isBotRoom
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
const headerSubtitle = hasNoSelection
|
| 92 |
? "Chọn một cuộc trò chuyện để bắt đầu"
|
| 93 |
: isBotRoom
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
|
| 101 |
return (
|
| 102 |
<div
|
|
|
|
| 77 |
roomListCollapsed,
|
| 78 |
memberListCollapsed,
|
| 79 |
onOpenRoomSettings,
|
| 80 |
+
spaceWelcome,
|
| 81 |
}) {
|
| 82 |
+
const hasNoSelection = (isDM && !dmUser) || spaceWelcome;
|
| 83 |
|
| 84 |
const headerTitle = hasNoSelection
|
| 85 |
? "Tin nhắn"
|
| 86 |
: isBotRoom
|
| 87 |
+
? "Trợ lý AI"
|
| 88 |
+
: isDM && dmUser
|
| 89 |
+
? dmUser.name
|
| 90 |
+
: roomName || (activeRoom ? `# ${activeRoom}` : "");
|
| 91 |
|
| 92 |
const headerSubtitle = hasNoSelection
|
| 93 |
? "Chọn một cuộc trò chuyện để bắt đầu"
|
| 94 |
: isBotRoom
|
| 95 |
+
? "Hỏi đáp với trợ lý AI"
|
| 96 |
+
: isDM && dmUser
|
| 97 |
+
? dmUser.isOnline
|
| 98 |
+
? "Đang hoạt động"
|
| 99 |
+
: "Ngoại tuyến"
|
| 100 |
+
: roomDescription || "";
|
| 101 |
|
| 102 |
return (
|
| 103 |
<div
|
src/components/chatarea/ChatInput.jsx
CHANGED
|
@@ -28,6 +28,7 @@ function ChatInput({
|
|
| 28 |
onTyping,
|
| 29 |
onStopTyping,
|
| 30 |
typingSender,
|
|
|
|
| 31 |
}) {
|
| 32 |
const [showMentions, setShowMentions] = useState(false);
|
| 33 |
const [mentionFilter, setMentionFilter] = useState("");
|
|
@@ -48,6 +49,14 @@ function ChatInput({
|
|
| 48 |
const users = [];
|
| 49 |
const seenIds = new Set();
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
conversations.forEach((conv) => {
|
| 52 |
const ou = conv.other_user;
|
| 53 |
if (ou && !seenIds.has(ou.id)) {
|
|
@@ -472,33 +481,33 @@ function ChatInput({
|
|
| 472 |
)}
|
| 473 |
|
| 474 |
<div
|
| 475 |
-
className=
|
| 476 |
style={{
|
| 477 |
background: "var(--input-bg)",
|
| 478 |
borderColor: "var(--input-border)",
|
| 479 |
}}
|
| 480 |
onFocus={(e) =>
|
| 481 |
-
(e.currentTarget.style.borderColor = "var(--primary)")
|
| 482 |
}
|
| 483 |
onBlur={(e) =>
|
| 484 |
-
(e.currentTarget.style.borderColor = "var(--input-border)")
|
| 485 |
}
|
| 486 |
>
|
| 487 |
<div className="flex items-center gap-2">
|
| 488 |
<div
|
| 489 |
className={`chat-input-placeholder ${isEmpty ? "" : "hidden"}`}
|
| 490 |
>
|
| 491 |
-
{placeholderText}
|
| 492 |
</div>
|
| 493 |
<div
|
| 494 |
ref={editorRef}
|
| 495 |
-
contentEditable
|
| 496 |
className="flex-1 border-none bg-transparent px-3 py-2 text-sm outline-none font-sans min-h-9 max-h-32 overflow-y-auto relative z-10"
|
| 497 |
style={{
|
| 498 |
color: "var(--input-text)",
|
| 499 |
}}
|
| 500 |
-
onInput={handleInput}
|
| 501 |
-
onKeyDown={handleKeyDown}
|
| 502 |
suppressContentEditableWarning
|
| 503 |
/>
|
| 504 |
{/* Hidden file input */}
|
|
@@ -509,19 +518,21 @@ function ChatInput({
|
|
| 509 |
multiple
|
| 510 |
className="hidden"
|
| 511 |
onChange={handleFileSelect}
|
|
|
|
| 512 |
/>
|
| 513 |
<button
|
| 514 |
type="button"
|
| 515 |
-
|
|
|
|
| 516 |
style={{
|
| 517 |
background: "transparent",
|
| 518 |
color: "var(--text-secondary)",
|
| 519 |
}}
|
| 520 |
onMouseEnter={(e) =>
|
| 521 |
-
(e.currentTarget.style.background = "var(--hover-primary)")
|
| 522 |
}
|
| 523 |
onMouseLeave={(e) =>
|
| 524 |
-
(e.currentTarget.style.background = "transparent")
|
| 525 |
}
|
| 526 |
onClick={handleAttachmentClick}
|
| 527 |
title="Đính kèm file (PDF, hình ảnh)"
|
|
@@ -530,16 +541,17 @@ function ChatInput({
|
|
| 530 |
</button>
|
| 531 |
<button
|
| 532 |
type="button"
|
| 533 |
-
|
|
|
|
| 534 |
style={{
|
| 535 |
background: "var(--primary)",
|
| 536 |
color: isDark ? "var(--bg-surface)" : "#fff",
|
| 537 |
}}
|
| 538 |
onMouseEnter={(e) =>
|
| 539 |
-
(e.currentTarget.style.background = "var(--primary-hover)")
|
| 540 |
}
|
| 541 |
onMouseLeave={(e) =>
|
| 542 |
-
(e.currentTarget.style.background = "var(--primary)")
|
| 543 |
}
|
| 544 |
onClick={handleSend}
|
| 545 |
title="Gửi"
|
|
|
|
| 28 |
onTyping,
|
| 29 |
onStopTyping,
|
| 30 |
typingSender,
|
| 31 |
+
disabled,
|
| 32 |
}) {
|
| 33 |
const [showMentions, setShowMentions] = useState(false);
|
| 34 |
const [mentionFilter, setMentionFilter] = useState("");
|
|
|
|
| 49 |
const users = [];
|
| 50 |
const seenIds = new Set();
|
| 51 |
|
| 52 |
+
// Add StudyBot as a mentionable user
|
| 53 |
+
users.push({
|
| 54 |
+
id: "studybot",
|
| 55 |
+
name: "StudyBot",
|
| 56 |
+
isBot: true,
|
| 57 |
+
});
|
| 58 |
+
seenIds.add("studybot");
|
| 59 |
+
|
| 60 |
conversations.forEach((conv) => {
|
| 61 |
const ou = conv.other_user;
|
| 62 |
if (ou && !seenIds.has(ou.id)) {
|
|
|
|
| 481 |
)}
|
| 482 |
|
| 483 |
<div
|
| 484 |
+
className={`chat-input-wrapper border rounded-lg p-1 transition-colors relative ${disabled ? "opacity-50" : ""}`}
|
| 485 |
style={{
|
| 486 |
background: "var(--input-bg)",
|
| 487 |
borderColor: "var(--input-border)",
|
| 488 |
}}
|
| 489 |
onFocus={(e) =>
|
| 490 |
+
!disabled && (e.currentTarget.style.borderColor = "var(--primary)")
|
| 491 |
}
|
| 492 |
onBlur={(e) =>
|
| 493 |
+
!disabled && (e.currentTarget.style.borderColor = "var(--input-border)")
|
| 494 |
}
|
| 495 |
>
|
| 496 |
<div className="flex items-center gap-2">
|
| 497 |
<div
|
| 498 |
className={`chat-input-placeholder ${isEmpty ? "" : "hidden"}`}
|
| 499 |
>
|
| 500 |
+
{disabled ? "Chọn một cuộc trò chuyện để bắt đầu" : placeholderText}
|
| 501 |
</div>
|
| 502 |
<div
|
| 503 |
ref={editorRef}
|
| 504 |
+
contentEditable={!disabled}
|
| 505 |
className="flex-1 border-none bg-transparent px-3 py-2 text-sm outline-none font-sans min-h-9 max-h-32 overflow-y-auto relative z-10"
|
| 506 |
style={{
|
| 507 |
color: "var(--input-text)",
|
| 508 |
}}
|
| 509 |
+
onInput={disabled ? undefined : handleInput}
|
| 510 |
+
onKeyDown={disabled ? undefined : handleKeyDown}
|
| 511 |
suppressContentEditableWarning
|
| 512 |
/>
|
| 513 |
{/* Hidden file input */}
|
|
|
|
| 518 |
multiple
|
| 519 |
className="hidden"
|
| 520 |
onChange={handleFileSelect}
|
| 521 |
+
disabled={disabled}
|
| 522 |
/>
|
| 523 |
<button
|
| 524 |
type="button"
|
| 525 |
+
disabled={disabled}
|
| 526 |
+
className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors disabled:cursor-not-allowed"
|
| 527 |
style={{
|
| 528 |
background: "transparent",
|
| 529 |
color: "var(--text-secondary)",
|
| 530 |
}}
|
| 531 |
onMouseEnter={(e) =>
|
| 532 |
+
!disabled && (e.currentTarget.style.background = "var(--hover-primary)")
|
| 533 |
}
|
| 534 |
onMouseLeave={(e) =>
|
| 535 |
+
!disabled && (e.currentTarget.style.background = "transparent")
|
| 536 |
}
|
| 537 |
onClick={handleAttachmentClick}
|
| 538 |
title="Đính kèm file (PDF, hình ảnh)"
|
|
|
|
| 541 |
</button>
|
| 542 |
<button
|
| 543 |
type="button"
|
| 544 |
+
disabled={disabled}
|
| 545 |
+
className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors disabled:cursor-not-allowed"
|
| 546 |
style={{
|
| 547 |
background: "var(--primary)",
|
| 548 |
color: isDark ? "var(--bg-surface)" : "#fff",
|
| 549 |
}}
|
| 550 |
onMouseEnter={(e) =>
|
| 551 |
+
!disabled && (e.currentTarget.style.background = "var(--primary-hover)")
|
| 552 |
}
|
| 553 |
onMouseLeave={(e) =>
|
| 554 |
+
!disabled && (e.currentTarget.style.background = "var(--primary)")
|
| 555 |
}
|
| 556 |
onClick={handleSend}
|
| 557 |
title="Gửi"
|
src/components/chatarea/ChatMessages.jsx
CHANGED
|
@@ -238,7 +238,7 @@ function MessageSkeleton({ isDark, width = "75%", showSecondLine = true }) {
|
|
| 238 |
);
|
| 239 |
}
|
| 240 |
|
| 241 |
-
function EmptyChatState({ dmUser, isDark, hasNoSelection }) {
|
| 242 |
return (
|
| 243 |
<div className="flex flex-col items-center justify-center px-6 text-center">
|
| 244 |
<div
|
|
@@ -265,21 +265,25 @@ function EmptyChatState({ dmUser, isDark, hasNoSelection }) {
|
|
| 265 |
className="text-base font-semibold mb-2"
|
| 266 |
style={{ color: "var(--text-primary)" }}
|
| 267 |
>
|
| 268 |
-
{
|
| 269 |
-
?
|
| 270 |
-
:
|
| 271 |
-
?
|
| 272 |
-
:
|
|
|
|
|
|
|
| 273 |
</div>
|
| 274 |
<div
|
| 275 |
className="text-sm leading-relaxed max-w-xs"
|
| 276 |
style={{ color: "var(--text-muted)" }}
|
| 277 |
>
|
| 278 |
-
{
|
| 279 |
-
?
|
| 280 |
-
:
|
| 281 |
-
? "Hãy
|
| 282 |
-
:
|
|
|
|
|
|
|
| 283 |
</div>
|
| 284 |
</div>
|
| 285 |
);
|
|
@@ -293,6 +297,8 @@ function ChatMessages({
|
|
| 293 |
onEdit,
|
| 294 |
onShowProfile,
|
| 295 |
hasNoSelection,
|
|
|
|
|
|
|
| 296 |
sendingMessages,
|
| 297 |
isLoading,
|
| 298 |
conversationId,
|
|
@@ -413,15 +419,15 @@ function ChatMessages({
|
|
| 413 |
|
| 414 |
const hasMessages = chatMessages.length > 0;
|
| 415 |
|
| 416 |
-
const isEmpty = !hasMessages && !isLoading;
|
| 417 |
|
| 418 |
return (
|
| 419 |
<div
|
| 420 |
ref={messagesContainerRef}
|
| 421 |
-
className={`flex-1 p-4 ${isEmpty ? "flex items-center justify-center overflow-hidden" : ` ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}`}
|
| 422 |
onScroll={isEmpty ? undefined : handleScroll}
|
| 423 |
>
|
| 424 |
-
{isLoading && !hasMessages ? (
|
| 425 |
<div className="flex flex-col gap-2 w-full min-h-full justify-end pb-2">
|
| 426 |
<MessageSkeleton isDark={isDark} width="60%" showSecondLine={false} />
|
| 427 |
<MessageSkeleton isDark={isDark} width="85%" />
|
|
@@ -438,6 +444,7 @@ function ChatMessages({
|
|
| 438 |
dmUser={dmUser}
|
| 439 |
isDark={isDark}
|
| 440 |
hasNoSelection={hasNoSelection}
|
|
|
|
| 441 |
/>
|
| 442 |
) : (
|
| 443 |
<div className="flex flex-col min-h-full justify-end gap-1 w-full">
|
|
|
|
| 238 |
);
|
| 239 |
}
|
| 240 |
|
| 241 |
+
function EmptyChatState({ dmUser, isDark, hasNoSelection, spaceWelcome }) {
|
| 242 |
return (
|
| 243 |
<div className="flex flex-col items-center justify-center px-6 text-center">
|
| 244 |
<div
|
|
|
|
| 265 |
className="text-base font-semibold mb-2"
|
| 266 |
style={{ color: "var(--text-primary)" }}
|
| 267 |
>
|
| 268 |
+
{spaceWelcome
|
| 269 |
+
? spaceWelcome.title
|
| 270 |
+
: hasNoSelection
|
| 271 |
+
? "Chọn một cuộc trò chuyện"
|
| 272 |
+
: dmUser
|
| 273 |
+
? `Bắt đầu trò chuyện với ${dmUser.name}`
|
| 274 |
+
: "Chưa có tin nhắn nào"}
|
| 275 |
</div>
|
| 276 |
<div
|
| 277 |
className="text-sm leading-relaxed max-w-xs"
|
| 278 |
style={{ color: "var(--text-muted)" }}
|
| 279 |
>
|
| 280 |
+
{spaceWelcome
|
| 281 |
+
? spaceWelcome.description
|
| 282 |
+
: hasNoSelection
|
| 283 |
+
? "Hãy chọn một ngườii bạn bên trái để bắt đầu nhắn tin."
|
| 284 |
+
: dmUser
|
| 285 |
+
? "Hãy gửi lờii chào hoặc câu hỏi để bắt đầu cuộc trò chuyện đầu tiên nhé!"
|
| 286 |
+
: "Chọn một cuộc trò chuyện để bắt đầu nhắn tin."}
|
| 287 |
</div>
|
| 288 |
</div>
|
| 289 |
);
|
|
|
|
| 297 |
onEdit,
|
| 298 |
onShowProfile,
|
| 299 |
hasNoSelection,
|
| 300 |
+
spaceWelcome,
|
| 301 |
+
isKnownEmpty,
|
| 302 |
sendingMessages,
|
| 303 |
isLoading,
|
| 304 |
conversationId,
|
|
|
|
| 419 |
|
| 420 |
const hasMessages = chatMessages.length > 0;
|
| 421 |
|
| 422 |
+
const isEmpty = !hasMessages && (!isLoading || isKnownEmpty);
|
| 423 |
|
| 424 |
return (
|
| 425 |
<div
|
| 426 |
ref={messagesContainerRef}
|
| 427 |
+
className={`flex-1 p-4 ${isEmpty ? "flex items-center justify-center overflow-hidden" : ` ${isLoading && !isKnownEmpty ? "overflow-hidden" : "overflow-y-auto"}`}`}
|
| 428 |
onScroll={isEmpty ? undefined : handleScroll}
|
| 429 |
>
|
| 430 |
+
{isLoading && !hasMessages && !isKnownEmpty ? (
|
| 431 |
<div className="flex flex-col gap-2 w-full min-h-full justify-end pb-2">
|
| 432 |
<MessageSkeleton isDark={isDark} width="60%" showSecondLine={false} />
|
| 433 |
<MessageSkeleton isDark={isDark} width="85%" />
|
|
|
|
| 444 |
dmUser={dmUser}
|
| 445 |
isDark={isDark}
|
| 446 |
hasNoSelection={hasNoSelection}
|
| 447 |
+
spaceWelcome={spaceWelcome}
|
| 448 |
/>
|
| 449 |
) : (
|
| 450 |
<div className="flex flex-col min-h-full justify-end gap-1 w-full">
|
src/components/createspace/CreateSpace.jsx
CHANGED
|
@@ -1,50 +1,11 @@
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useSelector, useDispatch } from "react-redux";
|
| 3 |
-
import {
|
| 4 |
-
PiGraduationCap,
|
| 5 |
-
PiRobot,
|
| 6 |
-
PiFolder,
|
| 7 |
-
PiPencil,
|
| 8 |
-
PiComputerTower,
|
| 9 |
-
PiBooks,
|
| 10 |
-
PiStudent,
|
| 11 |
-
PiFlask,
|
| 12 |
-
PiCode,
|
| 13 |
-
PiGlobe,
|
| 14 |
-
PiMusicNotes,
|
| 15 |
-
PiPalette,
|
| 16 |
-
PiCamera,
|
| 17 |
-
PiGameController,
|
| 18 |
-
PiHeart,
|
| 19 |
-
PiStar,
|
| 20 |
-
PiRocket,
|
| 21 |
-
PiBrain,
|
| 22 |
-
PiCalculator,
|
| 23 |
-
PiCalendar,
|
| 24 |
-
PiUsers,
|
| 25 |
-
PiTrophy,
|
| 26 |
-
PiFlag,
|
| 27 |
-
PiSun,
|
| 28 |
-
PiMoon,
|
| 29 |
-
PiCloud,
|
| 30 |
-
PiHouse,
|
| 31 |
-
PiCar,
|
| 32 |
-
PiAirplane,
|
| 33 |
-
PiBasketball,
|
| 34 |
-
PiGuitar,
|
| 35 |
-
PiPhone,
|
| 36 |
-
PiLaptop,
|
| 37 |
-
PiCoffee,
|
| 38 |
-
PiPizza,
|
| 39 |
-
PiFirstAid,
|
| 40 |
-
PiLockKey,
|
| 41 |
-
PiMoney,
|
| 42 |
-
PiGift,
|
| 43 |
-
PiFire,
|
| 44 |
-
PiSnowflake,
|
| 45 |
-
} from "react-icons/pi";
|
| 46 |
import { FiSearch, FiX } from "react-icons/fi";
|
| 47 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 48 |
import { createSpace } from "../../store/slices/spaceSlice";
|
| 49 |
import { dmService } from "../../services/dm.service";
|
| 50 |
import { spaceIcons, getSpaceIconComponent } from "../../constants/spaceIcons";
|
|
@@ -55,7 +16,7 @@ function CreateSpace() {
|
|
| 55 |
const [spaceName, setSpaceName] = useState("");
|
| 56 |
const [spaceIcon, setSpaceIcon] = useState(spaceIcons[0].id);
|
| 57 |
const [spaceDescription, setSpaceDescription] = useState("");
|
| 58 |
-
|
| 59 |
// Members state
|
| 60 |
const [members, setMembers] = useState([]);
|
| 61 |
const [searchQuery, setSearchQuery] = useState("");
|
|
@@ -66,7 +27,7 @@ function CreateSpace() {
|
|
| 66 |
// Search users - only trigger on Enter key
|
| 67 |
const handleSearch = async (query) => {
|
| 68 |
setSearchQuery(query);
|
| 69 |
-
|
| 70 |
if (!query.trim()) {
|
| 71 |
setSearchResults([]);
|
| 72 |
setIsSearching(false);
|
|
@@ -75,15 +36,15 @@ function CreateSpace() {
|
|
| 75 |
};
|
| 76 |
|
| 77 |
const handleSearchKeyDown = async (e) => {
|
| 78 |
-
if (e.key ===
|
| 79 |
e.preventDefault();
|
| 80 |
const query = searchQuery.trim();
|
| 81 |
-
|
| 82 |
if (!query) {
|
| 83 |
setSearchResults([]);
|
| 84 |
return;
|
| 85 |
}
|
| 86 |
-
|
| 87 |
setIsSearching(true);
|
| 88 |
try {
|
| 89 |
const { data } = await dmService.searchUsers(query);
|
|
@@ -131,9 +92,8 @@ function CreateSpace() {
|
|
| 131 |
if (spaceDescription.trim()) {
|
| 132 |
payload.description = spaceDescription.trim();
|
| 133 |
}
|
| 134 |
-
// Gửi icon
|
| 135 |
-
|
| 136 |
-
// payload.icon = spaceIcon;
|
| 137 |
|
| 138 |
// Gửi memberIds nếu có members được chọn
|
| 139 |
if (members.length > 0) {
|
|
@@ -299,18 +259,27 @@ function CreateSpace() {
|
|
| 299 |
>
|
| 300 |
Thêm thành viên
|
| 301 |
</h3>
|
| 302 |
-
|
| 303 |
{/* Search input */}
|
| 304 |
<div className="relative mb-3">
|
| 305 |
{isSearching ? (
|
| 306 |
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
| 307 |
-
<div
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
/>
|
| 310 |
</div>
|
| 311 |
) : (
|
| 312 |
<button
|
| 313 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
className="absolute left-3 top-1/2 -translate-y-1/2 hover:opacity-70 transition-opacity"
|
| 315 |
style={{ color: "var(--text-muted)" }}
|
| 316 |
>
|
|
@@ -337,7 +306,7 @@ function CreateSpace() {
|
|
| 337 |
}
|
| 338 |
/>
|
| 339 |
</div>
|
| 340 |
-
|
| 341 |
{/* Selected members - chips */}
|
| 342 |
{members.length > 0 && (
|
| 343 |
<div className="mb-3">
|
|
@@ -378,7 +347,7 @@ function CreateSpace() {
|
|
| 378 |
</div>
|
| 379 |
</div>
|
| 380 |
)}
|
| 381 |
-
|
| 382 |
{/* Search results with checkbox - keep showing after select */}
|
| 383 |
{searchResults.length > 0 && (
|
| 384 |
<div>
|
|
|
|
| 1 |
import { useState } from "react";
|
| 2 |
import { useSelector, useDispatch } from "react-redux";
|
| 3 |
+
import { PiGraduationCap } from "react-icons/pi";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { FiSearch, FiX } from "react-icons/fi";
|
| 5 |
+
import {
|
| 6 |
+
cancelCreateSpace,
|
| 7 |
+
navigateToSpace,
|
| 8 |
+
} from "../../store/slices/appSlice";
|
| 9 |
import { createSpace } from "../../store/slices/spaceSlice";
|
| 10 |
import { dmService } from "../../services/dm.service";
|
| 11 |
import { spaceIcons, getSpaceIconComponent } from "../../constants/spaceIcons";
|
|
|
|
| 16 |
const [spaceName, setSpaceName] = useState("");
|
| 17 |
const [spaceIcon, setSpaceIcon] = useState(spaceIcons[0].id);
|
| 18 |
const [spaceDescription, setSpaceDescription] = useState("");
|
| 19 |
+
|
| 20 |
// Members state
|
| 21 |
const [members, setMembers] = useState([]);
|
| 22 |
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
| 27 |
// Search users - only trigger on Enter key
|
| 28 |
const handleSearch = async (query) => {
|
| 29 |
setSearchQuery(query);
|
| 30 |
+
|
| 31 |
if (!query.trim()) {
|
| 32 |
setSearchResults([]);
|
| 33 |
setIsSearching(false);
|
|
|
|
| 36 |
};
|
| 37 |
|
| 38 |
const handleSearchKeyDown = async (e) => {
|
| 39 |
+
if (e.key === "Enter") {
|
| 40 |
e.preventDefault();
|
| 41 |
const query = searchQuery.trim();
|
| 42 |
+
|
| 43 |
if (!query) {
|
| 44 |
setSearchResults([]);
|
| 45 |
return;
|
| 46 |
}
|
| 47 |
+
|
| 48 |
setIsSearching(true);
|
| 49 |
try {
|
| 50 |
const { data } = await dmService.searchUsers(query);
|
|
|
|
| 92 |
if (spaceDescription.trim()) {
|
| 93 |
payload.description = spaceDescription.trim();
|
| 94 |
}
|
| 95 |
+
// Gửi icon id (theo spaceIcons id) lên BE
|
| 96 |
+
payload.icon = spaceIcon;
|
|
|
|
| 97 |
|
| 98 |
// Gửi memberIds nếu có members được chọn
|
| 99 |
if (members.length > 0) {
|
|
|
|
| 259 |
>
|
| 260 |
Thêm thành viên
|
| 261 |
</h3>
|
| 262 |
+
|
| 263 |
{/* Search input */}
|
| 264 |
<div className="relative mb-3">
|
| 265 |
{isSearching ? (
|
| 266 |
<div className="absolute left-3 top-1/2 -translate-y-1/2">
|
| 267 |
+
<div
|
| 268 |
+
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
|
| 269 |
+
style={{
|
| 270 |
+
borderColor: "var(--text-muted)",
|
| 271 |
+
borderTopColor: "transparent",
|
| 272 |
+
}}
|
| 273 |
/>
|
| 274 |
</div>
|
| 275 |
) : (
|
| 276 |
<button
|
| 277 |
+
onClick={() =>
|
| 278 |
+
handleSearchKeyDown({
|
| 279 |
+
key: "Enter",
|
| 280 |
+
preventDefault: () => {},
|
| 281 |
+
})
|
| 282 |
+
}
|
| 283 |
className="absolute left-3 top-1/2 -translate-y-1/2 hover:opacity-70 transition-opacity"
|
| 284 |
style={{ color: "var(--text-muted)" }}
|
| 285 |
>
|
|
|
|
| 306 |
}
|
| 307 |
/>
|
| 308 |
</div>
|
| 309 |
+
|
| 310 |
{/* Selected members - chips */}
|
| 311 |
{members.length > 0 && (
|
| 312 |
<div className="mb-3">
|
|
|
|
| 347 |
</div>
|
| 348 |
</div>
|
| 349 |
)}
|
| 350 |
+
|
| 351 |
{/* Search results with checkbox - keep showing after select */}
|
| 352 |
{searchResults.length > 0 && (
|
| 353 |
<div>
|
src/components/roomlist/SpaceRoomList.jsx
CHANGED
|
@@ -3,8 +3,16 @@ import { useSelector, useDispatch } from "react-redux";
|
|
| 3 |
import { FiSearch, FiPlus, FiSliders } from "react-icons/fi";
|
| 4 |
import { createRoom } from "../../store/slices/spaceSlice";
|
| 5 |
import { setActiveRoom } from "../../store/slices/appSlice";
|
|
|
|
| 6 |
|
| 7 |
-
function RoomItem({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const [isHovered, setIsHovered] = useState(false);
|
| 9 |
|
| 10 |
return (
|
|
@@ -58,9 +66,8 @@ function RoomItem({ room, isActive, onClick, lastMessage, lastMessageTime, unrea
|
|
| 58 |
<div
|
| 59 |
className="text-xs mt-0.5 truncate"
|
| 60 |
style={{
|
| 61 |
-
color:
|
| 62 |
-
? "var(--text-primary)"
|
| 63 |
-
: "var(--text-secondary)",
|
| 64 |
fontWeight: unreadCount > 0 ? 500 : 400,
|
| 65 |
}}
|
| 66 |
>
|
|
@@ -269,9 +276,13 @@ function SpaceRoomList({
|
|
| 269 |
const dispatch = useDispatch();
|
| 270 |
const [isSearching, setIsSearching] = useState(false);
|
| 271 |
|
| 272 |
-
const {
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
const currentUser = useSelector((state) => state.auth.user);
|
| 276 |
|
| 277 |
const spaceRooms = roomsMap[activeSpace] || [];
|
|
@@ -281,8 +292,13 @@ function SpaceRoomList({
|
|
| 281 |
const getRoomLastMessage = (room) => {
|
| 282 |
const lastMsg = room.last_message;
|
| 283 |
if (!lastMsg) return null;
|
| 284 |
-
const isOwn =
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
return {
|
| 287 |
content: lastMsg.content,
|
| 288 |
senderName,
|
|
@@ -328,11 +344,13 @@ function SpaceRoomList({
|
|
| 328 |
style={{ borderColor: "var(--border-primary)" }}
|
| 329 |
>
|
| 330 |
<div className="flex items-center justify-between mb-3">
|
| 331 |
-
<div
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
| 336 |
</div>
|
| 337 |
<button
|
| 338 |
onClick={() => console.log("Space settings clicked")}
|
|
@@ -403,16 +421,16 @@ function SpaceRoomList({
|
|
| 403 |
room={room}
|
| 404 |
isActive={activeRoom === room.id}
|
| 405 |
onClick={() => setActiveRoom(room.id)}
|
| 406 |
-
lastMessage={
|
| 407 |
-
|
|
|
|
|
|
|
| 408 |
/>
|
| 409 |
);
|
| 410 |
})}
|
| 411 |
</div>
|
| 412 |
)}
|
| 413 |
</div>
|
| 414 |
-
|
| 415 |
-
|
| 416 |
</div>
|
| 417 |
);
|
| 418 |
}
|
|
|
|
| 3 |
import { FiSearch, FiPlus, FiSliders } from "react-icons/fi";
|
| 4 |
import { createRoom } from "../../store/slices/spaceSlice";
|
| 5 |
import { setActiveRoom } from "../../store/slices/appSlice";
|
| 6 |
+
import { getSpaceIconComponent } from "../../constants/spaceIcons";
|
| 7 |
|
| 8 |
+
function RoomItem({
|
| 9 |
+
room,
|
| 10 |
+
isActive,
|
| 11 |
+
onClick,
|
| 12 |
+
lastMessage,
|
| 13 |
+
lastMessageTime,
|
| 14 |
+
unreadCount,
|
| 15 |
+
}) {
|
| 16 |
const [isHovered, setIsHovered] = useState(false);
|
| 17 |
|
| 18 |
return (
|
|
|
|
| 66 |
<div
|
| 67 |
className="text-xs mt-0.5 truncate"
|
| 68 |
style={{
|
| 69 |
+
color:
|
| 70 |
+
unreadCount > 0 ? "var(--text-primary)" : "var(--text-secondary)",
|
|
|
|
| 71 |
fontWeight: unreadCount > 0 ? 500 : 400,
|
| 72 |
}}
|
| 73 |
>
|
|
|
|
| 276 |
const dispatch = useDispatch();
|
| 277 |
const [isSearching, setIsSearching] = useState(false);
|
| 278 |
|
| 279 |
+
const {
|
| 280 |
+
spaces,
|
| 281 |
+
roomsMap,
|
| 282 |
+
roomsLoading,
|
| 283 |
+
fetchedRooms,
|
| 284 |
+
roomUnreadCounts,
|
| 285 |
+
} = useSelector((state) => state.space);
|
| 286 |
const currentUser = useSelector((state) => state.auth.user);
|
| 287 |
|
| 288 |
const spaceRooms = roomsMap[activeSpace] || [];
|
|
|
|
| 292 |
const getRoomLastMessage = (room) => {
|
| 293 |
const lastMsg = room.last_message;
|
| 294 |
if (!lastMsg) return null;
|
| 295 |
+
const isOwn =
|
| 296 |
+
lastMsg.sender_id &&
|
| 297 |
+
currentUser?.id &&
|
| 298 |
+
String(lastMsg.sender_id) === String(currentUser.id);
|
| 299 |
+
const senderName = isOwn
|
| 300 |
+
? "Bạn"
|
| 301 |
+
: lastMsg.sender_display_name || lastMsg.username || "Unknown";
|
| 302 |
return {
|
| 303 |
content: lastMsg.content,
|
| 304 |
senderName,
|
|
|
|
| 344 |
style={{ borderColor: "var(--border-primary)" }}
|
| 345 |
>
|
| 346 |
<div className="flex items-center justify-between mb-3">
|
| 347 |
+
<div className="flex items-center gap-2 min-w-0">
|
| 348 |
+
<div
|
| 349 |
+
className="text-base font-semibold truncate"
|
| 350 |
+
style={{ color: "var(--text-primary)" }}
|
| 351 |
+
>
|
| 352 |
+
{currentSpace?.name || "Space"}
|
| 353 |
+
</div>
|
| 354 |
</div>
|
| 355 |
<button
|
| 356 |
onClick={() => console.log("Space settings clicked")}
|
|
|
|
| 421 |
room={room}
|
| 422 |
isActive={activeRoom === room.id}
|
| 423 |
onClick={() => setActiveRoom(room.id)}
|
| 424 |
+
lastMessage={
|
| 425 |
+
lastMsg ? `${lastMsg.senderName}: ${lastMsg.content}` : null
|
| 426 |
+
}
|
| 427 |
+
unreadCount={roomUnreadCounts[room.id] || 0}
|
| 428 |
/>
|
| 429 |
);
|
| 430 |
})}
|
| 431 |
</div>
|
| 432 |
)}
|
| 433 |
</div>
|
|
|
|
|
|
|
| 434 |
</div>
|
| 435 |
);
|
| 436 |
}
|
src/store/slices/dmSlice.js
CHANGED
|
@@ -125,6 +125,24 @@ export const preloadAllData = createAsyncThunk(
|
|
| 125 |
socketService.joinDM(conv.id);
|
| 126 |
});
|
| 127 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
const elapsed = Math.round(performance.now() - preloadStart);
|
| 129 |
console.log(`%c[preloadAllData] << Hoàn tất preload trong ${elapsed}ms`, "color: #8b5cf6; font-weight: bold; font-size: 13px;", {
|
| 130 |
conversations: convList.length,
|
|
|
|
| 125 |
socketService.joinDM(conv.id);
|
| 126 |
});
|
| 127 |
|
| 128 |
+
// ─── Phase 3: Join all Space rooms via WebSocket ───
|
| 129 |
+
const allRoomIds = [];
|
| 130 |
+
spaces.forEach((space) => {
|
| 131 |
+
if (space.rooms && Array.isArray(space.rooms)) {
|
| 132 |
+
space.rooms.forEach((room) => {
|
| 133 |
+
if (room.id) {
|
| 134 |
+
allRoomIds.push(room.id);
|
| 135 |
+
}
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
allRoomIds.forEach((roomId) => {
|
| 140 |
+
socketService.joinRoom(roomId).catch((err) => {
|
| 141 |
+
console.warn("[preloadAllData] Failed to join room:", roomId, err);
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
console.log(`[preloadAllData] Joined ${allRoomIds.length} space rooms via WebSocket`);
|
| 145 |
+
|
| 146 |
const elapsed = Math.round(performance.now() - preloadStart);
|
| 147 |
console.log(`%c[preloadAllData] << Hoàn tất preload trong ${elapsed}ms`, "color: #8b5cf6; font-weight: bold; font-size: 13px;", {
|
| 148 |
conversations: convList.length,
|
src/store/slices/spaceSlice.js
CHANGED
|
@@ -192,6 +192,11 @@ const initialState = {
|
|
| 192 |
spacesFetched: false,
|
| 193 |
fetchedRooms: {}, // { [spaceId]: boolean }
|
| 194 |
fetchedMembers: {}, // { [spaceId]: boolean }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
};
|
| 196 |
|
| 197 |
const spaceSlice = createSlice({
|
|
@@ -260,6 +265,75 @@ const spaceSlice = createSlice({
|
|
| 260 |
|
| 261 |
resetSpaceState: () => initialState,
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
// Load spaces and rooms from localStorage cache
|
| 264 |
loadSpacesFromCache: (state) => {
|
| 265 |
// Skip if already loaded in Redux
|
|
@@ -453,6 +527,12 @@ export const {
|
|
| 453 |
resetSpaceState,
|
| 454 |
loadSpacesFromCache,
|
| 455 |
loadMembersFromCache,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
} = spaceSlice.actions;
|
| 457 |
|
| 458 |
export default spaceSlice.reducer;
|
|
|
|
| 192 |
spacesFetched: false,
|
| 193 |
fetchedRooms: {}, // { [spaceId]: boolean }
|
| 194 |
fetchedMembers: {}, // { [spaceId]: boolean }
|
| 195 |
+
// 🆕 Room unread tracking (similar to DM)
|
| 196 |
+
roomUnreadCounts: {}, // { [roomId]: number }
|
| 197 |
+
roomTotalUnreadCount: 0,
|
| 198 |
+
// 🆕 Track joined rooms for WebSocket
|
| 199 |
+
joinedRooms: [], // Array<roomId>
|
| 200 |
};
|
| 201 |
|
| 202 |
const spaceSlice = createSlice({
|
|
|
|
| 265 |
|
| 266 |
resetSpaceState: () => initialState,
|
| 267 |
|
| 268 |
+
// 🆕 Update room's last_message and move to top of list
|
| 269 |
+
updateRoomLastMessage: (state, action) => {
|
| 270 |
+
const { roomId, message } = action.payload;
|
| 271 |
+
// Find the room in roomsMap
|
| 272 |
+
for (const spaceId of Object.keys(state.roomsMap)) {
|
| 273 |
+
const rooms = state.roomsMap[spaceId];
|
| 274 |
+
const idx = rooms.findIndex((r) => r.id === roomId);
|
| 275 |
+
if (idx !== -1) {
|
| 276 |
+
rooms[idx] = {
|
| 277 |
+
...rooms[idx],
|
| 278 |
+
last_message: {
|
| 279 |
+
id: message.id,
|
| 280 |
+
content: message.content,
|
| 281 |
+
created_at: message.created_at,
|
| 282 |
+
sender_id: message.sender_id,
|
| 283 |
+
sender_display_name: message.sender?.display_name || message.sender?.name || "Unknown",
|
| 284 |
+
},
|
| 285 |
+
};
|
| 286 |
+
// Move room to top of the list (similar to DM conversation sort)
|
| 287 |
+
const room = rooms.splice(idx, 1)[0];
|
| 288 |
+
rooms.unshift(room);
|
| 289 |
+
break;
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
|
| 294 |
+
// 🆕 Increment unread count for a room
|
| 295 |
+
incrementRoomUnreadCount: (state, action) => {
|
| 296 |
+
const { roomId } = action.payload;
|
| 297 |
+
const current = state.roomUnreadCounts[roomId] || 0;
|
| 298 |
+
state.roomUnreadCounts[roomId] = current + 1;
|
| 299 |
+
state.roomTotalUnreadCount = Object.values(state.roomUnreadCounts).reduce(
|
| 300 |
+
(a, b) => a + b,
|
| 301 |
+
0,
|
| 302 |
+
);
|
| 303 |
+
},
|
| 304 |
+
|
| 305 |
+
// 🆕 Clear unread count for a room
|
| 306 |
+
clearRoomUnreadCount: (state, action) => {
|
| 307 |
+
const { roomId } = action.payload;
|
| 308 |
+
delete state.roomUnreadCounts[roomId];
|
| 309 |
+
state.roomTotalUnreadCount = Object.values(state.roomUnreadCounts).reduce(
|
| 310 |
+
(a, b) => a + b,
|
| 311 |
+
0,
|
| 312 |
+
);
|
| 313 |
+
},
|
| 314 |
+
|
| 315 |
+
// 🆕 Set unread count for a room
|
| 316 |
+
setRoomUnreadCount: (state, action) => {
|
| 317 |
+
const { roomId, count } = action.payload;
|
| 318 |
+
if (count <= 0) {
|
| 319 |
+
delete state.roomUnreadCounts[roomId];
|
| 320 |
+
} else {
|
| 321 |
+
state.roomUnreadCounts[roomId] = count;
|
| 322 |
+
}
|
| 323 |
+
state.roomTotalUnreadCount = Object.values(state.roomUnreadCounts).reduce(
|
| 324 |
+
(a, b) => a + b,
|
| 325 |
+
0,
|
| 326 |
+
);
|
| 327 |
+
},
|
| 328 |
+
|
| 329 |
+
// 🆕 Track joined rooms
|
| 330 |
+
addJoinedRoom: (state, action) => {
|
| 331 |
+
const roomId = action.payload;
|
| 332 |
+
if (!state.joinedRooms.includes(roomId)) {
|
| 333 |
+
state.joinedRooms.push(roomId);
|
| 334 |
+
}
|
| 335 |
+
},
|
| 336 |
+
|
| 337 |
// Load spaces and rooms from localStorage cache
|
| 338 |
loadSpacesFromCache: (state) => {
|
| 339 |
// Skip if already loaded in Redux
|
|
|
|
| 527 |
resetSpaceState,
|
| 528 |
loadSpacesFromCache,
|
| 529 |
loadMembersFromCache,
|
| 530 |
+
// 🆕 Room unread tracking exports
|
| 531 |
+
updateRoomLastMessage,
|
| 532 |
+
incrementRoomUnreadCount,
|
| 533 |
+
clearRoomUnreadCount,
|
| 534 |
+
setRoomUnreadCount,
|
| 535 |
+
addJoinedRoom,
|
| 536 |
} = spaceSlice.actions;
|
| 537 |
|
| 538 |
export default spaceSlice.reducer;
|