291 lines
11 KiB
TypeScript
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>
|
|
);
|
|
});
|