|
|
@@ -0,0 +1,373 @@
|
|
|
+'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>
|
|
|
+ );
|
|
|
+}
|