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, +}) => ( +
+

Enter a room

+
+ + +
+ +
+ + +
+ +
+) + +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} +

+ +
+ {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 = () => ( +
+
+

ClinicAll

+
+
+ +
+ +
+) + +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(); + }); + } +}