'use client'; import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent } from 'react'; import Link from 'next/link'; import { HubConnectionState } from '@microsoft/signalr'; import useAuth from '@/hooks/useAuth'; import { useSignalRContext } from '@/contexts/signalrProvider'; import type { ChatMessage } from '@/types/chat'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faUsers, faPaperPlane, faBullhorn, faArrowDown } from '@fortawesome/free-solid-svg-icons'; 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; 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; }; export default function MainChat({ initialMessages, notice, slowModeSeconds }: Props) { const { isAuthenticated } = 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); const listRef = useRef(null); const autoScrollRef = useRef(true); const systemIdRef = useRef(0); const cooldownEndRef = useRef(0); const activeRef = useRef(true); 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 handleKick = () => { setKicked(true); pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.'); }; chatConnection.on('ReceiveHistory', handleHistory); chatConnection.on('ReceiveMessage', handleMessage); chatConnection.on('ReceiveSystemMessage', handleSystem); chatConnection.on('ReceiveParticipantCount', handleCount); 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('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]); // 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출 useEffect(() => { const el = listRef.current; if (!el || merged.length === 0) { return; } if (autoScrollRef.current) { el.scrollTop = el.scrollHeight; } else { setShowJump(true); } }, [merged.length]); 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]); const canType = connState === 'connected' && !kicked; const canSend = canType && inputValue.trim().length > 0 && cooldownLeft === 0; return (
{/* 헤더: 제목 + 접속자 수 + 연결 상태 */}

실시간 채팅

{participantCount.toLocaleString()}명 참여 중 {connState === 'connecting' && ( 연결 중… )} {connState === 'reconnecting' && ( 재연결 중… )} {connState === 'disconnected' && ( 연결 끊김 — 새로고침 후 다시 시도해 주세요 )}
{/* 상단 공지 */} {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 && ( {badgeAlt} )} {msg.memberName || msg.memberSID} {msg.content}
); })}
{showJump && ( )}
{/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
{kicked ? (
관리자에 의해 채팅 이용이 제한되었습니다.
) : isAuthenticated ? ( <>
setInputValue(e.target.value)} onKeyDown={handleKeyDown} maxLength={MAX_CONTENT_LENGTH} disabled={!canType} aria-label='채팅 메시지 입력' />
{slowModeSeconds > GLOBAL_RATE_LIMIT_SECONDS && ( 슬로우 모드 {slowModeSeconds}초 )} {cooldownLeft > 0 && ( {cooldownLeft}초 후 전송 가능 )} {inputValue.length}/{MAX_CONTENT_LENGTH}
) : (
읽기는 자유롭게, 참여는 로그인 후 가능합니다. 로그인 후 참여
)}
); }