Spaces:
Sleeping
Sleeping
| import { useState, useRef, useEffect, useCallback } from "react"; | |
| import { FiPaperclip, FiRefreshCw } from "react-icons/fi"; | |
| import { useDispatch } from "react-redux"; | |
| import { ReplyPreview } from "./MessageActions"; | |
| import { renderMessageWithMentions } from "./MessageContent"; | |
| import FileAttachment from "./FileAttachment"; | |
| import QuizCard from "./QuizCard"; | |
| import { getUserColor } from "../../utils/userColor"; | |
| import { fetchMessages } from "../../store/slices/dmSlice"; | |
| import { fetchRoomMessages } from "../../store/slices/messageSlice"; | |
| function ChatMessage({ | |
| msg, | |
| isDark, | |
| onReply, | |
| onEdit, | |
| onShowProfile, | |
| isSending, | |
| onRetry, | |
| }) { | |
| const senderColor = getUserColor(msg.sender, msg.color); | |
| const isOwnMessage = msg.isOwn; | |
| const [isHovered, setIsHovered] = useState(false); | |
| const isFailed = msg.failed || isSending?.failed; | |
| const quizCardData = parseQuizMessage(msg.content); | |
| const handlePlayQuiz = (quizId) => { | |
| window.history.pushState(null, "", `/quiz/${quizId}`); | |
| window.dispatchEvent(new PopStateEvent("popstate")); | |
| }; | |
| return ( | |
| <div | |
| className="flex gap-3 px-3 py-2 rounded-lg transition-colors relative group" | |
| style={{ | |
| background: isHovered ? "var(--hover-primary)" : "transparent", | |
| opacity: msg.pending ? 0.6 : 1, | |
| }} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| > | |
| <div | |
| className="w-9 h-9 rounded-lg flex items-center justify-center text-sm font-semibold shrink-0 cursor-pointer" | |
| style={{ | |
| background: msg.isBot ? "var(--tertiary-active)" : senderColor, | |
| color: msg.isBot | |
| ? "var(--tertiary)" | |
| : isDark | |
| ? "var(--bg-surface)" | |
| : "#fff", | |
| fontSize: msg.isBot ? "1.25rem" : "0.875rem", | |
| }} | |
| onClick={() => onShowProfile && onShowProfile(msg.sender)} | |
| > | |
| {msg.avatar} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| {/* Sender name and timestamp first */} | |
| <div className="flex items-baseline gap-2 mb-1"> | |
| <span | |
| className="text-[13px] font-semibold cursor-pointer" | |
| style={{ | |
| color: msg.isBot ? "var(--tertiary)" : senderColor, | |
| }} | |
| onClick={() => onShowProfile && onShowProfile(msg.sender)} | |
| > | |
| {msg.sender} | |
| </span> | |
| {msg.isBot && ( | |
| <span | |
| className="text-[10px] px-1.5 py-0.5 rounded font-medium" | |
| style={{ | |
| background: "var(--tertiary-active)", | |
| color: "var(--tertiary)", | |
| }} | |
| > | |
| Bot | |
| </span> | |
| )} | |
| <span className="text-[11px]" style={{ color: "var(--text-muted)" }}> | |
| {msg.timestamp} | |
| </span> | |
| {msg.pending && !isFailed && ( | |
| <span | |
| className="text-[10px] italic ml-1" | |
| style={{ | |
| color: "var(--primary)", | |
| fontWeight: 500, | |
| }} | |
| > | |
| · Đang gửi... | |
| </span> | |
| )} | |
| {isFailed && ( | |
| <span | |
| className="text-[10px] italic ml-1" | |
| style={{ | |
| color: "#ef4444", | |
| fontWeight: 500, | |
| }} | |
| > | |
| · Gửi thất bại | |
| </span> | |
| )} | |
| </div> | |
| {/* Message content */} | |
| <div | |
| className="text-sm leading-relaxed" | |
| style={{ color: "var(--text-primary)" }} | |
| > | |
| {quizCardData ? ( | |
| <QuizCard | |
| quizId={quizCardData.quizId} | |
| quizTitle={quizCardData.quizTitle} | |
| questionCount={quizCardData.questionCount} | |
| passingScore={quizCardData.passingScore} | |
| onPlay={handlePlayQuiz} | |
| /> | |
| ) : ( | |
| renderMessageWithMentions( | |
| msg.content, | |
| isDark, | |
| onShowProfile, | |
| msg.isBot, | |
| ) | |
| )} | |
| {msg.isEdited && ( | |
| <span | |
| className="ml-1 text-[10px] italic" | |
| style={{ color: "var(--text-muted)" }} | |
| > | |
| (đã chỉnh sửa) | |
| </span> | |
| )} | |
| </div> | |
| {msg.hasAttachment && msg.attachments && msg.attachments.length > 0 ? ( | |
| <div className="mt-2 space-y-2"> | |
| {msg.attachments.map((attachment, index) => ( | |
| <FileAttachment | |
| key={index} | |
| attachment={attachment} | |
| isDark={isDark} | |
| /> | |
| ))} | |
| </div> | |
| ) : msg.hasAttachment ? ( | |
| <div | |
| className="flex items-center gap-2 mt-2 px-3 py-2 border rounded-md text-sm cursor-pointer" | |
| style={{ | |
| background: "var(--card-bg)", | |
| borderColor: "var(--border-primary)", | |
| color: "var(--primary)", | |
| }} | |
| > | |
| <FiPaperclip size={14} /> {msg.attachmentName} | |
| </div> | |
| ) : null} | |
| {/* Retry button for failed messages */} | |
| {isFailed && onRetry && ( | |
| <button | |
| onClick={() => onRetry(msg)} | |
| className="mt-1.5 flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors" | |
| style={{ color: "#ef4444" }} | |
| onMouseEnter={(e) => (e.currentTarget.style.color = "#dc2626")} | |
| onMouseLeave={(e) => (e.currentTarget.style.color = "#ef4444")} | |
| > | |
| <FiRefreshCw size={12} /> | |
| Thử lại | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function parseQuizMessage(content) { | |
| if (!content || typeof content !== "string" || !content.includes("Quiz ID")) { | |
| return null; | |
| } | |
| const quizId = content.match(/Quiz ID:\s*`?([0-9a-f-]{36})`?/i)?.[1]; | |
| if (!quizId) return null; | |
| const title = | |
| content.match(/\*\*([^*]+)\*\*/)?.[1]?.trim() || | |
| content.match(/^#?\s*(.+)$/m)?.[1]?.trim() || | |
| "Quiz"; | |
| const questionCount = Number(content.match(/(\d+)\s*câu hỏi/i)?.[1] || 0); | |
| const passingScore = Number(content.match(/Cần\s*(\d+)\s*điểm/i)?.[1] || 60); | |
| return { | |
| quizId, | |
| quizTitle: title, | |
| questionCount, | |
| passingScore, | |
| }; | |
| } | |
| function TypingIndicator({ isDark, senderName = "Đang nhập" }) { | |
| return ( | |
| <div | |
| className="flex items-center gap-2 px-4 py-1.5" | |
| style={{ background: "transparent" }} | |
| > | |
| <span className="text-xs italic" style={{ color: "var(--primary)" }}> | |
| {senderName} đang nhập | |
| </span> | |
| <span className="flex gap-0.5"> | |
| <span | |
| className="w-1 h-1 rounded-full animate-bounce" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "0ms", | |
| }} | |
| /> | |
| <span | |
| className="w-1 h-1 rounded-full animate-bounce" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "150ms", | |
| }} | |
| /> | |
| <span | |
| className="w-1 h-1 rounded-full animate-bounce" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "300ms", | |
| }} | |
| /> | |
| </span> | |
| </div> | |
| ); | |
| } | |
| function MessageSkeleton({ isDark, width = "75%", showSecondLine = true }) { | |
| return ( | |
| <div className="flex gap-3 px-3 py-3 animate-pulse"> | |
| <div | |
| className="w-9 h-9 rounded-lg flex-shrink-0" | |
| style={{ | |
| background: isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)", | |
| }} | |
| /> | |
| <div className="flex-1 min-w-0 space-y-2"> | |
| <div className="flex items-center gap-2"> | |
| <div | |
| className="h-3.5 w-24 rounded" | |
| style={{ | |
| background: isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)", | |
| }} | |
| /> | |
| <div | |
| className="h-3 w-12 rounded" | |
| style={{ | |
| background: isDark | |
| ? "rgba(255,255,255,0.06)" | |
| : "rgba(0,0,0,0.05)", | |
| }} | |
| /> | |
| </div> | |
| <div | |
| className="h-4 rounded" | |
| style={{ | |
| background: isDark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)", | |
| width: width, | |
| }} | |
| /> | |
| {showSecondLine && ( | |
| <div | |
| className="h-4 rounded" | |
| style={{ | |
| background: isDark | |
| ? "rgba(255,255,255,0.08)" | |
| : "rgba(0,0,0,0.06)", | |
| width: "50%", | |
| }} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function EmptyChatState({ dmUser, isDark, hasNoSelection, spaceWelcome }) { | |
| return ( | |
| <div className="flex flex-col items-center justify-center px-6 text-center"> | |
| <div | |
| className="w-20 h-20 rounded-lg flex items-center justify-center mb-5" | |
| style={{ | |
| background: isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.03)", | |
| }} | |
| > | |
| <svg | |
| width="36" | |
| height="36" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.5" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| style={{ color: "var(--text-muted)" }} | |
| > | |
| <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> | |
| </svg> | |
| </div> | |
| <div | |
| className="text-base font-semibold mb-2" | |
| style={{ color: "var(--text-primary)" }} | |
| > | |
| {spaceWelcome | |
| ? spaceWelcome.title | |
| : hasNoSelection | |
| ? "Chọn một cuộc trò chuyện" | |
| : dmUser | |
| ? `Bắt đầu trò chuyện với ${dmUser.name}` | |
| : "Chưa có tin nhắn nào"} | |
| </div> | |
| <div | |
| className="text-sm leading-relaxed max-w-xs" | |
| style={{ color: "var(--text-muted)" }} | |
| > | |
| {spaceWelcome | |
| ? spaceWelcome.description | |
| : hasNoSelection | |
| ? "Hãy chọn một ngườii bạn bên trái để bắt đầu nhắn tin." | |
| : dmUser | |
| ? "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é!" | |
| : "Chọn một cuộc trò chuyện để bắt đầu nhắn tin."} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function ChatMessages({ | |
| isDark, | |
| chatMessages, | |
| dmUser, | |
| onReply, | |
| onEdit, | |
| onShowProfile, | |
| hasNoSelection, | |
| spaceWelcome, | |
| isKnownEmpty, | |
| sendingMessages, | |
| isLoading, | |
| conversationId, | |
| roomId, | |
| onRetry, | |
| }) { | |
| const dispatch = useDispatch(); | |
| const messagesContainerRef = useRef(null); | |
| const [isLoadingMore, setIsLoadingMore] = useState(false); | |
| const [currentPage, setCurrentPage] = useState(1); | |
| const prevLoadingRef = useRef(isLoading); | |
| const prevMessagesLengthRef = useRef(chatMessages.length); | |
| const prevConversationIdRef = useRef(conversationId); | |
| const hasMoreMessagesRef = useRef(true); | |
| // Track if we have cached messages displayed while background loading | |
| const hasCachedMessages = chatMessages.length > 0 && isLoading; | |
| // Reset pagination state when conversation or room changes | |
| useEffect(() => { | |
| setCurrentPage(1); | |
| hasMoreMessagesRef.current = true; | |
| }, [conversationId, roomId]); | |
| // Auto scroll to bottom when loading finishes, new messages arrive, or conversation changes | |
| useEffect(() => { | |
| const container = messagesContainerRef.current; | |
| if (!container) return; | |
| // Scroll to bottom when conversation changes | |
| if (prevConversationIdRef.current !== conversationId) { | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| // Scroll to bottom when loading finishes | |
| if (prevLoadingRef.current && !isLoading) { | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| // Scroll to bottom when new messages added (but not when loading more) | |
| if (chatMessages.length > prevMessagesLengthRef.current && !isLoadingMore) { | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| prevLoadingRef.current = isLoading; | |
| prevMessagesLengthRef.current = chatMessages.length; | |
| prevConversationIdRef.current = conversationId; | |
| }, [isLoading, chatMessages.length, isLoadingMore, conversationId]); | |
| // Handle scroll event to detect when user reaches the top | |
| const handleScroll = useCallback( | |
| (e) => { | |
| const container = e.target; | |
| // Check if scrolled to top (within 10px threshold) | |
| if ( | |
| container.scrollTop < 10 && | |
| !isLoadingMore && | |
| hasMoreMessagesRef.current | |
| ) { | |
| // DM pagination | |
| if ( | |
| conversationId && | |
| !conversationId.toString().startsWith("temp-conv-") | |
| ) { | |
| setIsLoadingMore(true); | |
| const nextPage = currentPage + 1; | |
| dispatch( | |
| fetchMessages({ | |
| conversationId, | |
| page: nextPage, | |
| limit: 50, | |
| }), | |
| ) | |
| .unwrap() | |
| .then((result) => { | |
| const fetched = result.messages || []; | |
| if (fetched.length === 0) { | |
| hasMoreMessagesRef.current = false; | |
| } else { | |
| setCurrentPage(nextPage); | |
| } | |
| }) | |
| .catch((err) => { | |
| console.error("[ChatMessages] Failed to load more DM:", err); | |
| }) | |
| .finally(() => { | |
| setIsLoadingMore(false); | |
| }); | |
| } | |
| // Room pagination | |
| else if (roomId) { | |
| setIsLoadingMore(true); | |
| const nextPage = currentPage + 1; | |
| dispatch( | |
| fetchRoomMessages({ | |
| roomId, | |
| page: nextPage, | |
| limit: 50, | |
| }), | |
| ) | |
| .unwrap() | |
| .then((result) => { | |
| const fetched = result.messages || []; | |
| if (fetched.length === 0) { | |
| hasMoreMessagesRef.current = false; | |
| } else { | |
| setCurrentPage(nextPage); | |
| } | |
| }) | |
| .catch((err) => { | |
| console.error("[ChatMessages] Failed to load more room:", err); | |
| }) | |
| .finally(() => { | |
| setIsLoadingMore(false); | |
| }); | |
| } | |
| } | |
| }, | |
| [isLoadingMore, currentPage, conversationId, roomId, dispatch], | |
| ); | |
| const hasMessages = chatMessages.length > 0; | |
| const isEmpty = !hasMessages && (!isLoading || isKnownEmpty); | |
| return ( | |
| <div | |
| ref={messagesContainerRef} | |
| className={`flex-1 p-4 ${isEmpty ? "flex items-center justify-center overflow-hidden" : ` ${isLoading && !isKnownEmpty ? "overflow-hidden" : "overflow-y-auto"}`}`} | |
| onScroll={isEmpty ? undefined : handleScroll} | |
| > | |
| {isLoading && !hasMessages && !isKnownEmpty ? ( | |
| <div className="flex flex-col gap-2 w-full min-h-full justify-end pb-2"> | |
| <MessageSkeleton isDark={isDark} width="60%" showSecondLine={false} /> | |
| <MessageSkeleton isDark={isDark} width="85%" /> | |
| <MessageSkeleton isDark={isDark} width="40%" showSecondLine={false} /> | |
| <MessageSkeleton isDark={isDark} width="70%" /> | |
| <MessageSkeleton isDark={isDark} width="55%" showSecondLine={false} /> | |
| <MessageSkeleton isDark={isDark} width="90%" /> | |
| <MessageSkeleton isDark={isDark} width="45%" showSecondLine={false} /> | |
| <MessageSkeleton isDark={isDark} width="78%" /> | |
| <MessageSkeleton isDark={isDark} width="35%" showSecondLine={false} /> | |
| </div> | |
| ) : isEmpty ? ( | |
| <EmptyChatState | |
| dmUser={dmUser} | |
| isDark={isDark} | |
| hasNoSelection={hasNoSelection} | |
| spaceWelcome={spaceWelcome} | |
| /> | |
| ) : ( | |
| <div className="flex flex-col min-h-full justify-end gap-1 w-full"> | |
| {/* Background loading indicator (when showing cached messages while fetching) */} | |
| {hasCachedMessages && ( | |
| <div className="flex items-center justify-center gap-2 py-1"> | |
| <div className="flex items-center gap-1"> | |
| <div | |
| className="w-1.5 h-1.5 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "0ms", | |
| }} | |
| /> | |
| <div | |
| className="w-1.5 h-1.5 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "150ms", | |
| }} | |
| /> | |
| <div | |
| className="w-1.5 h-1.5 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--primary)", | |
| animationDelay: "300ms", | |
| }} | |
| /> | |
| </div> | |
| <span | |
| className="text-[10px]" | |
| style={{ color: "var(--text-muted)" }} | |
| > | |
| Đang cập nhật... | |
| </span> | |
| </div> | |
| )} | |
| {/* Loading indicator at top when fetching older messages */} | |
| {isLoadingMore && ( | |
| <div className="flex items-center justify-center gap-2 py-3"> | |
| <div className="flex items-center gap-1"> | |
| <div | |
| className="w-2 h-2 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--tertiary)", | |
| animationDelay: "0ms", | |
| }} | |
| /> | |
| <div | |
| className="w-2 h-2 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--tertiary)", | |
| animationDelay: "150ms", | |
| }} | |
| /> | |
| <div | |
| className="w-2 h-2 rounded-full animate-pulse" | |
| style={{ | |
| background: "var(--tertiary)", | |
| animationDelay: "300ms", | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {chatMessages.map((msg) => ( | |
| <ChatMessage | |
| key={msg.id} | |
| msg={msg} | |
| isDark={isDark} | |
| isSending={sendingMessages?.[msg.id]} | |
| onReply={onReply} | |
| onEdit={onEdit} | |
| onShowProfile={onShowProfile} | |
| onRetry={onRetry} | |
| /> | |
| ))} | |
| {/* Typing indicator removed — now shown in ChatInput instead */} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default ChatMessages; | |