├── .gitignore ├── app ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── src │ ├── setupTests.js │ ├── App.test.js │ ├── App.js │ ├── index.css │ ├── reportWebVitals.js │ ├── core │ │ └── Menu.js │ ├── webrtc │ │ └── webrtc.js │ ├── MainRouter.js │ ├── index.js │ ├── App.css │ ├── components │ │ ├── Video.jsx │ │ └── chat │ │ │ ├── TextInput.js │ │ │ ├── ChatLog.js │ │ │ └── Message.js │ ├── room │ │ ├── api-room.js │ │ ├── Create.js │ │ └── Room.js │ └── logo.svg ├── .gitignore ├── package.json └── README.md ├── README.md ├── src ├── db │ └── pscale.js ├── options.js ├── room.js ├── routes │ └── room.routes.js ├── server.js ├── express.js ├── ssl │ ├── server.crt │ └── server.key ├── controllers │ └── room.controller.js └── socket.js ├── .eslintrc.json └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | node_modules 3 | .env 4 | -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethand91/simple-meeting/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethand91/simple-meeting/HEAD/app/public/logo192.png -------------------------------------------------------------------------------- /app/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ethand91/simple-meeting/HEAD/app/public/logo512.png -------------------------------------------------------------------------------- /app/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | A simple 1 to 1 metting application created with React, Nodejs, PlanetScale, WebRTC. 4 | 5 | This project was created for my PlanetScale X Hashnode Hackathon 6 | -------------------------------------------------------------------------------- /src/db/pscale.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const mysql = require('mysql2'); 3 | 4 | const connection = mysql.createConnection(process.env.DATABASE_URL); 5 | 6 | module.exports = connection; 7 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const options = { 4 | key: fs.readFileSync('./src/ssl/server.key'), 5 | cert: fs.readFileSync('./src/ssl/server.crt') 6 | }; 7 | 8 | module.exports = options; 9 | -------------------------------------------------------------------------------- /app/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'; 6 | -------------------------------------------------------------------------------- /app/src/App.test.js: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import App from './App'; 3 | 4 | test('renders learn react link', () => { 5 | render(); 6 | const linkElement = screen.getByText(/learn react/i); 7 | expect(linkElement).toBeInTheDocument(); 8 | }); 9 | -------------------------------------------------------------------------------- /app/src/App.js: -------------------------------------------------------------------------------- 1 | import { BrowserRouter } from 'react-router-dom'; 2 | 3 | import './App.css'; 4 | import MainRouter from './MainRouter'; 5 | 6 | function App() { 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": false, 4 | "node": true, 5 | "commonjs": true, 6 | "es2021": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": "latest" 11 | }, 12 | "rules": { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/room.js: -------------------------------------------------------------------------------- 1 | class Room { 2 | constructor () { 3 | this.users = new Set(); 4 | } 5 | 6 | addUser (socketId) { 7 | this.users.add(socketId); 8 | } 9 | 10 | removeUser (socketId) { 11 | this.users.delete(socketId); 12 | } 13 | 14 | count () { 15 | return this.users.size; 16 | } 17 | } 18 | 19 | module.exports = Room 20 | -------------------------------------------------------------------------------- /app/.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 | -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/room.routes.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const roomCtrl = require('./../controllers/room.controller'); 4 | 5 | const router = express.Router(); 6 | 7 | router.route('/api/rooms') 8 | .post(roomCtrl.create); 9 | 10 | router.route('/api/rooms/:roomId') 11 | .get(roomCtrl.login) 12 | .delete(roomCtrl.remove); 13 | 14 | router.param('roomId', roomCtrl.roomById); 15 | 16 | module.exports = router; 17 | -------------------------------------------------------------------------------- /app/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /app/src/core/Menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import AppBar from '@material-ui/core/AppBar'; 3 | import Toolbar from '@material-ui/core/Toolbar'; 4 | import Typography from '@material-ui/core/Typography'; 5 | 6 | const Menu = () => ( 7 | 8 | 9 | 10 | Simple Meeting 11 | 12 | 13 | 14 | ); 15 | 16 | export default Menu; 17 | -------------------------------------------------------------------------------- /app/src/webrtc/webrtc.js: -------------------------------------------------------------------------------- 1 | const getLocalMediaStream = async () => 2 | await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 480 } }); 3 | 4 | const initializePeerConnection = () => { 5 | const config = { iceServers: [{ urls: [ 'stun:stun1.l.google.com:19302' ] }] }; 6 | const peerConnection = new RTCPeerConnection(config); 7 | 8 | return peerConnection; 9 | }; 10 | 11 | export { 12 | getLocalMediaStream, 13 | initializePeerConnection 14 | }; 15 | -------------------------------------------------------------------------------- /app/src/MainRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Routes } from 'react-router-dom'; 3 | 4 | import Menu from './core/Menu'; 5 | import Create from './room/Create'; 6 | import Room from './room/Room'; 7 | 8 | const MainRouter = () => { 9 | return ( 10 |
11 | 12 | 13 | }/> 14 | }/> 15 | 16 |
17 | ); 18 | }; 19 | 20 | export default MainRouter; 21 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const app = require('./express'); 2 | const socket = require('./socket')(app); 3 | 4 | const connection = require('./db/pscale'); 5 | 6 | const PORT = process.env.PORT || 3000; 7 | 8 | try { 9 | connection.connect(); 10 | console.log('database connection established'); 11 | 12 | app.listen(PORT, error => { 13 | if (error) { 14 | console.error(error); 15 | process.exit(1); 16 | } 17 | 18 | console.log(`server started on port ${PORT}`); 19 | }); 20 | } catch (error) { 21 | console.error('app failed to start', error); 22 | process.exit(1); 23 | } 24 | -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | const root = ReactDOM.createRoot(document.getElementById('root')); 8 | root.render( 9 | 10 | 11 | 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-meeting", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/server.js", 6 | "scripts": { 7 | "start": "node src/server.js", 8 | "heroku-postbuild": "cd app && npm i && npm run build", 9 | "lint": "eslint ./src/**/*.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "body-parser": "^1.20.0", 17 | "compression": "^1.7.4", 18 | "cors": "^2.8.5", 19 | "dotenv": "^16.0.1", 20 | "express": "^4.18.1", 21 | "helmet": "^5.1.0", 22 | "mysql2": "^2.3.3", 23 | "socket.io": "^4.5.1", 24 | "uuid": "^8.3.2" 25 | }, 26 | "devDependencies": { 27 | "eslint": "^8.20.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/express.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const http = require('http'); 4 | const bodyParser = require('body-parser'); 5 | const compress = require('compression'); 6 | const cors = require('cors'); 7 | const helmet = require('helmet'); 8 | 9 | const options = require('./options'); 10 | const roomRoutes = require('./routes/room.routes'); 11 | 12 | const app = express(); 13 | 14 | app.use(express.static(path.join(__dirname, "./../app/build"))); 15 | 16 | app.use(bodyParser.json()); 17 | app.use(compress()); 18 | app.use(helmet()); 19 | app.use(cors()); 20 | 21 | app.use('/', roomRoutes); 22 | 23 | app.get('*', (req, res) => { 24 | res.sendFile(path.join(__dirname + './../app/build/index.html')); 25 | }); 26 | 27 | const server = http.createServer(app); 28 | 29 | module.exports = server; 30 | -------------------------------------------------------------------------------- /app/src/components/Video.jsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | 4 | const useStyles = makeStyles(theme => ({ 5 | localVideo: { 6 | 'z-index': 1, 7 | background: 'clear', 8 | position: 'absolute', 9 | 'object-fit': 'cover', 10 | top: 0, 11 | left: 0 12 | }, 13 | remoteVideo: { 14 | width: '100vw', 15 | height: '90vh', 16 | background: 'clear', 17 | position: 'absolute', 18 | 'object-fit': 'cover', 19 | top: 0, 20 | left: 0, 21 | } 22 | })); 23 | 24 | export const Video = forwardRef ((props, ref) => { 25 | const classes = useStyles(); 26 | 27 | return ( 28 | 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.12.4", 7 | "@material-ui/icons": "^4.11.3", 8 | "@testing-library/jest-dom": "^5.16.4", 9 | "@testing-library/react": "^13.3.0", 10 | "@testing-library/user-event": "^13.5.0", 11 | "axios": "^0.27.2", 12 | "react": "^18.2.0", 13 | "react-copy-to-clipboard": "^5.1.0", 14 | "react-dom": "^18.2.0", 15 | "react-router-dom": "^6.3.0", 16 | "react-scripts": "5.0.1", 17 | "socket.io-client": "^4.5.1", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 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/ssl/server.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDazCCAlOgAwIBAgIUXZSfn8NSSe5A8yfdjKCtVXbqxY4wDQYJKoZIhvcNAQEL 3 | BQAwRTELMAkGA1UEBhMCSlAxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 4 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA3MjQwMzEwNDhaFw0yMzA3 5 | MjQwMzEwNDhaMEUxCzAJBgNVBAYTAkpQMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 6 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB 7 | AQUAA4IBDwAwggEKAoIBAQC6rXlRlve8ZWCn9rDpqzn+LE8A1IO6tQs3f9nZyYeB 8 | wytun2J8tQv2cQKuYN+fcNCjyYBz06qGV6OrxUQ21RA73re/KiwqlWjHqdy1LwET 9 | quUCvS7QDzDSIKP4D0n6SrcFnz59heGx6+r9uUkds+DNU7xrmQmfLevqhim5ePiU 10 | flg3jwSKOXUTR6C+shV98BMXVoegHRvfAUFzlQsBeQKnDPEPAwIcUZQ07JezXlFQ 11 | lyh6AjHayyxTF8QUWiM8AOev2iBxTdMQf5LsIExQC5un9O6bk/RaxbME6A8fIsNG 12 | 9Btk2HjFAu10RErPY0cWpBDf2TLwwJjGxHOLuA9SKtn9AgMBAAGjUzBRMB0GA1Ud 13 | DgQWBBSMWJLTIBV4CTH/Ls1ClRpuaW2rEzAfBgNVHSMEGDAWgBSMWJLTIBV4CTH/ 14 | Ls1ClRpuaW2rEzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCR 15 | 3r+MzyNmYvG7dGSP5KC0cs90Q4AceZrk/l+0zOlVfO3X/2+xVUjwWXwimq9wlP3I 16 | PiLuRyTgt6/acsADFLrVt3iso4hTcVAiOOlpdpHeLQL4e6zpYmbdbZE/AONa2c58 17 | KzQj/fig00lZ8OU4Pse/XOwJL8xxI23h1slf9HETEBVwfLxPQJDJnT3Q2KUgtacx 18 | ITuFN8krpf+787fjdW1dDVZLxPmjWmb+xbF2Vx8r7UagZHkw+8LKexkczs1tC0rO 19 | 1O/9PrOVc0TjDHuzVjkDt+yAaKL53fwCG59xzMNntupu3ETMfIqFDWWgw6RpYU2m 20 | Q6H35ov1b1pK4iKuo07j 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /app/src/room/api-room.js: -------------------------------------------------------------------------------- 1 | const create = async () => { 2 | try { 3 | const response = await fetch(`https://${window.location.hostname}/api/rooms/`, { 4 | method: 'POST', 5 | headers: { 6 | 'Accept': 'application/json', 7 | 'Content-Type': 'application/json' 8 | } 9 | }); 10 | 11 | return await response.json(); 12 | } catch (error) { 13 | console.error(error); 14 | } 15 | }; 16 | 17 | const login = async (roomId, signal) => { 18 | try { 19 | const response = await fetch(`https://${window.location.hostname}/api/rooms/${roomId}`, { 20 | method: 'GET', 21 | signal, 22 | headers: { 23 | 'Accept': 'application/json', 24 | 'Content-Type': 'application/json' 25 | } 26 | }); 27 | 28 | return await response.json(); 29 | } catch (error) { 30 | console.error(error); 31 | } 32 | }; 33 | 34 | const remove = async (roomId) => { 35 | try { 36 | const response = await fetch(`https://${window.location.hostname}/api/rooms/${roomId}`, { 37 | method: 'DELETE', 38 | headers: { 39 | 'Accept': 'application/json', 40 | 'Content-Type': 'application/json' 41 | } 42 | }); 43 | 44 | return await response.json(); 45 | } catch (error) { 46 | console.error(error); 47 | } 48 | }; 49 | 50 | export { 51 | create, 52 | login, 53 | remove 54 | }; 55 | -------------------------------------------------------------------------------- /src/controllers/room.controller.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | const connection = require('./../db/pscale'); 4 | 5 | const create = async (req, res) => { 6 | try { 7 | const key = uuidv4(); 8 | 9 | await connection.promise().query('INSERT INTO room (`key`) VALUES (?)', [key]); 10 | 11 | return res.status(200).json({ key }); 12 | } catch (error) { 13 | console.error(error); 14 | return res.status(400).json({ error: 'failed to create room' }); 15 | } 16 | }; 17 | 18 | const login = async (req, res) => { 19 | try { 20 | return res.status(200); 21 | } catch (error) { 22 | console.error(error); 23 | } 24 | }; 25 | 26 | const remove = async (req, res) => { 27 | try { 28 | const room = req.room; 29 | 30 | await connection.promise().query(`delete from room where id=${room.id}`); 31 | 32 | return res.status(200); 33 | } catch (error) { 34 | return res.status(400).json({ error: 'failed to delete room' }); 35 | } 36 | }; 37 | 38 | const roomById = async (req, res, next, key) => { 39 | try { 40 | const result = await connection.promise().query('SELECT * FROM room WHERE room.`key` = ? limit 1', [key]); 41 | 42 | if (!result[0][0]) throw new Error('room not found'); 43 | 44 | req.room = result[0][0]; 45 | next(); 46 | } catch (error) { 47 | console.error(error); 48 | return res.status(404).json({ error: 'room could not be found' }); 49 | } 50 | }; 51 | 52 | module.exports = { 53 | create, 54 | login, 55 | remove, 56 | roomById 57 | }; 58 | -------------------------------------------------------------------------------- /app/src/components/chat/TextInput.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import TextField from '@material-ui/core/TextField'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import SendIcon from '@material-ui/icons/Send'; 5 | import Button from '@material-ui/core/Button'; 6 | 7 | const useStyles = makeStyles(theme => ({ 8 | form: { 9 | display: 'flex', 10 | justifyContent: 'center', 11 | width: '95%', 12 | margin: `${theme.spacing(0)} auto` 13 | }, 14 | text: { 15 | width: '100%' 16 | } 17 | })); 18 | 19 | export const TextInput = (props) => { 20 | const classes = useStyles(); 21 | const [ message, setMessage ] = useState(''); 22 | const textInput = useRef(undefined); 23 | 24 | const handleTextChange = event => { 25 | setMessage(event.target.value); 26 | }; 27 | 28 | const handleButtonClick = () => { 29 | setMessage(''); 30 | textInput.current.value = ''; 31 | props.handleSendNewChatMessage(message); 32 | }; 33 | 34 | return ( 35 | <> 36 |
37 | 44 | 45 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/socket.js: -------------------------------------------------------------------------------- 1 | const socket = require('socket.io'); 2 | 3 | const Room = require('./room'); 4 | 5 | module.exports = function (server) { 6 | const io = socket(server, { 7 | cors: { 8 | origin: '*' 9 | } 10 | }); 11 | 12 | const rooms = new Map(); 13 | 14 | io.on('connection', (socket) => { 15 | console.log('new connection', socket.id); 16 | 17 | socket.on('message', data => { 18 | socket.broadcast.emit('message', data); 19 | }); 20 | 21 | socket.on('init', roomKey => { 22 | socket.roomKey = roomKey; 23 | 24 | if (!rooms.has(roomKey)) { 25 | const room = new Room(); 26 | room.addUser(socket.id); 27 | rooms.set(roomKey, room); 28 | 29 | console.log('new room created'); 30 | 31 | return; 32 | } 33 | 34 | if (rooms.get(roomKey) === socket.id) return; 35 | 36 | if (Array.from(rooms.values()).length === 2) { 37 | socket.disconnect(); 38 | 39 | return; 40 | } 41 | 42 | const room = rooms.get(roomKey); 43 | room.addUser(socket.id); 44 | socket.broadcast.emit('offer'); 45 | }); 46 | 47 | socket.once('disconnect', () => { 48 | console.log('disconnect', socket.roomKey); 49 | if (rooms.has(socket.roomKey)) { 50 | const room = rooms.get(socket.roomKey); 51 | 52 | room.removeUser(socket.id); 53 | 54 | if (room.count() === 0) { 55 | rooms.delete(socket.roomKey); 56 | 57 | console.log('room was deleted', socket.roomKey); 58 | } 59 | } 60 | }); 61 | 62 | socket.once('error', error => { 63 | console.error(error); 64 | }); 65 | }); 66 | 67 | } 68 | -------------------------------------------------------------------------------- /app/src/components/chat/ChatLog.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import { Paper } from '@material-ui/core'; 4 | 5 | import { TextInput } from './TextInput'; 6 | import { MessageLeft, MessageRight } from './Message'; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | paper: { 10 | height: '100%', 11 | maxWidth: '400px', 12 | minWidth: '400px', 13 | display: 'flex', 14 | alignItems: 'center', 15 | flexDirection: 'column', 16 | }, 17 | container: { 18 | height: '90vh', 19 | display: 'flex-end', 20 | alignItems: 'center', 21 | }, 22 | messageBody: { 23 | width: '100%', 24 | height: '100%', 25 | margin: 10, 26 | overFlowY: 'scroll', 27 | } 28 | })); 29 | 30 | export const ChatLog = props => { 31 | const classes = useStyles(); 32 | 33 | useEffect(() => { 34 | 35 | }, []); 36 | 37 | return ( 38 |
39 | 40 | 41 | { 42 | props.messages.map((data, i) => { 43 | { 44 | return data.isLocal ? ( 45 | 49 | ) : ( 50 | 54 | ) 55 | } 56 | }) 57 | } 58 | 59 | 62 | 63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /src/ssl/server.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC6rXlRlve8ZWCn 3 | 9rDpqzn+LE8A1IO6tQs3f9nZyYeBwytun2J8tQv2cQKuYN+fcNCjyYBz06qGV6Or 4 | xUQ21RA73re/KiwqlWjHqdy1LwETquUCvS7QDzDSIKP4D0n6SrcFnz59heGx6+r9 5 | uUkds+DNU7xrmQmfLevqhim5ePiUflg3jwSKOXUTR6C+shV98BMXVoegHRvfAUFz 6 | lQsBeQKnDPEPAwIcUZQ07JezXlFQlyh6AjHayyxTF8QUWiM8AOev2iBxTdMQf5Ls 7 | IExQC5un9O6bk/RaxbME6A8fIsNG9Btk2HjFAu10RErPY0cWpBDf2TLwwJjGxHOL 8 | uA9SKtn9AgMBAAECggEAKccnDo511RDvJ8f64eCzMuIhsDLfZEqePwZ679W9YNoX 9 | /0LmXLh7++Rg0DvX8CvkVbOD7ughEr3MYGXelVLrtipq/vpmNgWIFJ88SMRDPm6R 10 | oCJMtr2flHc/mwV47e4ItdZkutzanOIKjkwIRAB8wBcMZRwz3g797FeoQN0E7N5T 11 | 7UkLaM/WQduREJhs7kYzAde/H8NnaLmdtGT2yb5UVhMDg1614X66fBbJ4SPW0+Ez 12 | iFCrJCSZIQD9Q+r9NS9Nqh6AAD4somSgqJsY0zCugzNcVGS1k1q9wJ1OcPzbbSSp 13 | gtnUg0vn8uK92VfeGxMSaJP9+JwhoYyibH/fNMFHIQKBgQDeILKZDZhSJ/MuVj5S 14 | +3ev17rAiXuoY9R3TkK6QRkDvSqsDsZeQ46+AEDVkOAOf8wOaUzPVhEbldRZV3Vb 15 | QXQISLy4N0Bl33geRB25Htz3YuT9knhHN97JOEEIv56JlkoQIEVl7sqj9R3cyH4A 16 | wUkdYEJVmoiqCL/9zqzfSejH2QKBgQDXJOUHtDdzSJKwk610O6gRSHZJaN2K7/FZ 17 | yxJpoPSZV0NN6c3siieHsz5frViLC7fTQyFLvny1lU8JZc98Iyyz87j9x0VaDUdI 18 | 3Bj2DCK5xnyr8FuBtZTFrjqUz7BpesOnwLhMA6486nawSAoYXDYvLiQp7rsmD13X 19 | 1BP44jeQxQKBgQCiGxK8B+Pl8SnT4zTQspyCQ9oSsUMBfPmNqBuieeebhu4haxbx 20 | rDP8DYtDUNtzOWjeC5L2rUUG8K1sFhubYYUglpTDi+7/abrru6JFe3SfRhj/xWjp 21 | 8KgyCU/M5qr4limu4x5CaaaRSU1l1xu9yVFmkt1WQ9UA4inbPH2E5xdu4QKBgQC1 22 | ua9PY5VW5l0pk4P24xEikB+CAHbpjaVCoHpMCK2y/HeYTz4mZ8feIrQz4tsgj+RV 23 | KaXtMdhrFNQu7vVkON3gnqSKkBBvcTnePDNFWZjXbOYP4bWZiYRBudo3qnqrjgvI 24 | HcxOQOmjALUCT8dfLjyCe6oGVWV1T5OH49Z+6q3etQKBgHjvAphsWr7KmTA6KEfl 25 | wNIDfsqISUPB3kT74Wz4Oafgq1KrdwqvcV3SM7a7oR7lb3XMUvoq8EMaTjWtNm5l 26 | Wo36XTwAlkYNDBlGSqK631atVjBlx79xTMhUMzJDPEVrlXRrnVgyLIbjZUp2tzVd 27 | RuwaGX1/ReAhZPvrs01F9c9r 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/components/chat/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import { deepOrange } from '@material-ui/core/colors'; 4 | 5 | const useStyles = makeStyles(theme => ({ 6 | messageRow: { 7 | display: 'flex' 8 | }, 9 | messageRowRight: { 10 | display: 'flex', 11 | justifyContent: 'flex-end' 12 | }, 13 | messageBlue: { 14 | position: 'relative', 15 | marginLeft: '20px', 16 | marginBottom: '10px', 17 | padding: '10px', 18 | backgroudColor: 'blue', 19 | width: '60%', 20 | textAlign: 'left', 21 | border: '1px solid #97C6E3', 22 | borderRadius: '10px', 23 | '&:after': { 24 | content: "''", 25 | position: 'absolute', 26 | width: '0', 27 | height: '0', 28 | borderTop: '15px solid #A8DDFD', 29 | borderLeft: '15px solid transparent', 30 | borderRight: '15px solid transparent', 31 | top: '0', 32 | left: '-15px' 33 | }, 34 | '&:before': { 35 | content: "''", 36 | position: 'absolute', 37 | width: '0', 38 | height: '0', 39 | borderTop: '17px solid #97C6E3', 40 | borderLeft: '16px solid transparent', 41 | borderRight: '16px solid transparent', 42 | top: '-1px', 43 | left: '-17px' 44 | } 45 | }, 46 | messageOrange: { 47 | position: 'relative', 48 | marginRight: '20px', 49 | marginBottom: '10px', 50 | padding: '10px', 51 | backgroundColor: '#f8e896', 52 | width: '60%', 53 | textAlign: 'left', 54 | border: '1px solid #dfd087', 55 | borderRadius: '10px', 56 | '&:after': { 57 | content: "''", 58 | position: 'absolute', 59 | width: '0', 60 | height: '0', 61 | borderTop: '15px solid #f8e896', 62 | borderLeft: '15px solid transparent', 63 | borderRight: '15px solid transparent', 64 | top: '0', 65 | right: '-15px' 66 | }, 67 | '&:before': { 68 | content: '""', 69 | position: 'absolute', 70 | width: '0', 71 | height: '0', 72 | borderTop: '17px solid #dfd087', 73 | borderLeft: '16px solid transparent', 74 | borderRight: '16px solid transparent', 75 | top: '-1px', 76 | right: '-17px' 77 | } 78 | }, 79 | messageContent: { 80 | padding: 0, 81 | margin: 0 82 | }, 83 | orange: { 84 | color: theme.palette.getContrastText(deepOrange[500]), 85 | backgroundColor: deepOrange[500], 86 | width: theme.spacing(4), 87 | height: theme.spacing(4) 88 | } 89 | })); 90 | 91 | export const MessageLeft = props => { 92 | const classes = useStyles(); 93 | 94 | return ( 95 | <> 96 |
97 |
98 |
99 |

{ props.message }

100 |
101 |
102 |
103 | 104 | ); 105 | }; 106 | 107 | export const MessageRight = props => { 108 | const classes = useStyles(); 109 | 110 | return ( 111 | <> 112 |
113 |
114 |

{ props.message }

115 |
116 |
117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in your browser. 13 | 14 | The page will reload when you make changes.\ 15 | You may also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can't go back!** 35 | 36 | If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. 39 | 40 | You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | 48 | ### Code Splitting 49 | 50 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 51 | 52 | ### Analyzing the Bundle Size 53 | 54 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 55 | 56 | ### Making a Progressive Web App 57 | 58 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 59 | 60 | ### Advanced Configuration 61 | 62 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 63 | 64 | ### Deployment 65 | 66 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 67 | 68 | ### `npm run build` fails to minify 69 | 70 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 71 | -------------------------------------------------------------------------------- /app/src/room/Create.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardActions from '@material-ui/core/CardActions'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import Button from '@material-ui/core/Button'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import Icon from '@material-ui/core/Icon'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | import Dialog from '@material-ui/core/Dialog'; 10 | import DialogActions from '@material-ui/core/DialogActions'; 11 | import DialogContent from '@material-ui/core/DialogContent'; 12 | import DialogContentText from '@material-ui/core/DialogContentText'; 13 | import DialogTitle from '@material-ui/core/DialogTitle'; 14 | import { Link } from 'react-router-dom'; 15 | import AssignmentIcon from '@material-ui/icons/Assignment'; 16 | import Tooltip from '@material-ui/core/Tooltip'; 17 | import InputAdornment from '@material-ui/core/InputAdornment'; 18 | import IconButton from '@material-ui/core/IconButton'; 19 | import CopyToClipBoard from 'react-copy-to-clipboard'; 20 | 21 | import { create } from './api-room'; 22 | 23 | const useStyles = makeStyles(theme => ({ 24 | card: { 25 | maxWidth: 600, 26 | margin: 'auto', 27 | textAlign: 'center', 28 | marginTop: theme.spacing(5), 29 | paddingBottom: theme.spacing(2) 30 | }, 31 | error: { 32 | verticalAlign: 'middle' 33 | }, 34 | title: { 35 | marginTop: theme.spacing(2), 36 | }, 37 | textField: { 38 | marginLeft: theme.spacing(1), 39 | marginRight: theme.spacing(1), 40 | width: 300 41 | }, 42 | submit: { 43 | margin: 'auto', 44 | marginBottom: theme.spacing(2) 45 | } 46 | })); 47 | 48 | export default function Create () { 49 | const classes = useStyles(); 50 | const [ values, setValues ] = useState({ 51 | open: false, 52 | roomKey: '', 53 | error: '', 54 | openTooltip: false 55 | }); 56 | 57 | const clickSubmit = async () => { 58 | console.log('create room'); 59 | const data = await create(); 60 | 61 | if (data.error) { 62 | setValues({ ...values, error: data.error }); 63 | 64 | return; 65 | } 66 | 67 | console.log('got data', data); 68 | setValues({ ...values, roomKey: data.key, open: true }); 69 | }; 70 | 71 | const handleClipBoardClicked = () => { 72 | setValues({ ...values, openTooltip: true }); 73 | }; 74 | 75 | const closeTooltip = () => { 76 | setValues({ ...values, openTooltip: false }); 77 | }; 78 | 79 | return ( 80 |
81 | 82 | 83 | 84 | Room Generator 85 | 86 |
{ 87 | values.error && 88 | ( 89 | 90 | error 91 | { values.error } 92 | 93 | ) 94 | } 95 |
96 | 97 | 98 | 99 |
100 | 101 | 102 | 103 | 104 | New room was created succesfully access it via the following url: 105 |
106 | 107 | Copy URL: 108 | 109 |

110 | 118 | 119 | 120 | 121 | 124 | 125 | 126 | 127 | 128 | 129 |
130 | 131 |
132 | 133 | 134 | Go To Room 135 | 136 | 137 |
138 |
139 | ); 140 | } 141 | -------------------------------------------------------------------------------- /app/src/room/Room.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Grid from '@material-ui/core/Grid'; 4 | import { Navigate, useParams, useNavigate } from 'react-router-dom'; 5 | import io from 'socket.io-client'; 6 | 7 | import { login } from './api-room'; 8 | import { Video } from './../components/Video'; 9 | import { ChatLog } from './../components/chat/ChatLog'; 10 | import { 11 | getLocalMediaStream, 12 | initializePeerConnection 13 | } from './../webrtc/webrtc'; 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | videoGrid: { 17 | position: 'relative' 18 | }, 19 | chatLog: { 20 | flex: 1, 21 | maxWidth: '400px', 22 | marginLeft: 'auto' 23 | } 24 | })); 25 | 26 | export default function Room() { 27 | const params = useParams(); 28 | const navigate = useNavigate(); 29 | const videoRef = useRef(undefined); 30 | const remoteVideoRef = useRef(undefined); 31 | const socketRef = useRef(); 32 | const classes = useStyles(); 33 | const [ values, setValues ] = useState({ 34 | redirectToCreate: false, 35 | localMediaStream: undefined, 36 | }); 37 | const [ messages, setMessages ] = useState([]); 38 | const peerConnection = initializePeerConnection(); 39 | const remoteMediaStream = new MediaStream(); 40 | 41 | useEffect(() => { 42 | console.log('init'); 43 | const abortController = new AbortController(); 44 | const signal = abortController.signal; 45 | 46 | login(params.roomKey, signal).then((data) => { 47 | if (data && data.error) { 48 | setValues({ ...values, redirectToCreate: true }); 49 | 50 | return; 51 | } 52 | }); 53 | 54 | return function cleanup() { 55 | abortController.abort(); 56 | } 57 | }, [ params.roomKey ]); 58 | 59 | useEffect(() => { 60 | // init socket 61 | socketRef.current = io(`https://${window.location.hostname}`, { reconnection: false }); 62 | handleSocket(params.roomKey); 63 | 64 | return () => { 65 | socketRef.current.disconnect(); 66 | }; 67 | }, []); 68 | 69 | const handleSocket = (roomKey) => { 70 | socketRef.current.once('connect', () => { 71 | console.log('socket connected'); 72 | initializeLocalMedia(roomKey); 73 | }); 74 | 75 | socketRef.current.once('disconnect', reason => { 76 | console.log('socket disconnected due to reason', reason); 77 | 78 | if (videoRef.current) { 79 | const mediaTracks = videoRef.current.srcObject.getTracks(); 80 | 81 | for (const mediaTrack of mediaTracks) { 82 | mediaTrack.stop(); 83 | } 84 | } 85 | 86 | navigate('/'); 87 | }); 88 | 89 | socketRef.current.once('init', async () => { 90 | try { 91 | console.log('init call'); 92 | } catch (error) { 93 | console.error(error); 94 | } 95 | }); 96 | 97 | socketRef.current.once('offer', async () => { 98 | try { 99 | console.log('create local offer'); 100 | const offer = await peerConnection.createOffer(); 101 | await peerConnection.setLocalDescription(offer); 102 | socketRef.current.emit('message', { 103 | action: 'offer', 104 | offer: peerConnection.localDescription 105 | }); 106 | } catch (error) { 107 | console.error(error); 108 | } 109 | }); 110 | 111 | socketRef.current.on('message', message => { 112 | console.log('remote message', message); 113 | 114 | handleRemoteMessage(message); 115 | }); 116 | }; 117 | 118 | const initializeLocalMedia = async (roomKey) => { 119 | try { 120 | const mediaStream = await getLocalMediaStream(); 121 | console.log('local media devices initialized'); 122 | 123 | await setValues({ ...values, localMediaStream: mediaStream }); 124 | 125 | setTimeout(() => { 126 | videoRef.current.srcObject = mediaStream; 127 | videoRef.current.play(); 128 | 129 | 130 | handlePeerConnection(roomKey); 131 | }, 1000); 132 | } catch (error) { 133 | console.error(error); 134 | } 135 | }; 136 | 137 | const handlePeerConnection = (roomKey) => { 138 | console.log('peerConnection', peerConnection); 139 | 140 | peerConnection.onicecandidate = ({ candidate }) => { 141 | if (!candidate) return; 142 | 143 | console.log('new candidate', candidate); 144 | socketRef.current.emit('message', { 145 | action: 'candidate', 146 | candidate 147 | }); 148 | }; 149 | 150 | peerConnection.oniceconnectionstatechange = () => { 151 | console.log('iceconnectionstatechange', peerConnection.iceConnectionState); 152 | 153 | if (peerConnection.iceConnectionState === 'disconnected') { 154 | console.warn('state is disconnected'); 155 | } 156 | 157 | if (peerConnection.iceConnectionState === 'disconnected' || peerConnection.isConnectionState === 'closed') { 158 | socketRef.current.disconnect(); 159 | } 160 | }; 161 | 162 | peerConnection.ontrack = ({ track }) => { 163 | console.log('on remote track', track.kind); 164 | 165 | remoteMediaStream.addTrack(track); 166 | 167 | if (track.kind === 'video') { 168 | remoteVideoRef.current.srcObject = remoteMediaStream; 169 | remoteVideoRef.current.load(); 170 | } 171 | }; 172 | 173 | for (const mediaTrack of videoRef.current.srcObject.getTracks()) { 174 | peerConnection.addTrack(mediaTrack); 175 | } 176 | 177 | console.log('init'); 178 | socketRef.current.emit('init', roomKey); 179 | }; 180 | 181 | const handleRemoteMessage = async (message) => { 182 | try { 183 | switch(message.action) { 184 | case 'offer': 185 | console.log('remote offer'); 186 | await peerConnection.setRemoteDescription(new RTCSessionDescription(message.offer)); 187 | 188 | const answer = await peerConnection.createAnswer(); 189 | await peerConnection.setLocalDescription(answer); 190 | 191 | socketRef.current.emit('message', { 192 | action: 'answer', 193 | answer 194 | }); 195 | break; 196 | case 'answer': 197 | console.log('remote answer'); 198 | await peerConnection.setRemoteDescription(message.answer); 199 | break; 200 | case 'candidate': 201 | console.log('remote ice'); 202 | await peerConnection.addIceCandidate(message.candidate); 203 | break; 204 | case 'chat': 205 | message.data.isLocal = false; 206 | console.log('chat message', message.data); 207 | 208 | setMessages(prevMessages => [ ...prevMessages, message.data ]); 209 | break; 210 | default: console.warn('unknown action', message.action); 211 | } 212 | } catch (error) { 213 | console.error(error); 214 | } 215 | }; 216 | 217 | const handleSendNewChatMessage = message => { 218 | console.log('send', message); 219 | 220 | const data = { isLocal: true, message }; 221 | setMessages(prevMessages => [ ...prevMessages, data ]); 222 | console.log(messages); 223 | 224 | socketRef.current.emit('message', { 225 | action: 'chat', 226 | data 227 | }); 228 | }; 229 | 230 | if (values.redirectToCreate) { 231 | return ; 232 | } 233 | 234 | return ( 235 |
236 | 237 | 238 | { values.localMediaStream && 239 | 256 | 257 | 258 | 259 | 264 | 265 | 266 | 267 |
268 | ); 269 | }; 270 | --------------------------------------------------------------------------------