├── public ├── robots.txt ├── favicon.ico ├── logo192.png ├── logo512.png ├── manifest.json └── index.html ├── src ├── chess │ ├── assets │ │ ├── chessBoard.png │ │ └── moveSoundEffect.mp3 │ ├── ui │ │ ├── piecemap.js │ │ ├── piece.js │ │ └── chessgame.js │ └── model │ │ ├── chesspiece.js │ │ ├── square.js │ │ └── chess.js ├── context │ └── colorcontext.js ├── setupTests.js ├── App.test.js ├── connection │ ├── socket.js │ └── videochat.js ├── index.js ├── onboard │ ├── joingame.js │ ├── joinroom.js │ └── onboard.js ├── logo.svg ├── App.js └── serviceWorker.js ├── firebase.json ├── README.md ├── .gitignore └── package.json /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackHeTech/multiplayer-chess-game/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackHeTech/multiplayer-chess-game/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackHeTech/multiplayer-chess-game/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/chess/assets/chessBoard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackHeTech/multiplayer-chess-game/HEAD/src/chess/assets/chessBoard.png -------------------------------------------------------------------------------- /src/chess/assets/moveSoundEffect.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JackHeTech/multiplayer-chess-game/HEAD/src/chess/assets/moveSoundEffect.mp3 -------------------------------------------------------------------------------- /src/context/colorcontext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react' 2 | 3 | export const ColorContext = createContext({ 4 | didRedirect: false, 5 | playerDidRedirect: () => {}, 6 | playerDidNotRedirect: () => {} 7 | }) -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "build", 4 | "ignore": [ 5 | "firebase.json", 6 | "**/.*", 7 | "**/node_modules/**" 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "**", 12 | "destination": "/index.html" 13 | } 14 | ] 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Multiplayer Chess Game built with React and Node.js + Express. 2 | 3 | - Users can play their friends anonymously via link. 4 | - Users are also able to chat with each other during the game via camera + microphone. 5 | - Tech stack: React, webRTC, Node.js, Express, Socket.io 6 | 7 | Link to the backend: [Backend](https://github.com/ProjectsByJackHe/multiplayer-chess-game-backend) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /src/connection/socket.js: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client' 2 | 3 | const URL = 'http://localhost:8000' 4 | 5 | const socket = io(URL) 6 | 7 | var mySocketId 8 | // register preliminary event listeners here: 9 | 10 | 11 | socket.on("createNewGame", statusUpdate => { 12 | console.log("A new game has been created! Username: " + statusUpdate.userName + ", Game id: " + statusUpdate.gameId + " Socket id: " + statusUpdate.mySocketId) 13 | mySocketId = statusUpdate.mySocketId 14 | }) 15 | 16 | export { 17 | socket, 18 | mySocketId 19 | } 20 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | 13 | // If you want your app to work offline and load faster, you can change 14 | // unregister() to register() below. Note this comes with some pitfalls. 15 | // Learn more about service workers: https://bit.ly/CRA-PWA 16 | serviceWorker.unregister(); 17 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/chess/ui/piecemap.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'pawn': ['https://upload.wikimedia.org/wikipedia/commons/0/04/Chess_plt60.png', 'https://upload.wikimedia.org/wikipedia/commons/c/cd/Chess_pdt60.png'], 3 | 'knight':['https://upload.wikimedia.org/wikipedia/commons/2/28/Chess_nlt60.png','https://upload.wikimedia.org/wikipedia/commons/f/f1/Chess_ndt60.png'], 4 | 'bishop':['https://upload.wikimedia.org/wikipedia/commons/9/9b/Chess_blt60.png','https://upload.wikimedia.org/wikipedia/commons/8/81/Chess_bdt60.png'], 5 | 'king':['https://upload.wikimedia.org/wikipedia/commons/3/3b/Chess_klt60.png','https://upload.wikimedia.org/wikipedia/commons/e/e3/Chess_kdt60.png'], 6 | 'queen':['https://upload.wikimedia.org/wikipedia/commons/4/49/Chess_qlt60.png','https://upload.wikimedia.org/wikipedia/commons/a/af/Chess_qdt60.png'], 7 | 'rook':['https://upload.wikimedia.org/wikipedia/commons/5/5c/Chess_rlt60.png','https://upload.wikimedia.org/wikipedia/commons/a/a0/Chess_rdt60.png'] 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chess-game", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "chess.js": "^0.10.3", 10 | "konva": "6.0.0", 11 | "react": "^16.13.1", 12 | "react-dom": "^16.13.1", 13 | "react-konva": "^16.13.0-3", 14 | "react-router-dom": "^5.2.0", 15 | "react-scripts": "3.4.1", 16 | "simple-peer": "^9.7.2", 17 | "socket.io-client": "2.3.0", 18 | "styled-components": "^5.1.1", 19 | "use-image": "^1.0.5", 20 | "use-sound": "^1.0.2", 21 | "uuid": "^7.0.3" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/chess/model/chesspiece.js: -------------------------------------------------------------------------------- 1 | 2 | class ChessPiece { 3 | constructor(name, isAttacked, color, id) { 4 | this.name = name // string 5 | this.isAttacked = isAttacked // boolean 6 | this.color = color // string 7 | this.id = id // string 8 | } 9 | 10 | setSquare(newSquare) { 11 | // set the square this piece is sitting top of. 12 | // on any given piece (on the board), there will always be a piece on top of it. 13 | // console.log(newSquare) 14 | if (newSquare === undefined) { 15 | this.squareThisPieceIsOn = newSquare 16 | return 17 | } 18 | 19 | if (this.squareThisPieceIsOn === undefined) { 20 | this.squareThisPieceIsOn = newSquare 21 | newSquare.setPiece(this) 22 | } 23 | 24 | const isNewSquareDifferent = this.squareThisPieceIsOn.x != newSquare.x || this.squareThisPieceIsOn.y != newSquare.y 25 | 26 | if (isNewSquareDifferent) { 27 | // console.log("set") 28 | this.squareThisPieceIsOn = newSquare 29 | newSquare.setPiece(this) 30 | } 31 | } 32 | 33 | getSquare() { 34 | return this.squareThisPieceIsOn 35 | } 36 | } 37 | 38 | 39 | export default ChessPiece -------------------------------------------------------------------------------- /src/chess/ui/piece.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Image } from 'react-konva'; 3 | import useImage from 'use-image' 4 | 5 | const Piece = (props) => { 6 | const choiceOfColor = props.isWhite ? 0 : 1 7 | const [image] = useImage(props.imgurls[choiceOfColor]); 8 | const isDragged = props.id === props.draggedPieceTargetId 9 | 10 | const canThisPieceEvenBeMovedByThisPlayer = props.isWhite === props.thisPlayersColorIsWhite 11 | const isItThatPlayersTurn = props.playerTurnToMoveIsWhite === props.thisPlayersColorIsWhite 12 | 13 | const thisWhiteKingInCheck = props.id === "wk1" && props.whiteKingInCheck 14 | const thisBlackKingInCheck = props.id === "bk1" && props.blackKingInCheck 15 | 16 | 17 | // console.log("this piece ID:" + props.thisPieceTargetId) 18 | // console.log("dragged piece ID:" + props.draggedPieceTargetId) 19 | return ; 30 | }; 31 | 32 | export default Piece -------------------------------------------------------------------------------- /src/onboard/joingame.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useParams } from 'react-router-dom' 3 | const socket = require('../connection/socket').socket 4 | 5 | /** 6 | * 'Join game' is where we actually join the game room. 7 | */ 8 | 9 | 10 | const JoinGameRoom = (gameid, userName, isCreator) => { 11 | /** 12 | * For this browser instance, we want 13 | * to join it to a gameRoom. For now 14 | * assume that the game room exists 15 | * on the backend. 16 | * 17 | * 18 | * TODO: handle the case when the game room doesn't exist. 19 | */ 20 | const idData = { 21 | gameId : gameid, 22 | userName : userName, 23 | isCreator: isCreator 24 | } 25 | socket.emit("playerJoinGame", idData) 26 | } 27 | 28 | 29 | const JoinGame = (props) => { 30 | /** 31 | * Extract the 'gameId' from the URL. 32 | * the 'gameId' is the gameRoom ID. 33 | */ 34 | const { gameid } = useParams() 35 | JoinGameRoom(gameid, props.userName, props.isCreator) 36 | return
37 |

Welcome to Chess with Friend!

38 |

Made with ❤️ by Jack He. Subscribe to my YouTube channel. Follow me on Instagram.

39 |
40 | } 41 | 42 | export default JoinGame 43 | 44 | -------------------------------------------------------------------------------- /src/chess/model/square.js: -------------------------------------------------------------------------------- 1 | class Square { 2 | constructor(x, y, pieceOnThisSquare, canvasCoord) { 3 | this.x = x // Int 0 < x < 7 4 | this.y = y // Int 0 < y < 7 5 | this.canvasCoord = canvasCoord 6 | this.pieceOnThisSquare = pieceOnThisSquare // ChessPiece || null 7 | } 8 | 9 | setPiece(newPiece) { 10 | if (newPiece === null && this.pieceOnThisSquare === null) { 11 | return 12 | } else if (newPiece === null) { 13 | // case where the function caller wants to remove the piece that is on this square. 14 | this.pieceOnThisSquare.setSquare(undefined) 15 | this.pieceOnThisSquare = null 16 | } else if (this.pieceOnThisSquare === null) { 17 | // case where the function caller wants assign a new piece on this square 18 | this.pieceOnThisSquare = newPiece 19 | newPiece.setSquare(this) 20 | } else if (this.getPieceIdOnThisSquare() != newPiece.id && this.pieceOnThisSquare.color != newPiece.color) { 21 | // case where the function caller wants to change the piece on this square. (only different color allowed) 22 | console.log("capture!") 23 | this.pieceOnThisSquare = newPiece 24 | newPiece.setSquare(this) 25 | } else { 26 | return "user tried to capture their own piece" 27 | } 28 | } 29 | 30 | removePiece() { 31 | this.pieceOnThisSquare = null 32 | } 33 | 34 | getPiece() { 35 | return this.pieceOnThisSquare 36 | } 37 | 38 | getPieceIdOnThisSquare() { 39 | if (this.pieceOnThisSquare === null) { 40 | return "empty" 41 | } 42 | return this.pieceOnThisSquare.id 43 | } 44 | 45 | isOccupied() { 46 | return this.pieceOnThisSquare != null 47 | } 48 | 49 | getCoord() { 50 | return [this.x, this.y] 51 | } 52 | 53 | getCanvasCoord() { 54 | return this.canvasCoord 55 | } 56 | } 57 | 58 | export default Square -------------------------------------------------------------------------------- /src/onboard/joinroom.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import JoinGame from './joingame' 3 | import ChessGame from '../chess/ui/chessgame' 4 | 5 | 6 | /** 7 | * Onboard is where we create the game room. 8 | */ 9 | 10 | class JoinRoom extends React.Component { 11 | state = { 12 | didGetUserName: false, 13 | inputText: "" 14 | } 15 | 16 | constructor(props) { 17 | super(props); 18 | this.textArea = React.createRef(); 19 | } 20 | 21 | typingUserName = () => { 22 | // grab the input text from the field from the DOM 23 | const typedText = this.textArea.current.value 24 | 25 | // set the state with that text 26 | this.setState({ 27 | inputText: typedText 28 | }) 29 | } 30 | 31 | render() { 32 | 33 | return ( 34 | { 35 | this.state.didGetUserName ? 36 | 37 | 38 | 39 | 40 | : 41 |
42 |

Your Username:

43 | 44 | 47 | 48 | 59 |
60 | } 61 |
) 62 | } 63 | } 64 | 65 | export default JoinRoom -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router, Route, Redirect, Switch } from 'react-router-dom'; 3 | import JoinRoom from './onboard/joinroom' 4 | import { ColorContext } from './context/colorcontext' 5 | import Onboard from './onboard/onboard' 6 | import JoinGame from './onboard/joingame' 7 | import ChessGame from './chess/ui/chessgame' 8 | /* 9 | * Frontend flow: 10 | * 11 | * 1. user first opens this app in the browser. 12 | * 2. a screen appears asking the user to send their friend their game URL to start the game. 13 | * 3. the user sends their friend their game URL 14 | * 4. the user clicks the 'start' button and waits for the other player to join. 15 | * 5. As soon as the other player joins, the game starts. 16 | * 17 | * 18 | * Other player flow: 19 | * 1. user gets the link sent by their friend 20 | * 2. user clicks on the link and it redirects to their game. If the 'host' has not yet 21 | * clicked the 'start' button yet, the user will wait for when the host clicks the start button. 22 | * If the host decides to leave before they click on the "start" button, the user will be notified 23 | * that the host has ended the session. 24 | * 3. Once the host clicks the start button or the start button was already clicked on 25 | * before, that's when the game starts. 26 | * Onboarding screen =====> Game start. 27 | * 28 | * Every time a user opens our site from the '/' path, a new game instance is automatically created 29 | * on the back-end. We should generate the uuid on the frontend, send the request with the uuid 30 | * as a part of the body of the request. If any player leaves, then the other player wins automatically. 31 | * 32 | */ 33 | 34 | 35 | function App() { 36 | 37 | const [didRedirect, setDidRedirect] = React.useState(false) 38 | 39 | const playerDidRedirect = React.useCallback(() => { 40 | setDidRedirect(true) 41 | }, []) 42 | 43 | const playerDidNotRedirect = React.useCallback(() => { 44 | setDidRedirect(false) 45 | }, []) 46 | 47 | const [userName, setUserName] = React.useState('') 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {didRedirect ? 58 | 59 | 60 | 61 | 62 | : 63 | } 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | export default App; 72 | -------------------------------------------------------------------------------- /src/onboard/onboard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Redirect } from 'react-router-dom' 3 | import uuid from 'uuid/v4' 4 | import { ColorContext } from '../context/colorcontext' 5 | const socket = require('../connection/socket').socket 6 | 7 | /** 8 | * Onboard is where we create the game room. 9 | */ 10 | 11 | class CreateNewGame extends React.Component { 12 | state = { 13 | didGetUserName: false, 14 | inputText: "", 15 | gameId: "" 16 | } 17 | 18 | constructor(props) { 19 | super(props); 20 | this.textArea = React.createRef(); 21 | } 22 | 23 | send = () => { 24 | /** 25 | * This method should create a new room in the '/' namespace 26 | * with a unique identifier. 27 | */ 28 | const newGameRoomId = uuid() 29 | 30 | // set the state of this component with the gameId so that we can 31 | // redirect the user to that URL later. 32 | this.setState({ 33 | gameId: newGameRoomId 34 | }) 35 | 36 | // emit an event to the server to create a new room 37 | socket.emit('createNewGame', newGameRoomId) 38 | } 39 | 40 | typingUserName = () => { 41 | // grab the input text from the field from the DOM 42 | const typedText = this.textArea.current.value 43 | 44 | // set the state with that text 45 | this.setState({ 46 | inputText: typedText 47 | }) 48 | } 49 | 50 | render() { 51 | // !!! TODO: edit this later once you have bought your own domain. 52 | 53 | return ( 54 | { 55 | this.state.didGetUserName ? 56 | 57 | 58 | 59 | : 60 |
61 |

Your Username:

62 | 63 | 66 | 67 | 81 |
82 | } 83 |
) 84 | } 85 | } 86 | 87 | const Onboard = (props) => { 88 | const color = React.useContext(ColorContext) 89 | 90 | return 91 | } 92 | 93 | 94 | export default Onboard -------------------------------------------------------------------------------- /src/connection/videochat.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import Peer from "simple-peer"; 3 | import styled from "styled-components"; 4 | const socket = require('../connection/socket').socket 5 | 6 | 7 | const Container = styled.div` 8 | height: 100vh; 9 | width: 100%; 10 | flex-direction: column; 11 | `; 12 | 13 | const Row = styled.div` 14 | width: 100%; 15 | `; 16 | 17 | const Video = styled.video` 18 | border: 1px solid blue; 19 | `; 20 | 21 | function VideoChatApp(props) { 22 | /** 23 | * initial state: both player is neutral and have the option to call each other 24 | * 25 | * player 1 calls player 2: Player 1 should display: 'Calling {player 2 username},' and the 26 | * 'CallPeer' button should disappear for Player 1. 27 | * Player 2 should display '{player 1 username} is calling you' and 28 | * the 'CallPeer' button for Player 2 should also disappear. 29 | * 30 | * Case 1: player 2 accepts call - the video chat begins and there is no button to end it. 31 | * 32 | * Case 2: player 2 ignores player 1 call - nothing happens. Wait until the connection times out. 33 | * 34 | */ 35 | 36 | const [stream, setStream] = useState(); 37 | const [receivingCall, setReceivingCall] = useState(false); 38 | const [caller, setCaller] = useState(""); 39 | const [callerSignal, setCallerSignal] = useState(); 40 | const [callAccepted, setCallAccepted] = useState(false); 41 | const [isCalling, setIsCalling] = useState(false) 42 | const userVideo = useRef(); 43 | const partnerVideo = useRef(); 44 | 45 | useEffect(() => { 46 | navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then(stream => { 47 | setStream(stream); 48 | if (userVideo.current) { 49 | userVideo.current.srcObject = stream; 50 | } 51 | }) 52 | 53 | socket.on("hey", (data) => { 54 | setReceivingCall(true); 55 | setCaller(data.from); 56 | setCallerSignal(data.signal); 57 | }) 58 | }, []); 59 | 60 | function callPeer(id) { 61 | setIsCalling(true) 62 | const peer = new Peer({ 63 | initiator: true, 64 | trickle: false, 65 | stream: stream, 66 | }); 67 | 68 | peer.on("signal", data => { 69 | socket.emit("callUser", { userToCall: id, signalData: data, from: props.mySocketId}) 70 | }) 71 | 72 | peer.on("stream", stream => { 73 | if (partnerVideo.current) { 74 | partnerVideo.current.srcObject = stream; 75 | } 76 | }); 77 | 78 | socket.on("callAccepted", signal => { 79 | setCallAccepted(true); 80 | peer.signal(signal); 81 | }) 82 | 83 | } 84 | 85 | function acceptCall() { 86 | setCallAccepted(true); 87 | setIsCalling(false) 88 | const peer = new Peer({ 89 | initiator: false, 90 | trickle: false, 91 | stream: stream, 92 | }); 93 | peer.on("signal", data => { 94 | socket.emit("acceptCall", { signal: data, to: caller }) 95 | }) 96 | 97 | peer.on("stream", stream => { 98 | partnerVideo.current.srcObject = stream; 99 | }); 100 | 101 | peer.signal(callerSignal); 102 | } 103 | 104 | let UserVideo; 105 | if (stream) { 106 | UserVideo = ( 107 |