diff --git a/package-lock.json b/package-lock.json index 67f7978..1721dba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.32.0", "axios": "^1.6.8", "event-source-polyfill": "^1.0.31", @@ -1195,6 +1196,11 @@ "win32" ] }, + "node_modules/@stomp/stompjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.0.0.tgz", + "integrity": "sha512-fGdq4wPDnSV/KyOsjq4P+zLc8MFWC3lMmP5FBgLWKPJTYcuCbAIrnRGjB7q2jHZdYCOD5vxLuFoKIYLy5/u8Pw==" + }, "node_modules/@swc/core": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.4.15.tgz", diff --git a/package.json b/package.json index 46061dc..b306a6c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dependencies": { "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@stomp/stompjs": "^7.0.0", "@tanstack/react-query": "^5.32.0", "axios": "^1.6.8", "event-source-polyfill": "^1.0.31", diff --git a/src/axios/http.ts b/src/axios/http.ts index ec4c3b9..9a16471 100644 --- a/src/axios/http.ts +++ b/src/axios/http.ts @@ -1,7 +1,7 @@ import { useSuspenseQuery } from '@tanstack/react-query'; import { - Chat, + ChatArray, ChatRequest, DeadResult, GameExist, @@ -45,8 +45,8 @@ export const useChatsQuery = () => { const { data: chats, ...rest } = useSuspenseQuery({ queryKey: ['chats', localStorage.getItem('auth')], queryFn: () => getChats(), - refetchInterval: 500, - staleTime: 500, + // refetchInterval: 500, + // staleTime: 500, }); return { chats, @@ -85,7 +85,7 @@ export const existGame = () => { }; export const getChats = () => { - return http.get(`/chat`); + return http.get(`/v2/chat`); }; export const postChats = (payload: ChatRequest) => { diff --git a/src/axios/instances.ts b/src/axios/instances.ts index 585bb05..9d95997 100644 --- a/src/axios/instances.ts +++ b/src/axios/instances.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -export const BASE_URL = 'https://dev.mafia-together.com/api'; +export const DOMAIN = 'dev.mafia-together.com'; +export const BASE_URL = `https://${DOMAIN}/api`; export const axiosInstance = axios.create({ baseURL: BASE_URL, diff --git a/src/components/chat/Chat.tsx b/src/components/chat/Chat.tsx new file mode 100644 index 0000000..a8b4a79 --- /dev/null +++ b/src/components/chat/Chat.tsx @@ -0,0 +1,45 @@ +/** @jsxImportSource @emotion/react */ +import { css } from '@emotion/react'; +import { useRecoilState } from 'recoil'; + +import { roomInfoState } from '../../recoil/roominfo/atom'; +import { VariablesCSS } from '../../styles/VariablesCSS'; +import { ChatArray } from '../../type'; +import { ChatForm } from './ChatForm'; +import { Chats } from './Chats'; + +type PropsType = { + publishChat: (content: string) => void; + chats: ChatArray; + setChats: React.Dispatch>; +}; + +export const Chat = ({ publishChat, chats, setChats }: PropsType) => { + /* 방 정보 */ + const [roomInfo] = useRecoilState(roomInfoState); + + // 내가 살아있는지 + const isAlive = roomInfo?.isAlive; + + return ( + <> + {/* 채팅목록 */} +
+ +
+ + {/* 살아있는 경우에만 input창이 보인다. */} + {isAlive && } + + ); +}; + +const middle = css` + height: calc(100% - ${VariablesCSS.top} - 55px - 20px); + overflow: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +`; diff --git a/src/components/chat/ChatForm.tsx b/src/components/chat/ChatForm.tsx index 9049ff7..90306e6 100644 --- a/src/components/chat/ChatForm.tsx +++ b/src/components/chat/ChatForm.tsx @@ -2,7 +2,6 @@ import { css } from '@emotion/react'; import { useState } from 'react'; -import { postChats } from '../../axios/http'; import { VariablesCSS } from '../../styles/VariablesCSS'; function isInvalidInputChat(inputChat: string) { @@ -10,14 +9,19 @@ function isInvalidInputChat(inputChat: string) { return inputChat.length === 0; } -export const ChatForm = () => { +type PropsType = { + publishChat: (content: string) => void; +}; + +export const ChatForm = ({ publishChat }: PropsType) => { const [inputChat, setInputChat] = useState(''); return (
{ event.preventDefault(); - postChats({ contents: inputChat }); + publishChat(inputChat); + // postChats({ content: inputChat }); setInputChat(''); }} > diff --git a/src/components/chat/ChatGroup.tsx b/src/components/chat/ChatGroup.tsx index 5b3f3e5..47fc9e4 100644 --- a/src/components/chat/ChatGroup.tsx +++ b/src/components/chat/ChatGroup.tsx @@ -20,8 +20,12 @@ export default forwardRef(function ChatGroup(props: PropsType, ref: any) {

{chats[0].name}

- {chats.map(chat => ( - + {chats.map((chat, idx) => ( + ))}
diff --git a/src/components/chat/Chats.tsx b/src/components/chat/Chats.tsx index f4253cc..bef3ab5 100644 --- a/src/components/chat/Chats.tsx +++ b/src/components/chat/Chats.tsx @@ -1,13 +1,16 @@ import { useEffect, useRef } from 'react'; -import { useChatsQuery } from '../../axios/http'; -import { Chat } from '../../type'; +import { Chat, ChatArray } from '../../type'; import ChatGroup from './ChatGroup'; -export const Chats = () => { +type PropsType = { + chats: ChatArray; + setChats: React.Dispatch>; +}; + +export const Chats = ({ chats }: PropsType) => { const chatRef = useRef(null); - const { chats } = useChatsQuery(); useEffect(() => { if (!chatRef.current) return; chatRef.current.scrollIntoView({ block: 'end' }); diff --git a/src/pages/Day.tsx b/src/pages/Day.tsx index 001db28..592f1cc 100644 --- a/src/pages/Day.tsx +++ b/src/pages/Day.tsx @@ -3,8 +3,7 @@ import { css } from '@emotion/react'; import { Suspense, useEffect, useState } from 'react'; import { useRecoilState } from 'recoil'; -import { ChatForm } from '../components/chat/ChatForm'; -import { Chats } from '../components/chat/Chats'; +import { Chat } from '../components/chat/Chat'; import { Loading } from '../components/etc/Loading'; import AppContainerCSS from '../components/layout/AppContainerCSS'; import ModalContainer from '../components/modal/ModalContainer'; @@ -16,13 +15,16 @@ import VoteResult from '../components/modal/VoteResult'; import TopDay from '../components/top/TopDay'; import { gameRound, roomInfoState } from '../recoil/roominfo/atom'; import { VariablesCSS } from '../styles/VariablesCSS'; -import { Status } from '../type'; +import { ChatArray, Status } from '../type'; type PropsType = { statusType: Status; + publishChat: (content: string) => void; + chats: ChatArray; + setChats: React.Dispatch>; }; -export default function Day({ statusType }: PropsType) { +export default function Day({ statusType, publishChat, chats, setChats }: PropsType) { // 라운드 (몇일차) const [gameRoundState] = useRecoilState(gameRound); @@ -64,12 +66,7 @@ export default function Day({ statusType }: PropsType) { statusType={statusType} /> -
- -
- - {/* 살아있는 경우에만 input창이 보인다. */} - {isAlive && } + {/* 공지 모달 TIME*/} @@ -131,13 +128,3 @@ const gameMessage = css` color: ${VariablesCSS.day}; animation: smoothshow 0.8s; `; - -const middle = css` - height: calc(100% - ${VariablesCSS.top} - 55px - 20px); - overflow: scroll; - -ms-overflow-style: none; - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } -`; diff --git a/src/pages/Game.tsx b/src/pages/Game.tsx index e3ac92d..0094199 100644 --- a/src/pages/Game.tsx +++ b/src/pages/Game.tsx @@ -1,18 +1,27 @@ +import * as StompJs from '@stomp/stompjs'; import { EventListener, EventSourcePolyfill } from 'event-source-polyfill'; import { useEffect, useRef, useState } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; -import { getGamesInfo } from '../axios/http'; +import { getChats, getGamesInfo } from '../axios/http'; import { BASE_URL } from '../axios/instances'; import { gameRound, roomInfoState } from '../recoil/roominfo/atom'; +import { ChatArray, ChatResponse, GameStatus } from '../type'; import Day from './Day'; import Night from './Night'; import Result from './Result'; import WaitingRoom from './WaitingRoom'; export default function Game() { + const auth = localStorage.getItem('auth'); + const [chats, setChats] = useState([]); + const socketClientState = useRef(null); + const [chatSubscribeId, setChatSubscribeId] = useState(null); + const [roomsInfoState, setRoomsInfoState] = useRecoilState(roomInfoState); // 방 정보 + const setGameRoundState = useSetRecoilState(gameRound); + // 방 상태 불러오기 - const [gamesStatus, setGameStatus] = useState({ statusType: 'WAIT' }); + const [gamesStatus, setGameStatus] = useState({ statusType: 'WAIT' }); // SSE const eventSource = useRef(null); @@ -37,10 +46,77 @@ export default function Game() { }; }, []); - // 방 정보 저장 (방 상태가 바뀔때만 작동?) - const setRoomsInfoState = useSetRecoilState(roomInfoState); // 방 정보 + // WebSocket + const connect = () => { + const socket = new StompJs.Client({ + brokerURL: `wss://dev.mafia-together.com/api/stomp`, + reconnectDelay: 10000, + }); - const setGameRoundState = useSetRecoilState(gameRound); + if (!socket.active) { + socket.activate(); + } + + socketClientState.current = socket; + }; + + // 채팅구독 + const subscribeChat = () => { + if (!socketClientState.current?.connected) return; + + const chatSubscribeId = socketClientState.current.subscribe(`/sub/chat/${auth}`, response => { + const msg: ChatResponse = JSON.parse(response.body); + + const isOwner = msg.name == roomsInfoState.myName; + setChats(chats => [...chats, { ...msg, isOwner: isOwner }]); + }); + + setChatSubscribeId(chatSubscribeId); + }; + + // 채팅구독끊기 + const unsubscribeChat = () => { + if (!socketClientState.current?.connected) return; + chatSubscribeId?.unsubscribe(); + }; + + // 채팅보내기 + const publishChat = (content: string) => { + if (!socketClientState.current?.connected) return; + + socketClientState.current.publish({ + destination: `/pub/chat/${auth}`, + body: JSON.stringify({ content: content }), + }); + }; + + const disConnect = () => { + socketClientState.current?.deactivate(); + }; + + // 웹소켓 연결 + useEffect(() => { + connect(); + return () => disConnect(); + }, []); + + // 채팅구독 + useEffect(() => { + if (gamesStatus.statusType === 'DAY') { + subscribeChat(); + } + return () => unsubscribeChat(); + }, [gamesStatus.statusType]); + + // 본래 채팅불러오기 + useEffect(() => { + (async () => { + const response = await getChats(); + setChats(response); + })(); + }, []); + + // 방 정보 저장 (방 상태가 바뀔때만 작동?) useEffect(() => { // 방 정보 불러오기 (async () => { @@ -63,7 +139,14 @@ export default function Game() { gamesStatus.statusType === 'NOTICE' || gamesStatus.statusType === 'DAY' || gamesStatus.statusType === 'VOTE' || - gamesStatus.statusType === 'VOTE_RESULT') && } + gamesStatus.statusType === 'VOTE_RESULT') && ( + + )} {(gamesStatus.statusType === 'NIGHT_INTRO' || gamesStatus.statusType === 'NIGHT') && ( )} diff --git a/src/pages/WaitingRoom.tsx b/src/pages/WaitingRoom.tsx index 88fce82..85d7adf 100644 --- a/src/pages/WaitingRoom.tsx +++ b/src/pages/WaitingRoom.tsx @@ -5,6 +5,7 @@ import { Toaster } from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import { getRoomsCode, startGame, useGamesInfoQuery } from '../axios/http'; +import { DOMAIN } from '../axios/instances'; import BigButton from '../components/button/BigButton'; import { Loading } from '../components/etc/Loading'; import AppContainerCSS from '../components/layout/AppContainerCSS'; @@ -66,7 +67,7 @@ export default function WaitingRoom() { const onShareLink = async () => { // 링크 공유s - const inviteLink = 'https://dev.mafia-together.com/api' + '/#/participate?code=' + code; + const inviteLink = DOMAIN + '/#/participate?code=' + code; const shareData = { url: inviteLink, }; diff --git a/src/recoil/roominfo/atom.ts b/src/recoil/roominfo/atom.ts index 08dea3f..e96acaf 100644 --- a/src/recoil/roominfo/atom.ts +++ b/src/recoil/roominfo/atom.ts @@ -1,6 +1,6 @@ import { atom } from 'recoil'; -import { Job, GameInfo } from '../../type'; +import { GameInfo, Job } from '../../type'; export const gameRound = atom({ key: 'gameRound', diff --git a/src/type/index.ts b/src/type/index.ts index fdcddb6..456e9fb 100644 --- a/src/type/index.ts +++ b/src/type/index.ts @@ -18,16 +18,25 @@ export interface GameStatus { statusType: Status; } +export type ChatArray = Chat[]; + export interface Chat { name: string; - contents: string; - timestamp: Date; + content: string; + timeStamp: Date; isOwner: boolean; job: Job; } +export interface ChatResponse { + name: string; + content: string; + timeStamp: Date; + job: Job; +} + export interface ChatRequest { - contents: string; + content: string; } export interface RoomResponse {