- 사용자 온라인 상태 표시 (온라인/자리비움/오프라인) 디스코드 스타일 - 채팅방별 알림 ON/OFF 토글 (Bell 아이콘, localStorage 저장) - 파일 업로드 실시간 소켓 브로드캐스트 및 한글 파일명 깨짐 수정 - FAB 읽지않음 배지 버그 수정 (메신저 닫혀있을 때 markAsRead 차단) - 타이핑 도트 애니메이션, 날짜 오늘/어제 표시 - 입력창 bordered box, DM 편집 버튼 숨김 - 메신저 설정 버튼 제거, 새 대화 시작하기 Empty state CTA - useMessengerSocket 소켓 중복 생성 방지 (MessengerModal로 이동) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com> [RAPID-micro] 추적 파일 정리 및 메신저 소소한 변경 - .omc/state/ 파일 git 추적 제거 (.gitignore 이미 설정됨) - db/checkpoints/ gitignore 추가 - globals.css: 메신저 메시지 시간 폰트 스타일 추가 - useMessenger.ts: fileMimeType 필드 및 API_BASE_URL import 추가 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
235 lines
9.3 KiB
TypeScript
235 lines
9.3 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { MessageSquare, Pencil, Check, X, ChevronsDown, Bell, BellOff } from "lucide-react";
|
|
import { useMessages, useMarkAsRead, useUpdateRoom } from "@/hooks/useMessenger";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useMessengerContext } from "@/contexts/MessengerContext";
|
|
import { MessageItem } from "./MessageItem";
|
|
import { MessageInput } from "./MessageInput";
|
|
import type { Room } from "@/hooks/useMessenger";
|
|
|
|
interface ChatPanelProps {
|
|
room: Room | null;
|
|
typingUsers: Map<string, string[]>;
|
|
emitTypingStart: (roomId: string) => void;
|
|
emitTypingStop: (roomId: string) => void;
|
|
onNewRoom: () => void;
|
|
}
|
|
|
|
const formatDateLabel = (dateStr: string) => {
|
|
const d = new Date(dateStr);
|
|
const today = new Date();
|
|
const yesterday = new Date();
|
|
yesterday.setDate(today.getDate() - 1);
|
|
if (d.toDateString() === today.toDateString()) return "오늘";
|
|
if (d.toDateString() === yesterday.toDateString()) return "어제";
|
|
return d.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", weekday: "short" });
|
|
};
|
|
|
|
export function ChatPanel({ room, typingUsers, emitTypingStart, emitTypingStop, onNewRoom }: ChatPanelProps) {
|
|
const { user } = useAuth();
|
|
const { selectedRoomId, isOpen, mutedRooms, toggleRoomMute } = useMessengerContext();
|
|
const { data: messages } = useMessages(selectedRoomId);
|
|
const markAsRead = useMarkAsRead();
|
|
const updateRoom = useUpdateRoom();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
const [editName, setEditName] = useState("");
|
|
const editInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && selectedRoomId) {
|
|
markAsRead.mutate(selectedRoomId);
|
|
}
|
|
}, [isOpen, selectedRoomId, messages?.length]);
|
|
|
|
// Re-attach scroll listener whenever room changes
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => {
|
|
// In flex-col-reverse, scrollTop=0 means bottom (newest messages)
|
|
setIsAtBottom(Math.abs(el.scrollTop) < 60);
|
|
};
|
|
el.addEventListener("scroll", onScroll);
|
|
return () => el.removeEventListener("scroll", onScroll);
|
|
}, [room?.id]);
|
|
|
|
if (!room) {
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-3">
|
|
<MessageSquare className="h-10 w-10" />
|
|
<p className="text-sm">대화를 선택하세요</p>
|
|
<button
|
|
onClick={onNewRoom}
|
|
className="px-4 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
|
>
|
|
새 대화 시작하기
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const roomTyping = selectedRoomId ? typingUsers.get(selectedRoomId) : undefined;
|
|
|
|
// messages is oldest-first; flex-col-reverse CSS pins scroll to bottom (newest visible)
|
|
const isFirstInGroup = (idx: number) => {
|
|
if (!messages) return true;
|
|
if (idx === 0) return true;
|
|
const prev = messages[idx - 1];
|
|
const curr = messages[idx];
|
|
if (prev.senderId !== curr.senderId || curr.isDeleted || prev.isDeleted) return true;
|
|
const gap = new Date(curr.createdAt).getTime() - new Date(prev.createdAt).getTime();
|
|
return gap > 5 * 60 * 1000;
|
|
};
|
|
|
|
const isLastInGroup = (idx: number) => {
|
|
if (!messages) return true;
|
|
if (idx === messages.length - 1) return true;
|
|
const curr = messages[idx];
|
|
const next = messages[idx + 1];
|
|
if (curr.senderId !== next.senderId || next.isDeleted || curr.isDeleted) return true;
|
|
const gap = new Date(next.createdAt).getTime() - new Date(curr.createdAt).getTime();
|
|
return gap > 5 * 60 * 1000;
|
|
};
|
|
|
|
const shouldShowDate = (idx: number) => {
|
|
if (!messages) return false;
|
|
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;
|
|
};
|
|
|
|
// 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 min-h-0 overflow-hidden relative">
|
|
{/* Header */}
|
|
<div className="border-b px-4 py-2 flex items-center gap-2">
|
|
{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>
|
|
{room.type !== "dm" && (
|
|
<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>
|
|
<button
|
|
onClick={() => selectedRoomId && toggleRoomMute(selectedRoomId)}
|
|
className="p-0.5 hover:bg-muted rounded shrink-0 ml-auto"
|
|
title={selectedRoomId && mutedRooms.has(selectedRoomId) ? "알림 켜기" : "알림 끄기"}
|
|
>
|
|
{selectedRoomId && mutedRooms.has(selectedRoomId)
|
|
? <BellOff className="h-3.5 w-3.5 text-muted-foreground" />
|
|
: <Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
|
}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Messages — flex-col-reverse keeps scroll pinned to bottom (newest visible) */}
|
|
<div ref={scrollRef} className="flex-1 min-h-0 overflow-y-auto relative flex flex-col-reverse">
|
|
<div className="pb-2">
|
|
{/* Typing indicator at top of inner div = visually just above messages */}
|
|
<div className="px-4 h-5 flex items-center text-xs text-muted-foreground gap-1">
|
|
{roomTyping && roomTyping.length > 0 && (
|
|
<>
|
|
<span>{roomTyping.join(", ")}님이 입력 중</span>
|
|
<span className="flex gap-0.5 items-center">
|
|
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:0ms]" />
|
|
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:150ms]" />
|
|
<span className="w-1 h-1 bg-muted-foreground rounded-full animate-bounce [animation-delay:300ms]" />
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{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">
|
|
{formatDateLabel(msg.createdAt)}
|
|
</span>
|
|
<div className="flex-1 h-px bg-border" />
|
|
</div>
|
|
)}
|
|
<MessageItem
|
|
message={msg}
|
|
isOwn={msg.senderId === user?.userId}
|
|
showAvatar={isFirstInGroup(idx)}
|
|
isLastInGroup={isLastInGroup(idx)}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{!isAtBottom && (
|
|
<button
|
|
onClick={() => { const el = scrollRef.current; if (el) el.scrollTop = 0; }}
|
|
className="absolute bottom-14 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-primary text-primary-foreground text-xs px-3 py-1.5 rounded-full shadow-md hover:bg-primary/90"
|
|
>
|
|
<ChevronsDown className="h-3.5 w-3.5" />
|
|
최신 메시지
|
|
</button>
|
|
)}
|
|
|
|
{/* Input */}
|
|
<MessageInput
|
|
roomId={room.id}
|
|
onTypingStart={() => emitTypingStart(room.id)}
|
|
onTypingStop={() => emitTypingStop(room.id)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|