Files
vexplor_dev/frontend/components/messenger/MessageItem.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

149 lines
5.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { SmilePlus, MessageSquare, Download } from "lucide-react";
import { cn } from "@/lib/utils";
import { UserAvatar } from "./UserAvatar";
import type { Message } from "@/hooks/useMessenger";
import { useAddReaction } from "@/hooks/useMessenger";
const QUICK_EMOJIS = ["\u{1F44D}", "\u{2764}\u{FE0F}", "\u{1F602}", "\u{1F44F}", "\u{1F64F}", "\u{1F525}"];
interface MessageItemProps {
message: Message;
isOwn: boolean;
showAvatar: boolean;
}
export function MessageItem({ message, isOwn, showAvatar }: MessageItemProps) {
const [showActions, setShowActions] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const addReaction = useAddReaction();
if (message.isDeleted) {
return (
<div className={cn("flex gap-2 px-3 py-1", isOwn && "flex-row-reverse")}>
{showAvatar && !isOwn ? (
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
) : (
<div className="w-7" />
)}
<div className="text-xs text-muted-foreground italic"> </div>
</div>
);
}
const time = new Date(message.createdAt).toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
className={cn("group flex gap-2 px-3 py-0.5", isOwn && "flex-row-reverse")}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => {
setShowActions(false);
setShowEmojiPicker(false);
}}
>
{showAvatar && !isOwn ? (
<UserAvatar photo={message.senderPhoto} name={message.senderName} size="sm" />
) : (
<div className="w-7" />
)}
<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}
</span>
)}
<div className="relative">
<div
className={cn(
"rounded-lg px-3 py-1.5 text-sm break-words",
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{message.type === "file" && message.fileUrl ? (
<a
href={message.fileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 underline"
>
<Download className="h-3.5 w-3.5" />
{message.fileName || "파일 다운로드"}
</a>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
{showActions && (
<div
className={cn(
"absolute top-0 flex items-center gap-0.5 bg-background border rounded-md shadow-sm px-1 py-0.5 -translate-y-1/2",
isOwn ? "left-0 -translate-x-full mr-1" : "right-0 translate-x-full ml-1"
)}
>
<button
onClick={() => setShowEmojiPicker((p) => !p)}
className="p-0.5 hover:bg-muted rounded"
>
<SmilePlus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
<button className="p-0.5 hover:bg-muted rounded">
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
</button>
</div>
)}
{showEmojiPicker && (
<div
className={cn(
"absolute top-0 -translate-y-full bg-background border rounded-md shadow-md p-1 flex gap-0.5 z-10",
isOwn ? "right-0" : "left-0"
)}
>
{QUICK_EMOJIS.map((emoji) => (
<button
key={emoji}
onClick={() => {
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji });
setShowEmojiPicker(false);
}}
className="hover:bg-muted rounded p-0.5 text-base"
>
{emoji}
</button>
))}
</div>
)}
</div>
{message.reactions.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{message.reactions.map((r) => (
<button
key={r.emoji}
onClick={() =>
addReaction.mutate({ messageId: message.id, roomId: message.roomId, emoji: r.emoji })
}
className="flex items-center gap-0.5 rounded-full bg-muted px-1.5 py-0.5 text-xs hover:bg-muted/80"
>
<span>{r.emoji}</span>
<span className="text-muted-foreground">{r.users.length}</span>
</button>
))}
</div>
)}
<span className="text-[10px] text-muted-foreground mt-0.5">{time}</span>
</div>
</div>
);
}