MainChat.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. 'use client';
  2. import { useState, useRef, useEffect, useCallback, useMemo, type CSSProperties, type KeyboardEvent, type FocusEvent, type ChangeEvent, type ReactNode } from 'react';
  3. import { HubConnectionState } from '@microsoft/signalr';
  4. import useAuth from '@/hooks/useAuth';
  5. import { useSignalRContext } from '@/contexts/signalrProvider';
  6. import type { ChatMessage, ChatParticipant } from '@/types/chat';
  7. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  8. import { faUsers, faPaperPlane, faBullhorn, faArrowDown, faChevronDown, faBell } from '@fortawesome/free-solid-svg-icons';
  9. import EmojiPicker from '@/app/component/EmojiPicker';
  10. import ChatParticipants from './_component/ChatParticipants';
  11. import ChatSettingsMenu from './_component/ChatSettingsMenu';
  12. import ChatReceiveModal from './_component/ChatReceiveModal';
  13. const ROOM_KEY = 'main';
  14. const MAX_MESSAGES = 200; // 최근 200건 유지·오버플로 드랍 (Backend ChatSettings.MaxMessages 와 동일)
  15. const MAX_SYSTEM_MESSAGES = 50;
  16. const MAX_CONTENT_LENGTH = 500;
  17. const GLOBAL_RATE_LIMIT_SECONDS = 2; // Hub 전역 rate limit (ChatSettings.RateLimitSeconds)
  18. const SCROLL_BOTTOM_THRESHOLD = 50;
  19. // 글씨 크게/작게 — #chat-room 의 --chat-fs 배율(localStorage 영속)
  20. const FONT_SCALE_MIN = 0.85;
  21. const FONT_SCALE_MAX = 1.6;
  22. const FONT_SCALE_STEP = 0.1;
  23. const FONT_SCALE_KEY = 'az-chat-fs';
  24. type SystemMessage = {
  25. id: number;
  26. content: string;
  27. receivedAt: string;
  28. };
  29. type MergedItem =
  30. | { kind: 'chat'; time: string; data: ChatMessage }
  31. | { kind: 'system'; time: string; data: SystemMessage };
  32. type ConnectionState = 'connecting'|'connected'|'reconnecting'|'disconnected';
  33. // 메시지 dedupe 키 — ChatMessage 페이로드에 단일 ID 필드가 없어 (memberSID, sentAt, content) 합성 키 사용
  34. // SSR 초기 히스토리 ↔ JoinRoom ReceiveHistory ↔ 실시간 ReceiveMessage 간 중복 제거
  35. const messageKey = (m: ChatMessage) => `${m.memberSID}|${m.sentAt}|${m.content}`;
  36. const formatTime = (dateStr: string) => {
  37. const date = new Date(dateStr);
  38. return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
  39. };
  40. type Props = {
  41. initialMessages?: ChatMessage[];
  42. notice?: string|null;
  43. slowModeSeconds?: number;
  44. // 헤더 우측(.chat-room__meta)에 주입할 컨트롤 — 채팅 도크의 접기 버튼 등 (없으면 미렌더)
  45. headerSlot?: ReactNode;
  46. // 팝업 창(/chat-popup)에서 렌더 시 true — 전체 창 레이아웃 + "새창으로 보기"/도크 컨트롤 숨김
  47. popup?: boolean;
  48. };
  49. export default function MainChat({ initialMessages = [], notice = null, slowModeSeconds = 0, headerSlot, popup = false }: Props)
  50. {
  51. const { isAuthenticated, loginCheck } = useAuth();
  52. const { chatConnection, chatConnected } = useSignalRContext();
  53. const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
  54. const [systemMessages, setSystemMessages] = useState<SystemMessage[]>([]);
  55. const [participantCount, setParticipantCount] = useState(0);
  56. const [connState, setConnState] = useState<ConnectionState>('connecting');
  57. const [kicked, setKicked] = useState(false);
  58. const [inputValue, setInputValue] = useState('');
  59. const [cooldownLeft, setCooldownLeft] = useState(0);
  60. const [showJump, setShowJump] = useState(false);
  61. // 참여자 목록 / 설정 UI
  62. const [participants, setParticipants] = useState<ChatParticipant[]>([]);
  63. const [participantsLoading, setParticipantsLoading] = useState(false);
  64. const [showParticipants, setShowParticipants] = useState(false);
  65. const [showReceiveModal, setShowReceiveModal] = useState(false);
  66. const [fontScale, setFontScale] = useState(1);
  67. const listRef = useRef<HTMLDivElement>(null);
  68. const inputRef = useRef<HTMLInputElement>(null);
  69. const autoScrollRef = useRef(true);
  70. const systemIdRef = useRef(0);
  71. const cooldownEndRef = useRef(0);
  72. const activeRef = useRef(true);
  73. // 글씨 배율 localStorage 복원
  74. useEffect(() => {
  75. try {
  76. const saved = parseFloat(window.localStorage.getItem(FONT_SCALE_KEY) ?? '');
  77. if (!Number.isNaN(saved) && saved >= FONT_SCALE_MIN && saved <= FONT_SCALE_MAX) {
  78. setFontScale(saved);
  79. }
  80. } catch {}
  81. }, []);
  82. const pushSystem = useCallback((content: string) => {
  83. setSystemMessages((prev) => {
  84. const next = [...prev, { id: ++systemIdRef.current, content, receivedAt: new Date().toISOString() }];
  85. return next.length > MAX_SYSTEM_MESSAGES ? next.slice(next.length - MAX_SYSTEM_MESSAGES) : next;
  86. });
  87. }, []);
  88. const appendMessage = useCallback((message: ChatMessage) => {
  89. setMessages((prev) => {
  90. const key = messageKey(message);
  91. if (prev.some((c) => messageKey(c) === key)) {
  92. return prev;
  93. }
  94. const next = [...prev, message];
  95. return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
  96. });
  97. }, []);
  98. // JoinRoom/재연결 시 ReceiveHistory — SSR 초기분과 병합 (dedupe + 시간순 + 상한)
  99. const mergeHistory = useCallback((history: ChatMessage[]) => {
  100. setMessages((prev) => {
  101. const seen = new Set(prev.map(messageKey));
  102. const added = history.filter((c) => !seen.has(messageKey(c)));
  103. if (added.length === 0) {
  104. return prev;
  105. }
  106. const next = [...prev, ...added].sort((a, b) => new Date(a.sentAt).getTime() - new Date(b.sentAt).getTime());
  107. return next.length > MAX_MESSAGES ? next.slice(next.length - MAX_MESSAGES) : next;
  108. });
  109. }, []);
  110. // ── 연결 라이프사이클: provider 의 chatConnection 재사용, JoinRoom 은 마운트 시 · LeaveRoom 은 언마운트 시 ──
  111. useEffect(() => {
  112. if (!chatConnection || !chatConnected) {
  113. return;
  114. }
  115. activeRef.current = true;
  116. const handleHistory = (history: ChatMessage[]) => mergeHistory(history);
  117. const handleMessage = (message: ChatMessage) => appendMessage(message);
  118. const handleSystem = (content: string) => pushSystem(content);
  119. const handleCount = (count: number) => setParticipantCount(count);
  120. const handleParticipants = (list: ChatParticipant[]) => {
  121. setParticipants(list);
  122. setParticipantsLoading(false);
  123. };
  124. const handleKick = () => {
  125. setKicked(true);
  126. pushSystem('관리자에 의해 채팅 이용이 제한되었습니다.');
  127. };
  128. chatConnection.on('ReceiveHistory', handleHistory);
  129. chatConnection.on('ReceiveMessage', handleMessage);
  130. chatConnection.on('ReceiveSystemMessage', handleSystem);
  131. chatConnection.on('ReceiveParticipantCount', handleCount);
  132. chatConnection.on('ReceiveParticipants', handleParticipants);
  133. chatConnection.on('Kick', handleKick);
  134. // 자동 재연결 상태 UX — signalR 은 이 콜백들의 해제 API 가 없어 activeRef 로 stale 호출 가드
  135. chatConnection.onreconnecting(() => {
  136. if (activeRef.current) {
  137. setConnState('reconnecting');
  138. }
  139. });
  140. chatConnection.onreconnected(() => {
  141. if (!activeRef.current) {
  142. return;
  143. }
  144. setConnState('connected');
  145. // 재연결 시 그룹 멤버십 소실 → 재참가 (히스토리 재수신은 mergeHistory 가 dedupe)
  146. chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
  147. console.error('채팅 룸 재참가 실패:', err);
  148. });
  149. });
  150. chatConnection.onclose(() => {
  151. if (activeRef.current) {
  152. setConnState('disconnected');
  153. }
  154. });
  155. setConnState('connected');
  156. chatConnection.invoke('JoinRoom', ROOM_KEY).catch((err) => {
  157. console.error('채팅 룸 참가 실패:', err);
  158. pushSystem('채팅방 입장에 실패했습니다. 새로고침 후 다시 시도해 주세요.');
  159. });
  160. return () => {
  161. activeRef.current = false;
  162. chatConnection.off('ReceiveHistory', handleHistory);
  163. chatConnection.off('ReceiveMessage', handleMessage);
  164. chatConnection.off('ReceiveSystemMessage', handleSystem);
  165. chatConnection.off('ReceiveParticipantCount', handleCount);
  166. chatConnection.off('ReceiveParticipants', handleParticipants);
  167. chatConnection.off('Kick', handleKick);
  168. if (chatConnection.state === HubConnectionState.Connected) {
  169. chatConnection.invoke('LeaveRoom').catch(() => {});
  170. }
  171. };
  172. }, [chatConnection, chatConnected, mergeHistory, appendMessage, pushSystem]);
  173. // 채팅 + 시스템 메시지 시간순 병합
  174. const merged = useMemo<MergedItem[]>(() => {
  175. const items: MergedItem[] = [
  176. ...messages.map((c) => ({ kind: 'chat' as const, time: c.sentAt, data: c })),
  177. ...systemMessages.map((c) => ({ kind: 'system' as const, time: c.receivedAt, data: c }))
  178. ];
  179. items.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());
  180. return items;
  181. }, [messages, systemMessages]);
  182. // 자동 스크롤 (하단 고정) — 위로 스크롤 중이면 "새 메시지 ↓" 점프 버튼 노출
  183. // 도크 접힘(display:none) 상태에서는 clientHeight===0 → 스크롤 조작 생략(펼칠 때 ResizeObserver 가 복원)
  184. useEffect(() => {
  185. const el = listRef.current;
  186. if (!el || merged.length === 0 || el.clientHeight === 0) {
  187. return;
  188. }
  189. if (autoScrollRef.current) {
  190. el.scrollTop = el.scrollHeight;
  191. } else {
  192. setShowJump(true);
  193. }
  194. }, [merged.length]);
  195. // 접힘→펼침(또는 리사이즈)으로 메시지 영역이 다시 표시되면 하단 고정 상태였을 때 하단으로 재정렬
  196. useEffect(() => {
  197. const el = listRef.current;
  198. if (!el || typeof ResizeObserver === 'undefined') {
  199. return;
  200. }
  201. const observer = new ResizeObserver(() => {
  202. if (el.clientHeight > 0 && autoScrollRef.current) {
  203. el.scrollTop = el.scrollHeight;
  204. }
  205. });
  206. observer.observe(el);
  207. return () => observer.disconnect();
  208. }, []);
  209. const handleScroll = useCallback(() => {
  210. const el = listRef.current;
  211. if (!el) {
  212. return;
  213. }
  214. const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_BOTTOM_THRESHOLD;
  215. autoScrollRef.current = atBottom;
  216. if (atBottom) {
  217. setShowJump(false);
  218. }
  219. }, []);
  220. const jumpToBottom = useCallback(() => {
  221. const el = listRef.current;
  222. if (!el) {
  223. return;
  224. }
  225. el.scrollTop = el.scrollHeight;
  226. autoScrollRef.current = true;
  227. setShowJump(false);
  228. }, []);
  229. // 전송 쿨다운 (전역 2초 + 룸 SlowMode 중 큰 값) — 남은 초 표시
  230. const cooling = cooldownLeft > 0;
  231. useEffect(() => {
  232. if (!cooling) {
  233. return;
  234. }
  235. const timer = window.setInterval(() => {
  236. const left = Math.ceil((cooldownEndRef.current - Date.now()) / 1000);
  237. setCooldownLeft(left > 0 ? left : 0);
  238. }, 250);
  239. return () => window.clearInterval(timer);
  240. }, [cooling]);
  241. const handleSend = useCallback(async () => {
  242. const trimmed = inputValue.trim();
  243. if (!trimmed || trimmed.length > MAX_CONTENT_LENGTH || cooldownLeft > 0 || kicked) {
  244. return;
  245. }
  246. if (!chatConnection || chatConnection.state !== HubConnectionState.Connected) {
  247. return;
  248. }
  249. try {
  250. await chatConnection.invoke('SendMessage', trimmed);
  251. setInputValue('');
  252. const seconds = Math.max(GLOBAL_RATE_LIMIT_SECONDS, slowModeSeconds);
  253. cooldownEndRef.current = Date.now() + seconds * 1000;
  254. setCooldownLeft(seconds);
  255. } catch (error) {
  256. console.error('메시지 전송 실패:', error);
  257. pushSystem('메시지 전송에 실패했습니다. 잠시 후 다시 시도해 주세요.');
  258. }
  259. }, [inputValue, cooldownLeft, kicked, chatConnection, slowModeSeconds, pushSystem]);
  260. const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
  261. if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
  262. e.preventDefault();
  263. handleSend();
  264. }
  265. }, [handleSend]);
  266. // 비로그인 입력 게이트 — 입력창 focus(클릭·탭 포함) 시 로그인 confirm 후 blur
  267. const handleInputFocus = useCallback((e: FocusEvent<HTMLInputElement>) => {
  268. if (isAuthenticated) {
  269. return;
  270. }
  271. const el = e.currentTarget;
  272. loginCheck();
  273. el.blur();
  274. }, [isAuthenticated, loginCheck]);
  275. const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
  276. if (!isAuthenticated) {
  277. return;
  278. }
  279. setInputValue(e.target.value);
  280. }, [isAuthenticated]);
  281. // 이모지 선택 — 비로그인은 로그인 유도
  282. const handleEmoji = useCallback((emoji: string) => {
  283. if (!isAuthenticated) {
  284. loginCheck();
  285. return;
  286. }
  287. setInputValue((prev) => {
  288. const next = prev + emoji;
  289. // 길이 초과 시 이모지 전체 거부 — 코드유닛 slice 로 surrogate/ZWJ 시퀀스가 잘리는 것 방지
  290. return next.length <= MAX_CONTENT_LENGTH ? next : prev;
  291. });
  292. }, [isAuthenticated, loginCheck]);
  293. // 참여자 목록 토글 — 열 때 서버에 목록 요청
  294. const toggleParticipants = useCallback(() => {
  295. setShowParticipants((prev) => {
  296. const next = !prev;
  297. if (next && chatConnection && chatConnection.state === HubConnectionState.Connected) {
  298. setParticipantsLoading(true);
  299. chatConnection.invoke('RequestParticipants').catch(() => setParticipantsLoading(false));
  300. }
  301. return next;
  302. });
  303. }, [chatConnection]);
  304. // 새로고침 — 메시지 비우고 히스토리·인원 재요청
  305. const handleRefresh = useCallback(() => {
  306. setMessages([]);
  307. setSystemMessages([]);
  308. if (chatConnection && chatConnection.state === HubConnectionState.Connected) {
  309. chatConnection.invoke('RequestHistory').catch(() => {});
  310. chatConnection.invoke('RequestParticipantCount').catch(() => {});
  311. }
  312. }, [chatConnection]);
  313. // 지우개 — 내 화면만 비우기(서버 삭제 아님)
  314. const handleClearLocal = useCallback(() => {
  315. setMessages([]);
  316. setSystemMessages([]);
  317. }, []);
  318. // 글씨 크게/작게
  319. const changeFontScale = useCallback((delta: number) => {
  320. setFontScale((prev) => {
  321. const next = Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, Math.round((prev + delta) * 100) / 100));
  322. try {
  323. window.localStorage.setItem(FONT_SCALE_KEY, String(next));
  324. } catch {}
  325. return next;
  326. });
  327. }, []);
  328. // 새창으로 보기 — 별도 팝업 라우트(별개 SignalR 연결)
  329. const handleOpenPopup = useCallback(() => {
  330. window.open('/chat-popup', 'antoozaChat', 'width=400,height=760,resizable=yes,scrollbars=yes');
  331. }, []);
  332. // 수신설정 modal — 로그인 필수
  333. const openReceiveModal = useCallback(() => {
  334. if (!isAuthenticated) {
  335. loginCheck();
  336. return;
  337. }
  338. setShowReceiveModal(true);
  339. }, [isAuthenticated, loginCheck]);
  340. const canType = connState === 'connected' && !kicked;
  341. const canSend = isAuthenticated && canType && inputValue.trim().length > 0 && cooldownLeft === 0;
  342. return (
  343. <div
  344. id='chat-room'
  345. className={popup ? 'chat-room--popup' : undefined}
  346. style={{ '--chat-fs': fontScale } as CSSProperties}
  347. >
  348. {/* 헤더: 참여자 수(클릭→목록) + 연결 상태 + 수신설정 + 설정 dropdown */}
  349. <header className='chat-room__head'>
  350. <button
  351. type='button'
  352. className='chat-room__count'
  353. onClick={toggleParticipants}
  354. aria-expanded={showParticipants}
  355. aria-controls='chat-participants-panel'
  356. title='참여자 목록'
  357. >
  358. <FontAwesomeIcon icon={faUsers} />
  359. <span className='chat-room__count-num'>{participantCount.toLocaleString()}</span>
  360. <span className='chat-room__count-label'>명 참여 중</span>
  361. <FontAwesomeIcon icon={faChevronDown} className={`chat-room__count-caret${showParticipants ? ' is-open' : ''}`} />
  362. </button>
  363. <div className='chat-room__meta'>
  364. {connState === 'connecting' && (
  365. <span className='chat-room__state'>연결 중…</span>
  366. )}
  367. {connState === 'reconnecting' && (
  368. <span className='chat-room__state chat-room__state--warn' role='status'>재연결 중…</span>
  369. )}
  370. {connState === 'disconnected' && (
  371. <span className='chat-room__state chat-room__state--error' role='status'>연결 끊김</span>
  372. )}
  373. <button type='button' className='chat-room__icon-btn' onClick={openReceiveModal} title='수신 설정' aria-label='수신 설정'>
  374. <FontAwesomeIcon icon={faBell} />
  375. </button>
  376. <ChatSettingsMenu
  377. onRefresh={handleRefresh}
  378. onOpenPopup={handleOpenPopup}
  379. onClear={handleClearLocal}
  380. onFontLarger={() => changeFontScale(FONT_SCALE_STEP)}
  381. onFontSmaller={() => changeFontScale(-FONT_SCALE_STEP)}
  382. showPopup={!popup}
  383. />
  384. {headerSlot}
  385. </div>
  386. </header>
  387. {/* 상단 공지 */}
  388. {notice && (
  389. <div className='chat-room__notice' role='note'>
  390. <FontAwesomeIcon icon={faBullhorn} />
  391. <span>{notice}</span>
  392. </div>
  393. )}
  394. {/* 메시지 리스트 (+ 참여자 목록 오버레이) */}
  395. <div className='chat-room__body'>
  396. <div className='chat-room__messages' ref={listRef} onScroll={handleScroll} aria-live='off'>
  397. {merged.length === 0 && (
  398. <div className='chat-room__empty'>아직 메시지가 없습니다. 첫 메시지를 남겨보세요.</div>
  399. )}
  400. {merged.map((item) => {
  401. if (item.kind === 'system') {
  402. return (
  403. <div key={`sys-${item.data.id}`} className='chat-room__system'>
  404. {item.data.content}
  405. </div>
  406. );
  407. }
  408. const msg = item.data;
  409. const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon;
  410. const badgeAlt = msg.titleName ?? '';
  411. return (
  412. <div key={messageKey(msg)} className='chat-room__message'>
  413. <time className='chat-room__time' dateTime={msg.sentAt}>{formatTime(msg.sentAt)}</time>
  414. {badgeUrl && (
  415. // eslint-disable-next-line @next/next/no-img-element
  416. <img src={badgeUrl} alt={badgeAlt} className='chat-room__badge' aria-hidden={!badgeAlt} />
  417. )}
  418. <span
  419. className='chat-room__name'
  420. style={msg.titleColor ? ({ '--title-color': msg.titleColor } as CSSProperties) : undefined}
  421. >
  422. {msg.memberName || msg.memberSID}
  423. </span>
  424. <span className='chat-room__content'>{msg.content}</span>
  425. </div>
  426. );
  427. })}
  428. </div>
  429. {showParticipants && (
  430. <div id='chat-participants-panel' className='chat-room__participants'>
  431. <ChatParticipants
  432. participants={participants}
  433. loading={participantsLoading}
  434. onClose={() => setShowParticipants(false)}
  435. />
  436. </div>
  437. )}
  438. {showJump && (
  439. <button type='button' className='chat-room__jump' onClick={jumpToBottom}>
  440. <FontAwesomeIcon icon={faArrowDown} />
  441. <span>새 메시지</span>
  442. </button>
  443. )}
  444. </div>
  445. {/* 입력부 — 로그인 사용자만 전송. 비로그인은 입력창 focus 시 로그인 유도 */}
  446. <div className='chat-room__footer'>
  447. {kicked ? (
  448. <div className='chat-room__blocked' role='status'>
  449. 관리자에 의해 채팅 이용이 제한되었습니다.
  450. </div>
  451. ) : (
  452. <div className='chat-room__input-row'>
  453. <input
  454. ref={inputRef}
  455. type='text'
  456. className='chat-room__input'
  457. placeholder={isAuthenticated ? '메시지를 입력하세요' : '로그인 후 채팅에 참여하세요'}
  458. value={inputValue}
  459. onChange={handleInputChange}
  460. onFocus={handleInputFocus}
  461. onKeyDown={handleKeyDown}
  462. maxLength={MAX_CONTENT_LENGTH}
  463. disabled={isAuthenticated && !canType}
  464. aria-label='채팅 메시지 입력'
  465. />
  466. <EmojiPicker onEmojiSelect={handleEmoji} />
  467. <button type='button' className='chat-room__send' title='전송' onClick={handleSend} disabled={!canSend}>
  468. {cooldownLeft > 0 ? (
  469. <span className='chat-room__cooldown'>{cooldownLeft}</span>
  470. ) : (
  471. <FontAwesomeIcon icon={faPaperPlane} />
  472. )}
  473. </button>
  474. </div>
  475. )}
  476. </div>
  477. {showReceiveModal && <ChatReceiveModal onClose={() => setShowReceiveModal(false)} />}
  478. </div>
  479. );
  480. }