diff --git a/package.json b/package.json index 0ddc9d6d..6a7b51d2 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,13 @@ "react-dom": "18.2.0", "react-hook-form": "^7.43.7", "react-tooltip": "^5.11.1", + "socket.io-client": "^4.7.4", "typescript": "4.9.4" }, "devDependencies": { "@next/eslint-plugin-next": "^13.1.1", "@types/react-calendar-heatmap": "^1.6.3", + "@types/socket.io-client": "^3.0.0", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", "eslint-config-prettier": "^8.5.0", diff --git a/src/components/matching/MatchingController.tsx b/src/components/matching/MatchingController.tsx index 31780c7a..ce067fe7 100644 --- a/src/components/matching/MatchingController.tsx +++ b/src/components/matching/MatchingController.tsx @@ -1,23 +1,90 @@ import styled from '@emotion/styled'; - +import { useRouter } from 'next/router'; +import { useEffect, useRef } from 'react'; +import type { MatchingInformation } from 'types/matching'; import { MicrophoneOffIcon, EndCallIcon } from 'assets/icons'; +import { AlertModal } from 'components/common'; +import { PAGE_PATH } from 'constants/common'; import { colors } from 'constants/styles'; +import { useMatching } from 'contexts/MatchingProvider'; +import { useModal } from 'hooks/common'; import { ScreenReaderOnly } from 'styles'; const MatchingController = () => { + const router = useRouter(); + + const audioRef = useRef(null); + + const matching = useMatching(); + + const { isVisible, handleModal } = useModal(); + + const signaling = async () => { + const { query } = router; + + const { current: audioElement } = audioRef; + + if (audioElement === null) return; + + try { + await matching.signaling(audioElement, { + role: query.r as MatchingInformation['role'], + socketId: query.ms as MatchingInformation['socketId'], + userId: query.mu as MatchingInformation['userId'], + }); + } catch (error) { + handleModal.open(); + } + }; + + useEffect(() => { + void signaling(); + + return () => { + matching.disconnect(); + }; + }, []); + + const handleEndMatching = () => { + // FIXME: 매칭 설문 페이지로 이동할 예정입니다. + matching.disconnect(); + void router.replace(PAGE_PATH.main); + }; + + const handleCloseAlert = () => { + handleModal.close(); + void router.replace(PAGE_PATH.main); + }; + return ( - - 통화 제어 - - 마이크를 켜주세요! - - 마이크 off - - - - 통화 종료 - - + <> + + 통화 제어 + + + 마이크를 켜주세요! + + 마이크 off + + + + 통화 종료 + + + {/* FIXME: 디자인이 없어 임시로 디자인한 모달입니다. 추후 변경 예정 */} + + +

의도하지 않은 에러가 발생했습니다.

+

메인 페이지도 이동합니다.

+
+
+ ); }; @@ -37,24 +104,21 @@ const CircleButton = styled.button<{ backgroundColor: string; }>` position: relative; + display: flex; + justify-content: center; + align-items: center; width: 60px; height: 60px; border-radius: 100%; background-color: ${(props) => props.backgroundColor}; - svg { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - } span { + ${({ theme }) => theme.fonts.body_07} position: absolute; bottom: -26px; left: 50%; transform: translateX(-50%); width: 60px; color: ${({ theme }) => theme.colors.gray_00}; - ${({ theme }) => theme.fonts.body_07} } `; @@ -79,3 +143,11 @@ const Tooltip = styled.div` border-top-color: ${({ theme }) => theme.colors.primary_00}; } `; + +const ModalContent = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 40px 32px 30px; +`; diff --git a/src/components/matching/MatchingUserInfo.tsx b/src/components/matching/MatchingUserInfo.tsx index 82459404..06df0ab1 100644 --- a/src/components/matching/MatchingUserInfo.tsx +++ b/src/components/matching/MatchingUserInfo.tsx @@ -1,24 +1,36 @@ import styled from '@emotion/styled'; import Image from 'next/image'; +import { useRouter } from 'next/router'; +import { DEFAULT_PROFILE_IMAGES } from 'constants/profile'; import { useTimer } from 'hooks/common/useTimer'; +import { useProfile } from 'hooks/services'; import { ScreenReaderOnly } from 'styles'; const MatchingUserInfo = () => { const { minutes, seconds } = useTimer(); + const router = useRouter(); + + const { query } = router; + + const { profileData: matchingUserProfile } = useProfile( + query.mu as string, // matching username + ); + return ( 사용자 프로필 + {/* FIXME: useProfile의 isLoading으로 스켈레톤 UI로 대체 논의 */} 프로필 사진 - username + {matchingUserProfile?.username} {minutes}:{seconds} diff --git a/src/constants/common/page.ts b/src/constants/common/page.ts index 65e886be..4bb02fad 100644 --- a/src/constants/common/page.ts +++ b/src/constants/common/page.ts @@ -3,7 +3,8 @@ export const PAGE_PATH = { matching: { index: '/matching', - loading: '/matching/loading', + queue: '/matching/queue', + matchUp: '/matching/match-up', }, diary: { diff --git a/src/constants/matching/index.ts b/src/constants/matching/index.ts new file mode 100644 index 00000000..ac0798c2 --- /dev/null +++ b/src/constants/matching/index.ts @@ -0,0 +1,2 @@ +export * from './socketEvent'; +export * from './message'; diff --git a/src/constants/matching/message.ts b/src/constants/matching/message.ts new file mode 100644 index 00000000..c1c62735 --- /dev/null +++ b/src/constants/matching/message.ts @@ -0,0 +1,5 @@ +export const EXCEPTION_MESSAGE = { + rejectMicrophone: + '매칭 서비스 이용을 위해 마이크 권한을 설정해주세요.\nChrome 우측 상단 더보기 > 설정 > 개인 정보 및 보안 > 사이트 설정 > 마이크에서 설정할 수 있습니다.\n\n메인 페이지로 이동합니다.', + failedMatching: '랜덤 매칭에 실패하였습니다.\n메인 페이지로 이동합니다.', +} as const; diff --git a/src/constants/matching/socketEvent.ts b/src/constants/matching/socketEvent.ts new file mode 100644 index 00000000..6182cb64 --- /dev/null +++ b/src/constants/matching/socketEvent.ts @@ -0,0 +1,14 @@ +export const MATCHING_SOCKET_EVENT = { + client: { + joinQueue: 'joinQueue', + offer: 'offer', + answer: 'answer', + ice: 'ice', + }, + server: { + success: 'success', + offer: 'offer', + answer: 'answer', + ice: 'ice', + }, +} as const; diff --git a/src/constants/modal/message.ts b/src/constants/modal/message.ts index fe79553b..28b3f106 100644 --- a/src/constants/modal/message.ts +++ b/src/constants/modal/message.ts @@ -2,6 +2,7 @@ export const MODAL_MESSAGE = { beforeLeave: '등록하지 않고 나갈 시 작성한 일기가 모두 삭제됩니다. 나가시겠습니까?', delete: '삭제하시겠습니까?', + leaveMatching: '페이지 이동 시 매칭이 종료될 수 있습니다. 이동하시겠습니까?', }; export const MODAL_BUTTON = { diff --git a/src/contexts/MatchingProvider.tsx b/src/contexts/MatchingProvider.tsx new file mode 100644 index 00000000..309dedb8 --- /dev/null +++ b/src/contexts/MatchingProvider.tsx @@ -0,0 +1,30 @@ +import { createContext, useContext, useRef } from 'react'; +import type { ReactNode } from 'react'; +import { Matching } from 'utils'; + +const MatchingContext = createContext(null); + +interface MatchingProviderProps { + children: ReactNode; +} + +const MatchingProvider = ({ children }: MatchingProviderProps) => { + const { current: matching } = useRef(new Matching()); + + return ( + + {children} + + ); +}; + +export const useMatching = () => { + const context = useContext(MatchingContext); + + if (context === null) { + throw new Error('MatchingContext must be used within a MatchingProvider'); + } + return context; +}; + +export default MatchingProvider; diff --git a/src/hooks/common/useUser.ts b/src/hooks/common/useUser.ts deleted file mode 100644 index 999837ac..00000000 --- a/src/hooks/common/useUser.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useRouter } from 'next/router'; -import { useSession } from 'next-auth/react'; -import type { User } from 'next-auth'; -import { PAGE_PATH } from 'constants/common'; - -interface LoggedStatus { - user: User | null; - isLoading: boolean; - isLoggedIn: boolean; -} - -export const useUser = (): LoggedStatus => { - const router = useRouter(); - const { data: session, status } = useSession(); - - const isLoading = status === 'loading'; - const isAuthenticated = status === 'authenticated'; - const isUnauthenticated = status === 'authenticated'; - - const useData = session !== null ? session?.user : null; - - if (isUnauthenticated) { - void router.push(PAGE_PATH.account.login); - } - - return { - user: useData, - isLoading, - isLoggedIn: isAuthenticated, - }; -}; diff --git a/src/hooks/services/common/useAuthentication.ts b/src/hooks/services/common/useAuthentication.ts new file mode 100644 index 00000000..f968272a --- /dev/null +++ b/src/hooks/services/common/useAuthentication.ts @@ -0,0 +1,34 @@ +import { useRouter } from 'next/router'; +import { useSession } from 'next-auth/react'; +import { useEffect, useState } from 'react'; +import type { Authentication } from 'types/authentication'; +import { PAGE_PATH } from 'constants/common'; + +export const useAuthentication = () => { + const router = useRouter(); + + const session = useSession({ + required: true, + onUnauthenticated: () => { + void router.replace(PAGE_PATH.account.login); + alert('인증이 필요한 페이지입니다.'); // FIXME: 문구 변경 예정입니다. + }, + }); + + const [authentication, setAuthentication] = useState({ + user: undefined, + status: 'loading', + }); + + useEffect(() => { + if (session.status === 'loading') return; + + setAuthentication({ + update: session.update, + user: session.data.user, + status: 'authenticated', + }); + }, [session.status]); + + return authentication; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5738e697..279fb4e2 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -9,6 +9,7 @@ import { SessionProvider } from 'next-auth/react'; import { useState } from 'react'; import type { AppProps } from 'next/app'; import { Layout } from 'components/layouts'; +import MatchingProvider from 'contexts/MatchingProvider'; import { theme, GlobalStyle } from 'styles'; export default function App({ Component, pageProps }: AppProps) { @@ -25,7 +26,9 @@ export default function App({ Component, pageProps }: AppProps) { - + + + diff --git a/src/pages/matching/index.tsx b/src/pages/matching/index.tsx index 6d62047e..85647883 100644 --- a/src/pages/matching/index.tsx +++ b/src/pages/matching/index.tsx @@ -14,6 +14,10 @@ import { ScreenReaderOnly } from 'styles'; const MatchingRule: NextPage = () => { const router = useRouter(); + const handleGoToMatchingQueue = () => { + void router.push(PAGE_PATH.matching.queue); + }; + return ( <> @@ -65,9 +69,7 @@ const MatchingRule: NextPage = () => { type="button" shape="round" size="xl" - onClick={async () => { - await router.push(PAGE_PATH.matching.loading); - }} + onClick={handleGoToMatchingQueue} text="랜덤매칭 시작" /> diff --git a/src/pages/matching/playing.tsx b/src/pages/matching/match-up.tsx similarity index 50% rename from src/pages/matching/playing.tsx rename to src/pages/matching/match-up.tsx index f3dffa88..28b55a74 100644 --- a/src/pages/matching/playing.tsx +++ b/src/pages/matching/match-up.tsx @@ -1,14 +1,24 @@ import styled from '@emotion/styled'; import type { NextPage } from 'next'; - -import { Seo } from 'components/common'; +import { ConfirmModal, Seo } from 'components/common'; import MatchingController from 'components/matching/MatchingController'; import MatchingUserInfo from 'components/matching/MatchingUserInfo'; import RecommendTopic from 'components/matching/RecommendTopic'; +import { MODAL_BUTTON, MODAL_MESSAGE } from 'constants/modal'; +import { useBeforeLeave, useModal } from 'hooks/common'; import { ScreenReaderOnly } from 'styles'; -const MatchingPlaying: NextPage = () => { +const MatchUp: NextPage = () => { + const { + isVisible: isVisibleBeforeLeave, + handleModal: handleBeforeLeaveModal, + } = useModal(); + + const { handleRouterBack } = useBeforeLeave({ + beforeLeaveCallback: handleBeforeLeaveModal.open, + }); + return ( <> @@ -18,11 +28,18 @@ const MatchingPlaying: NextPage = () => { + ); }; -export default MatchingPlaying; +export default MatchUp; const Title = styled.h1` ${ScreenReaderOnly} diff --git a/src/pages/matching/loading.tsx b/src/pages/matching/queue.tsx similarity index 70% rename from src/pages/matching/loading.tsx rename to src/pages/matching/queue.tsx index f3b514ea..9bc6db64 100644 --- a/src/pages/matching/loading.tsx +++ b/src/pages/matching/queue.tsx @@ -1,25 +1,52 @@ import styled from '@emotion/styled'; - import Image from 'next/image'; import { useRouter } from 'next/router'; - -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import type { NextPage } from 'next'; - import type { LoadingAnimationKey } from 'types/common'; +import type { MatchingInformation } from 'types/matching'; import { loadingAnimation } from 'animation'; import { Button } from 'components/common'; import { PAGE_PATH } from 'constants/common'; +import { useMatching } from 'contexts/MatchingProvider'; +import { useAuthentication } from 'hooks/services/common/useAuthentication'; -const MatchingLoading: NextPage = () => { +const MatchingQueue: NextPage = () => { const router = useRouter(); + const matching = useMatching(); + + const { user } = useAuthentication(); + const [isCancel, setIsCancel] = useState(false); + useEffect(() => { + if (user === undefined) return; + + void matching.joinQueue({ + user, + onSuccess: (matchingInformation: MatchingInformation) => { + void router.replace({ + pathname: PAGE_PATH.matching.matchUp, + query: { + r: matchingInformation.role, + ms: matchingInformation.socketId, + mu: matchingInformation.userId, + }, + }); + }, + onError: (message: string) => { + alert(message); + void router.replace(PAGE_PATH.main); + }, + }); + }, [user]); + const cancelMatching = () => { + matching.disconnect(); setIsCancel(true); setTimeout(async () => { - await router.push(PAGE_PATH.matching.index); + await router.replace(PAGE_PATH.matching.index); }, 2000); }; @@ -66,7 +93,7 @@ const MatchingLoading: NextPage = () => { ); }; -export default MatchingLoading; +export default MatchingQueue; const Section = styled.section` text-align: center; diff --git a/src/types/authentication.ts b/src/types/authentication.ts new file mode 100644 index 00000000..a8eff26d --- /dev/null +++ b/src/types/authentication.ts @@ -0,0 +1,12 @@ +import type { Session, User } from 'next-auth'; + +export type Authentication = + | { + user: undefined; + status: 'loading'; + } + | { + update: (data?: User) => Promise; + user: User; + status: 'authenticated'; + }; diff --git a/src/types/matching.ts b/src/types/matching.ts index 495cfc48..c68095c5 100644 --- a/src/types/matching.ts +++ b/src/types/matching.ts @@ -10,3 +10,9 @@ export interface MatchingFeedbackForm { message: string; isBlockedMatching: boolean; } + +export interface MatchingInformation { + role: 'offer' | 'answer'; + socketId: string; + userId: string; +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index c07f791a..e873a534 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -10,9 +10,8 @@ declare module 'next-auth' { isAdmin: boolean; accessToken: string; } - interface Session { + interface Session extends DefaultSession { user: User; - accessToken: string; } } diff --git a/src/utils/Matching.ts b/src/utils/Matching.ts new file mode 100644 index 00000000..0c947331 --- /dev/null +++ b/src/utils/Matching.ts @@ -0,0 +1,179 @@ +import { io } from 'socket.io-client'; +import type { User } from 'next-auth'; +import type { Socket } from 'socket.io-client'; +import type { MatchingInformation } from 'types/matching'; +import { MATCHING_SOCKET_EVENT, EXCEPTION_MESSAGE } from 'constants/matching'; + +export class Matching { + public socket: Socket | null = null; + + public peer: RTCPeerConnection | null = null; + + private audioStream: MediaStream | null = null; + + private async canUseMicrophone() { + try { + this.audioStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + const microphonePermission = await navigator.permissions.query({ + name: 'microphone' as PermissionName, + }); + + return microphonePermission.state === 'granted'; + } catch (error) { + return false; + } + } + + public async joinQueue({ + user, + onSuccess, + onError, + }: { + user: Pick; + onSuccess: (matchingInformation: MatchingInformation) => void; + onError: (message: string) => void; + }) { + const canUseMicrophone = await this.canUseMicrophone(); + + if (!canUseMicrophone) { + onError(EXCEPTION_MESSAGE.rejectMicrophone); + return; + } + + try { + const matchingSocketUrl = process.env.NEXT_PUBLIC_SOCKET_URL; + + const stunServers = process.env.NEXT_PUBLIC_STUN_SERVERS?.split(','); + + if (matchingSocketUrl === undefined) + throw new Error('invalid socket url'); + + this.socket = io(matchingSocketUrl); + + this.peer = new RTCPeerConnection({ + iceServers: [{ urls: stunServers ?? [] }], + }); + + this.socket.emit(MATCHING_SOCKET_EVENT.client.joinQueue, user); + + this.socket.on( + MATCHING_SOCKET_EVENT.server.success, + (matchingInformation: MatchingInformation) => { + onSuccess(matchingInformation); + }, + ); + } catch (error) { + console.log(error); + onError(EXCEPTION_MESSAGE.failedMatching); + } + } + + public async signaling( + audioElement: HTMLAudioElement, + matchingInformation: MatchingInformation, + ) { + if ( + this.socket === null || + this.peer === null || + this.audioStream === null + ) { + throw new Error( + '개발자 에러: signaling 메소드 호출 이전에 joinQueue 메소드가 먼저 호출되어야 합니다.', + ); + } + + const { role, socketId } = matchingInformation; + + this.audioStream.getTracks().forEach((track) => { + if (this.audioStream === null) return; + + this.peer?.addTrack(track, this.audioStream); + }); + + if (role === 'offer') { + const offer = await this.peer?.createOffer(); + + await this.peer?.setLocalDescription(offer); + + // [send event] offer -> answer + this.socket.emit(MATCHING_SOCKET_EVENT.client.offer, { + answerSocket: socketId, + offer, + }); + } + + // [received event] answer <- offer + this.socket.on( + MATCHING_SOCKET_EVENT.server.offer, + async (offer: RTCSessionDescriptionInit) => { + await this.peer?.setRemoteDescription(offer); + + const answer = await this.peer?.createAnswer(); + + await this.peer?.setLocalDescription(answer); + + if (this.socket !== null) { + // [send event] answer -> offer + this.socket.emit(MATCHING_SOCKET_EVENT.client.answer, { + offerSocket: socketId, + answer, + }); + } + }, + ); + + // [received event] offer <- answer + this.socket.on( + MATCHING_SOCKET_EVENT.server.answer, + async (answer: RTCSessionDescriptionInit) => { + await this.peer?.setRemoteDescription(answer); + }, + ); + + // [received event] offer <-> answer (complete signaling) + this.socket.on( + MATCHING_SOCKET_EVENT.server.ice, + async (candidate: RTCIceCandidateInit) => { + await this.peer?.addIceCandidate(candidate); + }, + ); + + this.peer.onicecandidate = ({ candidate }: RTCPeerConnectionIceEvent) => { + // [send event] offer <-> answer + this.socket?.emit(MATCHING_SOCKET_EVENT.client.ice, { + matchingSocket: socketId, + candidate, + }); + }; + + this.peer.ontrack = (trackEvent: RTCTrackEvent) => { + audioElement.srcObject = trackEvent.streams[0]; + }; + } + + public disconnect() { + if (this.socket === null || this.peer === null || this.audioStream === null) + return; + + this.socket.removeAllListeners(MATCHING_SOCKET_EVENT.server.offer); + this.socket.removeAllListeners(MATCHING_SOCKET_EVENT.server.answer); + this.socket.removeAllListeners(MATCHING_SOCKET_EVENT.server.ice); + this.socket.disconnect(); + this.socket = null; + + this.peer.onicecandidate = null; + this.peer.ontrack = null; + this.peer.getSenders().forEach((sender) => { + this.peer?.removeTrack(sender); + }); + this.peer.close(); + this.peer = null; + + this.audioStream.getTracks().forEach((track) => { + track.stop(); + }); + } +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 95d19b34..b9b4e9ff 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './date'; export * from './ErrorResponseMessage'; export * from './Formatter'; +export * from './Matching'; export * from './query'; export { default as textareaAutosize } from './TextareaAutosize'; diff --git a/yarn.lock b/yarn.lock index deddcfa7..cf40fbe0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1326,6 +1326,11 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + "@svgr/babel-plugin-add-jsx-attribute@^6.5.1": version "6.5.1" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz#74a5d648bd0347bda99d82409d87b8ca80b9a1ba" @@ -1524,6 +1529,13 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.13.tgz#da4bfd73f49bd541d28920ab0e2bf0ee80f71c91" integrity sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw== +"@types/socket.io-client@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@types/socket.io-client/-/socket.io-client-3.0.0.tgz#d0b8ea22121b7c1df68b6a923002f9c8e3cefb42" + integrity sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg== + dependencies: + socket.io-client "*" + "@typescript-eslint/eslint-plugin@^5.47.1": version "5.51.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.51.0.tgz#da3f2819633061ced84bb82c53bba45a6fe9963a" @@ -1998,7 +2010,7 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: +debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2122,6 +2134,22 @@ emotion-reset@^3.0.1: resolved "https://registry.yarnpkg.com/emotion-reset/-/emotion-reset-3.0.1.tgz#1445e66bab7e8fd9975ce0d8cd3324589981f0a6" integrity sha512-v6scW83qSu+wtxg7lX1s0+/2U4EAAGFxDQMkvXE10jhKtyuXCzy3/su5/MU9ZjXeNv6ZjxZH51WktrKosKUy9g== +engine.io-client@~6.5.2: + version "6.5.3" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.3.tgz#4cf6fa24845029b238f83c628916d9149c399bc5" + integrity sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + xmlhttprequest-ssl "~2.0.0" + +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== + enhanced-resolve@^5.10.0: version "5.12.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" @@ -3774,6 +3802,24 @@ slash@^4.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +socket.io-client@*, socket.io-client@^4.7.4: + version "4.7.4" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.4.tgz#5f0e060ff34ac0a4b4c5abaaa88e0d1d928c64c8" + integrity sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.2" + engine.io-client "~6.5.2" + socket.io-parser "~4.2.4" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -4098,6 +4144,16 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@~8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== + +xmlhttprequest-ssl@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67" + integrity sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"