Do not join call automatically

Present a user with a menu to join call manually
This commit is contained in:
Jerko Steiner 2019-11-16 23:49:12 -03:00
parent 22380ea381
commit a8f3757d53
12 changed files with 185 additions and 145 deletions

View File

@ -55,6 +55,8 @@ overrides:
- files:
- '*.test.ts'
- '*.test.tsx'
- '**/__mocks__/*.ts'
- '**/__mocks__/*.tsx'
rules:
'@typescript-eslint/no-explicit-any': off
- files:

View File

@ -14,18 +14,16 @@ export class MediaStream {
}]
}
}
export function getUserMedia () {
return !getUserMedia.shouldFail
? Promise.resolve(getUserMedia.stream)
: Promise.reject(new Error('test'))
}
getUserMedia.shouldFail = false
getUserMedia.fail = (shouldFail: boolean) =>
getUserMedia.shouldFail = shouldFail
getUserMedia.stream = new MediaStream()
export const navigator = window.navigator
;(window as any).navigator.mediaDevices = {}
window.navigator.mediaDevices.enumerateDevices = async () => {
return []
}
window.navigator.mediaDevices.getUserMedia = async () => {
return {} as any
}
export const play = jest.fn()
export const valueOf = jest.fn()

View File

@ -6,7 +6,7 @@ import * as CallActions from './CallActions'
import * as SocketActions from './SocketActions'
import * as constants from '../constants'
import socket from '../socket'
import { callId, getUserMedia } from '../window'
import { callId } from '../window'
import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux'
import reducers from '../reducers'
import { middlewares } from '../middlewares'
@ -33,7 +33,6 @@ describe('CallActions', () => {
applyMiddleware(...middlewares),
)
callActions = bindActionCreators(CallActions, store.dispatch);
(getUserMedia as any).fail(false);
(SocketActions.handshake as jest.Mock).mockReturnValue(jest.fn())
})
@ -55,19 +54,12 @@ describe('CallActions', () => {
message: 'Connected to server socket',
type: 'warning',
},
}, {
type: constants.STREAM_ADD,
payload: {
stream: jasmine.anything(),
userId: constants.ME,
},
}, {
type: constants.INIT,
}])
expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
socket,
roomName: callId,
stream: (getUserMedia as any).stream,
}]])
})
@ -94,7 +86,6 @@ describe('CallActions', () => {
})
it('dispatches alert when failed to get media stream', async () => {
(getUserMedia as any).fail(true)
const promise = callActions.init()
socket.emit('connect', undefined)
await promise

View File

@ -1,11 +1,9 @@
import * as constants from '../constants'
import socket from '../socket'
import { Dispatch, ThunkResult } from '../store'
import { callId, getUserMedia } from '../window'
import { callId } from '../window'
import { ClientSocket } from '../socket'
import * as NotifyActions from './NotifyActions'
import * as SocketActions from './SocketActions'
import * as StreamActions from './StreamActions'
export interface InitAction {
type: 'INIT'
@ -23,12 +21,12 @@ const initialize = (): InitializeAction => ({
export const init = (): ThunkResult<Promise<void>> =>
async (dispatch, getState) => {
const socket = await dispatch(connect())
const stream = await dispatch(getCameraStream())
// const stream = await dispatch(getCameraStream())
dispatch(SocketActions.handshake({
socket,
roomName: callId,
stream,
// stream,
}))
dispatch(initialize())
@ -48,16 +46,17 @@ export const connect = () => (dispatch: Dispatch) => {
})
}
export const getCameraStream = () => async (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
}
}
// export const getCameraStream = () => async (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

@ -11,6 +11,7 @@ import Chat from './Chat'
import Notifications from './Notifications'
import Toolbar from './Toolbar'
import Video from './Video'
import { Media } from './Media'
export interface AppProps {
active: string | null
@ -84,6 +85,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
/>
<Alerts alerts={alerts} dismiss={dismissAlert} />
<Notifications notifications={notifications} />
<Media />
<Chat
messages={messages}
onClose={this.handleHideChat}

View File

@ -1,3 +1,5 @@
jest.mock('../window')
import React from 'react'
import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
@ -8,9 +10,7 @@ import { MEDIA_ENUMERATE } from '../constants'
describe('Media', () => {
const onSave = jest.fn()
beforeEach(() => {
jest.resetAllMocks()
store = createStore()
store.dispatch({
type: MEDIA_ENUMERATE,
@ -34,7 +34,7 @@ describe('Media', () => {
ReactDOM.render(
<div ref={div => resolve(div!)}>
<Provider store={store}>
<Media onSave={onSave} />
<Media />
</Provider>
</div>,
div,
@ -44,18 +44,27 @@ describe('Media', () => {
}
describe('submit', () => {
it('calls onSave', async () => {
const stream = {} as MediaStream
let promise: Promise<MediaStream>
beforeEach(() => {
navigator.mediaDevices.getUserMedia = async () => {
promise = Promise.resolve(stream)
return promise
}
})
it('tries to retrieve audio/video media stream', async () => {
const node = await render()
expect(node.tagName).toBe('FORM')
TestUtils.Simulate.submit(node)
expect(onSave.mock.calls.length).toBe(1)
expect(promise).toBeDefined()
await promise
})
})
describe('onVideoChange', () => {
it('calls onSetVideoConstraint', async () => {
const node = await render()
const select = node.querySelector('select.media-video')!
const select = node.querySelector('select[name=video-input]')!
TestUtils.Simulate.change(select, {
target: {
value: '{"deviceId":123}',
@ -68,7 +77,7 @@ describe('Media', () => {
describe('onAudioChange', () => {
it('calls onSetAudioConstraint', async () => {
const node = await render()
const select = node.querySelector('select.media-audio')!
const select = node.querySelector('select[name="audio-input"]')!
TestUtils.Simulate.change(select, {
target: {
value: '{"deviceId":456}',

View File

@ -1,19 +1,14 @@
import React from 'react'
import { connect } from 'react-redux'
import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint } from '../actions/MediaActions'
import { AudioConstraint, MediaDevice, setAudioConstraint, setVideoConstraint, VideoConstraint, getMediaStream, enumerateDevices } from '../actions/MediaActions'
import { MediaState } from '../reducers/media'
import { State } from '../store'
export type MediaProps = MediaState & {
enumerateDevices: typeof enumerateDevices
onSetVideoConstraint: typeof setVideoConstraint
onSetAudioConstraint: typeof setAudioConstraint
onSave: () => void
}
function getId(constraint: VideoConstraint | AudioConstraint) {
return typeof constraint === 'object' && 'deviceId' in constraint
? constraint.deviceId
: ''
getMediaStream: typeof getMediaStream
}
function mapStateToProps(state: State) {
@ -23,17 +18,27 @@ function mapStateToProps(state: State) {
}
const mapDispatchToProps = {
enumerateDevices,
onSetVideoConstraint: setVideoConstraint,
onSetAudioConstraint: setAudioConstraint,
getMediaStream,
}
const c = connect(mapStateToProps, mapDispatchToProps)
export const Media = c(React.memo(function Media(props: MediaProps) {
function onSave(event: React.FormEvent<HTMLFormElement>) {
React.useMemo(async () => await props.enumerateDevices(), [])
async function onSave(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
props.onSave()
const { audio, video } = props
try {
await props.getMediaStream({ audio, video })
} catch (err) {
console.error(err.stack)
// TODO display a message
}
}
function onVideoChange(event: React.ChangeEvent<HTMLSelectElement>) {
@ -46,12 +51,17 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
props.onSetAudioConstraint(constraint)
}
const videoId = getId(props.video)
const audioId = getId(props.audio)
const videoId = JSON.stringify(props.video)
const audioId = JSON.stringify(props.audio)
return (
<form className='media' onSubmit={onSave}>
<select className='media-video' onChange={onVideoChange} value={videoId}>
<label htmlFor='video-input'>Video</label>
<select
name='video-input'
onChange={onVideoChange}
value={videoId}
>
<Options
devices={props.devices}
default='{"facingMode":"user"}'
@ -59,7 +69,12 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
/>
</select>
<select className='media-audio' onChange={onAudioChange} value={audioId}>
<label htmlFor='video-input'>Audio</label>
<select
name='audio-input'
onChange={onAudioChange}
value={audioId}
>
<Options
devices={props.devices}
default='true'
@ -68,7 +83,7 @@ export const Media = c(React.memo(function Media(props: MediaProps) {
</select>
<button type='submit'>
Save
Join Call
</button>
</form>
)
@ -91,7 +106,9 @@ function Options(props: OptionsProps) {
.map(device =>
<option
key={device.id}
value={JSON.stringify({deviceId: device.id})}>{device.name}
value={JSON.stringify({deviceId: device.id})}
>
{device.name || device.type}
</option>,
)
}

View File

@ -1,6 +1,7 @@
import * as MediaActions from '../actions/MediaActions'
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM } from '../constants'
import { MEDIA_ENUMERATE, MEDIA_VIDEO_CONSTRAINT_SET, MEDIA_AUDIO_CONSTRAINT_SET, MEDIA_STREAM, ME, PEERS_DESTROY, PEER_ADD } from '../constants'
import { createStore, Store } from '../store'
import SimplePeer from 'simple-peer'
describe('media', () => {
@ -95,14 +96,73 @@ describe('media', () => {
navigator.mediaDevices.getUserMedia = async () => stream
})
it('returns a promise with media stream', async () => {
const promise = MediaActions.getMediaStream({
async function dispatch() {
const promise = store.dispatch(MediaActions.getMediaStream({
audio: true,
video: true,
})
expect(promise.type).toBe('MEDIA_STREAM')
expect(promise.status).toBe('pending')
}))
expect(await promise).toBe(stream)
}
describe('reducers/streams', () => {
it('adds the local stream to the map of videos', async () => {
expect(store.getState().streams[ME]).toBeFalsy()
await dispatch()
expect(store.getState().streams[ME]).toBeTruthy()
expect(store.getState().streams[ME].stream).toBe(stream)
})
})
describe('reducers/peers', () => {
const peer1 = new SimplePeer()
peer1.addStream = jest.fn()
peer1.removeStream = jest.fn()
const peer2 = new SimplePeer()
peer2.addStream = jest.fn()
peer2.removeStream = jest.fn()
const peers = [peer1, peer2]
beforeEach(() => {
store.dispatch({
type: PEERS_DESTROY,
})
store.dispatch({
type: PEER_ADD,
payload: {
userId: '1',
peer: peer1,
},
})
store.dispatch({
type: PEER_ADD,
payload: {
userId: '2',
peer: peer2,
},
})
})
afterEach(() => {
store.dispatch({
type: PEERS_DESTROY,
})
})
it('replaces local stream on all peers', async () => {
await dispatch()
peers.forEach(peer => {
expect((peer.addStream as jest.Mock).mock.calls)
.toEqual([[ stream ]])
expect((peer.removeStream as jest.Mock).mock.calls).toEqual([])
})
await dispatch()
peers.forEach(peer => {
expect((peer.addStream as jest.Mock).mock.calls)
.toEqual([[ stream ], [ stream ]])
expect((peer.removeStream as jest.Mock).mock.calls)
.toEqual([[ stream ]])
})
})
})
});

View File

@ -3,14 +3,17 @@ import omit from 'lodash/omit'
import Peer from 'simple-peer'
import { PeerAction } from '../actions/PeerActions'
import * as constants from '../constants'
import { MediaStreamAction } from '../actions/MediaActions'
export type PeersState = Record<string, Peer.Instance>
const defaultState: PeersState = {}
let localStream: MediaStream | undefined
export default function peers(
state = defaultState,
action: PeerAction,
action: PeerAction | MediaStreamAction,
): PeersState {
switch (action.type) {
case constants.PEER_ADD:
@ -21,8 +24,18 @@ export default function peers(
case constants.PEER_REMOVE:
return omit(state, [action.payload.userId])
case constants.PEERS_DESTROY:
localStream = undefined
forEach(state, peer => peer.destroy())
return defaultState
case constants.MEDIA_STREAM:
if (action.status === 'resolved') {
forEach(state, peer => {
localStream && peer.removeStream(localStream)
peer.addStream(action.payload)
})
localStream = action.payload
}
return state
default:
return state
}

View File

@ -1,8 +1,9 @@
import _debug from 'debug'
import omit from 'lodash/omit'
import { AddStreamAction, AddStreamPayload, RemoveStreamAction, StreamAction } from '../actions/StreamActions'
import { STREAM_ADD, STREAM_REMOVE } from '../constants'
import { STREAM_ADD, STREAM_REMOVE, MEDIA_STREAM, ME } from '../constants'
import { createObjectURL, revokeObjectURL } from '../window'
import { MediaStreamAction } from '../actions/MediaActions'
const debug = _debug('peercalls')
const defaultState = Object.freeze({})
@ -21,9 +22,9 @@ export interface StreamsState {
}
function addStream (
state: StreamsState, action: AddStreamAction,
state: StreamsState, payload: AddStreamAction['payload'],
): StreamsState {
const { userId, stream } = action.payload
const { userId, stream } = payload
const userStream: AddStreamPayload = {
userId,
@ -38,9 +39,9 @@ function addStream (
}
function removeStream (
state: StreamsState, action: RemoveStreamAction,
state: StreamsState, payload: RemoveStreamAction['payload'],
): StreamsState {
const { userId } = action.payload
const { userId } = payload
const stream = state[userId]
if (stream && stream.url) {
revokeObjectURL(stream.url)
@ -48,15 +49,31 @@ function removeStream (
return omit(state, [userId])
}
function replaceStream(state: StreamsState, stream: MediaStream): StreamsState {
state = removeStream(state, {
userId: ME,
})
return addStream(state, {
userId: ME,
stream,
})
}
export default function streams(
state = defaultState,
action: StreamAction,
action: StreamAction | MediaStreamAction,
): StreamsState {
switch (action.type) {
case STREAM_ADD:
return addStream(state, action)
return addStream(state, action.payload)
case STREAM_REMOVE:
return removeStream(state, action)
return removeStream(state, action.payload)
case MEDIA_STREAM:
if (action.status === 'resolved') {
return replaceStream(state, action.payload)
} else {
return state
}
default:
return state
}

View File

@ -1,58 +1,7 @@
import {
createObjectURL,
revokeObjectURL,
getUserMedia,
navigator,
play,
valueOf,
} from './window'
import { createObjectURL, play, revokeObjectURL, valueOf } from './window'
describe('window', () => {
describe('getUserMedia', () => {
class MediaStream {}
const stream = new MediaStream()
const constraints = { video: true }
afterEach(() => {
delete (navigator as any).mediaDevices
delete navigator.getUserMedia
delete (navigator as any).webkitGetUserMedia
})
it('calls navigator.mediaDevices.getUserMedia', async () => {
const promise = Promise.resolve(stream);
(navigator as any).mediaDevices = {
getUserMedia: jest.fn().mockReturnValue(promise),
}
expect(await getUserMedia(constraints)).toBe(stream)
})
;['getUserMedia', 'webkitGetUserMedia'].forEach((method) => {
it(`it calls navigator.${method} as a fallback`, () => {
(navigator as any)[method] = jest.fn()
.mockImplementation(
(constraints, onSuccess, onError) => onSuccess(stream),
)
return getUserMedia(constraints)
.then(s => expect(s).toBe(stream))
})
})
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: HTMLVideoElement & { play: jest.Mock }

View File

@ -2,27 +2,10 @@ import _debug from 'debug'
const debug = _debug('peercalls')
export async function getUserMedia (constraints: MediaStreamConstraints) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia(constraints)
}
return new Promise<MediaStream>((resolve, reject) => {
const getMedia = navigator.getUserMedia ||
(navigator as unknown as {
webkitGetUserMedia: typeof navigator.getUserMedia
}).webkitGetUserMedia
if (!getMedia) reject(new Error('Browser unsupported'))
getMedia.call(navigator, constraints, resolve, reject)
})
}
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 () {
const videos = window.document.querySelectorAll('video')
Array.prototype.forEach.call(videos, (video, index) => {