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 (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} >
onShowProfile && onShowProfile(msg.sender)} > {msg.avatar}
{/* Sender name and timestamp first */}
onShowProfile && onShowProfile(msg.sender)} > {msg.sender} {msg.isBot && ( Bot )} {msg.timestamp} {msg.pending && !isFailed && ( · Đang gửi... )} {isFailed && ( · Gửi thất bại )}
{/* Message content */}
{quizCardData ? ( ) : ( renderMessageWithMentions( msg.content, isDark, onShowProfile, msg.isBot, ) )} {msg.isEdited && ( (đã chỉnh sửa) )}
{msg.hasAttachment && msg.attachments && msg.attachments.length > 0 ? (
{msg.attachments.map((attachment, index) => ( ))}
) : msg.hasAttachment ? (
{msg.attachmentName}
) : null} {/* Retry button for failed messages */} {isFailed && onRetry && ( )}
); } 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 (
{senderName} đang nhập
); } function MessageSkeleton({ isDark, width = "75%", showSecondLine = true }) { return (
{showSecondLine && (
)}
); } function EmptyChatState({ dmUser, isDark, hasNoSelection, spaceWelcome }) { return (
{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"}
{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."}
); } 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 (
{isLoading && !hasMessages && !isKnownEmpty ? (
) : isEmpty ? ( ) : (
{/* Background loading indicator (when showing cached messages while fetching) */} {hasCachedMessages && (
Đang cập nhật...
)} {/* Loading indicator at top when fetching older messages */} {isLoadingMore && (
)} {chatMessages.map((msg) => ( ))} {/* Typing indicator removed — now shown in ChatInput instead */}
)}
); } export default ChatMessages;