- 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>
119 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|