From 47968ff0d2cdf2d823572594cf5c2e613b1f5d24 Mon Sep 17 00:00:00 2001 From: Woosang <77317312+yws1502@users.noreply.github.com> Date: Wed, 10 Jul 2024 21:52:56 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9E=9C=EB=8D=A4=20=EB=A7=A4=EC=B9=AD=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ๐Ÿ’„ #194 - style ์ˆ˜์ • * โœจ #194 - audio ํƒœ๊ทธ ์ถ”๊ฐ€ ๋ฐ ์‚ฌ์šฉ์ž ์˜ค๋””์˜ค ๋ฐ์ดํ„ฐ ์กฐํšŒ ๋ฐ ์ฃผ์ž… ๋กœ์ง ์ถ”๊ฐ€ * โž• #194 - socket.io-client ํŒจํ‚ค์ง€ ์„ค์น˜ * โœจ #194 - ์‚ฌ์šฉ์ž ์ธ์ฆ ์ •๋ณด๋ฅผ ์กฐํšŒํ•˜๋Š” ํ›… ๊ฐœ๋ฐœ ๋ฐ ์ ์šฉ * ๐Ÿท๏ธ #194 - next auth์˜ session ํƒ€์ž… ์ˆ˜์ • - accessToken -> expires * ๐Ÿฉน #194 - ์‚ฌ์šฉ์ž ์ •๋ณด ์กฐํšŒ hook์—์„œ useEffect ์˜์กด์„ฑ ๋ฐฐ์—ด ์ˆ˜์ • - session -> session.status * โœจ #194 - ๋งค์นญ ์†Œ์ผ“ ์—ฐ๊ฒฐ ๋กœ์ง ์ถ”๊ฐ€ * โœจ #194 - ๋งค์นญ ์„ฑ๊ณต ์ด๋ฒคํŠธ ์ถ”๊ฐ€ ๋ฐ playing ํŽ˜์ด์ง€๋กœ redirection * ๐Ÿšš #194 - matching socket provider ์ด๋ฆ„๋ณ€๊ฒฝ - matchingSocketProvider -> matchingRTCProvider * โœจ #194 - webRTC์„ ํ™œ์šฉํ•œ ๋žœ๋ค ๋งค์นญ ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ * โ™ป๏ธ #194 - ๋žœ๋ค ๋งค์นญ ๊ธฐ๋Šฅ ๋ฆฌํŽ™ํ† ๋ง - socket, peerConnection ๋กœ์ง ๋ถ„๋ฆฌ * ๐Ÿ› #194 - ์˜ค๋””์˜ค ๊ถŒํ•œ์„ ์ฒ˜์Œ ์„ค์ •ํ•˜๋Š” ์œ ์ €์˜ ๊ฒฝ์šฐ ์˜ค๋””์˜ค ์ŠคํŠธ๋ฆผ์ด ์ „๋‹ฌ๋˜์ง€ ์•Š๋Š” ๋ฒ„๊ทธ ํ•ด๊ฒฐ * โ™ป๏ธ #194 - event ์ดˆ๊ธฐํ™” ๋กœ์ง ์ถ”๊ฐ€ * ๐Ÿšš #194 - matching ๊ด€๋ จ ํด๋ž˜์Šค๋ช… ๋ณ€๊ฒฝ - matchingRTC -> randomMatching * โœจ #194 - ๋งค์นญ์ด ์‹คํŒจ๋˜๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ์ฒ˜๋ฆฌ * ๐Ÿง‘โ€๐Ÿ’ป #194 - startSignaling ๊ฐœ๋ฐœ์ž ์˜ˆ์™ธ์ฒ˜๋ฆฌ ๋กœ์ง ์ถ”๊ฐ€ * โ™ป๏ธ #194 - ํŽ˜์ด์ง€ ๋ฆฌ๋‹ค์ด๋ ‰์…˜ ์ฃผ์†Œ ์ƒ์ˆ˜ ๋ณ€๊ฒฝ * ๐Ÿง‘โ€๐Ÿ’ป #194 - ๋งค์นญ ๊ด€๋ จ ์ƒ์ˆ˜ ์ •๋ฆฌ * ๐Ÿ”ฅ #194 - ๋ถˆํ•„์š”ํ•œ console.log ์ œ๊ฑฐ * ๐Ÿฉน #194 - matching playing ๋งค์นญ ์œ ์ € ์ •๋ณด ์ ์šฉ * โ™ป๏ธ #194 - ์†Œ์ผ“ IP, stun ์„œ๋ฒ„ ip ๋ชฉ๋ก ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์ฒ˜๋ฆฌ * โ™ป๏ธ #194 - inline ํ•จ์ˆ˜ ๋ถ„๋ฆฌ ๋ฐ PATH_PATH ์ƒ์ˆ˜ ์ฒ˜๋ฆฌ * โœ๏ธ #194 - ๋งค์นญ ๊ด€๋ จ ์†Œ์ผ“ ์ด๋ฒคํŠธ ๋ช… ์ˆ˜์ • * ๐Ÿšš #194 - ์ธ์ฆ ๊ด€๋ จ hook ์ด๋ฆ„ ๋ณ€๊ฒฝ * โ™ป๏ธ #194 - ์ธ์ฆ ๊ด€๋ จ hook session์˜ ๋ชจ๋“  ์ผ€์ด์Šค ์ฒ˜๋ฆฌ ๊ตฌ๋ฌธ ์ถ”๊ฐ€ * ๐Ÿท๏ธ #194 - matching ๊ด€๋ จ ์ •๋ณด ํƒ€์ž… key ๊ฐ’ ์ˆ˜์ • * ๐Ÿท๏ธ #194 - next auth session ํƒ€์ž… ํ™•์žฅ ์ ์šฉ * โœ๏ธ #194 - userInformation ๋ณ€์ˆ˜๋ช… ๋ณ€๊ฒฝ - userInformation -> user * โ™ป๏ธ #194 - Microphone ๊ด€๋ จ ๋ณ€์ˆ˜๋ช… ๋ฐ ๋ฐ˜ํ™˜ ์กฐ๊ฑด ๋ณ€๊ฒฝ - audioPermission -> MicrophonePermission * ๐Ÿ› #194 - ๋งค์นญ ์„ฑ๊ณต ์‹œ ์‚ฌ์šฉ์ž์˜ ์†Œ์ผ“, ์œ ์ € ์•„์ด๋””๊ฐ€ ๋„˜์–ด์˜ค์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ * โ™ป๏ธ #194 - ์†Œ์ผ“์œผ๋กœ ๋ฐ›์€ ๋ฐ์ดํ„ฐ ๊ตฌ์กฐ ๋ณ€๊ฒฝ * โœ๏ธ #194 - random matching ๊ด€๋ จ ๋ฉ”์†Œ๋“œ๋ช… ๋ณ€๊ฒฝ - startMatching -> joinQueue - startSignaling -> signaling * โœ๏ธ #194 - ๋งค์นญ ๊ด€๋ จ ๋ณ€์ˆ˜๋ช… ํ†ต์ผ - RandomMatching -> Matching * โ™ป๏ธ #194 - matching context ๊ตฌ์กฐ ๊ฐœ์„  * ๐Ÿ› #194 - matching/playing ํŽ˜์ด์ง€ ์ ‘๊ทผ ์‹œ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š” ๊ฒฝ์šฐ ์˜ˆ์™ธ์ฒ˜๋ฆฌ * ๐Ÿฉน #194 - ๋งค์นญ ๊ด€๋ จ ์ปดํฌ๋„ŒํŠธ์—์„œ router ์ด๋™ ์‹œ ๋ฉ”์†Œ๋“œ ๋ณ€๊ฒฝ - push -> replace * ๐Ÿšธ #194 - ๋งค์นญ ํŽ˜์ด์ง€์—์„œ ํŽ˜์ด์ง€ ์ด๋™ ์‹œ ๋งค์นญ ์ข…๋ฃŒ ์•Œ๋ฆผ์ฐฝ ์ ์šฉ * ๐Ÿ”ฅ #194 - useAuthentication ํ›…์œผ๋กœ ๋Œ€์ฒด๋กœ ์ธํ•œ useUser ํ›… ์ œ๊ฑฐ * ๐Ÿšš #194 - ๋งค์นญ ๋Œ€๊ธฐ์—ด, ๋งค์นญ ์™„๋ฃŒ ํŽ˜์ด์ง€ ์ด๋ฆ„(route path) ๋ณ€๊ฒฝ --- package.json | 2 + .../matching/MatchingController.tsx | 112 +++++++++-- src/components/matching/MatchingUserInfo.tsx | 18 +- src/constants/common/page.ts | 3 +- src/constants/matching/index.ts | 2 + src/constants/matching/message.ts | 5 + src/constants/matching/socketEvent.ts | 14 ++ src/constants/modal/message.ts | 1 + src/contexts/MatchingProvider.tsx | 30 +++ src/hooks/common/useUser.ts | 31 --- .../services/common/useAuthentication.ts | 34 ++++ src/pages/_app.tsx | 5 +- src/pages/matching/index.tsx | 8 +- .../matching/{playing.tsx => match-up.tsx} | 25 ++- src/pages/matching/{loading.tsx => queue.tsx} | 41 +++- src/types/authentication.ts | 12 ++ src/types/matching.ts | 6 + src/types/next-auth.d.ts | 3 +- src/utils/Matching.ts | 179 ++++++++++++++++++ src/utils/index.ts | 1 + yarn.lock | 58 +++++- 21 files changed, 517 insertions(+), 73 deletions(-) create mode 100644 src/constants/matching/index.ts create mode 100644 src/constants/matching/message.ts create mode 100644 src/constants/matching/socketEvent.ts create mode 100644 src/contexts/MatchingProvider.tsx delete mode 100644 src/hooks/common/useUser.ts create mode 100644 src/hooks/services/common/useAuthentication.ts rename src/pages/matching/{playing.tsx => match-up.tsx} (50%) rename src/pages/matching/{loading.tsx => queue.tsx} (70%) create mode 100644 src/types/authentication.ts create mode 100644 src/utils/Matching.ts 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"