diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 070944f..0000000 --- a/.dockerignore +++ /dev/null @@ -1,22 +0,0 @@ -# Copyright 2015 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -node_modules -.dockerignore -Dockerfile -npm-debug.log -yarn-error.log -.git -.hg -.svn diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 32e99ff..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Dockerfile extending the generic Node image with application files for a -# single application. -FROM gcr.io/google_appengine/nodejs -# Check to see if the the version included in the base runtime satisfies -# '>=4.3.2', if not then do an npm install of the latest available -# version that satisfies it. -RUN /usr/local/bin/install_node '>=4.3.2' -COPY . /app/ -# You have to specify "--unsafe-perm" with npm install -# when running as root. Failing to do this can cause -# install to appear to succeed even if a preinstall -# script fails, and may have other adverse consequences -# as well. -# This command will also cat the npm-debug.log file after the -# build, if it exists. -RUN npm install --unsafe-perm || \ - ((if [ -f npm-debug.log ]; then \ - cat npm-debug.log; \ - fi) && false) -CMD npm run start:production diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..489b270 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node server.js diff --git a/README.md b/README.md index 26e7760..ae2ba8e 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,16 @@ The site is currently hosted through Google Cloud Platform (GCP). The main reaso - Make sure to lock `package.json` versions by removing `^` from the version number - Create a version release in GitHub -### Google Cloud Platform -In order to deploy the app to GCP, either set up the SDK locally, or push the code up to the repo's `master` branch in GitHub (which is mirrored to GCP) and log in through Google Cloud Shell and run +### Heroku +In order to deploy the app to Heroku's staging server, run: + +`npm run deploy:current` -`npm run deploy` +When you're ready to promote the staging app to production, use the Pipeline in the Heroku Dashboard. -To view server logs, make sure you're connected into a Google Cloud Shell instance and run +To view server logs, make sure you're connected into either the staging or production Heroku app and run: -`gcloud app logs tail -s default` +`heroku logs --tail` or `npm run remote:logs` --- BEGINE CREATE REACT APP README diff --git a/app.yaml b/app.yaml deleted file mode 100644 index 9c38a34..0000000 --- a/app.yaml +++ /dev/null @@ -1,6 +0,0 @@ -env: flex -runtime: custom -instance_class: F1 -automatic_scaling: - min_num_instances: 1 - max_num_instances: 1 diff --git a/package.json b/package.json index 7f88c55..87f921a 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,15 @@ { "name": "portfolio-site", - "version": "0.1.5", + "version": "0.1.7", "private": true, "homepage": "http://brunnerjosh.github.io", "bugs": { "url": "https://github.com/brunnerjosh/brunnerjosh.github.io/issues" }, "engines": { - "node": ">=4.3.2" + "node": ">=6.x" }, + "proxy": "https://localhost:3001", "devDependencies": { "react-scripts": "0.9.5" }, @@ -19,25 +20,36 @@ "dotenv": "4.0.0", "express": "4.15.2", "flickrapi": "0.6.0", + "fs": "0.0.1-security", + "install": "0.8.8", "json-loader": "0.5.4", + "lodash": "4.17.4", "moment": "2.17.1", + "npm": "4.5.0", "path": "0.12.7", + "portals": "^1.0.9", "react": "15.4.2", "react-addons-css-transition-group": "15.4.2", "react-dom": "15.4.2", "react-ga": "2.1.2", + "react-input-mask": "^0.8.0", "react-redux": "5.0.2", "react-router": "3.0.2", "react-router-redux": "4.0.7", "redux": "3.6.0", "redux-logger": "2.7.4", - "redux-thunk": "2.2.0" + "redux-thunk": "2.2.0", + "socket.io": "^1.7.3", + "uuid": "3.0.1", + "webrtc-adapter": "3.3.3" }, "scripts": { "start": "react-scripts start", - "start:production": "NODE_ENV=production node server.js", + "start:proxy": "PORT=3001 NODE_ENV=development node server.js", + "start:proxy-prod": "PORT=3001 NODE_ENV=production node server.js", "build": "react-scripts build", - "deploy": "npm run build; gcloud app deploy", + "deploy:current": "npm run build; git push heroku $(git rev-parse --symbolic-full-name --abbrev-ref HEAD):master", + "remote:logs": "heroku logs --tail", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" } diff --git a/routes/api-router.js b/routes/api-router.js index 933ba3b..59d4d8b 100644 --- a/routes/api-router.js +++ b/routes/api-router.js @@ -1,5 +1,7 @@ +const io = require('socket.io')(); const express = require('express'); const Flickr = require('flickrapi'); +const findRoom = require('./chatManager') ; const flickrOptions = { api_key: process.env.FLICKR_KEY, secret: process.env.FLICKR_SECRET @@ -11,6 +13,11 @@ router.get('/', function(req, res) { res.json({ message: 'success' }); }); +router.get('/room', (req, res) => { + const roomId = findRoom().roomId; + res.json({ roomId: roomId }); +}) + Flickr.tokenOnly(flickrOptions, function(error, flickr) { if (error) console.error('Flickr error: ', error); flickr.proxy(router, '/flickr') diff --git a/routes/chatManager.js b/routes/chatManager.js new file mode 100644 index 0000000..d51120a --- /dev/null +++ b/routes/chatManager.js @@ -0,0 +1,56 @@ +const rooms = []; +const OPEN_ROOM_DURATION = 2; // mins + +function addDashes (num) +{ + return num.slice(0,3)+'-'+num.slice(3,6)+'-'+num.slice(6); +} + +function isUniqueRoom (roomId) { + for (var i = 0; i < rooms.length; i++){ + if (rooms[i].roomId === roomId) { + return false; + } + } + return true; +} + +function createNewRoom () { + return { + createdAt: (new Date()).getTime(), + roomId: addDashes(Math.floor(100000000 + Math.random() * 900000000).toString()) + }; +} + +function generateRoomId () { + var newRoom = createNewRoom(); + if (isUniqueRoom(newRoom.roomId)) { + rooms.push(newRoom); + return rooms[rooms.length - 1]; + } + return null; +} + +function findRoom () { + const lastRoom = rooms[rooms.length - 1] || null; + if (lastRoom) { + const timeNow = (new Date()).getTime(); + const fiveMinsBefore = timeNow - OPEN_ROOM_DURATION * 60 * 1000; + if (lastRoom.createdAt > fiveMinsBefore) { + console.log('Returning previously created room', lastRoom); + return lastRoom; + } else { + while (true) { + var newRoom = generateRoomId(); + if (newRoom) { + console.log("Creating new chat room: ", newRoom) + return newRoom; + } + } + } + } else { + return generateRoomId(); + } +} + +module.exports = findRoom; diff --git a/routes/socket.js b/routes/socket.js new file mode 100644 index 0000000..6cabb39 --- /dev/null +++ b/routes/socket.js @@ -0,0 +1,68 @@ +const io = require('socket.io')(); + +const socketIdToNames = {} + +//------------------------------------------------------------------------------ +// Credit to: https://github.com/oney/react-native-webrtc-server +// WebRTC Signaling +function socketIdsInRoom(roomId) { + var socketIds = io.nsps['/'].adapter.rooms[roomId]; + if (socketIds.sockets) { + var collection = []; + for (var key in socketIds.sockets) { + collection.push(key); + } + return collection; + } else { + return []; + } +} + +io.on('connection', function(socket){ + console.log('SOCKET_CONNECT: ' + socket.id); + + socket.on('disconnect', function(){ + console.log('SOCKET_DISCONNECT: ' + socket.id + ': ' + socketIdToNames[socket.id]); + delete socketIdToNames[socket.id]; + if (socket.room) { + var room = socket.room; + io.to(room).emit('leave', socket.id); + socket.leave(room); + } + }); + + /** + * Callback: list of {socketId, name: name of user} + */ + socket.on('join', function(joinData, callback){ //Join room + let roomId = joinData.roomId; + let name = joinData.name; + socket.join(roomId); + socket.room = roomId; + socketIdToNames[socket.id] = name; + var socketIds = socketIdsInRoom(roomId); + let friends = socketIds.map((socketId) => { + return { + socketId: socketId, + name: socketIdToNames[socketId] + } + }).filter((friend) => friend.socketId != socket.id); + callback(friends); + // broadcast + friends.forEach((friend) => { + io.sockets.connected[friend.socketId].emit('join', { + socketId: socket.id, name + }); + }); + console.log('SOCKET_JOIN: ', joinData); + }); + + socket.on('exchange', function(data){ + data.from = socket.id; + var to = io.sockets.connected[data.to]; + to.emit('exchange', data); + }); + +}); + +module.exports = io; diff --git a/server.js b/server.js index 3eabb5d..cfff6bf 100644 --- a/server.js +++ b/server.js @@ -1,28 +1,43 @@ require('dotenv').config(); +const fs = require('fs'); const path = require('path'); +const http = require('http'); +const https = require('https'); const express = require('express'); +const app = express(); const bodyParser = require('body-parser'); const routes = require('./routes/api-router'); - -const app = express(); +const socket = require('./routes/socket'); +const config = require('./static.json'); const port = process.env.PORT || 8080; -const config = { - root: 'build/', - home: 'index.html' +const options = process.env.NODE_ENV === 'development' ? { + key: fs.readFileSync('../.localhost-ssl/key.pem'), + cert: fs.readFileSync('../.localhost-ssl/cert.pem') +} : {}; + +// http://stackoverflow.com/questions/7185074/heroku-nodejs-http-to-https-ssl-forced-redirect +const forceSSL = (req, res, next) => { + return req.headers['x-forwarded-proto'] !== 'https' + ? res.redirect(['https://', req.get('Host'), req.url].join('')) + : next(); }; +const secureMode = process.env.HTTPS === 'true' && process.env.NODE_ENV === 'development'; +const server = secureMode ? https.createServer(options, app) : http.createServer(app); +socket.listen(server); + +if (process.env.NODE_ENV === 'production') app.use(forceSSL); app.use(bodyParser.json()); // for parsing application/json app.use(bodyParser.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded app.use('/api', routes); -app.use(express.static(__dirname + `/${config.root}`)); - -app.get('*', (req, res) => res.sendFile( path.resolve(__dirname, config.root, config.home)) ); +app.use(express.static(__dirname + `/${config['root']}`)); +app.get('*', (req, res) => res.sendFile( path.resolve(__dirname, config['root'], config.routes['/**'])) ); -app.listen(port, function (err) { +server.listen(port, function (err) { if (err) { console.error(err); return; } - - console.log('Express server listening on %d, in %s mode', port, app.get('env')); + console.log( (secureMode ? 'HTTPS' : 'HTTP') + ' Express server listening on %d, in %s mode', port, app.get('env')); }); + diff --git a/src/Actions/WebRTC.js b/src/Actions/WebRTC.js new file mode 100644 index 0000000..f6fe33c --- /dev/null +++ b/src/Actions/WebRTC.js @@ -0,0 +1,14 @@ +import Api from '../Services/Api'; +import { initWebRTC } from '../Services/WebRTC'; + +export function findOpenChatRoom () { + return dispatch => { + Api.get('api/room') + .then( data => { + initWebRTC(data.body.roomId)(dispatch); + }) + .catch( err => { + dispatch({ type: 'GET_CHAT_ROOM_ERROR' }) + }) + } +} diff --git a/src/Assets/medium_posts.json b/src/Assets/medium_posts.json index 9e50d57..56771b7 100644 --- a/src/Assets/medium_posts.json +++ b/src/Assets/medium_posts.json @@ -1,11 +1,18 @@ { "items": [ + { + "title": "From Hawaiian Farms to Helpful Human — The Story of How I Became a Developer by Accident", + "pubdate": "Fri, 28 April 2017 17:29:01 GMT", + "link": "https://medium.com/helpful-human/from-hawaiian-farms-to-helpful-human-the-story-of-how-i-became-a-developer-by-accident-352d2b9f421e", + "img": "https://cdn-images-1.medium.com/max/2000/1*hBHY59BtiyU0Ng6JBLKpgA.jpeg", + "description": "I grew up on the beautiful Big Island of Hawaii. My father moved to the Big Island 36 years ago to sell “puka shell” necklaces..." + }, { "title": "Embracing Dyslexia As A Software Engineer", "pubdate": "Tue, 14 Mar 2017 17:29:01 GMT", "link": "https://medium.com/helpful-human/embracing-dyslexia-as-a-software-engineer-86419a94bd94", "img": "https://cdn-images-1.medium.com/max/1000/1*reqzxN6y8YoDeEQLLq2cJQ.png", - "description": "Growing up in a small private school, I did not have much exposure to other types of thinkers. So, when I would slip up when reading out…" + "description": "Growing up in a small private school, I did not have much exposure to other types of thinkers. So, when I would slip up when reading out..." } ] } diff --git a/src/Components/App.js b/src/Components/App.js index 6e2ec7f..3d5c65f 100644 --- a/src/Components/App.js +++ b/src/Components/App.js @@ -7,6 +7,8 @@ import Theme from '../Components/Theme'; import '../Styles/Reset.css'; import '../Styles/index.css'; +import '../Styles/Button.css'; +import '../Styles/Input.css'; import '../Styles/App.css'; import '../Styles/Content.css'; diff --git a/src/Components/Icon/Icons.js b/src/Components/Icon/Icons.js index afcbb76..db8f7c6 100644 --- a/src/Components/Icon/Icons.js +++ b/src/Components/Icon/Icons.js @@ -448,8 +448,35 @@ export function Arduino () { ) } +export function screenFull () { + return ( + + + + ) +} + +export function screenNormal () { + return ( + + + + ) +} + +export function Alert() { + return ( + + + + ) +} + export default { + screenFull, + screenNormal, W, + Alert, UWBSTEM, FilePDF, Repo, diff --git a/src/Components/Live/Live.css b/src/Components/Live/Live.css new file mode 100644 index 0000000..e4c6ef9 --- /dev/null +++ b/src/Components/Live/Live.css @@ -0,0 +1,171 @@ +.live { + width: 100%; + display: flex; + justify-content: center; + margin-top: 4em; +} + +.live.is-fullscreen { + left: 0; + bottom: 0; + z-index: 10; + width: 100vw; + height: 100vh; + position: fixed; +} + +.live.is-fullscreen .live__friend-selected video { + width: 100vw; + height: 100vh; +} + +.live__container { + width: 100%; + height: 100%; + display: flex; + position: relative; + align-items: center; + justify-content: space-between; + background: #101113; + box-shadow: 0 0.2em 0.9em 0.1em rgba(0, 0, 0, 0.4) +} + +/* THESE GET ENABLED WHEN THE USER IS HOVERING THE LIVE_CONTAINER */ +.live__container:hover .live__local-stream, +.live__container:hover .live__fullscreen-btn, +.live__container:hover .live__leave-room-btn { + opacity: 1; +} + +.live__local-stream, +.live__fullscreen-btn, +.live__leave-room-btn { + opacity: .1; + transition: opacity 1000ms ease; +} +.live__fullscreen-btn, +.live__enter-room-btn, +.live__leave-room-btn, +.live__local-stream { + flex-basis: 25%; +} + +.live__fullscreen-btn { + z-index: 1; + padding: 1em; + cursor: pointer; + align-self: flex-start; +} + +.live__enter-room-btn { + z-index: 1; + text-align: center; + flex-direction: column; +} + +.live__enter-room-btn .live__enter-room-alt { + margin: 0; + padding: 1em; + font-size: .8em; + cursor: pointer; + color: #47BA83; + text-align: center; + text-transform: uppercase; +} + +.live__enter-room-btn input { + font-size: 2em; + text-align: center; +} + +.live__leave-room-btn { + z-index: 1; + padding: 1em; + text-align: center; + align-self: flex-start; +} + +.live__leave-room-btn .live__leave-room-alt { + margin: 0; + padding: 1em 0em; + letter-spacing: .3em; + color: #fafafa; + text-align: center; + text-shadow: 0em 0em 0.5em black; +} + +.live__self-feed { + height: 100%; + position: relative; + box-shadow: inset 0 0 .3em 0em rgba(0, 0, 0, 0.3); +} + +.live__self-feed video { + height: 100%; + width: 100%; + transform: scaleX(-1); +} + +.live__local-conn-count { + top: 0; + right: 0; + z-index: 1; + color: rgba(255,255,255,0.5); + padding: 0.3em; + font-size: .8em; + line-height: 1.2; + text-align: right; + position: absolute; + text-shadow: 0em 0em 0.5em black; +} + +.live__local-stream { + z-index: 1; + padding: 1em; + position: relative; + align-self: flex-start; +} + +.live__friend { + width: 100%; + position: absolute; +} + +.live__friend-feed video { + width: 100%; + height: 100%; + transform: scaleX(-1); +} + +.live__friend-tray { + bottom: 0; + width: 100%; + height: 10em; + display: flex; + position: absolute; + flex-direction: row-reverse; + justify-content: space-between; +} + +.live__friend-tray .live__friend-feed { + z-index: 1; + cursor: pointer; + flex-basis: 25%; +} + +.live__preview { + top: 0; + left: 0; + z-index: 1; + width: 100%; + height: 100%; + display: flex; + cursor: pointer; + color: #2B2D34; + position: absolute; + padding: .5em 1.5em; + align-items: center; + background: #fafafa; + justify-content: center; + border: .5em dashed #2B2D34; +} diff --git a/src/Components/Live/PeerFeed.js b/src/Components/Live/PeerFeed.js new file mode 100644 index 0000000..6508ccf --- /dev/null +++ b/src/Components/Live/PeerFeed.js @@ -0,0 +1,18 @@ +import React from 'react'; + +export default class PeerFeed extends React.Component { + + componentDidMount () { + if (this.stream) { + this.stream.srcObject = this.props.stream; + } + } + + render () { + return ( +
+
+ ) + } +} diff --git a/src/Components/Live/Peers.js b/src/Components/Live/Peers.js new file mode 100644 index 0000000..c744e01 --- /dev/null +++ b/src/Components/Live/Peers.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PeerFeed from './PeerFeed'; + +export default class Peers extends React.Component { + + constructor (props) { + super(props); + this.state = { + selectedPeer: props.streams ? Object.keys(props.streams)[0] : null + }; + this.handlePeerClick = this.handlePeerClick.bind(this); + } + + componentWillReceiveProps (nextProps) { + const firstStream = Object.keys(nextProps.streams)[0]; + if ( this.state.selectedPeer !== firstStream ) { + this.setState({ + selectedPeer: firstStream + }) + } + } + + handlePeerClick (socketId) { + this.setState({ + selectedPeer: socketId + }) + } + + renderPeers (friends) { + const { streams } = this.props; + + const friendStreams = []; + + for (let key in streams) { + if (key !== this.state.selectedPeer) { + friendStreams.push( + + ) + } + } + + return friendStreams; + } + + render () { + const { streams } = this.props; + const { selectedPeer } = this.state; + return ( +
+
+ +
+
+ {this.renderPeers()} +
+
+ ) + } +} diff --git a/src/Components/Live/RecordingTime.js b/src/Components/Live/RecordingTime.js new file mode 100644 index 0000000..0fe428b --- /dev/null +++ b/src/Components/Live/RecordingTime.js @@ -0,0 +1,40 @@ +import React from 'react'; +import moment from 'moment'; + +export default class RecordingTime extends React.Component { + constructor (props) { + super(props); + this.state = { + startTime: moment(), + endTime: moment() + } + } + + componentDidMount () { + this.timer = setInterval( () => { + this.setState({ + endTime: moment() + }) + }, 1000); + } + + componentWillUnmount () { + clearInterval(this.timer); + } + + determineDisplayTime () { + const { startTime, endTime } = this.state; + let hours = endTime.diff(startTime, 'hours'); + hours = hours % 10 === hours ? `0${hours}` : hours; + let mins = endTime.diff(startTime, 'minutes') % 60; + mins = mins % 10 === mins ? `0${mins}` : mins; + let secs = endTime.diff(startTime, 'seconds') % 60; + secs = secs % 10 === secs ? `0${secs}` : secs; + return `${hours}:${mins}:${secs}`; + } + + render () { + return {this.determineDisplayTime().toString()} + } + +} diff --git a/src/Components/Live/index.js b/src/Components/Live/index.js new file mode 100644 index 0000000..1216985 --- /dev/null +++ b/src/Components/Live/index.js @@ -0,0 +1,221 @@ +import React from 'react'; +import classNames from 'classnames'; +import InputMask from 'react-input-mask'; +import Peers from './Peers'; +import RecordingTime from './RecordingTime'; +import Icon from '../Icon/Icon'; +import './Live.css'; + +export default class Live extends React.Component { + + constructor (props) { + super(props); + this.state = { + joinById: false, + fullscreen: false, + desiredRoomId: '', + viewedDisclaimer: false + } + this.onUnload = this.onUnload.bind(this); + this.toggleJoinById = this.toggleJoinById.bind(this); + this.handleRoomIdSubmit = this.handleRoomIdSubmit.bind(this); + this.updateDesiredRoomId = this.updateDesiredRoomId.bind(this); + this.markDisclaimerAsViewed = this.markDisclaimerAsViewed.bind(this); + this.attemptToggleLocalStream = this.attemptToggleLocalStream.bind(this); + } + + componentWillMount () { + this.props.checkForWebRTCSupport(); + } + + componentDidMount () { + window.addEventListener('beforeunload', this.onUnload); + } + + componentWillUnmount () { + this.onUnload(); + window.removeEventListener('beforeunload', this.onUnload); + } + + onUnload () { + if (this.props.webrtc.socketId) { + this.props.closeWebRTC(); + this.setState({ + joinById: false, + desiredRoomId: '', + fullscreen: false + }) + } + } + + componentWillReceiveProps (nextProps) { + if (this.selfFeed) { + if (this.props.webrtc.localStream !== nextProps.webrtc.localStream) { + this.selfFeed.srcObject = nextProps.webrtc.localStream; + } + } + } + + toggleJoinById () { + this.setState({ + joinById: ! this.state.joinById + }) + } + + updateDesiredRoomId (event) { + this.setState({ desiredRoomId: event.target.value }); + if (event.target.value.replace(/[^0-9]/g,"").length === 9) { + this.props.initWebRTC(event.target.value); + } + } + + handleRoomIdSubmit (event) { + event.preventDefault(); + if (this.state.desiredRoomId.replace(/[^0-9]/g,"").length === 9) { + setTimeout(() => { + this.props.initWebRTC(this.state.desiredRoomId); + }, 1000); + } else { + alert('Please enter a valid nine-digit room ID. Example: 123-456-789 '); + } + } + + attemptToggleLocalStream () { + // Make sure that we aren't currently in a chat + if ( ! this.props.webrtc.socketId ) { + // Closes socket, video, and audio streams + this.props.closeWebRTC(); + } + } + + markDisclaimerAsViewed () { + this.setState({ + viewedDisclaimer: true + }); + } + + renderSelfVideoFeed () { + if ( ! this.state.viewedDisclaimer ) return; + const numOfConnections = Object.keys(this.props.webrtc.connections).length; + return ( +
+ + Connections: {numOfConnections} +
+ { this.props.webrtc.socketId ? : null } +
+
+ ) + } + + renderRequestLocalStream () { + if ( ! this.state.viewedDisclaimer ) return; + const showPreview = ! this.selfFeed || (this.selfFeed && ! this.selfFeed.srcObject); + const previewScreen = showPreview ? ( +
+ Preview +
+ ) : null; + return ( +
+ {this.renderSelfVideoFeed()} + {previewScreen} +
+ ) + } + + renderRoomActionButton () { + if ( ! this.state.viewedDisclaimer ) return; + if (this.props.webrtc.socketId) { + return ( +
+ +

{this.props.webrtc.roomId}

+
+ ) + } else if ( ! this.state.joinById ) { + return ( +
+ +

join by id

+
+ ) + } else if (this.state.joinById) { + return ( +
+
+ + +

cancel

+
+ ) + } + } + + renderDisclaimer () { + const cta = this.props.webrtc.isSupported ? ( +
+

Note: Once you enter that chat room, you can share the room ID for others to join. Otherwise, everyone must join the chat within 2 minutes of the first person who joined the room.

+ +
+ ) : ( +
+
+
+ +
+
+

Download Chrome in order to test out this video chat client as it was designed to work. If you think your browser can handle WebRTC and Socket.IO anyway, click here to proceed.

+
+
+
+ ); + return ( +
+
+

Live: A Video Chat Client

+

I was interested in learning and applying my knowledge of WebRTC and Socket.IO to build a video chat client. This project is very experimental and will likely have bugs and other issues. If you come across something and want to report it, please do so here.

+ {cta} +
+
+ ); + } + + renderEnterFullScreenIcons () { + if ( ! this.state.viewedDisclaimer ) return; + const icon = this.state.fullscreen ? 'screenNormal' : 'screenFull'; + return ( +
this.setState({ fullscreen: ! this.state.fullscreen })}> + +
+ ) + } + + render () { + const { streams } = this.props.webrtc; + const liveClasses = classNames('live', { + 'is-fullscreen': this.state.fullscreen + }); + if (! this.state.viewedDisclaimer ) { + return this.renderDisclaimer(); + } else { + return ( +
+
+ {this.renderEnterFullScreenIcons()} + {this.renderRoomActionButton()} + {this.renderRequestLocalStream()} + +
+
+ ) + } + } +} diff --git a/src/Components/Pages/views/Personal.js b/src/Components/Pages/views/Personal.js index d715c38..91dcf04 100644 --- a/src/Components/Pages/views/Personal.js +++ b/src/Components/Pages/views/Personal.js @@ -10,6 +10,11 @@ export default class Personal extends React.Component {

Personal Projects

When I'm not hiking in the mountains, I'm probably behind a computer screen learning new technologies or practicing something I may need to learn for work. Here are a few of the projects that have actually seen the light of day.

+
May, 2017
+
March, 2017
Coming soon
- ipInfo = data.body ) + +/* INITIALIZE BROWSER SHIMS */ +for (var key in WebRTCAdapter.browserShim) { + if (WebRTCAdapter.browserShim.hasOwnProperty(key)) { + WebRTCAdapter.browserShim[key](); + } +} + +let socket; +let peers = new PeerConnections(); +var configuration = {"iceServers": [{"urls": "stun:stun.l.google.com:19302"}]}; +var localStream; + +export function checkForWebRTCSupport () { + return dispatch => { + const browser = WebRTCAdapter.browserDetails.browser + const isSupported = supportedBrowsers[browser]; + dispatch({ type: 'WEBRTC_SUPPORT', isSupported }); + }; +} + +export function initWebRTC (roomId) { + return dispatch => { + dispatch({ type: 'WEBRTC_SET_ROOM_ID', roomId }); + initSocketListeners({ + roomId, + ipInfo, + name: 'uuid_' + uuid.v4() + }, '/')(dispatch); + dispatch({ type: 'WEBRTC_INITIALED' }); + } +} + +export function closeWebRTC () { + return dispatch => { + if (socket) socket.close(); + if (localStream) { + localStream.getAudioTracks()[0].stop(); + localStream.getVideoTracks()[0].stop(); + localStream = null; + } + dispatch({ type: 'WEBRTC_CLOSED' }); + } +} + +function initSocketListeners (userData, server) { + return dispatch => { + socket = io(server); + socket.on('exchange', (data) => { + onExchange(data)(dispatch); + }); + socket.on('connect', () => { + onConnect(userData)(dispatch); + }); + socket.on('leave', socketId => { + onLeave(socketId)(dispatch); + }) + socket.on('join', peer => { + createPeerConnection(peer.socketId, true)(dispatch) + }) + }; +} + +function onLeave (socketId) { + return dispatch => { + if (peers.get(socketId)) { + peers.close(socketId); + dispatch({ type: 'WEBRTC_SOCKET_LEAVE', socketId }); + } + } +} + +export function loadLocalStream (success) { + return dispatch => { + getLocalStream({ + audio: true, + video: { + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }, stream => { + localStream = stream; + dispatch({ type: 'WEBRTC_LOCAL_MEDIA_STREAM', stream }); + if (typeof success === 'function') success(); + }); + } +} + +function onConnect (userData) { + return dispatch => { + if ( ! localStream ) { + loadLocalStream(() => { + joinRoom(userData)(dispatch); + })(dispatch); + } else { + joinRoom(userData)(dispatch); + } + } +} + +function joinRoom (userData) { + return dispatch => { + socket.emit('join', userData, friends => { + dispatch({ type: 'WEBRTC_JOIN_SUCCESS', socketId: socket.id }) + friends.forEach( friend => { + createPeerConnection(friend.socketId, false)(dispatch) + }); + }); + } +} + +function onExchange (data) { + return dispatch => { + + const fromId = data.from; + let pc = peers.get(fromId); + + if (pc === undefined) pc = createPeerConnection(fromId, false)(dispatch); + + if (data.sdp) { + // If the data exchange is an offer to connect (incoming call), answer it. + pc.setRemoteDescription(new RTCSessionDescription(data.sdp), () => { + // Auto answer calls that come in + if (pc.remoteDescription.type === "offer") { + pc.createAnswer( desc => { + pc.setLocalDescription(desc, () => { + socket.emit('exchange', {'to': fromId, 'sdp': pc.localDescription }); + }, logError); + }, logError); + } + }, logError); + } else { + // Else, the "exchange" event was just part of the negotiation process + const iceCandidate = new RTCIceCandidate(data.candidate); + pc.addIceCandidate(iceCandidate); + } + }; +} + +function createPeerConnection (socketId, isOffer) { + return dispatch => { + + var pc = new RTCPeerConnection(configuration); + function createOffer() { + pc.createOffer(desc => { + pc.setLocalDescription(desc, () => { + socket.emit('exchange', {'to': socketId, 'sdp': pc.localDescription }); + }, logError); + }, logError); + } + + pc.onicecandidate = event => { + if (event.candidate) socket.emit('exchange', {'to': socketId, 'candidate': event.candidate }); + }; + + pc.onnegotiationneeded = () => { + if (isOffer) createOffer(); + } + + pc.oniceconnectionstatechange = event => { + var iceConnectionState = event.target.iceConnectionState; + if (iceConnectionState === 'disconnected' || iceConnectionState === 'closed') { + onLeave(socketId)(dispatch); + } + }; + pc.onsignalingstatechange = event => { }; + pc.onaddstream = event => { + dispatch({ type: 'WEBRTC_ADD_PEER_STREAM', socketId, stream: event.stream }) + }; + + pc.addStream(localStream); + + peers.set(socketId, pc); + dispatch({ type: 'WEBRTC_NEW_PEER_CONNECTION', socketId, pc }) + return pc; + } +} + +function getLocalStream (config, success) { + navigator.mediaDevices + .getUserMedia( config ) + .then( success ) + .catch ( logError ) +} + +function logError (errorMsg) { + console.error(errorMsg); +} diff --git a/src/Styles/Button.css b/src/Styles/Button.css new file mode 100644 index 0000000..a0c5309 --- /dev/null +++ b/src/Styles/Button.css @@ -0,0 +1,23 @@ +button { + border: none; + color: #fafafa; + font-size: 1em; + padding: 1em 2em; + background: #47BA83; + text-transform: uppercase; + transition: background 250ms ease; + cursor: pointer; +} + +button:hover { + background: #79CCA4; +} + +button.is-danger { + background: #B02E0C; + color: #fafafa; +} + +button.is-danger:hover { + background: #B74122; +} diff --git a/src/Styles/Input.css b/src/Styles/Input.css new file mode 100644 index 0000000..12c98ae --- /dev/null +++ b/src/Styles/Input.css @@ -0,0 +1,3 @@ +input { + font-size: 1em; +} diff --git a/src/Styles/Variables.json b/src/Styles/Variables.json deleted file mode 100644 index 46910c1..0000000 --- a/src/Styles/Variables.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "red": "#f00" -} diff --git a/src/routes.js b/src/routes.js index dcd384a..b33c384 100644 --- a/src/routes.js +++ b/src/routes.js @@ -5,6 +5,7 @@ import App from './Containers/App'; import Landing from './Containers/Landing'; import Pages from './Containers/Pages'; import Typography from './Components/Typography'; +import Live from './Containers/Live'; import NotFound from './Components/NotFound'; export default ( @@ -26,6 +27,7 @@ export default ( {/* HIDDEN ROUTES */} + {/* EXTERNAL REDIRECTS */} window.location = 'https://www.flickr.com/photos/77226941@N04/' } /> diff --git a/static.json b/static.json new file mode 100644 index 0000000..1a17ce7 --- /dev/null +++ b/static.json @@ -0,0 +1,13 @@ +{ + "root": "build/", + "clean_urls": false, + "https_only": true, + "routes": { + "/**": "index.html" + }, + "headers": { + "/**": { + "Strict-Transport-Security": "max-age=7776000" + } + } +}