Files
vexplor/frontend/components/messenger/MessageInput.tsx
syc0123 f558073ef8 [RAPID] feat: 메신저 기능 구현 (Socket.IO 실시간 채팅)
- 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>
2026-04-01 12:20:40 +09:00

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>
);
}