[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>
This commit is contained in:
@@ -416,10 +416,12 @@ const server = app.listen(PORT, HOST, async () => {
|
||||
try {
|
||||
const { Server: SocketIOServer } = await import("socket.io");
|
||||
const { initMessengerSocket } = await import("./socket/messengerSocket");
|
||||
const { setIo } = await import("./socket/socketManager");
|
||||
const io = new SocketIOServer(server, {
|
||||
cors: { origin: "*", methods: ["GET", "POST"] },
|
||||
path: "/socket.io",
|
||||
});
|
||||
setIo(io);
|
||||
initMessengerSocket(io);
|
||||
logger.info("💬 Socket.IO messenger initialized");
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { messengerService } from '../services/messengerService';
|
||||
import { AuthenticatedRequest } from '../types/auth';
|
||||
import { getIo } from '../socket/socketManager';
|
||||
import path from 'path';
|
||||
|
||||
class MessengerController {
|
||||
@@ -69,6 +70,13 @@ class MessengerController {
|
||||
}
|
||||
|
||||
const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId);
|
||||
|
||||
// Broadcast to all room participants via Socket.IO
|
||||
const io = getIo();
|
||||
if (io) {
|
||||
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
|
||||
}
|
||||
|
||||
res.json({ success: true, data: message });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
||||
@@ -161,7 +161,8 @@ class MessengerService {
|
||||
}
|
||||
|
||||
const result = await PostgreSQLService.query(query, params);
|
||||
const messages: MessengerMessage[] = result.rows;
|
||||
// Reverse so messages are in chronological order (query uses DESC for cursor pagination)
|
||||
const messages: MessengerMessage[] = result.rows.reverse();
|
||||
|
||||
// Attach reactions and files
|
||||
if (messages.length > 0) {
|
||||
|
||||
11
backend-node/src/socket/socketManager.ts
Normal file
11
backend-node/src/socket/socketManager.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
let _io: Server | null = null;
|
||||
|
||||
export function setIo(io: Server) {
|
||||
_io = io;
|
||||
}
|
||||
|
||||
export function getIo(): Server | null {
|
||||
return _io;
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MessageSquare, Pencil, Check, X } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useMessages, useMarkAsRead } from "@/hooks/useMessenger";
|
||||
import { useMessages, useMarkAsRead, useUpdateRoom } from "@/hooks/useMessenger";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useMessengerSocket } from "@/hooks/useMessengerSocket";
|
||||
@@ -20,8 +20,12 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
const { selectedRoomId } = useMessengerContext();
|
||||
const { data: messages } = useMessages(selectedRoomId);
|
||||
const markAsRead = useMarkAsRead();
|
||||
const updateRoom = useUpdateRoom();
|
||||
const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editName, setEditName] = useState("");
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRoomId) {
|
||||
@@ -60,14 +64,64 @@ export function ChatPanel({ room }: ChatPanelProps) {
|
||||
return prev !== curr;
|
||||
};
|
||||
|
||||
// Compute display name based on room type
|
||||
const displayName = (() => {
|
||||
if (room.type === "dm") {
|
||||
const other = room.participants.find((p) => p.userId !== user?.userId);
|
||||
return other?.userName ?? room.name;
|
||||
}
|
||||
if (room.name) return room.name;
|
||||
const others = room.participants.filter((p) => p.userId !== user?.userId);
|
||||
if (others.length <= 2) {
|
||||
return others.map((p) => p.userName).join(", ");
|
||||
}
|
||||
return `${others[0].userName}, ${others[1].userName} 외 ${others.length - 2}명`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-2 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm truncate">{room.name}</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{room.participants.length}명
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<form
|
||||
className="flex items-center gap-1 flex-1 min-w-0"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = editName.trim();
|
||||
if (trimmed && trimmed !== room.name) {
|
||||
updateRoom.mutate({ roomId: room.id, name: trimmed });
|
||||
}
|
||||
setIsEditingName(false);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={editInputRef}
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="font-semibold text-sm bg-transparent border-b border-primary outline-none flex-1 min-w-0"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="p-0.5 hover:bg-muted rounded">
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
</button>
|
||||
<button type="button" onClick={() => setIsEditingName(false)} className="p-0.5 hover:bg-muted rounded">
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="font-semibold text-sm truncate">{displayName}</h3>
|
||||
<button
|
||||
onClick={() => { setEditName(room.name); setIsEditingName(true); }}
|
||||
className="p-0.5 hover:bg-muted rounded shrink-0"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{room.participants.length}명
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
|
||||
@@ -41,6 +41,13 @@ export function MessageInput({ roomId, onTypingStart, onTypingStop }: MessageInp
|
||||
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;
|
||||
|
||||
@@ -53,7 +53,7 @@ export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
|
||||
<div className="w-7" />
|
||||
)}
|
||||
|
||||
<div className={cn("flex flex-col max-w-[70%]", isOwn && "items-end")}>
|
||||
<div className={cn("flex flex-col max-w-[70%]", isOwn ? "items-end" : "items-start")}>
|
||||
{showAvatar && !isOwn && (
|
||||
<span className="text-xs font-medium text-muted-foreground mb-0.5">
|
||||
{message.senderName}
|
||||
|
||||
@@ -59,7 +59,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-999 bg-black/60",
|
||||
"fixed inset-0 z-[10000] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -134,13 +134,13 @@ const DialogContent = React.forwardRef<
|
||||
return (
|
||||
<DialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
className={scoped ? "absolute inset-0 z-[10000] flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
style={(hiddenProp || (scoped && !isTabActive)) ? { display: "none" } : undefined}
|
||||
>
|
||||
{scoped ? (
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
) : (
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-[10000] bg-black/60" />
|
||||
)}
|
||||
<DialogPrimitive.Content
|
||||
ref={mergedRef}
|
||||
@@ -149,7 +149,7 @@ const DialogContent = React.forwardRef<
|
||||
className={cn(
|
||||
scoped
|
||||
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
|
||||
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
: "bg-background fixed top-[50%] left-[50%] z-[10001] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
scoped && "max-h-full",
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
@@ -70,8 +71,10 @@ async function postApi<T>(url: string, data?: unknown): Promise<T> {
|
||||
// Hooks
|
||||
// ============================================
|
||||
export function useRooms() {
|
||||
const { user } = useAuth();
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
return useQuery<Room[]>({
|
||||
queryKey: ["messenger", "rooms"],
|
||||
queryKey: ["messenger", "rooms", companyCode],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/rooms");
|
||||
return data.map((r) => ({
|
||||
@@ -122,8 +125,10 @@ export function useMessages(roomId: string | null) {
|
||||
}
|
||||
|
||||
export function useCompanyUsers() {
|
||||
const { user } = useAuth();
|
||||
const companyCode = user?.companyCode || user?.company_code;
|
||||
return useQuery<CompanyUser[]>({
|
||||
queryKey: ["messenger", "users"],
|
||||
queryKey: ["messenger", "users", companyCode],
|
||||
queryFn: async () => {
|
||||
const data = await fetchApi<any[]>("/messenger/users");
|
||||
return data.map((u) => ({
|
||||
@@ -189,6 +194,17 @@ export function useAddReaction() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRoom() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ roomId, name }: { roomId: string; name: string }) =>
|
||||
apiClient.put(`/messenger/rooms/${roomId}`, { room_name: name }).then((res) => res.data?.data ?? res.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
|
||||
@@ -2,35 +2,44 @@
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMessengerContext } from "@/contexts/MessengerContext";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8080";
|
||||
|
||||
interface NewMessageEvent {
|
||||
roomId: string;
|
||||
message: {
|
||||
id: string;
|
||||
content: string;
|
||||
senderName: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
room_id: number;
|
||||
sender_name: string;
|
||||
sender_id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface TypingEvent {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
room_id: number;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
}
|
||||
|
||||
export function useMessengerSocket() {
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const { selectedRoomId, notificationEnabled } = useMessengerContext();
|
||||
const selectedRoomIdRef = useRef(selectedRoomId);
|
||||
const notificationEnabledRef = useRef(notificationEnabled);
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const [onlineUsers, setOnlineUsers] = useState<Set<string>>(new Set());
|
||||
const [typingUsers, setTypingUsers] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Keep refs in sync so socket handlers use latest values
|
||||
useEffect(() => {
|
||||
selectedRoomIdRef.current = selectedRoomId;
|
||||
}, [selectedRoomId]);
|
||||
|
||||
useEffect(() => {
|
||||
notificationEnabledRef.current = notificationEnabled;
|
||||
}, [notificationEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (!token) return;
|
||||
@@ -43,6 +52,11 @@ export function useMessengerSocket() {
|
||||
|
||||
socketRef.current = socket;
|
||||
|
||||
// BUG-8: Join rooms on connect
|
||||
socket.on("connect", () => {
|
||||
socket.emit("join_rooms");
|
||||
});
|
||||
|
||||
socket.on("user_online", (data: { userId: string; online: boolean }) => {
|
||||
setOnlineUsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -52,33 +66,45 @@ export function useMessengerSocket() {
|
||||
});
|
||||
});
|
||||
|
||||
// BUG-5 & BUG-8: Handle new_message with cache invalidation and toast
|
||||
socket.on("new_message", (data: NewMessageEvent) => {
|
||||
if (data.roomId !== selectedRoomId && notificationEnabled) {
|
||||
const roomIdStr = String(data.room_id);
|
||||
|
||||
// Invalidate React Query caches
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "messages", roomIdStr] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "rooms"] });
|
||||
qc.invalidateQueries({ queryKey: ["messenger", "unread"] });
|
||||
|
||||
// Toast for messages in other rooms
|
||||
if (roomIdStr !== selectedRoomIdRef.current && notificationEnabledRef.current) {
|
||||
toast({
|
||||
title: data.message.senderName,
|
||||
description: data.message.content.slice(0, 50),
|
||||
title: data.sender_name,
|
||||
description: data.content?.slice(0, 50),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("typing_start", (data: TypingEvent) => {
|
||||
// BUG-7: Backend emits "user_typing" / "user_stop_typing", not "typing_start" / "typing_stop"
|
||||
socket.on("user_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(data.roomId) || [];
|
||||
if (!users.includes(data.userName)) {
|
||||
next.set(data.roomId, [...users, data.userName]);
|
||||
const users = next.get(roomIdStr) || [];
|
||||
if (!users.includes(data.user_name)) {
|
||||
next.set(roomIdStr, [...users, data.user_name]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("typing_stop", (data: TypingEvent) => {
|
||||
socket.on("user_stop_typing", (data: TypingEvent) => {
|
||||
const roomIdStr = String(data.room_id);
|
||||
setTypingUsers((prev) => {
|
||||
const next = new Map(prev);
|
||||
const users = next.get(data.roomId) || [];
|
||||
const users = next.get(roomIdStr) || [];
|
||||
next.set(
|
||||
data.roomId,
|
||||
users.filter((u) => u !== data.userName)
|
||||
roomIdStr,
|
||||
users.filter((u) => u !== data.user_name)
|
||||
);
|
||||
return next;
|
||||
});
|
||||
@@ -88,18 +114,19 @@ export function useMessengerSocket() {
|
||||
socket.disconnect();
|
||||
socketRef.current = null;
|
||||
};
|
||||
}, [selectedRoomId, notificationEnabled, toast]);
|
||||
}, [toast, qc]);
|
||||
|
||||
// BUG-7: Backend expects { room_id }, not { roomId }
|
||||
const emitTypingStart = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_start", { roomId });
|
||||
socketRef.current?.emit("typing_start", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const emitTypingStop = useCallback(
|
||||
(roomId: string) => {
|
||||
socketRef.current?.emit("typing_stop", { roomId });
|
||||
socketRef.current?.emit("typing_stop", { room_id: Number(roomId) });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user