'use client'; import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type FocusEvent, type ChangeEvent, type ReactNode } from 'react'; import { HubConnectionState } from '@microsoft/signalr'; import useAuth from '@/hooks/useAuth'; import { useSignalRContext } from '@/contexts/signalrProvider'; import type { ChatMessage, ChatParticipant } from '@/types/chat'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUsers, faPaperPlane, faBullhorn, faArrowDown, faChevronDown, faBell } from '@fortawesome/free-solid-svg-icons'; import EmojiPicker from '@/app/component/EmojiPicker'; import ChatParticipants from './_component/ChatParticipants'; import ChatSettingsMenu from './_component/ChatSettingsMenu'; import ChatReceiveModal from './_component/ChatReceiveModal'; const ROOM_KEY = 'main'; const MAX_MESSAGES = 200; // 최근 200건 유지·오버플로 드랍 (Backend ChatSettings.MaxMessages 와 동일) const MAX_SYSTEM_MESSAGES = 50; const MAX_CONTENT_LENGTH = 500; const GLOBAL_RATE_LIMIT_SECONDS = 2; // Hub 전역 rate limit (ChatSettings.RateLimitSeconds) const SCROLL_BOTTOM_THRESHOLD = 50; // 글씨 크게/작게 — #chat-room 의 --chat-fs 배율(localStorage 영속) const FONT_SCALE_MIN = 0.85; const FONT_SCALE_MAX = 1.6; const FONT_SCALE_STEP = 0.1; const FONT_SCALE_KEY = 'az-chat-fs'; type SystemMessage = { id: number; content: string; receivedAt: string; }; type MergedItem = | { kind: 'chat'; time: string; data: ChatMessage } | { kind: 'system'; time: string; data: SystemMessage }; type ConnectionState = 'connecting'|'connected'|'reconnecting'|'disconnected'; // 메시지 dedupe 키 — ChatMessage 페이로드에 단일 ID 필드가 없어 (memberSID, sentAt, content) 합성 키 사용 // SSR 초기 히스토리 ↔ JoinRoom ReceiveHistory ↔ 실시간 ReceiveMessage 간 중복 제거 const messageKey = (m: ChatMessage) => `${m.memberSID}|${m.sentAt}|${m.content}`; const formatTime = (dateStr: string) => { const date = new Date(dateStr); return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; }; type Props = { initialMessages?: ChatMessage[]; notice?: string|null; slowModeSeconds?: number; // 헤더 우측(.chat-room__meta)에 주입할 컨트롤 — 채팅 도크의 접기 버튼 등 (없으면 미렌더) headerSlot?: ReactNode; // 팝업 창(/chat-popup)에서 렌더 시 true — 전체 창 레이아웃 + "새창으로 보기"/도크 컨트롤 숨김 popup?: boolean; }; export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot, popup = false }: Props) { const { isAuthenticated, loginCheck } = useAuth(); const { chatConnection, chatConnected } = useSignalRContext(); const [messages, setMessages] = useState(initialMessages); const [systemMessages, setSystemMessages] = useState([]); const [participantCount, setParticipantCount] = useState(0); const [connState, setConnState] = useState('connecting'); const [kicked, setKicked] = useState(false); const [inputValue, setInputValue] = useState(''); const [cooldownLeft, setCooldownLeft] = useState(0); const [showJump, setShowJump] = useState(false); // 참여자 목록 / 설정 UI const [participants, setParticipants] = useState([]); const [participantsLoading, setParticipantsLoading] = useState(false); const [showParticipants, setShowParticipants] = useState(false); const [showReceiveModal, setShowReceiveModal] = useState(false); const [fontScale, setFontScale] = useState(1); const listRef = useRef(null); const inputRef = useRef(null); const autoScrollRef = useRef(true); const systemIdRef = useRef(0); const cooldownEndRef = useRef(0); const activeRef = useRef(true); // 글씨 배율 localStorage 복원 useEffect(() => { try { const saved = parseFloat(window.localStorage.getItem(FONT_SCALE_KEY) ?? ''); if (!Number.isNaN(saved) && saved >= FONT_SCALE_MIN && saved <= FONT_SCALE_MAX) { setFontScale(saved); } } catch {} }, []); const pushSystem = useCallback((content: string) => { setSystemMessages((prev) => { const next = [...prev, { id: ++systemIdRef.current, content, receivedAt: new Date().toISOString() }]; return next.length > MAX_SYSTEM_MESSAGES ? next.slice(next.length - MAX_SYSTEM_MESSAGES) : next; }); }, []); const appendMessage = useCallback((message: ChatMessage) => { setMessages((prev) => { const key = messageKey(message); if (prev.some((c) => messageKey(c) === key)) { return prev; } const next = [...prev, message]; return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next; }); }, []); // JoinRoom/재연결 시 ReceiveHistory — SSR 초기분과 병합 (dedupe + 시간순 + 상한) const mergeHistory = useCallback((history: ChatMessage[]) => { setMessages((prev) => { const seen = new Set(prev.map(messageKey)); const added = history.filter((c) => !seen.has(messageKey(c))); if (added.length === 0) { return prev; } const next = [...prev, ...added].sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime()); return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next; }); }, []); // ── 연결 라이프사이클: provider 의 chatConnection 재사용, JoinRoom 은 마운트 시 · LeaveRoom 은 언마운트 시 ── useEffect(() => { if (!chatConnection || !chatConnected) { return; } activeRef.current = true; const handleHistory = (history: ChatMessage[]) => mergeHistory(history); const handleMessage = (message: ChatMessage) => appendMessage(message); const handleSystem = (content: string) => pushSystem(content); const handleCount = (count: number) => setParticipantCount(count); const handleParticipants = (list: ChatParticipant[]) => { setParticipants(list); setParticipantsLoading(false); }; const handleKick = () => { setKicked(true); pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.'); }; chatConnection.on('ReceiveHistory', handleHistory); chatConnection.on('ReceiveMessage', handleMessage); chatConnection.on('ReceiveSystemMessage', handleSystem); chatConnection.on('ReceiveParticipantCount', handleCount); chatConnection.on('ReceiveParticipants', handleParticipants); chatConnection.on('Kick', handleKick); // 자동 재연결 상태 UX — signalR 은 이 콜백들의 해제 API 가 없어 activeRef 로 stale 호출 가드 chatConnection.onreconnecting(() => { if (activeRef.current) { setConnState('reconnecting'); } }); chatConnection.onreconnected(() => { if (!activeRef.current) { return; } setConnState('connected'); // 재연결 시 그룹 멤버십 소실 → 재참가 (히스토리 재수신은 mergeHistory 가 dedupe) chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => { console.error('채팅 룸 재참가 실패:', err); }); }); chatConnection.onclose(() => { if (activeRef.current) { setConnState('disconnected'); } }); setConnState('connected'); chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => { console.error('채팅 룸 참가 실패:', err); pushSystem('채팅방 입장에 실패했습니다. 새로고침 후 다시 시도해 주세요.'); }); return () => { activeRef.current = false; chatConnection.off('ReceiveHistory', handleHistory); chatConnection.off('ReceiveMessage', handleMessage); chatConnection.off('ReceiveSystemMessage', handleSystem); chatConnection.off('ReceiveParticipantCount', handleCount); chatConnection.off('ReceiveParticipants', handleParticipants); chatConnection.off('Kick', handleKick); if (chatConnection.state === HubConnectionState.Connected) { chatConnection.invoke('LeaveRoom').catch(() => {}); } }; }, [chatConnection, chatConnected, mergeHistory, appendMessage, pushSystem]); // 채팅 + 시스템 메시지 시간순 병합 const merged = useMemo(() => { const items: MergedItem[] = [ ...messages.map((c) => ({ kind: 'chat' as const, time: c.sentAt, data: c })), ...systemMessages.map((c) => ({ kind: 'system' as const, time: c.receivedAt, data: c })) ]; items.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()); return items; }, [messages, systemMessages]); // 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출 // 도크 접힘(display:none) 상태에서는 clientHeight===0 → 스크롤 조작 생략(펼칠 때 ResizeObserver 가 복원) useEffect(() => { const el = listRef.current; if (!el || merged.length === 0 || el.clientHeight === 0) { return; } if (autoScrollRef.current) { el.scrollTop = el.scrollHeight; } else { setShowJump(true); } }, [merged.length]); // 접힘→펼침(또는 리사이즈)으로 메시지 영역이 다시 표시되면 하단 고정 상태였을 때 하단으로 재정렬 useEffect(() => { const el = listRef.current; if (!el || typeof ResizeObserver === 'undefined') { return; } const observer = new ResizeObserver(() => { if (el.clientHeight > 0 && autoScrollRef.current) { el.scrollTop = el.scrollHeight; } }); observer.observe(el); return () => observer.disconnect(); }, []); const handleScroll = useCallback(() => { const el = listRef.current; if (!el) { return; } const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_BOTTOM_THRESHOLD; autoScrollRef.current = atBottom; if (atBottom) { setShowJump(false); } }, []); const jumpToBottom = useCallback(() => { const el = listRef.current; if (!el) { return; } el.scrollTop = el.scrollHeight; autoScrollRef.current = true; setShowJump(false); }, []); // 전송 쿨다운 (전역 2초 + 룸 SlowMode 중 큰 값) — 남은 초 표시 const cooling = cooldownLeft > 0; useEffect(() => { if (!cooling) { return; } const timer = window.setInterval(() => { const left = Math.ceil((cooldownEndRef.current - Date.now()) / 1000); setCooldownLeft(left > 0 ? left : 0); }, 250); return () => window.clearInterval(timer); }, [cooling]); const handleSend = useCallback(async () => { const trimmed = inputValue.trim(); if (!trimmed || trimmed.length > MAX_CONTENT_LENGTH || cooldownLeft > 0 || kicked) { return; } if (!chatConnection || chatConnection.state !== HubConnectionState.Connected) { return; } try { await chatConnection.invoke('SendMessage', trimmed); setInputValue(''); const seconds = Math.max(GLOBAL_RATE_LIMIT_SECONDS, slowModeSeconds); cooldownEndRef.current = Date.now() + seconds * 1000; setCooldownLeft(seconds); } catch (error) { console.error('메시지 전송 실패:', error); pushSystem('메시지 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.'); } }, [inputValue, cooldownLeft, kicked, chatConnection, slowModeSeconds, pushSystem]); const handleKeyDown = useCallback((e: KeyboardEvent) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) { e.preventDefault(); handleSend(); } }, [handleSend]); // 비로그인 입력 게이트 — 입력창 focus(클릭·탭 포함) 시 로그인 confirm 후 blur const handleInputFocus = useCallback((e: FocusEvent) => { if (isAuthenticated) { return; } const el = e.currentTarget; loginCheck(); el.blur(); }, [isAuthenticated, loginCheck]); const handleInputChange = useCallback((e: ChangeEvent) => { if (!isAuthenticated) { return; } setInputValue(e.target.value); }, [isAuthenticated]); // 이모지 선택 — 비로그인은 로그인 유도 const handleEmoji = useCallback((emoji: string) => { if (!isAuthenticated) { loginCheck(); return; } setInputValue((prev) => { const next = prev + emoji; // 길이 초과 시 이모지 전체 거부 — 코드유닛 slice 로 surrogate/ZWJ 시퀀스가 잘리는 것 방지 return next.length <= MAX_CONTENT_LENGTH ? next : prev; }); }, [isAuthenticated, loginCheck]); // 참여자 목록 토글 — 열 때 서버에 목록 요청 const toggleParticipants = useCallback(() => { setShowParticipants((prev) => { const next = !prev; if (next && chatConnection && chatConnection.state === HubConnectionState.Connected) { setParticipantsLoading(true); chatConnection.invoke('RequestParticipants').catch(() => setParticipantsLoading(false)); } return next; }); }, [chatConnection]); // 새로고침 — 메시지 비우고 히스토리·인원 재요청 const handleRefresh = useCallback(() => { setMessages([]); setSystemMessages([]); if (chatConnection && chatConnection.state === HubConnectionState.Connected) { chatConnection.invoke('RequestHistory').catch(() => {}); chatConnection.invoke('RequestParticipantCount').catch(() => {}); } }, [chatConnection]); // 지우개 — 내 화면만 비우기(서버 삭제 아님) const handleClearLocal = useCallback(() => { setMessages([]); setSystemMessages([]); }, []); // 글씨 크게/작게 const changeFontScale = useCallback((delta: number) => { setFontScale((prev) => { const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round((prev + delta) * 100) / 100)); try { window.localStorage.setItem(FONT_SCALE_KEY, String(next)); } catch {} return next; }); }, []); // 새창으로 보기 — 별도 팝업 라우트(별개 SignalR 연결) const handleOpenPopup = useCallback(() => { window.open('/chat-popup', 'antoozaChat', 'width=400,height=760,resizable=yes,scrollbars=yes'); }, []); // 수신설정 modal — 로그인 필수 const openReceiveModal = useCallback(() => { if (!isAuthenticated) { loginCheck(); return; } setShowReceiveModal(true); }, [isAuthenticated, loginCheck]); const canType = connState === 'connected' && !kicked; const canSend = isAuthenticated && canType && inputValue.trim().length > 0 && cooldownLeft === 0; return (
{/* 헤더: 참여자 수(클릭→목록) + 연결 상태 + 수신설정 + 설정 dropdown */}
{connState === 'connecting' && ( 연결 중… )} {connState === 'reconnecting' && ( 재연결 중… )} {connState === 'disconnected' && ( 연결 끊김 )} changeFontScale(FONT_SCALE_STEP)} onFontSmaller={() => changeFontScale(-FONT_SCALE_STEP)} showPopup={!popup} /> {headerSlot}
{/* 상단 공지 */} {notice && (
{notice}
)} {/* 메시지 리스트 (+ 참여자 목록 오버레이) */}
{merged.length === 0 && (
아직 메시지가 없습니다. 첫 메시지를 남겨보세요.
)} {merged.map((item) => { if (item.kind === 'system') { return (
{item.data.content}
); } const msg = item.data; const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon; const badgeAlt = msg.titleName ?? ''; return (
{badgeUrl && ( // eslint-disable-next-line @next/next/no-img-element {badgeAlt} )} {msg.memberName || msg.memberSID} {msg.content}
); })}
{showParticipants && (
setShowParticipants(false)} />
)} {showJump && ( )}
{/* 입력부 — 로그인 사용자만 전송. 비로그인은 입력창 focus 시 로그인 유도 */}
{kicked ? (
관리자에 의해 채팅 이용이 제한되었습니다.
) : (
)}
{showReceiveModal && setShowReceiveModal(false)} />}
); }