Add alerts and notifications about connection status

This commit is contained in:
Jerko Steiner 2016-04-04 08:46:54 -04:00 committed by Jerko Steiner
parent be14b016de
commit b8108226cd
18 changed files with 700 additions and 9 deletions

View File

@ -22,6 +22,7 @@
"flux": "^2.1.1", "flux": "^2.1.1",
"jade": "^1.11.0", "jade": "^1.11.0",
"react": "^0.14.8", "react": "^0.14.8",
"react-addons-css-transition-group": "^0.14.8",
"react-dom": "^0.14.8", "react-dom": "^0.14.8",
"simple-peer": "^6.0.3", "simple-peer": "^6.0.3",
"socket.io": "^1.3.7", "socket.io": "^1.3.7",

View File

@ -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'
}
}]]);
});
});
});

42
src/js/action/notify.js Normal file
View File

@ -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 };

View File

@ -9,6 +9,7 @@ function getUserMedia(constraints) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia; const getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia;
if (!getMedia) reject(new Error('Browser unsupported'));
getMedia.call(navigator, constraints, resolve, reject); getMedia.call(navigator, constraints, resolve, reject);
}); });
} }

View File

@ -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(<div><Alert /></div>);
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
}]]);
});
});
});

View File

@ -5,6 +5,8 @@ const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const TestUtils = require('react-addons-test-utils'); const TestUtils = require('react-addons-test-utils');
require('../alert.js').mockImplementation(() => <div />);
require('../notifications.js').mockImplementation(() => <div />);
const App = require('../app.js'); const App = require('../app.js');
const activeStore = require('../../store/activeStore.js'); const activeStore = require('../../store/activeStore.js');
const dispatcher = require('../../dispatcher/dispatcher.js'); const dispatcher = require('../../dispatcher/dispatcher.js');

View File

@ -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(<div>{component}</div>);
return ReactDOM.findDOMNode(rendered);
}
describe('render', () => {
it('should render notifications placeholder', () => {
let node = render(<Notifications />);
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(<Notifications />);
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(<Notifications max={1} />);
expect(notificationsStore.getNotifications.mock.calls).toEqual([[ 1 ]]);
});
});
});

View File

@ -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 <div className="alert hidden"><span>&nbsp;</span></div>;
let button;
function dismiss() {
dispatcher.dispatch({
type: 'alert-dismiss',
alert
});
}
if (alert.dismissable) {
button = <button onClick={dismiss}>{alert.action}</button>;
}
return (
<div className={alert.type + ' alert'}>
<span>{alert.message}</span>
{button}
</div>
);
}
module.exports = alert;

View File

@ -1,3 +1,5 @@
const Alert = require('./alert.js');
const Notifications = require('./notifications.js');
const React = require('react'); const React = require('react');
const _ = require('underscore'); const _ = require('underscore');
const activeStore = require('../store/activeStore.js'); const activeStore = require('../store/activeStore.js');
@ -38,6 +40,8 @@ function app() {
}); });
return (<div className="app"> return (<div className="app">
<Alert />
<Notifications />
<div className="videos"> <div className="videos">
{videos} {videos}
</div> </div>

View File

@ -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 (
<div className={notif.type + ' notification'} key={notif._id}>
{notif.message}
</div>
);
});
return (
<div className="notifications">
<Transition
transitionEnterTimeout={200}
transitionLeaveTimeout={100}
transitionName="fade"
>
{notificationElements}
</Transition>
</div>
);
}
module.exports = notifications;

View File

@ -8,10 +8,13 @@ const App = require('./components/app.js');
const React = require('react'); const React = require('react');
const ReactDom = require('react-dom'); const ReactDom = require('react-dom');
const activeStore = require('./store/activeStore.js'); const activeStore = require('./store/activeStore.js');
const alertStore = require('./store/alertStore.js');
const debug = require('debug')('peer-calls:index'); const debug = require('debug')('peer-calls:index');
const dispatcher = require('./dispatcher/dispatcher.js'); const dispatcher = require('./dispatcher/dispatcher.js');
const getUserMedia = require('./browser/getUserMedia.js'); const getUserMedia = require('./browser/getUserMedia.js');
const handshake = require('./peer/handshake.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 socket = require('./socket.js');
const streamStore = require('./store/streamStore.js'); const streamStore = require('./store/streamStore.js');
@ -36,12 +39,10 @@ dispatcher.register(action => {
if (action.type === 'play') play(); if (action.type === 'play') play();
}); });
streamStore.addListener(() => () => {
debug('streamStore - change');
debug(streamStore.getStreams());
});
streamStore.addListener(render);
activeStore.addListener(render); activeStore.addListener(render);
alertStore.addListener(render);
notificationsStore.addListener(render);
streamStore.addListener(render);
render(); render();
@ -54,9 +55,13 @@ getUserMedia({ video: true, audio: false })
userId: '_me_', userId: '_me_',
stream stream
}); });
})
.catch(() => {
notify.alert('Could not get access to microphone & camera');
}); });
socket.once('connect', () => { socket.once('connect', () => {
notify.warn('Connected to server socket');
debug('socket connected'); debug('socket connected');
getUserMedia({ video: true, audio: true }) getUserMedia({ video: true, audio: true })
.then(stream => { .then(stream => {
@ -64,6 +69,11 @@ socket.once('connect', () => {
handshake.init(socket, callId, stream); handshake.init(socket, callId, stream);
}) })
.catch(err => { .catch(err => {
notify.alert('Could not get access to camera!', true);
debug('error getting media: %s %s', err.name, err.message); debug('error getting media: %s %s', err.name, err.message);
}); });
}); });
socket.on('disconnect', () => {
notify.error('Server socket disconnected');
});

View File

@ -2,6 +2,7 @@
const Peer = require('./Peer.js'); const Peer = require('./Peer.js');
const debug = require('debug')('peer-calls:peer'); const debug = require('debug')('peer-calls:peer');
const dispatcher = require('../dispatcher/dispatcher.js'); const dispatcher = require('../dispatcher/dispatcher.js');
const notify = require('../action/notify.js');
const _ = require('underscore'); const _ = require('underscore');
function init(socket, roomName, stream) { function init(socket, roomName, stream) {
@ -9,6 +10,7 @@ function init(socket, roomName, stream) {
function createPeer(user, initiator) { function createPeer(user, initiator) {
debug('create peer: %s', user.id); debug('create peer: %s', user.id);
notify.warn('Initializing new peer connection');
let peer = peers[user.id] = Peer.init({ let peer = peers[user.id] = Peer.init({
initiator: '/#' + socket.id === initiator, initiator: '/#' + socket.id === initiator,
@ -27,6 +29,7 @@ function init(socket, roomName, stream) {
peer.once('error', err => { peer.once('error', err => {
debug('peer: %s, error %s', user.id, err.stack); debug('peer: %s, error %s', user.id, err.stack);
notify.error('A peer connection error occurred');
destroyPeer(user.id); destroyPeer(user.id);
}); });
@ -39,6 +42,7 @@ function init(socket, roomName, stream) {
peer.once('connect', () => { peer.once('connect', () => {
debug('peer: %s, connect', user.id); debug('peer: %s, connect', user.id);
notify.warn('Peer connection established');
dispatcher.dispatch({ type: 'play' }); dispatcher.dispatch({ type: 'play' });
}); });
@ -53,6 +57,7 @@ function init(socket, roomName, stream) {
peer.once('close', () => { peer.once('close', () => {
debug('peer: %s, close', user.id); debug('peer: %s, close', user.id);
notify.error('Peer connection closed');
dispatcher.dispatch({ dispatcher.dispatch({
type: 'remove-stream', type: 'remove-stream',
userId: user.id userId: user.id
@ -82,6 +87,7 @@ function init(socket, roomName, stream) {
socket.on('users', payload => { socket.on('users', payload => {
let { initiator, users } = payload; let { initiator, users } = payload;
debug('socket users: %o', users); debug('socket users: %o', users);
notify.info('Connected users: {0}', users.length);
users users
.filter(user => !peers[user.id] && user.id !== '/#' + socket.id) .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('socket.id: %s', socket.id);
debug('emit ready for room: %s', roomName); debug('emit ready for room: %s', roomName);
notify.info('Ready for connections');
socket.emit('ready', roomName); socket.emit('ready', roomName);
} }

View File

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

View File

@ -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 ]);
});
});
});

View File

@ -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,
};

View File

@ -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
};

View File

@ -1,5 +1,8 @@
@import "fonts.less"; @import "fonts.less";
@font-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif;
@font-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
@color-bg: #086788; @color-bg: #086788;
@color-fg: #07A0C3; @color-fg: #07A0C3;
// @color-btn: #F0C808; // @color-btn: #F0C808;
@ -7,6 +10,10 @@
@color-active: #F0C808; @color-active: #F0C808;
@icon-size: 48px; @icon-size: 48px;
@color-info: white;
@color-warning: @color-active;
@color-error: #FF4400;
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -19,13 +26,16 @@ html, body {
body { body {
background-color: @color-bg; 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-image: url('/res/peer-calls.svg');
background-size: 200px; background-size: 200px;
background-position: 50% 50%; background-position: 50% 50%;
background-repeat: no-repeat; background-repeat: no-repeat;
color: @color-fg;
margin: 0 0;
font-family: sans-serif;
} }
#form { #form {
@ -87,8 +97,80 @@ body {
} }
} }
.warning {
color: @color-warning;
}
.error {
color: @color-error;
}
.info {
color: @color-info;
}
.app { .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 { .videos {
position: fixed; position: fixed;
height: 100px; 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;
}

View File

@ -10,7 +10,7 @@ html
link(rel="icon" sizes="256x256" href="../res/icon.png") link(rel="icon" sizes="256x256" href="../res/icon.png")
link(rel="stylesheet" type="text/css" href="../less/main.css") link(rel="stylesheet" type="text/css" href="../less/main.css")
body body.call
input#callId(type="hidden" value="#{callId}") input#callId(type="hidden" value="#{callId}")
div#container div#container