From 20a357032d33aa769b7ed69678dee4c2f974046a Mon Sep 17 00:00:00 2001 From: Jae-HyeokKim Date: Fri, 22 Aug 2025 17:17:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(chatroom):=20ChatRoom=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=B0=B0=ED=8F=AC=EC=84=A4=EC=A0=95=20=EB=B3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Chatroom.jsx 코드 수정 --- src/pages/Chatroom.jsx | 1698 +++++++++++++++++++++------------------- 1 file changed, 881 insertions(+), 817 deletions(-) diff --git a/src/pages/Chatroom.jsx b/src/pages/Chatroom.jsx index ec15d46..36a1e24 100644 --- a/src/pages/Chatroom.jsx +++ b/src/pages/Chatroom.jsx @@ -1,902 +1,966 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, {useState, useEffect, useRef, useCallback} from 'react'; import './Chatroom.css'; -import { useNavigate, useParams } from 'react-router-dom'; -import { useInView } from 'react-intersection-observer'; +import {useNavigate, useParams} from 'react-router-dom'; +import {useInView} from 'react-intersection-observer'; import SockJS from 'sockjs-client'; -import { Client } from '@stomp/stompjs'; +import {Client} from '@stomp/stompjs'; import debounce from 'lodash.debounce'; // import axiosInstance from '../api/axiosConfig'; // 프로젝트 구조에 맞게 경로 수정 필요 import axios from 'axios'; // axiosInstance 직접 생성 (동적 토큰 처리) const axiosInstance = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080' + baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080' }); // 요청 인터셉터 추가 axiosInstance.interceptors.request.use( (config) => { - const token = localStorage.getItem('accessToken'); - if (token) { - config.headers['Authorization'] = `Bearer ${token}`; - } - return config; + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers['Authorization'] = `Bearer ${token}`; + } + return config; }, (error) => { - return Promise.reject(error); + return Promise.reject(error); } ); -export default function Chatroom() { - const navigate = useNavigate(); - const { roomId } = useParams(); // URL에서 roomId 가져오기 - const chatRef = useRef(null); - const stompClientRef = useRef(null); - - // roomId가 없으면 테스트용 ID 사용 - const actualRoomId = roomId || 'test-' + Date.now(); - - // 기본 상태 - const [selectedParticipantId, setSelectedParticipantId] = useState(null); - const [isComposing, setIsComposing] = useState(false); - const [chatList, setChatList] = useState([]); - const [message, setMessage] = useState(''); - const [isConnected, setIsConnected] = useState(false); - - - // 무한스크롤 관련 상태 - const [hasMore, setHasMore] = useState(true); - const [loading, setLoading] = useState(false); - const [nextCursor, setNextCursor] = useState(null); - const [isInitialLoad, setIsInitialLoad] = useState(true); - - // 스코어보드 및 로스터 데이터 - const [scoreboard, setScoreboard] = useState([]); - const [currentRoster, setCurrentRoster] = useState(null); - - // 사용자 정보 - const [currentUser, setCurrentUser] = useState(null); - - // 읽음 표시 관련 상태 - const [lastReadMessageId, setLastReadMessageId] = useState(null); - const [unreadCount, setUnreadCount] = useState(0); - - // 탭 관련 상태 - const [activeTab, setActiveTab] = useState('participants'); // 'participants' | 'formation' - - // 최근 골/어시스트 알림 상태 (최대 3개 저장) - const [recentAlerts, setRecentAlerts] = useState([]); - - // 검색 관련 상태 - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [showSearchResults, setShowSearchResults] = useState(false); - - // 무한스크롤 감지를 위한 IntersectionObserver - const { ref: loadMoreRef, inView } = useInView({ - threshold: 0, - rootMargin: '100px 0px', - }); - - // JWT 토큰 가져오기 - const getAuthToken = () => { - return localStorage.getItem('accessToken') || sessionStorage.getItem('accessToken'); - }; +const RAW_WS_BASE = + (typeof import.meta !== 'undefined' && + import.meta.env && + import.meta.env.VITE_API_WS_URL) || + (typeof window !== 'undefined' && window.REACT_APP_WS_BASE_URL) || + 'ws://localhost:8080'; - // 현재 사용자 정보 가져오기 - const fetchCurrentUser = async () => { - try { - // /api/users/me가 404면 다른 엔드포인트 시도 - const response = await axiosInstance.get('/api/user/me'); - setCurrentUser(response.data); - } catch (error) { - console.error('사용자 정보 로드 실패:', error); - // 임시 사용자 정보 설정 - setCurrentUser({ - id: localStorage.getItem('userId') || 'test-user', - email: localStorage.getItem('userEmail') || 'test@gmail.com' - }); - } - }; +// 끝 슬래시 제거 + ws/wss → http/https 변환 +const SOCKJS_ORIGIN = RAW_WS_BASE +.replace(/\/$/, '') +.replace(/^wss:\/\//, 'https://') +.replace(/^ws:\/\//, 'http://'); - // 스코어보드 정보 가져오기 - const fetchScoreboard = async () => { - try { - const response = await axiosInstance.get(`/api/chat-rooms/${roomId}/scoreboard`); - setScoreboard(response.data); +export default function Chatroom() { + const navigate = useNavigate(); + const {roomId} = useParams(); // URL에서 roomId 가져오기 + const chatRef = useRef(null); + const stompClientRef = useRef(null); + + // roomId가 없으면 테스트용 ID 사용 + const actualRoomId = roomId || 'test-' + Date.now(); + + // 기본 상태 + const [selectedParticipantId, setSelectedParticipantId] = useState(null); + const [isComposing, setIsComposing] = useState(false); + const [chatList, setChatList] = useState([]); + const [message, setMessage] = useState(''); + const [isConnected, setIsConnected] = useState(false); + + // 무한스크롤 관련 상태 + const [hasMore, setHasMore] = useState(true); + const [loading, setLoading] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [isInitialLoad, setIsInitialLoad] = useState(true); + + // 스코어보드 및 로스터 데이터 + const [scoreboard, setScoreboard] = useState([]); + const [currentRoster, setCurrentRoster] = useState(null); + + // 사용자 정보 + const [currentUser, setCurrentUser] = useState(null); + + // 읽음 표시 관련 상태 + const [lastReadMessageId, setLastReadMessageId] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + + // 탭 관련 상태 + const [activeTab, setActiveTab] = useState('participants'); // 'participants' | 'formation' + + // 최근 골/어시스트 알림 상태 (최대 3개 저장) + const [recentAlerts, setRecentAlerts] = useState([]); + + // 검색 관련 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showSearchResults, setShowSearchResults] = useState(false); + + // 무한스크롤 감지를 위한 IntersectionObserver + const {ref: loadMoreRef, inView} = useInView({ + threshold: 0, + rootMargin: '100px 0px', + }); + + // JWT 토큰 가져오기 + const getAuthToken = () => { + return localStorage.getItem('accessToken') || sessionStorage.getItem( + 'accessToken'); + }; + + // 현재 사용자 정보 가져오기 + const fetchCurrentUser = async () => { + try { + // /api/users/me가 404면 다른 엔드포인트 시도 + const response = await axiosInstance.get('/api/user/me'); + setCurrentUser(response.data); + } catch (error) { + console.error('사용자 정보 로드 실패:', error); + // 임시 사용자 정보 설정 + setCurrentUser({ + id: localStorage.getItem('userId') || 'test-user', + email: localStorage.getItem('userEmail') || 'test@gmail.com' + }); + } + }; + + // 스코어보드 정보 가져오기 + const fetchScoreboard = async () => { + try { + const response = await axiosInstance.get( + `/api/chat-rooms/${roomId}/scoreboard`); + setScoreboard(response.data); + + // 첫 번째 참가자를 기본 선택 + if (response.data.length > 0 && !selectedParticipantId) { + setSelectedParticipantId(response.data[0].participantId); + } + } catch (error) { + console.error('스코어보드 로드 실패:', error); + } + }; - // 첫 번째 참가자를 기본 선택 - if (response.data.length > 0 && !selectedParticipantId) { - setSelectedParticipantId(response.data[0].participantId); - } - } catch (error) { - console.error('스코어보드 로드 실패:', error); - } - }; + // 선택된 참가자의 로스터 정보 가져오기 + const fetchRoster = async (participantId) => { + if (!participantId) { + return; + } - // 선택된 참가자의 로스터 정보 가져오기 - const fetchRoster = async (participantId) => { - if (!participantId) return; - - try { - const response = await axiosInstance.get( - `/api/chat-rooms/${roomId}/participants/${participantId}/roster` - ); - setCurrentRoster(response.data); - } catch (error) { - console.error('로스터 로드 실패:', error); - } - }; + try { + const response = await axiosInstance.get( + `/api/chat-rooms/${roomId}/participants/${participantId}/roster` + ); + setCurrentRoster(response.data); + } catch (error) { + console.error('로스터 로드 실패:', error); + } + }; + + // 읽음 상태 조회 + const fetchReadState = async () => { + try { + const response = await axiosInstance.get( + `/api/chat-rooms/${actualRoomId}/read-state`); + setLastReadMessageId(response.data.lastReadMessageId); + setUnreadCount(response.data.unreadCount); + } catch (error) { + console.error('읽음 상태 조회 실패:', error); + } + }; + + // 읽음 표시 업데이트 + const markReadUpTo = async (messageId) => { + try { + const response = await axiosInstance.post( + `/api/chat-rooms/${actualRoomId}/read-state`, { + messageId: messageId + }); + setUnreadCount(response.data.unreadCount); + setLastReadMessageId(messageId); + } catch (error) { + console.error('읽음 표시 업데이트 실패:', error); + } + }; + + // 읽음 표시 업데이트 디바운스 + const debouncedMarkRead = useCallback( + debounce((messageId) => { + markReadUpTo(messageId); + }, 1000), + [actualRoomId] + ); + + // 채팅 검색 함수 + const searchChatMessages = async (query) => { + if (!query.trim()) { + setSearchResults([]); + setShowSearchResults(false); + return; + } - // 읽음 상태 조회 - const fetchReadState = async () => { - try { - const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/read-state`); - setLastReadMessageId(response.data.lastReadMessageId); - setUnreadCount(response.data.unreadCount); - } catch (error) { - console.error('읽음 상태 조회 실패:', error); - } - }; + setIsSearching(true); + try { + const response = await axiosInstance.get( + `/api/chat-rooms/${actualRoomId}/search`, { + params: { + q: query.trim(), + limit: 20 + } + }); + + const {items} = response.data; + setSearchResults(items.map(formatMessage)); + setShowSearchResults(true); + } catch (error) { + console.error('채팅 검색 실패:', error); + setSearchResults([]); + setShowSearchResults(false); + } finally { + setIsSearching(false); + } + }; + + // 검색 실행 + const handleSearch = () => { + searchChatMessages(searchQuery); + }; + + // 검색 초기화 + const clearSearch = () => { + setSearchQuery(''); + setSearchResults([]); + setShowSearchResults(false); + }; + + // 시간 포맷 + const formatTime = (timestamp) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('ko-KR', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + }; + + // 메시지 포맷 변환 + const formatMessage = useCallback((item) => { + // 사용자 이름 결정 + let userName = '시스템'; + if (item.type === 'ALERT' || item.type === 'SYSTEM') { + userName = '⚽ 알림'; + } else if (item.userId) { + // 스코어보드에서 사용자 정보 찾기 + const user = scoreboard.find(s => s.userId === item.userId); + if (user) { + userName = user.email; + } else if (currentUser && item.userId === currentUser.id) { + // 현재 사용자인 경우 현재 사용자 이메일 사용 + userName = currentUser.email; + } else { + // 디버깅: Unknown인 경우 정보 출력 + console.log('Unknown user detected:', { + userId: item.userId, + currentUser: currentUser, + scoreboard: scoreboard, + content: item.content + }); + userName = 'Unknown'; + } + } - // 읽음 표시 업데이트 - const markReadUpTo = async (messageId) => { - try { - const response = await axiosInstance.post(`/api/chat-rooms/${actualRoomId}/read-state`, { - messageId: messageId - }); - setUnreadCount(response.data.unreadCount); - setLastReadMessageId(messageId); - } catch (error) { - console.error('읽음 표시 업데이트 실패:', error); - } + return { + id: item.id, + user: userName, + text: item.content, + time: formatTime(item.createdAt), + type: item.type, + userId: item.userId }; + }, [scoreboard, currentUser]); - // 읽음 표시 업데이트 디바운스 - const debouncedMarkRead = useCallback( - debounce((messageId) => { - markReadUpTo(messageId); - }, 1000), - [actualRoomId] - ); - - // 채팅 검색 함수 - const searchChatMessages = async (query) => { - if (!query.trim()) { - setSearchResults([]); - setShowSearchResults(false); - return; + // 채팅 히스토리 초기 로드 (최신 메시지부터) + const loadInitialHistory = async () => { + if (loading) { + return; + } + setLoading(true); + + try { + // 최신 메시지부터 20개 가져오기 (백엔드에서 이미 처리됨) + const response = await axiosInstance.get( + `/api/chat-rooms/${actualRoomId}/messages`, { + params: {limit: 20} + }); + + const {items, nextCursor: cursor, hasMore: more} = response.data; + + // 백엔드에서 이미 오래된->최신 순으로 정렬해서 반환하므로 그대로 사용 + setChatList(items.map(formatMessage)); + setNextCursor(cursor); + setHasMore(more); + setIsInitialLoad(false); + + // 초기 로드 완료 후 즉시 맨 아래로 스크롤 (애니메이션 없이) + requestAnimationFrame(() => { + if (chatRef.current) { + chatRef.current.scrollTop = chatRef.current.scrollHeight; } + }); - setIsSearching(true); - try { - const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/search`, { - params: { - q: query.trim(), - limit: 20 - } - }); - - const { items } = response.data; - setSearchResults(items.map(formatMessage)); - setShowSearchResults(true); - } catch (error) { - console.error('채팅 검색 실패:', error); - setSearchResults([]); - setShowSearchResults(false); - } finally { - setIsSearching(false); - } - }; - - // 검색 실행 - const handleSearch = () => { - searchChatMessages(searchQuery); - }; - - // 검색 초기화 - const clearSearch = () => { - setSearchQuery(''); - setSearchResults([]); - setShowSearchResults(false); - }; - - // 시간 포맷 - const formatTime = (timestamp) => { - const date = new Date(timestamp); - return date.toLocaleTimeString('ko-KR', { - hour: 'numeric', - minute: '2-digit', - hour12: true - }); - }; + } catch (error) { + console.error('채팅 히스토리 초기 로드 실패:', error); + } finally { + setLoading(false); + } + }; - // 메시지 포맷 변환 - const formatMessage = useCallback((item) => { - // 사용자 이름 결정 - let userName = '시스템'; - if (item.type === 'ALERT' || item.type === 'SYSTEM') { - userName = '⚽ 알림'; - } else if (item.userId) { - // 스코어보드에서 사용자 정보 찾기 - const user = scoreboard.find(s => s.userId === item.userId); - if (user) { - userName = user.email; - } else if (currentUser && item.userId === currentUser.id) { - // 현재 사용자인 경우 현재 사용자 이메일 사용 - userName = currentUser.email; - } else { - // 디버깅: Unknown인 경우 정보 출력 - console.log('Unknown user detected:', { - userId: item.userId, - currentUser: currentUser, - scoreboard: scoreboard, - content: item.content - }); - userName = 'Unknown'; + // 이전 메시지 로드 (무한스크롤) + const loadMoreMessages = async () => { + if (loading || !nextCursor || !hasMore) { + return; + } + setLoading(true); + + try { + const response = await axiosInstance.get( + `/api/chat-rooms/${roomId}/messages/before`, { + params: { + cursor: nextCursor, + limit: 20 // 20개씩 로드 } - } + }); - return { - id: item.id, - user: userName, - text: item.content, - time: formatTime(item.createdAt), - type: item.type, - userId: item.userId - }; - }, [scoreboard, currentUser]); - - // 채팅 히스토리 초기 로드 (최신 메시지부터) - const loadInitialHistory = async () => { - if (loading) return; - setLoading(true); - - try { - // 최신 메시지부터 20개 가져오기 (백엔드에서 이미 처리됨) - const response = await axiosInstance.get(`/api/chat-rooms/${actualRoomId}/messages`, { - params: { limit: 20 } - }); - - const { items, nextCursor: cursor, hasMore: more } = response.data; - - // 백엔드에서 이미 오래된->최신 순으로 정렬해서 반환하므로 그대로 사용 - setChatList(items.map(formatMessage)); - setNextCursor(cursor); - setHasMore(more); - setIsInitialLoad(false); - - // 초기 로드 완료 후 즉시 맨 아래로 스크롤 (애니메이션 없이) - requestAnimationFrame(() => { - if (chatRef.current) { - chatRef.current.scrollTop = chatRef.current.scrollHeight; - } - }); + const {items, nextCursor: cursor, hasMore: more} = response.data; - } catch (error) { - console.error('채팅 히스토리 초기 로드 실패:', error); - } finally { - setLoading(false); - } - }; + // 스크롤 위치 보존을 위해 현재 스크롤 높이 저장 + const currentScrollHeight = chatRef.current?.scrollHeight || 0; - // 이전 메시지 로드 (무한스크롤) - const loadMoreMessages = async () => { - if (loading || !nextCursor || !hasMore) return; - setLoading(true); + // 기존 메시지 위에 추가 (오래된 메시지를 위에) + setChatList(prev => [...items.map(formatMessage), ...prev]); + setNextCursor(cursor); + setHasMore(more); - try { - const response = await axiosInstance.get(`/api/chat-rooms/${roomId}/messages/before`, { - params: { - cursor: nextCursor, - limit: 20 // 20개씩 로드 - } - }); + // 스크롤 위치 조정 (새 메시지가 추가되어도 사용자가 보던 위치 유지) + setTimeout(() => { + if (chatRef.current) { + const newScrollHeight = chatRef.current.scrollHeight; + const heightDiff = newScrollHeight - currentScrollHeight; + chatRef.current.scrollTop = chatRef.current.scrollTop + heightDiff; + } + }, 50); - const { items, nextCursor: cursor, hasMore: more } = response.data; + } catch (error) { + console.error('이전 메시지 로드 실패:', error); + } finally { + setLoading(false); + } + }; - // 스크롤 위치 보존을 위해 현재 스크롤 높이 저장 - const currentScrollHeight = chatRef.current?.scrollHeight || 0; + // WebSocket 연결 + const connectWebSocket = useCallback(() => { + if (stompClientRef.current?.connected) { + return; + } - // 기존 메시지 위에 추가 (오래된 메시지를 위에) - setChatList(prev => [...items.map(formatMessage), ...prev]); - setNextCursor(cursor); - setHasMore(more); + const token = getAuthToken(); + if (!token) { + console.error('인증 토큰이 없습니다.'); + setIsConnected(false); + return; + } - // 스크롤 위치 조정 (새 메시지가 추가되어도 사용자가 보던 위치 유지) - setTimeout(() => { - if (chatRef.current) { - const newScrollHeight = chatRef.current.scrollHeight; - const heightDiff = newScrollHeight - currentScrollHeight; - chatRef.current.scrollTop = chatRef.current.scrollTop + heightDiff; + try { + // 시크릿이 wss://여도 SOCKJS_ORIGIN이 https://로 변환됨 + const socket = new SockJS(`${SOCKJS_ORIGIN}/ws`); + const stompClient = new Client({ + webSocketFactory: () => socket, + connectHeaders: { + 'Authorization': `Bearer ${token}` + }, + debug: (str) => { + console.log('STOMP:', str); + }, + onConnect: (frame) => { + console.log('WebSocket 연결 성공:', frame); + setIsConnected(true); + + // 채팅방 구독 + const subscription = stompClient.subscribe( + `/topic/chat/${actualRoomId}`, (message) => { + console.log('메시지 수신:', message.body); + const newMessage = JSON.parse(message.body); + + // 사용자 이름 결정 + let userName = '시스템'; + if (newMessage.type === 'ALERT' || newMessage.type + === 'SYSTEM') { + userName = '⚽ 알림'; + } else if (newMessage.userId) { + const user = scoreboard.find( + s => s.userId === newMessage.userId); + if (user) { + userName = user.email; + } else if (currentUser && newMessage.userId + === currentUser.id) { + // 현재 사용자인 경우 현재 사용자 이메일 사용 + userName = currentUser.email; + } else { + userName = 'Unknown'; + } } - }, 50); - } catch (error) { - console.error('이전 메시지 로드 실패:', error); - } finally { - setLoading(false); - } - }; + const formattedMessage = { + id: newMessage.id || Date.now().toString(), + user: userName, + text: newMessage.content, + time: formatTime(newMessage.createdAt || new Date()), + type: newMessage.type, + userId: newMessage.userId + }; - // WebSocket 연결 - const connectWebSocket = useCallback(() => { - if (stompClientRef.current?.connected) return; - - const token = getAuthToken(); - if (!token) { - console.error('인증 토큰이 없습니다.'); - setIsConnected(false); - return; - } + setChatList(prev => { + const newList = [...prev, formattedMessage]; - try { - // SockJS 연결 (백엔드가 SockJS를 지원하지 않으면 일반 WebSocket 사용) - const wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8080'; - const socket = new SockJS(`${wsUrl.replace('ws://', 'http://')}/ws`); - const stompClient = new Client({ - webSocketFactory: () => socket, - connectHeaders: { - 'Authorization': `Bearer ${token}` - }, - debug: (str) => { - console.log('STOMP:', str); - }, - onConnect: (frame) => { - console.log('WebSocket 연결 성공:', frame); - setIsConnected(true); - - // 채팅방 구독 - const subscription = stompClient.subscribe(`/topic/chat/${actualRoomId}`, (message) => { - console.log('메시지 수신:', message.body); - const newMessage = JSON.parse(message.body); - - // 사용자 이름 결정 - let userName = '시스템'; - if (newMessage.type === 'ALERT' || newMessage.type === 'SYSTEM') { - userName = '⚽ 알림'; - } else if (newMessage.userId) { - const user = scoreboard.find(s => s.userId === newMessage.userId); - if (user) { - userName = user.email; - } else if (currentUser && newMessage.userId === currentUser.id) { - // 현재 사용자인 경우 현재 사용자 이메일 사용 - userName = currentUser.email; - } else { - userName = 'Unknown'; - } - } - - const formattedMessage = { - id: newMessage.id || Date.now().toString(), - user: userName, - text: newMessage.content, - time: formatTime(newMessage.createdAt || new Date()), - type: newMessage.type, - userId: newMessage.userId - }; - - setChatList(prev => { - const newList = [...prev, formattedMessage]; - - // 새 메시지 추가 후 스크롤을 맨 아래로 (카카오톡 방식) - setTimeout(() => { - if (chatRef.current) { - chatRef.current.scrollTop = chatRef.current.scrollHeight; - } - }, 100); - - return newList; - }); - - // 알림 메시지일 경우 스코어보드 새로고침 및 최근 알림에 추가 - if (newMessage.type === 'ALERT') { - fetchScoreboard(); - - // 골/어시스트 관련 알림인지 확인 - const isGoalOrAssist = newMessage.content.includes('골') || - newMessage.content.includes('어시스트') || - newMessage.content.includes('득점') || - newMessage.content.includes('도움'); - - if (isGoalOrAssist) { - setRecentAlerts(prev => { - const newAlert = { - id: newMessage.id || Date.now().toString(), - content: newMessage.content, - time: formatTime(newMessage.createdAt || new Date()), - type: 'goal-assist' - }; - - // 최대 3개까지만 저장 (최신순) - const updatedAlerts = [newAlert, ...prev].slice(0, 3); - return updatedAlerts; - }); - } - } - }, { - 'Authorization': `Bearer ${token}` - }); - - console.log('구독 완료:', subscription); - }, - onDisconnect: (frame) => { - console.log('WebSocket 연결 해제:', frame); - setIsConnected(false); - }, - onStompError: (frame) => { - console.error('STOMP 오류:', frame); - setIsConnected(false); - - // 에러 메시지 파싱 - if (frame.headers && frame.headers.message) { - console.error('오류 메시지:', frame.headers.message); + // 새 메시지 추가 후 스크롤을 맨 아래로 (카카오톡 방식) + setTimeout(() => { + if (chatRef.current) { + chatRef.current.scrollTop = chatRef.current.scrollHeight; } + }, 100); - // 재연결 시도 - setTimeout(() => { - if (!stompClientRef.current?.connected) { - console.log('재연결 시도...'); - connectWebSocket(); - } - }, 3000); - }, - onWebSocketError: (error) => { - console.error('WebSocket 오류:', error); - }, - reconnectDelay: 5000, - heartbeatIncoming: 4000, - heartbeatOutgoing: 4000 - }); - - stompClient.activate(); - stompClientRef.current = stompClient; - console.log('STOMP 클라이언트 활성화됨'); - } catch (error) { - console.error('WebSocket 연결 실패:', error); - setIsConnected(false); - } - }, [actualRoomId, scoreboard]); - - // 메시지 전송 - const handleSendMessage = () => { + return newList; + }); - if (isComposing) return; - if (!message.trim()) return; + // 알림 메시지일 경우 스코어보드 새로고침 및 최근 알림에 추가 + if (newMessage.type === 'ALERT') { + fetchScoreboard(); + + // 골/어시스트 관련 알림인지 확인 + const isGoalOrAssist = newMessage.content.includes('골') || + newMessage.content.includes('어시스트') || + newMessage.content.includes('득점') || + newMessage.content.includes('도움'); + + if (isGoalOrAssist) { + setRecentAlerts(prev => { + const newAlert = { + id: newMessage.id || Date.now().toString(), + content: newMessage.content, + time: formatTime(newMessage.createdAt || new Date()), + type: 'goal-assist' + }; + + // 최대 3개까지만 저장 (최신순) + const updatedAlerts = [newAlert, ...prev].slice(0, 3); + return updatedAlerts; + }); + } + } + }, { + 'Authorization': `Bearer ${token}` + }); + + console.log('구독 완료:', subscription); + }, + onDisconnect: (frame) => { + console.log('WebSocket 연결 해제:', frame); + setIsConnected(false); + }, + onStompError: (frame) => { + console.error('STOMP 오류:', frame); + setIsConnected(false); + + // 에러 메시지 파싱 + if (frame.headers && frame.headers.message) { + console.error('오류 메시지:', frame.headers.message); + } + + // 재연결 시도 + setTimeout(() => { + if (!stompClientRef.current?.connected) { + console.log('재연결 시도...'); + connectWebSocket(); + } + }, 3000); + }, + onWebSocketError: (error) => { + console.error('WebSocket 오류:', error); + }, + reconnectDelay: 5000, + heartbeatIncoming: 4000, + heartbeatOutgoing: 4000 + }); + + stompClient.activate(); + stompClientRef.current = stompClient; + console.log('STOMP 클라이언트 활성화됨'); + } catch (error) { + console.error('WebSocket 연결 실패:', error); + setIsConnected(false); + } + }, [actualRoomId, scoreboard]); - if (!stompClientRef.current?.connected) { - alert('채팅 서버에 연결되지 않았습니다. 잠시 후 다시 시도해주세요.'); - return; - } + // 메시지 전송 + const handleSendMessage = () => { - const messageData = { - roomId: actualRoomId, - content: message.trim() - }; - - try { - stompClientRef.current.publish({ - destination: `/app/chat/${actualRoomId}/send`, - body: JSON.stringify(messageData) - }); - setMessage(''); - } catch (error) { - console.error('메시지 전송 실패:', error); - alert('메시지 전송에 실패했습니다.'); - } - }; + if (isComposing) { + return; + } + if (!message.trim()) { + return; + } - // 참가자 선택 - const handleSelectParticipant = (participantId) => { - setSelectedParticipantId(participantId); - fetchRoster(participantId); - }; + if (!stompClientRef.current?.connected) { + alert('채팅 서버에 연결되지 않았습니다. 잠시 후 다시 시도해주세요.'); + return; + } - // 채팅방 나가기 - const exitRoom = () => { - if (window.confirm('채팅방에서 나가시겠습니까?')) { - if (stompClientRef.current) { - stompClientRef.current.deactivate(); - } - navigate('/'); - } + const messageData = { + roomId: actualRoomId, + content: message.trim() }; - // 무한스크롤 로드 디바운스 - const debouncedLoadMore = useCallback( - debounce(() => { - if (hasMore && !loading && !isInitialLoad) { - loadMoreMessages(); - } - }, 300), - [hasMore, loading, nextCursor, isInitialLoad] - ); + try { + stompClientRef.current.publish({ + destination: `/app/chat/${actualRoomId}/send`, + body: JSON.stringify(messageData) + }); + setMessage(''); + } catch (error) { + console.error('메시지 전송 실패:', error); + alert('메시지 전송에 실패했습니다.'); + } + }; + + // 참가자 선택 + const handleSelectParticipant = (participantId) => { + setSelectedParticipantId(participantId); + fetchRoster(participantId); + }; + + // 채팅방 나가기 + const exitRoom = () => { + if (window.confirm('채팅방에서 나가시겠습니까?')) { + if (stompClientRef.current) { + stompClientRef.current.deactivate(); + } + navigate('/'); + } + }; - // 무한스크롤 트리거 - useEffect(() => { - if (inView && hasMore && !loading && !isInitialLoad) { - debouncedLoadMore(); + // 무한스크롤 로드 디바운스 + const debouncedLoadMore = useCallback( + debounce(() => { + if (hasMore && !loading && !isInitialLoad) { + loadMoreMessages(); } - }, [inView, hasMore, loading, isInitialLoad, debouncedLoadMore]); + }, 300), + [hasMore, loading, nextCursor, isInitialLoad] + ); + + // 무한스크롤 트리거 + useEffect(() => { + if (inView && hasMore && !loading && !isInitialLoad) { + debouncedLoadMore(); + } + }, [inView, hasMore, loading, isInitialLoad, debouncedLoadMore]); - // 컴포넌트 마운트 - useEffect(() => { - if (!roomId) { - navigate('/'); - return; - } + // 컴포넌트 마운트 + useEffect(() => { + if (!roomId) { + navigate('/'); + return; + } - // 데이터 로드 - fetchCurrentUser(); - fetchScoreboard(); - loadInitialHistory(); - fetchReadState(); + // 데이터 로드 + fetchCurrentUser(); + fetchScoreboard(); + loadInitialHistory(); + fetchReadState(); - return () => { - if (stompClientRef.current) { - stompClientRef.current.deactivate(); - } - }; - }, [roomId]); + return () => { + if (stompClientRef.current) { + stompClientRef.current.deactivate(); + } + }; + }, [roomId]); - // 선택된 참가자 변경 시 로스터 로드 - useEffect(() => { - if (selectedParticipantId) { - fetchRoster(selectedParticipantId); - } - }, [selectedParticipantId]); + // 선택된 참가자 변경 시 로스터 로드 + useEffect(() => { + if (selectedParticipantId) { + fetchRoster(selectedParticipantId); + } + }, [selectedParticipantId]); - // WebSocket 연결 (스코어보드 로드 후) - useEffect(() => { - if (scoreboard.length > 0 && !isConnected) { - connectWebSocket(); - } - }, [scoreboard, connectWebSocket]); - - // 사용자 정보 또는 스코어보드 업데이트 시 기존 메시지 다시 포맷팅 - useEffect(() => { - if ((currentUser || scoreboard.length > 0) && chatList.length > 0) { - setChatList(prevList => - prevList.map(msg => ({ - ...msg, - user: (() => { - // 메시지가 이미 포맷된 것이면서 userId가 있는 경우만 다시 처리 - if (!msg.userId || msg.user === '⚽ 알림' || msg.user === '시스템') { - return msg.user; - } - - // 스코어보드에서 사용자 정보 찾기 - const user = scoreboard.find(s => s.userId === msg.userId); - if (user) { - return user.email; - } else if (currentUser && msg.userId === currentUser.id) { - return currentUser.email; - } else { - return msg.user; // 기존 값 유지 (Unknown일 수도 있음) - } - })() - })) - ); + // WebSocket 연결 (스코어보드 로드 후) + useEffect(() => { + if (scoreboard.length > 0 && !isConnected) { + connectWebSocket(); + } + }, [scoreboard, connectWebSocket]); + + // 사용자 정보 또는 스코어보드 업데이트 시 기존 메시지 다시 포맷팅 + useEffect(() => { + if ((currentUser || scoreboard.length > 0) && chatList.length > 0) { + setChatList(prevList => + prevList.map(msg => ({ + ...msg, + user: (() => { + // 메시지가 이미 포맷된 것이면서 userId가 있는 경우만 다시 처리 + if (!msg.userId || msg.user === '⚽ 알림' || msg.user === '시스템') { + return msg.user; + } + + // 스코어보드에서 사용자 정보 찾기 + const user = scoreboard.find(s => s.userId === msg.userId); + if (user) { + return user.email; + } else if (currentUser && msg.userId === currentUser.id) { + return currentUser.email; + } else { + return msg.user; // 기존 값 유지 (Unknown일 수도 있음) + } + })() + })) + ); + } + }, [currentUser, scoreboard]); + + // 새 메시지 추가 시 스크롤 및 읽음 표시 업데이트 + useEffect(() => { + if (chatRef.current && chatList.length > 0) { + const {scrollTop, scrollHeight, clientHeight} = chatRef.current; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + + if (isNearBottom) { + setTimeout(() => { + if (chatRef.current) { + chatRef.current.scrollTop = chatRef.current.scrollHeight; + } + }, 100); + + // 최신 메시지를 읽음 처리 (디바운스 적용) + const lastMessage = chatList[chatList.length - 1]; + if (lastMessage && lastMessage.id !== lastReadMessageId) { + debouncedMarkRead(lastMessage.id); } - }, [currentUser, scoreboard]); - - // 새 메시지 추가 시 스크롤 및 읽음 표시 업데이트 - useEffect(() => { - if (chatRef.current && chatList.length > 0) { - const { scrollTop, scrollHeight, clientHeight } = chatRef.current; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; - - if (isNearBottom) { - setTimeout(() => { - if (chatRef.current) { - chatRef.current.scrollTop = chatRef.current.scrollHeight; - } - }, 100); - - // 최신 메시지를 읽음 처리 (디바운스 적용) - const lastMessage = chatList[chatList.length - 1]; - if (lastMessage && lastMessage.id !== lastReadMessageId) { - debouncedMarkRead(lastMessage.id); - } - } + } + } + }, [chatList, lastReadMessageId, debouncedMarkRead]); + + // 스크롤 이벤트로 읽음 표시 업데이트 + useEffect(() => { + const handleScroll = () => { + if (!chatRef.current || chatList.length === 0) { + return; + } + + const {scrollTop, scrollHeight, clientHeight} = chatRef.current; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + + if (isNearBottom) { + const lastMessage = chatList[chatList.length - 1]; + if (lastMessage && lastMessage.id !== lastReadMessageId) { + debouncedMarkRead(lastMessage.id); } - }, [chatList, lastReadMessageId, debouncedMarkRead]); - - // 스크롤 이벤트로 읽음 표시 업데이트 - useEffect(() => { - const handleScroll = () => { - if (!chatRef.current || chatList.length === 0) return; - - const { scrollTop, scrollHeight, clientHeight } = chatRef.current; - const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; + } + }; - if (isNearBottom) { - const lastMessage = chatList[chatList.length - 1]; - if (lastMessage && lastMessage.id !== lastReadMessageId) { - debouncedMarkRead(lastMessage.id); - } - } - }; + const chatElement = chatRef.current; + if (chatElement) { + chatElement.addEventListener('scroll', handleScroll); + return () => chatElement.removeEventListener('scroll', handleScroll); + } + }, [chatList, lastReadMessageId, debouncedMarkRead]); - const chatElement = chatRef.current; - if (chatElement) { - chatElement.addEventListener('scroll', handleScroll); - return () => chatElement.removeEventListener('scroll', handleScroll); - } - }, [chatList, lastReadMessageId, debouncedMarkRead]); - - // 포메이션 렌더링 헬퍼 - const renderFormation = () => { - if (!currentRoster) return null; - - const positions = { - GK: [], - DF: [], - MID: [], - FWD: [] - }; - - currentRoster.players.forEach(player => { - const pos = player.position === 'FW' ? 'FWD' : player.position; - if (positions[pos]) { - positions[pos].push(player); - } - }); + // 포메이션 렌더링 헬퍼 + const renderFormation = () => { + if (!currentRoster) { + return null; + } - return ( - <> - {Object.entries(positions).map(([position, players]) => ( - players.length > 0 && ( -
- {players.map((player) => ( -
-
-
{player.name}
-
{position}
-
- ))} -
- ) - ))} - - ); + const positions = { + GK: [], + DF: [], + MID: [], + FWD: [] }; + currentRoster.players.forEach(player => { + const pos = player.position === 'FW' ? 'FWD' : player.position; + if (positions[pos]) { + positions[pos].push(player); + } + }); + return ( <> -
-
Fantasy11
-
-
- 접속중 - + {Object.entries(positions).map(([position, players]) => ( + players.length > 0 && ( +
+ {players.map((player) => ( +
+
+
{player.name}
+
{position}
+
+ ))} +
+ ) + ))} + + ); + }; + + return ( + <> +
+
Fantasy11
+
+
+ 접속중 + {isConnected ? ' ●' : ' ●'} -
- -
+
+ +
+
+ +
+
+ {/* 탭 헤더 */} +
+ +
-
-
- {/* 탭 헤더 */} -
- - -
- - {/* 탭 내용 */} -
- {activeTab === 'participants' && ( -
-
- {scoreboard.map((participant, index) => { - // 메달 이모티콘 결정 - const getMedalIcon = (rank) => { - switch(rank) { - case 1: return '🥇'; - case 2: return '🥈'; - case 3: return '🥉'; - default: return '4️⃣'; - } - }; - - const getRankText = (rank) => { - return `${rank}위`; - }; - - return ( -
{ - handleSelectParticipant(participant.participantId); - setActiveTab('formation'); // 선택 후 포메이션 탭으로 자동 이동 - }} - > -
- {getMedalIcon(participant.rank)} - {getRankText(participant.rank)} -
-
-
- {participant.email} - {participant.userId === currentUser?.id && ' (나)'} -
-
{participant.totalPoints}점
-
-
- ); - })} -
+ {/* 탭 내용 */} +
+ {activeTab === 'participants' && ( +
+
+ {scoreboard.map((participant, index) => { + // 메달 이모티콘 결정 + const getMedalIcon = (rank) => { + switch (rank) { + case 1: + return '🥇'; + case 2: + return '🥈'; + case 3: + return '🥉'; + default: + return '4️⃣'; + } + }; - {/* 최근 골/어시스트 알림 영역 */} -
-
- ⚽ 최근 알림 -
-
- {recentAlerts.length > 0 ? ( - recentAlerts.map((alert) => ( -
-
{alert.content}
-
{alert.time}
-
- )) - ) : ( -
- 아직 골/어시스트 알림이 없습니다 -
- )} -
-
-
- )} + const getRankText = (rank) => { + return `${rank}위`; + }; - {activeTab === 'formation' && ( -
-
- {currentRoster ? `${scoreboard.find(s => s.participantId === selectedParticipantId)?.email || ''}의 팀` : '팀 선택'} - {currentRoster && ` (${currentRoster.formation})`} + return ( +
{ + handleSelectParticipant( + participant.participantId); + setActiveTab('formation'); // 선택 후 포메이션 탭으로 자동 이동 + }} + > +
+ {getMedalIcon( + participant.rank)} + {getRankText( + participant.rank)} +
+
+
+ {participant.email} + {participant.userId === currentUser?.id + && ' (나)'}
-
-
-
- {renderFormation()} -
+
{participant.totalPoints}점
+
- )} + ); + })}
-
-
-
- 채팅 - {unreadCount > 0 && 미읽음 {unreadCount}} - {loading && 로딩중...} -
- - {/* 채팅 검색 영역 */} -
- setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSearch(); - } - }} - placeholder="채팅 메시지 검색..." - /> - - {showSearchResults && ( - - )} -
-
- {showSearchResults ? ( - /* 검색 결과 표시 */ - <> -
- 검색 결과: "{searchQuery}" ({searchResults.length}건) + {/* 최근 골/어시스트 알림 영역 */} +
+
+ ⚽ 최근 알림 +
+
+ {recentAlerts.length > 0 ? ( + recentAlerts.map((alert) => ( +
+
{alert.content}
+
{alert.time}
- {searchResults.length > 0 ? ( - searchResults.map((msg) => ( -
-
{msg.user}
-
{msg.text}
-
{msg.time}
-
- )) - ) : ( -
- 검색 결과가 없습니다. -
- )} - + )) ) : ( - /* 일반 채팅 메시지 표시 */ - <> - {hasMore && ( -
- {loading &&
이전 메시지를 불러오는 중...
} -
- )} - {!hasMore && chatList.length > 0 && ( -
채팅의 시작입니다.
- )} - {chatList.map((msg) => ( -
-
{msg.user}
-
{msg.text}
-
{msg.time}
-
- ))} - +
+ 아직 골/어시스트 알림이 없습니다 +
)} +
-
- setMessage(e.target.value)} - onCompositionStart={() => setIsComposing(true)} - onCompositionEnd={(e) => { - // 조합 - // 종료 시 최종 텍스트 반영(안전) - setMessage(e.currentTarget.value); - setIsComposing(false); - }} - onKeyDown={(e) => { - // 브라우저별 안전 가드: 로컬 state || native flag 둘 다 확인 - const composing = isComposing || e.nativeEvent.isComposing; - if (e.key === 'Enter' && !e.shiftKey) { - if (composing) return; // ⬅️ 조합 중이면 전송 금지 - e.preventDefault(); - handleSendMessage(); - } - }} - placeholder={isConnected ? "메시지를 입력하세요..." : "연결 중..."} - disabled={!isConnected} - /> - +
+ )} + + {activeTab === 'formation' && ( +
+
+ {currentRoster ? `${scoreboard.find( + s => s.participantId === selectedParticipantId)?.email + || ''}의 팀` : '팀 선택'} + {currentRoster && ` (${currentRoster.formation})`}
-
+
+
+
+ {renderFormation()} +
+
+
+ )}
- - ); +
+ +
+
+ 채팅 + {unreadCount > 0 && 미읽음 {unreadCount}} + {loading && 로딩중...} +
+ + {/* 채팅 검색 영역 */} +
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }} + placeholder="채팅 메시지 검색..." + /> + + {showSearchResults && ( + + )} +
+
+ {showSearchResults ? ( + /* 검색 결과 표시 */ + <> +
+ 검색 결과: "{searchQuery}" ({searchResults.length}건) +
+ {searchResults.length > 0 ? ( + searchResults.map((msg) => ( +
+
{msg.user}
+
{msg.text}
+
{msg.time}
+
+ )) + ) : ( +
+ 검색 결과가 없습니다. +
+ )} + + ) : ( + /* 일반 채팅 메시지 표시 */ + <> + {hasMore && ( +
+ {loading &&
이전 메시지를 + 불러오는 중...
} +
+ )} + {!hasMore && chatList.length > 0 && ( +
채팅의 시작입니다.
+ )} + {chatList.map((msg) => ( +
+
{msg.user}
+
{msg.text}
+
{msg.time}
+
+ ))} + + )} +
+
+ setMessage(e.target.value)} + onCompositionStart={() => setIsComposing(true)} + onCompositionEnd={(e) => { + // 조합 + // 종료 시 최종 텍스트 반영(안전) + setMessage(e.currentTarget.value); + setIsComposing(false); + }} + onKeyDown={(e) => { + // 브라우저별 안전 가드: 로컬 state || native flag 둘 다 확인 + const composing = isComposing || e.nativeEvent.isComposing; + if (e.key === 'Enter' && !e.shiftKey) { + if (composing) { + return; + } // ⬅️ 조합 중이면 전송 금지 + e.preventDefault(); + handleSendMessage(); + } + }} + placeholder={isConnected ? "메시지를 입력하세요..." : "연결 중..."} + disabled={!isConnected} + /> + +
+
+
+ + ); } \ No newline at end of file