Files
vexplor/frontend/components/messenger/MessageInput.tsx
syc0123 6b3e6cce5e [RAPID-micro] 새 대화 모달 z-index 메신저 위로 상향 (10000/10001)
[RAPID-fix] 메신저 사용자 목록 회사 전환 시 캐시 격리

- useRooms/useCompanyUsers queryKey에 companyCode 포함
- 회사 전환 시 다른 회사 사용자가 캐시에서 노출되던 문제 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

[RAPID-fix] 메신저 버그 수정 (8건)

- 방 생성 후 자동 입장 + 커서 포커스
- DM 헤더 상대방 이름, 그룹 "이름1, 이름2 외 N명" 표시
- 채팅방 이름 인라인 수정 기능 추가
- Socket.IO join_rooms 누락 수정 → 실시간 메시지 수신 정상화
- new_message 이벤트 수신 시 React Query 캐시 무효화
- 토스트 알림 stale closure 수정 (ref 패턴 적용)
- 타이핑 이벤트명 백엔드 일치 (user_typing/user_stop_typing)
- 메시지 순서 역전 수정 (.reverse())
- unread queryKey 불일치 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

[RAPID-fix] REST API 메시지 전송 시 Socket.IO broadcast 추가

- socketManager.ts 모듈 생성 (io 전역 공유)
- sendMessage 컨트롤러에서 io.to(room).emit('new_message') broadcast
- 상대방 말풍선 너비 고정 수정 (items-start 추가)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:20:41 +09:00

204 lines
6.6 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]);
// Auto-focus when room changes
useEffect(() => {
if (roomId) {
textareaRef.current?.focus();
}
}, [roomId]);
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>
);
}