diff --git a/package-lock.json b/package-lock.json index b88cc50..fb0fe95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1933,6 +1933,12 @@ "@types/babel-types": "*" } }, + "@types/bluebird": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.29.tgz", + "integrity": "sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==", + "dev": true + }, "@types/body-parser": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.1.tgz", @@ -1997,6 +2003,33 @@ "@types/range-parser": "*" } }, + "@types/hoist-non-react-statics": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", + "integrity": "sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==", + "dev": true, + "requires": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", + "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "dev": true, + "requires": { + "react-is": "^16.7.0" + } + }, + "react-is": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", + "integrity": "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw==", + "dev": true + } + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -2049,12 +2082,107 @@ "integrity": "sha512-E6Zn0rffhgd130zbCbAr/JdXfXkoOUFAKNs/rF8qnafSJ8KYaA/j3oz7dcwal+lYjLA7xvdd5J4wdYpCTlP8+w==", "dev": true }, + "@types/prop-types": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.3.tgz", + "integrity": "sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==", + "dev": true + }, "@types/range-parser": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz", "integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==", "dev": true }, + "@types/react": { + "version": "16.9.11", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.9.11.tgz", + "integrity": "sha512-UBT4GZ3PokTXSWmdgC/GeCGEJXE5ofWyibCcecRLUVN2ZBpXQGVgQGtG2foS7CrTKFKlQVVswLvf7Js6XA/CVQ==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, + "@types/react-dom": { + "version": "16.9.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.4.tgz", + "integrity": "sha512-fya9xteU/n90tda0s+FtN5Ym4tbgxpq/hb/Af24dvs6uYnYn+fspaxw5USlw0R8apDNwxsqumdRoCoKitckQqw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, + "@types/react-redux": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.5.tgz", + "integrity": "sha512-ZoNGQMDxh5ENY7PzU7MVonxDzS1l/EWiy8nUhDqxFqUZn4ovboCyvk4Djf68x6COb7vhGTKjyjxHxtFdAA5sUA==", + "dev": true, + "requires": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + }, + "dependencies": { + "hoist-non-react-statics": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.0.tgz", + "integrity": "sha512-0XsbTXxgiaCDYDIWFcwkmerZPSwywfUqYmwT4jzewKTQSWoE6FCMoUVOeBJWK3E/CrWbxRG3m5GzY4lnIwGRBA==", + "dev": true, + "requires": { + "react-is": "^16.7.0" + } + }, + "react-is": { + "version": "16.11.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.11.0.tgz", + "integrity": "sha512-gbBVYR2p8mnriqAwWx9LbuUrShnAuSCNnuPGyc7GJrMVQtPDAh8iLpv7FRuMPFb56KkaVZIYSz1PrjI9q0QPCw==", + "dev": true + } + } + }, + "@types/redux": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz", + "integrity": "sha1-8evh5UEVGAcuT9/KXHbhbnTBOZo=", + "dev": true, + "requires": { + "redux": "*" + } + }, + "@types/redux-logger": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/redux-logger/-/redux-logger-3.0.7.tgz", + "integrity": "sha512-oV9qiCuowhVR/ehqUobWWkXJjohontbDGLV88Be/7T4bqMQ3kjXwkFNL7doIIqlbg3X2PC5WPziZ8/j/QHNQ4A==", + "dev": true, + "requires": { + "redux": "^3.6.0" + }, + "dependencies": { + "redux": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz", + "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==", + "dev": true, + "requires": { + "lodash": "^4.2.1", + "lodash-es": "^4.2.1", + "loose-envify": "^1.1.0", + "symbol-observable": "^1.0.3" + } + } + } + }, + "@types/redux-mock-store": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.1.tgz", + "integrity": "sha512-1egEnh2/+sRRKImnCo5EMVm0Uxu4fBHeLHk/inhSp/VpE93It8lk3gYeNfehUgXd6OzqP5LLA9kzO9x7o3WfwA==", + "dev": true, + "requires": { + "redux": "^4.0.0" + } + }, "@types/serve-static": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", @@ -2065,6 +2193,15 @@ "@types/mime": "*" } }, + "@types/simple-peer": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@types/simple-peer/-/simple-peer-6.1.6.tgz", + "integrity": "sha512-jnktVe4nDkNAIf/5brRQ4VqMP845dtRCrFMRagOxiTdC9gZ3rpnidt8EGR89b55Wtol+otYEJveFkEy3IpxA0Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/socket.io": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-2.1.4.tgz", @@ -4330,6 +4467,12 @@ "cssom": "~0.3.6" } }, + "csstype": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.7.tgz", + "integrity": "sha512-9Mcn9sFbGBAdmimWb2gLVDtFJzeKtDGIr76TUqmjZrw9LFXBMSU70lcs+C0/7fyCd6iBDqmksUcCOUIkisPHsQ==", + "dev": true + }, "currently-unhandled": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", @@ -9956,6 +10099,12 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, + "lodash-es": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.15.tgz", + "integrity": "sha512-rlrc3yU3+JNOpZ9zj5pQtxnx2THmvRykwL4Xlxoa8I9lHBlVbbyPhgyPMioxVZ4NqyxaVVtaJnzsyOidQIhyyQ==", + "dev": true + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", diff --git a/package.json b/package.json index 506b2cb..d3de070 100644 --- a/package.json +++ b/package.json @@ -80,11 +80,19 @@ "@babel/polyfill": "^7.4.4", "@babel/preset-env": "^7.5.0", "@babel/preset-react": "^7.0.0", + "@types/bluebird": "^3.5.29", "@types/config": "0.0.36", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "@types/jest": "^24.0.23", "@types/node": "^12.12.7", + "@types/react": "^16.9.11", + "@types/react-dom": "^16.9.4", + "@types/react-redux": "^7.1.5", + "@types/redux": "^3.6.0", + "@types/redux-logger": "^3.0.7", + "@types/redux-mock-store": "^1.0.1", + "@types/simple-peer": "^6.1.6", "@types/socket.io": "^2.1.4", "@types/socket.io-client": "^1.4.32", "@types/supertest": "^2.0.8", diff --git a/src/client/__mocks__/socket.js b/src/client/__mocks__/socket.ts similarity index 100% rename from src/client/__mocks__/socket.js rename to src/client/__mocks__/socket.ts diff --git a/src/client/__mocks__/store.js b/src/client/__mocks__/store.ts similarity index 100% rename from src/client/__mocks__/store.js rename to src/client/__mocks__/store.ts diff --git a/src/client/__mocks__/window.js b/src/client/__mocks__/window.ts similarity index 78% rename from src/client/__mocks__/window.js rename to src/client/__mocks__/window.ts index 0af2b0a..342d8cf 100644 --- a/src/client/__mocks__/window.js +++ b/src/client/__mocks__/window.ts @@ -7,21 +7,21 @@ export const revokeObjectURL = jest.fn() export class MediaStream { getVideoTracks () { return [{ - enabled: true + enabled: true, }] } getAudioTracks () { return [{ - enabled: true + enabled: true, }] } } export function getUserMedia () { - return !getUserMedia.shouldFail + return !(getUserMedia as any).shouldFail ? Promise.resolve(getUserMedia.stream) : Promise.reject(new Error('test')) } -getUserMedia.fail = shouldFail => getUserMedia.shouldFail = shouldFail +getUserMedia.fail = (shouldFail: boolean) => (getUserMedia as any).shouldFail = shouldFail getUserMedia.stream = new MediaStream() export const navigator = window.navigator diff --git a/src/client/actions/CallActions.js b/src/client/actions/CallActions.js deleted file mode 100644 index bff2ca6..0000000 --- a/src/client/actions/CallActions.js +++ /dev/null @@ -1,50 +0,0 @@ -import * as NotifyActions from './NotifyActions.js' -import * as SocketActions from './SocketActions.js' -import * as StreamActions from './StreamActions.js' -import * as constants from '../constants.js' -import Promise from 'bluebird' -import socket from '../socket.js' -import { callId, getUserMedia } from '../window.js' - -export const init = () => dispatch => { - return dispatch({ - type: constants.INIT, - payload: Promise.all([ - connect()(dispatch), - getCameraStream()(dispatch) - ]) - .spread((socket, stream) => { - dispatch(SocketActions.handshake({ - socket, - roomName: callId, - stream - })) - }) - }) -} - -export const connect = () => dispatch => { - return new Promise(resolve => { - socket.once('connect', () => { - resolve(socket) - }) - socket.on('connect', () => { - dispatch(NotifyActions.warning('Connected to server socket')) - }) - socket.on('disconnect', () => { - dispatch(NotifyActions.error('Server socket disconnected')) - }) - }) -} - -export const getCameraStream = () => dispatch => { - return getUserMedia({ video: { facingMode: 'user' }, audio: true }) - .then(stream => { - dispatch(StreamActions.addStream({ stream, userId: constants.ME })) - return stream - }) - .catch(() => { - dispatch(NotifyActions.alert('Could not get access to microphone & camera')) - return null - }) -} diff --git a/src/client/actions/CallActions.test.js b/src/client/actions/CallActions.test.ts similarity index 100% rename from src/client/actions/CallActions.test.js rename to src/client/actions/CallActions.test.ts diff --git a/src/client/actions/CallActions.ts b/src/client/actions/CallActions.ts new file mode 100644 index 0000000..fdafb9b --- /dev/null +++ b/src/client/actions/CallActions.ts @@ -0,0 +1,62 @@ +import * as NotifyActions from './NotifyActions.js' +import * as SocketActions from './SocketActions.js' +import * as StreamActions from './StreamActions.js' +import * as constants from '../constants.js' +import socket from '../socket.js' +import { callId, getUserMedia } from '../window.js' +import { Dispatch } from 'redux' +import { GetState } from '../store.js' + +export interface InitAction { + type: 'INIT' + payload: Promise +} + +interface InitializeAction { + type: 'INIT' +} + +const initialize = (): InitializeAction => ({ + type: 'INIT', +}) + +export const init = async (dispatch: Dispatch, getState: GetState) => { + dispatch(initialize()) + + const socket = await connect(dispatch) + const stream = await getCameraStream(dispatch) + + SocketActions.handshake({ + socket, + roomName: callId, + stream, + })(dispatch, getState) +} + +export async function connect (dispatch: Dispatch) { + return new Promise(resolve => { + socket.once('connect', () => { + resolve(socket) + }) + socket.on('connect', () => { + NotifyActions.warning('Connected to server socket')(dispatch) + }) + socket.on('disconnect', () => { + NotifyActions.error('Server socket disconnected')(dispatch) + }) + }) +} + +export async function getCameraStream (dispatch: Dispatch) { + try { + const stream = await getUserMedia({ + video: { facingMode: 'user' }, + audio: true, + }) + dispatch(StreamActions.addStream({ stream, userId: constants.ME })) + return stream + } catch (err) { + dispatch(NotifyActions.alert('Could not get access to microphone & camera')) + return + } +} diff --git a/src/client/actions/ChatActions.js b/src/client/actions/ChatActions.js deleted file mode 100644 index ef01410..0000000 --- a/src/client/actions/ChatActions.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as constants from '../constants.js' - -export const addMessage = ({ userId, message, timestamp, image }) => ({ - type: constants.MESSAGE_ADD, - payload: { - userId, - message, - timestamp, - image - } -}) diff --git a/src/client/actions/ChatActions.ts b/src/client/actions/ChatActions.ts new file mode 100644 index 0000000..1e5b54a --- /dev/null +++ b/src/client/actions/ChatActions.ts @@ -0,0 +1,18 @@ +import { MESSAGE_ADD } from '../constants.js' + +export interface MessageAddAction { + type: 'MESSAGE_ADD' + payload: Message +} + +export interface Message { + userId: string + message: string + timestamp: string + image?: string +} + +export const addMessage = (message: Message): MessageAddAction => ({ + type: MESSAGE_ADD, + payload: message, +}) diff --git a/src/client/actions/NotifyActions.js b/src/client/actions/NotifyActions.js deleted file mode 100644 index 5cdf875..0000000 --- a/src/client/actions/NotifyActions.js +++ /dev/null @@ -1,75 +0,0 @@ -import * as constants from '../constants.js' -import * as ChatActions from './ChatActions.js' -import _ from 'underscore' - -const TIMEOUT = 5000 - -function format (string, args) { - string = args - .reduce((string, arg, i) => string.replace('{' + i + '}', arg), string) - return string -} - -const _notify = (type, args) => dispatch => { - let string = args[0] || '' - let message = format(string, Array.prototype.slice.call(args, 1)) - const id = _.uniqueId('notification') - const payload = { id, type, message } - dispatch({ - type: constants.NOTIFY, - payload - }) - dispatch(ChatActions.addMessage({ - userId: '[PeerCalls]', - message, - timestamp: new Date().toLocaleString(), - image: null - })) - setTimeout(() => { - dispatch({ - type: constants.NOTIFY_DISMISS, - payload: { id } - }) - }, TIMEOUT) -} - -export const info = function () { - return dispatch => _notify('info', arguments)(dispatch) -} - -export const warning = function () { - return dispatch => _notify('warning', arguments)(dispatch) -} - -export const error = function () { - return dispatch => _notify('error', arguments)(dispatch) -} - -export const clear = () => ({ - type: constants.NOTIFY_CLEAR -}) - -export function alert (message, dismissable) { - return { - type: constants.ALERT, - payload: { - action: dismissable ? 'Dismiss' : '', - dismissable: !!dismissable, - message, - type: 'warning' - } - } -} - -export const dismissAlert = alert => { - return { - type: constants.ALERT_DISMISS, - payload: alert - } -} - -export const clearAlerts = () => { - return { - type: constants.ALERT_CLEAR - } -} diff --git a/src/client/actions/NotifyActions.ts b/src/client/actions/NotifyActions.ts new file mode 100644 index 0000000..f411c4f --- /dev/null +++ b/src/client/actions/NotifyActions.ts @@ -0,0 +1,137 @@ +import * as constants from '../constants.js' +import * as ChatActions from './ChatActions.js' +import { Dispatch } from 'redux' +import _ from 'underscore' + +const TIMEOUT = 5000 + +function format (string: string, args: string[]) { + string = args + .reduce((string, arg, i) => string.replace('{' + i + '}', arg), string) + return string +} + +export type NotifyType = 'info' | 'warning' | 'error' + +const _notify = (type: NotifyType, args: string[]) => (dispatch: Dispatch) => { + const string = args[0] || '' + const message = format(string, Array.prototype.slice.call(args, 1)) + const id = _.uniqueId('notification') + const payload: Notification = { id, type, message } + dispatch(addNotification(payload)) + dispatch(ChatActions.addMessage({ + userId: '[PeerCalls]', + message, + timestamp: new Date().toLocaleString(), + image: undefined, + })) + setTimeout(() => { + dispatch(dismissNotification(id)) + }, TIMEOUT) +} + +function addNotification(payload: Notification): NotificationAddAction { + return { + type: constants.NOTIFY, + payload, + } +} + +function dismissNotification(id: string): NotificationDismissAction { + return { + type: constants.NOTIFY_DISMISS, + payload: { id }, + } +} + +export interface Notification { + id: string + type: NotifyType + message: string +} + +export interface NotificationAddAction { + type: 'NOTIFY' + payload: Notification +} + +export interface NotificationDismissAction { + type: 'NOTIFY_DISMISS' + payload: { id: string } +} + +export function info (...args: any[]) { + return (dispatch: Dispatch) => _notify('info', args)(dispatch) +} + +export function warning (...args: any[]) { + return (dispatch: Dispatch) => _notify('warning', args)(dispatch) +} + +export function error (...args: any[]) { + return (dispatch: Dispatch) => _notify('error', args)(dispatch) +} + +export interface NotificationClearAction { + type: 'NOTIFY_CLEAR' +} + +export const clear = (): NotificationClearAction => ({ + type: constants.NOTIFY_CLEAR, +}) + +export interface Alert { + action?: string + dismissable: boolean + message: string + type: NotifyType +} + +export interface AlertAddAction { + type: 'ALERT' + payload: Alert +} + +export function alert (message: string, dismissable = false): AlertAddAction { + return { + type: constants.ALERT, + payload: { + action: dismissable ? 'Dismiss' : '', + dismissable: !!dismissable, + message, + type: 'warning', + }, + } +} + +export interface AlertDismissAction { + type: 'ALERT_DISMISS' + payload: Alert +} + +export const dismissAlert = (alert: Alert): AlertDismissAction => { + return { + type: constants.ALERT_DISMISS, + payload: alert, + } +} + +export interface AlertClearAction { + type: 'ALERT_CLEAR' +} + +export const clearAlerts = (): AlertClearAction => { + return { + type: constants.ALERT_CLEAR, + } +} + +export type AlertActionType = + AlertAddAction | + AlertDismissAction | + AlertClearAction + +export type NotificationActionType = + NotificationAddAction | + NotificationDismissAction | + NotificationClearAction diff --git a/src/client/actions/PeerActions.test.js b/src/client/actions/PeerActions.test.js deleted file mode 100644 index 45f46a0..0000000 --- a/src/client/actions/PeerActions.test.js +++ /dev/null @@ -1,169 +0,0 @@ -jest.mock('../window.js') -jest.mock('simple-peer') - -import * as PeerActions from './PeerActions.js' -import Peer from 'simple-peer' -import { EventEmitter } from 'events' -import { createStore } from '../store.js' -import { play } from '../window.js' - -describe('PeerActions', () => { - function createSocket () { - const socket = new EventEmitter() - socket.id = 'user1' - return socket - } - - let socket, stream, user, store - beforeEach(() => { - store = createStore() - - user = { id: 'user2' } - socket = createSocket() - Peer.instances = [] - Peer.mockClear() - play.mockClear() - stream = { stream: true } - }) - - describe('create', () => { - it('creates a new peer', () => { - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user2', stream }) - ) - - expect(Peer.instances.length).toBe(1) - expect(Peer.mock.calls.length).toBe(1) - expect(Peer.mock.calls[0][0].initiator).toBe(false) - expect(Peer.mock.calls[0][0].stream).toBe(stream) - }) - - it('sets initiator correctly', () => { - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user1', stream }) - ) - - expect(Peer.instances.length).toBe(1) - expect(Peer.mock.calls.length).toBe(1) - expect(Peer.mock.calls[0][0].initiator).toBe(true) - expect(Peer.mock.calls[0][0].stream).toBe(stream) - }) - - it('destroys old peer before creating new one', () => { - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user2', stream }) - ) - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user2', stream }) - ) - - expect(Peer.instances.length).toBe(2) - expect(Peer.mock.calls.length).toBe(2) - expect(Peer.instances[0].destroy.mock.calls.length).toBe(1) - expect(Peer.instances[1].destroy.mock.calls.length).toBe(0) - }) - }) - - describe('events', () => { - let peer - - beforeEach(() => { - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user1', stream }) - ) - peer = Peer.instances[0] - }) - - describe('connect', () => { - beforeEach(() => peer.emit('connect')) - - it('dispatches "play" action', () => { - expect(play.mock.calls.length).toBe(1) - }) - }) - - describe('data', () => { - - beforeEach(() => { - window.TextDecoder = class TextDecoder { - constructor (encoding) { - this.encoding = encoding - } - decode (object) { - return object.toString(this.encoding) - } - } - }) - - it('decodes a message', () => { - const payload = 'test' - const object = JSON.stringify({ payload }) - peer.emit('data', Buffer.from(object, 'utf-8')) - const { messages } = store.getState() - expect(messages[messages.length - 1]).toEqual({ - userId: 'user2', - timestamp: jasmine.any(String), - image: null, - message: 'test' - }) - }) - }) - }) - - describe('get', () => { - it('returns undefined when not found', () => { - const { peers } = store.getState() - expect(peers[user.id]).not.toBeDefined() - }) - - it('returns Peer instance when found', () => { - store.dispatch( - PeerActions.createPeer({ socket, user, initiator: 'user2', stream }) - ) - - const { peers } = store.getState() - expect(peers[user.id]).toBe(Peer.instances[0]) - }) - }) - - describe('destroyPeers', () => { - it('destroys all peers and removes them', () => { - store.dispatch(PeerActions.createPeer({ - socket, user: { id: 'user2' }, initiator: 'user2', stream - })) - store.dispatch(PeerActions.createPeer({ - socket, user: { id: 'user3' }, initiator: 'user3', stream - })) - - store.dispatch(PeerActions.destroyPeers()) - - expect(Peer.instances[0].destroy.mock.calls.length).toEqual(1) - expect(Peer.instances[1].destroy.mock.calls.length).toEqual(1) - - const { peers } = store.getState() - expect(Object.keys(peers)).toEqual([]) - }) - }) - - describe('sendMessage', () => { - - beforeEach(() => { - store.dispatch(PeerActions.createPeer({ - socket, user: { id: 'user2' }, initiator: 'user2', stream - })) - store.dispatch(PeerActions.createPeer({ - socket, user: { id: 'user3' }, initiator: 'user3', stream - })) - }) - - it('sends a message to all peers', () => { - store.dispatch(PeerActions.sendMessage({ payload: 'test', type: 'text' })) - const { peers } = store.getState() - expect(peers['user2'].send.mock.calls) - .toEqual([[ '{"payload":"test","type":"text"}' ]]) - expect(peers['user3'].send.mock.calls) - .toEqual([[ '{"payload":"test","type":"text"}' ]]) - }) - - }) -}) diff --git a/src/client/actions/PeerActions.test.ts b/src/client/actions/PeerActions.test.ts new file mode 100644 index 0000000..abc1caf --- /dev/null +++ b/src/client/actions/PeerActions.test.ts @@ -0,0 +1,177 @@ +jest.mock('../window.js') +jest.mock('simple-peer') + +import * as PeerActions from './PeerActions.js' +import Peer from 'simple-peer' +import { EventEmitter } from 'events' +import { createStore, Store, GetState } from '../store.js' +import { play } from '../window.js' +import { Dispatch } from 'redux' + +describe('PeerActions', () => { + function createSocket () { + const socket = new EventEmitter() as unknown as SocketIOClient.Socket + socket.id = 'user1' + return socket + } + + let socket: SocketIOClient.Socket + let stream: MediaStream + let user: { id: string } + let store: Store + let instances: Peer.Instance[] + let dispatch: Dispatch + let getState: GetState + let PeerMock: jest.Mock + + beforeEach(() => { + store = createStore() + dispatch = store.dispatch + getState = store.getState + + user = { id: 'user2' } + socket = createSocket() + instances = (Peer as any).instances = []; + (Peer as unknown as jest.Mock).mockClear(); + (play as jest.Mock).mockClear() + stream = { stream: true } as unknown as MediaStream + PeerMock = Peer as unknown as jest.Mock + }) + + describe('create', () => { + it('creates a new peer', () => { + PeerActions.createPeer({ socket, user, initiator: 'user2', stream })( + dispatch, getState) + + expect(instances.length).toBe(1) + expect(PeerMock.mock.calls.length).toBe(1) + expect(PeerMock.mock.calls[0][0].initiator).toBe(false) + expect(PeerMock.mock.calls[0][0].stream).toBe(stream) + }) + + it('sets initiator correctly', () => { + PeerActions + .createPeer({ + socket, user, initiator: 'user1', stream, + })(dispatch, getState) + + expect(instances.length).toBe(1) + expect(PeerMock.mock.calls.length).toBe(1) + expect(PeerMock.mock.calls[0][0].initiator).toBe(true) + expect(PeerMock.mock.calls[0][0].stream).toBe(stream) + }) + + it('destroys old peer before creating new one', () => { + PeerActions.createPeer({ socket, user, initiator: 'user2', stream })( + dispatch, getState) + PeerActions.createPeer({ socket, user, initiator: 'user2', stream })( + dispatch, getState) + + expect(instances.length).toBe(2) + expect(PeerMock.mock.calls.length).toBe(2) + expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(1) + expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0) + }) + }) + + describe('events', () => { + let peer: Peer.Instance + + beforeEach(() => { + PeerActions.createPeer({ socket, user, initiator: 'user1', stream })( + dispatch, getState) + peer = instances[0] + }) + + describe('connect', () => { + beforeEach(() => peer.emit('connect')) + + it('dispatches "play" action', () => { + expect((play as jest.Mock).mock.calls.length).toBe(1) + }) + }) + + describe('data', () => { + + beforeEach(() => { + (window as any).TextDecoder = class TextDecoder { + constructor (readonly encoding: string) { + } + decode (object: any) { + return object.toString(this.encoding) + } + } + }) + + it('decodes a message', () => { + const payload = 'test' + const object = JSON.stringify({ payload }) + peer.emit('data', Buffer.from(object, 'utf-8')) + const { messages } = store.getState() + expect(messages[messages.length - 1]).toEqual({ + userId: 'user2', + timestamp: jasmine.any(String), + image: null, + message: 'test', + }) + }) + }) + }) + + describe('get', () => { + it('returns undefined when not found', () => { + const { peers } = store.getState() + expect(peers[user.id]).not.toBeDefined() + }) + + it('returns Peer instance when found', () => { + PeerActions.createPeer({ socket, user, initiator: 'user2', stream })( + dispatch, getState) + + const { peers } = store.getState() + expect(peers[user.id]).toBe(instances[0]) + }) + }) + + describe('destroyPeers', () => { + it('destroys all peers and removes them', () => { + PeerActions.createPeer({ + socket, user: { id: 'user2' }, initiator: 'user2', stream, + })(dispatch, getState) + PeerActions.createPeer({ + socket, user: { id: 'user3' }, initiator: 'user3', stream, + })(dispatch, getState) + + store.dispatch(PeerActions.destroyPeers()) + + expect((instances[0].destroy as jest.Mock).mock.calls.length).toEqual(1) + expect((instances[1].destroy as jest.Mock).mock.calls.length).toEqual(1) + + const { peers } = store.getState() + expect(Object.keys(peers)).toEqual([]) + }) + }) + + describe('sendMessage', () => { + + beforeEach(() => { + PeerActions.createPeer({ + socket, user: { id: 'user2' }, initiator: 'user2', stream, + })(dispatch, getState) + PeerActions.createPeer({ + socket, user: { id: 'user3' }, initiator: 'user3', stream, + })(dispatch, getState) + }) + + it('sends a message to all peers', () => { + PeerActions.sendMessage({ payload: 'test', type: 'text' })( + dispatch, getState) + const { peers } = store.getState() + expect(peers['user2'].send.mock.calls) + .toEqual([[ '{"payload":"test","type":"text"}' ]]) + expect(peers['user3'].send.mock.calls) + .toEqual([[ '{"payload":"test","type":"text"}' ]]) + }) + + }) +}) diff --git a/src/client/actions/PeerActions.js b/src/client/actions/PeerActions.ts similarity index 59% rename from src/client/actions/PeerActions.js rename to src/client/actions/PeerActions.ts index c92a550..71906e0 100644 --- a/src/client/actions/PeerActions.js +++ b/src/client/actions/PeerActions.ts @@ -6,25 +6,44 @@ import Peer from 'simple-peer' import _ from 'underscore' import _debug from 'debug' import { play, iceServers } from '../window.js' +import { Dispatch } from 'redux' const debug = _debug('peercalls') +export interface Peers { + [id: string]: Peer.Instance +} + +export type GetState = () => { peers: Peers } + +export interface PeerHandlerOptions { + socket: SocketIOClient.Socket + user: { id: string } + dispatch: Dispatch + getState: GetState +} + class PeerHandler { - constructor ({ socket, user, dispatch, getState }) { - this.socket = socket - this.user = user - this.dispatch = dispatch - this.getState = getState + socket: SocketIOClient.Socket + user: { id: string } + dispatch: Dispatch + getState: GetState + + constructor (readonly options: PeerHandlerOptions) { + this.socket = options.socket + this.user = options.user + this.dispatch = options.dispatch + this.getState = options.getState } - handleError = err => { + handleError = (err: Error) => { const { dispatch, getState, user } = this debug('peer: %s, error %s', user.id, err.stack) - dispatch(NotifyActions.error('A peer connection error occurred')) + NotifyActions.error('A peer connection error occurred')(dispatch) const peer = getState().peers[user.id] peer && peer.destroy() dispatch(removePeer(user.id)) } - handleSignal = signal => { + handleSignal = (signal: unknown) => { const { socket, user } = this debug('peer: %s, signal: %o', user.id, signal) @@ -34,18 +53,18 @@ class PeerHandler { handleConnect = () => { const { dispatch, user } = this debug('peer: %s, connect', user.id) - dispatch(NotifyActions.warning('Peer connection established')) + NotifyActions.warning('Peer connection established')(dispatch) play() } - handleStream = stream => { + handleStream = (stream: MediaStream) => { const { user, dispatch } = this debug('peer: %s, stream', user.id) dispatch(StreamActions.addStream({ userId: user.id, - stream + stream, })) } - handleData = object => { + handleData = (object: any) => { const { dispatch, user } = this const message = JSON.parse(new window.TextDecoder('utf-8').decode(object)) debug('peer: %s, message: %o', user.id, object) @@ -55,7 +74,7 @@ class PeerHandler { userId: user.id, message: message.payload.name, timestamp: new Date().toLocaleString(), - image: message.payload.data + image: message.payload.data, })) break default: @@ -63,19 +82,26 @@ class PeerHandler { userId: user.id, message: message.payload, timestamp: new Date().toLocaleString(), - image: null + image: undefined, })) } } handleClose = () => { const { dispatch, user } = this debug('peer: %s, close', user.id) - dispatch(NotifyActions.error('Peer connection closed')) + NotifyActions.error('Peer connection closed')(dispatch) dispatch(StreamActions.removeStream(user.id)) dispatch(removePeer(user.id)) } } +export interface CreatePeerOptions { + socket: SocketIOClient.Socket + user: { id: string } + initiator: string + stream?: MediaStream +} + /** * @param {Object} options * @param {Socket} options.socket @@ -84,15 +110,17 @@ class PeerHandler { * @param {Boolean} [options.initiator=false] * @param {MediaStream} [options.stream] */ -export function createPeer ({ socket, user, initiator, stream }) { - return (dispatch, getState) => { +export function createPeer (options: CreatePeerOptions) { + const { socket, user, initiator, stream } = options + + return (dispatch: Dispatch, getState: GetState) => { const userId = user.id debug('create peer: %s, stream:', userId, stream) - dispatch(NotifyActions.warning('Connecting to peer...')) + NotifyActions.warning('Connecting to peer...')(dispatch) const oldPeer = getState().peers[userId] if (oldPeer) { - dispatch(NotifyActions.info('Cleaning up old connection...')) + NotifyActions.info('Cleaning up old connection...')(dispatch) oldPeer.destroy() dispatch(removePeer(userId)) } @@ -104,16 +132,16 @@ export function createPeer ({ socket, user, initiator, stream }) { // https://github.com/feross/simple-peer/issues/95 offerConstraints: { offerToReceiveAudio: true, - offerToReceiveVideo: true + offerToReceiveVideo: true, }, - stream + stream, }) const handler = new PeerHandler({ socket, user, dispatch, - getState + getState, }) peer.once(constants.PEER_EVENT_ERROR, handler.handleError) @@ -127,21 +155,65 @@ export function createPeer ({ socket, user, initiator, stream }) { } } -export const addPeer = ({ peer, userId }) => ({ +export interface AddPeerParams { + peer: Peer.Instance + userId: string +} + +export interface AddPeerAction { + type: 'PEER_ADD' + payload: AddPeerParams +} + +export const addPeer = (payload: AddPeerParams): AddPeerAction => ({ type: constants.PEER_ADD, - payload: { peer, userId } + payload, }) -export const removePeer = userId => ({ +export interface RemovePeerAction { + type: 'PEER_REMOVE' + payload: { userId: string } +} + +export const removePeer = (userId: string): RemovePeerAction => ({ type: constants.PEER_REMOVE, - payload: { userId } + payload: { userId }, }) -export const destroyPeers = () => ({ - type: constants.PEERS_DESTROY +export interface DestroyPeersAction { + type: 'PEERS_DESTROY' +} + +export const destroyPeers = (): DestroyPeersAction => ({ + type: constants.PEERS_DESTROY, }) -export const sendMessage = message => (dispatch, getState) => { +export type PeerAction = + AddPeerAction | + RemovePeerAction | + DestroyPeersAction + +export interface TextMessage { + type: 'text' + payload: string +} + +export interface Base64File { + name: string + size: number + type: string + data: string +} + +export interface FileMessage { + type: 'file' + payload: Base64File +} + +export type Message = TextMessage | FileMessage + +export const sendMessage = (message: Message) => +(dispatch: Dispatch, getState: GetState) => { const { peers } = getState() debug('Sending message type: %s to %s peers.', message.type, Object.keys(peers).length) @@ -153,7 +225,7 @@ export const sendMessage = message => (dispatch, getState) => { message: 'Send file: "' + message.payload.name + '" to peer: ' + userId, timestamp: new Date().toLocaleString(), - image: message.payload.data + image: message.payload.data, })) break default: @@ -161,27 +233,28 @@ export const sendMessage = message => (dispatch, getState) => { userId: 'You', message: message.payload, timestamp: new Date().toLocaleString(), - image: null + image: undefined, })) } peer.send(JSON.stringify(message)) }) } -export const sendFile = file => async (dispatch, getState) => { +export const sendFile = (file: File) => +async (dispatch: Dispatch, getState: GetState) => { const { name, size, type } = file if (!window.FileReader) { - dispatch(NotifyActions.error('File API is not supported by your browser')) + NotifyActions.error('File API is not supported by your browser')(dispatch) return } const reader = new window.FileReader() - const base64File = await new Promise(resolve => { + const base64File = await new Promise(resolve => { reader.addEventListener('load', () => { resolve({ name, size, type, - data: reader.result + data: reader.result as string, }) }) reader.readAsDataURL(file) diff --git a/src/client/actions/SocketActions.js b/src/client/actions/SocketActions.js deleted file mode 100644 index 69c6429..0000000 --- a/src/client/actions/SocketActions.js +++ /dev/null @@ -1,64 +0,0 @@ -import * as NotifyActions from '../actions/NotifyActions.js' -import * as PeerActions from '../actions/PeerActions.js' -import * as constants from '../constants.js' -import _ from 'underscore' -import _debug from 'debug' - -const debug = _debug('peercalls') - -class SocketHandler { - constructor ({ socket, roomName, stream, dispatch, getState }) { - this.socket = socket - this.roomName = roomName - this.stream = stream - this.dispatch = dispatch - this.getState = getState - } - handleSignal = ({ userId, signal }) => { - const { getState } = this - const peer = getState().peers[userId] - // debug('socket signal, userId: %s, signal: %o', userId, signal); - if (!peer) return debug('user: %s, no peer found', userId) - peer.signal(signal) - } - handleUsers = ({ initiator, users }) => { - const { socket, stream, dispatch, getState } = this - debug('socket users: %o', users) - dispatch(NotifyActions.info('Connected users: {0}', users.length)) - const { peers } = getState() - - users - .filter(user => !peers[user.id] && user.id !== socket.id) - .forEach(user => dispatch(PeerActions.createPeer({ - socket, - user, - initiator, - stream - }))) - - let newUsersMap = _.indexBy(users, 'id') - _.keys(peers) - .filter(id => !newUsersMap[id]) - .forEach(id => peers[id].destroy()) - } -} - -export function handshake ({ socket, roomName, stream }) { - return (dispatch, getState) => { - const handler = new SocketHandler({ - socket, - roomName, - stream, - dispatch, - getState - }) - - socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal) - socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers) - - debug('socket.id: %s', socket.id) - debug('emit ready for room: %s', roomName) - dispatch(NotifyActions.info('Ready for connections')) - socket.emit('ready', roomName) - } -} diff --git a/src/client/actions/SocketActions.test.js b/src/client/actions/SocketActions.test.ts similarity index 60% rename from src/client/actions/SocketActions.test.js rename to src/client/actions/SocketActions.test.ts index 56900f9..491d00c 100644 --- a/src/client/actions/SocketActions.test.js +++ b/src/client/actions/SocketActions.test.ts @@ -5,94 +5,101 @@ import * as SocketActions from './SocketActions.js' import * as constants from '../constants.js' import Peer from 'simple-peer' import { EventEmitter } from 'events' -import { createStore } from '../store.js' +import { createStore, Store, GetState } from '../store.js' +import { Dispatch } from 'redux' describe('SocketActions', () => { const roomName = 'bla' - let socket, store + let socket: SocketIOClient.Socket + let store: Store + let dispatch: Dispatch + let getState: GetState + let instances: Peer.Instance[] beforeEach(() => { - socket = new EventEmitter() - socket.id = 'a' + socket = new EventEmitter() as any; + (socket as any).id = 'a' store = createStore() + getState = store.getState + dispatch = store.dispatch - Peer.instances = [] + instances = (Peer as any).instances = [] }) describe('handshake', () => { describe('users', () => { beforeEach(() => { - store.dispatch(SocketActions.handshake({ socket, roomName })) + SocketActions.handshake({ socket, roomName })(dispatch, getState) const payload = { users: [{ id: 'a' }, { id: 'b' }], - initiator: 'a' + initiator: 'a', } socket.emit('users', payload) - expect(Peer.instances.length).toBe(1) + expect(instances.length).toBe(1) }) it('adds a peer for each new user and destroys peers for missing', () => { const payload = { users: [{ id: 'a' }, { id: 'c' }], - initiator: 'c' + initiator: 'c', } socket.emit(constants.SOCKET_EVENT_USERS, payload) // then - expect(Peer.instances.length).toBe(2) - expect(Peer.instances[0].destroy.mock.calls.length).toBe(1) - expect(Peer.instances[1].destroy.mock.calls.length).toBe(0) + expect(instances.length).toBe(2) + expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(1) + expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0) }) }) describe('signal', () => { - let data + let data: Peer.SignalData beforeEach(() => { - data = {} - store.dispatch(SocketActions.handshake({ socket, roomName })) + data = {} as any + SocketActions.handshake({ socket, roomName })(dispatch, getState) socket.emit('users', { initiator: 'a', - users: [{ id: 'a' }, { id: 'b' }] + users: [{ id: 'a' }, { id: 'b' }], }) }) it('should forward signal to peer', () => { socket.emit('signal', { userId: 'b', - data + data, }) - expect(Peer.instances.length).toBe(1) - expect(Peer.instances[0].signal.mock.calls.length).toBe(1) + expect(instances.length).toBe(1) + expect((instances[0].signal as jest.Mock).mock.calls.length).toBe(1) }) it('does nothing if no peer', () => { socket.emit('signal', { userId: 'a', - data + data, }) - expect(Peer.instances.length).toBe(1) - expect(Peer.instances[0].signal.mock.calls.length).toBe(0) + expect(instances.length).toBe(1) + expect((instances[0].signal as jest.Mock).mock.calls.length).toBe(0) }) }) }) describe('peer events', () => { - let peer + let peer: Peer.Instance beforeEach(() => { let ready = false socket.once('ready', () => { ready = true }) - store.dispatch(SocketActions.handshake({ socket, roomName })) + SocketActions.handshake({ socket, roomName })(dispatch, getState) socket.emit('users', { initiator: 'a', - users: [{ id: 'a' }, { id: 'b' }] + users: [{ id: 'a' }, { id: 'b' }], }) - expect(Peer.instances.length).toBe(1) - peer = Peer.instances[0] + expect(instances.length).toBe(1) + peer = instances[0] expect(ready).toBeDefined() }) @@ -100,15 +107,15 @@ describe('SocketActions', () => { describe('error', () => { it('destroys peer', () => { peer.emit(constants.PEER_EVENT_ERROR, new Error('bla')) - expect(peer.destroy.mock.calls.length).toBe(1) + expect((peer.destroy as jest.Mock).mock.calls.length).toBe(1) }) }) describe('signal', () => { it('emits socket signal with user id', done => { - let signal = { bla: 'bla' } + const signal = { bla: 'bla' } - socket.once('signal', payload => { + socket.once('signal', (payload: SocketActions.SignalOptions) => { expect(payload.userId).toEqual('b') expect(payload.signal).toBe(signal) done() @@ -126,8 +133,8 @@ describe('SocketActions', () => { expect(store.getState().streams).toEqual({ b: { mediaStream: stream, - url: jasmine.any(String) - } + url: jasmine.any(String), + }, }) }) }) @@ -139,8 +146,8 @@ describe('SocketActions', () => { expect(store.getState().streams).toEqual({ b: { mediaStream: stream, - url: jasmine.any(String) - } + url: jasmine.any(String), + }, }) }) diff --git a/src/client/actions/SocketActions.ts b/src/client/actions/SocketActions.ts new file mode 100644 index 0000000..3bcbda2 --- /dev/null +++ b/src/client/actions/SocketActions.ts @@ -0,0 +1,98 @@ +import * as NotifyActions from '../actions/NotifyActions.js' +import * as PeerActions from '../actions/PeerActions.js' +import * as constants from '../constants.js' +import _ from 'underscore' +import _debug from 'debug' +import { Dispatch } from 'redux' +import { SignalData } from 'simple-peer' + +const debug = _debug('peercalls') + +export interface SocketHandlerOptions { + socket: SocketIOClient.Socket + roomName: string + stream?: MediaStream + dispatch: Dispatch + getState: PeerActions.GetState +} + +export interface SignalOptions { + signal: SignalData + userId: string +} + +export interface UsersOptions { + initiator: string + users: Array<{ id: string }> +} + +class SocketHandler { + socket: SocketIOClient.Socket + roomName: string + stream?: MediaStream + dispatch: Dispatch + getState: PeerActions.GetState + + constructor (options: SocketHandlerOptions) { + this.socket = options.socket + this.roomName = options.roomName + this.stream = options.stream + this.dispatch = options.dispatch + this.getState = options.getState + } + handleSignal = ({ userId, signal }: SignalOptions) => { + const { getState } = this + const peer = getState().peers[userId] + // debug('socket signal, userId: %s, signal: %o', userId, signal); + if (!peer) return debug('user: %s, no peer found', userId) + peer.signal(signal) + } + handleUsers = ({ initiator, users }: UsersOptions) => { + const { socket, stream, dispatch, getState } = this + debug('socket users: %o', users) + NotifyActions.info('Connected users: {0}', users.length)(dispatch) + const { peers } = getState() + + users + .filter(user => !peers[user.id] && user.id !== socket.id) + .forEach(user => PeerActions.createPeer({ + socket, + user, + initiator, + stream, + })(dispatch, getState)) + + const newUsersMap = _.indexBy(users, 'id') + _.keys(peers) + .filter(id => !newUsersMap[id]) + .forEach(id => peers[id].destroy()) + } +} + +export interface HandshakeOptions { + socket: SocketIOClient.Socket + roomName: string + stream?: MediaStream +} + +export function handshake (options: HandshakeOptions) { + const { socket, roomName, stream } = options + + return (dispatch: Dispatch, getState: PeerActions.GetState) => { + const handler = new SocketHandler({ + socket, + roomName, + stream, + dispatch, + getState, + }) + + socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal) + socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers) + + debug('socket.id: %s', socket.id) + debug('emit ready for room: %s', roomName) + NotifyActions.info('Ready for connections')(dispatch) + socket.emit('ready', roomName) + } +} diff --git a/src/client/actions/StreamActions.js b/src/client/actions/StreamActions.js deleted file mode 100644 index 59f4863..0000000 --- a/src/client/actions/StreamActions.js +++ /dev/null @@ -1,24 +0,0 @@ -import * as constants from '../constants.js' - -export const addStream = ({ stream, userId }) => ({ - type: constants.STREAM_ADD, - payload: { - userId, - stream - } -}) - -export const removeStream = userId => ({ - type: constants.STREAM_REMOVE, - payload: { userId } -}) - -export const setActive = userId => ({ - type: constants.ACTIVE_SET, - payload: { userId } -}) - -export const toggleActive = userId => ({ - type: constants.ACTIVE_TOGGLE, - payload: { userId } -}) diff --git a/src/client/actions/StreamActions.ts b/src/client/actions/StreamActions.ts new file mode 100644 index 0000000..2320f95 --- /dev/null +++ b/src/client/actions/StreamActions.ts @@ -0,0 +1,61 @@ +import * as constants from '../constants.js' + +export interface AddStreamPayload { + userId: string + stream: MediaStream + url?: string +} + +export interface AddStreamAction { + type: 'PEER_STREAM_ADD' + payload: AddStreamPayload +} + +export interface RemoveStreamAction { + type: 'PEER_STREAM_REMOVE' + payload: RemoveStreamPayload +} + +export interface RemoveStreamPayload { + userId: string +} + +export interface SetActiveStreamAction { + type: 'ACTIVE_SET' + payload: RemoveStreamPayload +} + +export interface ToggleActiveStreamAction { + type: 'ACTIVE_TOGGLE' + payload: UserIdPayload +} + +export interface UserIdPayload { + userId: string +} + +export const addStream = (payload: AddStreamPayload): AddStreamAction => ({ + type: constants.STREAM_ADD, + payload, +}) + +export const removeStream = (userId: string): RemoveStreamAction => ({ + type: constants.STREAM_REMOVE, + payload: { userId }, +}) + +export const setActive = (userId: string): SetActiveStreamAction => ({ + type: constants.ACTIVE_SET, + payload: { userId }, +}) + +export const toggleActive = (userId: string): ToggleActiveStreamAction => ({ + type: constants.ACTIVE_TOGGLE, + payload: { userId }, +}) + +export type StreamAction = + AddStreamAction | + RemoveStreamAction + // SetActiveStreamAction | + // ToggleActiveStreamAction diff --git a/src/client/components/Alerts.js b/src/client/components/Alerts.ts similarity index 100% rename from src/client/components/Alerts.js rename to src/client/components/Alerts.ts diff --git a/src/client/components/App.js b/src/client/components/App.ts similarity index 100% rename from src/client/components/App.js rename to src/client/components/App.ts diff --git a/src/client/components/Chat.js b/src/client/components/Chat.ts similarity index 100% rename from src/client/components/Chat.js rename to src/client/components/Chat.ts diff --git a/src/client/components/Input.test.js b/src/client/components/Input.test.ts similarity index 100% rename from src/client/components/Input.test.js rename to src/client/components/Input.test.ts diff --git a/src/client/components/Input.js b/src/client/components/Input.ts similarity index 100% rename from src/client/components/Input.js rename to src/client/components/Input.ts diff --git a/src/client/components/Notifications.js b/src/client/components/Notifications.ts similarity index 100% rename from src/client/components/Notifications.js rename to src/client/components/Notifications.ts diff --git a/src/client/components/Toolbar.test.js b/src/client/components/Toolbar.test.ts similarity index 100% rename from src/client/components/Toolbar.test.js rename to src/client/components/Toolbar.test.ts diff --git a/src/client/components/Toolbar.js b/src/client/components/Toolbar.ts similarity index 100% rename from src/client/components/Toolbar.js rename to src/client/components/Toolbar.ts diff --git a/src/client/components/Video.test.js b/src/client/components/Video.test.ts similarity index 100% rename from src/client/components/Video.test.js rename to src/client/components/Video.test.ts diff --git a/src/client/components/Video.js b/src/client/components/Video.ts similarity index 100% rename from src/client/components/Video.js rename to src/client/components/Video.ts diff --git a/src/client/constants.js b/src/client/constants.ts similarity index 99% rename from src/client/constants.js rename to src/client/constants.ts index a5eba94..616d2a1 100644 --- a/src/client/constants.js +++ b/src/client/constants.ts @@ -1,4 +1,3 @@ - export const ACTIVE_SET = 'ACTIVE_SET' export const ACTIVE_TOGGLE = 'ACTIVE_TOGGLE' diff --git a/src/client/containers/App.test.js b/src/client/containers/App.test.ts similarity index 100% rename from src/client/containers/App.test.js rename to src/client/containers/App.test.ts diff --git a/src/client/containers/App.js b/src/client/containers/App.ts similarity index 100% rename from src/client/containers/App.js rename to src/client/containers/App.ts diff --git a/src/client/debug/index.js b/src/client/debug/index.ts similarity index 100% rename from src/client/debug/index.js rename to src/client/debug/index.ts diff --git a/src/client/index.js b/src/client/index.tsx similarity index 86% rename from src/client/index.js rename to src/client/index.tsx index a41932d..d512f88 100644 --- a/src/client/index.js +++ b/src/client/index.tsx @@ -1,6 +1,5 @@ -'use strict' import '@babel/polyfill' -import App from './containers/App.js' +import App from './containers/App' import React from 'react' import ReactDOM from 'react-dom' import store from './store.js' diff --git a/src/client/middlewares.test.js b/src/client/middlewares.test.ts similarity index 100% rename from src/client/middlewares.test.js rename to src/client/middlewares.test.ts diff --git a/src/client/middlewares.js b/src/client/middlewares.ts similarity index 85% rename from src/client/middlewares.js rename to src/client/middlewares.ts index d0e109a..785af08 100644 --- a/src/client/middlewares.js +++ b/src/client/middlewares.ts @@ -3,7 +3,7 @@ import promiseMiddleware from 'redux-promise-middleware' import thunk from 'redux-thunk' export const middlewares = [thunk, promiseMiddleware()] -export const create = log => { +export const create = (log = false) => { const m = middlewares.slice() log && m.push(logger) return m diff --git a/src/client/reducers/active.test.js b/src/client/reducers/active.test.ts similarity index 100% rename from src/client/reducers/active.test.js rename to src/client/reducers/active.test.ts diff --git a/src/client/reducers/active.js b/src/client/reducers/active.ts similarity index 83% rename from src/client/reducers/active.js rename to src/client/reducers/active.ts index ad27756..8af5773 100644 --- a/src/client/reducers/active.js +++ b/src/client/reducers/active.ts @@ -1,6 +1,6 @@ import * as constants from '../constants.js' -export default function active (state = null, action) { +export default function active (state = null, action: Action) { switch (action && action.type) { case constants.ACTIVE_SET: case constants.STREAM_ADD: diff --git a/src/client/reducers/alerts.test.js b/src/client/reducers/alerts.test.ts similarity index 100% rename from src/client/reducers/alerts.test.js rename to src/client/reducers/alerts.test.ts diff --git a/src/client/reducers/alerts.js b/src/client/reducers/alerts.ts similarity index 100% rename from src/client/reducers/alerts.js rename to src/client/reducers/alerts.ts diff --git a/src/client/reducers/index.js b/src/client/reducers/index.ts similarity index 100% rename from src/client/reducers/index.js rename to src/client/reducers/index.ts diff --git a/src/client/reducers/messages.js b/src/client/reducers/messages.js deleted file mode 100644 index b9fcaa2..0000000 --- a/src/client/reducers/messages.js +++ /dev/null @@ -1,15 +0,0 @@ -import * as constants from '../constants.js' -import Immutable from 'seamless-immutable' - -const defaultState = Immutable([]) - -export default function messages (state = defaultState, action) { - switch (action && action.type) { - case constants.MESSAGE_ADD: - const messages = state.asMutable() - messages.push(action.payload) - return Immutable(messages) - default: - return state - } -} diff --git a/src/client/reducers/messages.test.js b/src/client/reducers/messages.test.ts similarity index 100% rename from src/client/reducers/messages.test.js rename to src/client/reducers/messages.test.ts diff --git a/src/client/reducers/messages.ts b/src/client/reducers/messages.ts new file mode 100644 index 0000000..a5472e2 --- /dev/null +++ b/src/client/reducers/messages.ts @@ -0,0 +1,17 @@ +import * as constants from '../constants.js' +import { Message, MessageAddAction } from '../actions/ChatActions.js' + +export type MessagesState = Message[] + +const defaultState: MessagesState = [] + +export default function messages ( + state = defaultState, action: MessageAddAction, +) { + switch (action && action.type) { + case constants.MESSAGE_ADD: + return [...state, action.payload] + default: + return state + } +} diff --git a/src/client/reducers/notifications.js b/src/client/reducers/notifications.ts similarity index 100% rename from src/client/reducers/notifications.js rename to src/client/reducers/notifications.ts diff --git a/src/client/reducers/peers.js b/src/client/reducers/peers.ts similarity index 52% rename from src/client/reducers/peers.js rename to src/client/reducers/peers.ts index 9f46115..1f702e8 100644 --- a/src/client/reducers/peers.js +++ b/src/client/reducers/peers.ts @@ -1,14 +1,20 @@ import * as constants from '../constants.js' import _ from 'underscore' +import Peer from 'simple-peer' +import { PeerAction } from '../actions/PeerActions' -const defaultState = {} +export interface PeersState { + [userId: string]: Peer.Instance +} -export default function peers (state = defaultState, action) { - switch (action && action.type) { +const defaultState: PeersState = {} + +export default function peers (state = defaultState, action: PeerAction) { + switch (action.type) { case constants.PEER_ADD: return { ...state, - [action.payload.userId]: action.payload.peer + [action.payload.userId]: action.payload.peer, } case constants.PEER_REMOVE: return _.omit(state, [action.payload.userId]) diff --git a/src/client/reducers/streams.test.js b/src/client/reducers/streams.test.ts similarity index 83% rename from src/client/reducers/streams.test.js rename to src/client/reducers/streams.test.ts index f43070d..13435c8 100644 --- a/src/client/reducers/streams.test.js +++ b/src/client/reducers/streams.test.ts @@ -3,23 +3,23 @@ jest.mock('../window.js') import * as StreamActions from '../actions/StreamActions.js' import reducers from './index.js' import { createObjectURL, MediaStream } from '../window.js' -import { applyMiddleware, createStore } from 'redux' +import { applyMiddleware, createStore, Store } from 'redux' import { create } from '../middlewares.js' describe('reducers/alerts', () => { - let store, stream, userId + let store: Store, stream: MediaStream, userId: string beforeEach(() => { store = createStore( reducers, - applyMiddleware.apply(null, create()) + applyMiddleware(...create()), ) userId = 'test id' stream = new MediaStream() }) afterEach(() => { - createObjectURL + (createObjectURL as jest.Mock) .mockImplementation(object => 'blob://' + String(object)) }) @@ -35,19 +35,19 @@ describe('reducers/alerts', () => { expect(store.getState().streams).toEqual({ [userId]: { mediaStream: stream, - url: jasmine.any(String) - } + url: jasmine.any(String), + }, }) }) it('does not fail when createObjectURL fails', () => { - createObjectURL + (createObjectURL as jest.Mock) .mockImplementation(() => { throw new Error('test') }) store.dispatch(StreamActions.addStream({ userId, stream })) expect(store.getState().streams).toEqual({ [userId]: { mediaStream: stream, - url: null - } + url: null, + }, }) }) }) diff --git a/src/client/reducers/streams.js b/src/client/reducers/streams.ts similarity index 54% rename from src/client/reducers/streams.js rename to src/client/reducers/streams.ts index ff0701e..5ca6292 100644 --- a/src/client/reducers/streams.js +++ b/src/client/reducers/streams.ts @@ -1,12 +1,13 @@ -import * as constants from '../constants.js' import _ from 'underscore' import { createObjectURL, revokeObjectURL } from '../window.js' import _debug from 'debug' +import { AddStreamPayload, AddStreamAction, RemoveStreamAction, StreamAction } from '../actions/StreamActions.js' +import { STREAM_ADD, STREAM_REMOVE } from '../constants.js' const debug = _debug('peercalls') const defaultState = Object.freeze({}) -function safeCreateObjectURL (stream) { +function safeCreateObjectURL (stream: MediaStream) { try { return createObjectURL(stream) } catch (err) { @@ -15,18 +16,22 @@ function safeCreateObjectURL (stream) { } } -function addStream (state, action) { +export interface StreamsState { + [userId: string]: AddStreamPayload +} + +function addStream (state: StreamsState, action: AddStreamAction) { const { userId, stream } = action.payload return Object.freeze({ ...state, [userId]: Object.freeze({ mediaStream: stream, - url: safeCreateObjectURL(stream) - }) + url: safeCreateObjectURL(stream), + }), }) } -function removeStream (state, action) { +function removeStream (state: StreamsState, action: RemoveStreamAction) { const { userId } = action.payload const stream = state[userId] if (stream && stream.url) { @@ -35,11 +40,11 @@ function removeStream (state, action) { return Object.freeze(_.omit(state, [userId])) } -export default function streams (state = defaultState, action) { - switch (action && action.type) { - case constants.STREAM_ADD: +export default function streams (state = defaultState, action: StreamAction) { + switch (action.type) { + case STREAM_ADD: return addStream(state, action) - case constants.STREAM_REMOVE: + case STREAM_REMOVE: return removeStream(state, action) default: return state diff --git a/src/client/socket.js b/src/client/socket.ts similarity index 56% rename from src/client/socket.js rename to src/client/socket.ts index 9d8ce1b..6a7535a 100644 --- a/src/client/socket.js +++ b/src/client/socket.ts @@ -1,3 +1,3 @@ import SocketIOClient from 'socket.io-client' import { baseUrl } from './window.js' -export default new SocketIOClient('', { path: baseUrl + '/ws' }) +export default SocketIOClient('', { path: baseUrl + '/ws' }) diff --git a/src/client/store.js b/src/client/store.js deleted file mode 100644 index 69e09fa..0000000 --- a/src/client/store.js +++ /dev/null @@ -1,13 +0,0 @@ -import { create } from './middlewares.js' -import reducers from './reducers' -import { applyMiddleware, createStore as _createStore } from 'redux' -export const middlewares = create( - window.localStorage && window.localStorage.log -) - -export const createStore = () => _createStore( - reducers, - applyMiddleware.apply(null, middlewares) -) - -export default createStore() diff --git a/src/client/store.ts b/src/client/store.ts new file mode 100644 index 0000000..6e5c824 --- /dev/null +++ b/src/client/store.ts @@ -0,0 +1,19 @@ +import { create } from './middlewares.js' +import reducers from './reducers' +import { applyMiddleware, createStore as _createStore, Store as ReduxStore } from 'redux' +export const middlewares = create( + window.localStorage && window.localStorage.log, +) + +export const createStore = () => _createStore( + reducers, + applyMiddleware(...middlewares), +) + +export default createStore() + +export type Store = ReturnType + +type TGetState = T extends ReduxStore ? State : never +export type State = TGetState +export type GetState = () => State diff --git a/src/client/window.test.js b/src/client/window.test.ts similarity index 72% rename from src/client/window.test.js rename to src/client/window.test.ts index b81904e..9cb0916 100644 --- a/src/client/window.test.js +++ b/src/client/window.test.ts @@ -1,12 +1,10 @@ -import Promise from 'bluebird' - import { createObjectURL, revokeObjectURL, getUserMedia, navigator, play, - valueOf + valueOf, } from './window.js' describe('window', () => { @@ -18,47 +16,50 @@ describe('window', () => { const constraints = { video: true } afterEach(() => { - delete navigator.mediaDevices + delete (navigator as any).mediaDevices delete navigator.getUserMedia - delete navigator.webkitGetUserMedia + delete (navigator as any).webkitGetUserMedia }) it('calls navigator.mediaDevices.getUserMedia', () => { - const promise = Promise.resolve(stream) - navigator.mediaDevices = { - getUserMedia: jest.fn().mockReturnValue(promise) + const promise = Promise.resolve(stream); + (navigator as any).mediaDevices = { + getUserMedia: jest.fn().mockReturnValue(promise), } expect(getUserMedia(constraints)).toBe(promise) }) ;['getUserMedia', 'webkitGetUserMedia'].forEach((method) => { it(`it calls navigator.${method} as a fallback`, () => { - navigator[method] = jest.fn() + (navigator as any)[method] = jest.fn() .mockImplementation( - (constraints, onSuccess, onError) => onSuccess(stream) + (constraints, onSuccess, onError) => onSuccess(stream), ) return getUserMedia(constraints) .then(s => expect(s).toBe(stream)) }) }) - it('throws error when no supported method', done => { - getUserMedia(constraints) - .asCallback(err => { - expect(err).toBeTruthy() - expect(err.message).toBe('Browser unsupported') - done() - }) + it('throws error when no supported method', async () => { + let error: Error + try { + await getUserMedia(constraints) + } catch (err) { + error = err + } + expect(error!).toBeTruthy() + expect(error!.message).toBe('Browser unsupported') }) }) describe('play', () => { - let v1, v2 + let v1: HTMLVideoElement & { play: jest.Mock } + let v2: HTMLVideoElement & { play: jest.Mock } beforeEach(() => { - v1 = window.document.createElement('video') - v2 = window.document.createElement('video') + v1 = window.document.createElement('video') as any + v2 = window.document.createElement('video') as any window.document.body.appendChild(v1) window.document.body.appendChild(v2) v1.play = jest.fn() @@ -96,7 +97,7 @@ describe('window', () => { it('calls window.URL.createObjectURL', () => { window.URL.createObjectURL = jest.fn().mockReturnValue('test') - expect(createObjectURL()).toBe('test') + expect(createObjectURL('bla')).toBe('test') }) }) @@ -105,14 +106,14 @@ describe('window', () => { it('calls window.URL.revokeObjectURL', () => { window.URL.revokeObjectURL = jest.fn() - expect(revokeObjectURL()).toBe(undefined) + expect(revokeObjectURL('bla')).toBe(undefined) }) }) describe('valueOf', () => { - let input + let input: HTMLInputElement beforeEach(() => { input = window.document.createElement('input') input.setAttribute('id', 'my-main-id') diff --git a/src/client/window.js b/src/client/window.ts similarity index 54% rename from src/client/window.js rename to src/client/window.ts index 8b8bda5..5f74af3 100644 --- a/src/client/window.js +++ b/src/client/window.ts @@ -1,27 +1,28 @@ -import Promise from 'bluebird' import _debug from 'debug' const debug = _debug('peercalls') -export function getUserMedia (constraints) { +export async function getUserMedia (constraints: MediaStreamConstraints) { if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { return navigator.mediaDevices.getUserMedia(constraints) } - return new Promise((resolve, reject) => { - const getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia + return new Promise((resolve, reject) => { + const getMedia = navigator.getUserMedia || + (navigator as any).webkitGetUserMedia if (!getMedia) reject(new Error('Browser unsupported')) getMedia.call(navigator, constraints, resolve, reject) }) } -export const createObjectURL = object => window.URL.createObjectURL(object) -export const revokeObjectURL = url => window.URL.revokeObjectURL(url) +export const createObjectURL = (object: unknown) => + window.URL.createObjectURL(object) +export const revokeObjectURL = (url: string) => window.URL.revokeObjectURL(url) export const navigator = window.navigator export function play () { - let videos = window.document.querySelectorAll('video') + const videos = window.document.querySelectorAll('video') Array.prototype.forEach.call(videos, (video, index) => { debug('playing video: %s', index) try { @@ -32,13 +33,13 @@ export function play () { }) } -export const valueOf = id => { - const el = window.document.getElementById(id) +export const valueOf = (id: string) => { + const el = window.document.getElementById(id) as HTMLInputElement return el && el.value } export const baseUrl = valueOf('baseUrl') export const callId = valueOf('callId') -export const iceServers = JSON.parse(valueOf('iceServers')) +export const iceServers = JSON.parse(valueOf('iceServers')!) export const MediaStream = window.MediaStream