|
|
@@ -1,13 +1,16 @@
|
|
|
'use client';
|
|
|
|
|
|
-import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react';
|
|
|
-import Link from 'next/link';
|
|
|
+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 } from '@/types/chat';
|
|
|
+import type { ChatMessage, ChatParticipant } from '@/types/chat';
|
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
|
-import { faUsers, faPaperPlane, faBullhorn, faArrowDown } from '@fortawesome/free-solid-svg-icons';
|
|
|
+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 와 동일)
|
|
|
@@ -16,6 +19,12 @@ 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;
|
|
|
@@ -43,11 +52,13 @@ type Props = {
|
|
|
slowModeSeconds?: number;
|
|
|
// 헤더 우측(.chat-room__meta)에 주입할 컨트롤 — 채팅 도크의 접기 버튼 등 (없으면 미렌더)
|
|
|
headerSlot?: ReactNode;
|
|
|
+ // 팝업 창(/chat-popup)에서 렌더 시 true — 전체 창 레이아웃 + "새창으로 보기"/도크 컨트롤 숨김
|
|
|
+ popup?: boolean;
|
|
|
};
|
|
|
|
|
|
-export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot }: Props)
|
|
|
+export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot, popup = false }: Props)
|
|
|
{
|
|
|
- const { isAuthenticated } = useAuth();
|
|
|
+ const { isAuthenticated, loginCheck } = useAuth();
|
|
|
const { chatConnection, chatConnected } = useSignalRContext();
|
|
|
|
|
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
|
|
@@ -59,12 +70,30 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
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() }];
|
|
|
@@ -108,6 +137,10 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
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('관리자에 의해 채팅 이용이 제한되었습니다.');
|
|
|
@@ -117,6 +150,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
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 호출 가드
|
|
|
@@ -154,6 +188,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
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) {
|
|
|
@@ -173,9 +208,10 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
}, [messages, systemMessages]);
|
|
|
|
|
|
// 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
|
|
|
+ // 도크 접힘(display:none) 상태에서는 clientHeight===0 → 스크롤 조작 생략(펼칠 때 ResizeObserver 가 복원)
|
|
|
useEffect(() => {
|
|
|
const el = listRef.current;
|
|
|
- if (!el || merged.length === 0) {
|
|
|
+ if (!el || merged.length === 0 || el.clientHeight === 0) {
|
|
|
return;
|
|
|
}
|
|
|
if (autoScrollRef.current) {
|
|
|
@@ -185,6 +221,21 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
}
|
|
|
}, [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) {
|
|
|
@@ -249,19 +300,115 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
}
|
|
|
}, [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 = canType && inputValue.trim().length > 0 && cooldownLeft === 0;
|
|
|
+ const canSend = isAuthenticated && canType && inputValue.trim().length > 0 && cooldownLeft === 0;
|
|
|
|
|
|
return (
|
|
|
- <div id='chat-room'>
|
|
|
- {/* 헤더: 제목 + 접속자 수 + 연결 상태 */}
|
|
|
+ <div
|
|
|
+ id='chat-room'
|
|
|
+ className={popup ? 'chat-room--popup' : undefined}
|
|
|
+ style={{ '--chat-fs': fontScale } as CSSProperties}
|
|
|
+ >
|
|
|
+ {/* 헤더: 참여자 수(클릭→목록) + 연결 상태 + 수신설정 + 설정 dropdown */}
|
|
|
<header className='chat-room__head'>
|
|
|
- <h1>실시간 채팅</h1>
|
|
|
+ <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'>
|
|
|
- <span className='chat-room__count' aria-live='polite'>
|
|
|
- <FontAwesomeIcon icon={faUsers} />
|
|
|
- <span>{participantCount.toLocaleString()}명 참여 중</span>
|
|
|
- </span>
|
|
|
{connState === 'connecting' && (
|
|
|
<span className='chat-room__state'>연결 중…</span>
|
|
|
)}
|
|
|
@@ -269,8 +416,22 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
<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>
|
|
|
+ <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>
|
|
|
@@ -283,7 +444,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
- {/* 메시지 리스트 */}
|
|
|
+ {/* 메시지 리스트 (+ 참여자 목록 오버레이) */}
|
|
|
<div className='chat-room__body'>
|
|
|
<div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
|
|
|
{merged.length === 0 && (
|
|
|
@@ -305,6 +466,7 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
<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
|
|
|
@@ -319,6 +481,16 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
})}
|
|
|
</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} />
|
|
|
@@ -327,50 +499,40 @@ export default function MainChat({ initialMessages = [], notice = null, slowMode
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* 입력부 — 게스트는 읽기 전용 + 로그인 CTA */}
|
|
|
+ {/* 입력부 — 로그인 사용자만 전송. 비로그인은 입력창 focus 시 로그인 유도 */}
|
|
|
<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 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>
|
|
|
);
|
|
|
-}
|
|
|
+}
|