| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- '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<ChatMessage[]>(initialMessages);
- const [systemMessages, setSystemMessages] = useState<SystemMessage[]>([]);
- const [participantCount, setParticipantCount] = useState(0);
- const [connState, setConnState] = useState<ConnectionState>('connecting');
- const [kicked, setKicked] = useState(false);
- const [inputValue, setInputValue] = useState('');
- const [cooldownLeft, setCooldownLeft] = useState(0);
- const [showJump, setShowJump] = useState(false);
- const listRef = useRef<HTMLDivElement>(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<MergedItem[]>(() => {
- 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<HTMLInputElement>) => {
- 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 (
- <div id='chat-room'>
- {/* 헤더: 제목 + 접속자 수 + 연결 상태 */}
- <header className='chat-room__head'>
- <h1>실시간 채팅</h1>
- <div className='chat-room__meta'>
- <span className='chat-room__count' aria-live='polite'>
- <FontAwesomeIcon icon={faUsers} />
- <span>{participantCount.toLocaleString()}명 참여 중</span>
- </span>
- {connState === 'connecting' && (
- <span className='chat-room__state'>연결 중…</span>
- )}
- {connState === 'reconnecting' && (
- <span className='chat-room__state chat-room__state--warn' role='status'>재연결 중…</span>
- )}
- {connState === 'disconnected' && (
- <span className='chat-room__state chat-room__state--error' role='status'>연결 끊김 — 새로고침 후 다시 시도해 주세요</span>
- )}
- </div>
- </header>
- {/* 상단 공지 */}
- {notice && (
- <div className='chat-room__notice' role='note'>
- <FontAwesomeIcon icon={faBullhorn} />
- <span>{notice}</span>
- </div>
- )}
- {/* 메시지 리스트 */}
- <div className='chat-room__body'>
- <div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
- {merged.length === 0 && (
- <div className='chat-room__empty'>아직 메시지가 없습니다. 첫 메시지를 남겨보세요.</div>
- )}
- {merged.map((item) => {
- if (item.kind === 'system') {
- return (
- <div key={`sys-${item.data.id}`} className='chat-room__system'>
- {item.data.content}
- </div>
- );
- }
- const msg = item.data;
- const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon;
- const badgeAlt = msg.titleName ?? '';
- return (
- <div key={messageKey(msg)} className='chat-room__message'>
- <time className='chat-room__time' dateTime={msg.sentAt}>{formatTime(msg.sentAt)}</time>
- {badgeUrl && (
- <img src={badgeUrl} alt={badgeAlt} className='chat-room__badge' aria-hidden={!badgeAlt} />
- )}
- <span
- className='chat-room__name'
- style={msg.titleColor ? ({ '--title-color': msg.titleColor } as CSSProperties) : undefined}
- >
- {msg.memberName || msg.memberSID}
- </span>
- <span className='chat-room__content'>{msg.content}</span>
- </div>
- );
- })}
- </div>
- {showJump && (
- <button type='button' className='chat-room__jump' onClick={jumpToBottom}>
- <FontAwesomeIcon icon={faArrowDown} />
- <span>새 메시지</span>
- </button>
- )}
- </div>
- {/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
- <div className='chat-room__footer'>
- {kicked ? (
- <div className='chat-room__blocked' role='status'>
- 관리자에 의해 채팅 이용이 제한되었습니다.
- </div>
- ) : isAuthenticated ? (
- <>
- <div className='chat-room__input-row'>
- <input
- type='text'
- placeholder='메시지를 입력하세요'
- value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
- onKeyDown={handleKeyDown}
- maxLength={MAX_CONTENT_LENGTH}
- disabled={!canType}
- aria-label='채팅 메시지 입력'
- />
- <button type='button' title='전송' onClick={handleSend} disabled={!canSend}>
- {cooldownLeft > 0 ? (
- <span className='chat-room__cooldown'>{cooldownLeft}</span>
- ) : (
- <FontAwesomeIcon icon={faPaperPlane} />
- )}
- </button>
- </div>
- <div className='chat-room__input-meta'>
- {slowModeSeconds > GLOBAL_RATE_LIMIT_SECONDS && (
- <span className='chat-room__slowmode'>슬로우 모드 {slowModeSeconds}초</span>
- )}
- {cooldownLeft > 0 && (
- <span className='chat-room__cooldown-left' aria-live='polite'>{cooldownLeft}초 후 전송 가능</span>
- )}
- <span className='chat-room__counter'>{inputValue.length}/{MAX_CONTENT_LENGTH}</span>
- </div>
- </>
- ) : (
- <div className='chat-room__guest'>
- <span>읽기는 자유롭게, 참여는 로그인 후 가능합니다.</span>
- <Link href='/login' className='chat-room__login-cta'>로그인 후 참여</Link>
- </div>
- )}
- </div>
- </div>
- );
- }
|