"use client"; import { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle, KeyboardEvent, ChangeEvent } from "react"; import { Paperclip, Send, SmilePlus, X, FileIcon } from "lucide-react"; import { useSendMessage, useUploadFile, useCompanyUsers } from "@/hooks/useMessenger"; import { toast } from "sonner"; const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}", "\u{1F389}", "\u{1F914}"]; interface PendingFile { file: File; previewUrl: string | null; // object URL for images, null for others } export interface MessageInputHandle { addFiles: (files: File[]) => void; } interface MessageInputProps { roomId: string; onTypingStart?: () => void; onTypingStop?: () => void; } export const MessageInput = forwardRef( function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps, ref) { const [text, setText] = useState(""); const [pendingFiles, setPendingFiles] = useState([]); const [showEmoji, setShowEmoji] = useState(false); const emojiRef = useRef(null); const [mentionQuery, setMentionQuery] = useState(null); const [mentionIndex, setMentionIndex] = useState(0); const textareaRef = useRef(null); const fileRef = useRef(null); const typingTimerRef = useRef | null>(null); const sendMessage = useSendMessage(); const uploadFile = useUploadFile(); const { data: users } = useCompanyUsers(); const filteredMentionUsers = mentionQuery !== null && users ? users.filter((u) => u.userName.toLowerCase().includes(mentionQuery.toLowerCase())).slice(0, 5) : []; // Revoke object URLs when pending files change or component unmounts useEffect(() => { return () => { pendingFiles.forEach((pf) => { if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); }); }; }, [pendingFiles]); const adjustHeight = useCallback(() => { const el = textareaRef.current; if (el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 120) + "px"; } }, []); useEffect(() => { adjustHeight(); }, [text, adjustHeight]); useEffect(() => { if (roomId) textareaRef.current?.focus(); }, [roomId]); // Close emoji picker on outside click useEffect(() => { if (!showEmoji) return; const handler = (e: MouseEvent) => { if (emojiRef.current && !emojiRef.current.contains(e.target as Node)) setShowEmoji(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [showEmoji]); const BLOCKED_TYPES = /^(audio|video)\//; const addFiles = (files: FileList | File[]) => { const arr = Array.from(files).filter((f) => { if (BLOCKED_TYPES.test(f.type)) { toast(`${f.name} — 음원/동영상 파일은 전송할 수 없습니다.`); return false; } return true; }); if (arr.length === 0) return; setPendingFiles((prev) => [ ...prev, ...arr.map((file) => ({ file, previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : null, })), ]); textareaRef.current?.focus(); }; useImperativeHandle(ref, () => ({ addFiles })); const removePendingFile = (idx: number) => { setPendingFiles((prev) => { const pf = prev[idx]; if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); return prev.filter((_, i) => i !== idx); }); }; const handleSend = useCallback(async () => { const trimmed = text.trim(); if (!trimmed && pendingFiles.length === 0) return; // Upload all pending files (backend creates a message per file) for (const pf of pendingFiles) { try { await uploadFile.mutateAsync({ file: pf.file, roomId }); if (pf.previewUrl) URL.revokeObjectURL(pf.previewUrl); } catch { // upload failed silently } } setPendingFiles([]); // Send text message if any if (trimmed) { sendMessage.mutate({ roomId, content: trimmed }); setText(""); } if (typingTimerRef.current) { clearTimeout(typingTimerRef.current); typingTimerRef.current = null; } onTypingStop?.(); }, [text, pendingFiles, roomId, uploadFile, sendMessage, onTypingStop]); const handleKeyDown = (e: KeyboardEvent) => { if (mentionQuery !== null && filteredMentionUsers.length > 0) { if (e.key === "ArrowDown") { e.preventDefault(); setMentionIndex((p) => Math.min(p + 1, filteredMentionUsers.length - 1)); return; } if (e.key === "ArrowUp") { e.preventDefault(); setMentionIndex((p) => Math.max(p - 1, 0)); return; } if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); insertMention(filteredMentionUsers[mentionIndex]); return; } if (e.key === "Escape") { setMentionQuery(null); return; } } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } }; const insertMention = (user: { userId: string; userName: string }) => { const el = textareaRef.current; if (!el) return; const val = el.value; const atIdx = val.lastIndexOf("@", el.selectionStart - 1); if (atIdx === -1) return; setText(`${val.slice(0, atIdx)}@${user.userName} ${val.slice(el.selectionStart)}`); setMentionQuery(null); }; const handleChange = (e: ChangeEvent) => { const val = e.target.value; setText(val); if (typingTimerRef.current) clearTimeout(typingTimerRef.current); if (val.trim()) { onTypingStart?.(); typingTimerRef.current = setTimeout(() => { onTypingStop?.(); typingTimerRef.current = null; }, 3000); } else { onTypingStop?.(); } const cursor = e.target.selectionStart; const atMatch = val.slice(0, cursor).match(/@(\S*)$/); if (atMatch) { setMentionQuery(atMatch[1]); setMentionIndex(0); } else { setMentionQuery(null); } }; const handleFileChange = (e: ChangeEvent) => { if (e.target.files?.length) addFiles(e.target.files); e.target.value = ""; }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files); }; const handlePaste = (e: React.ClipboardEvent) => { if (e.clipboardData.files?.length) { e.preventDefault(); addFiles(e.clipboardData.files); } }; const canSend = text.trim().length > 0 || pendingFiles.length > 0; return (
e.preventDefault()} onDrop={handleDrop} > {/* Mention dropdown */} {mentionQuery !== null && filteredMentionUsers.length > 0 && (
{filteredMentionUsers.map((u, i) => ( ))}
)} {/* Pending file previews */} {pendingFiles.length > 0 && (
{pendingFiles.map((pf, idx) => (
{pf.previewUrl ? ( {pf.file.name} ) : (
{pf.file.name}
)}
))}
)} {/* Input toolbar */}
{showEmoji && (
{QUICK_EMOJIS.map((emoji) => ( ))}
)}