Refactor actions/reducers to TS

This commit is contained in:
Jerko Steiner 2019-11-13 11:16:09 -03:00
parent 1eaca46a16
commit e9926e3484
54 changed files with 972 additions and 556 deletions

149
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

@ -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<void>
}
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<SocketIOClient.Socket>(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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Peer.Instance>
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<Peer.Instance>
})
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"}' ]])
})
})
})

View File

@ -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<Base64File>(resolve => {
reader.addEventListener('load', () => {
resolve({
name,
size,
type,
data: reader.result
data: reader.result as string,
})
})
reader.readAsDataURL(file)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
export const ACTIVE_SET = 'ACTIVE_SET'
export const ACTIVE_TOGGLE = 'ACTIVE_TOGGLE'

View File

@ -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'

View File

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

View File

@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
src/client/store.ts Normal file
View File

@ -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<typeof createStore>
type TGetState<T> = T extends ReduxStore<infer State> ? State : never
export type State = TGetState<Store>
export type GetState = () => State

View File

@ -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')

View File

@ -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<MediaStream>((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