[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>
149 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|