diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 0000000000000000000000000000000000000000..32b6e49583c24592f92368da8c1203b05d4add46
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1 @@
+node
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000000000000000000000000000000000000..75fcb60e67df034ff606da90e18d145994fba99d
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,20 @@
+dist: trusty
+
+language: node_js
+
+node_js:
+- "8"
+
+if: type = pull_request
+
+env:
+  global:
+    - BBB_SERVER_URL=http://localhost/bigbluebutton/api
+
+script:
+  - bash ./build_script.sh
+
+after_script:
+  - docker stop $docker
+  - docker rm $docker
+  - docker rmi b2
diff --git a/bigbluebutton-config/bin/bbb-conf b/bigbluebutton-config/bin/bbb-conf
index e3a624968a5ac1802abffbc503368e2f8dd884ec..b322cf6485169921821618cf4ec4978dff631577 100755
--- a/bigbluebutton-config/bin/bbb-conf
+++ b/bigbluebutton-config/bin/bbb-conf
@@ -80,6 +80,7 @@ FREESWITCH_PID=/opt/freeswitch/run/freeswitch.pid
 FREESWITCH_EVENT_SOCKET=/opt/freeswitch/conf/autoload_configs/event_socket.conf.xml
 
 HTML5_CONFIG=/usr/share/meteor/bundle/programs/server/assets/app/config/settings-production.json
+HTML5_CONFIG_NEW=/usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml
 
 if [ -f /etc/redhat-release ]; then
   DISTRIB_ID=centos
@@ -279,9 +280,10 @@ comment () {
 }
 
 change_yml_value () {
-        sed -i "s<^[[:blank:]#]*\(${2}\):[ ].*<\1: ${3}<" $1
+	sed -i "s<^\([[:blank:]#]*\)\(${2}\): .*<\1\2: ${3}<" $1
 }
 
+
 # comment lines matching $2 ($1 is the file)
 uncomment () {
 	check_and_backup $1
@@ -1911,6 +1913,16 @@ if [ -n "$HOST" ]; then
 		fi
 	fi
 
+	if [ -f $HTML5_CONFIG_NEW ]; then
+        	WS=$(cat $SERVLET_DIR/bigbluebutton/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | sed -n '/^bigbluebutton.web.serverURL/{s/.*=//;p}' | sed 's/https/wss/g' | sed s'/http/ws/g')
+
+		change_yml_value $HTML5_CONFIG_NEW wsUrl "$WS/bbb-webrtc-sfu"
+
+		if [ -f /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml ]; then
+		  change_yml_value /usr/local/bigbluebutton/bbb-webrtc-sfu/config/default.yml kurentoUrl "ws://$HOST:8888/kurento"
+		fi
+	fi
+
         #
         # Update thumbnail links
         #
diff --git a/bigbluebutton-html5/.eslintignore b/bigbluebutton-html5/.eslintignore
index 9dce6fc290b961483e663a32585d2d15260332a1..01b9706e2ecef17c6d803f5a0bb4bf4d2331cd61 100644
--- a/bigbluebutton-html5/.eslintignore
+++ b/bigbluebutton-html5/.eslintignore
@@ -1,2 +1,2 @@
-tests/*
 Gruntfile.js
+client/compatibility/*
diff --git a/bigbluebutton-html5/client/compatibility/kurento-extension.js b/bigbluebutton-html5/client/compatibility/kurento-extension.js
index bcd267e2d269c78c0a78aeb9e93322d523542848..0af48b797cf84543e7b5d1c43baa7839ecd37005 100644
--- a/bigbluebutton-html5/client/compatibility/kurento-extension.js
+++ b/bigbluebutton-html5/client/compatibility/kurento-extension.js
@@ -117,8 +117,7 @@ KurentoManager.prototype.exitScreenShare = function () {
 
 KurentoManager.prototype.exitVideo = function () {
   if (typeof this.kurentoVideo !== 'undefined' && this.kurentoVideo) {
-
-    if(this.kurentoVideo.webRtcPeer) {
+    if (this.kurentoVideo.webRtcPeer) {
       this.kurentoVideo.webRtcPeer.peerConnection.oniceconnectionstatechange = null;
     }
 
@@ -473,14 +472,14 @@ Kurento.prototype.setAudio = function (tag) {
 };
 
 Kurento.prototype.listenOnly = function () {
-  var self = this;
+  const self = this;
   const remoteVideo = document.getElementById(this.renderTag);
   remoteVideo.muted = true;
   if (!this.webRtcPeer) {
-    var options = {
+    const options = {
       audioStream: this.inputStream,
       remoteVideo,
-      onicecandidate : this.onListenOnlyIceCandidate.bind(this),
+      onicecandidate: this.onListenOnlyIceCandidate.bind(this),
       mediaConstraints: {
         audio: true,
         video: false,
@@ -653,18 +652,18 @@ window.getScreenConstraints = function (sendSource, callback) {
   ];
 
   if (isElectron) {
-        var sourceId = ipcRenderer.sendSync('screen-chooseSync');
-        kurentoManager.kurentoScreenshare.extensionInstalled = true;
+    const sourceId = ipcRenderer.sendSync('screen-chooseSync');
+    kurentoManager.kurentoScreenshare.extensionInstalled = true;
 
-        // this statement sets gets 'sourceId" and sets "chromeMediaSourceId"
-        screenConstraints.video.chromeMediaSource = { exact: [sendSource] };
-        screenConstraints.video.chromeMediaSourceId = sourceId;
-        screenConstraints.optional = optionalConstraints;
+    // this statement sets gets 'sourceId" and sets "chromeMediaSourceId"
+    screenConstraints.video.chromeMediaSource = { exact: [sendSource] };
+    screenConstraints.video.chromeMediaSourceId = sourceId;
+    screenConstraints.optional = optionalConstraints;
 
-        console.log('getScreenConstraints for Chrome returns => ', screenConstraints);
-        return callback(null, screenConstraints);
+    console.log('getScreenConstraints for Chrome returns => ', screenConstraints);
+    return callback(null, screenConstraints);
   }
-  
+
   if (isChrome) {
     const extensionKey = kurentoManager.getChromeExtensionKey();
     getChromeScreenConstraints(extensionKey).then((constraints) => {
diff --git a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js
index 23e12c888a8633f2b1be0dca693e84cf47972a4e..debe0a6d59a2e80aa316a4ffce88e99832408a90 100644
--- a/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js
+++ b/bigbluebutton-html5/imports/api/users-settings/server/methods/addUserSettings.js
@@ -1,4 +1,3 @@
-import { Meteor } from 'meteor/meteor';
 import { check } from 'meteor/check';
 import addUserSetting from '/imports/api/users-settings/server/modifiers/addUserSetting';
 
@@ -27,6 +26,12 @@ export default function addUserSettings(credentials, meetingId, userId, settings
       'enableVideo',
       'enableVideoStats',
       // WHITEBOARD
+      'multiUserPenOnly',
+      'presenterTools',
+      'multiUserTools',
+      'autoSwapLayout',
+      'autoShareWebcam',
+      'hidePresentation',
     ];
     if (!handledHTML5Parameters.includes(key)) {
       return acc;
diff --git a/bigbluebutton-html5/imports/startup/client/auth.js b/bigbluebutton-html5/imports/startup/client/auth.js
index b204d762523e2d81249629f99f9982f5df5227b4..a968b67e10fd22f22eb11be0af27776c4c544843 100755
--- a/bigbluebutton-html5/imports/startup/client/auth.js
+++ b/bigbluebutton-html5/imports/startup/client/auth.js
@@ -1,5 +1,4 @@
 import Auth from '/imports/ui/services/auth';
-import SessionStorage from '/imports/ui/services/storage/session';
 import { setCustomLogoUrl } from '/imports/ui/components/user-list/service';
 import { log, makeCall } from '/imports/ui/services/api';
 import deviceInfo from '/imports/utils/deviceInfo';
@@ -8,7 +7,6 @@ import { Session } from 'meteor/session';
 
 // disconnected and trying to open a new connection
 const STATUS_CONNECTING = 'connecting';
-const METADATA_KEY = 'metadata';
 
 export function joinRouteHandler(callback) {
   const urlParams = new URLSearchParams(window.location.search);
@@ -31,7 +29,7 @@ export function joinRouteHandler(callback) {
     .then(response => response.json())
     .then(({ response }) => {
       const {
-        returncode, meetingID, internalUserID, authToken, logoutUrl, customLogoURL, metadata,
+        returncode, meetingID, internalUserID, authToken, logoutUrl, customLogoURL,
         externUserID, fullname, confname, customdata,
       } = response;
 
@@ -43,37 +41,10 @@ export function joinRouteHandler(callback) {
       } else {
         setCustomLogoUrl(customLogoURL);
 
-        let metakeys = 0;
-        if (metadata) {
-          metakeys = metadata.length
-            ? metadata.reduce((acc, meta) => {
-              const key = Object.keys(meta).shift();
-
-              const handledHTML5Parameters = [
-                'html5autoswaplayout', 'html5autosharewebcam', 'html5hidepresentation',
-              ];
-              if (handledHTML5Parameters.indexOf(key) === -1) {
-                return acc;
-              }
-
-              /* this reducer transforms array of objects in a single object and
-               forces the metadata a be boolean value */
-              let value = meta[key];
-              try {
-                value = JSON.parse(meta[key]);
-              } catch (e) {
-                log('error', `Caught: ${e.message}`);
-              }
-              return { ...acc, [key]: value };
-            }, {}) : {};
-        }
-
         if (customdata.length) {
           makeCall('addUserSettings', meetingID, internalUserID, customdata);
         }
 
-        SessionStorage.setItem(METADATA_KEY, metakeys);
-
         Auth.set(
           meetingID, internalUserID, authToken, logoutUrl,
           sessionToken, fullname, externUserID, confname,
diff --git a/bigbluebutton-html5/imports/ui/components/app/component.jsx b/bigbluebutton-html5/imports/ui/components/app/component.jsx
index 794dfd7416f4fbddc757ca605f95ed45960cb909..dc7037f9181f24d796e7fcad4f153d26c7523922 100755
--- a/bigbluebutton-html5/imports/ui/components/app/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/app/component.jsx
@@ -170,7 +170,7 @@ class App extends Component {
     const { userListIsOpen } = this.props;
 
     // Variables for resizing user-list.
-    const USERLIST_MIN_WIDTH_PX = 100;
+    const USERLIST_MIN_WIDTH_PX = 150;
     const USERLIST_MAX_WIDTH_PX = 240;
     const USERLIST_DEFAULT_WIDTH_RELATIVE = 18;
 
diff --git a/bigbluebutton-html5/imports/ui/components/media/container.jsx b/bigbluebutton-html5/imports/ui/components/media/container.jsx
index efb3eb28127bf99106dc534290e982fb4b712733..75723126c6ce67fb6ebcb1bae2e7325d895bcb5d 100755
--- a/bigbluebutton-html5/imports/ui/components/media/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/media/container.jsx
@@ -1,8 +1,8 @@
 import React, { Component } from 'react';
 import { withTracker } from 'meteor/react-meteor-data';
-import SessionStorage from '/imports/ui/services/storage/session';
 import Settings from '/imports/ui/services/settings';
-import { defineMessages, injectIntl } from 'react-intl';
+import { defineMessages, injectIntl, intlShape } from 'react-intl';
+import PropTypes from 'prop-types';
 import { notify } from '/imports/ui/services/notification';
 import VideoService from '/imports/ui/components/video-provider/service';
 import getFromUserSettings from '/imports/ui/services/users-settings';
@@ -12,6 +12,14 @@ import PresentationPodsContainer from '../presentation-pod/container';
 import ScreenshareContainer from '../screenshare/container';
 import DefaultContent from '../presentation/default-content/component';
 
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+const KURENTO_CONFIG = Meteor.settings.public.kurento;
+
+const propTypes = {
+  isScreensharing: PropTypes.bool.isRequired,
+  intl: intlShape.isRequired,
+};
+
 const intlMessages = defineMessages({
   screenshareStarted: {
     id: 'app.media.screenshare.start',
@@ -38,16 +46,13 @@ const intlMessages = defineMessages({
 class MediaContainer extends Component {
   componentWillMount() {
     const { willMount } = this.props;
-    willMount && willMount();
+    if (willMount) {
+      willMount();
+    }
     document.addEventListener('installChromeExtension', this.installChromeExtension.bind(this));
     document.addEventListener('safariScreenshareNotSupported', this.safariScreenshareNotSupported.bind(this));
   }
 
-  componentWillUnmount() {
-    document.removeEventListener('installChromeExtension', this.installChromeExtension.bind(this));
-    document.removeEventListener('safariScreenshareNotSupported', this.safariScreenshareNotSupported.bind(this));
-  }
-
   componentWillReceiveProps(nextProps) {
     const {
       isScreensharing,
@@ -63,19 +68,27 @@ class MediaContainer extends Component {
     }
   }
 
+  componentWillUnmount() {
+    document.removeEventListener('installChromeExtension', this.installChromeExtension.bind(this));
+    document.removeEventListener('safariScreenshareNotSupported', this.safariScreenshareNotSupported.bind(this));
+  }
+
   installChromeExtension() {
     const { intl } = this.props;
 
-    const CHROME_DEFAULT_EXTENSION_LINK = Meteor.settings.public.kurento.chromeDefaultExtensionLink;
-    const CHROME_CUSTOM_EXTENSION_LINK = Meteor.settings.public.kurento.chromeExtensionLink;
+    const CHROME_DEFAULT_EXTENSION_LINK = KURENTO_CONFIG.chromeDefaultExtensionLink;
+    const CHROME_CUSTOM_EXTENSION_LINK = KURENTO_CONFIG.chromeExtensionLink;
     const CHROME_EXTENSION_LINK = CHROME_CUSTOM_EXTENSION_LINK === 'LINK' ? CHROME_DEFAULT_EXTENSION_LINK : CHROME_CUSTOM_EXTENSION_LINK;
 
-    notify(<div>
-      {intl.formatMessage(intlMessages.chromeExtensionError)}{' '}
-      <a href={CHROME_EXTENSION_LINK} target="_blank">
-        {intl.formatMessage(intlMessages.chromeExtensionErrorLink)}
-      </a>
-    </div>, 'error', 'desktop');
+    const chromeErrorElement = (
+      <div>
+        {intl.formatMessage(intlMessages.chromeExtensionError)}{' '}
+        <a href={CHROME_EXTENSION_LINK} target="_blank">
+          {intl.formatMessage(intlMessages.chromeExtensionErrorLink)}
+        </a>
+      </div>
+    );
+    notify(chromeErrorElement, 'error', 'desktop');
   }
 
   safariScreenshareNotSupported() {
@@ -92,7 +105,7 @@ export default withTracker(() => {
   const { dataSaving } = Settings;
   const { viewParticipantsWebcams, viewScreenshare } = dataSaving;
 
-  const hidePresentation = SessionStorage.getItem('metadata').html5hidepresentation || false;
+  const hidePresentation = getFromUserSettings('hidePresentation', LAYOUT_CONFIG.hidePresentation);
   const data = {
     children: <DefaultContent />,
   };
@@ -121,12 +134,13 @@ export default withTracker(() => {
     data.hideOverlay = hidePresentation;
   }
 
-  const enableVideo = getFromUserSettings('enableVideo', Meteor.settings.public.kurento.enableVideo);
-  const autoShareWebcam = SessionStorage.getItem('metadata').html5autosharewebcam || false;
+  const enableVideo = getFromUserSettings('enableVideo', KURENTO_CONFIG.enableVideo);
+  const autoShareWebcam = getFromUserSettings('autoShareWebcam', KURENTO_CONFIG.autoShareWebcam);
 
   if (enableVideo && autoShareWebcam) {
     data.willMount = VideoService.joinVideo;
   }
 
+  MediaContainer.propTypes = propTypes;
   return data;
 })(injectIntl(MediaContainer));
diff --git a/bigbluebutton-html5/imports/ui/components/media/service.js b/bigbluebutton-html5/imports/ui/components/media/service.js
index 5b2866c8cb58897bcf0527c79a78479630cd5d56..d3bac4dfa2d201b5363504795d18183288c71791 100755
--- a/bigbluebutton-html5/imports/ui/components/media/service.js
+++ b/bigbluebutton-html5/imports/ui/components/media/service.js
@@ -1,4 +1,3 @@
-import SessionStorage from '/imports/ui/services/storage/session';
 import Presentations from '/imports/api/presentations';
 import { isVideoBroadcasting } from '/imports/ui/components/screenshare/service';
 import Auth from '/imports/ui/services/auth';
@@ -8,6 +7,9 @@ import VideoService from '/imports/ui/components/video-provider/service';
 import PollingService from '/imports/ui/components/polling/service';
 import getFromUserSettings from '/imports/ui/services/users-settings';
 
+const LAYOUT_CONFIG = Meteor.settings.public.layout;
+const KURENTO_CONFIG = Meteor.settings.public.kurento;
+
 const getPresentationInfo = () => {
   const currentPresentation = Presentations.findOne({
     current: true,
@@ -25,11 +27,11 @@ function shouldShowWhiteboard() {
 }
 
 function shouldShowScreenshare() {
-  return isVideoBroadcasting() && getFromUserSettings('enableScreensharing', Meteor.settings.public.kurento.enableScreensharing);
+  return isVideoBroadcasting() && getFromUserSettings('enableScreensharing', KURENTO_CONFIG.enableScreensharing);
 }
 
 function shouldShowOverlay() {
-  return getFromUserSettings('enableVideo', Meteor.settings.public.kurento.enableVideo);
+  return getFromUserSettings('enableVideo', KURENTO_CONFIG.enableVideo);
 }
 
 const swapLayout = {
@@ -54,8 +56,8 @@ export const shouldEnableSwapLayout = () => {
 
 export const getSwapLayout = () => {
   swapLayout.tracker.depend();
-  const metaAutoSwapLayout = SessionStorage.getItem('metadata').html5autoswaplayout || false;
-  return metaAutoSwapLayout || (swapLayout.value && shouldEnableSwapLayout());
+  const autoSwapLayout = getFromUserSettings('autoSwapLayout', LAYOUT_CONFIG.autoSwapLayout);
+  return autoSwapLayout || (swapLayout.value && shouldEnableSwapLayout());
 };
 
 export default {
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx
index 505a377415ec69913395b9dbef68b6cea7b33435..4def7fc1984afe4b2820cdcc9127fae45892a270 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/component.jsx
@@ -64,7 +64,7 @@ export default class PresentationArea extends Component {
       // By default presentation sizes are equal to the sizes of the refPresentationArea
       // direct parent of the svg wrapper
       let { clientWidth, clientHeight } = refPresentationArea;
-      
+
       // if a user is a presenter - this means there is a whiteboard toolbar on the right
       // and we have to get the width/height of the refWhiteboardArea
       // (inner hidden div with absolute position)
@@ -81,7 +81,7 @@ export default class PresentationArea extends Component {
   getInitialPresentationSizes() {
     // determining the presentationWidth and presentationHeight (available space for the svg)
     // on the initial load
-    
+
     const presentationSizes = this.getPresentationSizesAvailable();
     if (Object.keys(presentationSizes).length > 0) {
       // setting the state of the available space for the svg
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/default-content/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/default-content/component.jsx
index fa57cc2e724b334966a9aef70e17c24482f46af2..7d390783c9227e7e4bc6de498df905c4e5d83a10 100644
--- a/bigbluebutton-html5/imports/ui/components/presentation/default-content/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/default-content/component.jsx
@@ -1,175 +1,33 @@
-import React, { Component } from 'react';
-import { FormattedMessage, FormattedDate } from 'react-intl';
+import React from 'react';
+import { FormattedMessage } from 'react-intl';
 import { TransitionGroup, CSSTransition } from 'react-transition-group';
 import { styles } from './styles.scss';
-import Button from '../../button/component';
 
-export default class DefaultContent extends Component {
-  static handleClick() {
-    console.log('dummy handler');
-  }
-
-  render() {
-    return (
-      <TransitionGroup>
-        <CSSTransition
-          classNames={{
-            appear: styles.appear,
-            appearActive: styles.appearActive,
-          }}
-          appear
-          enter={false}
-          exit={false}
-          timeout={{ enter: 400 }}
-          className={styles.contentWrapper}
-        >
-          <div className={styles.contentRatio}>
-            <div className={styles.content}>
-              <div className={styles.defaultContent}>
-                <p>
-                  <FormattedMessage
-                    id="app.home.greeting"
-                    description="Message to greet the user."
-                    defaultMessage="Welcome {0}! Your presentation will begin shortly..."
-                    values={{ 0: 'James Bond' }}
-                  />
-                  <br />
-                  Today is {' '}<FormattedDate value={Date.now()} />
-                  <br />
-                  Here is some button examples
-                  <br />
-                </p>
-                <p>
-                  <Button
-                    label="Small"
-                    onClick={DefaultContent.handleClick}
-                    size="sm"
-                  />&nbsp;
-                  <Button
-                    label="Medium"
-                    onClick={DefaultContent.handleClick}
-                  />&nbsp;
-                  <Button
-                    label="Large"
-                    onClick={DefaultContent.handleClick}
-                    size="lg"
-                  />
-                </p>
-                <p>
-                  <Button
-                    label="Default"
-                    onClick={DefaultContent.handleClick}
-                  />&nbsp;
-                  <Button
-                    label="Primary"
-                    onClick={DefaultContent.handleClick}
-                    color="primary"
-                  />&nbsp;
-                  <Button
-                    label="Danger"
-                    onClick={DefaultContent.handleClick}
-                    color="danger"
-                  />&nbsp;
-                  <Button
-                    label="Success"
-                    onClick={DefaultContent.handleClick}
-                    color="success"
-                  />
-                </p>
-                <p>
-                  <Button
-                    label="Default"
-                    onClick={DefaultContent.handleClick}
-                    ghost
-                  />&nbsp;
-                  <Button
-                    label="Primary"
-                    onClick={DefaultContent.handleClick}
-                    color="primary"
-                    ghost
-                  />&nbsp;
-                  <Button
-                    label="Danger"
-                    onClick={DefaultContent.handleClick}
-                    color="danger"
-                    ghost
-                  />&nbsp;
-                  <Button
-                    label="Success"
-                    onClick={DefaultContent.handleClick}
-                    color="success"
-                    ghost
-                  />
-                </p>
-                <p>
-                  <Button
-                    label="With Icon"
-                    onClick={DefaultContent.handleClick}
-                    icon="add"
-                  />&nbsp;
-                  <Button
-                    label="Ghost With Icon"
-                    onClick={DefaultContent.handleClick}
-                    color="primary"
-                    icon="add"
-                    ghost
-                  />&nbsp;
-                  <Button
-                    label="Icon Right"
-                    onClick={DefaultContent.handleClick}
-                    color="danger"
-                    icon="add"
-                    ghost
-                    iconRight
-                  />&nbsp;
-                  <Button
-                    label="Icon Right"
-                    onClick={DefaultContent.handleClick}
-                    color="success"
-                    icon="add"
-                    iconRight
-                  />
-                </p>
-                <p>
-                  <Button
-                    label="Medium"
-                    onClick={DefaultContent.handleClick}
-                    color="primary"
-                    icon="unmute"
-                    ghost
-                    circle
-                  />&nbsp;
-                  <Button
-                    label="Large"
-                    onClick={DefaultContent.handleClick}
-                    color="danger"
-                    icon="unmute"
-                    size="lg"
-                    ghost
-                    circle
-                  /><br />
-                  <Button
-                    label="Small"
-                    onClick={DefaultContent.handleClick}
-                    icon="unmute"
-                    size="sm"
-                    circle
-                  />&nbsp;
-                  <Button
-                    label="Icon Right"
-                    onClick={DefaultContent.handleClick}
-                    color="success"
-                    icon="unmute"
-                    size="sm"
-                    iconRight
-                    circle
-                  />
-                </p>
-              </div>
-            </div>
-          </div>
-        </CSSTransition>
-      </TransitionGroup>
-    );
-  }
-}
+export default () => (
+  <TransitionGroup>
+    <CSSTransition
+      classNames={{
+          appear: styles.appear,
+          appearActive: styles.appearActive,
+        }}
+      appear
+      enter={false}
+      exit={false}
+      timeout={{ enter: 400 }}
+      className={styles.contentWrapper}
+    >
+      <div className={styles.content}>
+        <div className={styles.defaultContent}>
+          <p>
+            <FormattedMessage
+              id="app.home.greeting"
+              description="Message to greet the user."
+              defaultMessage="Your presentation will begin shortly..."
+            />
+            <br />
+          </p>
+        </div>
+      </div>
+    </CSSTransition>
+  </TransitionGroup>
+);
diff --git a/bigbluebutton-html5/imports/ui/components/presentation/presentation-overlay/component.jsx b/bigbluebutton-html5/imports/ui/components/presentation/presentation-overlay/component.jsx
index 7b7ac2f31e8cd26a822ab3e5450d1e93c5da28ef..5ea9ddcf6accf5ea876dedcef5cefcee8fde1d07 100755
--- a/bigbluebutton-html5/imports/ui/components/presentation/presentation-overlay/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/presentation/presentation-overlay/component.jsx
@@ -134,10 +134,10 @@ export default class PresentationOverlay extends Component {
     const relXcoordInPage = absXcoordInPage / this.calcPageW;
     const relYcoordInPage = absYcoordInPage / this.calcPageH;
 
-    if (this.isPortraitDoc() && this.fitToPage) {            
+    if (this.isPortraitDoc() && this.fitToPage) {
       this.calcPageH = (this.viewportH * zoomValue) / HUNDRED_PERCENT;
       this.calcPageW = (this.pageOrigW / this.pageOrigH) * this.calcPageH;
-    } else if (!this.isPortraitDoc() && this.fitToPage) {      
+    } else if (!this.isPortraitDoc() && this.fitToPage) {
       this.calcPageW = (this.viewportW * zoomValue) / HUNDRED_PERCENT;
       this.calcPageH = (this.viewportH * zoomValue) / HUNDRED_PERCENT;
     } else {
@@ -326,7 +326,7 @@ export default class PresentationOverlay extends Component {
       newZoom = HUNDRED_PERCENT;
     } else if (newZoom >= MAX_PERCENT) {
       newZoom = MAX_PERCENT;
-    } 
+    }
 
     const mouseX = e.clientX;
     const mouseY = e.clientY;
@@ -341,7 +341,7 @@ export default class PresentationOverlay extends Component {
     });
   }
   tapHandler(event) {
-    if (event.touches.length === 2) return; 
+    if (event.touches.length === 2) return;
     if (!this.tapedTwice) {
       this.tapedTwice = true;
       setTimeout(() => this.tapedTwice = false, 300);
@@ -349,13 +349,13 @@ export default class PresentationOverlay extends Component {
     }
     event.preventDefault();
     const sizeDefault = this.state.zoom === HUNDRED_PERCENT;
-    
-    if (sizeDefault) {      
+
+    if (sizeDefault) {
       this.doZoomCall(200, this.currentClientX, this.currentClientY);
       return;
     }
     this.doZoomCall(HUNDRED_PERCENT, 0, 0);
- }
+  }
 
   handleTouchStart(event) {
     // to prevent default behavior (scrolling) on devices (in Safari), when you draw a text box
@@ -388,7 +388,7 @@ export default class PresentationOverlay extends Component {
 
   handleTouchEnd(event) {
     event.preventDefault();
-    
+
     // touch ended, removing the interval
     clearInterval(this.intervalId);
     this.intervalId = 0;
diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss
index 75bbfb29e62076d82303c05e2400850fbe9013fa..3d136da16cfd1ce7eca0e212d2ca188fbbcede54 100755
--- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss
+++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/styles.scss
@@ -9,6 +9,12 @@
   overflow: hidden;
 }
 
+.container{
+  display: flex;
+  align-items: center;
+  margin-bottom: .3rem;
+}
+
 .scrollableList {
   @include elementFocus($list-item-bg-hover);
   @include scrollbox-vertical($user-list-bg);
@@ -26,6 +32,8 @@
 
 .smallTitle {
   @extend .smallTitle;
+  flex: 1;
+  margin: 0;
 }
 
 .separator {
diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
index 3d45555ebee6588132879a835b2915bfd6ed4e4c..0ce3f4a7fb7fb6ca41bb98a1407400ddc3354643 100755
--- a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/component.jsx
@@ -5,6 +5,7 @@ import PropTypes from 'prop-types';
 import cx from 'classnames';
 import { styles } from '/imports/ui/components/user-list/user-list-content/styles';
 import UserListItem from './user-list-item/component';
+import UserOptionsContainer from './user-options/container';
 
 const propTypes = {
   users: PropTypes.arrayOf(Object).isRequired,
@@ -109,8 +110,8 @@ class UserParticipants extends Component {
       setEmojiStatus,
       removeUser,
       toggleVoice,
-      getGroupChatPrivate, //// TODO check if this is used
-      handleEmojiChange, //// TODO add to props validation
+      getGroupChatPrivate, // // TODO check if this is used
+      handleEmojiChange, // // TODO add to props validation
       getEmojiList,
       getEmoji,
     } = this.props;
@@ -166,20 +167,21 @@ class UserParticipants extends Component {
   }
 
   render() {
-    const {
-      users,
-      intl,
-      compact,
-    } = this.props;
+    const { intl, users, compact } = this.props;
 
     return (
-      <div className={styles.participants}>
+      <div>
         {
           !compact ?
-            <h2 className={styles.smallTitle}>
-              {intl.formatMessage(intlMessages.usersTitle)}
-              &nbsp;({users.length})
-            </h2> : <hr className={styles.separator} />
+            <div className={styles.container}>
+              <h2 className={styles.smallTitle}>
+                {intl.formatMessage(intlMessages.usersTitle)}
+                &nbsp;({users.length})
+
+              </h2>
+              <UserOptionsContainer />
+            </div>
+            : <hr className={styles.separator} />
         }
         <div
           className={styles.scrollableList}
@@ -189,7 +191,7 @@ class UserParticipants extends Component {
         >
           <div className={styles.list}>
             <TransitionGroup ref={(ref) => { this.refScrollItems = ref; }}>
-              { this.getUsers() }
+              {this.getUsers()}
             </TransitionGroup>
             <div className={styles.footer} />
           </div>
diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx
new file mode 100755
index 0000000000000000000000000000000000000000..ae6ce9632f067f3dc48c675da82049eff6d6e8e4
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/component.jsx
@@ -0,0 +1,150 @@
+import React, { Component } from 'react';
+import { defineMessages, injectIntl } from 'react-intl';
+import _ from 'lodash';
+import { withModalMounter } from '/imports/ui/components/modal/service';
+import Button from '/imports/ui/components/button/component';
+import Dropdown from '/imports/ui/components/dropdown/component';
+import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component';
+import DropdownContent from '/imports/ui/components/dropdown/content/component';
+import DropdownList from '/imports/ui/components/dropdown/list/component';
+import DropdownListItem from '/imports/ui/components/dropdown/list/item/component';
+import { styles } from './styles';
+
+const intlMessages = defineMessages({
+  optionsLabel: {
+    id: 'app.userList.userOptions.manageUsersLabel',
+    description: 'Manage user label',
+  },
+  clearAllLabel: {
+    id: 'app.userList.userOptions.clearAllLabel',
+    description: 'Clear all label',
+  },
+  clearAllDesc: {
+    id: 'app.userList.userOptions.clearAllDesc',
+    description: 'Clear all description',
+  },
+  muteAllLabel: {
+    id: 'app.userList.userOptions.muteAllLabel',
+    description: 'Mute all label',
+  },
+  muteAllDesc: {
+    id: 'app.userList.userOptions.muteAllDesc',
+    description: 'Mute all description',
+  },
+  lockViewersLabel: {
+    id: 'app.userList.userOptions.lockViewersLabel',
+    description: 'Lock viewers label',
+  },
+  lockViewersDesc: {
+    id: 'app.userList.userOptions.lockViewersDesc',
+    description: 'Lock viewers description',
+  },
+  muteAllExceptPresenterLabel: {
+    id: 'app.userList.userOptions.muteAllExceptPresenterLabel',
+    description: 'Mute all except presenter label',
+  },
+  muteAllExceptPresenterDesc: {
+    id: 'app.userList.userOptions.muteAllExceptPresenterDesc',
+    description: 'Mute all except presenter description',
+  },
+});
+
+class UserOptions extends Component {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isUserOptionsOpen: false,
+    };
+
+    this.onActionsShow = this.onActionsShow.bind(this);
+    this.onActionsHide = this.onActionsHide.bind(this);
+  }
+
+  componentWillMount() {
+    const { intl } = this.props;
+
+    this.menuItems = _.compact([
+      (<DropdownListItem
+        key={_.uniqueId('list-item-')}
+        icon="clear_status"
+        label={intl.formatMessage(intlMessages.clearAllLabel)}
+        description={intl.formatMessage(intlMessages.clearAllDesc)}
+        onClick={this.props.toggleStatus}
+      />),
+      (<DropdownListItem
+        key={_.uniqueId('list-item-')}
+        icon="mute"
+        label={intl.formatMessage(intlMessages.muteAllLabel)}
+        description={intl.formatMessage(intlMessages.muteAllDesc)}
+        onClick={this.props.toggleMuteAllUsers}
+      />),
+      (<DropdownListItem
+        key={_.uniqueId('list-item-')}
+        icon="mute"
+        label={intl.formatMessage(intlMessages.muteAllExceptPresenterLabel)}
+        description={intl.formatMessage(intlMessages.muteAllExceptPresenterDesc)}
+        onClick={this.props.toggleMuteAllUsersExceptPresenter}
+      />),
+      (<DropdownListItem
+        key={_.uniqueId('list-item-')}
+        icon="lock"
+        label={intl.formatMessage(intlMessages.lockViewersLabel)}
+        description={intl.formatMessage(intlMessages.lockViewersDesc)}
+        onClick={this.props.toggleLockView}
+      />),
+    ]);
+  }
+
+  onActionsShow() {
+    this.setState({
+      isUserOptionsOpen: true,
+    });
+  }
+
+  onActionsHide() {
+    this.setState({
+      isUserOptionsOpen: false,
+    });
+  }
+
+  render() {
+    const { intl } = this.props;
+
+    return (
+      <Dropdown
+        ref={(ref) => { this.dropdown = ref; }}
+        autoFocus={false}
+        isOpen={this.state.isUserOptionsOpen}
+        onShow={this.onActionsShow}
+        onHide={this.onActionsHide}
+        className={styles.dropdown}
+      >
+        <DropdownTrigger tabIndex={0}>
+          <Button
+            label={intl.formatMessage(intlMessages.optionsLabel)}
+            icon="settings"
+            circle
+            ghost
+            color="primary"
+            hideLabel
+            className={styles.optionsButton}
+            onClick={() => null}
+          />
+        </DropdownTrigger>
+        <DropdownContent
+          className={styles.dropdownContent}
+          placement="right top"
+        >
+          <DropdownList>
+            {
+              this.menuItems
+            }
+          </DropdownList>
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+}
+
+export default withModalMounter(injectIntl(UserOptions));
diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx
new file mode 100755
index 0000000000000000000000000000000000000000..199e859178beb5612cbeb2efa7363bf32b7289a6
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/container.jsx
@@ -0,0 +1,48 @@
+import React, { Component } from 'react';
+import logger from '/imports/startup/client/logger';
+import Auth from '/imports/ui/services/auth';
+import mapUser from '/imports/ui/services/user/mapUser';
+import Users from '/imports/api/users/';
+import UserOptions from './component';
+
+export default class UserOptionsContainer extends Component {
+  constructor(props) {
+    super(props);
+
+    this.muteAllUsers = this.muteAllUsers.bind(this);
+    this.muteAllUsersExceptPresenter = this.muteAllUsersExceptPresenter.bind(this);
+    this.handleLockView = this.handleLockView.bind(this);
+    this.handleClearStatus = this.handleClearStatus.bind(this);
+  }
+
+  muteAllUsers() {
+    logger.info('muteAllUsers function');
+  }
+
+  muteAllUsersExceptPresenter() {
+    logger.info('muteAllUsersExceptPresenter function');
+  }
+
+  handleLockView() {
+    logger.info('handleLockView function');
+  }
+
+  handleClearStatus() {
+    logger.info('handleClearStatus function');
+  }
+
+  render() {
+    const currentUser = Users.findOne({ userId: Auth.userID });
+    const currentUserIsModerator = mapUser(currentUser).isModerator;
+
+    return (
+      currentUserIsModerator ?
+        <UserOptions
+          toggleMuteAllUsers={this.muteAllUsers}
+          toggleMuteAllUsersExceptPresenter={this.muteAllUsersExceptPresenter}
+          toggleLockView={this.handleLockView}
+          toggleStatus={this.handleClearStatus}
+        /> : null
+    );
+  }
+}
diff --git a/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.scss b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.scss
new file mode 100755
index 0000000000000000000000000000000000000000..a7bdeaa4de88fd8eed76900f9f06121294491ad0
--- /dev/null
+++ b/bigbluebutton-html5/imports/ui/components/user-list/user-list-content/user-participants/user-options/styles.scss
@@ -0,0 +1,40 @@
+@import "/imports/ui/stylesheets/variables/_all";
+$icon-offset-left: -.44em;
+$icon-offset-top: -.38em;
+$square-side-length: 1.56rem;
+$dropdown-offset-top: 4.8rem;
+$dropdown-offset-right: -0.2rem;
+
+.dropdown {
+  position: static;
+}
+
+.dropdownContent {
+  top: $dropdown-offset-top;
+  margin-right: $dropdown-offset-right;
+}
+
+.optionsButton{
+  display: block;
+  margin-right: $sm-padding-y;
+
+  span:first-child {
+    width: $square-side-length;
+    height: $square-side-length;
+  }
+
+  i {
+    top: $icon-offset-top;
+    left: $icon-offset-left;
+    color: $color-gray-dark !important;
+    background-color: transparent !important;
+  }
+
+  &:hover,
+  &:focus {
+    > span:first-child {
+      background-color: transparent !important;
+    }
+  }
+
+}
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/component.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/component.jsx
index 0c9a8fe51822a1ab8820c490aa944cdabafea693..ea5fc527e89c3431a15ef5d0cccb150eefd1cbc7 100755
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/component.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/component.jsx
@@ -16,7 +16,6 @@ const TOOLBAR_CONFIG = Meteor.settings.public.whiteboard.toolbar;
 const ANNOTATION_COLORS = TOOLBAR_CONFIG.colors;
 const THICKNESS_RADIUSES = TOOLBAR_CONFIG.thickness;
 const FONT_SIZES = TOOLBAR_CONFIG.font_sizes;
-const ANNOTATION_TOOLS = TOOLBAR_CONFIG.tools;
 
 const intlMessages = defineMessages({
   toolbarTools: {
@@ -64,18 +63,27 @@ const intlMessages = defineMessages({
 const runExceptInEdge = fn => (browser().name === 'edge' ? noop : fn);
 
 class WhiteboardToolbar extends Component {
-  constructor() {
-    super();
+  constructor(props) {
+    super(props);
+
+    const { annotations } = this.props;
     const isMobile = browser().mobile;
+
+    let annotationSelected = {
+      icon: isMobile ? 'hand' : 'pen_tool',
+      value: isMobile ? 'hand' : 'pencil',
+    };
+
+    if (!annotations.some(el => el.value === annotationSelected.value) && annotations.length > 0) {
+      annotationSelected = annotations[annotations.length - 1];
+    }
+
     this.state = {
       // a variable to control which list is currently open
       currentSubmenuOpen: '',
 
       // variables to keep current selected draw settings
-      annotationSelected: {
-        icon: isMobile ? 'hand' : 'pen_tool',
-        value: isMobile ? 'hand' : 'pencil',
-      },
+      annotationSelected,
       thicknessSelected: { value: 4 },
       colorSelected: { value: '#000000' },
       fontSizeSelected: { value: 20 },
@@ -143,9 +151,17 @@ class WhiteboardToolbar extends Component {
   }
 
   componentDidUpdate(prevProps, prevState) {
+    const { annotations } = this.props;
+    const { annotationSelected } = prevState;
+    const hadInAnnotations = annotations.some(el => el.value === annotationSelected.value);
+
     // if color or thickness were changed
     // we might need to trigger svg animation for Color and Thickness icons
     this.animateSvgIcons(prevState);
+
+    if (!hadInAnnotations) {
+      this.handleAnnotationChange(annotations[annotations.length - 1]);
+    }
   }
 
   setToolbarValues(drawSettings) {
@@ -413,7 +429,14 @@ class WhiteboardToolbar extends Component {
     return (
       <svg className={styles.customSvgIcon} shapeRendering="geometricPrecision">
         <RenderInBrowser only edge>
-          <circle cx="50%" cy="50%" r={this.state.thicknessSelected.value} stroke="black" strokeWidth="1" fill={this.state.colorSelected.value} />
+          <circle
+            cx="50%"
+            cy="50%"
+            r={this.state.thicknessSelected.value}
+            stroke="black"
+            strokeWidth="1"
+            fill={this.state.colorSelected.value}
+          />
         </RenderInBrowser>
         <RenderInBrowser except edge>
           <circle
@@ -486,7 +509,15 @@ class WhiteboardToolbar extends Component {
     return (
       <svg className={styles.customSvgIcon}>
         <RenderInBrowser only edge>
-          <rect x="25%" y="25%" width="50%" height="50%" stroke="black" strokeWidth="1" fill={this.state.colorSelected.value} />
+          <rect
+            x="25%"
+            y="25%"
+            width="50%"
+            height="50%"
+            stroke="black"
+            strokeWidth="1"
+            fill={this.state.colorSelected.value}
+          />
         </RenderInBrowser>
         <RenderInBrowser except edge>
           <rect x="25%" y="25%" width="50%" height="50%" stroke="black" strokeWidth="1">
@@ -574,7 +605,6 @@ WhiteboardToolbar.defaultProps = {
   colors: ANNOTATION_COLORS,
   thicknessRadiuses: THICKNESS_RADIUSES,
   fontSizes: FONT_SIZES,
-  annotations: ANNOTATION_TOOLS,
 };
 
 WhiteboardToolbar.propTypes = {
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/container.jsx b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/container.jsx
index 0c84c2f78c2d0df4e26aae9a47613f68b6fca8cb..c6b981bbd690620d79790c1e79937fa8b6ad1cf3 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/container.jsx
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/container.jsx
@@ -25,6 +25,7 @@ export default withTracker((params) => {
     textShapeActiveId: WhiteboardToolbarService.getTextShapeActiveId(),
     multiUser: WhiteboardToolbarService.getMultiUserStatus(whiteboardId),
     isPresenter: WhiteboardToolbarService.isPresenter(),
+    annotations: WhiteboardToolbarService.filterAnnotationList(),
   };
 
   return data;
diff --git a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/service.js b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/service.js
index d9e1ff21d49709f7928e30aec28138e90455d081..3a3e5004ef1426c96434f5c8d04053f4eea08358 100644
--- a/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/service.js
+++ b/bigbluebutton-html5/imports/ui/components/whiteboard/whiteboard-toolbar/service.js
@@ -3,8 +3,10 @@ import Storage from '/imports/ui/services/storage/session';
 import Users from '/imports/api/users';
 import Auth from '/imports/ui/services/auth';
 import WhiteboardMultiUser from '/imports/api/whiteboard-multi-user/';
+import getFromUserSettings from '/imports/ui/services/users-settings';
 
 const DRAW_SETTINGS = 'drawSettings';
+const WHITEBOARD_TOOLBAR = Meteor.settings.public.whiteboard.toolbar;
 
 const makeSetter = key => (value) => {
   const drawSettings = Storage.getItem(DRAW_SETTINGS);
@@ -67,6 +69,33 @@ const isPresenter = () => {
   return currentUser ? currentUser.presenter : false;
 };
 
+const filterAnnotationList = () => {
+  const multiUserPenOnly = getFromUserSettings('multiUserPenOnly', WHITEBOARD_TOOLBAR.multiUserPenOnly);
+
+  let filteredAnnotationList = WHITEBOARD_TOOLBAR.tools;
+
+  if (!isPresenter() && multiUserPenOnly) {
+    filteredAnnotationList = [{
+      icon: 'pen_tool',
+      value: 'pencil',
+    }];
+  }
+
+  const presenterTools = getFromUserSettings('presenterTools', WHITEBOARD_TOOLBAR.presenterTools);
+  if (isPresenter() && Array.isArray(presenterTools)) {
+    filteredAnnotationList = WHITEBOARD_TOOLBAR.tools.filter(el =>
+      presenterTools.includes(el.value));
+  }
+
+  const multiUserTools = getFromUserSettings('multiUserTools', WHITEBOARD_TOOLBAR.multiUserTools);
+  if (!isPresenter() && !multiUserPenOnly && Array.isArray(multiUserTools)) {
+    filteredAnnotationList = WHITEBOARD_TOOLBAR.tools.filter(el =>
+      multiUserTools.includes(el.value));
+  }
+
+  return filteredAnnotationList;
+};
+
 export default {
   undoAnnotation,
   clearWhiteboard,
@@ -81,4 +110,5 @@ export default {
   getTextShapeActiveId,
   getMultiUserStatus,
   isPresenter,
+  filterAnnotationList,
 };
diff --git a/bigbluebutton-html5/imports/utils/slideCalcUtils.js b/bigbluebutton-html5/imports/utils/slideCalcUtils.js
index b2aee0aab47f0d0841f23d5cc829cfcb8caa2c7d..b136f8c1dbda839878e5e09c20377574754a83c8 100644
--- a/bigbluebutton-html5/imports/utils/slideCalcUtils.js
+++ b/bigbluebutton-html5/imports/utils/slideCalcUtils.js
@@ -1,7 +1,7 @@
 const HUNDRED_PERCENT = 100;
 const MYSTERY_NUM = 2;
 export default class SlideCalcUtil {
-  // After lots of trial and error on why synching doesn't work properly, I found I had to 
+  // After lots of trial and error on why synching doesn't work properly, I found I had to
   // multiply the coordinates by 2. There's something I don't understand probably on the
   // canvas coordinate system. (ralam feb 22, 2012)
 
@@ -28,14 +28,14 @@ export default class SlideCalcUtil {
     if (ftp) {
       return (vpw / vrw) * HUNDRED_PERCENT;
     }
-      return vpw;
+    return vpw;
   }
 
   static calcCalcPageSizeHeight(ftp, vph, vrh, cpw, cph, opw, oph) {
     if (ftp) {
       return (vph / vrh) * HUNDRED_PERCENT;
     }
-      return (cpw / opw) * oph;
+    return (cpw / opw) * oph;
   }
 
   static calcViewedRegionX(cpx, cpw) {
@@ -50,7 +50,7 @@ export default class SlideCalcUtil {
     if (vpw == pw) {
       return 0;
     }
-      return (pw - vpw) / MYSTERY_NUM;
+    return (pw - vpw) / MYSTERY_NUM;
   }
 
   static calculateViewportY(vph, ph) {
@@ -58,6 +58,5 @@ export default class SlideCalcUtil {
       return 0;
     }
     return (ph - vph) / MYSTERY_NUM;
-    
   }
-}
\ No newline at end of file
+}
diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml
index 30e126c8e6b875886786d6dc2820110681ecdb13..22d04268a4485ce61cd60de108dd303bf594bcbb 100644
--- a/bigbluebutton-html5/private/config/settings.yml
+++ b/bigbluebutton-html5/private/config/settings.yml
@@ -98,6 +98,7 @@ public:
     enableVideo: false
     enableVideoStats: false
     enableListenOnly: false
+    autoShareWebcam: false
   acl:
     viewer:
       subscriptions:
@@ -169,6 +170,9 @@ public:
     storage_key: UNREAD_CHATS
     system_messages_keys:
       chat_clear: PUBLIC_CHAT_CLEAR
+  layout:
+    autoSwapLayout: false
+    hidePresentation: false
   media:
     WebRTCHangupRetryInterval: 2000
     vertoServerAddress: HOST
@@ -216,6 +220,7 @@ public:
         update: DRAW_UPDATE
         end: DRAW_END
     toolbar:
+      multiUserPenOnly: false
       colors:
       - label: black
         value: "#000000"
@@ -272,6 +277,22 @@ public:
         value: pencil
       - icon: hand
         value: hand
+      presenterTools:
+      - text
+      - line
+      - ellipse
+      - triangle
+      - rectangle
+      - pencil
+      - hand
+      multiUserTools:
+      - text
+      - line
+      - ellipse
+      - triangle
+      - rectangle
+      - pencil
+      - hand
   clientLog:
     server:
       enabled: true
diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json
index 01521cbb1c12aad257a388330a4790e7f1c2c9a8..2273d465e3461f2ae03f98bc73e3ff09eae0283a 100755
--- a/bigbluebutton-html5/private/locales/en.json
+++ b/bigbluebutton-html5/private/locales/en.json
@@ -1,5 +1,5 @@
 {
-    "app.home.greeting": "Welcome {0}! Your presentation will begin shortly...",
+    "app.home.greeting": "Your presentation will begin shortly...",
     "app.chat.submitLabel": "Send Message",
     "app.chat.errorMinMessageLength": "The message is {0} characters(s) too short",
     "app.chat.errorMaxMessageLength": "The message is {0} characters(s) too long",
@@ -39,6 +39,15 @@
     "app.userList.userAriaLabel": "{0} {1} {2}  Status {3}",
     "app.userList.menu.promoteUser.label": "Promote to moderator",
     "app.userList.menu.demoteUser.label": "Demote to viewer",
+    "app.userList.userOptions.manageUsersLabel": "Manage Users",
+    "app.userList.userOptions.muteAllLabel": "Mute all users",
+    "app.userList.userOptions.muteAllDesc": "Mutes all users in the meeting",
+    "app.userList.userOptions.clearAllLabel": "Clear all status icons",
+    "app.userList.userOptions.clearAllDesc": "Clears all status icons from users",
+    "app.userList.userOptions.muteAllExceptPresenterLabel": "Mute all users except presenter",
+    "app.userList.userOptions.muteAllExceptPresenterDesc": "Mute all users in the meeting except the presenter",
+    "app.userList.userOptions.lockViewersLabel": "Lock viewers",
+    "app.userList.userOptions.lockViewersDesc": "Lock certain functionalities for attendees of the meeting",
     "app.media.label": "Media",
     "app.media.screenshare.start": "Screenshare has started",
     "app.media.screenshare.end": "Screenshare has ended",
diff --git a/bigbluebutton-html5/test-html5.sh b/bigbluebutton-html5/test-html5.sh
new file mode 100755
index 0000000000000000000000000000000000000000..d96e5c3039636dc2588a10da6607eda83e006405
--- /dev/null
+++ b/bigbluebutton-html5/test-html5.sh
@@ -0,0 +1,30 @@
+export BBB_SERVER_URL="http://localhost/bigbluebutton/api"
+
+# Change to HTML5 directory
+cd $(dirname $0)
+echo "Working directory: $PWD"
+
+# Build and run Docker image
+docker build -t b2 .
+docker=$(docker run -d -p 80:80/tcp -p 443:443/tcp -p 1935:1935 -p 5066:5066 -p 3478:3478 -p 3478:3478/udp b2 -h localhost)
+echo $docker
+
+# Check if HTML5 client is ready 
+cd tests/puppeteer
+node html5-check.js
+status=$?
+echo $status
+
+conf=$(docker exec $(docker ps -q) bbb-conf --secret | grep "Secret:")
+secret=$(echo $conf | cut -d' ' -f2)
+export BBB_SHARED_SECRET=$secret
+echo $BBB_SHARED_SECRET
+
+# Run tests
+if [ $status -eq 0 ]; then
+  npm test
+fi
+
+# Stop Docker container
+docker stop $docker
+docker rm $docker
diff --git a/bigbluebutton-html5/tests/puppeteer/.env-template b/bigbluebutton-html5/tests/puppeteer/.env-template
new file mode 100644
index 0000000000000000000000000000000000000000..d8e887232b5e5c27cb92ec9c33f99f39e7c7b5af
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/.env-template
@@ -0,0 +1,2 @@
+BBB_SERVER_URL=""
+BBB_SHARED_SECRET=""
\ No newline at end of file
diff --git a/bigbluebutton-html5/tests/puppeteer/.gitignore b/bigbluebutton-html5/tests/puppeteer/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..684b68e5b84ca5eec207b1e3763a28ad6ecbed6d
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+screenshots/*
+!screenshots/screenshots.txt
+.directory
+.env
diff --git a/bigbluebutton-html5/tests/puppeteer/README.md b/bigbluebutton-html5/tests/puppeteer/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..4508a3d46648004b278d8148bb98fe34b439f405
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/README.md
@@ -0,0 +1,37 @@
+# BigBlueButton Puppeteer Tests
+
+Tests for BigBlueButton using Puppeteer, Chromium and Jest.
+
+## Setup
+
+To run these tests, you will need the following:
+* Ubuntu 16.04 or later
+* Node.js 8.11.4 or later
+* Docker
+
+These instructions assume you have the BigBlueButton repository cloned into a directory named `bigbluebutton`.
+
+First, install the required modules with `npm install`, from this directory. When Puppeteer installs, it will automatically install the Chromium browser in which the tests will run.
+
+To run individual tests, you can also optionally install Jest globally with `sudo npm install jest -g`.
+
+## Running the tests with an existing BigBlueButton server
+
+To run these tests with an existing BigBlueButton server, make sure you have a server set up, and that you have the server's URL and secret. These will be the same URL and secret you would use to make API calls to the server. If you do not have these, you can find them by running `bbb-conf --secret` from the terminal in the server.
+
+Copy the `.env-template` file to a new file, and name it `.env`. In the `.env` file, add your BigBlueButton server URL and secret, so the tests will know which server to connect to.
+
+To run all the tests at once, run `npm test`. If you have Jest installed globally, you can run individual tests with `jest TEST [TEST...]`. The tests are found in the `.test.js` files, but you may choose to omit file extensions when running the tests.
+
+## Running the tests in a Docker container
+
+Using this method, you can run the tests with the latest version of the HTML5 client, which you can find in this repository. You will need Docker to run tests this way. To run the tests, just run `./test-html5.sh` from the `bigbluebutton/bigbluebutton-html5` directory. The script will start a Docker container with a BigBlueButton server and the source code for the HTML5 client, and will run the tests with this server before stopping and removing the container.
+
+### Note
+
+The HTML5 client takes a long time to start in the Docker container. The script will check if the HTML5 client is running before running the tests, but it will exit if it takes too many attempts. If the HTML5 client takes too long to start and the check exits without running the tests, you can experiment with the values of `maxRetries` and `retryDelay` in `html5-check.js`. Note that the value of `retryDelay` is in milliseconds.
+
+## Known Issues
+
+* Hotkeys do not work yet. When hotkeys are pressed, keydown and keyup events are fired, but the click events that would normally be created to press buttons do not occur.
+* Some tests will sometimes fail with a timeout error. Different tests may fail every time the tests are run. This problem affects all tests, and the cause is unknown as of now.
\ No newline at end of file
diff --git a/bigbluebutton-html5/tests/puppeteer/elements.js b/bigbluebutton-html5/tests/puppeteer/elements.js
new file mode 100644
index 0000000000000000000000000000000000000000..7fe848ad23e80695cb733522c66382823dca9b77
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/elements.js
@@ -0,0 +1,43 @@
+exports.audioDialog = '.ReactModal__Content[aria-label="Modal"]';
+exports.closeAudio = 'button[aria-label="Close"]';
+exports.microphoneButton = 'button[aria-label="Microphone"]';
+exports.listenButton = 'button[aria-label="Listen Only"]';
+exports.echoYes = 'button[aria-label="Echo is audible"]';
+exports.title = '._imports_ui_components_nav_bar__styles__presentationTitle';
+exports.alerts = '.toastify-content';
+exports.skipSlide = '#skipSlide';
+exports.statusIcon = '._imports_ui_components_user_avatar__styles__content';
+
+exports.actions = 'button[aria-label="Actions"]';
+exports.options = 'button[aria-label="Options"]';
+exports.userList = 'button[aria-label="Users and Messages Toggle"]';
+exports.uploadPresentation = '._imports_ui_components_dropdown__styles__top-left > div:nth-child(1) > ul:nth-child(1) > li:nth-child(1)';
+exports.joinAudio = 'button[aria-label="Join Audio"]';
+exports.leaveAudio = 'button[aria-label="Leave Audio"]';
+exports.videoMenu = 'button[aria-label="Open video menu dropdown"]';
+exports.screenShare = 'button[aria-label="Share your screen"]';
+
+exports.chatButton = 'div._imports_ui_components_user_list_chat_list_item__styles__chatName';
+exports.chatBox = '#message-input';
+exports.sendButton = '[aria-label="Send Message"]';
+exports.chatMessages = '#chat-messages';
+
+exports.whiteboard = 'svg._imports_ui_components_presentation__styles__svgStyles';
+exports.toolbox = '._imports_ui_components_whiteboard_whiteboard_toolbar__styles__toolbarContainer';
+exports.tools = 'button[aria-label="Tools"]';
+exports.pencil = 'button[aria-label="Pencil"]';
+exports.rectangle = 'button[aria-label="Rectangle"]';
+
+exports.presentationToolbarWrapper = '#presentationToolbarWrapper';
+exports.nextSlide = 'button[aria-label="Next slide"]';
+exports.prevSlide = 'button[aria-label="Previous slide"]';
+
+exports.fileUpload = 'input[type="file"]';
+exports.start = 'button[aria-label="Start"]';
+exports.cancel = 'button[aria-label="Cancel]';
+
+exports.firstUser = 'div._imports_ui_components_user_list_user_list_content__styles__participantsList:nth-child(1)';
+exports.setStatus = '._imports_ui_components_user_list_user_list_content_user_participants_user_list_item_user_dropdown__styles__usertListItemWithMenu > div:nth-child(2) > div:nth-child(1) > ul:nth-child(1) > li:nth-child(1)';
+exports.away = '._imports_ui_components_user_list_user_list_content_user_participants_user_list_item_user_dropdown__styles__usertListItemWithMenu > div:nth-child(2) > div:nth-child(1) > ul:nth-child(1) > li:nth-child(3)';
+exports.applaud = 'li._imports_ui_components_dropdown_list__styles__item:nth-child(9)';
+exports.clearStatus = '._imports_ui_components_user_list_user_list_content_user_participants_user_list_item_user_dropdown__styles__usertListItemWithMenu > div:nth-child(2) > div:nth-child(1) > ul:nth-child(1) > li:nth-child(2)';
diff --git a/bigbluebutton-html5/tests/puppeteer/helper.js b/bigbluebutton-html5/tests/puppeteer/helper.js
new file mode 100644
index 0000000000000000000000000000000000000000..20bd2b34e0d169368a422f170cdf3c2b71312720
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/helper.js
@@ -0,0 +1,49 @@
+require('dotenv').config();
+const sha1 = require('sha1');
+const path = require('path');
+const axios = require('axios');
+
+const httpPath = path.join(path.dirname(require.resolve('axios')), 'lib/adapters/http');
+const http = require(httpPath);
+
+const params = require('./params');
+const e = require('./elements');
+
+function getRandomInt(min, max) {
+  min = Math.ceil(min);
+  max = Math.floor(max);
+  return Math.floor(Math.random() * (max - min)) + min;
+}
+
+async function createMeeting(params) {
+  const meetingID = `random-${getRandomInt(1000000, 10000000).toString()}`;
+  const mp = params.moderatorPW;
+  const ap = params.attendeePW;
+  const query = `name=${meetingID}&meetingID=${meetingID}&attendeePW=${ap}&moderatorPW=${mp}&joinViaHtml5=true` +
+    `&record=false&allowStartStopRecording=true&autoStartRecording=false&welcome=${params.welcome}`;
+  const apicall = `create${query}${params.secret}`;
+  const checksum = sha1(apicall);
+  const url = `${params.server}/create?${query}&checksum=${checksum}`;
+  const response = await axios.get(url, { adapter: http });
+  return meetingID;
+}
+
+function getJoinURL(meetingID, params, moderator) {
+  const pw = moderator ? params.moderatorPW : params.attendeePW;
+  const query = `fullName=${params.fullName}&joinViaHtml5=true&meetingID=${meetingID}&password=${pw}`;
+  const apicall = `join${query}${params.secret}`;
+  const checksum = sha1(apicall);
+  const url = `${params.server}/join?${query}&checksum=${checksum}`;
+  return url;
+}
+
+function sleep(time) {
+  return new Promise((resolve) => {
+    setTimeout(resolve, time);
+  });
+}
+
+exports.getRandomInt = getRandomInt;
+exports.createMeeting = createMeeting;
+exports.getJoinURL = getJoinURL;
+exports.sleep = sleep;
diff --git a/bigbluebutton-html5/tests/puppeteer/html5-check.js b/bigbluebutton-html5/tests/puppeteer/html5-check.js
new file mode 100644
index 0000000000000000000000000000000000000000..1a94182a260ab08f2cb4c039881f2c84456476ff
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/html5-check.js
@@ -0,0 +1,42 @@
+require('dotenv').config();
+const axios = require('axios');
+const path = require('path');
+const url = require('url');
+const helper = require('./helper');
+
+const httpPath = path.join(path.dirname(require.resolve('axios')), 'lib/adapters/http');
+const http = require(httpPath);
+
+(async () => {
+  const bbb = url.parse(process.env.BBB_SERVER_URL);
+  const check = `${bbb.protocol}//${bbb.hostname}/html5client/check`;
+  console.log(`HTML5 check URL: ${check}`);
+  const maxRetries = 20;
+  const retryDelay = 10000;
+  let retryCount = 0;
+  while (true) {
+    try {
+      const response = await axios.get(check, { adapter: http });
+      const status = response.data.html5clientStatus;
+      console.log(response.data);
+      if (status === 'running') {
+        break;
+      } else if (retryCount < maxRetries) {
+        retryCount++;
+      } else {
+        console.log('Too many attempts. Exiting...');
+        process.exit(1);
+      }
+    } catch (e) {
+      console.log(e.message);
+      if (retryCount < maxRetries) {
+        retryCount++;
+      } else {
+        console.log('Too many attempts. Exiting...');
+        process.exit(1);
+      }
+    }
+    console.log(`Retrying (attempt ${retryCount}/${maxRetries})...`);
+    await helper.sleep(retryDelay);
+  }
+})();
diff --git a/bigbluebutton-html5/tests/puppeteer/jest.config.js b/bigbluebutton-html5/tests/puppeteer/jest.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..462d7cd2177fd2d0a2c13d1796329ac62446cd51
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/jest.config.js
@@ -0,0 +1,4 @@
+module.exports =
+{
+  setupTestFrameworkScriptFile: './jest.setup.js',
+};
diff --git a/bigbluebutton-html5/tests/puppeteer/jest.setup.js b/bigbluebutton-html5/tests/puppeteer/jest.setup.js
new file mode 100644
index 0000000000000000000000000000000000000000..09ffd355dfadb6543b4a1bae554146506c2276df
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/jest.setup.js
@@ -0,0 +1 @@
+jest.setTimeout(60000);
diff --git a/bigbluebutton-html5/tests/puppeteer/package.json b/bigbluebutton-html5/tests/puppeteer/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..32f72ff3d99861a8460dd33f795b31032c5f7444
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/package.json
@@ -0,0 +1,22 @@
+{
+  "name": "bigbluebutton-tests",
+  "version": "1.0.0",
+  "description": "",
+  "main": "app.js",
+  "scripts": {
+    "test": "jest"
+  },
+  "jest": {
+    "verbose": false
+  },
+  "author": "",
+  "license": "ISC",
+  "dependencies": {
+    "axios": "^0.18.0",
+    "dotenv": "^6.0.0",
+    "jest": "^23.5.0",
+    "puppeteer": "^1.7.0",
+    "sha1": "^1.1.1"
+  },
+  "devDependencies": {}
+}
diff --git a/bigbluebutton-html5/tests/puppeteer/page-chat.js b/bigbluebutton-html5/tests/puppeteer/page-chat.js
new file mode 100644
index 0000000000000000000000000000000000000000..aa3675af1173f11a81ecbc7ef2daefd98efe8516
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-chat.js
@@ -0,0 +1,52 @@
+// Test: Sending a chat message
+
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class ChatTestPage extends Page {
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinWithoutAudio();
+
+    await this.page.waitFor(e.chatButton);
+    await this.page.click(e.chatButton);
+    await this.page.waitFor(e.chatBox);
+    await this.page.waitFor(e.chatMessages);
+
+    const messages0 = await this.getTestElements();
+
+    await this.page.type(e.chatBox, 'Hello world!');
+    await this.page.click(e.sendButton);
+    await helper.sleep(500);
+
+    await this.page.screenshot({ path: 'screenshots/test-chat.png' });
+
+    const messages1 = await this.getTestElements();
+
+    console.log('\nChat messages before posting:');
+    console.log(JSON.stringify(messages0, null, 2));
+    console.log('\nChat messages after posting:');
+    console.log(JSON.stringify(messages1, null, 2));
+  }
+
+  async getTestElements() {
+    const messages = await this.page.evaluate((chat) => {
+      const messages = [];
+      const children = document.querySelector(chat).childNodes;
+      for (let i = 0; i < children.length; i++) {
+        let content = children[i].childNodes[0].childNodes[1];
+        if (content) {
+          content = content.childNodes;
+          messages.push({ name: content[0].innerText, message: content[1].innerText });
+        }
+      }
+      console.log(messages);
+      return messages;
+    }, e.chatMessages);
+
+    return messages;
+  }
+}
+
+module.exports = exports = ChatTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/page-chat.test.js b/bigbluebutton-html5/tests/puppeteer/page-chat.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..51f863bee3c48895db7c4e6709485e0a355fea1c
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-chat.test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const ChatTestPage = require('./page-chat');
+
+test('Tests sending a message in chat', async () => {
+  const test = new ChatTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/page-draw.js b/bigbluebutton-html5/tests/puppeteer/page-draw.js
new file mode 100644
index 0000000000000000000000000000000000000000..08d6c854427a26d00120481013d75cf288567d38
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-draw.js
@@ -0,0 +1,41 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class DrawTestPage extends Page {
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinWithoutAudio();
+
+    await this.page.waitFor(e.tools);
+    await this.page.click(e.tools);
+    await this.page.waitFor(e.rectangle);
+    await this.page.click(e.rectangle);
+    await this.page.waitFor(e.whiteboard);
+
+    const shapes0 = await this.getTestElements();
+
+    const wb = await this.page.$(e.whiteboard);
+    const wbBox = await wb.boundingBox();
+    await this.page.mouse.move(wbBox.x + 0.3 * wbBox.width, wbBox.y + 0.3 * wbBox.height);
+    await this.page.mouse.down();
+    await this.page.mouse.move(wbBox.x + 0.7 * wbBox.width, wbBox.y + 0.7 * wbBox.height);
+    await this.page.mouse.up();
+
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-draw.png' });
+    const shapes1 = await this.getTestElements();
+
+    console.log('\nShapes before drawing box:');
+    console.log(shapes0);
+    console.log('\nShapes after drawing box:');
+    console.log(shapes1);
+  }
+
+  async getTestElements() {
+    const shapes = await this.page.evaluate(() => document.querySelector('svg g[clip-path]').children[1].outerHTML);
+    return shapes;
+  }
+}
+
+module.exports = exports = DrawTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/page-draw.test.js b/bigbluebutton-html5/tests/puppeteer/page-draw.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..d143df16fd9fa1d4e70ccd13d6ad01c7ac9f5da4
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-draw.test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const DrawTestPage = require('./page-draw');
+
+test('Tests drawing a box on the whiteboard', async () => {
+  const test = new DrawTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/page-status.js b/bigbluebutton-html5/tests/puppeteer/page-status.js
new file mode 100644
index 0000000000000000000000000000000000000000..2b43519abc4484d8bc95a6059ce00290b62c0e29
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-status.js
@@ -0,0 +1,48 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class StatusTestPage extends Page {
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinWithoutAudio();
+    await this.page.screenshot({ path: 'screenshots/test-status-0.png' });
+    const status0 = await this.getTestElements();
+
+    await this.page.click(e.firstUser);
+    await this.page.click(e.setStatus);
+    await this.page.click(e.applaud);
+    await helper.sleep(100);
+    await this.page.screenshot({ path: 'screenshots/test-status-1.png' });
+    const status1 = await this.getTestElements();
+
+    await this.page.click(e.firstUser);
+    await this.page.click(e.setStatus);
+    await this.page.click(e.away);
+    await helper.sleep(100);
+    await this.page.screenshot({ path: 'screenshots/test-status-2.png' });
+    const status2 = await this.getTestElements();
+
+    await this.page.click(e.firstUser);
+    await this.page.click(e.clearStatus);
+    await helper.sleep(100);
+    await this.page.screenshot({ path: 'screenshots/test-status-3.png' });
+    const status3 = await this.getTestElements();
+
+    console.log('\nStatus at start of meeting:');
+    console.log(status0);
+    console.log('\nStatus after status set (applaud):');
+    console.log(status1);
+    console.log('\nStatus after status change (away):');
+    console.log(status2);
+    console.log('\nStatus after status clear:');
+    console.log(status3);
+  }
+
+  async getTestElements() {
+    const status = await this.page.evaluate(statusIcon => document.querySelector(statusIcon).innerHTML, e.statusIcon);
+    return status;
+  }
+}
+
+module.exports = exports = StatusTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/page-status.test.js b/bigbluebutton-html5/tests/puppeteer/page-status.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..c4da341487a2d718d70de3ae3e834e90e57db849
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-status.test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const StatusTestPage = require('./page-status');
+
+test("Tests setting/changing/clearing a user's status", async () => {
+  const test = new StatusTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/page-switch-slides.js b/bigbluebutton-html5/tests/puppeteer/page-switch-slides.js
new file mode 100644
index 0000000000000000000000000000000000000000..3c76b408b6d2c6f91b8f470643cdc086675b9f3a
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-switch-slides.js
@@ -0,0 +1,38 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class SlideSwitchTestPage extends Page {
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinWithoutAudio();
+
+    await this.page.waitFor(e.whiteboard);
+    await this.page.waitFor(e.presentationToolbarWrapper);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-switch-slides-0.png' });
+    const svg0 = await this.getTestElements();
+    await this.page.click(e.nextSlide);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-switch-slides-1.png' });
+    const svg1 = await this.getTestElements();
+    await this.page.click(e.prevSlide);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-switch-slides-2.png' });
+    const svg2 = await this.getTestElements();
+
+    console.log('\nStarting slide:');
+    console.log(svg0);
+    console.log('\nAfter next slide:');
+    console.log(svg1);
+    console.log('\nAfter previous slide:');
+    console.log(svg2);
+  }
+
+  async getTestElements() {
+    const svg = await this.page.evaluate(() => document.querySelector('svg g g g').outerHTML);
+    return svg;
+  }
+}
+
+module.exports = exports = SlideSwitchTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/page-switch-slides.test.js b/bigbluebutton-html5/tests/puppeteer/page-switch-slides.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..3d06081228711a9a5ea3db5537747f7b75fdcfdc
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-switch-slides.test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const SlideSwitchTestPage = require('./page-switch-slides');
+
+test('Tests switching slides', async () => {
+  const test = new SlideSwitchTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/page-upload.js b/bigbluebutton-html5/tests/puppeteer/page-upload.js
new file mode 100644
index 0000000000000000000000000000000000000000..5080ee47aa8d3378292e6b84f8631f074e30fe46
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-upload.js
@@ -0,0 +1,46 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class UploadTestPage extends Page {
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinWithoutAudio();
+
+    await this.page.waitFor(e.actions);
+    await this.page.waitFor(e.whiteboard);
+    await this.page.waitFor(e.skipSlide);
+    await this.page.click(e.actions);
+    await this.page.waitFor(e.uploadPresentation);
+
+    const slides0 = await this.getTestElements();
+
+    await this.page.click(e.uploadPresentation);
+    await this.page.waitFor(e.fileUpload);
+    const fileUpload = await this.page.$(e.fileUpload);
+    await fileUpload.uploadFile(`${__dirname}/upload-test.png`);
+    await this.page.waitFor(e.start);
+    await this.page.click(e.start);
+    await this.elementRemoved(e.start);
+
+    await helper.sleep(1000);
+    await this.page.screenshot({ path: 'screenshots/test-upload.png' });
+    const slides1 = await this.getTestElements();
+
+    console.log('\nSlides before presentation upload:');
+    console.log(slides0.slideList);
+    console.log(slides0.svg);
+    console.log('\nSlides after presentation upload:');
+    console.log(slides1.slideList);
+    console.log(slides1.svg);
+  }
+
+  async getTestElements() {
+    const slides = {};
+    slides.svg = await this.page.evaluate(() => document.querySelector('svg g g g').outerHTML);
+    slides.slideList = await this.page.evaluate(skipSlide => document.querySelector(skipSlide).innerHTML, e.skipSlide);
+    return slides;
+  }
+}
+
+module.exports = exports = UploadTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/page-upload.test.js b/bigbluebutton-html5/tests/puppeteer/page-upload.test.js
new file mode 100644
index 0000000000000000000000000000000000000000..f18539bfcc15b465cc4b24d6bd14f8f73a1d2cf3
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page-upload.test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const UploadTestPage = require('./page-upload');
+
+test('Tests uploading an image as a presentation', async () => {
+  const test = new UploadTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/page.js b/bigbluebutton-html5/tests/puppeteer/page.js
new file mode 100644
index 0000000000000000000000000000000000000000..3bfea73f628794cb27ad6b806cc47ffa7c923c75
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/page.js
@@ -0,0 +1,122 @@
+const puppeteer = require('puppeteer');
+const helper = require('./helper');
+const params = require('./params');
+const e = require('./elements');
+
+class Page {
+  // Initializes the page
+  async init(args) {
+    this.browser = await puppeteer.launch(args);
+    this.page = await this.browser.newPage();
+  }
+
+  // Navigates to a page
+  async goto(page) {
+    this.page.goto(page);
+  }
+
+  // Run the test for the page
+  async test() {
+  }
+
+  // Closes the page
+  async close() {
+    await this.browser.close();
+  }
+
+  // Gets the DOM elements being tested, as strings
+  async getTestElements() {
+  }
+
+  // Get the default arguments for creating a page
+  static getArgs() {
+    return { headless: true, args: ['--no-sandbox', '--use-fake-ui-for-media-stream'] };
+  }
+
+  // Creates a BigBlueButton meeting
+  async createBBBMeeting() {
+    const meetingID = await helper.createMeeting(params);
+    await this.joinBBBMeeting(meetingID);
+    return meetingID;
+  }
+
+  // Navigates the page to join a BigBlueButton meeting
+  async joinBBBMeeting(meetingID) {
+    const joinURL = helper.getJoinURL(meetingID, params, true);
+    await this.goto(joinURL);
+  }
+
+  // Joins a BigBlueButton as a listener
+  async joinAudioListenOnly() {
+    await this.page.waitFor(e.listenButton);
+    await this.page.click(e.listenButton);
+    await this.elementRemoved(e.audioDialog);
+    console.log('Joined meeting as listener');
+  }
+
+  // Joins a BigBlueButton meeting with a microphone
+  async joinAudioMicrophone() {
+    await this.page.waitFor(e.microphoneButton);
+    await this.page.click(e.microphoneButton);
+    await this.page.waitFor(e.echoYes);
+    await helper.sleep(500); // Echo test confirmation sometimes fails without this
+    await this.page.click(e.echoYes);
+    await this.elementRemoved(e.audioDialog);
+    console.log('Joined meeting with microphone');
+  }
+
+  // Joins a BigBlueButton meeting without audio
+  async joinWithoutAudio() {
+    await this.page.waitFor(e.listenButton);
+    await this.page.waitFor(e.closeAudio);
+    await this.page.click(e.closeAudio);
+    await this.elementRemoved(e.audioDialog);
+    console.log('Joined meeting without audio');
+  }
+
+  // Returns a Promise that resolves when an element does not exist/is removed from the DOM
+  elementRemoved(element) {
+    return this.page.waitFor(element => !document.querySelector(element), {}, element);
+  }
+
+  // Presses a hotkey (Ctrl, Alt and Shift can be held down while pressing the key)
+  async hotkey(key, ctrl, alt, shift) {
+    if (ctrl) { await this.page.keyboard.down('Control'); }
+    if (alt) { await this.page.keyboard.down('Alt'); }
+    if (shift) { await this.page.keyboard.down('Shift'); }
+
+    await this.page.keyboard.press(key);
+
+    if (ctrl) { await this.page.keyboard.up('Control'); }
+    if (alt) { await this.page.keyboard.up('Alt'); }
+    if (shift) { await this.page.keyboard.up('Shift'); }
+  }
+
+  // Presses the Tab key a set number of times
+  async tab(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('Tab');
+    }
+  }
+
+  // Presses the Enter key
+  async enter() {
+    await this.page.keyboard.press('Enter');
+  }
+
+  // Presses the Down Arrow key a set number of times
+  async down(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('ArrowDown');
+    }
+  }
+
+  // Presses the up arrow key a set number of times
+  async up(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('ArrowUp');
+    }
+  }
+}
+
+module.exports = exports = Page;
diff --git a/bigbluebutton-html5/tests/puppeteer/params.js b/bigbluebutton-html5/tests/puppeteer/params.js
new file mode 100644
index 0000000000000000000000000000000000000000..5695e082e35b601dc05aea270b2aabf86c1084c5
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/params.js
@@ -0,0 +1,9 @@
+module.exports = exports =
+{
+  server: process.env.BBB_SERVER_URL,
+  secret: process.env.BBB_SHARED_SECRET,
+  welcome: '%3Cbr%3EWelcome+to+%3Cb%3E%25%25CONFNAME%25%25%3C%2Fb%3E%21',
+  fullName: 'User1',
+  moderatorPW: 'mp',
+  attendeePW: 'ap',
+};
diff --git a/bigbluebutton-html5/tests/puppeteer/screenshots/screenshots.txt b/bigbluebutton-html5/tests/puppeteer/screenshots/screenshots.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1928861f4008bfa55dbab4cf9a8b10df464ac3ae
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/screenshots/screenshots.txt
@@ -0,0 +1 @@
+This is where screenshots from the BigBlueButton tests will be saved. 
diff --git a/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first-test.js b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first-test.js
new file mode 100644
index 0000000000000000000000000000000000000000..ffaedd122d809caddc15c7c63a993e8f71152972
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first-test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const HotkeysMicFirstTestPage = require('./page-hotkeys-mic-first');
+
+test('Tests hotkeys when a user first joins a meeting with a microphone: Leaving audio, rejoining as Listen Only, then rejoining with microphone', async () => {
+  const test = new HotkeysMicFirstTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first.js b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first.js
new file mode 100644
index 0000000000000000000000000000000000000000..875a521cb0b3f5b2b0e8193d13add4d2de2e0b35
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-mic-first.js
@@ -0,0 +1,65 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class HotkeysMicFirstTestPage extends Page {
+  constructor() {
+    super();
+    this.tabCounts =
+    {
+      audioNoMic: 12,
+      audioMic: 13,
+    };
+  }
+
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinAudioMicrophone();
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-mic-first-0.png' });
+
+    await this.page.waitFor(e.whiteboard);
+    await this.page.waitFor(e.options);
+    await this.page.waitFor(e.userList);
+    await this.page.waitFor(e.toolbox);
+    await this.page.waitFor(e.leaveAudio);
+    await this.page.waitFor(e.chatButton);
+    await this.page.waitFor(e.firstUser);
+    await this.page.waitFor(e.screenShare);
+    await this.page.waitFor(e.videoMenu);
+    await this.page.waitFor(e.actions);
+    await this.page.waitFor(e.nextSlide);
+    await this.page.waitFor(e.prevSlide);
+
+    // Leave/Join Audio as Listen Only
+    await this.elementRemoved(e.alerts);
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.audioMic);
+    await this.enter();
+    await this.enter();
+    await this.page.waitFor(e.listenButton);
+    await this.tab(3);
+    await this.enter();
+    await this.elementRemoved(e.audioDialog);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-mic-first-1.png' });
+
+    // Leave/Join Audio with Microphone
+    await this.elementRemoved(e.alerts);
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.audioNoMic);
+    await this.enter();
+    await this.enter();
+    await this.page.waitFor(e.microphoneButton);
+    await this.tab(2);
+    await this.enter();
+    await this.page.waitFor(e.echoYes);
+    await helper.sleep(500); // Echo test confirmation sometimes fails without this
+    await this.tab(1);
+    await this.enter();
+    await this.elementRemoved(e.audioDialog);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-mic-first-2.png' });
+  }
+}
+
+module.exports = exports = HotkeysMicFirstTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-test.js b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-test.js
new file mode 100644
index 0000000000000000000000000000000000000000..3a3bdea252eaf28bae821086c9c8e9d672b0dcac
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys-test.js
@@ -0,0 +1,15 @@
+const Page = require('./page');
+const HotkeysTestPage = require('./page-hotkeys');
+
+test('Tests hotkeys: Options, User List, Leave/Join Audio, Mute/Unmute, Toggle Public Chat, Actions Menu, Status Menu', async () => {
+  const test = new HotkeysTestPage();
+  try {
+    await test.init(Page.getArgs());
+    await test.test();
+    await test.close();
+  } catch (e) {
+    console.log(e);
+    await test.close();
+    throw new Error('Test failed');
+  }
+});
diff --git a/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys.js b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys.js
new file mode 100644
index 0000000000000000000000000000000000000000..bb54e8efaa6fc39837864de995e009d1288b1956
--- /dev/null
+++ b/bigbluebutton-html5/tests/puppeteer/tests-not-ready/page-hotkeys.js
@@ -0,0 +1,154 @@
+const Page = require('./page');
+const helper = require('./helper');
+const e = require('./elements');
+
+class HotkeysTestPage extends Page {
+  constructor() {
+    super();
+    this.tabCounts =
+    {
+      options: 1,
+      actions: 11,
+      audioNoMic: 12,
+      audioMic: 13,
+      mute: 12,
+      chat: 15,
+      closeChat: 17, // Only when chat is open
+      status: 16,
+      userList: 18,
+    };
+  }
+
+  async test() {
+    await this.createBBBMeeting();
+    await this.joinAudioListenOnly();
+
+    await this.page.waitFor(e.whiteboard);
+    await this.page.waitFor(e.options);
+    await this.page.waitFor(e.userList);
+    await this.page.waitFor(e.toolbox);
+    await this.page.waitFor(e.leaveAudio);
+    await this.page.waitFor(e.chatButton);
+    await this.page.waitFor(e.firstUser);
+    await this.page.waitFor(e.screenShare);
+    await this.page.waitFor(e.videoMenu);
+    await this.page.waitFor(e.actions);
+    await this.page.waitFor(e.nextSlide);
+    await this.page.waitFor(e.prevSlide);
+
+    await this.elementRemoved(e.alerts);
+
+    // Options
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.options);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-options-0.png' });
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-options-1.png' });
+
+    // User List
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.userList);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-userlist-0.png' });
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-userlist-1.png' });
+
+    // Toggle Public Chat
+    await this.elementRemoved(e.alerts);
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.chat);
+    await this.up(1);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-chat-0.png' });
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.closeChat);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-chat-1.png' });
+
+    // Open Actions Menu
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.actions);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-actions-0.png' });
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-actions-1.png' });
+
+    // Open Status Menu
+    await this.elementRemoved(e.alerts);
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.status);
+    await this.up(1);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-status-0.png' });
+    await this.tab(1);
+    await this.enter();
+    await this.tab(1);
+    await this.down(7); // Applaud status
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-status-1.png' });
+
+    // Leave/Join Audio
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.audioNoMic);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-audio-0.png' });
+    await this.enter();
+    await this.page.waitFor(e.microphoneButton);
+    await this.tab(2);
+    await this.enter();
+    await this.page.waitFor(e.echoYes);
+    await helper.sleep(500); // Echo test confirmation sometimes fails without this
+    await this.tab(1);
+    await this.enter();
+    await this.elementRemoved(e.audioDialog);
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-audio-1.png' });
+
+    // Mute/Unmute
+    await this.elementRemoved(e.alerts);
+    await this.page.click(e.title);
+    await this.tab(this.tabCounts.mute);
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-mute-0.png' });
+    await this.enter();
+    await helper.sleep(500);
+    await this.page.screenshot({ path: 'screenshots/test-hotkeys-mute-1.png' });
+  }
+
+  async tab(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('Tab');
+    }
+  }
+
+  async enter() {
+    await this.page.keyboard.press('Enter');
+  }
+
+  async down(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('ArrowDown');
+    }
+  }
+
+  async up(count) {
+    for (let i = 0; i < count; i++) {
+      await this.page.keyboard.press('ArrowUp');
+    }
+  }
+}
+
+module.exports = exports = HotkeysTestPage;
diff --git a/bigbluebutton-html5/tests/puppeteer/upload-test.png b/bigbluebutton-html5/tests/puppeteer/upload-test.png
new file mode 100644
index 0000000000000000000000000000000000000000..d957a9ebf3b6c2e83df03ecc9f14ca88956fb78f
Binary files /dev/null and b/bigbluebutton-html5/tests/puppeteer/upload-test.png differ
diff --git a/build_script.sh b/build_script.sh
new file mode 100755
index 0000000000000000000000000000000000000000..445a2adbed069bc16a6e0a821abcb58a5387ae7d
--- /dev/null
+++ b/build_script.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+set -ev
+
+files=`git diff --name-only HEAD..$TRAVIS_BRANCH`
+if [[ $files = *"bigbluebutton-html5"* ]]; then
+  {
+    cd bigbluebutton-html5
+    git clone --single-branch -b update-html5 https://github.com/bigbluebutton/docker.git
+    cp -r docker/{mod,restart.sh,setup.sh,supervisord.conf} .
+    cp -r docker/Dockerfile Dockerfile.test
+    docker build -t b2 -f Dockerfile.test .
+    docker=$(docker run -d -p 80:80/tcp -p 443:443/tcp -p 1935:1935 -p 5066:5066 -p 3478:3478 -p 3478:3478/udp b2 -h localhost)
+    echo $docker
+    cd tests/puppeteer
+    npm install
+    conf=$(docker exec $(docker ps -q) bbb-conf --secret | grep "Secret:")
+    secret=$(echo $conf | cut -d' ' -f2)
+    export BBB_SHARED_SECRET=$secret
+    node html5-check.js
+
+    cd ../..
+    curl https://install.meteor.com/ | sh
+    meteor npm install
+    cd tests/puppeteer
+    npm install
+    cd ../../..
+  } > /dev/null
+  bigbluebutton-html5/node_modules/.bin/eslint --ext .jsx,.js $files
+fi