anotherath's picture
feat(ui): space icons, chat improvements, StudyBot mentions
880ab03
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 (
<div
ref={containerRef}
className="border-t shrink-0 relative"
style={{
borderColor: "var(--border-primary)",
background: "var(--bg-surface-secondary)",
}}
>
{/* Typing indicator — nằm TRÊN box input, absolute position */}
{typingSender && (
<div className="absolute top-[-28px] left-0 flex items-center gap-2 px-4 pt-2 pb-1">
<span className="text-xs italic" style={{ color: "var(--primary)" }}>
{typingSender} đ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>
)}
<div className="px-4 py-3">
{/* File attachment preview */}
{selectedFiles.length > 0 && (
<FileAttachmentPreview
files={selectedFiles}
onRemove={handleRemoveFile}
isDark={isDark}
/>
)}
{showMentions && (
<div
className="absolute bottom-full left-4 right-4 mb-2 rounded-lg shadow-lg border overflow-hidden z-50"
style={{
background: isDark ? "var(--bg-surface-secondary)" : "#fff",
borderColor: "var(--border-primary)",
}}
>
<MentionSuggestions
onSelect={handleMentionSelect}
isDark={isDark}
filterText={mentionFilter}
selectedIndex={selectedMentionIndex}
/>
</div>
)}
<div
className={`chat-input-wrapper border rounded-lg p-1 transition-colors relative ${disabled ? "opacity-50" : ""}`}
style={{
background: "var(--input-bg)",
borderColor: "var(--input-border)",
}}
onFocus={(e) =>
!disabled && (e.currentTarget.style.borderColor = "var(--primary)")
}
onBlur={(e) =>
!disabled && (e.currentTarget.style.borderColor = "var(--input-border)")
}
>
<div className="flex items-center gap-2">
<div
className={`chat-input-placeholder ${isEmpty ? "" : "hidden"}`}
>
{disabled ? "Chọn một cuộc trò chuyện để bắt đầu" : placeholderText}
</div>
<div
ref={editorRef}
contentEditable={!disabled}
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"
style={{
color: "var(--input-text)",
}}
onInput={disabled ? undefined : handleInput}
onKeyDown={disabled ? undefined : handleKeyDown}
suppressContentEditableWarning
/>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.svg"
multiple
className="hidden"
onChange={handleFileSelect}
disabled={disabled}
/>
<button
type="button"
disabled={disabled}
className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors disabled:cursor-not-allowed"
style={{
background: "transparent",
color: "var(--text-secondary)",
}}
onMouseEnter={(e) =>
!disabled && (e.currentTarget.style.background = "var(--hover-primary)")
}
onMouseLeave={(e) =>
!disabled && (e.currentTarget.style.background = "transparent")
}
onClick={handleAttachmentClick}
title="Đính kèm file (PDF, hình ảnh)"
>
<FiPaperclip size={18} />
</button>
<button
type="button"
disabled={disabled}
className="w-9 h-9 border-none rounded-md cursor-pointer flex items-center justify-center transition-colors disabled:cursor-not-allowed"
style={{
background: "var(--primary)",
color: isDark ? "var(--bg-surface)" : "#fff",
}}
onMouseEnter={(e) =>
!disabled && (e.currentTarget.style.background = "var(--primary-hover)")
}
onMouseLeave={(e) =>
!disabled && (e.currentTarget.style.background = "var(--primary)")
}
onClick={handleSend}
title="Gửi"
>
<IoSend size={18} />
</button>
</div>
</div>
</div>
</div>
);
}
export default ChatInput;