Files
vexplor_dev/frontend/components/messenger/MessageInput.tsx
2026-04-01 12:28:42 +09:00

291 lines
11 KiB
TypeScript

"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<MessageInputHandle, MessageInputProps>(
function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps, ref) {
const [text, setText] = useState("");
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
const [showEmoji, setShowEmoji] = useState(false);
const emojiRef = useRef<HTMLDivElement>(null);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | 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<HTMLTextAreaElement>) => {
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<HTMLTextAreaElement>) => {
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<HTMLInputElement>) => {
if (e.target.files?.length) addFiles(e.target.files);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
};
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
if (e.clipboardData.files?.length) {
e.preventDefault();
addFiles(e.clipboardData.files);
}
};
const canSend = text.trim().length > 0 || pendingFiles.length > 0;
return (
<div
className="relative shrink-0"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{/* Mention dropdown */}
{mentionQuery !== null && filteredMentionUsers.length > 0 && (
<div className="absolute bottom-full left-2 right-2 bg-background border rounded-md shadow-md max-h-40 overflow-y-auto z-10">
{filteredMentionUsers.map((u, i) => (
<button
key={u.userId}
onClick={() => insertMention(u)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-muted ${i === mentionIndex ? "bg-muted" : ""}`}
>
<span className="font-medium">{u.userName}</span>
{u.deptName && <span className="text-muted-foreground ml-2">{u.deptName}</span>}
</button>
))}
</div>
)}
{/* Pending file previews */}
{pendingFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-3 pt-2 pb-1">
{pendingFiles.map((pf, idx) => (
<div key={idx} className="relative group/preview">
{pf.previewUrl ? (
<img
src={pf.previewUrl}
alt={pf.file.name}
className="h-16 w-16 rounded-md object-cover border"
/>
) : (
<div className="h-16 w-28 rounded-md border bg-muted flex flex-col items-center justify-center gap-1 px-2">
<FileIcon className="h-5 w-5 text-muted-foreground shrink-0" />
<span className="text-[10px] text-muted-foreground truncate w-full text-center">{pf.file.name}</span>
</div>
)}
<button
onClick={() => removePendingFile(idx)}
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full bg-foreground text-background flex items-center justify-center opacity-0 group-hover/preview:opacity-100 transition-opacity"
>
<X className="h-2.5 w-2.5" />
</button>
</div>
))}
</div>
)}
{/* Input toolbar */}
<div className="flex items-center gap-1 p-2 mx-2 mb-2 border rounded-lg focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30 transition-colors">
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded shrink-0">
<Paperclip className="h-4 w-4 text-muted-foreground" />
</button>
<input ref={fileRef} type="file" multiple className="hidden" onChange={handleFileChange} />
<div className="relative shrink-0" ref={emojiRef}>
<button onClick={() => setShowEmoji((p) => !p)} className="p-1.5 hover:bg-muted rounded">
<SmilePlus className="h-4 w-4 text-muted-foreground" />
</button>
{showEmoji && (
<div className="absolute bottom-full left-0 bg-background border rounded-md shadow-md p-1.5 flex flex-wrap gap-1 w-48 z-10">
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => { setText((p) => p + emoji); setShowEmoji(false); textareaRef.current?.focus(); }}
className="hover:bg-muted rounded p-1 text-lg"
>
{emoji}
</button>
))}
</div>
)}
</div>
<textarea
ref={textareaRef}
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="메시지를 입력하세요..."
rows={1}
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1.5 pl-1.5 pr-2 ml-1 max-h-[120px]"
/>
<button
onClick={handleSend}
disabled={!canSend || uploadFile.isPending}
className="p-1.5 hover:bg-muted rounded disabled:opacity-40 shrink-0"
>
<Send className="h-4 w-4 text-primary" />
</button>
</div>
</div>
);
});