import { useState, useEffect, useRef, useCallback } from "react"; import { FiX, FiPaperclip } from "react-icons/fi"; import { IoSend } from "react-icons/io5"; import MentionSuggestions from "./MentionSuggestions"; import FileAttachmentPreview from "./FileAttachmentPreview"; import { getUserColor } from "../../utils/userColor"; import { useSelector } from "react-redux"; // Allowed file types const ALLOWED_FILE_TYPES = [ "application/pdf", "image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp", "image/svg+xml", ]; // Max file size: 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; function ChatInput({ isDark, placeholder, replyTo, onSend, onTyping, onStopTyping, typingSender, disabled, }) { const [showMentions, setShowMentions] = useState(false); const [mentionFilter, setMentionFilter] = useState(""); const [mentionStartOffset, setMentionStartOffset] = useState(-1); const [selectedMentionIndex, setSelectedMentionIndex] = useState(0); const [mentions, setMentions] = useState([]); const [isEmpty, setIsEmpty] = useState(true); const [selectedFiles, setSelectedFiles] = useState([]); const editorRef = useRef(null); const containerRef = useRef(null); const fileInputRef = useRef(null); // Get real users from Redux for mentions const { conversations } = useSelector((state) => state.dm); const { membersMap } = useSelector((state) => state.space); const allUsers = (() => { const users = []; const seenIds = new Set(); // Add StudyBot as a mentionable user users.push({ id: "studybot", name: "StudyBot", isBot: true, }); seenIds.add("studybot"); conversations.forEach((conv) => { const ou = conv.other_user; if (ou && !seenIds.has(ou.id)) { seenIds.add(ou.id); users.push({ id: ou.id, name: ou.display_name || ou.name || "Unknown", }); } }); Object.values(membersMap).flat().forEach((member) => { if (member && !seenIds.has(member.id)) { seenIds.add(member.id); users.push({ id: member.id, name: member.display_name || member.name || member.email || "Unknown", }); } }); return users; })(); const placeholderText = placeholder; useEffect(() => { const handleClickOutside = (e) => { if (containerRef.current && !containerRef.current.contains(e.target)) { setShowMentions(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); const getPlainText = useCallback(() => { if (editorRef.current) { return editorRef.current.innerText || ""; } return ""; }, []); const getCursorOffset = useCallback(() => { const selection = window.getSelection(); if (!selection.rangeCount) return 0; const range = selection.getRangeAt(0); const preCaretRange = range.cloneRange(); preCaretRange.selectNodeContents(editorRef.current); preCaretRange.setEnd(range.endContainer, range.endOffset); return preCaretRange.toString().length; }, []); const checkForMention = useCallback(() => { const cursorOffset = getCursorOffset(); const plainText = getPlainText(); const textBeforeCursor = plainText.substring(0, cursorOffset); const mentionMatch = textBeforeCursor.match(/@([a-zA-Z0-9À-ỹ_]*)$/); if (mentionMatch) { setMentionFilter(mentionMatch[1]); setMentionStartOffset(cursorOffset - mentionMatch[0].length); setShowMentions(true); setSelectedMentionIndex(0); } else { setShowMentions(false); setMentionStartOffset(-1); } }, [getCursorOffset, getPlainText]); const handleInput = (e) => { checkForMention(); const plainText = getPlainText(); setIsEmpty(plainText.trim() === ""); if (onTyping && plainText.trim().length > 0) { onTyping(); } }; const handleMentionSelect = (user) => { console.log("[ChatInput] Mention selected:", user); if (user === null) { setShowMentions(false); return; } const selection = window.getSelection(); if (!selection.rangeCount) return; const plainText = getPlainText(); const cursorOffset = getCursorOffset(); const textBeforeCursor = plainText.substring(0, cursorOffset); const mentionMatch = textBeforeCursor.match(/@([a-zA-Z0-9À-ỹ_]*)$/); if (!mentionMatch) return; const atPosition = cursorOffset - mentionMatch[0].length; const beforeText = plainText.substring(0, atPosition); const afterText = plainText.substring(cursorOffset); const mentionColor = getUserColor(user.name); const mentionSpan = document.createElement("span"); mentionSpan.className = "mention-tag"; mentionSpan.contentEditable = "false"; mentionSpan.style.cssText = `color: ${mentionColor}; font-weight: 600; cursor: pointer;`; mentionSpan.textContent = `@${user.name}`; mentionSpan.dataset.userId = user.id; mentionSpan.dataset.userName = user.name; // Rebuild editor content editorRef.current.innerHTML = ""; if (beforeText) { editorRef.current.appendChild(document.createTextNode(beforeText)); } editorRef.current.appendChild(mentionSpan); // Add a zero-width space followed by regular space after the mention const spaceNode = document.createTextNode("\u200B "); editorRef.current.appendChild(spaceNode); if (afterText) { editorRef.current.appendChild(document.createTextNode(afterText)); } setMentions((prev) => [...prev, { id: user.id, name: user.name }]); // Focus editor and set cursor after the zero-width space + space editorRef.current.focus(); // Place cursor at position 2 (after zero-width space and regular space) selection.collapse(spaceNode, 2); setShowMentions(false); // Log final content setTimeout(() => { console.log("[ChatInput] After mention, plainText:", getPlainText()); }, 0); }; const handleSend = () => { const content = getPlainText().trim(); if (content || selectedFiles.length > 0) { if (onSend) { onSend(content, replyTo, selectedFiles); if (editorRef.current) { editorRef.current.innerHTML = ""; } setMentions([]); setSelectedFiles([]); setIsEmpty(true); } setShowMentions(false); if (onStopTyping) onStopTyping(); } }; // Handle file selection const handleFileSelect = (e) => { const files = Array.from(e.target.files); const imageFiles = []; const nonImageFiles = []; files.forEach((file) => { // Check file type if (!ALLOWED_FILE_TYPES.includes(file.type)) { alert( `File "${file.name}" có định dạng không được hỗ trợ. Chỉ chấp nhận file PDF và hình ảnh.`, ); return; } // Check file size if (file.size > MAX_FILE_SIZE) { alert(`File "${file.name}" vượt quá kích thước tối đa (10MB).`); return; } // Separate images (async) from non-images (sync) if (file.type.startsWith("image/")) { imageFiles.push(file); } else { nonImageFiles.push({ file, name: file.name, type: file.type, size: file.size, preview: null, }); } }); // Add non-image files immediately if (nonImageFiles.length > 0) { setSelectedFiles((prev) => [...prev, ...nonImageFiles]); } // Process images asynchronously with Promise.all if (imageFiles.length > 0) { Promise.all( imageFiles.map( (file) => new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { resolve({ file, name: file.name, type: file.type, size: file.size, preview: e.target.result, }); }; reader.readAsDataURL(file); }), ), ).then((imageResults) => { setSelectedFiles((prev) => [...prev, ...imageResults]); }); } // Reset file input if (fileInputRef.current) { fileInputRef.current.value = ""; } }; // Remove selected file const handleRemoveFile = (index) => { setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); }; // Trigger file input click const handleAttachmentClick = () => { if (fileInputRef.current) { fileInputRef.current.click(); } }; const handleKeyDown = (e) => { if (showMentions) { const filteredUsers = allUsers.filter((user) => user.name.toLowerCase().includes(mentionFilter.toLowerCase()), ); if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setSelectedMentionIndex((prev) => prev < filteredUsers.length - 1 ? prev + 1 : prev, ); return; } if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setSelectedMentionIndex((prev) => (prev > 0 ? prev - 1 : prev)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); e.stopPropagation(); if (filteredUsers[selectedMentionIndex]) { handleMentionSelect(filteredUsers[selectedMentionIndex]); } return; } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setShowMentions(false); return; } } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } if (e.key === "Backspace") { const selection = window.getSelection(); if (!selection.rangeCount) return; const range = selection.getRangeAt(0); const cursorOffset = getCursorOffset(); if (cursorOffset > 0 && range.startOffset === 0) { const prevNode = range.startContainer.previousSibling; if ( prevNode && prevNode.classList && prevNode.classList.contains("mention-tag") ) { e.preventDefault(); prevNode.remove(); setMentions((prev) => prev.filter((m) => m.id !== prevNode.dataset.userId), ); return; } } if (cursorOffset > 0) { const plainText = getPlainText(); const charBeforeCursor = plainText[cursorOffset - 1]; if (charBeforeCursor === " ") { const textBeforeSpace = plainText.substring(0, cursorOffset - 1); const lastAtPos = textBeforeSpace.lastIndexOf("@"); if (lastAtPos >= 0) { const potentialMention = textBeforeSpace.substring(lastAtPos); const mentionedUser = allUsers.find( (u) => `@${u.name} ` === potentialMention || `@${u.name}` === potentialMention, ); if (mentionedUser) { e.preventDefault(); const beforeMention = plainText.substring(0, lastAtPos); const afterMention = plainText.substring(cursorOffset); editorRef.current.innerHTML = ""; if (beforeMention) { editorRef.current.appendChild( document.createTextNode(beforeMention), ); } if (afterMention) { editorRef.current.appendChild( document.createTextNode(afterMention), ); } const newRange = document.createRange(); const textNode = editorRef.current.lastChild || editorRef.current; newRange.setStart(textNode, beforeMention.length); newRange.collapse(true); selection.removeAllRanges(); selection.addRange(newRange); setMentions((prev) => prev.filter((m) => m.id !== mentionedUser.id), ); return; } } } } } }; return (
{/* Typing indicator — nằm TRÊN box input, absolute position */} {typingSender && (
{typingSender} đang nhập
)}
{/* File attachment preview */} {selectedFiles.length > 0 && ( )} {showMentions && (
)}
!disabled && (e.currentTarget.style.borderColor = "var(--primary)") } onBlur={(e) => !disabled && (e.currentTarget.style.borderColor = "var(--input-border)") } >
{disabled ? "Chọn một cuộc trò chuyện để bắt đầu" : placeholderText}
{/* Hidden file input */}
); } export default ChatInput;