Spaces:
Running
Running
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import { useSelector, useDispatch } from "react-redux"; | |
| // REMOVED: No mock data fallback — all messages come from Redux store only | |
| import { ChatHeader, ChatMessages, ChatInput } from "./chatarea/index.js"; | |
| import { SettingsView } from "./settings/index.js"; | |
| import { CreateRoomView } from "./createroom/index.js"; | |
| import { UserProfilePopup } from "./memberlist/index.js"; | |
| import { setSelectedUser, clearSelectedUser } from "../store/slices/chatSlice"; | |
| import { | |
| addMessage as addDMMessage, | |
| updateMessage, | |
| setTyping, | |
| clearTyping, | |
| setActiveConversation, | |
| markConversationAsRead, | |
| createOrGetConversation, | |
| clearUnreadCount, | |
| updateUserStatus, | |
| fetchMessages, | |
| } from "../store/slices/dmSlice"; | |
| import { fetchRoomMessages } from "../store/slices/messageSlice"; | |
| import { addMessage } from "../store/slices/messageSlice"; | |
| import { | |
| createRoom, | |
| clearRoomUnreadCount, | |
| } from "../store/slices/spaceSlice"; | |
| import socketService from "../services/socket.service"; | |
| function ChatArea({ | |
| activeView, | |
| activeRoom, | |
| onToggleRoomList, | |
| onToggleMemberList, | |
| roomListCollapsed, | |
| memberListCollapsed, | |
| onOpenRoomSettings, | |
| }) { | |
| const dispatch = useDispatch(); | |
| const { isDark } = useSelector((state) => state.theme); | |
| const { selectedUser, selectedDMUser } = useSelector((state) => state.chat); | |
| const { user: currentUser } = useSelector((state) => state.auth); | |
| const { | |
| messages: dmMessagesMap, | |
| activeConversationId, | |
| activeConversation, | |
| typing: typingMap, | |
| messagesLoading, | |
| fetchedConversations, | |
| preloadPhase, | |
| onlineUsers, | |
| } = useSelector((state) => state.dm); | |
| const appState = useSelector((state) => state.app); | |
| const room = activeRoom || appState.activeRoom; | |
| const view = activeView || appState.activeView; | |
| const isAgentRoom = false; | |
| const [dmUser, setDmUser] = useState(null); | |
| const [isTyping, setIsTypingState] = useState(false); | |
| const [sendingMessages, setSendingMessages] = useState({}); // { [tempId]: { content, timestamp, sendTime } } | |
| const messageTimersRef = useRef({}); // { [tempId]: sendStartTime } | |
| const [isCreatingConversation, setIsCreatingConversation] = useState(false); | |
| const typingTimeoutRef = useRef(null); | |
| const [roomTypingUser, setRoomTypingUser] = useState(null); // { name, timeoutId } | |
| const roomTypingTimeoutRef = useRef(null); // Track typing timeout to clear properly | |
| const isCreatingConversationRef = useRef(false); | |
| const pendingMessageQueueRef = useRef([]); // Queue messages while creating conversation | |
| const sendingTimeoutsRef = useRef({}); // { [tempId]: timeoutId } - auto-clear pending after 10s | |
| const { spaces, roomsMap, membersMap } = useSelector((state) => state.space); | |
| // Find active spaceId for the current room | |
| const activeSpaceId = (() => { | |
| if (!room) return null; | |
| // Try from spaces array first | |
| const fromSpaces = spaces.find((s) => | |
| (roomsMap[s.id] || []).some((r) => r.id === room), | |
| )?.id; | |
| if (fromSpaces) return fromSpaces; | |
| // Fallback: search directly in roomsMap keys | |
| for (const [spaceId, rooms] of Object.entries(roomsMap)) { | |
| if (rooms.some((r) => r.id === room)) return spaceId; | |
| } | |
| return null; | |
| })(); | |
| const isBotRoom = room === "tro-ly-ai" || room === "studybot"; | |
| // Check if current room is a space room (exists in roomsMap) | |
| const isSpaceRoom = room && !isBotRoom && Object.values(roomsMap).some( | |
| (rooms) => rooms.some((r) => r.id === room) | |
| ); | |
| // DM view: either explicit messages view, or a room that looks like a DM conversation | |
| // (not a space room). DM conversations use UUID format or bot room. | |
| const isDM = | |
| view === "messages" || | |
| isBotRoom || | |
| (room && !isSpaceRoom && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(room)); | |
| // Move useSelector hooks to top (was at line 281) | |
| const { messages: roomMessagesMap, messagesLoading: roomMessagesLoading, fetchedRooms } = useSelector((state) => state.message); | |
| // Build dmUser from selectedDMUser or activeConversation | |
| useEffect(() => { | |
| if (!isDM || !room) { | |
| setDmUser(null); | |
| return; | |
| } | |
| if (isBotRoom) { | |
| setDmUser({ | |
| id: "studybot", | |
| name: "StudyBot", | |
| avatar: "🤖", | |
| color: null, | |
| isOnline: true, | |
| isFriend: true, | |
| email: "", | |
| bio: "Trợ lý AI học tập", | |
| isBot: true, | |
| }); | |
| return; | |
| } | |
| if (activeConversation?.other_user) { | |
| const ou = activeConversation.other_user; | |
| const isOnlineRealtime = onlineUsers.includes(String(ou.id)); | |
| console.log("[ChatArea] onlineUsers:", onlineUsers, "ou.id:", ou.id, "isOnlineRealtime:", isOnlineRealtime, "ou.status:", ou.status); | |
| setDmUser({ | |
| id: ou.id, | |
| name: ou.display_name || "Unknown", | |
| avatar: ou.avatar_url || null, | |
| color: ou.color || null, | |
| isOnline: isOnlineRealtime || ou.status === "online", | |
| isFriend: true, | |
| email: ou.email || "", | |
| bio: ou.bio || "", | |
| isBot: false, | |
| }); | |
| } else if (selectedDMUser) { | |
| const userId = selectedDMUser.id || selectedDMUser.userId; | |
| const isOnlineRealtime = onlineUsers.includes(String(userId)); | |
| setDmUser({ | |
| id: userId, | |
| name: selectedDMUser.name || "Unknown", | |
| avatar: selectedDMUser.avatar || null, | |
| color: selectedDMUser.color || null, | |
| isOnline: isOnlineRealtime || selectedDMUser.isOnline || false, | |
| isFriend: selectedDMUser.isFriend ?? true, | |
| email: selectedDMUser.email || "", | |
| bio: selectedDMUser.bio || "", | |
| isBot: selectedDMUser.isBot || false, | |
| }); | |
| } else { | |
| setDmUser({ | |
| id: room, | |
| name: room, | |
| avatar: null, | |
| color: null, | |
| isOnline: false, | |
| isFriend: false, | |
| email: "", | |
| bio: "", | |
| isBot: false, | |
| }); | |
| } | |
| }, [room, isDM, isBotRoom, selectedDMUser, activeConversation, onlineUsers]); | |
| // Join DM via WebSocket when opening a conversation | |
| // NOTE: We intentionally do NOT leaveDM on cleanup. | |
| // Once joined, the user stays in the DM room to receive real-time messages | |
| // even when switching to another conversation or tab. | |
| // leaveDM is only called on logout (in authSlice). | |
| useEffect(() => { | |
| if ( | |
| !isDM || | |
| !activeConversationId || | |
| activeConversationId.toString().startsWith("temp-conv-") | |
| ) | |
| return; | |
| socketService.joinDM(activeConversationId); | |
| dispatch(markConversationAsRead(activeConversationId)); | |
| // 🆕 Clear unread count when opening conversation | |
| dispatch(clearUnreadCount({ conversationId: activeConversationId })); | |
| }, [isDM, activeConversationId, dispatch]); | |
| // 🆕 Lazy load DM messages when opening a conversation | |
| useEffect(() => { | |
| if ( | |
| !isDM || | |
| !activeConversationId || | |
| activeConversationId.toString().startsWith("temp-conv-") | |
| ) | |
| return; | |
| const isFetched = fetchedConversations[activeConversationId]; | |
| if (!isFetched) { | |
| dispatch(fetchMessages({ conversationId: activeConversationId, page: 1, limit: 50 })); | |
| } | |
| }, [isDM, activeConversationId, dispatch, fetchedConversations]); | |
| // 🆕 Lazy load bot DM messages when opening bot conversation | |
| useEffect(() => { | |
| if (!isBotRoom || !room) return; | |
| const isFetched = fetchedConversations[room]; | |
| if (!isFetched) { | |
| dispatch(fetchMessages({ conversationId: room, page: 1, limit: 50 })); | |
| } | |
| }, [isBotRoom, room, dispatch, fetchedConversations]); | |
| // 🆕 Lazy load room messages when entering a room | |
| useEffect(() => { | |
| if (!isSpaceRoom || !room) return; | |
| const isFetched = fetchedRooms[room]; | |
| if (!isFetched) { | |
| dispatch(fetchRoomMessages({ roomId: room, page: 1, limit: 50 })); | |
| } | |
| }, [isSpaceRoom, room, dispatch, fetchedRooms]); | |
| // Listen for messageSent event to clear sending status | |
| useEffect(() => { | |
| if (!isSpaceRoom) return; | |
| const handleMessageSent = (data) => { | |
| console.log("[ChatArea] messageSent received:", JSON.stringify(data, null, 2)); | |
| const tempId = data?.tempId || data?.temp_id; | |
| if (tempId) { | |
| console.log("[ChatArea] Clearing sendingMessages for tempId:", tempId); | |
| // Clear auto-clear timeout | |
| if (sendingTimeoutsRef.current[tempId]) { | |
| clearTimeout(sendingTimeoutsRef.current[tempId]); | |
| delete sendingTimeoutsRef.current[tempId]; | |
| } | |
| setSendingMessages((prev) => { | |
| if (!prev[tempId]) { | |
| console.log("[ChatArea] tempId not found in sendingMessages:", tempId, Object.keys(prev)); | |
| return prev; | |
| } | |
| const next = { ...prev }; | |
| delete next[tempId]; | |
| console.log("[ChatArea] Cleared tempId:", tempId); | |
| return next; | |
| }); | |
| } | |
| }; | |
| socketService.onMessageSent(handleMessageSent); | |
| return () => { | |
| socketService.off("messageSent", handleMessageSent); | |
| }; | |
| }, [isSpaceRoom]); | |
| // Listen for room typing events | |
| useEffect(() => { | |
| if (!isSpaceRoom || !room) return; | |
| const handleUserTyping = (data) => { | |
| if (data.roomId === room && data.userId !== currentUser?.id) { | |
| setRoomTypingUser(data.senderName || "Someone"); | |
| // Clear previous timeout before setting new one | |
| if (roomTypingTimeoutRef.current) { | |
| clearTimeout(roomTypingTimeoutRef.current); | |
| } | |
| // Auto-clear after 3s | |
| roomTypingTimeoutRef.current = setTimeout(() => { | |
| setRoomTypingUser((current) => | |
| current === (data.senderName || "Someone") ? null : current | |
| ); | |
| }, 3000); | |
| } | |
| }; | |
| const handleUserStopTyping = (data) => { | |
| if (data.roomId === room) { | |
| setRoomTypingUser(null); | |
| } | |
| }; | |
| socketService.on("userTyping", handleUserTyping); | |
| socketService.on("userStopTyping", handleUserStopTyping); | |
| return () => { | |
| socketService.off("userTyping", handleUserTyping); | |
| socketService.off("userStopTyping", handleUserStopTyping); | |
| if (roomTypingTimeoutRef.current) { | |
| clearTimeout(roomTypingTimeoutRef.current); | |
| roomTypingTimeoutRef.current = null; | |
| } | |
| }; | |
| }, [isSpaceRoom, room, currentUser?.id]); | |
| // 🆕 REMOVED: WebSocket listeners for newDM/dmSent/dmTyping/dmRead | |
| // These are now handled globally in App.jsx (Single Source of Truth). | |
| // ChatArea only reads from Redux store. | |
| // | |
| // Kept: dmTyping listener for typing indicator (only relevant when viewing a conversation) | |
| // Kept: dmRead listener for read receipts (only relevant when viewing a conversation) | |
| // Kept: dmSent listener for optimistic UI cleanup | |
| useEffect(() => { | |
| if (!isDM) return; | |
| const handleDmSent = (data) => { | |
| if (data?.success) { | |
| const tempId = data.tempId || data.temp_id; | |
| const now = Date.now(); | |
| if (tempId && messageTimersRef.current[tempId]) { | |
| const latency = now - messageTimersRef.current[tempId]; | |
| console.log( | |
| `%c[DM Latency] SEND | ${latency}ms | tempId: ${tempId}`, | |
| "color: #22c55e; font-weight: bold;", | |
| ); | |
| delete messageTimersRef.current[tempId]; | |
| } | |
| // Clear auto-clear timeout | |
| if (sendingTimeoutsRef.current[tempId]) { | |
| clearTimeout(sendingTimeoutsRef.current[tempId]); | |
| delete sendingTimeoutsRef.current[tempId]; | |
| } | |
| setSendingMessages((prev) => { | |
| if (!prev[tempId]) return prev; | |
| const next = { ...prev }; | |
| delete next[tempId]; | |
| return next; | |
| }); | |
| } | |
| }; | |
| const handleDmTyping = (data) => { | |
| if (data.conversationId === activeConversationId) { | |
| if (data.isTyping) { | |
| dispatch( | |
| setTyping({ | |
| conversationId: data.conversationId, | |
| userId: data.userId, | |
| isTyping: true, | |
| }), | |
| ); | |
| } else { | |
| dispatch(clearTyping(data.conversationId)); | |
| } | |
| } | |
| }; | |
| const handleDmRead = (data) => { | |
| if (data.conversationId === activeConversationId) { | |
| dispatch( | |
| updateMessage({ | |
| conversationId: activeConversationId, | |
| messageId: data.messageId, | |
| updates: { is_read: true }, | |
| }), | |
| ); | |
| } | |
| }; | |
| console.log("[DM Debug] Registering dmSent listener"); | |
| socketService.onDmSent(handleDmSent); | |
| socketService.onDmTyping(handleDmTyping); | |
| socketService.onDmRead(handleDmRead); | |
| return () => { | |
| socketService.off("dmSent", handleDmSent); | |
| socketService.off("dmTyping", handleDmTyping); | |
| socketService.off("dmRead", handleDmRead); | |
| }; | |
| }, [isDM, activeConversationId, dispatch]); | |
| // ==================== Unified Message Formatting ==================== | |
| // DM and Room messages use the same logic, just different data sources | |
| // Get raw messages based on view type | |
| const conversationId = | |
| activeConversationId || (isDM && room ? room : null); | |
| const rawMessages = isDM | |
| ? (conversationId ? dmMessagesMap[conversationId] || [] : []) | |
| : (roomMessagesMap[room] || []); | |
| // Sort messages by created_at ascending | |
| const sortedMessages = [...rawMessages].sort((a, b) => { | |
| const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; | |
| const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; | |
| if (timeA !== timeB) return timeA - timeB; | |
| return String(a.id).localeCompare(String(b.id)); | |
| }); | |
| // Detect if sender is StudyBot bot | |
| const isStudyBot = (msg) => { | |
| const senderName = typeof msg.sender === "object" | |
| ? msg.sender?.display_name || msg.sender?.username || "" | |
| : typeof msg.sender === "string" | |
| ? msg.sender | |
| : ""; | |
| const senderUsername = typeof msg.sender === "object" | |
| ? msg.sender?.username || "" | |
| : ""; | |
| return senderName === "StudyBot" || senderUsername === "studybot"; | |
| }; | |
| // Convert API messages to unified UI format | |
| const chatMessages = sortedMessages.map((msg) => { | |
| const senderId = msg.sender_id || msg.senderId || msg.sender?.id; | |
| const isOwn = String(senderId) === String(currentUser?.id); | |
| const msgIsBot = isStudyBot(msg); | |
| // Handle both object sender (DM/WS) and string sender (old format) | |
| const senderName = isOwn | |
| ? currentUser?.display_name || currentUser?.name || "Bạn" | |
| : msgIsBot | |
| ? "StudyBot" | |
| : (typeof msg.sender === "object" && msg.sender?.display_name) | |
| ? msg.sender.display_name | |
| : (typeof msg.sender === "string" ? msg.sender : "Unknown"); | |
| const avatar = isOwn | |
| ? currentUser?.display_name?.charAt(0).toUpperCase() || | |
| currentUser?.name?.charAt(0).toUpperCase() || | |
| "B" | |
| : msgIsBot | |
| ? "🤖" | |
| : (typeof msg.sender === "object" && msg.sender?.display_name) | |
| ? msg.sender.display_name.charAt(0).toUpperCase() | |
| : (typeof msg.sender === "string" ? msg.sender.charAt(0).toUpperCase() : "?"); | |
| // Get color: own message from currentUser, DM other from dmUser, space from membersMap | |
| const color = (() => { | |
| if (isOwn) return currentUser?.color || null; | |
| if (msgIsBot) return null; // Bot uses tertiary color in ChatMessage component | |
| if (isDM && dmUser?.id && String(dmUser.id) === String(senderId)) { | |
| return dmUser.color || null; | |
| } | |
| if (isSpaceRoom && activeSpaceId && membersMap[activeSpaceId]) { | |
| const member = membersMap[activeSpaceId].find( | |
| (m) => String(m.id) === String(senderId), | |
| ); | |
| return member?.color || member?.profile?.color || null; | |
| } | |
| return msg.sender?.color || null; | |
| })(); | |
| const timestamp = (() => { | |
| if (!msg.created_at) return "—"; | |
| const date = new Date(msg.created_at); | |
| if (isNaN(date.getTime())) return msg.created_at; | |
| return date.toLocaleTimeString("vi-VN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }); | |
| })(); | |
| return { | |
| id: msg.id, | |
| sender: senderName, | |
| avatar, | |
| color, | |
| timestamp, | |
| content: msg.content, | |
| isPinned: msg.is_pinned || false, | |
| replyTo: msg.reply_to || null, | |
| isOwn, | |
| isBot: msgIsBot, | |
| senderId, | |
| pending: msg.pending || false, | |
| is_read: msg.is_read, | |
| created_at: msg.created_at, | |
| }; | |
| }); | |
| // Join space room via WebSocket | |
| // NOTE: We intentionally do NOT leaveRoom on cleanup. | |
| // Once joined, the user stays in the room to receive real-time messages | |
| // even when switching to another room, DM, or tab. | |
| // This matches DM behavior where all conversations are joined permanently. | |
| const [joinedRoom, setJoinedRoom] = useState(null); | |
| useEffect(() => { | |
| if (isSpaceRoom && room) { | |
| socketService.joinRoom(room).then(() => { | |
| setJoinedRoom(room); | |
| }).catch((err) => { | |
| console.error("[ChatArea] Failed to join room:", err); | |
| }); | |
| // No cleanup leave — keep receiving messages for this room | |
| } | |
| }, [isSpaceRoom, room]); | |
| // 🆕 Clear room unread count when opening a room | |
| useEffect(() => { | |
| if (isSpaceRoom && room) { | |
| dispatch(clearRoomUnreadCount({ roomId: room })); | |
| } | |
| }, [isSpaceRoom, room, dispatch]); | |
| // Space members are already fetched globally in App.jsx | |
| // No need to fetch again when entering a room | |
| // Typing indicator from other user | |
| const isOtherUserTyping = isDM && activeConversationId | |
| ? typingMap[activeConversationId]?.isTyping | |
| : false; | |
| const otherTyping = isOtherUserTyping; | |
| // Find current room info for placeholder | |
| const currentRoomInfo = isSpaceRoom | |
| ? Object.values(roomsMap).flat().find((r) => r.id === room) | |
| : null; | |
| // Check if we already know this room/DM has no messages (last_message is null from API) | |
| // This lets us skip loading skeleton and show empty state immediately | |
| const isKnownEmpty = (() => { | |
| if (isSpaceRoom && currentRoomInfo) { | |
| return currentRoomInfo.last_message === null || currentRoomInfo.last_message === undefined; | |
| } | |
| if (isDM && activeConversation) { | |
| return activeConversation.last_message === null || activeConversation.last_message === undefined; | |
| } | |
| if (isBotRoom) { | |
| return false; // Bot room: always fetch messages | |
| } | |
| return false; | |
| })(); | |
| // Check if we're in a space view but no room selected yet | |
| const isSpaceViewNoRoom = view === "space" && !room && appState.activeSpace; | |
| const currentSpaceForWelcome = isSpaceViewNoRoom | |
| ? spaces.find((s) => s.id === appState.activeSpace) | |
| : null; | |
| const spaceWelcome = isSpaceViewNoRoom && currentSpaceForWelcome | |
| ? { | |
| title: `Chào mừng đến với ${currentSpaceForWelcome.name}`, | |
| description: currentSpaceForWelcome.description || "Hãy chọn một room bên trái để bắt đầu trò chuyện.", | |
| } | |
| : null; | |
| const placeholder = isBotRoom || (isDM && room === "studybot-dm") | |
| ? "Hỏi trợ lý AI..." | |
| : dmUser | |
| ? `Nhắn tin cho ${dmUser.name}...` | |
| : currentRoomInfo | |
| ? `Nhắn tin trong #${currentRoomInfo.name}...` | |
| : "Nhắn tin cho nhóm học..."; | |
| // Handle send message via WebSocket | |
| const handleSend = useCallback( | |
| async (content, replyToMsg, files) => { | |
| if (!content.trim()) return; | |
| if (isDM) { | |
| // Guard: prevent chatting with self | |
| if (dmUser?.id && dmUser.id === currentUser?.id) { | |
| console.warn("Cannot send message to yourself"); | |
| return; | |
| } | |
| let conversationId = activeConversationId; | |
| const contentTrimmed = content.trim(); | |
| const msgTempId = `temp-${Date.now()}`; | |
| // Optimistic UI for message | |
| const optimisticMsg = { | |
| id: msgTempId, | |
| sender: currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar: | |
| currentUser?.display_name?.charAt(0).toUpperCase() || | |
| currentUser?.name?.charAt(0).toUpperCase() || | |
| "B", | |
| timestamp: new Date().toLocaleTimeString("vi-VN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }), | |
| content: contentTrimmed, | |
| isPinned: false, | |
| replyTo: replyToMsg || null, | |
| isOwn: true, | |
| senderId: currentUser?.id, | |
| sender_id: currentUser?.id, | |
| is_read: false, | |
| created_at: new Date().toISOString(), | |
| pending: true, | |
| }; | |
| // Track sending message | |
| const sendStartTime = Date.now(); | |
| messageTimersRef.current[msgTempId] = sendStartTime; | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [msgTempId]: { content: contentTrimmed, timestamp: sendStartTime }, | |
| })); | |
| // Auto-clear pending status after 10s if server doesn't confirm | |
| if (sendingTimeoutsRef.current[msgTempId]) { | |
| clearTimeout(sendingTimeoutsRef.current[msgTempId]); | |
| } | |
| sendingTimeoutsRef.current[msgTempId] = setTimeout(() => { | |
| setSendingMessages((prev) => { | |
| if (!prev[msgTempId]) return prev; | |
| const next = { ...prev }; | |
| delete next[msgTempId]; | |
| return next; | |
| }); | |
| delete sendingTimeoutsRef.current[msgTempId]; | |
| }, 10000); | |
| // Log send start | |
| console.log( | |
| `%c[DM Latency] START | tempId: ${msgTempId} | conv: ${conversationId || "NEW"}`, | |
| "color: #f59e0b; font-weight: bold;", | |
| ); | |
| // Lazy create conversation if not exists | |
| if (!conversationId && dmUser?.id) { | |
| if (isCreatingConversationRef.current) { | |
| // Queue the message for retry after conversation is created | |
| console.warn("[ChatArea] Conversation creation in progress, message queued"); | |
| pendingMessageQueueRef.current.push({ | |
| content: contentTrimmed, | |
| replyToMsg, | |
| files, | |
| msgTempId, | |
| }); | |
| // Show feedback to user | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [msgTempId]: { | |
| content: contentTrimmed, | |
| timestamp: Date.now(), | |
| queued: true, | |
| }, | |
| })); | |
| return; | |
| } | |
| isCreatingConversationRef.current = true; | |
| setIsCreatingConversation(true); | |
| const tempConvId = `temp-conv-${dmUser.id}`; | |
| // Optimistically set active conversation | |
| const tempConv = { | |
| id: tempConvId, | |
| other_user: dmUser, | |
| isTemp: true, | |
| unread_count: 0, | |
| }; | |
| dispatch(setActiveConversation(tempConv)); | |
| // Optimistically add message | |
| dispatch( | |
| addDMMessage({ | |
| conversationId: tempConvId, | |
| message: { | |
| ...optimisticMsg, | |
| conversation_id: tempConvId, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: | |
| currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| }, | |
| }), | |
| ); | |
| // Stop typing optimistic | |
| socketService.dmTyping(tempConvId, false); | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| typingTimeoutRef.current = null; | |
| } | |
| try { | |
| const result = await dispatch( | |
| createOrGetConversation(dmUser.id), | |
| ).unwrap(); | |
| if (result) { | |
| // Swap temp ID with real ID in Redux | |
| dispatch({ | |
| type: "dm/replaceTempConversation", | |
| payload: { tempId: tempConvId, realConversation: result }, | |
| }); | |
| // Join the real DM room so we can receive real-time messages | |
| socketService.joinDM(result.id); | |
| // Now send via WebSocket | |
| socketService.sendDM(result.id, contentTrimmed, msgTempId); | |
| // Process any queued messages | |
| const queued = pendingMessageQueueRef.current; | |
| pendingMessageQueueRef.current = []; | |
| for (const queuedMsg of queued) { | |
| const qTempId = queuedMsg.msgTempId || `temp-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; | |
| socketService.sendDM(result.id, queuedMsg.content, qTempId); | |
| // Add optimistic message for queued items | |
| dispatch( | |
| addDMMessage({ | |
| conversationId: result.id, | |
| message: { | |
| id: qTempId, | |
| sender: currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar: | |
| currentUser?.display_name?.charAt(0).toUpperCase() || | |
| currentUser?.name?.charAt(0).toUpperCase() || | |
| "B", | |
| timestamp: new Date().toLocaleTimeString("vi-VN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }), | |
| content: queuedMsg.content, | |
| isPinned: false, | |
| replyTo: queuedMsg.replyToMsg || null, | |
| isOwn: true, | |
| senderId: currentUser?.id, | |
| sender_id: currentUser?.id, | |
| is_read: false, | |
| created_at: new Date().toISOString(), | |
| pending: true, | |
| conversation_id: result.id, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: | |
| currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| }, | |
| }), | |
| ); | |
| } | |
| } | |
| } catch (err) { | |
| console.error("Failed to create conversation:", err); | |
| // Mark message as failed | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [msgTempId]: { | |
| ...prev[msgTempId], | |
| failed: true, | |
| error: err?.message || "Không thể tạo cuộc trò chuyện", | |
| }, | |
| })); | |
| // Also mark queued messages as failed | |
| pendingMessageQueueRef.current.forEach((queued) => { | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [queued.msgTempId]: { | |
| ...prev[queued.msgTempId], | |
| failed: true, | |
| error: err?.message || "Không thể tạo cuộc trò chuyện", | |
| }, | |
| })); | |
| }); | |
| pendingMessageQueueRef.current = []; | |
| } finally { | |
| isCreatingConversationRef.current = false; | |
| setIsCreatingConversation(false); | |
| } | |
| return; | |
| } | |
| if (!conversationId) return; | |
| // Existing conversation path | |
| socketService.sendDM(conversationId, contentTrimmed, msgTempId); | |
| dispatch( | |
| addDMMessage({ | |
| conversationId, | |
| message: { | |
| ...optimisticMsg, | |
| conversation_id: conversationId, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: | |
| currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| }, | |
| }), | |
| ); | |
| // Stop typing | |
| socketService.dmTyping(conversationId, false); | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| typingTimeoutRef.current = null; | |
| } | |
| } else if (isSpaceRoom && room) { | |
| // Space room message via WebSocket | |
| const msgTempId = `temp-${Date.now()}`; | |
| const contentTrimmed = content.trim(); | |
| // Optimistic UI - same format as DM | |
| const optimisticMsg = { | |
| id: msgTempId, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| sender_id: currentUser?.id, | |
| content: contentTrimmed, | |
| created_at: new Date().toISOString(), | |
| is_read: false, | |
| pending: true, | |
| }; | |
| // Track sending message | |
| const sendStartTime = Date.now(); | |
| messageTimersRef.current[msgTempId] = sendStartTime; | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [msgTempId]: { content: contentTrimmed, timestamp: sendStartTime }, | |
| })); | |
| // Auto-clear pending status after 10s if server doesn't confirm | |
| if (sendingTimeoutsRef.current[msgTempId]) { | |
| clearTimeout(sendingTimeoutsRef.current[msgTempId]); | |
| } | |
| sendingTimeoutsRef.current[msgTempId] = setTimeout(() => { | |
| setSendingMessages((prev) => { | |
| if (!prev[msgTempId]) return prev; | |
| const next = { ...prev }; | |
| delete next[msgTempId]; | |
| return next; | |
| }); | |
| delete sendingTimeoutsRef.current[msgTempId]; | |
| }, 10000); | |
| dispatch(addMessage({ roomId: room, message: optimisticMsg })); | |
| // Send with tempId so BE can echo it back | |
| socketService.sendMessage({ roomId: room, content: contentTrimmed, tempId: msgTempId }); | |
| // Stop typing | |
| socketService.emitStopTyping(room); | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| typingTimeoutRef.current = null; | |
| } | |
| } else { | |
| // Legacy: dispatch to Redux for non-DM | |
| const newMessage = { | |
| id: Date.now(), | |
| sender: "You", | |
| avatar: "Y", | |
| timestamp: new Date().toLocaleTimeString("vi-VN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }), | |
| content, | |
| isPinned: false, | |
| replyTo: replyToMsg || null, | |
| }; | |
| dispatch(addMessage({ roomId: room, message: newMessage })); | |
| } | |
| }, | |
| [isDM, activeConversationId, dmUser, currentUser, dispatch, room, isSpaceRoom, isBotRoom], | |
| ); | |
| // Handle typing indicator | |
| const handleTyping = useCallback(() => { | |
| if (isDM && activeConversationId) { | |
| if (!isTyping) { | |
| setIsTypingState(true); | |
| socketService.dmTyping(activeConversationId, true); | |
| } | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| } | |
| typingTimeoutRef.current = setTimeout(() => { | |
| setIsTypingState(false); | |
| socketService.dmTyping(activeConversationId, false); | |
| }, 3000); | |
| } else if (isSpaceRoom && room) { | |
| // Space room typing with debounce | |
| socketService.emitTyping(room); | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| } | |
| typingTimeoutRef.current = setTimeout(() => { | |
| socketService.emitStopTyping(room); | |
| }, 3000); | |
| } | |
| }, [isDM, activeConversationId, isTyping, isSpaceRoom, room]); | |
| // Cleanup typing on unmount / before unload | |
| useEffect(() => { | |
| const handleBeforeUnload = () => { | |
| if (isDM && activeConversationId) { | |
| socketService.dmTyping(activeConversationId, false); | |
| } | |
| }; | |
| window.addEventListener("beforeunload", handleBeforeUnload); | |
| return () => { | |
| window.removeEventListener("beforeunload", handleBeforeUnload); | |
| // Also clear typing when unmounting | |
| if (isDM && activeConversationId) { | |
| socketService.dmTyping(activeConversationId, false); | |
| } | |
| // Clear all pending sending timeouts | |
| Object.values(sendingTimeoutsRef.current).forEach(clearTimeout); | |
| sendingTimeoutsRef.current = {}; | |
| }; | |
| }, [isDM, activeConversationId]); | |
| const handleStopTyping = useCallback(() => { | |
| if (isDM && activeConversationId) { | |
| setIsTypingState(false); | |
| socketService.dmTyping(activeConversationId, false); | |
| if (typingTimeoutRef.current) { | |
| clearTimeout(typingTimeoutRef.current); | |
| typingTimeoutRef.current = null; | |
| } | |
| } else if (isSpaceRoom && room) { | |
| socketService.emitStopTyping(room); | |
| } | |
| }, [isDM, activeConversationId, isSpaceRoom, room]); | |
| // Retry sending a failed message | |
| const handleRetryMessage = useCallback( | |
| (msg) => { | |
| if (!msg?.content) return; | |
| // Remove the failed status | |
| setSendingMessages((prev) => { | |
| const next = { ...prev }; | |
| delete next[msg.id]; | |
| return next; | |
| }); | |
| // Re-send the message | |
| if (isDM && activeConversationId) { | |
| const newTempId = `temp-${Date.now()}`; | |
| socketService.sendDM(activeConversationId, msg.content, newTempId); | |
| // Add new optimistic message | |
| dispatch( | |
| addDMMessage({ | |
| conversationId: activeConversationId, | |
| message: { | |
| id: newTempId, | |
| sender: currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar: | |
| currentUser?.display_name?.charAt(0).toUpperCase() || | |
| currentUser?.name?.charAt(0).toUpperCase() || | |
| "B", | |
| timestamp: new Date().toLocaleTimeString("vi-VN", { | |
| hour: "2-digit", | |
| minute: "2-digit", | |
| }), | |
| content: msg.content, | |
| isPinned: false, | |
| replyTo: msg.replyTo || null, | |
| isOwn: true, | |
| senderId: currentUser?.id, | |
| sender_id: currentUser?.id, | |
| is_read: false, | |
| created_at: new Date().toISOString(), | |
| pending: true, | |
| conversation_id: activeConversationId, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: | |
| currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| }, | |
| }), | |
| ); | |
| // Track sending | |
| const sendStartTime = Date.now(); | |
| messageTimersRef.current[newTempId] = sendStartTime; | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [newTempId]: { content: msg.content, timestamp: sendStartTime }, | |
| })); | |
| } else if (isSpaceRoom && room) { | |
| const newTempId = `temp-${Date.now()}`; | |
| socketService.sendMessage({ | |
| roomId: room, | |
| content: msg.content, | |
| tempId: newTempId, | |
| }); | |
| dispatch( | |
| addMessage({ | |
| roomId: room, | |
| message: { | |
| id: newTempId, | |
| sender: { | |
| id: currentUser?.id, | |
| display_name: | |
| currentUser?.display_name || currentUser?.name || "Bạn", | |
| avatar_url: currentUser?.avatar || null, | |
| }, | |
| sender_id: currentUser?.id, | |
| content: msg.content, | |
| created_at: new Date().toISOString(), | |
| is_read: false, | |
| pending: true, | |
| }, | |
| }), | |
| ); | |
| const sendStartTime = Date.now(); | |
| messageTimersRef.current[newTempId] = sendStartTime; | |
| setSendingMessages((prev) => ({ | |
| ...prev, | |
| [newTempId]: { content: msg.content, timestamp: sendStartTime }, | |
| })); | |
| } | |
| }, | |
| [isDM, activeConversationId, isSpaceRoom, room, currentUser, dispatch], | |
| ); | |
| return ( | |
| <div | |
| className="flex-1 flex flex-col min-w-0" | |
| style={{ background: "var(--bg-surface)" }} | |
| > | |
| <ChatHeader | |
| isDark={isDark} | |
| activeRoom={room} | |
| isBotRoom={isBotRoom} | |
| isDM={isDM} | |
| dmUser={dmUser} | |
| roomName={currentRoomInfo?.name} | |
| roomDescription={currentRoomInfo?.description} | |
| spaceWelcome={spaceWelcome} | |
| onToggleRoomList={onToggleRoomList} | |
| onToggleMemberList={onToggleMemberList} | |
| roomListCollapsed={roomListCollapsed} | |
| memberListCollapsed={memberListCollapsed} | |
| onOpenRoomSettings={onOpenRoomSettings} | |
| /> | |
| {/* User Profile Popup */} | |
| {selectedUser && ( | |
| <UserProfilePopup | |
| user={selectedUser} | |
| isDark={isDark} | |
| onClose={() => dispatch(clearSelectedUser())} | |
| onSendMessage={(user) => { | |
| dispatch(clearSelectedUser()); | |
| }} | |
| /> | |
| )} | |
| <ChatMessages | |
| isDark={isDark} | |
| chatMessages={chatMessages} | |
| dmUser={dmUser} | |
| hasNoSelection={isDM && !dmUser} | |
| spaceWelcome={spaceWelcome} | |
| isKnownEmpty={isKnownEmpty} | |
| sendingMessages={sendingMessages} | |
| isLoading={!isKnownEmpty && ((isDM && messagesLoading) || (isSpaceRoom && roomMessagesLoading))} | |
| conversationId={conversationId} | |
| roomId={isSpaceRoom ? room : null} | |
| onRetry={handleRetryMessage} | |
| onShowProfile={(senderName) => { | |
| if ( | |
| isDM && | |
| dmUser && | |
| senderName !== (currentUser?.display_name || currentUser?.name) | |
| ) { | |
| dispatch(setSelectedUser(dmUser)); | |
| } else if ( | |
| senderName !== (currentUser?.display_name || currentUser?.name) | |
| ) { | |
| dispatch( | |
| setSelectedUser({ | |
| id: senderName.toLowerCase(), | |
| name: senderName, | |
| avatar: senderName.charAt(0).toUpperCase(), | |
| isOnline: true, | |
| isFriend: false, | |
| email: `${senderName.toLowerCase()}@vinclassroom.edu.vn`, | |
| mutualFriends: Math.floor(Math.random() * 10), | |
| sharedSpaces: ["Toán cao cấp"], | |
| }), | |
| ); | |
| } | |
| }} | |
| isTyping={isBotRoom} | |
| /> | |
| <ChatInput | |
| isDark={isDark} | |
| placeholder={placeholder} | |
| onSend={handleSend} | |
| onTyping={handleTyping} | |
| onStopTyping={handleStopTyping} | |
| disabled={!room} | |
| typingSender={ | |
| isOtherUserTyping | |
| ? dmUser?.name | |
| : roomTypingUser | |
| ? roomTypingUser | |
| : null | |
| } | |
| otherTyping={otherTyping} | |
| /> | |
| </div> | |
| ); | |
| } | |
| function ChatAreaWrapper({ | |
| isCreatingRoom, | |
| onCancelCreateRoom, | |
| isRoomSettingsOpen, | |
| onOpenRoomSettings, | |
| onCloseRoomSettings, | |
| ...props | |
| }) { | |
| const dispatch = useDispatch(); | |
| const appState = useSelector((state) => state.app); | |
| const { isDark } = useSelector((state) => state.theme); | |
| const view = props.activeView || appState.activeView; | |
| if (isCreatingRoom) { | |
| return ( | |
| <CreateRoomView | |
| onCancel={onCancelCreateRoom} | |
| onCreate={async (roomData) => { | |
| const spaceId = appState.activeSpace; | |
| if (!spaceId) { | |
| console.error("[CreateRoom] No active space"); | |
| return; | |
| } | |
| try { | |
| console.log("[CreateRoom] Creating room in space:", spaceId, "data:", roomData); | |
| const payload = { | |
| name: roomData.name, | |
| description: roomData.topic || undefined, | |
| isPrivate: roomData.type === "private", | |
| }; | |
| console.log("[CreateRoom] Payload:", payload); | |
| await dispatch(createRoom({ | |
| spaceId, | |
| data: payload, | |
| })).unwrap(); | |
| console.log("[CreateRoom] Success, closing view"); | |
| onCancelCreateRoom(); | |
| } catch (err) { | |
| console.error("[CreateRoom] Failed:", err); | |
| throw err; | |
| } | |
| }} | |
| /> | |
| ); | |
| } | |
| if (view === "settings") { | |
| return <SettingsView isDark={isDark} />; | |
| } | |
| return <ChatArea {...props} onOpenRoomSettings={onOpenRoomSettings} />; | |
| } | |
| export default ChatAreaWrapper; | |