diff --git a/.env b/.env
index 271d48c..ea7477b 100644
--- a/.env
+++ b/.env
@@ -1,3 +1,6 @@
REACT_APP_AUTH0_DOMAIN=clinicall.us.auth0.com
REACT_APP_AUTH0_CLIENT_ID=i7YJaB8vka2TJGyWKDJQtm0X07KNjFP4
SKIP_PREFLIGHT_CHECK=true
+TWILIO_ACCOUNT_SID= "AC7d0f4b2ab93382fa387732772e2fadf8"
+TWILIO_API_KEY= "SK2494b65a32e9e545b07ce0c8c0100474"
+TWILIO_API_SECRET= "RBNeyZn7rQRZLvqE3QT23yqMzlST0tdA"
diff --git a/package.json b/package.json
index e2c821e..eae2795 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
- "@material-ui/core": "^4.11.0",
"@auth0/auth0-react": "^1.0.0",
+ "@material-ui/core": "^4.11.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
@@ -17,40 +17,35 @@
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-rtc-real": "^1.11.22",
- "react-scripts": "3.4.3"
+ "react-scripts": "3.4.3",
+ "twilio-video": "^2.7.2"
},
"devDependencies": {
- "@types/express": "^4.17.2",
- "@types/socket.io": "^2.1.4",
- "eslint-config-airbnb": "^18.2.0",
- "eslint-plugin-import": "^2.22.0",
- "eslint-plugin-import-helpers": "^1.1.0",
- "eslint-plugin-jsx-a11y": "^6.3.1",
- "eslint-plugin-react": "^7.20.6",
- "eslint-plugin-react-hooks": "^4.1.0"
+ "body-parser": "^1.19.0",
+ "express": "^4.17.1",
+ "express-pino-logger": "^4.0.0",
+ "node-env-run": "^3.0.2",
+ "nodemon": "^1.19.3",
+ "npm-run-all": "^4.1.5",
+ "pino-colada": "^1.4.5",
+ "twilio": "^3.33.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
- "client": "cd client && npm start",
- "dev": "nodemon --watch './server/**/*.js' --exec 'babel-node' ./server/server.js",
- "server": "babel-node server/server.js"
+ "server": "node-env-run server --exec nodemon | pino-colada",
+ "dev": "run-p server start"
},
+ "proxy": "http://localhost:3001",
"eslintConfig": {
"extends": "react-app"
},
- "browserslist": {
- "production": [
- ">0.2%",
- "not dead",
- "not op_mini all"
- ],
- "development": [
- "last 1 chrome version",
- "last 1 firefox version",
- "last 1 safari version"
- ]
- }
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ]
}
diff --git a/server/config.js b/server/config.js
new file mode 100644
index 0000000..07e4da3
--- /dev/null
+++ b/server/config.js
@@ -0,0 +1,10 @@
+module.exports = {
+ twilio: {
+ accountSid: process.env.TWILIO_ACCOUNT_SID,
+ apiKey: process.env.TWILIO_API_KEY,
+ apiSecret: process.env.TWILIO_API_SECRET,
+ chatService: process.env.TWILIO_CHAT_SERVICE_SID,
+ outgoingApplicationSid: process.env.TWILIO_TWIML_APP_SID,
+ incomingAllow: process.env.TWILIO_ALLOW_INCOMING_CALLS === "true"
+ }
+};
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..15a967c
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,67 @@
+const config = require('./config');
+const express = require('express');
+const bodyParser = require('body-parser');
+const pino = require('express-pino-logger')();
+const { chatToken, videoToken, voiceToken } = require('./tokens');
+
+const app = express();
+app.use(bodyParser.urlencoded({ extended: false }));
+app.use(bodyParser.json());
+app.use(pino);
+
+const sendTokenResponse = (token, res) => {
+ res.set('Content-Type', 'application/json');
+ res.send(
+ JSON.stringify({
+ token: token.toJwt()
+ })
+ );
+};
+
+app.get('/api/greeting', (req, res) => {
+ const name = req.query.name || 'World';
+ res.setHeader('Content-Type', 'application/json');
+ res.send(JSON.stringify({ greeting: `Hello ${name}!` }));
+});
+
+app.get('/chat/token', (req, res) => {
+ const identity = req.query.identity;
+ const token = chatToken(identity, config);
+ sendTokenResponse(token, res);
+});
+
+app.post('/chat/token', (req, res) => {
+ const identity = req.body.identity;
+ const token = chatToken(identity, config);
+ sendTokenResponse(token, res);
+});
+
+app.get('/video/token', (req, res) => {
+ const identity = req.query.identity;
+ const room = req.query.room;
+ const token = videoToken(identity, room, config);
+ sendTokenResponse(token, res);
+});
+
+app.post('/video/token', (req, res) => {
+ const identity = req.body.identity;
+ const room = req.body.room;
+ const token = videoToken(identity, room, config);
+ sendTokenResponse(token, res);
+});
+
+app.get('/voice/token', (req, res) => {
+ const identity = req.body.identity;
+ const token = voiceToken(identity, config);
+ sendTokenResponse(token, res);
+});
+
+app.post('/voice/token', (req, res) => {
+ const identity = req.body.identity;
+ const token = voiceToken(identity, config);
+ sendTokenResponse(token, res);
+});
+
+app.listen(3001, () =>
+ console.log('Express server is running on localhost:3001')
+);
diff --git a/server/tokens.js b/server/tokens.js
new file mode 100644
index 0000000..019ce5d
--- /dev/null
+++ b/server/tokens.js
@@ -0,0 +1,54 @@
+const twilio = require("twilio");
+const AccessToken = twilio.jwt.AccessToken;
+const { ChatGrant, VideoGrant, VoiceGrant } = AccessToken;
+
+const generateToken = config => {
+ return new AccessToken(
+ config.twilio.accountSid,
+ config.twilio.apiKey,
+ config.twilio.apiSecret
+ );
+};
+
+const chatToken = (identity, config) => {
+ const chatGrant = new ChatGrant({
+ serviceSid: config.twilio.chatService
+ });
+ const token = generateToken(config);
+ token.addGrant(chatGrant);
+ token.identity = identity;
+ return token;
+};
+
+const videoToken = (identity, room, config) => {
+ let videoGrant;
+ if (typeof room !== "undefined") {
+ videoGrant = new VideoGrant({ room });
+ } else {
+ videoGrant = new VideoGrant();
+ }
+ const token = generateToken(config);
+ token.addGrant(videoGrant);
+ token.identity = identity;
+ return token;
+};
+
+const voiceToken = (identity, config) => {
+ let voiceGrant;
+ if (typeof config.twilio.outgoingApplicationSid !== "undefined") {
+ voiceGrant = new VoiceGrant({
+ outgoingApplicationSid: config.twilio.outgoingApplicationSid,
+ incomingAllow: config.twilio.incomingAllow
+ });
+ } else {
+ voiceGrant = new VoiceGrant({
+ incomingAllow: config.twilio.incomingAllow
+ });
+ }
+ const token = generateToken(config);
+ token.addGrant(voiceGrant);
+ token.identity = identity;
+ return token;
+};
+
+module.exports = { chatToken, videoToken, voiceToken };
diff --git a/src/App.scss b/src/App.scss
index 2b72a4a..4e6129c 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1,30 +1,4 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- height: 40vmin;
- pointer-events: none;
-}
-
.App-header {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
- font-size: calc(10px + 2vmin);
- color: white;
-}
-
-.App-link {
- color: #61dafb;
-}
-
-@keyframes App-logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
}
diff --git a/src/App.test.js b/src/App.test.js
index 4db7ebc..a754b20 100644
--- a/src/App.test.js
+++ b/src/App.test.js
@@ -1,9 +1,9 @@
import React from 'react';
-import { render } from '@testing-library/react';
+import ReactDOM from 'react-dom';
import App from './App';
-test('renders learn react link', () => {
- const { getByText } = render( );
- const linkElement = getByText(/learn react/i);
- expect(linkElement).toBeInTheDocument();
+it('renders without crashing', () => {
+ const div = document.createElement('div');
+ ReactDOM.render( , div);
+ ReactDOM.unmountComponentAtNode(div);
});
diff --git a/src/Routes.js b/src/Routes.js
index 438d793..b7dbf2d 100644
--- a/src/Routes.js
+++ b/src/Routes.js
@@ -5,6 +5,7 @@ import { useAuth0 } from "@auth0/auth0-react"
import Home from "./Home"
import Profile from "./pages/Profile"
+import Chat from './VideoChat'
function Routes() {
const { isAuthenticated } = useAuth0()
@@ -20,6 +21,7 @@ function Routes() {
))}
/>
+
)
}
diff --git a/src/VideoChat/App.scss b/src/VideoChat/App.scss
new file mode 100644
index 0000000..79ddb84
--- /dev/null
+++ b/src/VideoChat/App.scss
@@ -0,0 +1,143 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html {
+ height: 100%;
+}
+
+body {
+ font-family: Helvetica, Arial, sans-serif;
+ color: #333e5a;
+ min-height: 100%;
+}
+
+header {
+ background: #f0293e;
+ color: #fff;
+ text-align: center;
+ flex-grow: 0;
+ margin-bottom: 2em;
+}
+
+h1 {
+ font-weight: 300;
+ padding: 0.4em 0;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.app {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+main {
+ background: #ffffff;
+ flex-grow: 1;
+}
+
+form {
+ max-width: 300px;
+ margin: 0 auto;
+}
+
+h2 {
+ font-weight: 300;
+ margin-bottom: 1em;
+ text-align: center;
+}
+
+form > div {
+ width: 100%;
+ margin-bottom: 1em;
+}
+form > div > label {
+ display: block;
+ margin-bottom: 0.3em;
+}
+form > div > input {
+ display: block;
+ width: 100%;
+ font-size: 16px;
+ padding: 0.4em;
+ border-radius: 6px;
+ border: 1px solid #333e5a;
+}
+
+button {
+ background: #333e5a;
+ color: #fff;
+ font-size: 16px;
+ padding: 0.4em;
+ border-radius: 6px;
+ border: 1px solid transparent;
+}
+button:hover {
+ filter: brightness(150%);
+}
+
+.room {
+ position: relative;
+}
+.room button {
+ position: absolute;
+ top: 0;
+ right: 20px;
+}
+.room > h3 {
+ text-align: center;
+ font-weight: 300;
+ margin-bottom: 1em;
+}
+
+.local-participant {
+ text-align: center;
+ margin-bottom: 2em;
+}
+.remote-participants {
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: space-between;
+ padding: 0 2em 2em;
+}
+.participant {
+ background: #333e5a;
+ padding: 10px;
+ border-radius: 6px;
+ display: inline-block;
+ margin-right: 10px;
+}
+.participant:last-child {
+ margin-right: 0;
+}
+.participant h3 {
+ text-align: center;
+ padding-bottom: 0.5em;
+ color: #fff;
+}
+
+video {
+ width: 100%;
+ max-width: 600px;
+ display: block;
+ margin: 0 auto;
+ border-radius: 6px;
+}
+
+footer {
+ background: #333e5a;
+ color: #fff;
+ text-align: center;
+ flex-grow: 0;
+ padding: 1em 0;
+}
+
+footer a {
+ color: #fff;
+}
diff --git a/src/VideoChat/Lobby.js b/src/VideoChat/Lobby.js
new file mode 100644
index 0000000..fecf9ed
--- /dev/null
+++ b/src/VideoChat/Lobby.js
@@ -0,0 +1,37 @@
+import React from "react"
+
+const Lobby = ({
+ username,
+ handleUsernameChange,
+ roomName,
+ handleRoomNameChange,
+ handleSubmit,
+}) => (
+
+)
+
+export default Lobby
diff --git a/src/VideoChat/Participant.js b/src/VideoChat/Participant.js
new file mode 100644
index 0000000..5f0b74c
--- /dev/null
+++ b/src/VideoChat/Participant.js
@@ -0,0 +1,63 @@
+import React, { useState, useEffect, useRef } from 'react';
+
+const Participant = ({ participant }) => {
+ const [videoTracks, setVideoTracks] = useState([]);
+ const [audioTracks, setAudioTracks] = useState([]);
+
+ const videoRef = useRef();
+ const audioRef = useRef();
+
+ const trackpubsToTracks = trackMap => Array.from(trackMap.values())
+ .map(publication => publication.track)
+ .filter(track => track !== null);
+
+ useEffect(() => {
+ const trackSubscribed = track => {
+ if (track.kind === 'video') {
+ setVideoTracks(videoTracks => [...videoTracks, track]);
+ } else {
+ setAudioTracks(audioTracks => [...audioTracks, track]);
+ }
+ };
+
+ const trackUnsubscribed = track => {
+ if (track.kind === 'video') {
+ setVideoTracks(videoTracks => videoTracks.filter(v => v !== track));
+ } else {
+ setAudioTracks(audioTracks => audioTracks.filter(a => a !== track));
+ }
+ };
+
+ setVideoTracks(trackpubsToTracks(participant.videoTracks));
+ setAudioTracks(trackpubsToTracks(participant.audioTracks));
+
+ participant.on('trackSubscribed', trackSubscribed);
+ participant.on('trackUnsubscribed', trackUnsubscribed);
+
+ return () => {
+ setVideoTracks([]);
+ setAudioTracks([]);
+ participant.removeAllListeners();
+ };
+ }, [participant]);
+
+ useEffect(() => {
+ const videoTrack = videoTracks[0];
+ if (videoTrack) {
+ videoTrack.attach(videoRef.current);
+ return () => {
+ videoTrack.detach();
+ };
+ }
+ }, [videoTracks]);
+
+ return (
+
+
{participant.identity}
+
+
+
+ );
+};
+
+export default Participant;
diff --git a/src/VideoChat/Room.js b/src/VideoChat/Room.js
new file mode 100644
index 0000000..7affec0
--- /dev/null
+++ b/src/VideoChat/Room.js
@@ -0,0 +1,69 @@
+import React, { useState, useEffect } from "react"
+
+import Video from "twilio-video"
+
+import Participant from "./Participant"
+
+const Room = ({ roomName, token, handleLogout }) => {
+ const [room, setRoom] = useState(null)
+ const [participants, setParticipants] = useState([])
+
+ const remoteParticipants = participants.map((participant) => (
+
+ ))
+
+ useEffect(() => {
+ const participantConnected = (participant) => {
+ setParticipants((prevParticipants) => [...prevParticipants, participant])
+ }
+ const participantDisconnected = (participant) => {
+ setParticipants((prevParticipants) => prevParticipants.filter((p) => p !== participant))
+ }
+ Video.connect(token, {
+ name: roomName,
+ }).then((room) => {
+ setRoom(room)
+ room.on("participantConnected", participantConnected)
+ room.on("participantDisconnected", participantDisconnected)
+ room.participants.forEach(participantConnected)
+ })
+
+ return () => {
+ setRoom((currentRoom) => {
+ if (currentRoom && currentRoom.localParticipant.state === "connected") {
+ currentRoom.localParticipant.tracks.forEach((trackPublication) => {
+ trackPublication.track.stop()
+ })
+ currentRoom.disconnect()
+ return null
+ }
+ return currentRoom
+ })
+ }
+ }, [roomName, token])
+
+ return (
+
+
+ Room:
+ {" "}
+ {roomName}
+
+
Log out
+
+ {room ? (
+
+ ) : (
+ ""
+ )}
+
+
Remote Participants
+
{remoteParticipants}
+
+ )
+}
+
+export default Room
diff --git a/src/VideoChat/VideoChat.js b/src/VideoChat/VideoChat.js
new file mode 100644
index 0000000..e705bc1
--- /dev/null
+++ b/src/VideoChat/VideoChat.js
@@ -0,0 +1,57 @@
+import React, {useState, useCallback} from "react";
+
+import Lobby from "./Lobby";
+import Room from './Room';
+
+const VideoChat = () => {
+ const [username, setUsername] = useState('');
+ const [roomName, setRoomName] = useState('');
+ const [token, setToken] = useState(null);
+
+ const handleUsernameChange = useCallback(event => {
+ setUsername(event.target.value);
+ }, []);
+
+ const handleRoomNameChange = useCallback(event => {
+ setRoomName(event.target.value);
+ }, []);
+
+ const handleSubmit = useCallback(async event => {
+ event.preventDefault();
+ const data = await fetch('/video/token', {
+ method: 'POST',
+ body: JSON.stringify({
+ identity: username,
+ room: roomName
+ }),
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ }).then(res => res.json());
+ setToken(data.token);
+ }, [username, roomName]);
+
+ const handleLogout = useCallback(event => {
+ setToken(null);
+ }, []);
+
+ let render;
+ if (token) {
+ render = (
+
+ );
+ } else {
+ render = (
+
+ );
+ }
+ return (render);
+};
+
+export default VideoChat;
diff --git a/src/VideoChat/index.js b/src/VideoChat/index.js
new file mode 100644
index 0000000..356a767
--- /dev/null
+++ b/src/VideoChat/index.js
@@ -0,0 +1,30 @@
+import React from "react"
+
+import "./App.scss"
+import VideoChat from "./VideoChat"
+
+const Chat = () => (
+
+)
+
+export default Chat
diff --git a/src/global.css b/src/global.css
new file mode 100644
index 0000000..fbfc9cc
--- /dev/null
+++ b/src/global.css
@@ -0,0 +1,11 @@
+video{
+ width : 100%;
+}
+
+.githubribbon {
+ position: fixed;
+ right: 0;
+ z-index: 99999999;
+ height: 149px;
+ top: 0;
+}
\ No newline at end of file
diff --git a/src/index.scss b/src/index.scss
index 53c233d..60bf4b8 100644
--- a/src/index.scss
+++ b/src/index.scss
@@ -1,5 +1,6 @@
body {
margin: 0;
+ padding: 0;
font-family: -apple-system, 'Mulish', BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
diff --git a/src/serviceWorker.js b/src/serviceWorker.js
new file mode 100644
index 0000000..8859a0c
--- /dev/null
+++ b/src/serviceWorker.js
@@ -0,0 +1,127 @@
+// In production, we register a service worker to serve assets from local cache.
+
+// This lets the app load faster on subsequent visits in production, and gives
+// it offline capabilities. However, it also means that developers (and users)
+// will only see deployed updates on the "N+1" visit to a page, since previously
+// cached resources are updated in the background.
+
+// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
+// This link also includes instructions on opting out of this behavior.
+
+const isLocalhost = Boolean(
+ window.location.hostname === 'localhost' ||
+ // [::1] is the IPv6 localhost address.
+ window.location.hostname === '[::1]' ||
+ // 127.0.0.1/8 is considered localhost for IPv4.
+ window.location.hostname.match(
+ /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
+ )
+);
+
+export function register(config) {
+ if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
+ // The URL constructor is available in all browsers that support SW.
+ const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
+ if (publicUrl.origin !== window.location.origin) {
+ // Our service worker won't work if PUBLIC_URL is on a different origin
+ // from what our page is served on. This might happen if a CDN is used to
+ // serve assets; see https://github.com/facebook/create-react-app/issues/2374
+ return;
+ }
+
+ window.addEventListener('load', () => {
+ const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
+
+ if (isLocalhost) {
+ // This is running on localhost. Let's check if a service worker still exists or not.
+ checkValidServiceWorker(swUrl, config);
+
+ // Add some additional logging to localhost, pointing developers to the
+ // service worker/PWA documentation.
+ navigator.serviceWorker.ready.then(() => {
+ console.log(
+ 'This web app is being served cache-first by a service ' +
+ 'worker. To learn more, visit https://goo.gl/SC7cgQ'
+ );
+ });
+ } else {
+ // Is not local host. Just register service worker
+ registerValidSW(swUrl, config);
+ }
+ });
+ }
+}
+
+function registerValidSW(swUrl, config) {
+ navigator.serviceWorker
+ .register(swUrl)
+ .then(registration => {
+ registration.onupdatefound = () => {
+ const installingWorker = registration.installing;
+ installingWorker.onstatechange = () => {
+ if (installingWorker.state === 'installed') {
+ if (navigator.serviceWorker.controller) {
+ // At this point, the old content will have been purged and
+ // the fresh content will have been added to the cache.
+ // It's the perfect time to display a "New content is
+ // available; please refresh." message in your web app.
+ console.log('New content is available; please refresh.');
+
+ // Execute callback
+ if (config.onUpdate) {
+ config.onUpdate(registration);
+ }
+ } else {
+ // At this point, everything has been precached.
+ // It's the perfect time to display a
+ // "Content is cached for offline use." message.
+ console.log('Content is cached for offline use.');
+
+ // Execute callback
+ if (config.onSuccess) {
+ config.onSuccess(registration);
+ }
+ }
+ }
+ };
+ };
+ })
+ .catch(error => {
+ console.error('Error during service worker registration:', error);
+ });
+}
+
+function checkValidServiceWorker(swUrl, config) {
+ // Check if the service worker can be found. If it can't reload the page.
+ fetch(swUrl)
+ .then(response => {
+ // Ensure service worker exists, and that we really are getting a JS file.
+ if (
+ response.status === 404 ||
+ response.headers.get('content-type').indexOf('javascript') === -1
+ ) {
+ // No service worker found. Probably a different app. Reload the page.
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister().then(() => {
+ window.location.reload();
+ });
+ });
+ } else {
+ // Service worker found. Proceed as normal.
+ registerValidSW(swUrl, config);
+ }
+ })
+ .catch(() => {
+ console.log(
+ 'No internet connection found. App is running in offline mode.'
+ );
+ });
+}
+
+export function unregister() {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ registration.unregister();
+ });
+ }
+}