| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538 |
- '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<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);
- // 참여자 목록 / 설정 UI
- const [participants, setParticipants] = useState<ChatParticipant[]>([]);
- const [participantsLoading, setParticipantsLoading] = useState(false);
- const [showParticipants, setShowParticipants] = useState(false);
- const [showReceiveModal, setShowReceiveModal] = useState(false);
- const [fontScale, setFontScale] = useState(1);
- const listRef = useRef<HTMLDivElement>(null);
- const inputRef = useRef<HTMLInputElement>(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<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]);
- // 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
- // 도크 접힘(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<HTMLInputElement>) => {
- if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
- e.preventDefault();
- handleSend();
- }
- }, [handleSend]);
- // 비로그인 입력 게이트 — 입력창 focus(클릭·탭 포함) 시 로그인 confirm 후 blur
- const handleInputFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
- if (isAuthenticated) {
- return;
- }
- const el = e.currentTarget;
- loginCheck();
- el.blur();
- }, [isAuthenticated, loginCheck]);
- const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
- 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 (
- <div
- id='chat-room'
- className={popup ? 'chat-room--popup' : undefined}
- style={{ '--chat-fs': fontScale } as CSSProperties}
- >
- {/* 헤더: 참여자 수(클릭→목록) + 연결 상태 + 수신설정 + 설정 dropdown */}
- <header className='chat-room__head'>
- <button
- type='button'
- className='chat-room__count'
- onClick={toggleParticipants}
- aria-expanded={showParticipants}
- aria-controls='chat-participants-panel'
- title='참여자 목록'
- >
- <FontAwesomeIcon icon={faUsers} />
- <span className='chat-room__count-num'>{participantCount.toLocaleString()}</span>
- <span className='chat-room__count-label'>명 참여 중</span>
- <FontAwesomeIcon icon={faChevronDown} className={`chat-room__count-caret${showParticipants ? ' is-open' : ''}`} />
- </button>
- <div className='chat-room__meta'>
- {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>
- )}
- <button type='button' className='chat-room__icon-btn' onClick={openReceiveModal} title='수신 설정' aria-label='수신 설정'>
- <FontAwesomeIcon icon={faBell} />
- </button>
- <ChatSettingsMenu
- onRefresh={handleRefresh}
- onOpenPopup={handleOpenPopup}
- onClear={handleClearLocal}
- onFontLarger={() => changeFontScale(FONT_SCALE_STEP)}
- onFontSmaller={() => changeFontScale(-FONT_SCALE_STEP)}
- showPopup={!popup}
- />
- {headerSlot}
- </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 && (
- // eslint-disable-next-line @next/next/no-img-element
- <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>
- {showParticipants && (
- <div id='chat-participants-panel' className='chat-room__participants'>
- <ChatParticipants
- participants={participants}
- loading={participantsLoading}
- onClose={() => setShowParticipants(false)}
- />
- </div>
- )}
- {showJump && (
- <button type='button' className='chat-room__jump' onClick={jumpToBottom}>
- <FontAwesomeIcon icon={faArrowDown} />
- <span>새 메시지</span>
- </button>
- )}
- </div>
- {/* 입력부 — 로그인 사용자만 전송. 비로그인은 입력창 focus 시 로그인 유도 */}
- <div className='chat-room__footer'>
- {kicked ? (
- <div className='chat-room__blocked' role='status'>
- 관리자에 의해 채팅 이용이 제한되었습니다.
- </div>
- ) : (
- <div className='chat-room__input-row'>
- <input
- ref={inputRef}
- type='text'
- className='chat-room__input'
- placeholder={isAuthenticated ? '메시지를 입력하세요' : '로그인 후 채팅에 참여하세요'}
- value={inputValue}
- onChange={handleInputChange}
- onFocus={handleInputFocus}
- onKeyDown={handleKeyDown}
- maxLength={MAX_CONTENT_LENGTH}
- disabled={isAuthenticated && !canType}
- aria-label='채팅 메시지 입력'
- />
- <EmojiPicker onEmojiSelect={handleEmoji} />
- <button type='button' className='chat-room__send' title='전송' onClick={handleSend} disabled={!canSend}>
- {cooldownLeft > 0 ? (
- <span className='chat-room__cooldown'>{cooldownLeft}</span>
- ) : (
- <FontAwesomeIcon icon={faPaperPlane} />
- )}
- </button>
- </div>
- )}
- </div>
- {showReceiveModal && <ChatReceiveModal onClose={() => setShowReceiveModal(false)} />}
- </div>
- );
- }
|