Refactor all components

This commit is contained in:
Jerko Steiner 2019-11-13 18:36:31 -03:00
parent 69122466b1
commit 4fa6a0d17a
47 changed files with 718 additions and 600 deletions

63
package-lock.json generated
View File

@ -1949,6 +1949,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"@types/classnames": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.2.9.tgz",
"integrity": "sha512-MNl+rT5UmZeilaPxAVs6YaPC2m6aA8rofviZbhbxpPpl61uKodfdQVsBtgJGTqGizEf02oW3tsVe7FYB8kK14A==",
"dev": true
},
"@types/config": { "@types/config": {
"version": "0.0.36", "version": "0.0.36",
"resolved": "https://registry.npmjs.org/@types/config/-/config-0.0.36.tgz", "resolved": "https://registry.npmjs.org/@types/config/-/config-0.0.36.tgz",
@ -2142,6 +2148,15 @@
} }
} }
}, },
"@types/react-transition-group": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.2.3.tgz",
"integrity": "sha512-Hk8jiuT7iLOHrcjKP/ZVSyCNXK73wJAUz60xm0mVhiRujrdiI++j4duLiL282VGxwAgxetHQFfqA29LgEeSkFA==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/redux": { "@types/redux": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/redux/-/redux-3.6.0.tgz",
@ -2183,6 +2198,15 @@
"redux": "^4.0.0" "redux": "^4.0.0"
} }
}, },
"@types/screenfull": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@types/screenfull/-/screenfull-4.1.0.tgz",
"integrity": "sha512-TuFSOzuzGFVCdQmwp+a0/Qim+nJhclaWha18yK/xmjrwdPI7qrfeyoSyPx8MoJP8X5d2MwB4a6d2sZhUjJwz9A==",
"dev": true,
"requires": {
"screenfull": "*"
}
},
"@types/serve-static": { "@types/serve-static": {
"version": "1.13.3", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.3.tgz",
@ -3862,11 +3886,6 @@
"lazy-cache": "^1.0.3" "lazy-cache": "^1.0.3"
} }
}, },
"chain-function": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.1.tgz",
"integrity": "sha512-SxltgMwL9uCko5/ZCLiyG2B7R9fY4pDZUw7hJ4MhirdjBLosoDqkWABi3XMucddHdLiFJMb7PD2MZifZriuMTg=="
},
"chalk": { "chalk": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@ -4733,6 +4752,7 @@
"version": "3.4.0", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==", "integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
"dev": true,
"requires": { "requires": {
"@babel/runtime": "^7.1.2" "@babel/runtime": "^7.1.2"
} }
@ -11762,6 +11782,12 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz",
"integrity": "sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA==" "integrity": "sha512-u7FDWtthB4rWibG/+mFbVd5FvdI20yde86qKGx4lVUTWmPlSWQ4QxbBIrrs+HnXGbxOUlUzTAP/VDmvCwaP2yA=="
}, },
"react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"dev": true
},
"react-redux": { "react-redux": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-6.0.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-6.0.0.tgz",
@ -11776,15 +11802,15 @@
} }
}, },
"react-transition-group": { "react-transition-group": {
"version": "1.2.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-1.2.1.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.5.1.tgz",
"integrity": "sha512-CWaL3laCmgAFdxdKbhhps+c0HRGF4c+hdM4H23+FI1QBNUyx/AMeIJGWorehPNSaKnQNOAxL7PQmqMu78CDj3Q==", "integrity": "sha512-8x/CxUL9SjYFmUdzsBPTgtKeCxt7QArjNSte0wwiLtF/Ix/o1nWNJooNy5o9XbHIKS31pz7J5VF2l41TwlvbHQ==",
"dev": true,
"requires": { "requires": {
"chain-function": "^1.0.0", "dom-helpers": "^3.3.1",
"dom-helpers": "^3.2.0", "loose-envify": "^1.4.0",
"loose-envify": "^1.3.1", "prop-types": "^15.6.2",
"prop-types": "^15.5.6", "react-lifecycles-compat": "^3.0.4"
"warning": "^3.0.0"
} }
}, },
"read-only-stream": { "read-only-stream": {
@ -12604,7 +12630,8 @@
"screenfull": { "screenfull": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/screenfull/-/screenfull-3.3.3.tgz", "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-3.3.3.tgz",
"integrity": "sha512-DzYUuXr+OV2BDvYXaYzlYgJd4WXZZ2CW5NFC7Kw6TUCpzXJAx4MwlVD6CH+Mu6fi8rfAQIQfqdFZ4jtDsEkWig==" "integrity": "sha512-DzYUuXr+OV2BDvYXaYzlYgJd4WXZZ2CW5NFC7Kw6TUCpzXJAx4MwlVD6CH+Mu6fi8rfAQIQfqdFZ4jtDsEkWig==",
"dev": true
}, },
"scss-tokenizer": { "scss-tokenizer": {
"version": "0.2.3", "version": "0.2.3",
@ -14320,14 +14347,6 @@
"makeerror": "1.0.x" "makeerror": "1.0.x"
} }
}, },
"warning": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
"integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
"requires": {
"loose-envify": "^1.0.0"
}
},
"watchify": { "watchify": {
"version": "3.11.1", "version": "3.11.1",
"resolved": "https://registry.npmjs.org/watchify/-/watchify-3.11.1.tgz", "resolved": "https://registry.npmjs.org/watchify/-/watchify-3.11.1.tgz",

View File

@ -60,12 +60,10 @@
"react": "^16.6.3", "react": "^16.6.3",
"react-dom": "^16.6.3", "react-dom": "^16.6.3",
"react-redux": "^6.0.0", "react-redux": "^6.0.0",
"react-transition-group": "^1.2.1",
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-promise-middleware": "^5.1.1", "redux-promise-middleware": "^5.1.1",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.2.0",
"screenfull": "^3.3.3",
"seamless-immutable": "^7.1.2", "seamless-immutable": "^7.1.2",
"simple-peer": "^9.1.2", "simple-peer": "^9.1.2",
"socket.io": "^2.2.0", "socket.io": "^2.2.0",
@ -81,6 +79,7 @@
"@babel/preset-env": "^7.5.0", "@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.0.0", "@babel/preset-react": "^7.0.0",
"@types/bluebird": "^3.5.29", "@types/bluebird": "^3.5.29",
"@types/classnames": "^2.2.9",
"@types/config": "0.0.36", "@types/config": "0.0.36",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/express": "^4.17.2", "@types/express": "^4.17.2",
@ -89,9 +88,11 @@
"@types/react": "^16.9.11", "@types/react": "^16.9.11",
"@types/react-dom": "^16.9.4", "@types/react-dom": "^16.9.4",
"@types/react-redux": "^7.1.5", "@types/react-redux": "^7.1.5",
"@types/react-transition-group": "^4.2.3",
"@types/redux": "^3.6.0", "@types/redux": "^3.6.0",
"@types/redux-logger": "^3.0.7", "@types/redux-logger": "^3.0.7",
"@types/redux-mock-store": "^1.0.1", "@types/redux-mock-store": "^1.0.1",
"@types/screenfull": "^4.1.0",
"@types/simple-peer": "^6.1.6", "@types/simple-peer": "^6.1.6",
"@types/socket.io": "^2.1.4", "@types/socket.io": "^2.1.4",
"@types/socket.io-client": "^1.4.32", "@types/socket.io-client": "^1.4.32",
@ -117,7 +118,9 @@
"jest-cli": "^24.8.0", "jest-cli": "^24.8.0",
"node-sass": "^4.13.0", "node-sass": "^4.13.0",
"nodemon": "^1.18.8", "nodemon": "^1.18.8",
"react-transition-group": "^2.5.1",
"redux-mock-store": "^1.2.3", "redux-mock-store": "^1.2.3",
"screenfull": "^3.3.3",
"supertest": "^3.0.0", "supertest": "^3.0.0",
"ts-jest": "^24.1.0", "ts-jest": "^24.1.0",
"ts-node": "^8.5.0", "ts-node": "^8.5.0",

View File

@ -1,3 +1,3 @@
import configureStore from 'redux-mock-store' import configureStore from 'redux-mock-store'
import { middlewares } from '../middlewares.js' import { middlewares } from '../middlewares'
export default configureStore(middlewares)({}) export default configureStore(middlewares)({})

View File

@ -1,5 +1,3 @@
import Promise from 'bluebird'
export const createObjectURL = jest.fn() export const createObjectURL = jest.fn()
.mockImplementation(object => 'blob://' + String(object)) .mockImplementation(object => 'blob://' + String(object))
export const revokeObjectURL = jest.fn() export const revokeObjectURL = jest.fn()

View File

@ -1,23 +1,26 @@
jest.mock('../socket.js') jest.mock('../socket')
jest.mock('../window.js') jest.mock('../window')
jest.mock('../store.js') jest.mock('../store')
jest.mock('./SocketActions.js') jest.mock('./SocketActions')
import * as CallActions from './CallActions.js' import * as CallActions from './CallActions'
import * as SocketActions from './SocketActions.js' import * as SocketActions from './SocketActions'
import * as constants from '../constants.js' import * as constants from '../constants'
import socket from '../socket.js' import socket from '../socket'
import store from '../store.js' import storeMock from '../store'
import { callId, getUserMedia } from '../window.js' import { callId, getUserMedia } from '../window'
import { MockStore } from 'redux-mock-store'
jest.useFakeTimers() jest.useFakeTimers()
describe('reducers/alerts', () => { describe('reducers/alerts', () => {
const store: MockStore = storeMock as any
beforeEach(() => { beforeEach(() => {
store.clearActions() store.clearActions();
getUserMedia.fail(false) (getUserMedia as any).fail(false);
SocketActions.handshake.mockReturnValue(jest.fn()) (SocketActions.handshake as jest.Mock).mockReturnValue(jest.fn())
}) })
afterEach(() => { afterEach(() => {
@ -28,81 +31,80 @@ describe('reducers/alerts', () => {
describe('init', () => { describe('init', () => {
it('calls handshake.init when connected & got camera stream', async () => { it('calls handshake.init when connected & got camera stream', async () => {
const promise = store.dispatch(CallActions.init()) const promise = CallActions.init(store.dispatch, store.getState)
socket.emit('connect') socket.emit('connect')
expect(store.getActions()).toEqual([{ expect(store.getActions()).toEqual([{
type: constants.INIT_PENDING type: constants.INIT_PENDING,
}, { }, {
type: constants.NOTIFY, type: constants.NOTIFY,
payload: { payload: {
id: jasmine.any(String), id: jasmine.any(String),
message: 'Connected to server socket', message: 'Connected to server socket',
type: 'warning' type: 'warning',
} },
}, { }, {
type: constants.MESSAGE_ADD, type: constants.MESSAGE_ADD,
payload: { payload: {
image: null, image: null,
message: 'Connected to server socket', message: 'Connected to server socket',
timestamp: jasmine.any(String), timestamp: jasmine.any(String),
userId: '[PeerCalls]' userId: '[PeerCalls]',
} },
}]) }])
await promise await promise
expect(SocketActions.handshake.mock.calls).toEqual([[{ expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
socket, socket,
roomName: callId, roomName: callId,
stream: getUserMedia.stream stream: (getUserMedia as any).stream,
}]]) }]])
}) })
it('calls dispatches disconnect message on disconnect', async () => { it('calls dispatches disconnect message on disconnect', async () => {
const promise = store.dispatch(CallActions.init()) const promise = CallActions.init(store.dispatch, store.getState)
socket.emit('connect') socket.emit('connect')
socket.emit('disconnect') socket.emit('disconnect')
expect(store.getActions()).toEqual([{ expect(store.getActions()).toEqual([{
type: constants.INIT_PENDING type: constants.INIT_PENDING,
}, { }, {
type: constants.NOTIFY, type: constants.NOTIFY,
payload: { payload: {
id: jasmine.any(String), id: jasmine.any(String),
message: 'Connected to server socket', message: 'Connected to server socket',
type: 'warning' type: 'warning',
} },
}, { }, {
type: constants.MESSAGE_ADD, type: constants.MESSAGE_ADD,
payload: { payload: {
image: null, image: null,
message: 'Connected to server socket', message: 'Connected to server socket',
timestamp: jasmine.any(String), timestamp: jasmine.any(String),
userId: '[PeerCalls]' userId: '[PeerCalls]',
} },
}, { }, {
type: constants.NOTIFY, type: constants.NOTIFY,
payload: { payload: {
id: jasmine.any(String), id: jasmine.any(String),
message: 'Server socket disconnected', message: 'Server socket disconnected',
type: 'error' type: 'error',
} },
}, { }, {
type: constants.MESSAGE_ADD, type: constants.MESSAGE_ADD,
payload: { payload: {
image: null, image: null,
message: 'Server socket disconnected', message: 'Server socket disconnected',
timestamp: jasmine.any(String), timestamp: jasmine.any(String),
userId: '[PeerCalls]' userId: '[PeerCalls]',
} },
}]) }])
await promise await promise
}) })
it('dispatches alert when failed to get media stream', async () => { it('dispatches alert when failed to get media stream', async () => {
getUserMedia.fail(true) (getUserMedia as any).fail(true)
const promise = store.dispatch(CallActions.init()) const promise = CallActions.init(store.dispatch, store.getState)
socket.emit('connect') socket.emit('connect')
const result = await promise await promise
expect(result.value).toBe(null)
}) })
}) })

View File

@ -1,11 +1,11 @@
import * as NotifyActions from './NotifyActions.js' import * as NotifyActions from './NotifyActions'
import * as SocketActions from './SocketActions.js' import * as SocketActions from './SocketActions'
import * as StreamActions from './StreamActions.js' import * as StreamActions from './StreamActions'
import * as constants from '../constants.js' import * as constants from '../constants'
import socket from '../socket.js' import socket from '../socket'
import { callId, getUserMedia } from '../window.js' import { callId, getUserMedia } from '../window'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { GetState } from '../store.js' import { GetState } from '../store'
export interface InitAction { export interface InitAction {
type: 'INIT' type: 'INIT'

View File

@ -1,4 +1,4 @@
import { MESSAGE_ADD } from '../constants.js' import { MESSAGE_ADD } from '../constants'
export interface MessageAddAction { export interface MessageAddAction {
type: 'MESSAGE_ADD' type: 'MESSAGE_ADD'

View File

@ -1,5 +1,5 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import * as ChatActions from './ChatActions.js' import * as ChatActions from './ChatActions'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import _ from 'underscore' import _ from 'underscore'

View File

@ -1,11 +1,11 @@
jest.mock('../window.js') jest.mock('../window')
jest.mock('simple-peer') jest.mock('simple-peer')
import * as PeerActions from './PeerActions.js' import * as PeerActions from './PeerActions'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { createStore, Store, GetState } from '../store.js' import { createStore, Store, GetState } from '../store'
import { play } from '../window.js' import { play } from '../window'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
describe('PeerActions', () => { describe('PeerActions', () => {

View File

@ -1,11 +1,11 @@
import * as ChatActions from '../actions/ChatActions.js' import * as ChatActions from '../actions/ChatActions'
import * as NotifyActions from '../actions/NotifyActions.js' import * as NotifyActions from '../actions/NotifyActions'
import * as StreamActions from '../actions/StreamActions.js' import * as StreamActions from '../actions/StreamActions'
import * as constants from '../constants.js' import * as constants from '../constants'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import _ from 'underscore' import _ from 'underscore'
import _debug from 'debug' import _debug from 'debug'
import { play, iceServers } from '../window.js' import { play, iceServers } from '../window'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
const debug = _debug('peercalls') const debug = _debug('peercalls')

View File

@ -1,11 +1,11 @@
jest.mock('simple-peer') jest.mock('simple-peer')
jest.mock('../window.js') jest.mock('../window')
import * as SocketActions from './SocketActions.js' import * as SocketActions from './SocketActions'
import * as constants from '../constants.js' import * as constants from '../constants'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import { EventEmitter } from 'events' import { EventEmitter } from 'events'
import { createStore, Store, GetState } from '../store.js' import { createStore, Store, GetState } from '../store'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
describe('SocketActions', () => { describe('SocketActions', () => {

View File

@ -1,6 +1,6 @@
import * as NotifyActions from '../actions/NotifyActions.js' import * as NotifyActions from '../actions/NotifyActions'
import * as PeerActions from '../actions/PeerActions.js' import * as PeerActions from '../actions/PeerActions'
import * as constants from '../constants.js' import * as constants from '../constants'
import _ from 'underscore' import _ from 'underscore'
import _debug from 'debug' import _debug from 'debug'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'

View File

@ -1,4 +1,4 @@
import * as constants from '../constants.js' import * as constants from '../constants'
export interface AddStreamPayload { export interface AddStreamPayload {
userId: string userId: string
@ -56,6 +56,6 @@ export const toggleActive = (userId: string): ToggleActiveStreamAction => ({
export type StreamAction = export type StreamAction =
AddStreamAction | AddStreamAction |
RemoveStreamAction RemoveStreamAction |
// SetActiveStreamAction | SetActiveStreamAction |
// ToggleActiveStreamAction ToggleActiveStreamAction

View File

@ -1,18 +1,13 @@
import React from 'react' import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames' import classnames from 'classnames'
import { Alert as AlertType } from '../actions/NotifyActions'
export const AlertPropType = PropTypes.shape({ export interface AlertProps {
dismissable: PropTypes.bool, alert: AlertType
action: PropTypes.string.isRequired, dismiss: (alert: AlertType) => void
message: PropTypes.string.isRequired }
})
export class Alert extends React.PureComponent { export class Alert extends React.PureComponent<AlertProps> {
static propTypes = {
alert: AlertPropType,
dismiss: PropTypes.func.isRequired
}
dismiss = () => { dismiss = () => {
const { alert, dismiss } = this.props const { alert, dismiss } = this.props
dismiss(alert) dismiss(alert)
@ -34,11 +29,12 @@ export class Alert extends React.PureComponent {
} }
} }
export default class Alerts extends React.PureComponent { export interface AlertsProps {
static propTypes = { alerts: AlertType[]
alerts: PropTypes.arrayOf(AlertPropType).isRequired, dismiss: (alert: AlertType) => void
dismiss: PropTypes.func.isRequired }
}
export default class Alerts extends React.PureComponent<AlertsProps> {
render () { render () {
const { alerts, dismiss } = this.props const { alerts, dismiss } = this.props
return ( return (

View File

@ -1,42 +1,49 @@
import * as constants from '../constants.js'
import Alerts, { AlertPropType } from './Alerts.js'
import Chat, { MessagePropTypes } from './Chat.js'
import Notifications, { NotificationPropTypes } from './Notifications.js'
import PropTypes from 'prop-types'
import React from 'react' import React from 'react'
import Toolbar from './Toolbar.js' import Peer from 'simple-peer'
import Video, { StreamPropType } from './Video.js'
import _ from 'underscore' import _ from 'underscore'
import { Message } from '../actions/ChatActions'
import { Alert, Notification } from '../actions/NotifyActions'
import { TextMessage } from '../actions/PeerActions'
import { AddStreamPayload } from '../actions/StreamActions'
import * as constants from '../constants'
import Alerts from './Alerts'
import Chat from './Chat'
import Notifications from './Notifications'
import Toolbar from './Toolbar'
import Video from './Video'
export default class App extends React.PureComponent { export interface AppProps {
static propTypes = { active: string | null
active: PropTypes.string, alerts: Alert[]
alerts: PropTypes.arrayOf(AlertPropType).isRequired, dismissAlert: (alert: Alert) => void
dismissAlert: PropTypes.func.isRequired, init: () => void
init: PropTypes.func.isRequired, notifications: Record<string, Notification>
notifications: PropTypes.objectOf(NotificationPropTypes).isRequired, messages: Message[]
messages: PropTypes.arrayOf(MessagePropTypes).isRequired, peers: Record<string, Peer.Instance>
peers: PropTypes.object.isRequired, sendMessage: (message: TextMessage) => void
sendMessage: PropTypes.func.isRequired, streams: Record<string, AddStreamPayload>
streams: PropTypes.objectOf(StreamPropType).isRequired, onSendFile: (file: File) => void
onSendFile: PropTypes.func.isRequired, toggleActive: (userId: string) => void
toggleActive: PropTypes.func.isRequired }
}
constructor () { export interface AppState {
super() videos: Record<string, unknown>
this.state = { chatVisible: boolean
videos: {}, }
chatVisible: false
} export default class App extends React.PureComponent<AppProps, AppState> {
state: AppState = {
videos: {},
chatVisible: false,
} }
handleShowChat = () => { handleShowChat = () => {
this.setState({ this.setState({
chatVisible: true chatVisible: true,
}) })
} }
handleHideChat = () => { handleHideChat = () => {
this.setState({ this.setState({
chatVisible: false chatVisible: false,
}) })
} }
handleToggleChat = () => { handleToggleChat = () => {
@ -59,7 +66,7 @@ export default class App extends React.PureComponent {
peers, peers,
sendMessage, sendMessage,
toggleActive, toggleActive,
streams streams,
} = this.props } = this.props
const { videos } = this.state const { videos } = this.state
@ -79,7 +86,6 @@ export default class App extends React.PureComponent {
messages={messages} messages={messages}
onClose={this.handleHideChat} onClose={this.handleHideChat}
sendMessage={sendMessage} sendMessage={sendMessage}
videos={videos}
visible={this.state.chatVisible} visible={this.state.chatVisible}
/> />
<div className="videos"> <div className="videos">

View File

@ -1,16 +1,14 @@
import Input from './Input.js'
import PropTypes from 'prop-types'
import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import React from 'react'
import { Message as MessageType } from '../actions/ChatActions'
import { TextMessage } from '../actions/PeerActions'
import Input from './Input'
export const MessagePropTypes = PropTypes.shape({ export interface MessageProps {
userId: PropTypes.string.isRequired, message: MessageType
message: PropTypes.string.isRequired, }
timestamp: PropTypes.string.isRequired,
image: PropTypes.string
})
function Message (props) { function Message (props: MessageProps) {
const { message } = props const { message } = props
return ( return (
<p className="message-text"> <p className="message-text">
@ -22,24 +20,18 @@ function Message (props) {
) )
} }
Message.propTypes = { export interface ChatProps {
message: MessagePropTypes visible: boolean
messages: MessageType[]
onClose: () => void
sendMessage: (message: TextMessage) => void
} }
export default class Chat extends React.PureComponent { export default class Chat extends React.PureComponent<ChatProps> {
static propTypes = { chatHistoryRef = React.createRef<HTMLDivElement>()
visible: PropTypes.bool.isRequired,
messages: PropTypes.arrayOf(MessagePropTypes).isRequired,
onClose: PropTypes.func.isRequired,
sendMessage: PropTypes.func.isRequired,
videos: PropTypes.object.isRequired
}
constructor () {
super()
this.chatHistoryRef = React.createRef()
}
scrollToBottom = () => { scrollToBottom = () => {
const chatHistoryRef = this.chatHistoryRef.current const chatHistoryRef = this.chatHistoryRef.current!
chatHistoryRef.scrollTop = chatHistoryRef.scrollHeight chatHistoryRef.scrollTop = chatHistoryRef.scrollHeight
} }
componentDidMount () { componentDidMount () {
@ -49,10 +41,10 @@ export default class Chat extends React.PureComponent {
this.scrollToBottom() this.scrollToBottom()
} }
render () { render () {
const { messages, videos, sendMessage } = this.props const { messages, sendMessage } = this.props
return ( return (
<div className={classnames('chat-container', { <div className={classnames('chat-container', {
show: this.props.visible show: this.props.visible,
})}> })}>
<div className="chat-header"> <div className="chat-header">
<div className="chat-close" onClick={this.props.onClose}> <div className="chat-close" onClick={this.props.onClose}>
@ -111,10 +103,7 @@ export default class Chat extends React.PureComponent {
</div> </div>
<Input <Input sendMessage={sendMessage} />
videos={videos}
sendMessage={sendMessage}
/>
</div> </div>
) )
} }

View File

@ -1,36 +1,39 @@
import Input from './Input.js' import Input from './Input'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { TextMessage } from '../actions/PeerActions'
describe('components/Input', () => { describe('components/Input', () => {
let component, node, videos, notify, sendMessage let node: Element
function render () { let sendMessage: jest.Mock<(message: TextMessage) => void>
videos = {} async function render () {
notify = jest.fn()
sendMessage = jest.fn() sendMessage = jest.fn()
component = TestUtils.renderIntoDocument( const div = document.createElement('div')
<Input await new Promise<Input>(resolve => {
videos={videos} ReactDOM.render(
sendMessage={sendMessage} <Input
notify={notify} ref={input => resolve(input!)}
/> sendMessage={sendMessage}
) />,
node = ReactDOM.findDOMNode(component) div,
)
})
node = div.children[0]
} }
let message = 'test message' const message = 'test message'
beforeEach(() => render()) beforeEach(() => render())
describe('send message', () => { describe('send message', () => {
let input let input: HTMLTextAreaElement
beforeEach(() => { beforeEach(() => {
sendMessage.mockClear() sendMessage.mockClear()
input = node.querySelector('textarea') input = node.querySelector('textarea')!
TestUtils.Simulate.change(input, { TestUtils.Simulate.change(input, {
target: { value: message } target: { value: message } as any,
}) })
expect(input.value).toBe(message) expect(input.value).toBe(message)
}) })
@ -47,7 +50,7 @@ describe('components/Input', () => {
describe('handleKeyPress', () => { describe('handleKeyPress', () => {
it('sends a message', () => { it('sends a message', () => {
TestUtils.Simulate.keyPress(input, { TestUtils.Simulate.keyPress(input, {
key: 'Enter' key: 'Enter',
}) })
expect(input.value).toBe('') expect(input.value).toBe('')
expect(sendMessage.mock.calls) expect(sendMessage.mock.calls)
@ -56,7 +59,7 @@ describe('components/Input', () => {
it('does nothing when other key pressed', () => { it('does nothing when other key pressed', () => {
TestUtils.Simulate.keyPress(input, { TestUtils.Simulate.keyPress(input, {
key: 'test' key: 'test',
}) })
expect(sendMessage.mock.calls.length).toBe(0) expect(sendMessage.mock.calls.length).toBe(0)
}) })
@ -64,7 +67,7 @@ describe('components/Input', () => {
describe('handleSmileClick', () => { describe('handleSmileClick', () => {
it('adds smile to message', () => { it('adds smile to message', () => {
const div = node.querySelector('.chat-controls-buttons-smile') const div = node.querySelector('.chat-controls-buttons-smile')!
TestUtils.Simulate.click(div) TestUtils.Simulate.click(div)
expect(input.value).toBe('test message😑') expect(input.value).toBe('test message😑')
}) })

View File

@ -1,34 +1,37 @@
import PropTypes from 'prop-types' import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'
import React from 'react' import { TextMessage } from '../actions/PeerActions'
export default class Input extends React.PureComponent { export interface InputProps {
static propTypes = { sendMessage: (message: TextMessage) => void
sendMessage: PropTypes.func.isRequired }
export interface InputState {
message: string
}
export default class Input extends React.PureComponent<InputProps, InputState> {
textArea = React.createRef<HTMLTextAreaElement>()
state = {
message: '',
} }
constructor () { handleChange: ChangeEventHandler<HTMLTextAreaElement> = event => {
super()
this.state = {
message: ''
}
}
handleChange = e => {
this.setState({ this.setState({
message: e.target.value message: event.target.value,
}) })
} }
handleSubmit = e => { handleSubmit: ReactEventHandler<HTMLFormElement> = e => {
e.preventDefault() e.preventDefault()
this.submit() this.submit()
} }
handleKeyPress = e => { handleKeyPress: KeyboardEventHandler<HTMLTextAreaElement> = e => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
this.submit() this.submit()
} }
} }
handleSmileClick = e => { handleSmileClick: MouseEventHandler<HTMLElement> = event => {
this.setState({ this.setState({
message: this.textArea.value + e.currentTarget.innerHTML message: this.textArea.current!.value + event.currentTarget.innerHTML,
}) })
} }
submit = () => { submit = () => {
@ -37,7 +40,7 @@ export default class Input extends React.PureComponent {
if (message) { if (message) {
sendMessage({ sendMessage({
payload: message, payload: message,
type: 'text' type: 'text',
}) })
// let image = null // let image = null
@ -66,7 +69,7 @@ export default class Input extends React.PureComponent {
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
placeholder="Type a message" placeholder="Type a message"
value={message} value={message}
ref={node => { this.textArea = node }} ref={this.textArea}
/> />
<div className="chat-controls-buttons"> <div className="chat-controls-buttons">
<input type="submit" value="Send" <input type="submit" value="Send"

View File

@ -1,41 +0,0 @@
import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup'
import PropTypes from 'prop-types'
import React from 'react'
import classnames from 'classnames'
export const NotificationPropTypes = PropTypes.shape({
id: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
message: PropTypes.string.isRequired
})
export default class Notifications extends React.PureComponent {
static propTypes = {
notifications: PropTypes.objectOf(NotificationPropTypes).isRequired,
max: PropTypes.number.isRequired
}
static defaultProps = {
max: 10
}
render () {
const { notifications, max } = this.props
return (
<div className="notifications">
<CSSTransitionGroup
transitionEnterTimeout={200}
transitionLeaveTimeout={100}
transitionName="fade"
>
{Object.keys(notifications).slice(-max).map(id => (
<div
className={classnames(notifications[id].type, 'notification')}
key={id}
>
{notifications[id].message}
</div>
))}
</CSSTransitionGroup>
</div>
)
}
}

View File

@ -0,0 +1,41 @@
import CSSTransition from 'react-transition-group/CSSTransition'
import React from 'react'
import classnames from 'classnames'
import { Notification } from '../actions/NotifyActions'
export interface NotificationProps {
notifications: Record<string, Notification>
max: number
}
const transitionTimeout = {
enter: 200,
exit: 100,
}
export default class Notifications
extends React.PureComponent<NotificationProps> {
static defaultProps = {
max: 10,
}
render () {
const { notifications, max } = this.props
return (
<div className="notifications">
<CSSTransition
classNames='fade'
timeout={transitionTimeout}
>
{Object.keys(notifications).slice(-max).map(id => (
<div
className={classnames(notifications[id].type, 'notification')}
key={id}
>
{notifications[id].message}
</div>
))}
</CSSTransition>
</div>
)
}
}

View File

@ -1,17 +1,20 @@
jest.mock('../window.js') jest.mock('../window')
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import Toolbar from './Toolbar.js' import Toolbar, { ToolbarProps } from './Toolbar'
import { MediaStream } from '../window.js' import { MediaStream } from '../window'
import { AddStreamPayload } from '../actions/StreamActions'
describe('components/Toolbar', () => { describe('components/Toolbar', () => {
class ToolbarWrapper extends React.PureComponent { interface StreamState {
static propTypes = Toolbar.propTypes stream: AddStreamPayload | null
constructor () { }
super()
this.state = {} class ToolbarWrapper extends React.PureComponent<ToolbarProps, StreamState> {
state = {
stream: null,
} }
render () { render () {
return <Toolbar return <Toolbar
@ -24,31 +27,40 @@ describe('components/Toolbar', () => {
} }
} }
let component, node, mediaStream, url, onToggleChat, onSendFile let node: Element
function render () { let mediaStream: MediaStream
let url: string
let onToggleChat: jest.Mock<() => void>
let onSendFile: jest.Mock<(file: File) => void>
async function render () {
mediaStream = new MediaStream() mediaStream = new MediaStream()
onToggleChat = jest.fn() onToggleChat = jest.fn()
onSendFile = jest.fn() onSendFile = jest.fn()
component = TestUtils.renderIntoDocument( const div = document.createElement('div')
<ToolbarWrapper await new Promise<ToolbarWrapper>(resolve => {
chatVisible ReactDOM.render(
onToggleChat={onToggleChat} <ToolbarWrapper
onSendFile={onSendFile} ref={instance => resolve(instance!)}
messages={[]} chatVisible
stream={{ mediaStream, url }} onToggleChat={onToggleChat}
/> onSendFile={onSendFile}
) messages={[]}
node = ReactDOM.findDOMNode(component) stream={{ userId: '', stream: mediaStream, url }}
/>,
div,
)
})
node = div.children[0]
} }
beforeEach(() => { beforeEach(async () => {
render() await render()
}) })
describe('handleChatClick', () => { describe('handleChatClick', () => {
it('toggle chat', () => { it('toggle chat', () => {
expect(onToggleChat.mock.calls.length).toBe(0) expect(onToggleChat.mock.calls.length).toBe(0)
const button = node.querySelector('.chat') const button = node.querySelector('.chat')!
TestUtils.Simulate.click(button) TestUtils.Simulate.click(button)
expect(onToggleChat.mock.calls.length).toBe(1) expect(onToggleChat.mock.calls.length).toBe(1)
}) })
@ -56,7 +68,7 @@ describe('components/Toolbar', () => {
describe('handleMicClick', () => { describe('handleMicClick', () => {
it('toggle mic', () => { it('toggle mic', () => {
const button = node.querySelector('.mute-audio') const button = node.querySelector('.mute-audio')!
TestUtils.Simulate.click(button) TestUtils.Simulate.click(button)
expect(button.classList.contains('on')).toBe(true) expect(button.classList.contains('on')).toBe(true)
}) })
@ -64,7 +76,7 @@ describe('components/Toolbar', () => {
describe('handleCamClick', () => { describe('handleCamClick', () => {
it('toggle cam', () => { it('toggle cam', () => {
const button = node.querySelector('.mute-video') const button = node.querySelector('.mute-video')!
TestUtils.Simulate.click(button) TestUtils.Simulate.click(button)
expect(button.classList.contains('on')).toBe(true) expect(button.classList.contains('on')).toBe(true)
}) })
@ -72,7 +84,7 @@ describe('components/Toolbar', () => {
describe('handleFullscreenClick', () => { describe('handleFullscreenClick', () => {
it('toggle fullscreen', () => { it('toggle fullscreen', () => {
const button = node.querySelector('.fullscreen') const button = node.querySelector('.fullscreen')!
TestUtils.Simulate.click(button) TestUtils.Simulate.click(button)
expect(button.classList.contains('on')).toBe(false) expect(button.classList.contains('on')).toBe(false)
}) })
@ -80,7 +92,7 @@ describe('components/Toolbar', () => {
describe('handleHangoutClick', () => { describe('handleHangoutClick', () => {
it('hangout', () => { it('hangout', () => {
const button = node.querySelector('.hangup') const button = node.querySelector('.hangup')!
TestUtils.Simulate.click(button) TestUtils.Simulate.click(button)
expect(window.location.href).toBe('http://localhost/') expect(window.location.href).toBe('http://localhost/')
}) })
@ -88,10 +100,10 @@ describe('components/Toolbar', () => {
describe('handleSendFile', () => { describe('handleSendFile', () => {
it('triggers input dialog', () => { it('triggers input dialog', () => {
const sendFileButton = node.querySelector('.send-file') const sendFileButton = node.querySelector('.send-file')!
const click = jest.fn() const click = jest.fn()
const file = node.querySelector('input[type=file]') const file = node.querySelector('input[type=file]')!;
file.click = click (file as any).click = click
TestUtils.Simulate.click(sendFileButton) TestUtils.Simulate.click(sendFileButton)
expect(click.mock.calls.length).toBe(1) expect(click.mock.calls.length).toBe(1)
}) })
@ -99,12 +111,12 @@ describe('components/Toolbar', () => {
describe('handleSelectFiles', () => { describe('handleSelectFiles', () => {
it('iterates through files and calls onSendFile for each', () => { it('iterates through files and calls onSendFile for each', () => {
const file = node.querySelector('input[type=file]') const file = node.querySelector('input[type=file]')!
const files = [{ name: 'first' }] const files = [{ name: 'first' }] as any
TestUtils.Simulate.change(file, { TestUtils.Simulate.change(file, {
target: { target: {
files files,
} } as any,
}) })
expect(onSendFile.mock.calls).toEqual([[ files[0] ]]) expect(onSendFile.mock.calls).toEqual([[ files[0] ]])
}) })

View File

@ -1,63 +1,85 @@
import PropTypes from 'prop-types' import React, { ReactEventHandler } from 'react'
import React from 'react'
import classnames from 'classnames' import classnames from 'classnames'
import screenfull from 'screenfull' import screenfull from 'screenfull'
import { MessagePropTypes } from './Chat.js' import { Message } from '../actions/ChatActions'
import { StreamPropType } from './Video.js' import { AddStreamPayload } from '../actions/StreamActions'
const hidden = { const hidden = {
display: 'none' display: 'none',
} }
export default class Toolbar extends React.PureComponent { export interface ToolbarProps {
static propTypes = { messages: Message[]
messages: PropTypes.arrayOf(MessagePropTypes).isRequired, stream: AddStreamPayload
stream: StreamPropType, onToggleChat: () => void
onToggleChat: PropTypes.func.isRequired, onSendFile: (file: File) => void
onSendFile: PropTypes.func.isRequired, chatVisible: boolean
chatVisible: PropTypes.bool.isRequired }
}
constructor (props) { export interface ToolbarState {
readMessages: number
camDisabled: boolean
micMuted: boolean
fullScreenEnabled: boolean
}
export default class Toolbar
extends React.PureComponent<ToolbarProps, ToolbarState> {
file = React.createRef<HTMLInputElement>()
constructor(props: ToolbarProps) {
super(props) super(props)
this.file = React.createRef()
this.state = { this.state = {
readMessages: props.messages.length readMessages: props.messages.length,
camDisabled: false,
micMuted: false,
fullScreenEnabled: false,
} }
} }
handleMicClick = () => { handleMicClick = () => {
const { stream } = this.props const { stream } = this.props
stream.mediaStream.getAudioTracks().forEach(track => { stream.stream.getAudioTracks().forEach(track => {
track.enabled = !track.enabled track.enabled = !track.enabled
}) })
this.mixButton.classList.toggle('on') this.setState({
...this.state,
micMuted: !this.state.micMuted,
})
} }
handleCamClick = () => { handleCamClick = () => {
const { stream } = this.props const { stream } = this.props
stream.mediaStream.getVideoTracks().forEach(track => { stream.stream.getVideoTracks().forEach(track => {
track.enabled = !track.enabled track.enabled = !track.enabled
}) })
this.camButton.classList.toggle('on') this.setState({
...this.state,
camDisabled: !this.state.camDisabled,
})
} }
handleFullscreenClick = () => { handleFullscreenClick = () => {
if (screenfull.enabled) { if (screenfull.enabled) {
screenfull.toggle() screenfull.toggle()
this.fullscreenButton.classList.toggle('on') this.setState({
...this.state,
fullScreenEnabled: !this.state.fullScreenEnabled,
})
} }
} }
handleHangoutClick = () => { handleHangoutClick = () => {
window.location.href = '/' window.location.href = '/'
} }
handleSendFile = () => { handleSendFile = () => {
this.file.current.click() this.file.current!.click()
} }
handleSelectFiles = (event) => { handleSelectFiles: ReactEventHandler<HTMLInputElement> = event => {
Array Array
.from(event.target.files) .from(this.file.current!.files!)
.forEach(file => this.props.onSendFile(file)) .forEach(file => this.props.onSendFile(file))
} }
handleToggleChat = () => { handleToggleChat = () => {
this.setState({ this.setState({
readMessages: this.props.messages.length readMessages: this.props.messages.length,
}) })
this.props.onToggleChat() this.props.onToggleChat()
} }
@ -68,7 +90,7 @@ export default class Toolbar extends React.PureComponent {
<div className="toolbar active"> <div className="toolbar active">
<div onClick={this.handleToggleChat} <div onClick={this.handleToggleChat}
className={classnames('button chat', { className={classnames('button chat', {
on: this.props.chatVisible on: this.props.chatVisible,
})} })}
data-blink={!this.props.chatVisible && data-blink={!this.props.chatVisible &&
messages.length > this.state.readMessages} messages.length > this.state.readMessages}
@ -93,17 +115,20 @@ export default class Toolbar extends React.PureComponent {
{stream && ( {stream && (
<div> <div>
<div onClick={this.handleMicClick} <div
ref={node => { this.mixButton = node }} onClick={this.handleMicClick}
className="button mute-audio" className={classnames('button mute-audio', {
on: this.state.micMuted,
})}
title="Mute audio" title="Mute audio"
> >
<span className="on icon icon-mic_off" /> <span className="on icon icon-mic_off" />
<span className="off icon icon-mic" /> <span className="off icon icon-mic" />
</div> </div>
<div onClick={this.handleCamClick} <div onClick={this.handleCamClick}
ref={node => { this.camButton = node }} className={classnames('button mute-video', {
className="button mute-video" on: this.state.camDisabled,
})}
title="Mute video" title="Mute video"
> >
<span className="on icon icon-videocam_off" /> <span className="on icon icon-videocam_off" />
@ -113,8 +138,9 @@ export default class Toolbar extends React.PureComponent {
)} )}
<div onClick={this.handleFullscreenClick} <div onClick={this.handleFullscreenClick}
ref={node => { this.fullscreenButton = node }} className={classnames('button fullscreen', {
className="button fullscreen" on: this.state.fullScreenEnabled,
})}
title="Enter fullscreen" title="Enter fullscreen"
> >
<span className="on icon icon-fullscreen_exit" /> <span className="on icon icon-fullscreen_exit" />

View File

@ -1,90 +0,0 @@
jest.mock('../window.js')
import React from 'react'
import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import Video from './Video.js'
import { MediaStream } from '../window.js'
describe('components/Video', () => {
class VideoWrapper extends React.PureComponent {
static propTypes = Video.propTypes
constructor () {
super()
this.state = {}
}
render () {
return <Video
videos={this.props.videos}
active={this.props.active}
stream={this.state.stream || this.props.stream}
onClick={this.props.onClick}
userId="test"
muted={this.props.muted}
mirrored={this.props.mirrored}
/>
}
}
let component, videos, video, onClick, mediaStream, url, wrapper
function render (flags = {}) {
videos = {}
onClick = jest.fn()
mediaStream = new MediaStream()
component = TestUtils.renderIntoDocument(
<VideoWrapper
videos={videos}
active={flags.active || false}
stream={{ mediaStream, url }}
onClick={onClick}
userId="test"
muted={flags.muted || false}
mirrored={flags.mirrored}
/>
)
wrapper = ReactDOM.findDOMNode(component)
video = TestUtils.findRenderedComponentWithType(component, Video)
}
describe('render', () => {
it('should not fail', () => {
render({})
})
it('Mirrored and active propogate to rendered classes', () => {
render({ active: true, mirrored: true })
expect(wrapper.className).toBe('video-container active mirrored')
})
})
describe('componentDidUpdate', () => {
describe('src', () => {
beforeEach(() => {
render()
delete video.refs.video.srcObject
})
it('updates src only when changed', () => {
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', mediaStream }
})
expect(video.refs.video.src).toBe('http://localhost/test')
component.setState({
stream: { url: 'test', mediaStream }
})
})
it('updates srcObject only when changed', () => {
video.refs.video.srcObject = null
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', mediaStream }
})
expect(video.refs.video.srcObject).toBe(mediaStream)
component.setState({
stream: { url: 'test', mediaStream }
})
})
})
})
})

View File

@ -0,0 +1,120 @@
jest.mock('../window')
import React from 'react'
import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import { AddStreamPayload } from '../actions/StreamActions'
import Video, { VideoProps } from './Video'
import { MediaStream } from '../window'
describe('components/Video', () => {
interface VideoState {
stream: null | AddStreamPayload
}
class VideoWrapper extends React.PureComponent<VideoProps, VideoState> {
ref = React.createRef<Video>()
state = {
stream: null,
}
render () {
return <Video
ref={this.ref}
videos={this.props.videos}
active={this.props.active}
stream={this.state.stream || this.props.stream}
onClick={this.props.onClick}
userId="test"
muted={this.props.muted}
mirrored={this.props.mirrored}
/>
}
}
let component: VideoWrapper
let videos: Record<string, unknown> = {}
let video: Video
let onClick: (userId: string) => void
let mediaStream: MediaStream
let url: string
let wrapper: Element
interface Flags {
active: boolean
muted: boolean
mirrored: boolean
}
const defaultFlags: Flags = {
active: false,
muted: false,
mirrored: false,
}
async function render (args?: Partial<Flags>) {
const flags: Flags = Object.assign({}, defaultFlags, args)
videos = {}
onClick = jest.fn()
mediaStream = new MediaStream()
const div = document.createElement('div')
component = await new Promise<VideoWrapper>(resolve => {
ReactDOM.render(
<VideoWrapper
ref={instance => resolve(instance!)}
videos={videos}
active={flags.active}
stream={{ stream: mediaStream, url, userId: 'test' }}
onClick={onClick}
userId="test"
muted={flags.muted}
mirrored={flags.mirrored}
/>,
div,
)
})
video = TestUtils.findRenderedComponentWithType(component, Video)
wrapper = div.children[0]
}
describe('render', () => {
it('should not fail', async () => {
await render()
})
it('Mirrored and active propogate to rendered classes', () => {
render({ active: true, mirrored: true })
expect(wrapper.className).toBe('video-container active mirrored')
})
})
describe('componentDidUpdate', () => {
describe('src', () => {
beforeEach(async () => {
await render()
delete video.video.current!.srcObject
})
it('updates src only when changed', () => {
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' },
})
expect(video.video.current!.src).toBe('http://localhost/test')
component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' },
})
})
it('updates srcObject only when changed', () => {
video.video.current!.srcObject = null
mediaStream = new MediaStream()
component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' },
})
expect(video.video.current!.srcObject).toBe(mediaStream)
component.setState({
stream: { url: 'test', stream: mediaStream, userId: '' },
})
})
})
})
})

View File

@ -1,69 +0,0 @@
import PropTypes from 'prop-types'
import React from 'react'
import classnames from 'classnames'
import { MediaStream } from '../window.js'
import socket from '../socket.js'
export const StreamPropType = PropTypes.shape({
mediaStream: PropTypes.instanceOf(MediaStream).isRequired,
url: PropTypes.string
})
export default class Video extends React.PureComponent {
static propTypes = {
videos: PropTypes.object.isRequired,
onClick: PropTypes.func,
active: PropTypes.bool.isRequired,
stream: StreamPropType,
userId: PropTypes.string.isRequired,
muted: PropTypes.bool.isRequired,
mirrored: PropTypes.bool
}
static defaultProps = {
muted: false,
mirrored: false
}
handleClick = e => {
const { onClick, userId } = this.props
this.play(e)
onClick(userId)
}
play = e => {
e.preventDefault()
e.target.play()
}
componentDidMount () {
this.componentDidUpdate()
}
componentDidUpdate () {
const { videos, stream } = this.props
const { video } = this.refs
const mediaStream = stream && stream.mediaStream
const url = stream && stream.url
if ('srcObject' in video) {
if (video.srcObject !== mediaStream) {
this.refs.video.srcObject = mediaStream
}
} else if (video.src !== url) {
video.src = url
}
videos[socket.id] = video
}
render () {
const { active, mirrored, muted } = this.props
const className = classnames('video-container', { active, mirrored })
return (
<div className={className}>
<video
id={`video-${socket.id}`}
autoPlay
onClick={this.handleClick}
onLoadedMetadata={this.play}
playsInline
ref="video"
muted={muted}
/>
</div>
)
}
}

View File

@ -0,0 +1,66 @@
import React, { ReactEventHandler } from 'react'
import classnames from 'classnames'
import socket from '../socket'
import { AddStreamPayload } from '../actions/StreamActions'
export interface VideoProps {
videos: Record<string, unknown>
onClick: (userId: string) => void
active: boolean
stream?: AddStreamPayload
userId: string
muted: boolean
mirrored: boolean
}
export default class Video extends React.PureComponent<VideoProps> {
video = React.createRef<HTMLVideoElement>()
static defaultProps = {
muted: false,
mirrored: false,
}
handleClick: ReactEventHandler<HTMLVideoElement> = e => {
const { onClick, userId } = this.props
this.play(e)
onClick(userId)
}
play: ReactEventHandler<HTMLVideoElement> = e => {
e.preventDefault();
(e.target as HTMLVideoElement).play()
}
componentDidMount () {
this.componentDidUpdate()
}
componentDidUpdate () {
const { videos, stream } = this.props
const video = this.video.current!
const mediaStream = stream && stream.stream || null
const url = stream && stream.url
if (!('srcObject' in video as unknown)) {
if (video.srcObject !== mediaStream) {
video.srcObject = mediaStream
}
} else if (video.src !== url) {
video.src = url || ''
}
videos[socket.id] = video
}
render () {
const { active, mirrored, muted } = this.props
const className = classnames('video-container', { active, mirrored })
return (
<div className={className}>
<video
id={`video-${socket.id}`}
autoPlay
onClick={this.handleClick}
onLoadedMetadata={this.play}
playsInline
ref={this.video}
muted={muted}
/>
</div>
)
}
}

View File

@ -1,18 +1,18 @@
jest.mock('../actions/CallActions.js') jest.mock('../actions/CallActions')
jest.mock('../socket.js') jest.mock('../socket')
jest.mock('../window.js') jest.mock('../window')
import * as constants from '../constants.js' import * as constants from '../constants'
import App from './App.js' import App from './App'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import configureStore from 'redux-mock-store' import configureStore from 'redux-mock-store'
import reducers from '../reducers' import reducers from '../reducers'
import { MediaStream } from '../window.js' import { MediaStream } from '../window'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { init } from '../actions/CallActions.js' import { init } from '../actions/CallActions'
import { middlewares } from '../store.js' import { middlewares } from '../store'
describe('App', () => { describe('App', () => {

View File

@ -1,29 +1,30 @@
import * as CallActions from '../actions/CallActions.js' import * as CallActions from '../actions/CallActions'
import * as NotifyActions from '../actions/NotifyActions.js' import * as NotifyActions from '../actions/NotifyActions'
import * as PeerActions from '../actions/PeerActions.js' import * as PeerActions from '../actions/PeerActions'
import * as StreamActions from '../actions/StreamActions.js' import * as StreamActions from '../actions/StreamActions'
import App from '../components/App.js' import App from '../components/App'
import { bindActionCreators } from 'redux' import { bindActionCreators, Dispatch } from 'redux'
import { connect } from 'react-redux' import { connect } from 'react-redux'
import { State } from '../store'
function mapStateToProps (state) { function mapStateToProps (state: State) {
return { return {
streams: state.streams, streams: state.streams,
peers: state.peers, peers: state.peers,
alerts: state.alerts, alerts: state.alerts,
notifications: state.notifications, notifications: state.notifications,
messages: state.messages, messages: state.messages,
active: state.active active: state.active,
} }
} }
function mapDispatchToProps (dispatch) { function mapDispatchToProps (dispatch: Dispatch) {
return { return {
toggleActive: bindActionCreators(StreamActions.toggleActive, dispatch), toggleActive: bindActionCreators(StreamActions.toggleActive, dispatch),
sendMessage: bindActionCreators(PeerActions.sendMessage, dispatch), sendMessage: bindActionCreators(PeerActions.sendMessage, dispatch),
dismissAlert: bindActionCreators(NotifyActions.dismissAlert, dispatch), dismissAlert: bindActionCreators(NotifyActions.dismissAlert, dispatch),
init: bindActionCreators(CallActions.init, dispatch), init: bindActionCreators(CallActions.init, dispatch),
onSendFile: bindActionCreators(PeerActions.sendFile, dispatch) onSendFile: bindActionCreators(PeerActions.sendFile, dispatch),
} }
} }

View File

@ -1,49 +1,48 @@
const iceServers = require('./iceServers.js') import { config, ICEServer } from '../../server/config'
function noop () {} async function checkTURNServer (turnConfig: ICEServer, timeoutDuration = 5000) {
function checkTURNServer (turnConfig, timeout) {
console.log('checking turn server', turnConfig) console.log('checking turn server', turnConfig)
return new Promise(function (resolve, reject) { const timeout = new Promise<unknown>((resolve, reject) => {
setTimeout(function () { setTimeout(function () {
if (promiseResolved) return reject(new Error('timed out'))
resolve(false) }, timeoutDuration)
promiseResolved = true })
}, timeout || 5000)
let promiseResolved = false async function start() {
const PeerConnection = window.RTCPeerConnection || const PeerConnection = window.RTCPeerConnection ||
window.mozRTCPeerConnection || (
window as unknown as { mozRTCPeerConnection: RTCPeerConnection }
).mozRTCPeerConnection||
window.webkitRTCPeerConnection window.webkitRTCPeerConnection
const pc = new PeerConnection({ iceServers: [turnConfig] }) const pc = new PeerConnection({ iceServers: [turnConfig] })
// create a bogus data channel // create a bogus data channel
pc.createDataChannel('') pc.createDataChannel('')
pc.createOffer(function (sdp) { const sdp = await pc.createOffer()
// sometimes sdp contains the ice candidates... // sometimes sdp contains the ice candidates...
if (sdp.sdp.indexOf('typ relay') > -1) { if (sdp.sdp!.indexOf('typ relay') > -1) {
promiseResolved = true return true
}
pc.setLocalDescription(sdp)
return new Promise(resolve => {
pc.onicecandidate = function (ice) {
if (!ice ||
!ice.candidate ||
!ice.candidate.candidate ||
!(ice.candidate.candidate.indexOf('typ relay') > -1)) {
return
}
resolve(true) resolve(true)
} }
pc.setLocalDescription(sdp, noop, noop) })
}, noop) }
pc.onicecandidate = function (ice) { return Promise.race([ timeout, start() ])
if (promiseResolved ||
!ice ||
!ice.candidate ||
!ice.candidate.candidate ||
!(ice.candidate.candidate.indexOf('typ relay') > -1)) {
return
}
promiseResolved = true
resolve(true)
}
})
} }
checkTURNServer(iceServers[0], 10000) checkTURNServer(config.iceServers[0], 10000)
.then(console.log.bind(console)) .then(console.log.bind(console))
.catch(console.error.bind(console)) .catch(console.error.bind(console))

View File

@ -2,9 +2,9 @@ import '@babel/polyfill'
import App from './containers/App' import App from './containers/App'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import store from './store.js' import store from './store'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { play } from './window.js' import { play } from './window'
const component = ( const component = (
<Provider store={store}> <Provider store={store}>

View File

@ -1,5 +1,5 @@
import logger from 'redux-logger' import logger from 'redux-logger'
import { create } from './middlewares.js' import { create } from './middlewares'
describe('store', () => { describe('store', () => {

View File

@ -1,12 +1,12 @@
import * as StreamActions from '../actions/StreamActions.js' import * as StreamActions from '../actions/StreamActions'
import active from './active.js' import active from './active'
describe('reducers/active', () => { describe('reducers/active', () => {
describe('setActive', () => { describe('setActive', () => {
it('sets active to userId', () => { it('sets active to userId', () => {
const userId = 'test' const userId = 'test'
let state = active() let state = active(null, {type: 'test'} as any)
state = active(state, StreamActions.setActive(userId)) state = active(state, StreamActions.setActive(userId))
expect(state).toBe(userId) expect(state).toBe(userId)
}) })
@ -15,7 +15,7 @@ describe('reducers/active', () => {
describe('toggleActive', () => { describe('toggleActive', () => {
it('sets active to userId', () => { it('sets active to userId', () => {
const userId = 'test' const userId = 'test'
let state = active() let state = active(null, {type: 'test'} as any)
state = active(state, StreamActions.toggleActive(userId)) state = active(state, StreamActions.toggleActive(userId))
expect(state).toBe(userId) expect(state).toBe(userId)
state = active(state, StreamActions.toggleActive(userId)) state = active(state, StreamActions.toggleActive(userId))

View File

@ -1,6 +1,12 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import { StreamAction } from '../actions/StreamActions'
export default function active (state = null, action: Action) { export type ActiveState = null | string
export default function active (
state: ActiveState = null,
action: StreamAction,
): ActiveState {
switch (action && action.type) { switch (action && action.type) {
case constants.ACTIVE_SET: case constants.ACTIVE_SET:
case constants.STREAM_ADD: case constants.STREAM_ADD:

View File

@ -1,38 +1,34 @@
import * as NotifyActions from '../actions/NotifyActions.js' import * as NotifyActions from '../actions/NotifyActions'
import _ from 'underscore' import _ from 'underscore'
import { applyMiddleware, createStore } from 'redux' import { applyMiddleware, createStore, Store } from 'redux'
import { create } from '../middlewares.js' import { create } from '../middlewares'
import reducers from './index.js' import reducers from './index'
jest.useFakeTimers() jest.useFakeTimers()
describe('reducers/alerts', () => { describe('reducers/alerts', () => {
let store let store: Store
beforeEach(() => { beforeEach(() => {
store = createStore( store = createStore(
reducers, reducers,
applyMiddleware.apply(null, create()) applyMiddleware(...create()),
) )
}) })
describe('clearAlert', () => { describe('clearAlert', () => {
const actions = { [true, false].forEach(dismissable => {
true: 'Dismiss',
false: ''
}
;[true, false].forEach(dismissable => {
beforeEach(() => { beforeEach(() => {
store.dispatch(NotifyActions.clearAlerts()) store.dispatch(NotifyActions.clearAlerts())
}) })
it('adds alert to store', () => { it('adds alert to store', () => {
store.dispatch(NotifyActions.alert('test', dismissable)) store.dispatch(NotifyActions.alert('test', dismissable))
expect(store.getState().alerts).toEqual([{ expect(store.getState().alerts).toEqual([{
action: actions[dismissable], action: dismissable ? 'Dismiss' : undefined,
dismissable, dismissable,
message: 'test', message: 'test',
type: 'warning' type: 'warning',
}]) }])
}) })
}) })
@ -51,25 +47,36 @@ describe('reducers/alerts', () => {
it('does not remove an alert when not found', () => { it('does not remove an alert when not found', () => {
store.dispatch(NotifyActions.alert('test', true)) store.dispatch(NotifyActions.alert('test', true))
expect(store.getState().alerts.length).toBe(1) expect(store.getState().alerts.length).toBe(1)
store.dispatch(NotifyActions.dismissAlert({})) store.dispatch(NotifyActions.dismissAlert({
action: undefined,
dismissable: false,
message: 'bla',
type: 'error',
}))
expect(store.getState().alerts.length).toBe(1) expect(store.getState().alerts.length).toBe(1)
}) })
}) })
;['info', 'warning', 'error'].forEach(type => { const methods: Array<'info' | 'warning' | 'error'> = [
'info',
'warning',
'error',
]
methods.forEach(type => {
describe(type, () => { describe(type, () => {
beforeEach(() => { beforeEach(() => {
store.dispatch(NotifyActions[type]('Hi {0}!', 'John')) NotifyActions[type]('Hi {0}!', 'John')(store.dispatch)
}) })
it('adds a notification', () => { it('adds a notification', () => {
expect(_.values(store.getState().notifications)).toEqual([{ expect(_.values(store.getState().notifications)).toEqual([{
id: jasmine.any(String), id: jasmine.any(String),
message: 'Hi John!', message: 'Hi John!',
type type,
}]) }])
}) })
@ -79,7 +86,7 @@ describe('reducers/alerts', () => {
}) })
it('does not fail when no arguments', () => { it('does not fail when no arguments', () => {
store.dispatch(NotifyActions[type]()) NotifyActions[type]()(store.dispatch)
}) })
}) })
@ -89,9 +96,9 @@ describe('reducers/alerts', () => {
describe('clear', () => { describe('clear', () => {
it('clears all alerts', () => { it('clears all alerts', () => {
store.dispatch(NotifyActions.info('Hi {0}!', 'John')) NotifyActions.info('Hi {0}!', 'John')(store.dispatch)
store.dispatch(NotifyActions.warning('Hi {0}!', 'John')) NotifyActions.warning('Hi {0}!', 'John')(store.dispatch)
store.dispatch(NotifyActions.error('Hi {0}!', 'John')) NotifyActions.error('Hi {0}!', 'John')(store.dispatch)
store.dispatch(NotifyActions.clear()) store.dispatch(NotifyActions.clear())
expect(store.getState().notifications).toEqual({}) expect(store.getState().notifications).toEqual({})
}) })

View File

@ -1,14 +1,14 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import Immutable from 'seamless-immutable' import { AlertActionType, Alert } from '../actions/NotifyActions'
const defaultState = Immutable([]) export type AlertState = Alert[]
export default function alerts (state = defaultState, action) { const defaultState: AlertState = []
switch (action && action.type) {
export default function alerts (state = defaultState, action: AlertActionType) {
switch (action.type) {
case constants.ALERT: case constants.ALERT:
const alerts = state.asMutable() return [...state, action.payload]
alerts.push(action.payload)
return Immutable(alerts)
case constants.ALERT_DISMISS: case constants.ALERT_DISMISS:
return state.filter(a => a !== action.payload) return state.filter(a => a !== action.payload)
case constants.ALERT_CLEAR: case constants.ALERT_CLEAR:

View File

@ -1,9 +1,9 @@
import active from './active.js' import active from './active'
import alerts from './alerts.js' import alerts from './alerts'
import notifications from './notifications.js' import notifications from './notifications'
import messages from './messages.js' import messages from './messages'
import peers from './peers.js' import peers from './peers'
import streams from './streams.js' import streams from './streams'
import { combineReducers } from 'redux' import { combineReducers } from 'redux'
export default combineReducers({ export default combineReducers({
@ -12,5 +12,5 @@ export default combineReducers({
notifications, notifications,
messages, messages,
peers, peers,
streams streams,
}) })

View File

@ -1,17 +1,16 @@
import * as ChatActions from '../actions/ChatActions.js' import * as ChatActions from '../actions/ChatActions'
import messages from './messages.js' import messages from './messages'
describe('reducers/messages', () => { describe('reducers/messages', () => {
describe('addMessage', () => { describe('addMessage', () => {
it('add message to chat', () => { it('add message to chat', () => {
const payload = { const payload: ChatActions.Message = {
userId: 'test', userId: 'test',
message: 'hello', message: 'hello',
timestamp: new Date(), timestamp: new Date().toLocaleString(),
image: null
} }
let state = messages() let state = messages(undefined, {type: 'test'} as any)
state = messages(state, ChatActions.addMessage(payload)) state = messages(state, ChatActions.addMessage(payload))
expect(state).toEqual([payload]) expect(state).toEqual([payload])
}) })

View File

@ -1,5 +1,5 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import { Message, MessageAddAction } from '../actions/ChatActions.js' import { Message, MessageAddAction } from '../actions/ChatActions'
export type MessagesState = Message[] export type MessagesState = Message[]

View File

@ -1,16 +1,28 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import Immutable from 'seamless-immutable' import { Notification, NotificationActionType } from '../actions/NotifyActions'
const defaultState = Immutable({}) export type NotificationState = Record<string, Notification>
export default function notifications (state = defaultState, action) { const defaultState: NotificationState = {}
switch (action && action.type) {
export default function notifications (
state = defaultState,
action: NotificationActionType,
) {
switch (action.type) {
case constants.NOTIFY: case constants.NOTIFY:
return state.merge({ return {
[action.payload.id]: action.payload ...state,
}) [action.payload.id]: action.payload,
}
case constants.NOTIFY_DISMISS: case constants.NOTIFY_DISMISS:
return state.without(action.payload.id) return Object
.keys(state)
.filter(key => key !== action.payload.id)
.reduce((obj, key) => {
obj[key] = state[key]
return obj
}, {} as NotificationState)
case constants.NOTIFY_CLEAR: case constants.NOTIFY_CLEAR:
return defaultState return defaultState
default: default:

View File

@ -1,4 +1,4 @@
import * as constants from '../constants.js' import * as constants from '../constants'
import _ from 'underscore' import _ from 'underscore'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import { PeerAction } from '../actions/PeerActions' import { PeerAction } from '../actions/PeerActions'

View File

@ -1,10 +1,10 @@
jest.mock('../window.js') jest.mock('../window')
import * as StreamActions from '../actions/StreamActions.js' import * as StreamActions from '../actions/StreamActions'
import reducers from './index.js' import reducers from './index'
import { createObjectURL, MediaStream } from '../window.js' import { createObjectURL, MediaStream } from '../window'
import { applyMiddleware, createStore, Store } from 'redux' import { applyMiddleware, createStore, Store } from 'redux'
import { create } from '../middlewares.js' import { create } from '../middlewares'
describe('reducers/alerts', () => { describe('reducers/alerts', () => {

View File

@ -1,8 +1,8 @@
import _ from 'underscore' import _ from 'underscore'
import { createObjectURL, revokeObjectURL } from '../window.js' import { createObjectURL, revokeObjectURL } from '../window'
import _debug from 'debug' import _debug from 'debug'
import { AddStreamPayload, AddStreamAction, RemoveStreamAction, StreamAction } from '../actions/StreamActions.js' import { AddStreamPayload, AddStreamAction, RemoveStreamAction, StreamAction } from '../actions/StreamActions'
import { STREAM_ADD, STREAM_REMOVE } from '../constants.js' import { STREAM_ADD, STREAM_REMOVE } from '../constants'
const debug = _debug('peercalls') const debug = _debug('peercalls')
const defaultState = Object.freeze({}) const defaultState = Object.freeze({})

View File

@ -1,3 +1,3 @@
import SocketIOClient from 'socket.io-client' import SocketIOClient from 'socket.io-client'
import { baseUrl } from './window.js' import { baseUrl } from './window'
export default SocketIOClient('', { path: baseUrl + '/ws' }) export default SocketIOClient('', { path: baseUrl + '/ws' })

View File

@ -1,4 +1,4 @@
import { create } from './middlewares.js' import { create } from './middlewares'
import reducers from './reducers' import reducers from './reducers'
import { applyMiddleware, createStore as _createStore, Store as ReduxStore } from 'redux' import { applyMiddleware, createStore as _createStore, Store as ReduxStore } from 'redux'
export const middlewares = create( export const middlewares = create(

View File

@ -5,7 +5,7 @@ import {
navigator, navigator,
play, play,
valueOf, valueOf,
} from './window.js' } from './window'
describe('window', () => { describe('window', () => {

10
src/screenfull.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare module 'screenfull' {
interface Screenfull {
enabled: boolean
toggle: () => void
}
declare const screenfull: Screenfull
export = screenfull
}

View File

@ -154,11 +154,11 @@ body.call {
transition: opacity 200ms ease-in; transition: opacity 200ms ease-in;
} }
.fade-leave { .fade-exit {
opacity: 1; opacity: 1;
} }
.fade-leave.fade-leave-active { .fade-exit.fade-exit-active {
opacity: 0.01; opacity: 0.01;
transition: opacity 100ms ease-in; transition: opacity 100ms ease-in;
} }