import { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft, ChevronRight, Loader2, MessageCircle, Send, UserRound, X } from 'lucide-react'; import { io } from 'socket.io-client'; import type { Socket } from 'socket.io-client'; import { CHAT_SOCKET_ORIGIN, CHAT_SOCKET_PATH, createInstructorChatMessage, getInstructorChatThread, listInstructorChatThreads, type InstructorChatMessage, type InstructorChatThread, } from '../lib/chat'; import type { AuthSession } from '../types/auth'; type InstructorChatPanelProps = { session: AuthSession; open: boolean; initialThreadId?: string | null; initialThreadRequestId?: number; onClose: () => void; }; type ChatNotificationPayload = { type: 'chat.message.created'; threadId: string; messageId: string; senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'; preview: string; createdAt: string; }; export function InstructorChatPanel({ session, open, initialThreadId = null, initialThreadRequestId = 0, onClose }: InstructorChatPanelProps) { const [threads, setThreads] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(null); const [loading, setLoading] = useState(false); const [roomLoading, setRoomLoading] = useState(false); const [error, setError] = useState(null); const [mounted, setMounted] = useState(open); const selectedThread = useMemo(() => threads.find((thread) => thread.id === selectedThreadId) ?? null, [selectedThreadId, threads]); const refreshThreadList = (showLoading = false) => { let cancelled = false; if (showLoading) setLoading(true); setError(null); const request = listInstructorChatThreads(session.accessToken) .then((items) => { if (!cancelled) setThreads(items); }) .catch((reason: Error) => { if (!cancelled) { setThreads([]); setError(reason.message); } }) .finally(() => { if (!cancelled && showLoading) setLoading(false); }); return { cancel: () => { cancelled = true; }, request, }; }; const refreshSelectedThread = (threadId: string, showLoading = false) => { if (showLoading) setRoomLoading(true); setError(null); return getInstructorChatThread(session.accessToken, threadId) .then((thread) => { setThreads((current) => replaceThread(current, thread)); }) .catch((reason: Error) => { setError(reason.message); }) .finally(() => { if (showLoading) setRoomLoading(false); }); }; useEffect(() => { if (!open) return; const refresh = refreshThreadList(true); return () => { refresh.cancel(); }; }, [open, session.accessToken]); useEffect(() => { if (!open || !initialThreadId) return; setSelectedThreadId(initialThreadId); void refreshSelectedThread(initialThreadId, true); }, [open, initialThreadId, initialThreadRequestId, session.accessToken]); useEffect(() => { if (!open) return; const socket: Socket = io(CHAT_SOCKET_ORIGIN, { auth: { token: session.accessToken }, path: CHAT_SOCKET_PATH, transports: ['websocket', 'polling'], }); const handleMessageCreated = (payload: ChatNotificationPayload) => { void refreshThreadList(false).request; if (selectedThreadId && payload.threadId === selectedThreadId) { void refreshSelectedThread(selectedThreadId, false); } }; socket.on('connect', () => { void refreshThreadList(false).request; if (selectedThreadId) void refreshSelectedThread(selectedThreadId, false); }); socket.on('chat.message.created', handleMessageCreated); return () => { socket.off('chat.message.created', handleMessageCreated); socket.disconnect(); }; }, [open, selectedThreadId, session.accessToken]); useEffect(() => { if (!open) setSelectedThreadId(null); }, [open]); useEffect(() => { if (open) { setMounted(true); return; } const timer = window.setTimeout(() => setMounted(false), 220); return () => window.clearTimeout(timer); }, [open]); const handleOpenThread = (threadId: string) => { setSelectedThreadId(threadId); void refreshSelectedThread(threadId, true); }; const handleSendMessage = async (threadId: string, content: string) => { const updatedThread = await createInstructorChatMessage(session.accessToken, threadId, content); setThreads((current) => replaceThread(current, updatedThread)); }; if (!mounted) return null; return ( ); } function ThreadCard({ accessToken, thread, onOpen }: { accessToken: string; thread: InstructorChatThread; onOpen: () => void }) { return ( ); } function StudentAvatar({ accessToken, thread }: { accessToken: string; thread: InstructorChatThread }) { const [avatarSource, setAvatarSource] = useState(null); useEffect(() => { if (!thread.studentAvatarUrl) { setAvatarSource(null); return; } let revoked = false; let objectUrl: string | null = null; fetch(thread.studentAvatarUrl, { headers: { Authorization: `Bearer ${accessToken}`, }, }) .then((response) => (response.ok ? response.blob() : null)) .then((blob) => { if (!blob || revoked) return; objectUrl = URL.createObjectURL(blob); setAvatarSource(objectUrl); }) .catch(() => { if (!revoked) setAvatarSource(null); }); return () => { revoked = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [accessToken, thread.studentAvatarUrl]); return ( {avatarSource ? ( ) : ( ); } function ChatRoom({ thread, loading, onBack, onClose, onSend, }: { thread: InstructorChatThread; loading: boolean; onBack: () => void; onClose: () => void; onSend: (threadId: string, content: string) => Promise; }) { const messages = thread.messages.length > 0 ? thread.messages : createFallbackMessages(thread); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); const messageScrollRef = useRef(null); useEffect(() => { const target = messageScrollRef.current; if (!target || loading) return; target.scrollTo({ top: target.scrollHeight, behavior: 'smooth', }); }, [loading, messages.length, thread.id]); const handleSubmit = async () => { const content = draft.trim(); if (!content || sending) return; setSending(true); setSendError(null); try { await onSend(thread.id, content); setDraft(''); } catch (reason) { setSendError(reason instanceof Error ? reason.message : '강사 답변을 저장하지 못했습니다.'); } finally { setSending(false); } }; return ( <>

{thread.studentName}

{thread.status === 'WAITING_INSTRUCTOR' ? : null}

{[thread.groupName, thread.week ? `${thread.week}주차` : null].filter(Boolean).join(' · ') || '강사 문의'}

{loading ? (
) : (
{messages.map((message) => ( ))}
)}