diff --git a/package.json b/package.json index ccb0ffa..f6d19a3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "flux": "^2.1.1", "jade": "^1.11.0", "react": "^0.14.8", + "react-addons-css-transition-group": "^0.14.8", "react-dom": "^0.14.8", "simple-peer": "^6.0.3", "socket.io": "^1.3.7", diff --git a/src/js/action/__tests__/notify-test.js b/src/js/action/__tests__/notify-test.js new file mode 100644 index 0000000..8e34624 --- /dev/null +++ b/src/js/action/__tests__/notify-test.js @@ -0,0 +1,78 @@ +jest.unmock('../notify.js'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const notify = require('../notify.js'); + +describe('notify', () => { + + beforeEach(() => dispatcher.dispatch.mockClear()); + + describe('info', () => { + + it('should dispatch info notification', () => { + notify.info('test: {0} {1}', 'arg1', 'arg2'); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'notify', + notification: { + message: 'test: arg1 arg2', + type: 'info' + } + }]]); + }); + + }); + + describe('warn', () => { + + it('should dispatch warning notification', () => { + notify.warn('test: {0} {1}', 'arg1', 'arg2'); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'notify', + notification: { + message: 'test: arg1 arg2', + type: 'warning' + } + }]]); + }); + + }); + + describe('error', () => { + + it('should dispatch error notification', () => { + notify.error('test: {0} {1}', 'arg1', 'arg2'); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'notify', + notification: { + message: 'test: arg1 arg2', + type: 'error' + } + }]]); + }); + + }); + + describe('alert', () => { + + it('should dispatch an alert', () => { + + notify.alert('alert!', true); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'alert', + alert: { + action: 'Dismiss', + dismissable: true, + message: 'alert!', + type: 'warning' + } + }]]); + + }); + + }); + +}); diff --git a/src/js/action/notify.js b/src/js/action/notify.js new file mode 100644 index 0000000..fccba01 --- /dev/null +++ b/src/js/action/notify.js @@ -0,0 +1,42 @@ +const dispatcher = require('../dispatcher/dispatcher.js'); + +function format(string, args) { + string = args + .reduce((string, arg, i) => string.replace('{' + i + '}', arg), string); + return string; +} + +function _notify(type, args) { + let string = args[0] || ''; + let message = format(string, Array.prototype.slice.call(args, 1)); + dispatcher.dispatch({ + type: 'notify', + notification: { type, message } + }); +} + +function info() { + _notify('info', arguments); +} + +function warn() { + _notify('warning', arguments); +} + +function error() { + _notify('error', arguments); +} + +function alert(message, dismissable) { + dispatcher.dispatch({ + type: 'alert', + alert: { + action: dismissable ? 'Dismiss' : '', + dismissable: !!dismissable, + message, + type: 'warning' + } + }); +} + +module.exports = { alert, info, warn, error }; diff --git a/src/js/browser/getUserMedia.js b/src/js/browser/getUserMedia.js index abbe853..a5229b0 100644 --- a/src/js/browser/getUserMedia.js +++ b/src/js/browser/getUserMedia.js @@ -9,6 +9,7 @@ function getUserMedia(constraints) { return new Promise((resolve, reject) => { const getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia; + if (!getMedia) reject(new Error('Browser unsupported')); getMedia.call(navigator, constraints, resolve, reject); }); } diff --git a/src/js/components/__tests__/alert-test.js b/src/js/components/__tests__/alert-test.js new file mode 100644 index 0000000..9908ff7 --- /dev/null +++ b/src/js/components/__tests__/alert-test.js @@ -0,0 +1,74 @@ +jest.unmock('../alert.js'); + +const React = require('react'); +const ReactDOM = require('react-dom'); +const TestUtils = require('react-addons-test-utils'); + +const Alert = require('../alert.js'); +const dispatcher = require('../../dispatcher/dispatcher.js'); +const alertStore = require('../../store/alertStore.js'); + +describe('alert', () => { + + beforeEach(() => { + alertStore.getAlert.mockClear(); + }); + + function render() { + let component = TestUtils.renderIntoDocument(
); + return ReactDOM.findDOMNode(component); + } + + describe('render', () => { + + it('should do nothing when no alert', () => { + let node = render(); + expect(node.querySelector('.alert.hidden')).toBeTruthy(); + }); + + it('should render alert', () => { + alertStore.getAlert.mockReturnValue({ + message: 'example', + type: 'warning' + }); + + let node = render(); + + expect(node.querySelector('.alert.warning')).toBeTruthy(); + expect(node.querySelector('.alert span').textContent).toMatch(/example/); + expect(node.querySelector('.alert button')).toBeNull(); + }); + + it('should render dismissable alert', () => { + alertStore.getAlert.mockReturnValue({ + message: 'example', + type: 'warning', + dismissable: true + }); + + let node = render(); + + expect(node.querySelector('.alert.warning')).toBeTruthy(); + expect(node.querySelector('.alert span').textContent).toMatch(/example/); + expect(node.querySelector('.alert button')).toBeTruthy(); + }); + + it('should dispatch dismiss alert on dismiss clicked', () => { + let alert = { + message: 'example', + type: 'warning', + dismissable: true + }; + alertStore.getAlert.mockReturnValue(alert); + + let node = render(); + TestUtils.Simulate.click(node.querySelector('.alert button')); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'alert-dismiss', + alert + }]]); + }); + }); + +}); diff --git a/src/js/components/__tests__/app-test.js b/src/js/components/__tests__/app-test.js index 1bf796b..b19d099 100644 --- a/src/js/components/__tests__/app-test.js +++ b/src/js/components/__tests__/app-test.js @@ -5,6 +5,8 @@ const React = require('react'); const ReactDOM = require('react-dom'); const TestUtils = require('react-addons-test-utils'); +require('../alert.js').mockImplementation(() =>
); +require('../notifications.js').mockImplementation(() =>
); const App = require('../app.js'); const activeStore = require('../../store/activeStore.js'); const dispatcher = require('../../dispatcher/dispatcher.js'); diff --git a/src/js/components/__tests__/notifications-test.js b/src/js/components/__tests__/notifications-test.js new file mode 100644 index 0000000..076fc86 --- /dev/null +++ b/src/js/components/__tests__/notifications-test.js @@ -0,0 +1,59 @@ +jest.unmock('../notifications.js'); + +const React = require('react'); +const ReactDOM = require('react-dom'); +const TestUtils = require('react-addons-test-utils'); + +const Notifications = require('../notifications.js'); +const notificationsStore = require('../../store/notificationsStore.js'); + +describe('alert', () => { + + beforeEach(() => { + notificationsStore.getNotifications.mockClear(); + notificationsStore.getNotifications.mockReturnValue([]); + }); + + function render(component) { + let rendered = TestUtils.renderIntoDocument(
{component}
); + return ReactDOM.findDOMNode(rendered); + } + + describe('render', () => { + + it('should render notifications placeholder', () => { + let node = render(); + expect(node.querySelector('.notifications')).toBeTruthy(); + expect(node.querySelector('.notifications .notification')).toBeFalsy(); + }); + + it('should render notifications', () => { + notificationsStore.getNotifications.mockReturnValue([{ + _id: 1, + message: 'message 1', + type: 'warning' + }, { + _id: 2, + message: 'message 2', + type: 'error' + }]); + + let node = render(); + expect(notificationsStore.getNotifications.mock.calls).toEqual([[ 10 ]]); + + let c = node.querySelector('.notifications'); + expect(c).toBeTruthy(); + expect(c.querySelectorAll('.notification').length).toBe(2); + expect(c.querySelector('.notification.warning').textContent) + .toEqual('message 1'); + expect(c.querySelector('.notification.error').textContent) + .toEqual('message 2'); + }); + + it('should render max X notifications', () => { + render(); + expect(notificationsStore.getNotifications.mock.calls).toEqual([[ 1 ]]); + }); + }); + +}); diff --git a/src/js/components/alert.js b/src/js/components/alert.js new file mode 100644 index 0000000..bd6bf1f --- /dev/null +++ b/src/js/components/alert.js @@ -0,0 +1,30 @@ +const React = require('react'); +const alertStore = require('../store/alertStore.js'); +const dispatcher = require('../dispatcher/dispatcher.js'); + +function alert() { + let alert = alertStore.getAlert(); + if (!alert) return
 
; + let button; + + function dismiss() { + dispatcher.dispatch({ + type: 'alert-dismiss', + alert + }); + } + + if (alert.dismissable) { + button = ; + } + + return ( +
+ {alert.message} + {button} +
+ ); + +} + +module.exports = alert; diff --git a/src/js/components/app.js b/src/js/components/app.js index 866852c..080acbd 100644 --- a/src/js/components/app.js +++ b/src/js/components/app.js @@ -1,3 +1,5 @@ +const Alert = require('./alert.js'); +const Notifications = require('./notifications.js'); const React = require('react'); const _ = require('underscore'); const activeStore = require('../store/activeStore.js'); @@ -38,6 +40,8 @@ function app() { }); return (
+ +
{videos}
diff --git a/src/js/components/notifications.js b/src/js/components/notifications.js new file mode 100644 index 0000000..e23df3f --- /dev/null +++ b/src/js/components/notifications.js @@ -0,0 +1,30 @@ +const React = require('react'); +const Transition = require('react-addons-css-transition-group'); +const notificationsStore = require('../store/notificationsStore.js'); + +function notifications(props) { + let notifs = notificationsStore.getNotifications(props.max || 10); + + let notificationElements = notifs.map(notif => { + return ( +
+ {notif.message} +
+ ); + }); + + return ( +
+ + {notificationElements} + +
+ ); + +} + +module.exports = notifications; diff --git a/src/js/index.js b/src/js/index.js index ad81065..49fc397 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -8,10 +8,13 @@ const App = require('./components/app.js'); const React = require('react'); const ReactDom = require('react-dom'); const activeStore = require('./store/activeStore.js'); +const alertStore = require('./store/alertStore.js'); const debug = require('debug')('peer-calls:index'); const dispatcher = require('./dispatcher/dispatcher.js'); const getUserMedia = require('./browser/getUserMedia.js'); const handshake = require('./peer/handshake.js'); +const notificationsStore = require('./store/notificationsStore.js'); +const notify = require('./action/notify.js'); const socket = require('./socket.js'); const streamStore = require('./store/streamStore.js'); @@ -36,12 +39,10 @@ dispatcher.register(action => { if (action.type === 'play') play(); }); -streamStore.addListener(() => () => { - debug('streamStore - change'); - debug(streamStore.getStreams()); -}); -streamStore.addListener(render); activeStore.addListener(render); +alertStore.addListener(render); +notificationsStore.addListener(render); +streamStore.addListener(render); render(); @@ -54,9 +55,13 @@ getUserMedia({ video: true, audio: false }) userId: '_me_', stream }); +}) +.catch(() => { + notify.alert('Could not get access to microphone & camera'); }); socket.once('connect', () => { + notify.warn('Connected to server socket'); debug('socket connected'); getUserMedia({ video: true, audio: true }) .then(stream => { @@ -64,6 +69,11 @@ socket.once('connect', () => { handshake.init(socket, callId, stream); }) .catch(err => { + notify.alert('Could not get access to camera!', true); debug('error getting media: %s %s', err.name, err.message); }); }); + +socket.on('disconnect', () => { + notify.error('Server socket disconnected'); +}); diff --git a/src/js/peer/handshake.js b/src/js/peer/handshake.js index 269a15d..714431d 100644 --- a/src/js/peer/handshake.js +++ b/src/js/peer/handshake.js @@ -2,6 +2,7 @@ const Peer = require('./Peer.js'); const debug = require('debug')('peer-calls:peer'); const dispatcher = require('../dispatcher/dispatcher.js'); +const notify = require('../action/notify.js'); const _ = require('underscore'); function init(socket, roomName, stream) { @@ -9,6 +10,7 @@ function init(socket, roomName, stream) { function createPeer(user, initiator) { debug('create peer: %s', user.id); + notify.warn('Initializing new peer connection'); let peer = peers[user.id] = Peer.init({ initiator: '/#' + socket.id === initiator, @@ -27,6 +29,7 @@ function init(socket, roomName, stream) { peer.once('error', err => { debug('peer: %s, error %s', user.id, err.stack); + notify.error('A peer connection error occurred'); destroyPeer(user.id); }); @@ -39,6 +42,7 @@ function init(socket, roomName, stream) { peer.once('connect', () => { debug('peer: %s, connect', user.id); + notify.warn('Peer connection established'); dispatcher.dispatch({ type: 'play' }); }); @@ -53,6 +57,7 @@ function init(socket, roomName, stream) { peer.once('close', () => { debug('peer: %s, close', user.id); + notify.error('Peer connection closed'); dispatcher.dispatch({ type: 'remove-stream', userId: user.id @@ -82,6 +87,7 @@ function init(socket, roomName, stream) { socket.on('users', payload => { let { initiator, users } = payload; debug('socket users: %o', users); + notify.info('Connected users: {0}', users.length); users .filter(user => !peers[user.id] && user.id !== '/#' + socket.id) @@ -96,6 +102,7 @@ function init(socket, roomName, stream) { debug('socket.id: %s', socket.id); debug('emit ready for room: %s', roomName); + notify.info('Ready for connections'); socket.emit('ready', roomName); } diff --git a/src/js/store/__tests__/alertStore-test.js b/src/js/store/__tests__/alertStore-test.js new file mode 100644 index 0000000..fa11f6f --- /dev/null +++ b/src/js/store/__tests__/alertStore-test.js @@ -0,0 +1,70 @@ +jest.unmock('../alertStore.js'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const alertStore = require('../alertStore.js'); + +describe('alertStore', () => { + + let handleAction, onChange; + beforeEach(() => { + handleAction = dispatcher.register.mock.calls[0][0]; + handleAction({ type: 'alert-clear' }); + + onChange = jest.genMockFunction(); + alertStore.addListener(onChange); + }); + afterEach(() => { + alertStore.removeListener(onChange); + }); + + describe('alert', () => { + + it('should add alerts to end of queue and dispatch change', () => { + let alert1 = { message: 'example alert 1' }; + let alert2 = { message: 'example alert 2' }; + + handleAction({ type: 'alert', alert: alert1 }); + handleAction({ type: 'alert', alert: alert2 }); + + expect(onChange.mock.calls.length).toBe(2); + expect(alertStore.getAlerts()).toEqual([ alert1, alert2 ]); + expect(alertStore.getAlert()).toBe(alert1); + }); + + }); + + describe('alert-dismiss', () => { + + it('should remove alert and dispatch change', () => { + let alert1 = { message: 'example alert 1' }; + let alert2 = { message: 'example alert 2' }; + + handleAction({ type: 'alert', alert: alert1 }); + handleAction({ type: 'alert', alert: alert2 }); + handleAction({ type: 'alert-dismiss', alert: alert1 }); + + expect(onChange.mock.calls.length).toBe(3); + expect(alertStore.getAlerts()).toEqual([ alert2 ]); + expect(alertStore.getAlert()).toBe(alert2); + }); + + }); + + describe('alert-clear', () => { + + it('should remove all alerts', () => { + let alert1 = { message: 'example alert 1' }; + let alert2 = { message: 'example alert 2' }; + + handleAction({ type: 'alert', alert: alert1 }); + handleAction({ type: 'alert', alert: alert2 }); + handleAction({ type: 'alert-clear' }); + + expect(onChange.mock.calls.length).toBe(3); + expect(alertStore.getAlerts()).toEqual([]); + expect(alertStore.getAlert()).not.toBeDefined(); + }); + + }); + +}); diff --git a/src/js/store/__tests__/notificationsStore-test.js b/src/js/store/__tests__/notificationsStore-test.js new file mode 100644 index 0000000..fdf1408 --- /dev/null +++ b/src/js/store/__tests__/notificationsStore-test.js @@ -0,0 +1,67 @@ +jest.unmock('../notificationsStore.js'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const store = require('../notificationsStore.js'); + +describe('store', () => { + + let handleAction, onChange; + beforeEach(() => { + dispatcher.dispatch.mockClear(); + handleAction = dispatcher.register.mock.calls[0][0]; + + handleAction({ type: 'notify-clear' }); + + onChange = jest.genMockFunction(); + store.addListener(onChange); + }); + + describe('notify', () => { + + it('should add notification and dispatch change', () => { + let notif1 = { message: 'example notif 1' }; + let notif2 = { message: 'example notif 2' }; + + handleAction({ type: 'notify', notification: notif1 }); + handleAction({ type: 'notify', notification: notif2 }); + + expect(onChange.mock.calls.length).toBe(2); + expect(store.getNotifications()).toEqual([ notif1, notif2 ]); + expect(store.getNotifications(1)).toEqual([ notif2 ]); + expect(store.getNotifications(3)).toEqual([ notif1, notif2 ]); + }); + + it('should add timeout for autoremoval', () => { + let notif1 = { message: 'example notif 1' }; + + handleAction({ type: 'notify', notification: notif1 }); + + expect(onChange.mock.calls.length).toBe(1); + expect(store.getNotifications()).toEqual([ notif1 ]); + + jest.runAllTimers(); + + expect(onChange.mock.calls.length).toBe(2); + expect(store.getNotifications()).toEqual([]); + }); + + }); + + describe('notify-dismiss', () => { + + it('should remove notif and dispatch change', () => { + let notif1 = { message: 'example notif 1' }; + let notif2 = { message: 'example notif 2' }; + + handleAction({ type: 'notify', notification: notif1 }); + handleAction({ type: 'notify', notification: notif2 }); + handleAction({ type: 'notify-dismiss', notification: notif1 }); + + expect(onChange.mock.calls.length).toBe(3); + expect(store.getNotifications()).toEqual([ notif2 ]); + expect(store.getNotifications(2)).toEqual([ notif2 ]); + }); + + }); + +}); diff --git a/src/js/store/alertStore.js b/src/js/store/alertStore.js new file mode 100644 index 0000000..bed9a91 --- /dev/null +++ b/src/js/store/alertStore.js @@ -0,0 +1,50 @@ +const EventEmitter = require('events'); +const debug = require('debug')('peer-calls:alertStore'); +const dispatcher = require('../dispatcher/dispatcher.js'); + +const emitter = new EventEmitter(); +const addListener = cb => emitter.on('change', cb); +const removeListener = cb => emitter.removeListener('change', cb); + +let alerts = []; + +const handlers = { + alert: ({ alert }) => { + debug('alert: %s', alert.message); + alerts.push(alert); + }, + 'alert-dismiss': ({ alert }) => { + debug('alert-dismiss: %s', alert.message); + let index = alerts.indexOf(alert); + debug('index: %s', index); + if (index < 0) return; + alerts.splice(index, 1); + }, + 'alert-clear': () => { + debug('alert-clear'); + alerts = []; + } +}; + +const dispatcherIndex = dispatcher.register(action => { + let handle = handlers[action.type]; + if (!handle) return; + handle(action); + emitter.emit('change'); +}); + +function getAlert() { + return alerts[0]; +} + +function getAlerts() { + return alerts; +} + +module.exports = { + dispatcherIndex, + addListener, + removeListener, + getAlert, + getAlerts, +}; diff --git a/src/js/store/notificationsStore.js b/src/js/store/notificationsStore.js new file mode 100644 index 0000000..2fdb313 --- /dev/null +++ b/src/js/store/notificationsStore.js @@ -0,0 +1,65 @@ +const EventEmitter = require('events'); +const debug = require('debug')('peer-calls:alertStore'); +const dispatcher = require('../dispatcher/dispatcher.js'); + +const emitter = new EventEmitter(); +const addListener = cb => emitter.on('change', cb); +const removeListener = cb => emitter.removeListener('change', cb); + +let index = 0; +let notifications = []; + +function dismiss(notification) { + let index = notifications.indexOf(notification); + if (index < 0) return; + notifications.splice(index, 1); + clearTimeout(notification._timeout); + delete notification._timeout; +} + +function emitChange() { + emitter.emit('change'); +} + +const handlers = { + notify: ({ notification }) => { + index++; + debug('notify', notification.message); + notification._id = index; + notifications.push(notification); + notification._timeout = setTimeout(() => { + debug('notify-dismiss timeout: %s', notification.message); + dismiss(notification); + emitChange(); + }, 10000); + }, + 'notify-dismiss': ({ notification }) => { + debug('notify-dismiss: %s', notification.message); + dismiss(notification); + }, + 'notify-clear': () => { + debug('notify-clear'); + notifications = []; + } +}; + +const dispatcherIndex = dispatcher.register(action => { + let handle = handlers[action.type]; + if (!handle) return; + handle(action); + emitChange(); +}); + +function getNotifications(max) { + if (!max) max = notifications.length; + let start = notifications.length - max; + if (start < 0) start = 0; + return notifications.slice(start, notifications.length); +} + +module.exports = { + dispatcherIndex, + addListener, + removeListener, + getNotifications +}; diff --git a/src/less/main.less b/src/less/main.less index 390a4e8..abfb894 100644 --- a/src/less/main.less +++ b/src/less/main.less @@ -1,5 +1,8 @@ @import "fonts.less"; +@font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; +@font-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; + @color-bg: #086788; @color-fg: #07A0C3; // @color-btn: #F0C808; @@ -7,6 +10,10 @@ @color-active: #F0C808; @icon-size: 48px; +@color-info: white; +@color-warning: @color-active; +@color-error: #FF4400; + * { box-sizing: border-box; } @@ -19,13 +26,16 @@ html, body { body { background-color: @color-bg; + color: @color-fg; + margin: 0 0; + font-family: @font-sans-serif; +} + +body.call { background-image: url('/res/peer-calls.svg'); background-size: 200px; background-position: 50% 50%; background-repeat: no-repeat; - color: @color-fg; - margin: 0 0; - font-family: sans-serif; } #form { @@ -87,8 +97,80 @@ body { } } +.warning { + color: @color-warning; +} + +.error { + color: @color-error; +} + +.info { + color: @color-info; +} + .app { + .alert { + background-color: #000; + background-color: rgba(0, 0, 0, 0.3); + left: 0; + opacity: 1; + position: fixed; + right: 0; + text-align: center; + top: 0; + transition: visibility 100ms ease-in, opacity 100ms ease-in; + z-index: 4; + + span { + display: inline-block; + margin: 1rem 0; + padding: 0 1rem; + } + + button { + line-height: 1.4rem; + border: none; + border-radius: 0.3rem; + color: @color-info; + background-color: @color-fg; + vertical-align: middle; + } + } + + .alert.hidden { + opacity: 0; + visibility: hidden; + } + + .notifications { + font-family: @font-monospace; + font-size: 10px; + left: 1rem; + position: fixed; + right: 1rem; + text-align: right; + top: 1rem; + z-index: 3; + + .notification { + color: @color-info; + text-shadow: 0 0 0.1rem @color-info; + } + + .notification.error { + color: @color-error; + text-shadow: 0 0 0.1rem @color-error; + } + + .notification.warning { + color: @color-warning; + text-shadow: 0 0 0.1rem @color-warning; + } + + } + .videos { position: fixed; height: 100px; @@ -134,3 +216,22 @@ body { } } } + +.fade-enter { + opacity: 0.01; +} + +.fade-enter.fade-enter-active { + opacity: 1; + transition: opacity 200ms ease-in; +} + +.fade-leave { + opacity: 1; +} + +.fade-leave.fade-leave-active { + opacity: 0.01; + transition: opacity 100ms ease-in; +} + diff --git a/src/views/call.jade b/src/views/call.jade index 8b486af..8dca89d 100644 --- a/src/views/call.jade +++ b/src/views/call.jade @@ -10,7 +10,7 @@ html link(rel="icon" sizes="256x256" href="../res/icon.png") link(rel="stylesheet" type="text/css" href="../less/main.css") - body + body.call input#callId(type="hidden" value="#{callId}") div#container