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