anotherath commited on
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 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: String(senderId) === String(store.getState().auth.user?.id),
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 { createRoom } from "../store/slices/spaceSlice";
 
 
 
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
- : (typeof msg.sender === "object" && msg.sender?.display_name)
371
- ? msg.sender.display_name
372
- : (typeof msg.sender === "string" ? msg.sender : "Unknown");
 
 
373
  const avatar = isOwn
374
  ? currentUser?.display_name?.charAt(0).toUpperCase() ||
375
  currentUser?.name?.charAt(0).toUpperCase() ||
376
  "B"
377
- : (typeof msg.sender === "object" && msg.sender?.display_name)
378
- ? msg.sender.display_name.charAt(0).toUpperCase()
379
- : (typeof msg.sender === "string" ? msg.sender.charAt(0).toUpperCase() : "?");
 
 
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/leave space room via WebSocket
 
 
 
 
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
- return () => {
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.icon}
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
- ? "Trợ lý AI"
87
- : isDM && dmUser
88
- ? dmUser.name
89
- : roomName || `# ${activeRoom}`;
90
 
91
  const headerSubtitle = hasNoSelection
92
  ? "Chọn một cuộc trò chuyện để bắt đầu"
93
  : isBotRoom
94
- ? "Hỏi đáp với trợ lý AI"
95
- : isDM && dmUser
96
- ? dmUser.isOnline
97
- ? "Đang hoạt động"
98
- : "Ngoại tuyến"
99
- : roomDescription || "";
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="chat-input-wrapper border rounded-lg p-1 transition-colors relative"
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
- className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors"
 
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
- className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors"
 
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
- {hasNoSelection
269
- ? "Chọn một cuộc trò chuyện"
270
- : dmUser
271
- ? `Bắt đầu trò chuyện với ${dmUser.name}`
272
- : "Chưa có tin nhắn nào"}
 
 
273
  </div>
274
  <div
275
  className="text-sm leading-relaxed max-w-xs"
276
  style={{ color: "var(--text-muted)" }}
277
  >
278
- {hasNoSelection
279
- ? "Hãy chọn một ngườii bạn bên trái để bắt đầu nhắn tin."
280
- : dmUser
281
- ? "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é!"
282
- : "Chọn một cuộc trò chuyện để bắt đầu nhắn tin."}
 
 
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 { cancelCreateSpace, navigateToSpace } from "../../store/slices/appSlice";
 
 
 
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 === 'Enter') {
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 nếu BE hỗ trợ (URL string theo API doc)
135
- // Nếu BE chưa hỗ trợ, có thể bỏ qua hoặc comment dòng dưới
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 className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
308
- style={{ borderColor: "var(--text-muted)", borderTopColor: "transparent" }}
 
 
 
 
309
  />
310
  </div>
311
  ) : (
312
  <button
313
- onClick={() => handleSearchKeyDown({ key: 'Enter', preventDefault: () => {} })}
 
 
 
 
 
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({ room, isActive, onClick, lastMessage, lastMessageTime, unreadCount }) {
 
 
 
 
 
 
 
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: unreadCount > 0
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 { spaces, roomsMap, roomsLoading, fetchedRooms } = useSelector(
273
- (state) => state.space,
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 = lastMsg.sender_id && currentUser?.id && String(lastMsg.sender_id) === String(currentUser.id);
285
- const senderName = isOwn ? "Bạn" : (lastMsg.sender_display_name || lastMsg.username || "Unknown");
 
 
 
 
 
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
- className="text-base font-semibold truncate"
333
- style={{ color: "var(--text-primary)" }}
334
- >
335
- {currentSpace?.name || "Space"}
 
 
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={lastMsg ? `${lastMsg.senderName}: ${lastMsg.content}` : null}
407
- unreadCount={room.unreadCount || 0}
 
 
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;