[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>
204 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|