MainChat.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. 'use client';
  2. import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent } from 'react';
  3. import Link from 'next/link';
  4. import { HubConnectionState } from '@microsoft/signalr';
  5. import useAuth from '@/hooks/useAuth';
  6. import { useSignalRContext } from '@/contexts/signalrProvider';
  7. import type { ChatMessage } from '@/types/chat';
  8. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  9. import { faUsers, faPaperPlane, faBullhorn, faArrowDown } from '@fortawesome/free-solid-svg-icons';
  10. const ROOM_KEY = 'main';
  11. const MAX_MESSAGES = 200; // 최근 200건 유지·오버플로 드랍 (Backend ChatSettings.MaxMessages 와 동일)
  12. const MAX_SYSTEM_MESSAGES = 50;
  13. const MAX_CONTENT_LENGTH = 500;
  14. const GLOBAL_RATE_LIMIT_SECONDS = 2; // Hub 전역 rate limit (ChatSettings.RateLimitSeconds)
  15. const SCROLL_BOTTOM_THRESHOLD = 50;
  16. type SystemMessage = {
  17. id: number;
  18. content: string;
  19. receivedAt: string;
  20. };
  21. type MergedItem =
  22. | { kind: 'chat'; time: string; data: ChatMessage }
  23. | { kind: 'system'; time: string; data: SystemMessage };
  24. type ConnectionState = 'connecting'|'connected'|'reconnecting'|'disconnected';
  25. // 메시지 dedupe 키 — ChatMessage 페이로드에 단일 ID 필드가 없어 (memberSID, sentAt, content) 합성 키 사용
  26. // SSR 초기 히스토리 ↔ JoinRoom ReceiveHistory ↔ 실시간 ReceiveMessage 간 중복 제거
  27. const messageKey = (m: ChatMessage) => `${m.memberSID}|${m.sentAt}|${m.content}`;
  28. const formatTime = (dateStr: string) => {
  29. const date = new Date(dateStr);
  30. return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  31. };
  32. type Props = {
  33. initialMessages: ChatMessage[];
  34. notice: string|null;
  35. slowModeSeconds: number;
  36. };
  37. export default function MainChat({ initialMessages, notice, slowModeSeconds }: Props)
  38. {
  39. const { isAuthenticated } = useAuth();
  40. const { chatConnection, chatConnected } = useSignalRContext();
  41. const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
  42. const [systemMessages, setSystemMessages] = useState<SystemMessage[]>([]);
  43. const [participantCount, setParticipantCount] = useState(0);
  44. const [connState, setConnState] = useState<ConnectionState>('connecting');
  45. const [kicked, setKicked] = useState(false);
  46. const [inputValue, setInputValue] = useState('');
  47. const [cooldownLeft, setCooldownLeft] = useState(0);
  48. const [showJump, setShowJump] = useState(false);
  49. const listRef = useRef<HTMLDivElement>(null);
  50. const autoScrollRef = useRef(true);
  51. const systemIdRef = useRef(0);
  52. const cooldownEndRef = useRef(0);
  53. const activeRef = useRef(true);
  54. const pushSystem = useCallback((content: string) => {
  55. setSystemMessages((prev) => {
  56. const next = [...prev, { id: ++systemIdRef.current, content, receivedAt: new Date().toISOString() }];
  57. return next.length > MAX_SYSTEM_MESSAGES ? next.slice(next.length - MAX_SYSTEM_MESSAGES) : next;
  58. });
  59. }, []);
  60. const appendMessage = useCallback((message: ChatMessage) => {
  61. setMessages((prev) => {
  62. const key = messageKey(message);
  63. if (prev.some((c) => messageKey(c) === key)) {
  64. return prev;
  65. }
  66. const next = [...prev, message];
  67. return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
  68. });
  69. }, []);
  70. // JoinRoom/재연결 시 ReceiveHistory — SSR 초기분과 병합 (dedupe + 시간순 + 상한)
  71. const mergeHistory = useCallback((history: ChatMessage[]) => {
  72. setMessages((prev) => {
  73. const seen = new Set(prev.map(messageKey));
  74. const added = history.filter((c) => !seen.has(messageKey(c)));
  75. if (added.length === 0) {
  76. return prev;
  77. }
  78. const next = [...prev, ...added].sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime());
  79. return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
  80. });
  81. }, []);
  82. // ── 연결 라이프사이클: provider 의 chatConnection 재사용, JoinRoom 은 마운트 시 · LeaveRoom 은 언마운트 시 ──
  83. useEffect(() => {
  84. if (!chatConnection || !chatConnected) {
  85. return;
  86. }
  87. activeRef.current = true;
  88. const handleHistory = (history: ChatMessage[]) => mergeHistory(history);
  89. const handleMessage = (message: ChatMessage) => appendMessage(message);
  90. const handleSystem = (content: string) => pushSystem(content);
  91. const handleCount = (count: number) => setParticipantCount(count);
  92. const handleKick = () => {
  93. setKicked(true);
  94. pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.');
  95. };
  96. chatConnection.on('ReceiveHistory', handleHistory);
  97. chatConnection.on('ReceiveMessage', handleMessage);
  98. chatConnection.on('ReceiveSystemMessage', handleSystem);
  99. chatConnection.on('ReceiveParticipantCount', handleCount);
  100. chatConnection.on('Kick', handleKick);
  101. // 자동 재연결 상태 UX — signalR 은 이 콜백들의 해제 API 가 없어 activeRef 로 stale 호출 가드
  102. chatConnection.onreconnecting(() => {
  103. if (activeRef.current) {
  104. setConnState('reconnecting');
  105. }
  106. });
  107. chatConnection.onreconnected(() => {
  108. if (!activeRef.current) {
  109. return;
  110. }
  111. setConnState('connected');
  112. // 재연결 시 그룹 멤버십 소실 → 재참가 (히스토리 재수신은 mergeHistory 가 dedupe)
  113. chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
  114. console.error('채팅 룸 재참가 실패:', err);
  115. });
  116. });
  117. chatConnection.onclose(() => {
  118. if (activeRef.current) {
  119. setConnState('disconnected');
  120. }
  121. });
  122. setConnState('connected');
  123. chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
  124. console.error('채팅 룸 참가 실패:', err);
  125. pushSystem('채팅방 입장에 실패했습니다. 새로고침 후 다시 시도해 주세요.');
  126. });
  127. return () => {
  128. activeRef.current = false;
  129. chatConnection.off('ReceiveHistory', handleHistory);
  130. chatConnection.off('ReceiveMessage', handleMessage);
  131. chatConnection.off('ReceiveSystemMessage', handleSystem);
  132. chatConnection.off('ReceiveParticipantCount', handleCount);
  133. chatConnection.off('Kick', handleKick);
  134. if (chatConnection.state === HubConnectionState.Connected) {
  135. chatConnection.invoke('LeaveRoom').catch(() => {});
  136. }
  137. };
  138. }, [chatConnection, chatConnected, mergeHistory, appendMessage, pushSystem]);
  139. // 채팅 + 시스템 메시지 시간순 병합
  140. const merged = useMemo<MergedItem[]>(() => {
  141. const items: MergedItem[] = [
  142. ...messages.map((c) => ({ kind: 'chat' as const, time: c.sentAt, data: c })),
  143. ...systemMessages.map((c) => ({ kind: 'system' as const, time: c.receivedAt, data: c }))
  144. ];
  145. items.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
  146. return items;
  147. }, [messages, systemMessages]);
  148. // 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
  149. useEffect(() => {
  150. const el = listRef.current;
  151. if (!el || merged.length === 0) {
  152. return;
  153. }
  154. if (autoScrollRef.current) {
  155. el.scrollTop = el.scrollHeight;
  156. } else {
  157. setShowJump(true);
  158. }
  159. }, [merged.length]);
  160. const handleScroll = useCallback(() => {
  161. const el = listRef.current;
  162. if (!el) {
  163. return;
  164. }
  165. const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_BOTTOM_THRESHOLD;
  166. autoScrollRef.current = atBottom;
  167. if (atBottom) {
  168. setShowJump(false);
  169. }
  170. }, []);
  171. const jumpToBottom = useCallback(() => {
  172. const el = listRef.current;
  173. if (!el) {
  174. return;
  175. }
  176. el.scrollTop = el.scrollHeight;
  177. autoScrollRef.current = true;
  178. setShowJump(false);
  179. }, []);
  180. // 전송 쿨다운 (전역 2초 + 룸 SlowMode 중 큰 값) — 남은 초 표시
  181. const cooling = cooldownLeft > 0;
  182. useEffect(() => {
  183. if (!cooling) {
  184. return;
  185. }
  186. const timer = window.setInterval(() => {
  187. const left = Math.ceil((cooldownEndRef.current - Date.now()) / 1000);
  188. setCooldownLeft(left > 0 ? left : 0);
  189. }, 250);
  190. return () => window.clearInterval(timer);
  191. }, [cooling]);
  192. const handleSend = useCallback(async () => {
  193. const trimmed = inputValue.trim();
  194. if (!trimmed || trimmed.length > MAX_CONTENT_LENGTH || cooldownLeft > 0 || kicked) {
  195. return;
  196. }
  197. if (!chatConnection || chatConnection.state !== HubConnectionState.Connected) {
  198. return;
  199. }
  200. try {
  201. await chatConnection.invoke('SendMessage', trimmed);
  202. setInputValue('');
  203. const seconds = Math.max(GLOBAL_RATE_LIMIT_SECONDS, slowModeSeconds);
  204. cooldownEndRef.current = Date.now() + seconds * 1000;
  205. setCooldownLeft(seconds);
  206. } catch (error) {
  207. console.error('메시지 전송 실패:', error);
  208. pushSystem('메시지 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.');
  209. }
  210. }, [inputValue, cooldownLeft, kicked, chatConnection, slowModeSeconds, pushSystem]);
  211. const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
  212. if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
  213. e.preventDefault();
  214. handleSend();
  215. }
  216. }, [handleSend]);
  217. const canType = connState === 'connected' && !kicked;
  218. const canSend = canType && inputValue.trim().length > 0 && cooldownLeft === 0;
  219. return (
  220. <div id='chat-room'>
  221. {/* 헤더: 제목 + 접속자 수 + 연결 상태 */}
  222. <header className='chat-room__head'>
  223. <h1>실시간 채팅</h1>
  224. <div className='chat-room__meta'>
  225. <span className='chat-room__count' aria-live='polite'>
  226. <FontAwesomeIcon icon={faUsers} />
  227. <span>{participantCount.toLocaleString()}명 참여 중</span>
  228. </span>
  229. {connState === 'connecting' && (
  230. <span className='chat-room__state'>연결 중…</span>
  231. )}
  232. {connState === 'reconnecting' && (
  233. <span className='chat-room__state chat-room__state--warn' role='status'>재연결 중…</span>
  234. )}
  235. {connState === 'disconnected' && (
  236. <span className='chat-room__state chat-room__state--error' role='status'>연결 끊김 — 새로고침 후 다시 시도해 주세요</span>
  237. )}
  238. </div>
  239. </header>
  240. {/* 상단 공지 */}
  241. {notice && (
  242. <div className='chat-room__notice' role='note'>
  243. <FontAwesomeIcon icon={faBullhorn} />
  244. <span>{notice}</span>
  245. </div>
  246. )}
  247. {/* 메시지 리스트 */}
  248. <div className='chat-room__body'>
  249. <div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
  250. {merged.length === 0 && (
  251. <div className='chat-room__empty'>아직 메시지가 없습니다. 첫 메시지를 남겨보세요.</div>
  252. )}
  253. {merged.map((item) => {
  254. if (item.kind === 'system') {
  255. return (
  256. <div key={`sys-${item.data.id}`} className='chat-room__system'>
  257. {item.data.content}
  258. </div>
  259. );
  260. }
  261. const msg = item.data;
  262. const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon;
  263. const badgeAlt = msg.titleName ?? '';
  264. return (
  265. <div key={messageKey(msg)} className='chat-room__message'>
  266. <time className='chat-room__time' dateTime={msg.sentAt}>{formatTime(msg.sentAt)}</time>
  267. {badgeUrl && (
  268. <img src={badgeUrl} alt={badgeAlt} className='chat-room__badge' aria-hidden={!badgeAlt} />
  269. )}
  270. <span
  271. className='chat-room__name'
  272. style={msg.titleColor ? ({ '--title-color': msg.titleColor } as CSSProperties) : undefined}
  273. >
  274. {msg.memberName || msg.memberSID}
  275. </span>
  276. <span className='chat-room__content'>{msg.content}</span>
  277. </div>
  278. );
  279. })}
  280. </div>
  281. {showJump && (
  282. <button type='button' className='chat-room__jump' onClick={jumpToBottom}>
  283. <FontAwesomeIcon icon={faArrowDown} />
  284. <span>새 메시지</span>
  285. </button>
  286. )}
  287. </div>
  288. {/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
  289. <div className='chat-room__footer'>
  290. {kicked ? (
  291. <div className='chat-room__blocked' role='status'>
  292. 관리자에 의해 채팅 이용이 제한되었습니다.
  293. </div>
  294. ) : isAuthenticated ? (
  295. <>
  296. <div className='chat-room__input-row'>
  297. <input
  298. type='text'
  299. placeholder='메시지를 입력하세요'
  300. value={inputValue}
  301. onChange={(e) => setInputValue(e.target.value)}
  302. onKeyDown={handleKeyDown}
  303. maxLength={MAX_CONTENT_LENGTH}
  304. disabled={!canType}
  305. aria-label='채팅 메시지 입력'
  306. />
  307. <button type='button' title='전송' onClick={handleSend} disabled={!canSend}>
  308. {cooldownLeft > 0 ? (
  309. <span className='chat-room__cooldown'>{cooldownLeft}</span>
  310. ) : (
  311. <FontAwesomeIcon icon={faPaperPlane} />
  312. )}
  313. </button>
  314. </div>
  315. <div className='chat-room__input-meta'>
  316. {slowModeSeconds > GLOBAL_RATE_LIMIT_SECONDS && (
  317. <span className='chat-room__slowmode'>슬로우 모드 {slowModeSeconds}초</span>
  318. )}
  319. {cooldownLeft > 0 && (
  320. <span className='chat-room__cooldown-left' aria-live='polite'>{cooldownLeft}초 후 전송 가능</span>
  321. )}
  322. <span className='chat-room__counter'>{inputValue.length}/{MAX_CONTENT_LENGTH}</span>
  323. </div>
  324. </>
  325. ) : (
  326. <div className='chat-room__guest'>
  327. <span>읽기는 자유롭게, 참여는 로그인 후 가능합니다.</span>
  328. <Link href='/login' className='chat-room__login-cta'>로그인 후 참여</Link>
  329. </div>
  330. )}
  331. </div>
  332. </div>
  333. );
  334. }