Files
vexplor_dev/frontend/components/messenger/ChatPanel.tsx
syc0123 40aeb3079a [RAPID] feat: 메신저 기능 구현 (Socket.IO 실시간 채팅)
- DB: messenger_rooms/participants/messages/reactions/files 테이블 생성
- Backend: REST API 9개 엔드포인트 + Socket.IO 실시간 핸들러
- Frontend: Gmail 스타일 FAB + 모달, 채팅방 목록, 채팅 패널
- 기능: DM/그룹/채널, 파일 첨부, 이모지 리액션, 멘션, 스레드
- 알림: 토스트 on/off 토글, FAB 읽지 않은 배지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:05:54 +09:00

119 lines
3.9 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
import { MessageSquare } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useMessages, useMarkAsRead } from "@/hooks/useMessenger";
import { useAuth } from "@/hooks/useAuth";
import { useMessengerContext } from "@/contexts/MessengerContext";
import { useMessengerSocket } from "@/hooks/useMessengerSocket";
import { MessageItem } from "./MessageItem";
import { MessageInput } from "./MessageInput";
import type { Room } from "@/hooks/useMessenger";
interface ChatPanelProps {
room: Room | null;
}
export function ChatPanel({ room }: ChatPanelProps) {
const { user } = useAuth();
const { selectedRoomId } = useMessengerContext();
const { data: messages } = useMessages(selectedRoomId);
const markAsRead = useMarkAsRead();
const { emitTypingStart, emitTypingStop, typingUsers } = useMessengerSocket();
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedRoomId) {
markAsRead.mutate(selectedRoomId);
}
}, [selectedRoomId, messages?.length]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages?.length]);
if (!room) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2">
<MessageSquare className="h-10 w-10" />
<p className="text-sm"> </p>
</div>
);
}
const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined;
// Group consecutive messages from same sender
const isSameGroup = (idx: number) => {
if (idx === 0) return false;
const prev = messages![idx - 1];
const curr = messages![idx];
return prev.senderId === curr.senderId && !curr.isDeleted && !prev.isDeleted;
};
// Date separator helper
const shouldShowDate = (idx: number) => {
if (idx === 0) return true;
const prev = new Date(messages![idx - 1].createdAt).toDateString();
const curr = new Date(messages![idx].createdAt).toDateString();
return prev !== curr;
};
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>
</div>
{/* Messages */}
<ScrollArea className="flex-1">
<div className="py-2">
{messages?.map((msg, idx) => (
<div key={msg.id}>
{shouldShowDate(idx) && (
<div className="flex items-center gap-2 px-4 py-2">
<div className="flex-1 h-px bg-border" />
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleDateString("ko-KR", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
})}
</span>
<div className="flex-1 h-px bg-border" />
</div>
)}
<MessageItem
message={msg}
isOwn={msg.senderId === user?.userId}
showAvatar={!isSameGroup(idx)}
/>
</div>
))}
<div ref={bottomRef} />
</div>
</ScrollArea>
{/* Typing indicator */}
{roomTyping && roomTyping.length > 0 && (
<div className="px-4 py-1 text-xs text-muted-foreground">
{roomTyping.join(", ")} ...
</div>
)}
{/* Input */}
<MessageInput
roomId={room.id}
onTypingStart={() => emitTypingStart(room.id)}
onTypingStop={() => emitTypingStop(room.id)}
/>
</div>
);
}