diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d966a717..4b349a74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,12 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: npm ci + + - name: For CRDT package directory + shell: bash + run: | + cd @wabinar/crdt + npm test - name: For client directory run: | diff --git a/.gitignore b/.gitignore index 05af491d..53bd236c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # ignore secret keys deploy-scripts/vault-secrets +# ignore jest test coverage directories +**/coverage + # ignore settings for yarn berry zero install .yarn/* !.yarn/cache diff --git a/@wabinar/api-types/block.ts b/@wabinar/api-types/block.ts new file mode 100644 index 00000000..817559af --- /dev/null +++ b/@wabinar/api-types/block.ts @@ -0,0 +1,62 @@ +import { BlockType } from '@wabinar/constants/block'; +import LinkedList, { + RemoteDeleteOperation, + RemoteInsertOperation, +} from '@wabinar/crdt/linked-list'; + +export interface LoadType { + id: string; +} + +export interface LoadedType { + type: BlockType; +} + +export interface UpdateType { + id: string; + type: BlockType; +} + +export interface UpdatedType { + id: string; + type: BlockType; +} + +export interface InitText { + id: string; +} + +export interface InitializedText { + id: string; + crdt: LinkedList; +} + +export interface InsertText { + id: string; + op: RemoteInsertOperation; +} + +export interface InsertedText { + id: string; + op: RemoteInsertOperation; +} + +export interface DeleteText { + id: string; + op: RemoteDeleteOperation; +} + +export interface DeletedText { + id: string; + op: RemoteDeleteOperation; +} + +export interface UpdateText { + id: string; + ops: RemoteInsertOperation[]; +} + +export interface UpdatedText { + id: string; + crdt: LinkedList; +} diff --git a/@wabinar/api-types/mom.ts b/@wabinar/api-types/mom.ts new file mode 100644 index 00000000..4f82e9be --- /dev/null +++ b/@wabinar/api-types/mom.ts @@ -0,0 +1,52 @@ +import LinkedList, { + RemoteDeleteOperation, + RemoteInsertOperation, +} from '@wabinar/crdt/linked-list'; + +export type Mom = { + _id: string; + title: string; + createdAt: Date; +}; + +export interface Created { + mom: Mom; +} + +export interface Select { + id: string; +} + +export interface Selected { + mom: Mom; +} + +export interface UpdateTitle { + title: string; +} + +export interface UpdatedTitle { + title: string; +} + +export interface Initialized { + crdt: LinkedList; +} + +export interface InsertBlock { + blockId: string; + op: RemoteInsertOperation; +} + +export interface InsertedBlock { + op: RemoteInsertOperation; +} + +export interface DeleteBlock { + blockId: string; + op: RemoteDeleteOperation; +} + +export interface DeletedBlock { + op: RemoteDeleteOperation; +} diff --git a/@wabinar/constants/socket-message.ts b/@wabinar/constants/socket-message.ts index 734cb001..b7f9f498 100644 --- a/@wabinar/constants/socket-message.ts +++ b/@wabinar/constants/socket-message.ts @@ -9,7 +9,11 @@ export const WORKSPACE_EVENT = { RECEIVE_ANSWER: 'receive-answer', SEND_ICE: 'send-ice', RECEIVE_ICE: 'receive-ice', - RECEIVE_BYE: 'receive_bye', + AUDIO_STATE_CHANGED: 'audio-state-changed', + VIDEO_STATE_CHANGED: 'video-state-changed', + SEND_BYE: 'send-bye', + RECEIVE_BYE: 'receive-bye', + EXISTING_ROOM_USERS: 'existing-room-users', }; export const MOM_EVENT = { @@ -20,6 +24,8 @@ export const MOM_EVENT = { INSERT_BLOCK: 'insert-block', DELETE_BLOCK: 'delete-block', UPDATED: 'updated-mom', + LOADED: 'loaded-mom', + REQUEST_LOADED: 'request-loaded', }; export const BLOCK_EVENT = { diff --git a/@wabinar/crdt/jest.config.js b/@wabinar/crdt/jest.config.js index 5e854435..3537e5d0 100644 --- a/@wabinar/crdt/jest.config.js +++ b/@wabinar/crdt/jest.config.js @@ -1,5 +1,5 @@ module.exports = { preset: 'ts-jest', // to use typescript verbose: true, - modulePathIgnorePatterns: ['/dist/'], + collectCoverage: true, }; diff --git a/README.md b/README.md index 590e1874..f299950a 100644 --- a/README.md +++ b/README.md @@ -7,104 +7,215 @@

๐Ÿ’ป ํšŒ์˜์™€ ๊ธฐ๋ก์„ ํ•œ๋ฒˆ์— ๐Ÿ“

+
+

- ๋ฐฐํฌ ์„œ๋ฒ„ -   |   - ๋…ธ์…˜ -   |   - ์œ„ํ‚ค -   |   - ๋ฐฑ๋กœ๊ทธ -   |   - ๊ธฐํš์„œ +https://www.wabinar.live

-
- -
+
-

ํ”„๋กœ์ ํŠธ ์†Œ๊ฐœ

+

๐Ÿคทย ํ•˜๋‚˜์˜ ํ”Œ๋žซํผ์—์„œ ๋งํ•˜๊ณ  ๋˜ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์œผ๋ฉด ์˜ค๊ฐ€๋Š” ๋…ผ์˜๋“ค์ด ๋” ์ž˜ ๊ธฐ๋ก๋  ์ˆ˜ ์žˆ์ง€ ์•Š์„๊นŒ?

+

๋น„๋Œ€๋ฉด ํ™”์ƒ ํšŒ์˜๋ฅผ ์—ฌ๋Ÿฌ ์ฐจ๋ก€ ์ง„ํ–‰ํ•˜๋ฉด์„œ ํŒ€์›๋“ค์ด ๊ณตํ†ต์œผ๋กœ ๋Š๋‚€ ๋ฌธ์ œ์ ๋“ค์ด ์žˆ์—ˆ์–ด์š”. +ํšŒ์˜์™€ ๊ธฐ๋ก์ด ์„œ๋กœ ๋‹ค๋ฅธ ํ”Œ๋žซํผ์—์„œ ์ด๋ฃจ์–ด์ง€๋‹ค๋ณด๋‹ˆ ๊ธฐ๋ก๋˜์ง€ ๋ชปํ•˜๊ณ  ํœ˜๋ฐœ๋˜๋Š” ์ด์•ผ๊ธฐ๋“ค์ด ๋งŽ์•˜์–ด์š”. + +

+
-> ๐Ÿ’ก **ํ•˜๋‚˜์˜ ํ”Œ๋žซํผ์—์„œ ๋งํ•˜๊ณ  ๋˜ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์œผ๋ฉด ํŽธ๋ฆฌํ•˜์ง€ ์•Š์„๊นŒ** +**ํšŒ์˜๊ฐ€ ๋๋‚˜๋„ ์ƒ๊ฐ์€ ๋‚จ์•„์žˆ์„ ์ˆ˜ ์žˆ๊ฒŒ** -> ๐Ÿ’ก **๊ณต์œ ๋ณด๋“œ๋ฅผ ์ œ๊ณตํ•ด์„œ ์˜ค๊ฐ€๋Š” ์ด์•ผ๊ธฐ๋“ค์„ ๊ธฐ๋กํ•˜๋„๋ก ์œ ๋„ํ•  ์ˆ˜ ์žˆ์ง€ ์•Š์„๊นŒ** +**Wabinar๋Š” ๋น„๋Œ€๋ฉด ํšŒ์˜์— ๊ธฐ๋ก์ด๋ผ๋Š” ์š”์†Œ๋ฅผ ์ถ”๊ฐ€ํ–ˆ์–ด์š”.** -> ๐Ÿ’ก **ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ๋ฐœ์ œ๋กœ ๋น„๋””์˜ค/๋ณด์ด์Šค ๊ธฐ๋ฐ˜ ์†Œํ†ต์˜ ์ง„์ž…์žฅ๋ฒฝ์„ ์กฐ๊ธˆ์€ ํ—ˆ๋ฌผ ์ˆ˜ ์žˆ์ง€ ์•Š์„๊นŒ** +

-**Wabinar** ๋Š” ๋น„๋Œ€๋ฉด ํšŒ์˜ ํ™˜๊ฒฝ์— ๊ธฐ๋ก์ด๋ผ๋Š” ์š”์†Œ๋ฅผ ๋„ฃ์Œ์œผ๋กœ์จ ๋” ์ข‹์€ ํšŒ์˜๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์„ ๊ฒƒ์œผ๋กœ ๊ธฐ๋Œ€ํ•ด์š”. +

+ ๋…ธ์…˜ +   |   + ๋ฐฑ๋กœ๊ทธ +   |   + ๊ธฐํš์„œ +   |   + ์œ„ํ‚ค +

-
+

+ ๋ฐ๋ชจ +   |   + ์ตœ์ข… ๋ฐœํ‘œ +

+
+ +
-## ๋ฐ๋ชจ ์˜์ƒ +## ์ฃผ์š” ๊ธฐ๋Šฅ -(์ถ”ํ›„ ์—…๋ฐ์ดํŠธ) +### ๐Ÿง‘โ€๐Ÿคโ€๐Ÿง‘ย ์›Œํฌ์ŠคํŽ˜์ด์Šค -## ๊ธฐ๋Šฅ +> ์›ํ•˜๋Š” ์‚ฌ๋žŒ๋“ค๊ณผ ์›Œํฌ์ŠคํŽ˜์ด์Šค๋ฅผ ๋งŒ๋“ค์–ด **ํšŒ์˜**ํ•˜๊ณ  **ํšŒ์˜ ๊ธฐ๋ก์„ ๊ด€๋ฆฌ**ํ•ด์š”. -(์ถ”ํ›„ ์—…๋ฐ์ดํŠธ) +### ๐ŸŽฅย ํ™”์ƒ ํšŒ์˜ -## ๊ธฐ์ˆ  ์Šคํƒ (ํ ์•„๋‹˜) +> ์›Œํฌ์ŠคํŽ˜์ด์Šค ๋ฉค๋ฒ„๋“ค๊ณผ **ํ™”์ƒ ํšŒ์˜**๋ฅผ ์ง„ํ–‰ํ•  ์ˆ˜ ์žˆ์–ด์š”. -
-

Common

- - - - - - - -
+ + + + + + + + + +
+ + + +
+

์›Œํฌ์ŠคํŽ˜์ด์Šค ๊ธฐ๋Šฅ

+
+

ํ™”์ƒ ํšŒ์˜ ๊ธฐ๋Šฅ

+
-
-

FE

- - - - -
+### โœ๏ธย ์‹ค์‹œ๊ฐ„ ๊ณต์œ  ํšŒ์˜๋ก -
-

BE

- - - - - -
+> ์›Œํฌ์ŠคํŽ˜์ด์Šค ๋ฉค๋ฒ„๋“ค๊ณผ ํšŒ์˜๋ก์„ **์‹ค์‹œ๊ฐ„ ๊ณต๋™ ํŽธ์ง‘**ํ•  ์ˆ˜ ์žˆ์–ด์š”. +> +> ํšŒ์˜๋ก ์—๋””ํ„ฐ์—์„œ **์—ฌ๋Ÿฌ๊ฐ€์ง€ ๋ธ”๋Ÿญ**๋“ค์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์š”. -
-

CI/CD

- -
+ + + + + + + + + + + + + + + + + + + -
-

Deployment

- - -
+
+ + + +
+

์‹ค์‹œ๊ฐ„ ๊ณต๋™ ํŽธ์ง‘

+
+

ํ…์ŠคํŠธ ๋ธ”๋Ÿญ

+
+ + + +
+

์งˆ๋ฌธ ๋ธ”๋Ÿญ

+
+

ํˆฌํ‘œ ๋ธ”๋Ÿญ

+
+ +
+ +## ๊ธฐ์ˆ  ์Šคํƒ -
-

Collaboration

- - - - +
+
-## ํŒ€ ์†Œ๊ฐœ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
categorystack
+

Common

+
+ + + + + + + +
+

Frontend

+
+ + + + +
+

Backend

+
+ + + + + +
+

CI/CD

+
+ +
+

Deployment

+
+ + +
+

Collaboration

+
+ + + +
-### ์™ญ^^ +
+ +## ์™ญ^^ ์†Œ๊ฐœ
- Wab Logo + Wab Logo
์ €ํฌ๋Š” **`์™ญ^^`** ์ด์—์š”. @@ -122,16 +233,22 @@ - - - - + + + + ํ‡ด๊ทผ๋„ ์ข‹์ง€๋งŒ..^^ - ์›ํฌ๋‹˜ ์ฒœ์žฌ์•„๋‹ˆ์—์š”? + v2 ๋งŒ๋“ค๊ฑฐ์ฃ ? ๐Ÿซต ๋Šฆ๋Š” ์‚ฌ๋žŒ์ด ์žˆ๋„ค์š”..^^ + + ํ˜‘์—…์„ ์ฆ๊ธฐ๋ฉด์„œ ๊ฒฐ๊ณผ๋ฌผ ์™„์„ฑํ•˜๊ธฐ
์ปค๋ฎค๋‹ˆ์ผ€์ด์…˜, ์ง€์‹ ๊ณต์œ  ์—ด์‹ฌํžˆ ํ•˜๊ธฐ
+ ๋ฌธ์ œ๊ฐ€ ์ƒ๊ฒผ์„ ๋•Œ ๊ฒ๋จน์ง€ ์•Š๊ณ  ์ถฉ๋ถ„ํžˆ ์‹œ๋„ ํ›„ ํŠธ๋Ÿฌ๋ธ” ์ŠˆํŒ… ๊ณผ์ • ์ž˜ ๊ธฐ๋กํ•˜๊ธฐ, ๊ณต์œ ํ•˜๊ธฐ
์›ƒ์Œ๊ฐ€๋“ํ•œ ํŒ€ ๋งŒ๋“ค๊ธฐ + ์‚ฌ์†Œํ•œ๊ฑฐ๋ผ๋„ ๊ถ๊ธˆํ•ดํ•˜๊ธฐ
PR ๋ฆฌ๋ทฐ ์ž์„ธํžˆ ๋“ค์—ฌ๋‹ค๋ณด๊ธฐ + ๋‹ค๋ฅธ ์‚ฌ๋žŒ๋“ค๊ณผ ํ˜‘์—…ํ•˜๊ธฐ์— ์ข‹์€ ์‚ฌ๋žŒ ๋˜๊ธฐ + diff --git a/client/index.html b/client/index.html index 1457c022..6f4f527e 100644 --- a/client/index.html +++ b/client/index.html @@ -2,9 +2,6 @@ - - - Wabinar
diff --git a/client/jest.config.js b/client/jest.config.js new file mode 100644 index 00000000..5f07d6c2 --- /dev/null +++ b/client/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', // to use typescript + verbose: true, + modulePathIgnorePatterns: ['/dist/'], + collectCoverage: true, +}; diff --git a/client/package.json b/client/package.json index c501fe06..442a67e8 100644 --- a/client/package.json +++ b/client/package.json @@ -5,15 +5,18 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "test": "jest", "preview": "vite preview" }, "dependencies": { "@react-icons/all-files": "^4.1.0", + "@types/react-helmet": "^6.1.6", "axios": "^1.1.3", "classnames": "^2.3.2", "eventemitter3": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", "react-loader-spinner": "^5.3.4", "react-router-dom": "^6.4.3", "react-toastify": "^9.1.1", diff --git a/client/src/App.tsx b/client/src/App.tsx index 93a7a69d..221c3b99 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,21 +1,20 @@ -import { useState, useEffect } from 'react'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { getAuth } from 'src/apis/auth'; import UserContext from 'src/contexts/user'; -import { - LoadingPage, - LoginPage, - NotFoundPage, - OAuthPage, - WorkspacePage, -} from 'src/pages'; import { User } from 'src/types/user'; import 'styles/reset.scss'; +import MetaHelmet from './components/MetaHelmet'; + +const LoginPage = lazy(() => import('src/pages/Login')); +const OAuthPage = lazy(() => import('src/pages/OAuth')); +const WorkspacePage = lazy(() => import('src/pages/Workspace')); +const NotFoundPage = lazy(() => import('src/pages/404')); +const LoadingPage = lazy(() => import('src/pages/Loading')); function App() { const [user, setUser] = useState(null); - const [isLoaded, setIsLoaded] = useState(false); const location = useLocation(); const navigate = useNavigate(); @@ -23,11 +22,9 @@ function App() { const autoLogin = async () => { const { user } = await getAuth(); - setIsLoaded(true); - setUser(user); - if (user && !/^\/workspace(\/\d)?$/.test(location.pathname)) { + if (user && !/^\/workspace(\/\d+)?$/.test(location.pathname)) { navigate('/workspace'); } }; @@ -36,17 +33,20 @@ function App() { autoLogin(); }, []); - return isLoaded ? ( - - - } /> - } /> - } /> - } /> - - - ) : ( - + return ( + <> + + }> + + + } /> + } /> + } /> + } /> + + + + ); } diff --git a/client/src/components/Block/TextBlock.tsx b/client/src/components/Block/TextBlock.tsx index 6bf811ad..e87e085a 100644 --- a/client/src/components/Block/TextBlock.tsx +++ b/client/src/components/Block/TextBlock.tsx @@ -1,6 +1,10 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* TextBlock ๋งˆ์šดํŠธ ์ดํ›„ ํ•ญ์ƒ ์กด์žฌํ•˜๋Š” blockRef.current ์กด์žฌ ์—ฌ๋ถ€ ํ™•์ธํ•˜๋Š” ๋ถˆํ•„์š”ํ•œ ๊ฐ€๋“œ๋ฅผ ์ œ๊ฑฐํ•˜๊ธฐ ์œ„ํ•จ */ + +import * as BlockMessage from '@wabinar/api-types/block'; import { BlockType } from '@wabinar/constants/block'; import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; -import { +import LinkedList, { RemoteDeleteOperation, RemoteInsertOperation, } from '@wabinar/crdt/linked-list'; @@ -12,7 +16,7 @@ import { useOffset } from 'src/hooks/useOffset'; import ee from '../Mom/EventEmitter'; -interface BlockProps { +interface TextBlockProps { id: string; index: number; onHandleBlocks: React.KeyboardEventHandler; @@ -30,11 +34,12 @@ function TextBlock({ setType, isLocalTypeUpdate, registerRef, -}: BlockProps) { +}: TextBlockProps) { const { momSocket: socket } = useSocketContext(); const initBlock = () => { - socket.emit(BLOCK_EVENT.INIT_TEXT, id); + const message: BlockMessage.InitText = { id }; + socket.emit(BLOCK_EVENT.INIT_TEXT, message); }; const { @@ -56,7 +61,7 @@ function TextBlock({ // ๋ฆฌ๋ชจํŠธ ์—ฐ์‚ฐ ์ˆ˜ํ–‰๊ฒฐ๊ณผ๋กœ innerText ๋ณ€๊ฒฝ ์‹œ ์ปค์„œ์˜ ์œ„์น˜ ์กฐ์ • const updateCaretPosition = (updateOffset = 0) => { - if (!blockRef.current || offsetRef.current === null) return; + if (offsetRef.current === null) return; const selection = window.getSelection(); @@ -67,14 +72,14 @@ function TextBlock({ const range = new Range(); // ์šฐ์„  ๋ธ”๋Ÿญ์˜ ์ฒซ๋ฒˆ์งธ text node๋กœ ๊ณ ์ •, text node๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ clearOffset() - if (!blockRef.current.firstChild) { + if (!blockRef.current!.firstChild) { clearOffset(); return; } // range start์™€ range end๊ฐ€ ๊ฐ™์€ ๊ฒฝ์šฐ๋งŒ ๊ฐ€์ • range.setStart( - blockRef.current.firstChild, + blockRef.current!.firstChild, offsetRef.current + updateOffset, ); range.collapse(); @@ -84,12 +89,10 @@ function TextBlock({ setOffset(); }; - const onInitialize = (crdt: unknown) => { + const onInitialize = (crdt: LinkedList) => { syncCRDT(crdt); - if (!blockRef.current) return; - - blockRef.current.innerText = readCRDT(); + blockRef.current!.innerText = readCRDT(); updateCaretPosition(); }; @@ -104,9 +107,7 @@ function TextBlock({ return; } - if (!blockRef.current) return; - - blockRef.current.innerText = readCRDT(); + blockRef.current!.innerText = readCRDT(); if (prevIndex === null || offsetRef.current === null) return; @@ -123,9 +124,7 @@ function TextBlock({ return; } - if (!blockRef.current) return; - - blockRef.current.innerText = readCRDT(); + blockRef.current!.innerText = readCRDT(); if (targetIndex === null || offsetRef.current === null) return; @@ -151,6 +150,7 @@ function TextBlock({ useEffect(() => { registerRef(blockRef); + blockRef.current!.setAttribute('data-index', index.toString()); }, [index]); useEffect(() => { @@ -160,12 +160,12 @@ function TextBlock({ useEffect(() => { if (isLocalTypeUpdate && readCRDT().length) { const remoteDeletion = localDeleteCRDT(0); - socket.emit(BLOCK_EVENT.DELETE_TEXT, id, remoteDeletion); - if (!blockRef.current) return; + const message: BlockMessage.DeleteText = { id, op: remoteDeletion }; + socket.emit(BLOCK_EVENT.DELETE_TEXT, message); - blockRef.current.innerText = readCRDT(); - blockRef.current.focus(); + blockRef.current!.innerText = readCRDT(); + blockRef.current!.focus(); } }, [type]); @@ -173,9 +173,7 @@ function TextBlock({ const onInput: React.FormEventHandler = (e) => { setOffset(); - if (!blockRef.current) return; - - if (blockRef.current.innerText === '/') { + if (blockRef.current!.innerText === '/') { setIsOpen(true); } else if (isOpen) { setIsOpen(false); @@ -197,7 +195,8 @@ function TextBlock({ return; } - socket.emit(BLOCK_EVENT.DELETE_TEXT, id, remoteDeletion); + const message: BlockMessage.DeleteText = { id, op: remoteDeletion }; + socket.emit(BLOCK_EVENT.DELETE_TEXT, message); return; } @@ -210,9 +209,11 @@ function TextBlock({ remoteInsertion = localInsertCRDT(previousLetterIndex, letter); } catch { initBlock(); + return; } - socket.emit(BLOCK_EVENT.INSERT_TEXT, id, remoteInsertion); + const message: BlockMessage.InsertText = { id, op: remoteInsertion }; + socket.emit(BLOCK_EVENT.INSERT_TEXT, message); }; // ํ•œ๊ธ€ ์ž…๋ ฅ ํ•ธ๋“ค๋ง @@ -230,7 +231,8 @@ function TextBlock({ const remoteInsertion = localInsertCRDT(previousLetterIndex, letter); - socket.emit(BLOCK_EVENT.INSERT_TEXT, id, remoteInsertion); + const message: BlockMessage.InsertText = { id, op: remoteInsertion }; + socket.emit(BLOCK_EVENT.INSERT_TEXT, message); }); }; @@ -238,23 +240,24 @@ function TextBlock({ e.preventDefault(); setOffset(); - if (offsetRef.current === null || !blockRef.current) return; + if (offsetRef.current === null) return; let previousLetterIndex = offsetRef.current - 1; - const previousText = blockRef.current.innerText.slice( + const previousText = blockRef.current!.innerText.slice( 0, previousLetterIndex + 1, ); - const nextText = blockRef.current.innerText.slice(previousLetterIndex + 1); + const nextText = blockRef.current!.innerText.slice(previousLetterIndex + 1); const pastedText = e.clipboardData.getData('text/plain').replace('\n', ''); const remoteInsertions = pastedText .split('') .map((letter) => localInsertCRDT(previousLetterIndex++, letter)); - socket.emit(BLOCK_EVENT.UPDATE_TEXT, id, remoteInsertions); + const message: BlockMessage.UpdateText = { id, ops: remoteInsertions }; + socket.emit(BLOCK_EVENT.UPDATE_TEXT, message); - blockRef.current.innerText = previousText + pastedText + nextText; + blockRef.current!.innerText = previousText + pastedText + nextText; updateCaretPosition(pastedText.length); }; diff --git a/client/src/components/Block/index.tsx b/client/src/components/Block/index.tsx index 1ce315e4..314e6c86 100644 --- a/client/src/components/Block/index.tsx +++ b/client/src/components/Block/index.tsx @@ -1,4 +1,5 @@ import { BiPlus } from '@react-icons/all-files/bi/BiPlus'; +import * as BlockMessage from '@wabinar/api-types/block'; import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; import ee from 'components/Mom/EventEmitter'; import { memo, useEffect, useRef, useState } from 'react'; @@ -39,7 +40,10 @@ function Block({ const localUpdateFlagRef = useRef(false); useEffect(() => { - socket.emit(BLOCK_EVENT.LOAD_TYPE, id, (type: BlockType) => setType(type)); + const message: BlockMessage.LoadType = { id }; + const callback = ({ type }: BlockMessage.LoadedType) => setType(type); + + socket.emit(BLOCK_EVENT.LOAD_TYPE, message, callback); ee.on(`${BLOCK_EVENT.UPDATE_TYPE}-${id}`, (type) => { setType(type); @@ -48,8 +52,9 @@ function Block({ }, []); useEffect(() => { - if (localUpdateFlagRef.current) { - socket.emit(BLOCK_EVENT.UPDATE_TYPE, id, type); + if (localUpdateFlagRef.current && type) { + const message: BlockMessage.UpdateType = { id, type }; + socket.emit(BLOCK_EVENT.UPDATE_TYPE, message); } }, [type]); @@ -98,4 +103,8 @@ function Block({ ); } -export default memo(Block); +function isMemoized(prev: BlockProps, next: BlockProps) { + return prev.id === next.id; +} + +export default memo(Block, isMemoized); diff --git a/client/src/components/MeetingMediaBar/index.tsx b/client/src/components/MeetingMediaBar/index.tsx index 6413faa4..23c595c1 100644 --- a/client/src/components/MeetingMediaBar/index.tsx +++ b/client/src/components/MeetingMediaBar/index.tsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react'; import useSocketContext from 'src/hooks/context/useSocketContext'; import { MeetingMediaStream, - useMeetingMediaStreamsV2, -} from 'src/hooks/useMeetingMediaStreamsV2'; + useMeetingMediaStreams, +} from 'src/hooks/useMeetingMediaStreams'; import MeetingMedia from './MeetingMedia'; import StreamButton from './StreamButton'; @@ -12,7 +12,7 @@ import style from './style.module.scss'; function MeetingMediaBar() { const { workspaceSocket: socket } = useSocketContext(); const [streams, setLocalAudio, setLocalVideo] = - useMeetingMediaStreamsV2(socket); + useMeetingMediaStreams(socket); const [isMicOn, setIsMicOn] = useState(true); const [isCamOn, setIsCamOn] = useState(true); diff --git a/client/src/components/MetaHelmet/index.tsx b/client/src/components/MetaHelmet/index.tsx new file mode 100644 index 00000000..7b4f7965 --- /dev/null +++ b/client/src/components/MetaHelmet/index.tsx @@ -0,0 +1,34 @@ +import { Helmet } from 'react-helmet-async'; +import { META_INFO } from 'src/constants/meta'; + +interface MetaHelmetProps { + title: string; +} + +// TODO: ํŽ˜์ด์ง€๋ณ„ ๋‹ค๋ฅธ title ์ ์šฉํ•˜๋„๋ก ํ•  ๊ฒƒ +function MetaHelmet({ title }: MetaHelmetProps) { + return ( + + {title} + + + + + + + + + + + + + + + + + + + + ); +} +export default MetaHelmet; diff --git a/client/src/components/Mom/Skeleton.tsx b/client/src/components/Mom/Skeleton.tsx new file mode 100644 index 00000000..0ee645f4 --- /dev/null +++ b/client/src/components/Mom/Skeleton.tsx @@ -0,0 +1,14 @@ +import style from './style.module.scss'; + +function MomSkeleton() { + return ( +
+
+
+
+
+
+ ); +} + +export default MomSkeleton; diff --git a/client/src/components/Mom/index.tsx b/client/src/components/Mom/index.tsx index 4794bcd9..5f8b138c 100644 --- a/client/src/components/Mom/index.tsx +++ b/client/src/components/Mom/index.tsx @@ -1,3 +1,5 @@ +import * as BlockMessage from '@wabinar/api-types/block'; +import * as MomMessage from '@wabinar/api-types/mom'; import { BLOCK_EVENT, MOM_EVENT } from '@wabinar/constants/socket-message'; import Block from 'components/Block'; import { useEffect, useRef, useState } from 'react'; @@ -17,7 +19,8 @@ function Mom() { const initMom = () => { if (!selectedMom) return; - socket.emit(MOM_EVENT.INIT, selectedMom._id); + + socket.emit(MOM_EVENT.INIT); }; const { @@ -30,6 +33,12 @@ function Mom() { } = useCRDT(); const titleRef = useRef(null); + const blockRefs = useRef[]>([]); + const focusIndex = useRef(); + + const [blocks, setBlocks] = useState([]); + const [isLoaded, setIsLoaded] = useState(false); + const [isMomsEmpty, setIsMomsEmpty] = useState(false); const onTitleUpdate: React.FormEventHandler = useDebounce( () => { @@ -37,16 +46,14 @@ function Mom() { const title = titleRef.current.innerText; - socket.emit(MOM_EVENT.UPDATE_TITLE, title); + const message: MomMessage.UpdateTitle = { title }; + socket.emit(MOM_EVENT.UPDATE_TITLE, message); + + ee.emit(MOM_EVENT.UPDATE_TITLE, title); }, 500, ); - const [blocks, setBlocks] = useState([]); - - const blockRefs = useRef[]>([]); - const focusIndex = useRef(); - const updateBlockFocus = (idx: number | undefined) => { focusIndex.current = idx; }; @@ -85,9 +92,11 @@ function Mom() { remoteInsertion = localInsertCRDT(index, blockId); } catch { initMom(); + return; } - socket.emit(MOM_EVENT.INSERT_BLOCK, blockId, remoteInsertion); + const message: MomMessage.InsertBlock = { blockId, op: remoteInsertion }; + socket.emit(MOM_EVENT.INSERT_BLOCK, message); }; const deleteBlock = (id: string, index: number) => { @@ -97,9 +106,11 @@ function Mom() { remoteDeletion = localDeleteCRDT(index); } catch { initMom(); + return; } - socket.emit(MOM_EVENT.DELETE_BLOCK, id, remoteDeletion); + const message: MomMessage.DeleteBlock = { blockId: id, op: remoteDeletion }; + socket.emit(MOM_EVENT.DELETE_BLOCK, message); }; const onHandleBlocks: React.KeyboardEventHandler = (e) => { @@ -140,22 +151,23 @@ function Mom() { useEffect(() => { initMom(); - socket.on(MOM_EVENT.INIT, (crdt) => { + socket.on(MOM_EVENT.INIT, ({ crdt }: MomMessage.Initialized) => { syncCRDT(crdt); setBlocks(spreadCRDT()); }); - socket.on(MOM_EVENT.UPDATE_TITLE, (title) => { + socket.on(MOM_EVENT.UPDATE_TITLE, ({ title }: MomMessage.UpdatedTitle) => { if (!titleRef.current) return; titleRef.current.innerText = title; + ee.emit(MOM_EVENT.UPDATE_TITLE, title); }); socket.on(MOM_EVENT.UPDATED, () => { setBlocks(spreadCRDT()); }); - socket.on(MOM_EVENT.INSERT_BLOCK, (op) => { + socket.on(MOM_EVENT.INSERT_BLOCK, ({ op }: MomMessage.InsertedBlock) => { try { remoteInsertCRDT(op); } catch { @@ -167,7 +179,7 @@ function Mom() { setBlocks(spreadCRDT()); }); - socket.on(MOM_EVENT.DELETE_BLOCK, (op) => { + socket.on(MOM_EVENT.DELETE_BLOCK, ({ op }: MomMessage.DeletedBlock) => { try { remoteDeleteCRDT(op); } catch { @@ -179,25 +191,31 @@ function Mom() { setBlocks(spreadCRDT()); }); - socket.on(BLOCK_EVENT.INIT_TEXT, (id, crdt) => { + const onInitializedText = ({ id, crdt }: BlockMessage.InitializedText) => { ee.emit(`${BLOCK_EVENT.INIT_TEXT}-${id}`, crdt); - }); + }; - socket.on(BLOCK_EVENT.INSERT_TEXT, (id, op) => { + const onInsertedText = ({ id, op }: BlockMessage.InsertedText) => { ee.emit(`${BLOCK_EVENT.INSERT_TEXT}-${id}`, op); - }); + }; - socket.on(BLOCK_EVENT.DELETE_TEXT, (id, op) => { + const onDeletedText = ({ id, op }: BlockMessage.DeletedText) => { ee.emit(`${BLOCK_EVENT.DELETE_TEXT}-${id}`, op); - }); + }; - socket.on(BLOCK_EVENT.UPDATE_TEXT, (id, crdt) => { + const onUpdatedText = ({ id, crdt }: BlockMessage.UpdatedText) => { ee.emit(`${BLOCK_EVENT.UPDATE_TEXT}-${id}`, crdt); - }); + }; - socket.on(BLOCK_EVENT.UPDATE_TYPE, (id, type) => { + const onUpdatedType = ({ id, type }: BlockMessage.UpdatedType) => { ee.emit(`${BLOCK_EVENT.UPDATE_TYPE}-${id}`, type); - }); + }; + + socket.on(BLOCK_EVENT.INIT_TEXT, onInitializedText); + socket.on(BLOCK_EVENT.INSERT_TEXT, onInsertedText); + socket.on(BLOCK_EVENT.DELETE_TEXT, onDeletedText); + socket.on(BLOCK_EVENT.UPDATE_TEXT, onUpdatedText); + socket.on(BLOCK_EVENT.UPDATE_TYPE, onUpdatedType); return () => { [ @@ -214,43 +232,63 @@ function Mom() { }; }, [selectedMom]); + useEffect(() => { + ee.on(MOM_EVENT.LOADED, (momsLength: number) => { + setIsLoaded(true); + setIsMomsEmpty(momsLength === 0); + }); + + ee.emit(MOM_EVENT.REQUEST_LOADED); + + return () => { + ee.off(MOM_EVENT.LOADED); + }; + }, [socket]); + const registerRef = (index: number) => (ref: React.RefObject) => { blockRefs.current[index] = ref; setBlockFocus(index); }; - return selectedMom ? ( -
-
-
-

- {selectedMom.title} -

- {new Date(selectedMom.createdAt).toLocaleString()} -
+ if (!isLoaded) { + return
; + } -
- {blocks.map((id, index) => ( - - ))} + if (isMomsEmpty) { + return ; + } + + return ( +
+ {selectedMom && ( +
+
+

+ {selectedMom.title} +

+ {new Date(selectedMom.createdAt).toLocaleString()} +
+
+ {blocks.map((id, index) => ( + + ))} +
-
+ )}
- ) : ( - ); } diff --git a/client/src/components/Sidebar/MeetingButton.tsx b/client/src/components/Sidebar/MeetingButton.tsx index 2d26eebd..246f614c 100644 --- a/client/src/components/Sidebar/MeetingButton.tsx +++ b/client/src/components/Sidebar/MeetingButton.tsx @@ -14,7 +14,7 @@ function MeetingButton() { const onClick = () => { if (isOnGoing) { - socket.emit('bye'); + socket.emit(WORKSPACE_EVENT.SEND_BYE); } setIsOnGoing(!isOnGoing); }; diff --git a/client/src/components/Sidebar/MomList.tsx b/client/src/components/Sidebar/MomList.tsx index 3a17b2d8..df718bef 100644 --- a/client/src/components/Sidebar/MomList.tsx +++ b/client/src/components/Sidebar/MomList.tsx @@ -1,17 +1,20 @@ import { RiFileAddLine } from '@react-icons/all-files/ri/RiFileAddLine'; +import * as MomMessage from '@wabinar/api-types/mom'; import { MOM_EVENT } from '@wabinar/constants/socket-message'; import { memo, useEffect, useState } from 'react'; import useSocketContext from 'src/hooks/context/useSocketContext'; import { TMom } from 'src/types/mom'; +import ee from '../Mom/EventEmitter'; import style from './style.module.scss'; interface MomListProps { moms: TMom[]; + selectedMom: TMom | null; setSelectedMom: React.Dispatch>; } -function MomList({ moms, setSelectedMom }: MomListProps) { +function MomList({ moms, selectedMom, setSelectedMom }: MomListProps) { const { momSocket: socket } = useSocketContext(); const [momList, setMomList] = useState(moms); @@ -19,29 +22,57 @@ function MomList({ moms, setSelectedMom }: MomListProps) { socket.emit(MOM_EVENT.CREATE); }; - const onSelect = (targetId: string) => { - socket.emit(MOM_EVENT.SELECT, targetId); + const onSelect = (id: string) => { + const message: MomMessage.Select = { id }; + socket.emit(MOM_EVENT.SELECT, message); }; useEffect(() => { if (moms.length) { - socket.emit(MOM_EVENT.SELECT, moms[0]._id); + const message: MomMessage.Select = { id: moms[0]._id }; + socket.emit(MOM_EVENT.SELECT, message); } setMomList(moms); - socket.on(MOM_EVENT.CREATE, (mom) => setMomList((prev) => [...prev, mom])); + ee.on(MOM_EVENT.REQUEST_LOADED, () => { + ee.emit(MOM_EVENT.LOADED, moms ? moms.length : 0); + }); + + socket.on(MOM_EVENT.CREATE, ({ mom }: MomMessage.Created) => + setMomList((prev) => [...prev, mom]), + ); - socket.on(MOM_EVENT.SELECT, (mom) => { + socket.on(MOM_EVENT.SELECT, ({ mom }: MomMessage.Selected) => { setSelectedMom(mom); }); return () => { socket.off(MOM_EVENT.CREATE); socket.off(MOM_EVENT.SELECT); + ee.off(MOM_EVENT.REQUEST_LOADED); }; }, [moms]); + useEffect(() => { + ee.on(MOM_EVENT.UPDATE_TITLE, (title) => { + if (!selectedMom) return; + + const updatedMomList = momList.map((mom) => { + if (mom._id === selectedMom._id) { + return { ...mom, title }; + } + return mom; + }); + + setMomList(updatedMomList); + }); + + return () => { + ee.off(MOM_EVENT.UPDATE_TITLE); + }; + }, []); + return (
@@ -67,4 +98,8 @@ function MomList({ moms, setSelectedMom }: MomListProps) { ); } -export default memo(MomList); +const isMemoized = (prevProps: MomListProps, nextProps: MomListProps) => { + return prevProps.moms === nextProps.moms; +}; + +export default memo(MomList, isMemoized); diff --git a/client/src/components/Sidebar/Skeleton.tsx b/client/src/components/Sidebar/Skeleton.tsx new file mode 100644 index 00000000..54b8a8f3 --- /dev/null +++ b/client/src/components/Sidebar/Skeleton.tsx @@ -0,0 +1,13 @@ +import style from './style.module.scss'; + +function SideBarSkeleton() { + return ( +
+
+
+
+
+ ); +} + +export default SideBarSkeleton; diff --git a/client/src/components/Sidebar/index.tsx b/client/src/components/Sidebar/index.tsx index 12eedcc1..4ea139c6 100644 --- a/client/src/components/Sidebar/index.tsx +++ b/client/src/components/Sidebar/index.tsx @@ -12,14 +12,18 @@ interface SidebarProps { } function Sidebar({ workspace }: SidebarProps) { - const { setSelectedMom } = useSelectedMomContext(); + const { selectedMom, setSelectedMom } = useSelectedMomContext(); return (
- +
diff --git a/client/src/components/Sidebar/style.module.scss b/client/src/components/Sidebar/style.module.scss index 40553f98..1bdc0356 100644 --- a/client/src/components/Sidebar/style.module.scss +++ b/client/src/components/Sidebar/style.module.scss @@ -13,7 +13,7 @@ display: flex; flex-direction: column; align-items: center; - min-width: 240px; + width: 300px; height: 100vh; overflow: hidden; } diff --git a/client/src/components/Workspace/Skeleton/index.tsx b/client/src/components/Workspace/Skeleton/index.tsx new file mode 100644 index 00000000..98eeac8a --- /dev/null +++ b/client/src/components/Workspace/Skeleton/index.tsx @@ -0,0 +1,17 @@ +import MomSkeleton from 'src/components/Mom/Skeleton'; +import SideBarSkeleton from 'src/components/Sidebar/Skeleton'; +import WorkspaceListSkeleton from 'src/components/WorkspaceList/Skeleton'; + +import style from './style.module.scss'; + +function WorkspaceSkeleton() { + return ( +
+ + + +
+ ); +} + +export default WorkspaceSkeleton; diff --git a/client/src/components/Workspace/Skeleton/style.module.scss b/client/src/components/Workspace/Skeleton/style.module.scss new file mode 100644 index 00000000..b4714729 --- /dev/null +++ b/client/src/components/Workspace/Skeleton/style.module.scss @@ -0,0 +1,85 @@ +@import 'styles/color.module'; + +@mixin loading { + @keyframes loading { + 0% { + background-color: $gray-300-transparent; + } + 50% { + background-color: $gray-300; + } + 100% { + background-color: $gray-300-transparent; + } + } + + animation: loading 1.2s infinite ease-out; +} + +.workspace-sk { + display: flex; + width: 100%; + height: 100%; + + .side-bar-sk { + display: flex; + flex-direction: column; + min-width: 240px; + height: 100%; + padding: 20px; + .name { + @include loading(); + width: 90%; + height: 20px; + margin-bottom: 20px; + } + .members { + @include loading(); + width: 80%; + height: 20px; + margin-bottom: 20px; + } + .moms { + @include loading(); + width: 60%; + height: 20px; + } + } + .mom-sk { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + width: 100%; + height: 100vh; + + .head { + display: flex; + align-items: center; + justify-content: center; + width: 90%; + height: 60px; + margin-bottom: 20px; + padding: 5px 0; + + .title { + @include loading(); + width: 50%; + height: 30px; + } + .date { + @include loading(); + width: 20%; + height: 20px; + margin-top: auto; + margin-left: auto; + } + } + .mom { + @include loading(); + position: relative; + width: 90%; + height: calc(100% - 30px); + } + } +} diff --git a/client/src/components/Workspace/index.tsx b/client/src/components/Workspace/index.tsx index 907bd1e1..8c4df93b 100644 --- a/client/src/components/Workspace/index.tsx +++ b/client/src/components/Workspace/index.tsx @@ -19,7 +19,7 @@ function Workspace() { const [workspace, setWorkspace] = useState(null); const [selectedMom, setSelectedMom] = useState(null); - const momSocket = useSocket(`/sc-workspace/${id}`); + const momSocket = useSocket(`/workspace-mom/${id}`); const workspaceSocket = useSocket(`/workspace/${id}`); const loadWorkspaceInfo = async () => { diff --git a/client/src/components/WorkspaceList/Skeleton.tsx b/client/src/components/WorkspaceList/Skeleton.tsx new file mode 100644 index 00000000..4e507b8e --- /dev/null +++ b/client/src/components/WorkspaceList/Skeleton.tsx @@ -0,0 +1,7 @@ +import style from './style.module.scss'; + +function WorkspaceListSkeleton() { + return
; +} + +export default WorkspaceListSkeleton; diff --git a/client/src/components/common/Icon/Bubbles/index.tsx b/client/src/components/common/Icon/Bubbles/index.tsx index 2de489c2..323e6cb2 100644 --- a/client/src/components/common/Icon/Bubbles/index.tsx +++ b/client/src/components/common/Icon/Bubbles/index.tsx @@ -4,200 +4,11 @@ interface BubblesProps { function BubblesIcon({ className }: BubblesProps) { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + src="https://user-images.githubusercontent.com/63364990/207814310-bc20af5b-7118-46a8-bdb3-6470712889eb.jpeg" + alt="๋ฒ„๋ธ” ์•„์ด์ฝ˜" + /> ); } diff --git a/client/src/components/common/Icon/Logo/index.tsx b/client/src/components/common/Icon/Logo/index.tsx index a8cc9fe6..05ffa7b6 100644 --- a/client/src/components/common/Icon/Logo/index.tsx +++ b/client/src/components/common/Icon/Logo/index.tsx @@ -4,36 +4,11 @@ interface LogoIconProps { function LogoIcon({ className }: LogoIconProps) { return ( - - - - - - - - - + src="https://user-images.githubusercontent.com/63364990/207813515-e5afe2a8-fac6-466d-8274-a69c4463873a.jpeg" + alt="๋กœ๊ณ  ์•„์ด์ฝ˜" + /> ); } diff --git a/client/src/constants/meta.ts b/client/src/constants/meta.ts new file mode 100644 index 00000000..89b5ee05 --- /dev/null +++ b/client/src/constants/meta.ts @@ -0,0 +1,9 @@ +export const META_INFO = { + title: 'ํ™”์ƒํšŒ์˜์™€ ํšŒ์˜๋ก ์ž‘์„ฑ์„ ํ•œ๋ฒˆ์—, Wabinar', + description: 'ํšŒ์˜์™€ ๊ธฐ๋ก์„ ํ•œ๋ฒˆ์—', + keywords: 'ํšŒ์˜ํˆด, ๊ณต์œ  ํšŒ์˜๋ก, ํ™”์ƒํšŒ์˜, ์‹ค์‹œ๊ฐ„ ๊ณต๋™ ํŽธ์ง‘', + url: 'https://www.wabinar.live', + href: '/favicon.svg', + type: 'website', + image: '/favicon.svg', +}; diff --git a/client/src/constants/rtc.ts b/client/src/constants/rtc.ts index a4a42734..04d37e71 100644 --- a/client/src/constants/rtc.ts +++ b/client/src/constants/rtc.ts @@ -1 +1 @@ -export const STUN_SERVER = ['stun:stun.l.google.com:19302']; \ No newline at end of file +export const STUN_SERVER = ['stun:stun.l.google.com:19302']; diff --git a/client/src/contexts/rtc.ts b/client/src/contexts/rtc.ts new file mode 100644 index 00000000..1782cec6 --- /dev/null +++ b/client/src/contexts/rtc.ts @@ -0,0 +1,42 @@ +import { + createContext, + Dispatch, + MutableRefObject, + SetStateAction, +} from 'react'; + +export interface IMyMediaStreamContext { + myStreamRef: MutableRefObject; + myMediaStream: MediaStream; + setMyMediaStream: Dispatch>; + isMyMicOn: boolean; + setIsMyMicOn: Dispatch>; + isMyCamOn: boolean; + setIsMyCamOn: Dispatch>; +} + +export type TUserStreams = { + [key: string]: MediaStream | null; +}; + +export type TConnectedUser = { + name: string; + uid: number; + sid: string; + avatarUrl: string; +}; + +export interface IUserStreamContext { + userStreams: TUserStreams | null; + setUserStreams: Dispatch>; + connectedUsers: TConnectedUser[]; + setConnectedUsers: Dispatch>; +} + +export const MyMediaStreamContext = createContext( + null, +); + +export const UserStreamsContext = createContext( + null, +); diff --git a/client/src/hooks/context/useMyMediaStreamContext.ts b/client/src/hooks/context/useMyMediaStreamContext.ts new file mode 100644 index 00000000..f5a0c024 --- /dev/null +++ b/client/src/hooks/context/useMyMediaStreamContext.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import ERROR_MESSAGE from 'src/constants/error-message'; +import { MyMediaStreamContext } from 'src/contexts/rtc'; + +export default function useMyMediaStreamContext() { + const context = useContext(MyMediaStreamContext); + + if (!context) throw new Error(ERROR_MESSAGE.OUT_OF_CONTEXT_SCOPE); + + return context; +} diff --git a/client/src/hooks/context/useUserStreamsContext.ts b/client/src/hooks/context/useUserStreamsContext.ts new file mode 100644 index 00000000..22874fc5 --- /dev/null +++ b/client/src/hooks/context/useUserStreamsContext.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import ERROR_MESSAGE from 'src/constants/error-message'; +import { UserStreamsContext } from 'src/contexts/rtc'; + +export default function useUserStreamsContext() { + const context = useContext(UserStreamsContext); + + if (!context) throw new Error(ERROR_MESSAGE.OUT_OF_CONTEXT_SCOPE); + + return context; +} diff --git a/client/src/hooks/useCRDT.ts b/client/src/hooks/useCRDT.ts index 63cd063b..d6b6ad53 100644 --- a/client/src/hooks/useCRDT.ts +++ b/client/src/hooks/useCRDT.ts @@ -29,11 +29,8 @@ export function useCRDT() { const isCRDTInitializedRef = useRef(false); let operationSet: RemoteOperation[] = []; - const syncCRDT = (structure: unknown) => { - crdtRef.current = new CRDT( - clientId, - new LinkedList(structure as LinkedList), - ); + const syncCRDT = (structure: LinkedList) => { + crdtRef.current = new CRDT(clientId, new LinkedList(structure)); isCRDTInitializedRef.current = true; diff --git a/client/src/hooks/useCreateMediaStream.ts b/client/src/hooks/useCreateMediaStream.ts new file mode 100644 index 00000000..4bb6609b --- /dev/null +++ b/client/src/hooks/useCreateMediaStream.ts @@ -0,0 +1,64 @@ +import useMyMediaStreamContext from './context/useMyMediaStreamContext'; + +export const useCreateMediaStream = () => { + const { myMediaStream, setMyMediaStream, setIsMyMicOn, setIsMyCamOn } = + useMyMediaStreamContext(); + + const toggleAudioStream = (enabled: boolean) => { + if (myMediaStream) { + myMediaStream.getAudioTracks().forEach((track) => { + track.enabled = !enabled; + }); + } + setIsMyMicOn(enabled); + setMyMediaStream(new MediaStream()); + }; + + const toggleVideoStream = (enabled: boolean) => { + if (myMediaStream) { + myMediaStream.getVideoTracks().forEach((track) => { + myMediaStream.removeTrack(track); + track.stop(); + }); + } + setIsMyCamOn(enabled); + setMyMediaStream(new MediaStream()); + }; + + const createAudioStream = async () => { + try { + const audioStream = await navigator.mediaDevices.getUserMedia({ + audio: true, + }); + + myMediaStream.addTrack(audioStream.getAudioTracks()[0]); + + setIsMyMicOn(true); + setMyMediaStream(myMediaStream); + } catch (error) { + console.debug('failed to get audio stream', error); + } + }; + + const createVideoStream = async () => { + try { + const videoStream = await navigator.mediaDevices.getUserMedia({ + video: true, + }); + + myMediaStream.addTrack(videoStream.getVideoTracks()[0]); + + setIsMyCamOn(true); + setMyMediaStream(myMediaStream); + } catch (error) { + console.debug('failed to get video stream', error); + } + }; + + return { + toggleAudioStream, + toggleVideoStream, + createAudioStream, + createVideoStream, + }; +}; diff --git a/client/src/hooks/useJoinMeeting.ts b/client/src/hooks/useJoinMeeting.ts new file mode 100644 index 00000000..496cb22e --- /dev/null +++ b/client/src/hooks/useJoinMeeting.ts @@ -0,0 +1,50 @@ +import { WORKSPACE_EVENT } from '@wabinar/constants/socket-message'; +import { useEffect } from 'react'; +import { TConnectedUser } from 'src/contexts/rtc'; + +import useMyMediaStreamContext from './context/useMyMediaStreamContext'; +import useSocketContext from './context/useSocketContext'; +import useUserContext from './context/useUserContext'; +import useUserStreamContext from './context/useUserStreamsContext'; + +export default function useJoinMeeting() { + const { workspaceSocket } = useSocketContext(); + const { user } = useUserContext(); + const { setConnectedUsers } = useUserStreamContext(); + const { setUserStreams } = useUserStreamContext(); + const { setMyMediaStream, myStreamRef } = useMyMediaStreamContext(); + + useEffect(() => { + workspaceSocket.emit(WORKSPACE_EVENT.SEND_HELLO, user?.id); + + const onExistingUsers = (users: TConnectedUser[]) => { + const existingUsers = users.map((user) => ({ + ...user, + })); + + setConnectedUsers(existingUsers); + }; + + const onExitUser = (sid: string) => { + setUserStreams((prev) => { + delete prev?.[sid]; + return prev; + }); + + setConnectedUsers((prev) => prev.filter((user) => user.sid !== sid)); + }; + + workspaceSocket.on(WORKSPACE_EVENT.EXISTING_ROOM_USERS, onExistingUsers); + workspaceSocket.on(WORKSPACE_EVENT.RECEIVE_BYE, onExitUser); + + return () => { + workspaceSocket.off(WORKSPACE_EVENT.EXISTING_ROOM_USERS); + workspaceSocket.off(WORKSPACE_EVENT.RECEIVE_BYE); + + setUserStreams(null); + setConnectedUsers([]); + setMyMediaStream(new MediaStream()); + myStreamRef.current = undefined; + }; + }, []); +} diff --git a/client/src/hooks/useMeetingMediaStreams.ts b/client/src/hooks/useMeetingMediaStreams.ts index 87afe889..c27d4da6 100644 --- a/client/src/hooks/useMeetingMediaStreams.ts +++ b/client/src/hooks/useMeetingMediaStreams.ts @@ -1,50 +1,60 @@ -// DEPRECATED. -// use 'useMeetingMediaStreamsV2' instead - +import { WORKSPACE_EVENT } from '@wabinar/constants/socket-message'; import { useEffect, useState } from 'react'; -import { Socket } from 'socket.io-client'; +import { Socket, io } from 'socket.io-client'; import { STUN_SERVER } from 'src/constants/rtc'; import RTC from 'src/utils/rtc'; -import { setTrack, TrackKind } from 'src/utils/trackSetter'; +import { setTrack } from 'src/utils/trackSetter'; + +export interface MeetingMediaStream { + stream: MediaStream; + id: string; + type: 'local' | 'remote'; + audioOn: boolean; + videoOn: boolean; +} + +export type SetLocalAudio = (audioOn: boolean) => void; +export type SetLocalVideo = (videoOn: boolean) => void; export function useMeetingMediaStreams( socket: Socket, -): [Map, (kind: TrackKind, turnOn: boolean) => void] { - const [mediaStreams, setMediaStreams] = useState>( - new Map(), - ); - const [myStream, setMyStream] = useState(); - - const setMyTrack = async (kind: TrackKind, turnOn: boolean) => { - if (!myStream) { - return; - } - setTrack(myStream, kind, turnOn); - }; +): [MeetingMediaStream[], SetLocalAudio, SetLocalVideo] { + const [meetingMediaStreams, setMeetingMediaStreams] = useState< + MeetingMediaStream[] + >([]); + const [localStream, setLocalStream] = useState(); const initRTC = async () => { - const userMedia = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: true, - }); - - setMyStream(userMedia); - setMediaStreams((prev) => - copyMapWithOperation(prev, (map) => map.set('me', userMedia)), + const userMediaConstraints = { video: true, audio: true }; + const userStream = await navigator.mediaDevices.getUserMedia( + userMediaConstraints, ); + setLocalStream(userStream); - const rtc = new RTC(socket, STUN_SERVER, userMedia); + // note: local MeetingMediaStream has empty id + const localMeetingMediaStream: MeetingMediaStream = { + stream: userStream, + id: '', + type: 'local', + audioOn: true, + videoOn: true, + }; + setMeetingMediaStreams((prev) => [...prev, localMeetingMediaStream]); + const rtc = new RTC(socket, STUN_SERVER, userStream); rtc.onMediaConnected((socketId, remoteStream) => { - setMediaStreams((prev) => - copyMapWithOperation(prev, (map) => map.set(socketId, remoteStream)), - ); + const remoteMeetingMediaStream: MeetingMediaStream = { + stream: remoteStream, + id: socketId, + type: 'remote', + audioOn: true, + videoOn: true, + }; + setMeetingMediaStreams((prev) => [...prev, remoteMeetingMediaStream]); }); rtc.onMediaDisconnected((socketId) => { - setMediaStreams((prev) => - copyMapWithOperation(prev, (map) => map.delete(socketId)), - ); + setMeetingMediaStreams((prev) => prev.filter((_) => _.id !== socketId)); }); rtc.connect(); @@ -54,15 +64,33 @@ export function useMeetingMediaStreams( initRTC(); }, []); - return [mediaStreams, setMyTrack]; -} + const setLocalAudio: SetLocalAudio = async (audioOn) => { + if (!localStream) { + return; + } + setTrack(localStream, 'audio', audioOn); + socket.emit(WORKSPACE_EVENT.AUDIO_STATE_CHANGED, audioOn); + }; + + const setLocalVideo: SetLocalVideo = async (videoOn) => { + if (!localStream) { + return; + } + setTrack(localStream, 'video', videoOn); + socket.emit(WORKSPACE_EVENT.VIDEO_STATE_CHANGED, videoOn); + }; + + socket.on('audio_state_changed', (socketId, audioOn) => { + setMeetingMediaStreams((prev) => + prev.map((_) => (_.id === socketId ? { ..._, audioOn } : _)), + ); + }); + + socket.on('video_state_changed', (socketId, videoOn) => { + setMeetingMediaStreams((prev) => + prev.map((_) => (_.id === socketId ? { ..._, videoOn } : _)), + ); + }); -// TODO: ์ฝ”๋“œ ๋ฐ˜๋ณต๋•Œ๋ฌธ์— ๋งŒ๋“  ํ•จ์ˆ˜. ๋” ์ข‹์€ ๋ฐฉ๋ฒ• ์žˆ์œผ๋ฉด ๊ณ ์น˜๊ธฐ -function copyMapWithOperation( - prev: Map, - operation: (cur: Map) => void, -) { - const cur = new Map(prev); - operation(cur); - return cur; + return [meetingMediaStreams, setLocalAudio, setLocalVideo]; } diff --git a/client/src/hooks/useMeetingMediaStreamsV2.ts b/client/src/hooks/useMeetingMediaStreamsV2.ts deleted file mode 100644 index 36e557bf..00000000 --- a/client/src/hooks/useMeetingMediaStreamsV2.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Socket, io } from 'socket.io-client'; -import { STUN_SERVER } from 'src/constants/rtc'; -import RTC from 'src/utils/rtc'; -import { setTrack, TrackKind } from 'src/utils/trackSetter'; - -export interface MeetingMediaStream { - stream: MediaStream; - id: string; - type: 'local' | 'remote'; - audioOn: boolean; - videoOn: boolean; -} - -export type SetLocalAudio = (audioOn: boolean) => void; -export type SetLocalVideo = (videoOn: boolean) => void; - -export function useMeetingMediaStreamsV2( - socket: Socket, -): [MeetingMediaStream[], SetLocalAudio, SetLocalVideo] { - const [meetingMediaStreams, setMeetingMediaStreams] = useState< - MeetingMediaStream[] - >([]); - const [localStream, setLocalStream] = useState(); - - const initRTC = async () => { - const userMediaConstraints = { video: true, audio: true }; - const userStream = await navigator.mediaDevices.getUserMedia( - userMediaConstraints, - ); - setLocalStream(userStream); - - // note: local MeetingMediaStream has empty id - const localMeetingMediaStream: MeetingMediaStream = { - stream: userStream, - id: '', - type: 'local', - audioOn: true, - videoOn: true, - }; - setMeetingMediaStreams((prev) => [...prev, localMeetingMediaStream]); - - const rtc = new RTC(socket, STUN_SERVER, userStream); - rtc.onMediaConnected((socketId, remoteStream) => { - const remoteMeetingMediaStream: MeetingMediaStream = { - stream: remoteStream, - id: socketId, - type: 'remote', - audioOn: true, - videoOn: true, - }; - setMeetingMediaStreams((prev) => [...prev, remoteMeetingMediaStream]); - }); - - rtc.onMediaDisconnected((socketId) => { - setMeetingMediaStreams((prev) => prev.filter((_) => _.id !== socketId)); - }); - - rtc.connect(); - }; - - useEffect(() => { - initRTC(); - }, []); - - const setLocalAudio: SetLocalAudio = async (audioOn) => { - if (!localStream) { - return; - } - setTrack(localStream, 'audio', audioOn); - socket.emit('audio_state_changed', audioOn); - }; - - const setLocalVideo: SetLocalVideo = async (videoOn) => { - if (!localStream) { - return; - } - setTrack(localStream, 'video', videoOn); - socket.emit('video_state_changed', videoOn); - }; - - socket.on('audio_state_changed', (socketId, audioOn) => { - setMeetingMediaStreams((prev) => - prev.map((_) => (_.id === socketId ? { ..._, audioOn } : _)), - ); - }); - - socket.on('video_state_changed', (socketId, videoOn) => { - setMeetingMediaStreams((prev) => - prev.map((_) => (_.id === socketId ? { ..._, videoOn } : _)), - ); - }); - - return [meetingMediaStreams, setLocalAudio, setLocalVideo]; -} diff --git a/client/src/hooks/usePeerConnection.ts b/client/src/hooks/usePeerConnection.ts new file mode 100644 index 00000000..bbf488c1 --- /dev/null +++ b/client/src/hooks/usePeerConnection.ts @@ -0,0 +1,155 @@ +import { WORKSPACE_EVENT } from '@wabinar/constants/socket-message'; +import { useEffect, useRef } from 'react'; + +import { User } from './../types/user.d'; +import useMyMediaStreamContext from './context/useMyMediaStreamContext'; +import useSocketContext from './context/useSocketContext'; +import useUserStreamContext from './context/useUserStreamsContext'; + +const usePeerConnection = () => { + const { myMediaStream } = useMyMediaStreamContext(); + const { userStreams, setUserStreams, setConnectedUsers } = + useUserStreamContext(); + const pcsRef = useRef<{ [socketId: string]: RTCPeerConnection }>({}); + const { workspaceSocket } = useSocketContext(); + + useEffect(() => { + const createPeerConnection = (sid: string) => { + try { + const RTCConfig = { + iceServers: [ + { + urls: [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + 'stun:stun2.l.google.com:19302', + 'stun:stun3.l.google.com:19302', + 'stun:stun4.l.google.com:19302', + ], + }, + ], + }; + + const peerConnection = new RTCPeerConnection(RTCConfig); + + peerConnection.onicecandidate = (event: RTCPeerConnectionIceEvent) => { + if (event.candidate) { + workspaceSocket.emit( + WORKSPACE_EVENT.SEND_ICE, + event.candidate, + sid, + ); + } + }; + + peerConnection.ontrack = (event: RTCTrackEvent) => { + const stream = event.streams[0]; + + setUserStreams((prev) => ({ ...prev, [sid]: stream })); + }; + + myMediaStream?.getTracks().forEach((track) => { + peerConnection.addTrack(track, myMediaStream); + }); + + peerConnection.onnegotiationneeded = async () => { + try { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + workspaceSocket.emit(WORKSPACE_EVENT.SEND_OFFER, offer, sid); + } catch (err) { + console.error(err); + } + }; + + return peerConnection; + } catch (err) { + console.debug(err); + } + }; + + const onReceivedOffer = async ( + offer: RTCSessionDescriptionInit, + sid: string, + ) => { + const pc = createPeerConnection(sid); + if (!pc) return; + + pcsRef.current = { ...pcsRef.current, [sid]: pc }; + + try { + await pc.setRemoteDescription(offer); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + workspaceSocket.emit(WORKSPACE_EVENT.SEND_ANSWER, answer, sid); + } catch (err) { + console.debug(err); + } + }; + + const onReceivedAnswer = async ( + answer: RTCSessionDescriptionInit, + sid: string, + ) => { + const pc = pcsRef.current?.[sid]; + + if (!pc) return; + await pc.setRemoteDescription(answer); + }; + + const onReceivedIceCandidate = async ( + iceCandidate: RTCIceCandidateInit, + sid: string, + ) => { + const pc = pcsRef.current?.[sid]; + + if (!pc) return; + await pc.addIceCandidate(iceCandidate); + }; + + const onReceivedUser = async (sid: string, user: User) => { + const pc = createPeerConnection(sid); + + if (!pc) return; + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + pcsRef.current = { ...pcsRef.current, [sid]: pc }; + + setConnectedUsers((prev) => [ + ...prev, + { + sid: sid, + uid: user.id, + name: user.name, + avatarUrl: user.avatarUrl, + }, + ]); + + workspaceSocket.emit(WORKSPACE_EVENT.SEND_OFFER, offer, sid); + }; + + workspaceSocket.on(WORKSPACE_EVENT.RECEIVE_HELLO, onReceivedUser); + workspaceSocket.on(WORKSPACE_EVENT.RECEIVE_OFFER, onReceivedOffer); + workspaceSocket.on(WORKSPACE_EVENT.RECEIVE_ANSWER, onReceivedAnswer); + workspaceSocket.on(WORKSPACE_EVENT.RECEIVE_ICE, onReceivedIceCandidate); + + return () => { + workspaceSocket.off(WORKSPACE_EVENT.RECEIVE_OFFER); + workspaceSocket.off(WORKSPACE_EVENT.RECEIVE_ANSWER); + workspaceSocket.off(WORKSPACE_EVENT.RECEIVE_ICE); + + if (userStreams) { + Object.keys(userStreams).forEach((sid) => { + pcsRef.current?.[sid].close(); + delete pcsRef.current[sid]; + }); + } + }; + }, [userStreams, myMediaStream]); +}; + +export default usePeerConnection; diff --git a/client/src/main.tsx b/client/src/main.tsx index fda074a0..1021caff 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,4 +1,5 @@ import ReactDOM from 'react-dom/client'; +import { HelmetProvider } from 'react-helmet-async'; import { BrowserRouter } from 'react-router-dom'; import 'react-toastify/dist/ReactToastify.css'; @@ -7,7 +8,9 @@ import Toaster from './components/common/Toaster'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - + + + + , ); diff --git a/client/src/pages/Workspace/index.tsx b/client/src/pages/Workspace/index.tsx index 71753e63..e5f50c4b 100644 --- a/client/src/pages/Workspace/index.tsx +++ b/client/src/pages/Workspace/index.tsx @@ -1,23 +1,22 @@ -import Workspace from 'components/Workspace'; -import { useEffect, useState } from 'react'; +import DefaultWorkspace from 'components/Workspace/DefaultWorkspace'; +import WorkspaceSkeleton from 'components/Workspace/Skeleton'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { Route, Routes, useNavigate, useParams } from 'react-router-dom'; import { getWorkspaces } from 'src/apis/user'; -import DefaultWorkspace from 'src/components/Workspace/DefaultWorkspace'; import WorkspacesContext from 'src/contexts/workspaces'; import useUserContext from 'src/hooks/context/useUserContext'; -import LoadingPage from 'src/pages/Loading'; import { Workspace as TWorkspace } from 'src/types/workspace'; -import Layout from './Layout'; - function WorkspacePage() { + const Layout = lazy(() => import('./Layout')); + const Workspace = lazy(() => import('components/Workspace')); + const { user } = useUserContext(); const params = useParams(); const navigate = useNavigate(); const [workspaces, setWorkspaces] = useState([]); - const [isLoaded, setIsLoaded] = useState(false); const loadWorkspaces = async () => { if (!user) { @@ -31,8 +30,6 @@ function WorkspacePage() { id: userId, }); - setIsLoaded(true); - setWorkspaces(userWorkspaces); if (!userWorkspaces.length) { @@ -53,18 +50,16 @@ function WorkspacePage() { }, []); return ( - - {isLoaded ? ( + }> + }> } /> } /> - ) : ( - - )} - + + ); } diff --git a/client/src/pages/index.ts b/client/src/pages/index.ts deleted file mode 100644 index 88f5cc2a..00000000 --- a/client/src/pages/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as LoadingPage } from './Loading'; -export { default as WorkspacePage } from './Workspace'; -export { default as LoginPage } from './Login'; -export { default as OAuthPage } from './OAuth'; -export { default as NotFoundPage } from './404'; diff --git a/client/src/utils/rtc/index.ts b/client/src/utils/rtc/index.ts index a414f212..a9c6ec78 100644 --- a/client/src/utils/rtc/index.ts +++ b/client/src/utils/rtc/index.ts @@ -7,6 +7,7 @@ type onMediaDisconnectedCb = (socketId: string) => void; class RTC { static BITRATE = Number(env.WEBRTC_VIDEO_BITRATE); + private socket: Socket; private iceServerUrls: string[]; private userMediaStream: MediaStream; @@ -27,6 +28,28 @@ class RTC { this.streams = new Map(); this.onMediaConnectedCallback = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function this.onMediaDisconnectedCallback = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function + + this.socket.on( + WORKSPACE_EVENT.RECEIVE_HELLO, + this.sendOfferOnHello.bind(this), + ); + this.socket.on( + WORKSPACE_EVENT.RECEIVE_OFFER, + this.sendAnswerOnOffer.bind(this), + ); + this.socket.on( + WORKSPACE_EVENT.RECEIVE_ANSWER, + this.setRemotePeerOnAnswer.bind(this), + ); + this.socket.on(WORKSPACE_EVENT.RECEIVE_ICE, this.addIce.bind(this)); + this.socket.on( + WORKSPACE_EVENT.RECEIVE_BYE, + this.cleanUpRemoteAndEmitMediaDisconnectedEvent.bind(this), + ); + } + + connect() { + this.socket.emit(WORKSPACE_EVENT.SEND_HELLO); } onMediaConnected(callback: onMediaConnectedCb) { @@ -37,7 +60,61 @@ class RTC { this.onMediaDisconnectedCallback = callback; } - #createPeerConnection(remoteSocketId: string) { + private async sendOfferOnHello(remoteSocketId: string) { + const pc = this.createPeerConnection(remoteSocketId); + + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + this.socket.emit(WORKSPACE_EVENT.SEND_OFFER, offer, remoteSocketId); + } + + private async sendAnswerOnOffer( + offer: RTCSessionDescriptionInit, + remoteSocketId: string, + ) { + const pc = this.createPeerConnection(remoteSocketId); + await pc.setRemoteDescription(offer); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + this.setVideoBitrate(pc, RTC.BITRATE); + + this.socket.emit(WORKSPACE_EVENT.SEND_ANSWER, answer, remoteSocketId); + } + + private async setRemotePeerOnAnswer( + answer: RTCSessionDescriptionInit, + remoteSocketId: string, + ) { + const pc = this.connections.get(remoteSocketId); + if (!pc) { + throw new Error('No RTCPeerConnection on answer received.'); + } + + await pc.setRemoteDescription(answer); + + this.setVideoBitrate(pc, RTC.BITRATE); + } + + private addIce(ice: RTCIceCandidateInit, remoteSocketId: string) { + const pc = this.connections.get(remoteSocketId); + if (!pc) { + throw new Error('No RTCPeerConnection on ice candindate received.'); + } + + pc.addIceCandidate(ice); + } + + private cleanUpRemoteAndEmitMediaDisconnectedEvent(remoteSocketId: string) { + this.connections.delete(remoteSocketId); + this.streams.delete(remoteSocketId); + + this.onMediaDisconnectedCallback(remoteSocketId); + } + + private createPeerConnection(remoteSocketId: string) { // initialize const pcOptions = { iceServers: [{ urls: this.iceServerUrls }], @@ -45,33 +122,48 @@ class RTC { const pc = new RTCPeerConnection(pcOptions); // add event listeners - pc.addEventListener('icecandidate', (iceEvent) => { - this.socket.emit( - WORKSPACE_EVENT.SEND_ICE, - iceEvent.candidate, - remoteSocketId, - ); - }); - pc.addEventListener('track', async (event) => { - if (this.streams.has(remoteSocketId)) { - return; - } - - const [remoteStream] = event.streams; - - this.streams.set(remoteSocketId, remoteStream); - this.onMediaConnectedCallback(remoteSocketId, remoteStream); - }); + const emitIce: (iceEvent: RTCPeerConnectionIceEvent) => void = + this.emitIce.bind(this, remoteSocketId); + pc.addEventListener('icecandidate', emitIce); + + const setRemoteTrack: (trackEvent: RTCTrackEvent) => void = + this.setRemoteTrackAndEmitMediaConnectedEvent.bind(this, remoteSocketId); + pc.addEventListener('track', setRemoteTrack); // add tracks this.userMediaStream .getTracks() .forEach((track) => pc.addTrack(track, this.userMediaStream)); + // register connection + this.connections.set(remoteSocketId, pc); + return pc; } - async #setVideoBitrate(pc: RTCPeerConnection, bitrate: number) { + private emitIce(remoteSocketId: string, iceEvent: RTCPeerConnectionIceEvent) { + this.socket.emit( + WORKSPACE_EVENT.SEND_ICE, + iceEvent.candidate, + remoteSocketId, + ); + } + + private setRemoteTrackAndEmitMediaConnectedEvent( + remoteSocketId: string, + trackEvent: RTCTrackEvent, + ) { + if (this.streams.has(remoteSocketId)) { + return; + } + + const [remoteStream] = trackEvent.streams; + + this.streams.set(remoteSocketId, remoteStream); + this.onMediaConnectedCallback(remoteSocketId, remoteStream); + } + + private async setVideoBitrate(pc: RTCPeerConnection, bitrate: number) { // fetch video sender const videoSender = pc .getSenders() @@ -86,72 +178,6 @@ class RTC { params.encodings[0].maxBitrate = bitrate; await videoSender.setParameters(params); } - - connect() { - this.socket.on(WORKSPACE_EVENT.RECEIVE_HELLO, async (remoteSocketId) => { - const pc = this.#createPeerConnection(remoteSocketId); - this.connections.set(remoteSocketId, pc); - - const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); - - this.socket.emit( - WORKSPACE_EVENT.SEND_OFFER, - pc.localDescription, - remoteSocketId, - ); - }); - - this.socket.on( - WORKSPACE_EVENT.RECEIVE_OFFER, - async (offer, remoteSocketId) => { - const pc = this.#createPeerConnection(remoteSocketId); - this.connections.set(remoteSocketId, pc); - - await pc.setRemoteDescription(offer); - - const answer = await pc.createAnswer(); - await pc.setLocalDescription(answer); - - this.#setVideoBitrate(pc, RTC.BITRATE); - - this.socket.emit(WORKSPACE_EVENT.SEND_ANSWER, answer, remoteSocketId); - }, - ); - - this.socket.on( - WORKSPACE_EVENT.RECEIVE_ANSWER, - async (answer, remoteSocketId) => { - const pc = this.connections.get(remoteSocketId); - if (!pc) { - throw new Error('No RTCPeerConnection on answer received.'); - } - - await pc.setRemoteDescription(answer); - - this.#setVideoBitrate(pc, RTC.BITRATE); - }, - ); - - this.socket.on(WORKSPACE_EVENT.RECEIVE_ICE, (ice, remoteSocketId) => { - const pc = this.connections.get(remoteSocketId); - - if (!pc) { - throw new Error('No RTCPeerConnection on ice candindate received.'); - } - - pc.addIceCandidate(ice); - }); - - this.socket.on(WORKSPACE_EVENT.RECEIVE_BYE, (remoteSocketId) => { - this.connections.delete(remoteSocketId); - this.streams.delete(remoteSocketId); - - this.onMediaDisconnectedCallback(remoteSocketId); - }); - - this.socket.emit(WORKSPACE_EVENT.SEND_HELLO); - } } export default RTC; diff --git a/client/src/utils/trackSetter/index.test.ts b/client/src/utils/trackSetter/index.test.ts new file mode 100644 index 00000000..3496cdee --- /dev/null +++ b/client/src/utils/trackSetter/index.test.ts @@ -0,0 +1,143 @@ +import { setTrack, getTrack } from './'; + +const MediaStream = jest.fn().mockImplementation((tracks) => { + return new MediaStreamMock(tracks); +}); + +let stream: any; + +describe('getTrack()', () => { + describe('์ŠคํŠธ๋ฆผ์— ํŠธ๋ž™์ด ์žˆ์„ ๋•Œ', () => { + beforeEach(() => { + stream = new MediaStream(); + }); + + it('audio ํŠธ๋ž™์„ ์„ฑ๊ณต์ ์œผ๋กœ ๊ฐ€์ ธ์˜จ๋‹ค.', async () => { + // act + const track = await getTrack(stream, 'audio'); + + // assert + expect(track).not.toBe(undefined); + }); + + it('video ํŠธ๋ž™์„ ์„ฑ๊ณต์ ์œผ๋กœ ๊ฐ€์ ธ์˜จ๋‹ค.', async () => { + // act + const track = await getTrack(stream, 'video'); + + // assert + expect(track).not.toBe(undefined); + }); + }); + + describe('์ŠคํŠธ๋ฆผ์— ํŠธ๋ž™์ด ์—†์„ ๋•Œ', () => { + beforeEach(() => { + stream = new MediaStream([]); // empty track + }); + + it('audio ํŠธ๋ž™์„ ๊ฐ€์ ธ์˜ค๋ฉด ๋ฆฌํ„ด ๊ฐ’์ด ์—†๋‹ค.', async () => { + // act + const track = await getTrack(stream, 'audio'); + + // assert + expect(track).toBe(undefined); + }); + + it('video ํŠธ๋ž™์„ ๊ฐ€์ ธ์˜ค๋ฉด ๋ฆฌํ„ด ๊ฐ’์ด ์—†๋‹ค.', async () => { + // act + const track = await getTrack(stream, 'video'); + + // assert + expect(track).toBe(undefined); + }); + }); +}); + +describe('setTrack()', () => { + describe('์ŠคํŠธ๋ฆผ์— ํŠธ๋ž™์ด ์žˆ์„ ๋•Œ', () => { + beforeEach(() => { + stream = new MediaStream(); + }); + + it('audio ํŠธ๋ž™์„ ๋น„ํ™œ์„ฑํ™” ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.', async () => { + // act + setTrack(stream, 'audio', false); + + // assert + const track = await getTrack(stream, 'audio'); + expect(track!.enabled).toBe(false); + }); + + it('video ํŠธ๋ž™์„ ๋น„ํ™œ์„ฑํ™” ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.', async () => { + // act + setTrack(stream, 'video', false); + + // assert + const track = await getTrack(stream, 'video'); + expect(track!.enabled).toBe(false); + }); + + it('๋น„ํ™œ์„ฑํ™” ๋œ audio ํŠธ๋ž™์„ ํ™œ์„ฑํ™” ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.', async () => { + // arrange + setTrack(stream, 'audio', false); + + // act + setTrack(stream, 'audio', true); + + // assert + const track = await getTrack(stream, 'audio'); + expect(track!.enabled).toBe(true); + }); + + it('๋น„ํ™œ์„ฑํ™” ๋œ video ํŠธ๋ž™์„ ํ™œ์„ฑํ™” ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.', async () => { + // arrange + setTrack(stream, 'video', false); + + // act + setTrack(stream, 'video', true); + + // assert + const track = await getTrack(stream, 'video'); + expect(track!.enabled).toBe(true); + }); + }); + + describe('์ŠคํŠธ๋ฆผ์— ํŠธ๋ž™์ด ์—†์„ ๋•Œ', () => { + beforeEach(() => { + stream = new MediaStream([]); // empty tracks + }); + + it('audio ํŠธ๋ž™์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ์กฐ์šฉํžˆ ๋ฌด์‹œ๋œ๋‹ค.', async () => { + // assert + expect(() => setTrack(stream, 'audio', false)).not.toThrow(); + }); + + it('video ํŠธ๋ž™์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ์กฐ์šฉํžˆ ๋ฌด์‹œ๋œ๋‹ค.', async () => { + // assert + expect(() => setTrack(stream, 'video', false)).not.toThrow(); + }); + }); +}); + +// mock +class MediaStreamMock { + private tracks: TrackMock[]; + + // ๊ธฐ๋ณธ์ ์œผ๋กœ 'audio', 'video' ํŠธ๋ž™์„ ๋‘ ๊ฐœ ๊ฐ–๋„๋ก ๋งŒ๋“ ๋‹ค. + constructor(tracks = [new TrackMock('video'), new TrackMock('audio')]) { + this.tracks = tracks; + } + + getTracks() { + return this.tracks; + } +} + +class TrackMock { + kind: string; + enabled: boolean; + + constructor(kind: string) { + this.kind = kind; + this.enabled = true; + } +} diff --git a/client/src/utils/trackSetter/index.ts b/client/src/utils/trackSetter/index.ts index dbf747e4..3f5cf5e7 100644 --- a/client/src/utils/trackSetter/index.ts +++ b/client/src/utils/trackSetter/index.ts @@ -1,33 +1,27 @@ export type TrackKind = 'audio' | 'video'; const getAllTracks = async (stream: MediaStream) => { - if (!stream) { - return; - } - const tracks = stream.getTracks(); + return tracks; -} +}; -const getTrack = async (stream: MediaStream, kind: TrackKind) => { +export const getTrack = async (stream: MediaStream, kind: TrackKind) => { const tracks = await getAllTracks(stream); - if (!tracks) { - return; - } + const track = tracks.find((track) => track.kind === kind); - const track = tracks.find(track => track.kind === kind); return track; -} - -export const setTrack = async (stream: MediaStream, kind: TrackKind, turnOn: boolean) => { - if (!stream) { - return; - } +}; +export const setTrack = async ( + stream: MediaStream, + kind: TrackKind, + turnOn: boolean, +) => { const track = await getTrack(stream, kind); if (!track) { return; } track.enabled = turnOn; -} +}; diff --git a/client/vite.config.ts b/client/vite.config.ts index 5deeb84e..ebbd37ea 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,11 +1,11 @@ import { resolve } from 'path'; import react from '@vitejs/plugin-react'; -import { defineConfig, splitVendorChunkPlugin } from 'vite'; +import { defineConfig } from 'vite'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react(), splitVendorChunkPlugin()], + plugins: [react()], resolve: { alias: [ { find: 'src', replacement: resolve(__dirname, './src') }, diff --git a/package-lock.json b/package-lock.json index 20d69133..2ae803a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,11 +61,13 @@ "version": "0.0.0", "dependencies": { "@react-icons/all-files": "^4.1.0", + "@types/react-helmet": "^6.1.6", "axios": "^1.1.3", "classnames": "^2.3.2", "eventemitter3": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", "react-loader-spinner": "^5.3.4", "react-router-dom": "^6.4.3", "react-toastify": "^9.1.1", @@ -1746,7 +1748,6 @@ "version": "7.20.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -1881,7 +1882,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -1897,7 +1897,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3419,8 +3418,7 @@ "node_modules/@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "node_modules/@types/qs": { "version": "6.9.7", @@ -3438,7 +3436,6 @@ "version": "18.0.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3454,11 +3451,18 @@ "@types/react": "*" } }, + "node_modules/@types/react-helmet": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz", + "integrity": "sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "node_modules/@types/semver": { "version": "7.3.13", @@ -3878,7 +3882,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, + "devOptional": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -4232,7 +4236,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -4304,7 +4308,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, + "devOptional": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -4554,7 +4558,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, + "devOptional": true, "funding": [ { "type": "individual", @@ -4581,7 +4585,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -5059,8 +5063,7 @@ "node_modules/csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "dev": true + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, "node_modules/dateformat": { "version": "3.0.3", @@ -5628,7 +5631,6 @@ "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.13.tgz", "integrity": "sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" @@ -5668,7 +5670,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5684,7 +5685,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5700,7 +5700,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5716,7 +5715,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5732,7 +5730,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5748,7 +5745,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5764,7 +5760,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5780,7 +5775,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5796,7 +5790,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5812,7 +5805,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5828,7 +5820,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5844,7 +5835,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5860,7 +5850,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5876,7 +5865,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5892,7 +5880,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -5908,7 +5895,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -5924,7 +5910,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -5940,7 +5925,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5956,7 +5940,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5972,7 +5955,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -6885,7 +6867,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, + "devOptional": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -7046,7 +7028,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -7568,7 +7549,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "dev": true + "devOptional": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -7774,6 +7755,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -7825,7 +7814,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, + "devOptional": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -7865,7 +7854,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "dependencies": { "has": "^1.0.3" }, @@ -7907,7 +7895,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -7937,7 +7925,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -7979,7 +7967,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.12.0" } @@ -10468,7 +10456,6 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true, "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -10604,7 +10591,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -11134,8 +11121,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-to-regexp": { "version": "0.1.7", @@ -11154,8 +11140,7 @@ "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "node_modules/picomatch": { "version": "2.3.1", @@ -11257,7 +11242,6 @@ "version": "8.4.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -11424,7 +11408,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -11434,8 +11417,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -11560,6 +11542,27 @@ "react": "^18.2.0" } }, + "node_modules/react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "node_modules/react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -11777,7 +11780,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "devOptional": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -11829,8 +11832,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", @@ -12007,7 +12009,6 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -12113,7 +12114,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "dev": true, + "devOptional": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -12382,7 +12383,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -13035,7 +13035,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -13309,7 +13308,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "dependencies": { "is-number": "^7.0.0" }, @@ -13697,7 +13696,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.3.tgz", "integrity": "sha512-h8jl1TZ76eGs3o2dIBSsvXDLb1m/Ec1iej8ZMdz+PsaFUsftZeWe2CZOI3qogEsMNaywc17gu0q6cQDzh/weCQ==", - "dev": true, "dependencies": { "esbuild": "^0.15.9", "postcss": "^8.4.18", @@ -13809,7 +13807,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -15632,7 +15629,6 @@ "version": "7.20.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.11" } @@ -15705,7 +15701,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.2.tgz", "integrity": "sha512-IkpVW/ehM1hWKln4fCA3NzJU8KwD+kIOvPZA4cqxoJHtE21CCzjyp+Kxbu0i5I4tBNOlXPL9mjwnWlL0VEG4Fg==", - "dev": true + "dev": true, + "requires": {} }, "@cush/relative": { "version": "1.0.0", @@ -15740,14 +15737,12 @@ "version": "0.15.13", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.15.13.tgz", "integrity": "sha512-RY2fVI8O0iFUNvZirXaQ1vMvK0xhCcl0gqRj74Z6yEiO1zAUa7hbsdwZM1kzqbxHK7LFyMizipfXT3JME+12Hw==", - "dev": true, "optional": true }, "@esbuild/linux-loong64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.15.13.tgz", "integrity": "sha512-+BoyIm4I8uJmH/QDIH0fu7MG0AEx9OXEDXnqptXCwKOlOqZiS4iraH1Nr7/ObLMokW3sOCeBNyD68ATcV9b9Ag==", - "dev": true, "optional": true }, "@eslint/eslintrc": { @@ -16640,7 +16635,8 @@ "@react-icons/all-files": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@react-icons/all-files/-/all-files-4.1.0.tgz", - "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==" + "integrity": "sha512-hxBI2UOuVaI3O/BhQfhtb4kcGn9ft12RWAFVMUeNjqqhLsHvFtzIkFaptBJpFDANTKoDfdVoHTKZDlwKCACbMQ==", + "requires": {} }, "@remix-run/router": { "version": "1.0.3", @@ -16964,8 +16960,7 @@ "@types/prop-types": { "version": "15.7.5", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz", - "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==", - "dev": true + "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==" }, "@types/qs": { "version": "6.9.7", @@ -16983,7 +16978,6 @@ "version": "18.0.25", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.0.25.tgz", "integrity": "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g==", - "dev": true, "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -16999,11 +16993,18 @@ "@types/react": "*" } }, + "@types/react-helmet": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.6.tgz", + "integrity": "sha512-ZKcoOdW/Tg+kiUbkFCBtvDw0k3nD4HJ/h/B9yWxN4uDO8OkRksWTO+EL+z/Qu3aHTeTll3Ro0Cc/8UhwBCMG5A==", + "requires": { + "@types/react": "*" + } + }, "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", - "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==", - "dev": true + "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, "@types/semver": { "version": "7.3.13", @@ -17221,7 +17222,8 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "8.2.0", @@ -17288,7 +17290,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==", - "dev": true, + "devOptional": true, "requires": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -17551,7 +17553,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true + "devOptional": true }, "bl": { "version": "4.1.0", @@ -17618,7 +17620,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, + "devOptional": true, "requires": { "fill-range": "^7.0.1" } @@ -17782,7 +17784,7 @@ "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, + "devOptional": true, "requires": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -17798,7 +17800,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "requires": { "is-glob": "^4.0.1" } @@ -17921,6 +17923,7 @@ "@testing-library/jest-dom": "^5.16.5", "@types/react": "^18.0.24", "@types/react-dom": "^18.0.8", + "@types/react-helmet": "^6.1.6", "@types/testing-library__jest-dom": "^5", "@vitejs/plugin-react": "^2.2.0", "axios": "^1.1.3", @@ -17929,6 +17932,7 @@ "eventemitter3": "^5.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", "react-loader-spinner": "^5.3.4", "react-router-dom": "^6.4.3", "react-toastify": "^9.1.1", @@ -18194,8 +18198,7 @@ "csstype": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", - "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", - "dev": true + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, "dateformat": { "version": "3.0.3", @@ -18634,7 +18637,6 @@ "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.13.tgz", "integrity": "sha512-Cu3SC84oyzzhrK/YyN4iEVy2jZu5t2fz66HEOShHURcjSkOSAVL8C/gfUT+lDJxkVHpg8GZ10DD0rMHRPqMFaQ==", - "dev": true, "requires": { "@esbuild/android-arm": "0.15.13", "@esbuild/linux-loong64": "0.15.13", @@ -18664,140 +18666,120 @@ "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.15.13.tgz", "integrity": "sha512-yRorukXBlokwTip+Sy4MYskLhJsO0Kn0/Fj43s1krVblfwP+hMD37a4Wmg139GEsMLl+vh8WXp2mq/cTA9J97g==", - "dev": true, "optional": true }, "esbuild-android-arm64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.15.13.tgz", "integrity": "sha512-TKzyymLD6PiVeyYa4c5wdPw87BeAiTXNtK6amWUcXZxkV51gOk5u5qzmDaYSwiWeecSNHamFsaFjLoi32QR5/w==", - "dev": true, "optional": true }, "esbuild-darwin-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.15.13.tgz", "integrity": "sha512-WAx7c2DaOS6CrRcoYCgXgkXDliLnFv3pQLV6GeW1YcGEZq2Gnl8s9Pg7ahValZkpOa0iE/ojRVQ87sbUhF1Cbg==", - "dev": true, "optional": true }, "esbuild-darwin-arm64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.13.tgz", "integrity": "sha512-U6jFsPfSSxC3V1CLiQqwvDuj3GGrtQNB3P3nNC3+q99EKf94UGpsG9l4CQ83zBs1NHrk1rtCSYT0+KfK5LsD8A==", - "dev": true, "optional": true }, "esbuild-freebsd-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.13.tgz", "integrity": "sha512-whItJgDiOXaDG/idy75qqevIpZjnReZkMGCgQaBWZuKHoElDJC1rh7MpoUgupMcdfOd+PgdEwNQW9DAE6i8wyA==", - "dev": true, "optional": true }, "esbuild-freebsd-arm64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.13.tgz", "integrity": "sha512-6pCSWt8mLUbPtygv7cufV0sZLeylaMwS5Fznj6Rsx9G2AJJsAjQ9ifA+0rQEIg7DwJmi9it+WjzNTEAzzdoM3Q==", - "dev": true, "optional": true }, "esbuild-linux-32": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.15.13.tgz", "integrity": "sha512-VbZdWOEdrJiYApm2kkxoTOgsoCO1krBZ3quHdYk3g3ivWaMwNIVPIfEE0f0XQQ0u5pJtBsnk2/7OPiCFIPOe/w==", - "dev": true, "optional": true }, "esbuild-linux-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.15.13.tgz", "integrity": "sha512-rXmnArVNio6yANSqDQlIO4WiP+Cv7+9EuAHNnag7rByAqFVuRusLbGi2697A5dFPNXoO//IiogVwi3AdcfPC6A==", - "dev": true, "optional": true }, "esbuild-linux-arm": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.15.13.tgz", "integrity": "sha512-Ac6LpfmJO8WhCMQmO253xX2IU2B3wPDbl4IvR0hnqcPrdfCaUa2j/lLMGTjmQ4W5JsJIdHEdW12dG8lFS0MbxQ==", - "dev": true, "optional": true }, "esbuild-linux-arm64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.13.tgz", "integrity": "sha512-alEMGU4Z+d17U7KQQw2IV8tQycO6T+rOrgW8OS22Ua25x6kHxoG6Ngry6Aq6uranC+pNWNMB6aHFPh7aTQdORQ==", - "dev": true, "optional": true }, "esbuild-linux-mips64le": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.13.tgz", "integrity": "sha512-47PgmyYEu+yN5rD/MbwS6DxP2FSGPo4Uxg5LwIdxTiyGC2XKwHhHyW7YYEDlSuXLQXEdTO7mYe8zQ74czP7W8A==", - "dev": true, "optional": true }, "esbuild-linux-ppc64le": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.13.tgz", "integrity": "sha512-z6n28h2+PC1Ayle9DjKoBRcx/4cxHoOa2e689e2aDJSaKug3jXcQw7mM+GLg+9ydYoNzj8QxNL8ihOv/OnezhA==", - "dev": true, "optional": true }, "esbuild-linux-riscv64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.13.tgz", "integrity": "sha512-+Lu4zuuXuQhgLUGyZloWCqTslcCAjMZH1k3Xc9MSEJEpEFdpsSU0sRDXAnk18FKOfEjhu4YMGaykx9xjtpA6ow==", - "dev": true, "optional": true }, "esbuild-linux-s390x": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.13.tgz", "integrity": "sha512-BMeXRljruf7J0TMxD5CIXS65y7puiZkAh+s4XFV9qy16SxOuMhxhVIXYLnbdfLrsYGFzx7U9mcdpFWkkvy/Uag==", - "dev": true, "optional": true }, "esbuild-netbsd-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.13.tgz", "integrity": "sha512-EHj9QZOTel581JPj7UO3xYbltFTYnHy+SIqJVq6yd3KkCrsHRbapiPb0Lx3EOOtybBEE9EyqbmfW1NlSDsSzvQ==", - "dev": true, "optional": true }, "esbuild-openbsd-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.13.tgz", "integrity": "sha512-nkuDlIjF/sfUhfx8SKq0+U+Fgx5K9JcPq1mUodnxI0x4kBdCv46rOGWbuJ6eof2n3wdoCLccOoJAbg9ba/bT2w==", - "dev": true, "optional": true }, "esbuild-sunos-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.15.13.tgz", "integrity": "sha512-jVeu2GfxZQ++6lRdY43CS0Tm/r4WuQQ0Pdsrxbw+aOrHQPHV0+LNOLnvbN28M7BSUGnJnHkHm2HozGgNGyeIRw==", - "dev": true, "optional": true }, "esbuild-windows-32": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.15.13.tgz", "integrity": "sha512-XoF2iBf0wnqo16SDq+aDGi/+QbaLFpkiRarPVssMh9KYbFNCqPLlGAWwDvxEVz+ywX6Si37J2AKm+AXq1kC0JA==", - "dev": true, "optional": true }, "esbuild-windows-64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.15.13.tgz", "integrity": "sha512-Et6htEfGycjDrtqb2ng6nT+baesZPYQIW+HUEHK4D1ncggNrDNk3yoboYQ5KtiVrw/JaDMNttz8rrPubV/fvPQ==", - "dev": true, "optional": true }, "esbuild-windows-arm64": { "version": "0.15.13", "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.13.tgz", "integrity": "sha512-3bv7tqntThQC9SWLRouMDmZnlOukBhOCTlkzNqzGCmrkCJI7io5LLjwJBOVY6kOUlIvdxbooNZwjtBvj+7uuVg==", - "dev": true, "optional": true }, "escalade": { @@ -18929,7 +18911,8 @@ "version": "8.5.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz", "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==", - "dev": true + "dev": true, + "requires": {} }, "eslint-import-resolver-node": { "version": "0.3.6", @@ -19498,7 +19481,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, + "devOptional": true, "requires": { "to-regex-range": "^5.0.1" } @@ -19620,7 +19603,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "optional": true }, "function-bind": { @@ -19989,7 +19971,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.1.0.tgz", "integrity": "sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ==", - "dev": true + "devOptional": true }, "import-fresh": { "version": "3.3.0", @@ -20143,6 +20125,14 @@ "side-channel": "^1.0.4" } }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "ip": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", @@ -20182,7 +20172,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, + "devOptional": true, "requires": { "binary-extensions": "^2.0.0" } @@ -20207,7 +20197,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -20231,7 +20220,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true + "devOptional": true }, "is-fullwidth-code-point": { "version": "4.0.0", @@ -20249,7 +20238,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "requires": { "is-extglob": "^2.1.1" } @@ -20276,7 +20265,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true + "devOptional": true }, "is-number-object": { "version": "1.0.7", @@ -20952,7 +20941,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz", "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==", - "dev": true + "dev": true, + "requires": {} }, "jest-regex-util": { "version": "29.2.0", @@ -22109,8 +22099,7 @@ "nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", - "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==", - "dev": true + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" }, "natural-compare": { "version": "1.4.0", @@ -22220,7 +22209,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true + "devOptional": true }, "npm-run-path": { "version": "5.1.0", @@ -22588,8 +22577,7 @@ "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "path-to-regexp": { "version": "0.1.7", @@ -22605,8 +22593,7 @@ "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, "picomatch": { "version": "2.3.1", @@ -22677,7 +22664,6 @@ "version": "8.4.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.19.tgz", "integrity": "sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA==", - "dev": true, "requires": { "nanoid": "^3.3.4", "picocolors": "^1.0.0", @@ -22700,13 +22686,15 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-scss": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.5.tgz", "integrity": "sha512-F7xpB6TrXyqUh3GKdyB4Gkp3QL3DDW1+uI+gxx/oJnUt/qXI4trj5OGlp9rOKdoABGULuqtqeG+3HEVQk4DjmA==", - "dev": true + "dev": true, + "requires": {} }, "postcss-selector-parser": { "version": "6.0.10", @@ -22777,7 +22765,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -22787,8 +22774,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -22874,6 +22860,23 @@ "scheduler": "^0.23.0" } }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, + "react-helmet-async": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-1.3.0.tgz", + "integrity": "sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg==", + "requires": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -23035,7 +23038,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, + "devOptional": true, "requires": { "picomatch": "^2.2.1" } @@ -23083,8 +23086,7 @@ "regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", - "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", - "dev": true + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, "regexp.prototype.flags": { "version": "1.4.3", @@ -23207,7 +23209,6 @@ "version": "2.79.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", - "dev": true, "requires": { "fsevents": "~2.3.2" } @@ -23270,7 +23271,7 @@ "version": "1.56.1", "resolved": "https://registry.npmjs.org/sass/-/sass-1.56.1.tgz", "integrity": "sha512-VpEyKpyBPCxE7qGDtOcdJ6fFbcpOM+Emu7uZLxVrkX8KVU/Dp5UF7WLvzqRuUhB6mqqQt1xffLoG+AndxTZrCQ==", - "dev": true, + "devOptional": true, "requires": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -23510,8 +23511,7 @@ "source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "dev": true + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" }, "source-map-support": { "version": "0.5.13", @@ -23876,7 +23876,8 @@ "version": "9.0.4", "resolved": "https://registry.npmjs.org/stylelint-config-prettier/-/stylelint-config-prettier-9.0.4.tgz", "integrity": "sha512-38nIGTGpFOiK5LjJ8Ma1yUgpKENxoKSOhbDNSemY7Ep0VsJoXIW9Iq/2hSt699oB9tReynfWicTAoIHiq8Rvbg==", - "dev": true + "dev": true, + "requires": {} }, "stylelint-config-prettier-scss": { "version": "0.0.1", @@ -23901,7 +23902,8 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-9.0.0.tgz", "integrity": "sha512-9YQSrJq4NvvRuTbzDsWX3rrFOzOlYBmZP+o513BJN/yfEmGSr0AxdvrWs0P/ilSpVV/wisamAHu5XSk8Rcf4CQ==", - "dev": true + "dev": true, + "requires": {} }, "stylelint-config-recommended-scss": { "version": "8.0.0", @@ -23928,7 +23930,8 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-7.0.1.tgz", "integrity": "sha512-iLBFYz6VRYyLJEJsBJ8M3TCqNcckVzz4wFounSc5Oez35ogE/X+aoC5fFu103Ot7NyvjU3/xqIXn93Gp3kJk4g==", - "dev": true + "dev": true, + "requires": {} } } }, @@ -24002,8 +24005,7 @@ "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" }, "svg-tags": { "version": "1.0.0", @@ -24218,7 +24220,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "requires": { "is-number": "^7.0.0" } @@ -24473,7 +24475,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.3.tgz", "integrity": "sha512-h8jl1TZ76eGs3o2dIBSsvXDLb1m/Ec1iej8ZMdz+PsaFUsftZeWe2CZOI3qogEsMNaywc17gu0q6cQDzh/weCQ==", - "dev": true, "requires": { "esbuild": "^0.15.9", "fsevents": "~2.3.2", @@ -24486,7 +24487,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -24750,7 +24750,8 @@ "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", - "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==" + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} }, "xmlhttprequest-ssl": { "version": "2.0.0", diff --git a/server/apis/user/controller.ts b/server/apis/user/controller.ts index a14e9606..b7ed9eff 100644 --- a/server/apis/user/controller.ts +++ b/server/apis/user/controller.ts @@ -1,6 +1,6 @@ import jwtAuthenticator from '@middlewares/jwt-authenticator'; -import { GetWorkspaceParams } from '@wabinar/api-types/user'; import asyncWrapper from '@utils/async-wrapper'; +import { GetWorkspaceParams } from '@wabinar/api-types/user'; import express, { Request, Response } from 'express'; import * as userService from './service'; diff --git a/server/apis/workspace/controller.ts b/server/apis/workspace/controller.ts index 182aa909..5036788a 100644 --- a/server/apis/workspace/controller.ts +++ b/server/apis/workspace/controller.ts @@ -1,11 +1,11 @@ import { CREATED, OK } from '@constants/http-status'; import jwtAuthenticator from '@middlewares/jwt-authenticator'; +import asyncWrapper from '@utils/async-wrapper'; import { GetInfoParams, - PostJoinBody, PostBody, + PostJoinBody, } from '@wabinar/api-types/workspace'; -import asyncWrapper from '@utils/async-wrapper'; import express, { Request, Response } from 'express'; import * as workspaceService from './service'; diff --git a/server/config/index.ts b/server/config/index.ts index bbdcc9ce..0338e51e 100644 --- a/server/config/index.ts +++ b/server/config/index.ts @@ -15,4 +15,5 @@ export default { CLIENT_PATH: process.env.CLIENT_PATH, PORT: process.env.PORT, SOCKET_PATH: process.env.WEBSOCKET_PATH, + NODE_ENV: process.env.NODE_ENV, }; diff --git a/server/index.ts b/server/index.ts index ced50175..74d73cbe 100644 --- a/server/index.ts +++ b/server/index.ts @@ -12,7 +12,7 @@ import morgan from 'morgan'; import { Server } from 'socket.io'; const app = express(); -app.use(morgan('dev')); +app.use(env.NODE_ENV === 'production' ? morgan('combined') : morgan('dev')); app.use(express.json()); app.use(cookieParser(env.COOKIE_SECRET_KEY)); app.use(cors()); diff --git a/server/jest.config.js b/server/jest.config.js index 2266f99d..17ce7dac 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -11,4 +11,5 @@ module.exports = { '@errors/(.*)': '/errors/$1', }, modulePathIgnorePatterns: ['/dist/'], + collectCoverage: true, }; diff --git a/server/socket/Mom/handleTextBlock.ts b/server/socket/Mom/handleTextBlock.ts deleted file mode 100644 index f5b25c62..00000000 --- a/server/socket/Mom/handleTextBlock.ts +++ /dev/null @@ -1,54 +0,0 @@ -import CrdtManager from '@utils/crdt-manager'; -import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; -import { Socket } from 'socket.io'; - -export default function handleTextBlock( - socket: Socket, - crdtManager: CrdtManager, -) { - socket.on(BLOCK_EVENT.INIT_TEXT, async (blockId) => { - const blockCrdt = await crdtManager.getBlockCRDT(blockId); - - socket.emit(BLOCK_EVENT.INIT_TEXT, blockId, blockCrdt.data); - }); - - socket.on(BLOCK_EVENT.INSERT_TEXT, async (blockId, op) => { - const momId = socket.data.momId; - - try { - await crdtManager.onInsertText(blockId, op); - - socket.to(momId).emit(BLOCK_EVENT.INSERT_TEXT, blockId, op); - } catch { - const blockCrdt = await crdtManager.getBlockCRDT(blockId); - - socket.emit(BLOCK_EVENT.INIT_TEXT, blockId, blockCrdt.data); - } - }); - - socket.on(BLOCK_EVENT.DELETE_TEXT, async (blockId, op) => { - const momId = socket.data.momId; - - try { - await crdtManager.onDeleteText(blockId, op); - - socket.to(momId).emit(BLOCK_EVENT.DELETE_TEXT, blockId, op); - } catch { - const blockCrdt = await crdtManager.getBlockCRDT(blockId); - - socket.emit(BLOCK_EVENT.INIT_TEXT, blockId, blockCrdt.data); - } - }); - - socket.on(BLOCK_EVENT.UPDATE_TEXT, async (blockId, ops) => { - const momId = socket.data.momId; - - for await (const op of ops) { - await crdtManager.onInsertText(blockId, op); - } - - const blockCrdt = await crdtManager.getBlockCRDT(blockId); - - socket.to(momId).emit(BLOCK_EVENT.UPDATE_TEXT, blockId, blockCrdt.data); - }); -} diff --git a/server/socket/Mom/index.ts b/server/socket/Mom/index.ts deleted file mode 100644 index 8bfdadc8..00000000 --- a/server/socket/Mom/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Server } from 'socket.io'; - -import { getBlockType, putBlockType } from '@apis/mom/block/service'; -import { putMomTitle } from '@apis/mom/service'; -import CrdtManager from '@utils/crdt-manager'; -import { BLOCK_EVENT, MOM_EVENT } from '@wabinar/constants/socket-message'; - -import handleQuestionBlock from './handleQuestionBlock'; -import handleTextBlock from './handleTextBlock'; -import handleVoteBlock from './handleVoteBlock'; - -async function momSocketServer(io: Server) { - const workspace = io.of(/^\/sc-workspace\/\d+$/); - - const crdtManager = new CrdtManager(); - - workspace.on('connection', async (socket) => { - const namespace = socket.nsp.name; - const workspaceId = namespace.match(/\d+/g)[0]; - - if (!workspaceId) { - socket.disconnect(); - return; - } - - /* ํšŒ์˜๋ก ์ถ”๊ฐ€ํ•˜๊ธฐ */ - socket.on(MOM_EVENT.CREATE, async () => { - const mom = await crdtManager.onCreateMom(workspaceId); - - io.of(namespace).emit(MOM_EVENT.CREATE, mom); - }); - - /* ํšŒ์˜๋ก ์„ ํƒํ•˜๊ธฐ */ - socket.on(MOM_EVENT.SELECT, async (momId) => { - // ๊ธฐ์กด join ๋˜์–ด์žˆ๋˜ room์€ leave - const joinedRooms = [ - ...io.of(namespace).adapter.socketRooms(socket.id), - ].filter((id) => id !== socket.id); - - joinedRooms.forEach((room) => socket.leave(room)); - - // ์„ ํƒ๋œ ํšŒ์˜๋ก room์— join - socket.join(momId); - socket.data.momId = momId; - - const mom = await crdtManager.onSelectMom(momId); - - // ์„ ํƒ๋œ ํšŒ์˜๋ก์˜ ์ •๋ณด ์ „๋‹ฌ - socket.emit(MOM_EVENT.SELECT, mom); - }); - - socket.on(MOM_EVENT.UPDATE_TITLE, async (title: string) => { - const momId = socket.data.momId; - - await putMomTitle(momId, title); - - socket.to(momId).emit(MOM_EVENT.UPDATE_TITLE, title); - }); - - /* crdt for Mom */ - socket.on(MOM_EVENT.INIT, async () => { - const momId = socket.data.momId; - - const momCrdt = await crdtManager.getMomCRDT(momId); - - socket.emit(MOM_EVENT.INIT, momCrdt.data); - }); - - socket.on(MOM_EVENT.INSERT_BLOCK, async (blockId, op) => { - const momId = socket.data.momId; - - try { - await crdtManager.onInsertBlock(momId, blockId, op); - - socket.emit(MOM_EVENT.UPDATED); - socket.to(momId).emit(MOM_EVENT.INSERT_BLOCK, op); - } catch { - const momCrdt = await crdtManager.getMomCRDT(momId); - - socket.emit(MOM_EVENT.INIT, momCrdt.data); - } - }); - - socket.on(MOM_EVENT.DELETE_BLOCK, async (blockId, op) => { - const momId = socket.data.momId; - - try { - await crdtManager.onDeleteBlock(momId, blockId, op); - - socket.to(momId).emit(MOM_EVENT.DELETE_BLOCK, op); - } catch { - const momCrdt = await crdtManager.getMomCRDT(momId); - - socket.emit(MOM_EVENT.INIT, momCrdt.data); - } - }); - - socket.on(BLOCK_EVENT.LOAD_TYPE, async (blockId, callback) => { - const type = await getBlockType(blockId); - - callback(type); - }); - - socket.on(BLOCK_EVENT.UPDATE_TYPE, async (blockId, type) => { - const momId = socket.data.momId; - - await putBlockType(blockId, type); - - socket.to(momId).emit(BLOCK_EVENT.UPDATE_TYPE, blockId, type); - }); - - handleTextBlock(socket, crdtManager); - handleVoteBlock(io, namespace, socket); - handleQuestionBlock(io, namespace, socket); - - socket.on('error', (err) => { - console.log(err); - socket.disconnect(); - }); - - socket.on('disconnect', () => { - console.log('user disconnected', socket.id); - }); - }); -} - -export default momSocketServer; diff --git a/server/socket/index.ts b/server/socket/index.ts index b6894e9e..43666706 100644 --- a/server/socket/index.ts +++ b/server/socket/index.ts @@ -1,2 +1,2 @@ -export { default as momSocketServer } from './Mom'; +export { default as momSocketServer } from './mom'; export { default as workspaceSocketServer } from './workspace'; diff --git a/server/socket/Mom/handleQuestionBlock.ts b/server/socket/mom/handleQuestionBlock.ts similarity index 100% rename from server/socket/Mom/handleQuestionBlock.ts rename to server/socket/mom/handleQuestionBlock.ts diff --git a/server/socket/mom/handleTextBlock.ts b/server/socket/mom/handleTextBlock.ts new file mode 100644 index 00000000..9182debf --- /dev/null +++ b/server/socket/mom/handleTextBlock.ts @@ -0,0 +1,71 @@ +import CrdtManager from '@utils/crdt-manager'; +import { BLOCK_EVENT } from '@wabinar/constants/socket-message'; +import { Socket } from 'socket.io'; +import * as BlockMessage from '@wabinar/api-types/block'; + +export default function handleTextBlock( + socket: Socket, + crdtManager: CrdtManager, +) { + const initBlock = async (id: string) => { + const blockCrdt = await crdtManager.getBlockCRDT(id); + + const message: BlockMessage.InitializedText = { id, crdt: blockCrdt.data }; + socket.emit(BLOCK_EVENT.INIT_TEXT, message); + }; + + socket.on(BLOCK_EVENT.INIT_TEXT, async ({ id }: BlockMessage.InitText) => { + initBlock(id); + }); + + socket.on( + BLOCK_EVENT.INSERT_TEXT, + async ({ id, op }: BlockMessage.InsertText) => { + const momId = socket.data.momId; + + try { + await crdtManager.onInsertText(id, op); + + const message: BlockMessage.InsertedText = { id, op }; + socket.to(momId).emit(BLOCK_EVENT.INSERT_TEXT, message); + } catch { + initBlock(id); + } + }, + ); + + socket.on( + BLOCK_EVENT.DELETE_TEXT, + async ({ id, op }: BlockMessage.DeleteText) => { + const momId = socket.data.momId; + + try { + await crdtManager.onDeleteText(id, op); + + const message: BlockMessage.DeletedText = { id, op }; + socket.to(momId).emit(BLOCK_EVENT.DELETE_TEXT, message); + } catch { + initBlock(id); + } + }, + ); + + socket.on( + BLOCK_EVENT.UPDATE_TEXT, + async ({ id: blockId, ops }: BlockMessage.UpdateText) => { + const momId = socket.data.momId; + + for await (const op of ops) { + await crdtManager.onInsertText(blockId, op); + } + + const blockCrdt = await crdtManager.getBlockCRDT(blockId); + + const message: BlockMessage.UpdatedText = { + id: blockId, + crdt: blockCrdt.data, + }; + socket.to(momId).emit(BLOCK_EVENT.UPDATE_TEXT, message); + }, + ); +} diff --git a/server/socket/Mom/handleVoteBlock.ts b/server/socket/mom/handleVoteBlock.ts similarity index 100% rename from server/socket/Mom/handleVoteBlock.ts rename to server/socket/mom/handleVoteBlock.ts diff --git a/server/socket/mom/index.ts b/server/socket/mom/index.ts new file mode 100644 index 00000000..348f7265 --- /dev/null +++ b/server/socket/mom/index.ts @@ -0,0 +1,152 @@ +import { Server } from 'socket.io'; + +import { getBlockType, putBlockType } from '@apis/mom/block/service'; +import { putMomTitle } from '@apis/mom/service'; +import CrdtManager from '@utils/crdt-manager'; +import { BLOCK_EVENT, MOM_EVENT } from '@wabinar/constants/socket-message'; +import * as MomMessage from '@wabinar/api-types/mom'; +import * as BlockMessage from '@wabinar/api-types/block'; + +import handleQuestionBlock from './handleQuestionBlock'; +import handleTextBlock from './handleTextBlock'; +import handleVoteBlock from './handleVoteBlock'; + +async function momSocketServer(io: Server) { + const workspace = io.of(/^\/workspace-mom\/\d+$/); + + const crdtManager = new CrdtManager(); + + workspace.on('connection', async (socket) => { + const namespace = socket.nsp.name; + const workspaceId = namespace.match(/\d+/g)[0]; + + if (!workspaceId) { + socket.disconnect(); + return; + } + + /* ํšŒ์˜๋ก ์ถ”๊ฐ€ํ•˜๊ธฐ */ + socket.on(MOM_EVENT.CREATE, async () => { + const mom = await crdtManager.onCreateMom(workspaceId); + + // TODO: ๋ฉ”์„ธ์ง€ ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€, ํด๋ผ์ด์–ธํŠธ Mom ํƒ€์ž… ์ •์˜ ๋ฐ˜์˜ + io.of(namespace).emit(MOM_EVENT.CREATE, { mom }); + }); + + /* ํšŒ์˜๋ก ์„ ํƒํ•˜๊ธฐ */ + socket.on(MOM_EVENT.SELECT, async ({ id: momId }: MomMessage.Select) => { + // ๊ธฐ์กด join ๋˜์–ด์žˆ๋˜ room์€ leave + const joinedRooms = [ + ...io.of(namespace).adapter.socketRooms(socket.id), + ].filter((id) => id !== socket.id); + + joinedRooms.forEach((room) => socket.leave(room)); + + // ์„ ํƒ๋œ ํšŒ์˜๋ก room์— join + socket.join(momId); + socket.data.momId = momId; + + const mom = await crdtManager.onSelectMom(momId); + + // ์„ ํƒ๋œ ํšŒ์˜๋ก์˜ ์ •๋ณด ์ „๋‹ฌ + // TODO: ๋ฉ”์„ธ์ง€ ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€ + socket.emit(MOM_EVENT.SELECT, { mom }); + }); + + socket.on( + MOM_EVENT.UPDATE_TITLE, + async ({ title }: MomMessage.UpdateTitle) => { + const momId = socket.data.momId; + + await putMomTitle(momId, title); + + const message: MomMessage.UpdatedTitle = { title }; + socket.to(momId).emit(MOM_EVENT.UPDATE_TITLE, message); + }, + ); + + const initMom = async (id: string) => { + const momCrdt = await crdtManager.getMomCRDT(id); + + const message: MomMessage.Initialized = { crdt: momCrdt.data }; + socket.emit(MOM_EVENT.INIT, message); + }; + + socket.on(MOM_EVENT.INIT, async () => { + const momId = socket.data.momId; + + initMom(momId); + }); + + socket.on( + MOM_EVENT.INSERT_BLOCK, + async ({ blockId, op }: MomMessage.InsertBlock) => { + const momId = socket.data.momId; + + try { + await crdtManager.onInsertBlock(momId, blockId, op); + + socket.emit(MOM_EVENT.UPDATED); + + const message: MomMessage.InsertedBlock = { op }; + socket.to(momId).emit(MOM_EVENT.INSERT_BLOCK, message); + } catch { + initMom(momId); + } + }, + ); + + socket.on( + MOM_EVENT.DELETE_BLOCK, + async ({ blockId, op }: MomMessage.DeleteBlock) => { + const momId = socket.data.momId; + + try { + await crdtManager.onDeleteBlock(momId, blockId, op); + + const message: MomMessage.DeletedBlock = { op }; + socket.to(momId).emit(MOM_EVENT.DELETE_BLOCK, message); + } catch { + initMom(momId); + } + }, + ); + + socket.on( + BLOCK_EVENT.LOAD_TYPE, + async ({ id: blockId }: BlockMessage.LoadType, callback) => { + const type = await getBlockType(blockId); + + const message: BlockMessage.LoadedType = { type }; + callback(message); + }, + ); + + socket.on( + BLOCK_EVENT.UPDATE_TYPE, + async ({ id: blockId, type }: BlockMessage.UpdateType) => { + const momId = socket.data.momId; + + await putBlockType(blockId, type); + + const message: BlockMessage.UpdatedType = { id: blockId, type }; + socket.to(momId).emit(BLOCK_EVENT.UPDATE_TYPE, message); + }, + ); + + handleTextBlock(socket, crdtManager); + handleVoteBlock(io, namespace, socket); + handleQuestionBlock(io, namespace, socket); + + socket.on('error', (err) => { + console.log(err); + socket.disconnect(); + }); + + socket.on('disconnect', () => { + console.log('user disconnected', socket.id); + }); + }); +} + +export default momSocketServer; diff --git a/server/socket/workspace.ts b/server/socket/workspace.ts index d85e166b..8ce9648d 100644 --- a/server/socket/workspace.ts +++ b/server/socket/workspace.ts @@ -5,14 +5,6 @@ function workspaceSocketServer(io: Server) { const namespace = io.of(/^\/workspace\/\d+$/); namespace.on('connection', (socket) => { - socket.on(WORKSPACE_EVENT.START_MEETING, () => { - namespace.emit(WORKSPACE_EVENT.START_MEETING); - }); - - socket.on(WORKSPACE_EVENT.END_MEETING, () => { - namespace.emit(WORKSPACE_EVENT.END_MEETING); - }); - socket.on(WORKSPACE_EVENT.SEND_HELLO, () => { const senderId = socket.id; @@ -43,16 +35,15 @@ function workspaceSocketServer(io: Server) { socket.to(receiverId).emit(WORKSPACE_EVENT.RECEIVE_ICE, ice, senderId); }); - // TODO: ์†Œ์ผ“ ์ด๋ฒคํŠธ ๋ฉ”์‹œ์ง€ ์ƒ์ˆ˜ํ™” - socket.on('audio_state_changed', (audioOn) => { - namespace.emit('audio_state_changed', socket.id, audioOn); + socket.on(WORKSPACE_EVENT.AUDIO_STATE_CHANGED, (audioOn) => { + namespace.emit(WORKSPACE_EVENT.AUDIO_STATE_CHANGED, socket.id, audioOn); }); - socket.on('video_state_changed', (videoOn) => { - namespace.emit('video_state_changed', socket.id, videoOn); + socket.on(WORKSPACE_EVENT.VIDEO_STATE_CHANGED, (videoOn) => { + namespace.emit(WORKSPACE_EVENT.VIDEO_STATE_CHANGED, socket.id, videoOn); }); - socket.on('bye', () => { + socket.on(WORKSPACE_EVENT.SEND_BYE, () => { const senderId = socket.id; socket.broadcast.emit(WORKSPACE_EVENT.RECEIVE_BYE, senderId); });