- DB: messenger_rooms/participants/messages/reactions/files 테이블 생성 - Backend: REST API 9개 엔드포인트 + Socket.IO 실시간 핸들러 - Frontend: Gmail 스타일 FAB + 모달, 채팅방 목록, 채팅 패널 - 기능: DM/그룹/채널, 파일 첨부, 이모지 리액션, 멘션, 스레드 - 알림: 토스트 on/off 토글, FAB 읽지 않은 배지 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메신저 API snake_case→camelCase 변환 및 Socket.IO URL 수정 - useRooms/useMessages/useCompanyUsers 훅에서 DB 응답 camelCase 변환 - Socket.IO 기본 연결 URL 3001 → 8080 수정 - runMigration.ts 마이그레이션 파일 경로 수정 (../../ → ../../../) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 방 생성 API camelCase/snake_case 호환 처리 - createRoom 컨트롤러에서 participantIds/type/name (camelCase) fallback 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> [RAPID-fix] 메시지 전송 API 추가 (sendMessage 라우트/컨트롤러 누락) - POST /api/messenger/rooms/:roomId/messages 라우트 등록 - MessengerController.sendMessage 메서드 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
197 lines
6.5 KiB
TypeScript
197 lines
6.5 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useCallback, useEffect, KeyboardEvent, ChangeEvent } from "react";
|
|
import { Paperclip, Send, SmilePlus } from "lucide-react";
|
|
import { useSendMessage, useUploadFile, useCompanyUsers } from "@/hooks/useMessenger";
|
|
|
|
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}", "\u{1F389}", "\u{1F914}"];
|
|
|
|
interface MessageInputProps {
|
|
roomId: string;
|
|
onTypingStart?: () => void;
|
|
onTypingStop?: () => void;
|
|
}
|
|
|
|
export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInputProps) {
|
|
const [text, setText] = useState("");
|
|
const [showEmoji, setShowEmoji] = useState(false);
|
|
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)
|
|
: [];
|
|
|
|
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]);
|
|
|
|
const handleSend = useCallback(() => {
|
|
const trimmed = text.trim();
|
|
if (!trimmed) return;
|
|
sendMessage.mutate({ roomId, content: trimmed });
|
|
setText("");
|
|
onTypingStop?.();
|
|
}, [text, roomId, 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.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;
|
|
const before = val.slice(0, atIdx);
|
|
const after = val.slice(el.selectionStart);
|
|
setText(`${before}@${user.userName} ${after}`);
|
|
setMentionQuery(null);
|
|
};
|
|
|
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
|
const val = e.target.value;
|
|
setText(val);
|
|
|
|
// Typing events
|
|
onTypingStart?.();
|
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
|
|
typingTimerRef.current = setTimeout(() => onTypingStop?.(), 2000);
|
|
|
|
// Mention detection
|
|
const cursor = e.target.selectionStart;
|
|
const textBeforeCursor = val.slice(0, cursor);
|
|
const atMatch = textBeforeCursor.match(/@(\S*)$/);
|
|
if (atMatch) {
|
|
setMentionQuery(atMatch[1]);
|
|
setMentionIndex(0);
|
|
} else {
|
|
setMentionQuery(null);
|
|
}
|
|
};
|
|
|
|
const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
const result = await uploadFile.mutateAsync(file);
|
|
sendMessage.mutate({
|
|
roomId,
|
|
content: file.name,
|
|
type: "file",
|
|
});
|
|
} catch {
|
|
// upload failed silently
|
|
}
|
|
e.target.value = "";
|
|
};
|
|
|
|
return (
|
|
<div className="border-t p-2 relative">
|
|
{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">
|
|
{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>
|
|
)}
|
|
|
|
<div className="flex items-end gap-1">
|
|
<button onClick={() => fileRef.current?.click()} className="p-1.5 hover:bg-muted rounded">
|
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
<input ref={fileRef} type="file" className="hidden" onChange={handleFileChange} />
|
|
|
|
<div className="relative">
|
|
<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}
|
|
placeholder="메시지를 입력하세요..."
|
|
rows={1}
|
|
className="flex-1 resize-none bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1.5 px-2 max-h-[120px]"
|
|
/>
|
|
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!text.trim()}
|
|
className="p-1.5 hover:bg-muted rounded disabled:opacity-40"
|
|
>
|
|
<Send className="h-4 w-4 text-primary" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|