diff --git a/conference.js b/conference.js index c6d1795d6f..36c8758362 100644 --- a/conference.js +++ b/conference.js @@ -296,12 +296,6 @@ class ConferenceConnector { logger.error('CONFERENCE FAILED:', err, ...params); switch (err) { - case JitsiConferenceErrors.CONNECTION_ERROR: { - const [ msg ] = params; - - APP.UI.notifyConnectionFailed(msg); - break; - } case JitsiConferenceErrors.NOT_ALLOWED_ERROR: { // let's show some auth not allowed page @@ -336,14 +330,6 @@ class ConferenceConnector { APP.UI.notifyGracefulShutdown(); break; - case JitsiConferenceErrors.CONFERENCE_DESTROYED: { - const [ reason ] = params; - - APP.UI.hideStats(); - APP.UI.notifyConferenceDestroyed(reason); - break; - } - // FIXME FOCUS_DISCONNECTED is a confusing event name. // What really happens there is that the library is not ready yet, // because Jicofo is not available, but it is going to give it another diff --git a/css/_lobby.scss b/css/_lobby.scss new file mode 100644 index 0000000000..9f7249c3ec --- /dev/null +++ b/css/_lobby.scss @@ -0,0 +1,211 @@ +#lobby-screen { + align-items: center; + color: $overflowMenuItemColor; + display: flex; + flex-direction: column; + font-size: 1.2em; + margin: 48px 36px; + + span { + padding: 8px 0; + } + + .title { + color: $defaultColor; + font-size: 2em; + } + + .roomName { + font-size: 1em; + } + + .participantInfo { + align-items: center; + align-self: stretch; + border: 1px solid #B8C7E0; + border-radius: 4px; + display: flex; + flex-direction: column; + margin: 24px 0; + padding: 34px 0; + + &:hover { + padding-top: 0px; + + .editButton { + display: flex; + } + } + + .editButton { + align-self: stretch; + display: none; + justify-content: flex-end; + padding: 5px; + position: relative; + + button { + background-color: transparent; + border-width: 0; + margin: 0; + padding: 0; + } + } + + .displayName { + color: $defaultColor; + font-size: 1.3em; + } + } + + .form { + align-self: stretch; + display: flex; + flex-direction: column; + margin: 32px 0; + + input { + margin: 5px 0 15px 0; + } + + span { + color: white; + font-size: 1.3em; + text-align: center; + } + } + + .joiningContainer { + align-items: center; + display: flex; + flex-direction: column; + margin: 36px 0; + + span { + margin-top: 36px; + text-align: center; + } + } +} + +#lobby-dialog { + align-self: stretch; + display: flex; + flex-direction: column; + margin: 32px 0; + + .description { + margin-bottom: 18px; + } + + .field { + display: flex; + flex-direction: row; + + :first-child { + align-items: center; + display: flex; + padding-right: 15px; + } + + :last-child { + flex: 1; + } + } +} + +#knocking-participant-list { + background-color: $newToolbarBackgroundColor; + border: 1px solid rgba(255, 255, 255, .4); + border-radius: 8px; + display: flex; + flex-direction: column; + left: 0; + margin: 20px; + position: fixed; + top: 20; + transition: top 1s ease; + z-index: 100; + + &.toolbox-visible { + // Same as toolbox subject position + top: 120px; + } + + .title { + background-color: rgba(0, 0, 0, .2); + font-size: 1.2em; + padding: 15px + } + + ul { + list-style-type: none; + padding: 0 15px 15px 15px; + + li { + align-items: center; + display: flex; + flex-direction: row; + margin: 8px 0; + + .details { + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-evenly; + margin: 0 30px 0 10px; + } + + button { + align-self: unset; + margin: 0 5px; + } + } + } +} + +// Common styles + +#lobby-dialog, #lobby-screen, #knocking-participant-list { + input { + align-self: stretch; + background-color: transparent; + border: 1px solid #B8C7E0; + border-radius: 4px; + color: white; + padding: 12px 8px; + + &:focus { + border-color: rgb(3, 118, 218); + } + } + + button { + align-self: stretch; + margin: 8px 0; + padding: 12px; + transition: .2s transform ease; + + &:disabled { + opacity: .5; + } + + &:hover { + transform: scale(1.05); + + &:disabled { + transform: none; + } + } + + &.borderLess { + background-color: transparent; + border-width: 0; + } + + &.primary { + background-color: rgb(3, 118, 218); + border-width: 0; + } + } +} \ No newline at end of file diff --git a/css/main.scss b/css/main.scss index 0326aff014..ad61723a45 100644 --- a/css/main.scss +++ b/css/main.scss @@ -76,6 +76,7 @@ $flagsImagePath: "../images/"; @import 'filmstrip/vertical_filmstrip'; @import 'filmstrip/vertical_filmstrip_overrides'; @import 'labels'; +@import 'lobby'; @import 'unsupported-browser/main'; @import 'modals/invite/add-people'; @import 'deep-linking/main'; diff --git a/interface_config.js b/interface_config.js index 6081cc29da..bd82972db0 100644 --- a/interface_config.js +++ b/interface_config.js @@ -48,7 +48,7 @@ var interfaceConfig = { */ TOOLBAR_BUTTONS: [ 'microphone', 'camera', 'closedcaptions', 'desktop', 'fullscreen', - 'fodeviceselection', 'hangup', 'profile', 'chat', 'recording', + 'fodeviceselection', 'hangup', 'lobby', 'profile', 'chat', 'recording', 'livestreaming', 'etherpad', 'sharedvideo', 'settings', 'raisehand', 'videoquality', 'filmstrip', 'invite', 'feedback', 'stats', 'shortcuts', 'tileview', 'videobackgroundblur', 'download', 'help', 'mute-everyone', diff --git a/lang/main.json b/lang/main.json index 076ee147cf..f7e197b30e 100644 --- a/lang/main.json +++ b/lang/main.json @@ -675,6 +675,7 @@ "help": "Help", "invite": "Invite people", "kick": "Kick participant", + "lobbyButton": "Enable/disable lobby mode", "localRecording": "Toggle local recording controls", "lockRoom": "Toggle meeting password", "moreActions": "Toggle more actions menu", @@ -722,6 +723,8 @@ "hangup": "Leave", "help": "Help", "invite": "Invite people", + "lobbyButtonDisable": "Disable lobby mode", + "lobbyButtonEnable": "Enable lobby mode", "login": "Login", "logout": "Logout", "lowerYourHand": "Lower your hand", @@ -861,5 +864,29 @@ }, "helpView": { "header": "Help center" + }, + "lobby": { + "allow": "Allow", + "backToKnockModeButton": "No password, knock instead", + "dialogTitle": "Lobby mode", + "disableDialogContent": "Lobby mode is currently enabled. This feature ensures that unwanted participants can't join your meeting. Do you want to disable it?", + "disableDialogSubmit": "Disable", + "emailField": "Enter your email address", + "enableDialogPasswordField": "Set password (optional)", + "enableDialogSubmit": "Enable", + "enableDialogText": "Lobby mode lets you protect your meeting by only allowing people to enter after a formal approve of a moderator or by entering an optional predefined password.", + "enterPasswordButton": "Enter meeting password", + "joiningMessage": "You'll join the meeting as soon as someone accepts your request", + "joinWithPasswordMessage": "Trying to join with password, please wait...", + "joinRejectedMessage": "Your join request was rejected by a moderator.", + "joinTitle": "Join Meeting", + "joiningTitle": "Asking to join", + "joiningWithPasswordTitle": "Joining", + "knockButton": "Ask to Join", + "knockTitle": "Someone wants to join the meeting", + "nameField": "Enter your name", + "passwordField": "Enter password", + "passwordJoinButton": "Join", + "reject": "Reject" } } diff --git a/modules/UI/UI.js b/modules/UI/UI.js index 413599deec..01ef4b1be7 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -98,19 +98,6 @@ UI.notifyReservationError = function(code, msg) { }); }; -/** - * Notify user that conference was destroyed. - * @param reason {string} the reason text - */ -UI.notifyConferenceDestroyed = function(reason) { - // FIXME: use Session Terminated from translation, but - // 'reason' text comes from XMPP packet and is not translated - messageHandler.showError({ - description: reason, - titleKey: 'dialog.sessTerminated' - }); -}; - /** * Change nickname for the user. * @param {string} id user id diff --git a/react/features/app/actions.js b/react/features/app/actions.js index cbddebfd4a..00ad196fee 100644 --- a/react/features/app/actions.js +++ b/react/features/app/actions.js @@ -23,7 +23,7 @@ import { parseURIString, toURLString } from '../base/util'; -import { showNotification } from '../notifications'; +import { clearNotifications, showNotification } from '../notifications'; import { setFatalError } from '../overlay'; import { @@ -79,6 +79,10 @@ export function appNavigate(uri: ?string) { dispatch(disconnect()); } + // There are notifications now that gets displayed after we technically left + // the conference, but we're still on the conference screen. + dispatch(clearNotifications()); + dispatch(configWillLoad(locationURL, room)); let protocol = location.protocol.toLowerCase(); diff --git a/react/features/app/components/AbstractApp.js b/react/features/app/components/AbstractApp.js index dc1d3dc059..cd5bec43bb 100644 --- a/react/features/app/components/AbstractApp.js +++ b/react/features/app/components/AbstractApp.js @@ -7,6 +7,7 @@ import '../../base/lastn'; // Register lastN middleware import { toURLString } from '../../base/util'; import '../../follow-me'; import { OverlayContainer } from '../../overlay'; +import '../../lobby'; // Import lobby function import '../../rejoin'; // Enable rejoin analytics import { appNavigate } from '../actions'; import { getDefaultURL } from '../functions'; diff --git a/react/features/base/avatar/components/native/styles.js b/react/features/base/avatar/components/native/styles.js index a153c06b5f..b28a1db7be 100644 --- a/react/features/base/avatar/components/native/styles.js +++ b/react/features/base/avatar/components/native/styles.js @@ -70,7 +70,7 @@ export default { initialsText: (size: number = DEFAULT_SIZE) => { return { - color: 'rgba(255, 255, 255, 0.6)', + color: 'white', fontSize: size * 0.45, fontWeight: '100' }; diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index ffdb23fc53..4070b943eb 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -256,7 +256,7 @@ export function authStatusChanged(authEnabled: boolean, authLogin: string) { * }} * @public */ -export function conferenceFailed(conference: Object, error: string) { +export function conferenceFailed(conference: Object, error: string, ...params: any) { return { type: CONFERENCE_FAILED, conference, @@ -265,6 +265,7 @@ export function conferenceFailed(conference: Object, error: string) { // jitsi-meet needs it). error: { name: error, + params, recoverable: undefined } }; diff --git a/react/features/base/conference/functions.js b/react/features/base/conference/functions.js index 670018a4f7..bd9cd5f5f1 100644 --- a/react/features/base/conference/functions.js +++ b/react/features/base/conference/functions.js @@ -203,7 +203,7 @@ export function getConferenceTimestamp(stateful: Function | Object): number { * @returns {JitsiConference|undefined} */ export function getCurrentConference(stateful: Function | Object) { - const { conference, joining, leaving, passwordRequired } + const { conference, joining, leaving, membersOnly, passwordRequired } = toState(stateful)['features/base/conference']; // There is a precendence @@ -211,7 +211,7 @@ export function getCurrentConference(stateful: Function | Object) { return conference === leaving ? undefined : conference; } - return joining || passwordRequired; + return joining || passwordRequired || membersOnly; } /** diff --git a/react/features/base/conference/middleware.js b/react/features/base/conference/middleware.js index 0de643a54b..11e46869e2 100644 --- a/react/features/base/conference/middleware.js +++ b/react/features/base/conference/middleware.js @@ -8,7 +8,8 @@ import { sendAnalytics } from '../../analytics'; import { openDisplayNamePrompt } from '../../display-name'; -import { CONNECTION_ESTABLISHED, CONNECTION_FAILED } from '../connection'; +import { showErrorNotification } from '../../notifications'; +import { CONNECTION_ESTABLISHED, CONNECTION_FAILED, connectionDisconnected } from '../connection'; import { JitsiConferenceErrors } from '../lib-jitsi-meet'; import { MEDIA_TYPE } from '../media'; import { @@ -140,15 +141,43 @@ StateListenerRegistry.register( * @private * @returns {Object} The value returned by {@code next(action)}. */ -function _conferenceFailed(store, next, action) { +function _conferenceFailed({ dispatch, getState }, next, action) { const result = next(action); - const { conference, error } = action; if (error.name === JitsiConferenceErrors.OFFER_ANSWER_FAILED) { sendAnalytics(createOfferAnswerFailedEvent()); } + // Handle specific failure reasons. + switch (error.name) { + case JitsiConferenceErrors.CONFERENCE_DESTROYED: { + const [ reason ] = error.params; + + dispatch(showErrorNotification({ + description: reason, + titleKey: 'dialog.sessTerminated' + })); + + if (typeof APP !== 'undefined') { + APP.UI.hideStats(); + } + break; + } + case JitsiConferenceErrors.CONNECTION_ERROR: { + const [ msg ] = error.params; + + dispatch(connectionDisconnected(getState()['features/base/connection'].connection, 'Disconnected')); + dispatch(showErrorNotification({ + descriptionArguments: { msg }, + descriptionKey: msg ? 'dialog.connectErrorWithMsg' : 'dialog.connectError', + titleKey: 'connection.CONNFAIL' + })); + + break; + } + } + // FIXME: Workaround for the web version. Currently, the creation of the // conference is handled by /conference.js and appropriate failure handlers // are set there. diff --git a/react/features/base/conference/reducer.js b/react/features/base/conference/reducer.js index 06fa5855f1..3903ce0fd8 100644 --- a/react/features/base/conference/reducer.js +++ b/react/features/base/conference/reducer.js @@ -36,6 +36,7 @@ const DEFAULT_STATE = { leaving: undefined, locked: undefined, maxReceiverVideoQuality: VIDEO_QUALITY_LEVELS.HIGH, + membersOnly: undefined, password: undefined, passwordRequired: undefined, preferredVideoQuality: VIDEO_QUALITY_LEVELS.HIGH @@ -161,6 +162,7 @@ function _conferenceFailed(state, { conference, error }) { } let authRequired; + let membersOnly; let passwordRequired; switch (error.name) { @@ -168,6 +170,11 @@ function _conferenceFailed(state, { conference, error }) { authRequired = conference; break; + case JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED: + case JitsiConferenceErrors.MEMBERS_ONLY_ERROR: + membersOnly = conference; + break; + case JitsiConferenceErrors.PASSWORD_REQUIRED: passwordRequired = conference; break; @@ -189,6 +196,7 @@ function _conferenceFailed(state, { conference, error }) { * @type {string} */ locked: passwordRequired ? LOCKED_REMOTELY : undefined, + membersOnly, password: undefined, /** @@ -232,6 +240,7 @@ function _conferenceJoined(state, { conference }) { e2eeSupported: conference.isE2EESupported(), joining: undefined, + membersOnly: undefined, leaving: undefined, /** diff --git a/react/features/base/connection/actions.native.js b/react/features/base/connection/actions.native.js index 30fedc9942..f9117d7af9 100644 --- a/react/features/base/connection/actions.native.js +++ b/react/features/base/connection/actions.native.js @@ -119,7 +119,7 @@ export function connect(id: ?string, password: ?string) { */ function _onConnectionDisconnected(message: string) { unsubscribe(); - dispatch(_connectionDisconnected(connection, message)); + dispatch(connectionDisconnected(connection, message)); } /** @@ -195,7 +195,7 @@ export function connect(id: ?string, password: ?string) { * message: string * }} */ -function _connectionDisconnected(connection: Object, message: string) { +export function connectionDisconnected(connection: Object, message: string) { return { type: CONNECTION_DISCONNECTED, connection, diff --git a/react/features/base/connection/actions.web.js b/react/features/base/connection/actions.web.js index 811cc5c067..04cbc89e75 100644 --- a/react/features/base/connection/actions.web.js +++ b/react/features/base/connection/actions.web.js @@ -9,6 +9,7 @@ import { configureInitialDevices } from '../devices'; import { getBackendSafeRoomName } from '../util'; export { + connectionDisconnected, connectionEstablished, connectionFailed, setLocationURL diff --git a/react/features/base/dialog/components/native/BaseDialog.js b/react/features/base/dialog/components/native/BaseDialog.js index 6c0e8ebf7e..552fae6a02 100644 --- a/react/features/base/dialog/components/native/BaseDialog.js +++ b/react/features/base/dialog/components/native/BaseDialog.js @@ -57,14 +57,13 @@ class BaseDialog extends AbstractDialog { extends BaseDialog { * @returns {string} */ _getSubmitButtonKey() { - return 'dialog.Ok'; + return this.props.okKey || 'dialog.Ok'; } /** diff --git a/react/features/base/dialog/components/web/Dialog.js b/react/features/base/dialog/components/web/Dialog.js index aee91b04e0..1610b77e84 100644 --- a/react/features/base/dialog/components/web/Dialog.js +++ b/react/features/base/dialog/components/web/Dialog.js @@ -13,6 +13,11 @@ import StatelessDialog from './StatelessDialog'; */ type Props = AbstractDialogProps & { + /** + * True if listening for the Enter key should be disabled. + */ + disableEnter: boolean, + /** * Whether the dialog is modal. This means clicking on the blanket will * leave the dialog open. No cancel button. diff --git a/react/features/base/dialog/components/web/StatelessDialog.js b/react/features/base/dialog/components/web/StatelessDialog.js index c5494f7eec..6b7e5df548 100644 --- a/react/features/base/dialog/components/web/StatelessDialog.js +++ b/react/features/base/dialog/components/web/StatelessDialog.js @@ -33,6 +33,11 @@ type Props = { */ customHeader?: React$Element | Function, + /* + * True if listening for the Enter key should be disabled. + */ + disableEnter: boolean, + /** * Disables dismissing the dialog when the blanket is clicked. Enabled * by default. @@ -313,7 +318,7 @@ class StatelessDialog extends Component { return; } - if (event.key === 'Enter') { + if (event.key === 'Enter' && !this.props.disableEnter) { event.preventDefault(); event.stopPropagation(); diff --git a/react/features/base/icons/svg/edit.svg b/react/features/base/icons/svg/edit.svg new file mode 100644 index 0000000000..a6f23ff35d --- /dev/null +++ b/react/features/base/icons/svg/edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 0ed80e205e..112ea3aee3 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -32,6 +32,7 @@ export { default as IconDownload } from './download.svg'; export { default as IconDragHandle } from './drag-handle.svg'; export { default as IconE2EE } from './e2ee.svg'; export { default as IconEmail } from './envelope.svg'; +export { default as IconEdit } from './edit.svg'; export { default as IconEventNote } from './event_note.svg'; export { default as IconExclamation } from './exclamation.svg'; export { default as IconExclamationSolid } from './exclamation-solid.svg'; @@ -46,6 +47,8 @@ export { default as IconInviteMore } from './user-plus.svg'; export { default as IconKick } from './kick.svg'; export { default as IconLiveStreaming } from './public.svg'; export { default as IconLockPassword } from './lock.svg'; +export { default as IconMeetingLocked } from './meeting-locked.svg'; +export { default as IconMeetingUnlocked } from './meeting-unlocked.svg'; export { default as IconMenu } from './menu.svg'; export { default as IconMenuDown } from './menu-down.svg'; export { default as IconMenuThumb } from './thumb-menu.svg'; diff --git a/react/features/base/icons/svg/meeting-locked.svg b/react/features/base/icons/svg/meeting-locked.svg new file mode 100644 index 0000000000..983962057b --- /dev/null +++ b/react/features/base/icons/svg/meeting-locked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/features/base/icons/svg/meeting-unlocked.svg b/react/features/base/icons/svg/meeting-unlocked.svg new file mode 100644 index 0000000000..9149439940 --- /dev/null +++ b/react/features/base/icons/svg/meeting-unlocked.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react/features/base/react/functions.js b/react/features/base/react/functions.js new file mode 100644 index 0000000000..1180038c3d --- /dev/null +++ b/react/features/base/react/functions.js @@ -0,0 +1,11 @@ +// @flow + +/** + * Returns the field value in a platform generic way. + * + * @param {Object | string} fieldParameter - The parameter passed through the change event function. + * @returns {string} + */ +export function getFieldValue(fieldParameter: Object | string) { + return typeof fieldParameter === 'string' ? fieldParameter : fieldParameter?.target?.value; +} diff --git a/react/features/base/react/index.js b/react/features/base/react/index.js index 6fe039b8c7..08e8cd7b82 100644 --- a/react/features/base/react/index.js +++ b/react/features/base/react/index.js @@ -1,3 +1,5 @@ export * from './components'; +export * from './functions'; + export { default as Platform } from './Platform'; export * from './Types'; diff --git a/react/features/conference/components/native/Conference.js b/react/features/conference/components/native/Conference.js index 9488d8df5b..e647555d55 100644 --- a/react/features/conference/components/native/Conference.js +++ b/react/features/conference/components/native/Conference.js @@ -22,6 +22,7 @@ import { } from '../../../filmstrip'; import { AddPeopleDialog, CalleeInfoContainer } from '../../../invite'; import { LargeVideo } from '../../../large-video'; +import { KnockingParticipantList } from '../../../lobby'; import { BackButtonRegistry } from '../../../mobile/back-button'; import { Captions } from '../../../subtitles'; import { isToolboxVisible, setToolboxVisible, Toolbox } from '../../../toolbox'; @@ -320,6 +321,7 @@ class Conference extends AbstractConference { style = { styles.navBarSafeView }> { this._renderNotificationsContainer() } + @@ -414,6 +416,7 @@ function _mapStateToProps(state) { const { conference, joining, + membersOnly, leaving } = state['features/base/conference']; const { aspectRatio, reducedUI } = state['features/base/responsive-ui']; @@ -428,7 +431,7 @@ function _mapStateToProps(state) { // - the XMPP connection is connected and we have no conference yet, nor we // are leaving one. const connecting_ - = connecting || (connection && (joining || (!conference && !leaving))); + = connecting || (connection && (!membersOnly && (joining || (!conference && !leaving)))); return { ...abstractMapStateToProps(state), diff --git a/react/features/conference/components/web/Conference.js b/react/features/conference/components/web/Conference.js index df894c340b..2921b56e83 100644 --- a/react/features/conference/components/web/Conference.js +++ b/react/features/conference/components/web/Conference.js @@ -12,6 +12,7 @@ import { Chat } from '../../../chat'; import { Filmstrip } from '../../../filmstrip'; import { CalleeInfoContainer } from '../../../invite'; import { LargeVideo } from '../../../large-video'; +import { KnockingParticipantList } from '../../../lobby'; import { Prejoin, isPrejoinPageVisible } from '../../../prejoin'; import { Toolbox, @@ -198,8 +199,8 @@ class Conference extends AbstractConference {
- { hideLabels - || } + + { hideLabels || }
diff --git a/react/features/conference/middleware.js b/react/features/conference/middleware.js index ddc7b14e61..b5b5b7dab6 100644 --- a/react/features/conference/middleware.js +++ b/react/features/conference/middleware.js @@ -66,7 +66,7 @@ MiddlewareRegistry.register(store => next => action => { StateListenerRegistry.register( state => getCurrentConference(state), (conference, { dispatch, getState }, prevConference) => { - const { authRequired, passwordRequired } + const { authRequired, membersOnly, passwordRequired } = getState()['features/base/conference']; if (conference !== prevConference) { @@ -80,6 +80,7 @@ StateListenerRegistry.register( // and explicitly check. if (typeof authRequired === 'undefined' && typeof passwordRequired === 'undefined' + && typeof membersOnly === 'undefined' && !isDialogOpen(getState(), FeedbackDialog)) { // Conference changed, left or failed... and there is no // pending authentication, nor feedback request, so close any diff --git a/react/features/lobby/actionTypes.js b/react/features/lobby/actionTypes.js new file mode 100644 index 0000000000..dcc74282c6 --- /dev/null +++ b/react/features/lobby/actionTypes.js @@ -0,0 +1,21 @@ +// @flow + +/** + * Action type to signal the arriving or updating of a knocking participant. + */ +export const KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED = 'KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED'; + +/** + * Action type to signal the leave of a knocking participant. + */ +export const KNOCKING_PARTICIPANT_LEFT = 'KNOCKING_PARTICIPANT_LEFT'; + +/** + * Action type to set the new state of the lobby mode. + */ +export const SET_LOBBY_MODE_ENABLED = 'SET_LOBBY_MODE_ENABLED'; + +/** + * Action type to set the knocking state of the participant. + */ +export const SET_KNOCKING_STATE = 'SET_KNOCKING_STATE'; diff --git a/react/features/lobby/actions.js b/react/features/lobby/actions.js new file mode 100644 index 0000000000..48144b61c0 --- /dev/null +++ b/react/features/lobby/actions.js @@ -0,0 +1,189 @@ +// @flow + +import { type Dispatch } from 'redux'; + +import { appNavigate, maybeRedirectToWelcomePage } from '../app'; +import { conferenceLeft, conferenceWillJoin, getCurrentConference } from '../base/conference'; +import { openDialog } from '../base/dialog'; +import { getLocalParticipant } from '../base/participants'; + +import { + KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED, + KNOCKING_PARTICIPANT_LEFT, + SET_KNOCKING_STATE, + SET_LOBBY_MODE_ENABLED +} from './actionTypes'; +import { DisableLobbyModeDialog, EnableLobbyModeDialog, LobbyScreen } from './components'; + +declare var APP: Object; + +/** + * Cancels the ongoing knocking and abandones the join flow. + * + * @returns {Function} + */ +export function cancelKnocking() { + return async (dispatch: Dispatch, getState: Function) => { + if (typeof APP !== 'undefined') { + // when we are redirecting the library should handle any + // unload and clean of the connection. + APP.API.notifyReadyToClose(); + dispatch(maybeRedirectToWelcomePage()); + + return; + } + + dispatch(conferenceLeft(getCurrentConference(getState))); + dispatch(appNavigate(undefined)); + }; +} + +/** + * Action to be dispatched when a knocking poarticipant leaves before any response. + * + * @param {string} id - The ID of the participant. + * @returns {{ + * id: string, + * type: KNOCKING_PARTICIPANT_LEFT + * }} + */ +export function knockingParticipantLeft(id: string) { + return { + id, + type: KNOCKING_PARTICIPANT_LEFT + }; +} + +/** + * Action to set the knocking state of the participant. + * + * @param {boolean} knocking - The new state. + * @returns {{ + * state: boolean, + * type: SET_KNOCKING_STATE + * }} + */ +export function setKnockingState(knocking: boolean) { + return { + knocking, + type: SET_KNOCKING_STATE + }; +} + +/** + * Starts knocking and waiting for approval. + * + * @param {string} password - The password to bypass knocking, if any. + * @returns {Function} + */ +export function startKnocking(password?: string) { + return async (dispatch: Dispatch, getState: Function) => { + const state = getState(); + const { membersOnly } = state['features/base/conference']; + const localParticipant = getLocalParticipant(state); + + dispatch(setKnockingState(true)); + dispatch(conferenceWillJoin(membersOnly)); + membersOnly + && membersOnly.joinLobby(localParticipant.name, localParticipant.email, password ? password : undefined); + }; +} + +/** + * Action to open the lobby screen. + * + * @returns {openDialog} + */ +export function openLobbyScreen() { + return openDialog(LobbyScreen); +} + +/** + * Action to be executed when a participant starts knocking or an already knocking participant gets updated. + * + * @param {Object} participant - The knocking participant. + * @returns {{ + * participant: Object, + * type: KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED + * }} + */ +export function participantIsKnockingOrUpdated(participant: Object) { + return { + participant, + type: KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED + }; +} + +/** + * Approves (lets in) or rejects a knocking participant. + * + * @param {string} id - The id of the knocking participant. + * @param {boolean} approved - True if the participant is approved, false otherwise. + * @returns {Function} + */ +export function setKnockingParticipantApproval(id: string, approved: boolean) { + return async (dispatch: Dispatch, getState: Function) => { + const { conference } = getState()['features/base/conference']; + + if (conference) { + if (approved) { + conference.lobbyApproveAccess(id); + } else { + conference.lobbyDenyAccess(id); + } + } + }; +} + +/** + * Action to set the new state of the lobby mode. + * + * @param {boolean} enabled - The new state to set. + * @returns {{ + * enabled: boolean, + * type: SET_LOBBY_MODE_ENABLED + * }} + */ +export function setLobbyModeEnabled(enabled: boolean) { + return { + enabled, + type: SET_LOBBY_MODE_ENABLED + }; +} + +/** + * Action to show the dialog to disable lobby mode. + * + * @returns {showNotification} + */ +export function showDisableLobbyModeDialog() { + return openDialog(DisableLobbyModeDialog); +} + +/** + * Action to show the dialog to enable lobby mode. + * + * @returns {showNotification} + */ +export function showEnableLobbyModeDialog() { + return openDialog(EnableLobbyModeDialog); +} + +/** + * Action to toggle lobby mode on or off. + * + * @param {boolean} enabled - The desired (new) state of the lobby mode. + * @param {string} password - Optional password to be set. + * @returns {Function} + */ +export function toggleLobbyMode(enabled: boolean, password?: string) { + return async (dispatch: Dispatch, getState: Function) => { + const { conference } = getState()['features/base/conference']; + + if (enabled) { + conference.enableLobby(password); + } else { + conference.disableLobby(); + } + }; +} diff --git a/react/features/lobby/components/AbstractDisableLobbyModeDialog.js b/react/features/lobby/components/AbstractDisableLobbyModeDialog.js new file mode 100644 index 0000000000..2492e5d74e --- /dev/null +++ b/react/features/lobby/components/AbstractDisableLobbyModeDialog.js @@ -0,0 +1,47 @@ +// @flow + +import { PureComponent } from 'react'; + +import { toggleLobbyMode } from '../actions'; + +export type Props = { + + /** + * The Redux Dispatch function. + */ + dispatch: Function, + + /** + * Function to be used to translate i18n labels. + */ + t: Function +}; + +/** + * Abstract class to encapsulate the platform common code of the {@code DisableLobbyModeDialog}. + */ +export default class AbstractDisableLobbyModeDialog extends PureComponent

{ + /** + * Instantiates a new component. + * + * @inheritdoc + */ + constructor(props: P) { + super(props); + + this._onDisableLobbyMode = this._onDisableLobbyMode.bind(this); + } + + _onDisableLobbyMode: () => void; + + /** + * Callback to be invoked when the user initiates the lobby mode disable flow. + * + * @returns {void} + */ + _onDisableLobbyMode() { + this.props.dispatch(toggleLobbyMode(false)); + + return true; + } +} diff --git a/react/features/lobby/components/AbstractEnableLobbyModeDialog.js b/react/features/lobby/components/AbstractEnableLobbyModeDialog.js new file mode 100644 index 0000000000..a1b023f3f9 --- /dev/null +++ b/react/features/lobby/components/AbstractEnableLobbyModeDialog.js @@ -0,0 +1,75 @@ +// @flow + +import { PureComponent } from 'react'; + +import { getFieldValue } from '../../base/react'; +import { toggleLobbyMode } from '../actions'; + +export type Props = { + + /** + * The Redux Dispatch function. + */ + dispatch: Function, + + /** + * Function to be used to translate i18n labels. + */ + t: Function +}; + +type State = { + + /** + * The password value entered into the field. + */ + password: string +}; + +/** + * Abstract class to encapsulate the platform common code of the {@code EnableLobbyModeDialog}. + */ +export default class AbstractEnableLobbyModeDialog extends PureComponent { + /** + * Instantiates a new component. + * + * @inheritdoc + */ + constructor(props: P) { + super(props); + + this.state = { + password: '' + }; + + this._onEnableLobbyMode = this._onEnableLobbyMode.bind(this); + this._onChangePassword = this._onChangePassword.bind(this); + } + + _onChangePassword: Object => void; + + /** + * Callback to be invoked when the user changes the password. + * + * @param {SyntheticEvent} event - The SyntheticEvent instance of the change. + * @returns {void} + */ + _onChangePassword(event) { + this.setState({ + password: getFieldValue(event) + }); + } + + _onEnableLobbyMode: () => void; + + /** + * Callback to be invoked when the user initiates the lobby mode enable flow. + * + * @returns {void} + */ + _onEnableLobbyMode() { + this.props.dispatch(toggleLobbyMode(true, this.state.password)); + + return true; + } +} diff --git a/react/features/lobby/components/AbstractKnockingParticipantList.js b/react/features/lobby/components/AbstractKnockingParticipantList.js new file mode 100644 index 0000000000..8e55bcf9c4 --- /dev/null +++ b/react/features/lobby/components/AbstractKnockingParticipantList.js @@ -0,0 +1,82 @@ +// @flow + +import { PureComponent } from 'react'; + +import { isLocalParticipantModerator } from '../../base/participants'; +import { isToolboxVisible } from '../../toolbox'; +import { setKnockingParticipantApproval } from '../actions'; + +type Props = { + + /** + * The list of participants. + */ + _participants: Array, + + /** + * True if the toolbox is visible, so we need to adjust the position. + */ + _toolboxVisible: boolean, + + /** + * True if the list should be rendered. + */ + _visible: boolean, + + /** + * The Redux Dispatch function. + */ + dispatch: Function, + + /** + * Function to be used to translate i18n labels. + */ + t: Function +}; + +/** + * Abstract class to encapsulate the platform common code of the {@code KnockingParticipantList}. + */ +export default class AbstractKnockingParticipantList extends PureComponent { + /** + * Instantiates a new component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onRespondToParticipant = this._onRespondToParticipant.bind(this); + } + + _onRespondToParticipant: (string, boolean) => Function; + + /** + * Function that constructs a callback for the response handler button. + * + * @param {string} id - The id of the knocking participant. + * @param {boolean} approve - The response for the knocking. + * @returns {Function} + */ + _onRespondToParticipant(id, approve) { + return () => { + this.props.dispatch(setKnockingParticipantApproval(id, approve)); + }; + } +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +export function mapStateToProps(state: Object): $Shape { + const _participants = state['features/lobby'].knockingParticipants; + + return { + _participants, + _toolboxVisible: isToolboxVisible(state), + _visible: isLocalParticipantModerator(state) && Boolean(_participants?.length) + }; +} diff --git a/react/features/lobby/components/AbstractLobbyScreen.js b/react/features/lobby/components/AbstractLobbyScreen.js new file mode 100644 index 0000000000..fc3de82080 --- /dev/null +++ b/react/features/lobby/components/AbstractLobbyScreen.js @@ -0,0 +1,328 @@ +// @flow +// eslint-disable-next-line no-unused-vars +import React, { PureComponent } from 'react'; + +import { getConferenceName } from '../../base/conference'; +import { getLocalParticipant } from '../../base/participants'; +import { getFieldValue } from '../../base/react'; +import { updateSettings } from '../../base/settings'; +import { cancelKnocking, startKnocking } from '../actions'; + +export const SCREEN_STATES = { + EDIT: 1, + PASSWORD: 2, + VIEW: 3 +}; + +export type Props = { + + /** + * True if knocking is already happening, so we're waiting for a response. + */ + _knocking: boolean, + + /** + * The name of the meeting we're about to join. + */ + _meetingName: string, + + /** + * The email of the participant about to knock/join. + */ + _participantEmail: string, + + /** + * The id of the participant about to knock/join. This is the participant ID in the lobby room, at this point. + */ + _participantId: string, + + /** + * The name of the participant about to knock/join. + */ + _participantName: string; + + /** + * The Redux dispatch function. + */ + dispatch: Function, + + /** + * Function to be used to translate i18n labels. + */ + t: Function +}; + +type State = { + + /** + * The display name value entered into the field. + */ + displayName: string, + + /** + * The email value entered into the field. + */ + email: string, + + /** + * The password value entered into the field. + */ + password: string, + + /** + * The state of the screen. One of {@code SCREEN_STATES[*]} + */ + screenState: number +} + +/** + * Abstract class to encapsulate the platform common code of the {@code LobbyScreen}. + */ +export default class AbstractLobbyScreen extends PureComponent { + /** + * Instantiates a new component. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this.state = { + displayName: props._participantName || '', + email: props._participantEmail || '', + password: '', + screenState: props._participantName ? SCREEN_STATES.VIEW : SCREEN_STATES.EDIT + }; + + this._onAskToJoin = this._onAskToJoin.bind(this); + this._onCancel = this._onCancel.bind(this); + this._onChangeDisplayName = this._onChangeDisplayName.bind(this); + this._onChangeEmail = this._onChangeEmail.bind(this); + this._onChangePassword = this._onChangePassword.bind(this); + this._onEnableEdit = this._onEnableEdit.bind(this); + this._onSwitchToKnockMode = this._onSwitchToKnockMode.bind(this); + this._onSwitchToPasswordMode = this._onSwitchToPasswordMode.bind(this); + } + + /** + * Returns the screen title. + * + * @returns {string} + */ + _getScreenTitleKey() { + const withPassword = Boolean(this.state.password); + + return this.props._knocking + ? withPassword ? 'lobby.joiningWithPasswordTitle' : 'lobby.joiningTitle' + : 'lobby.joinTitle'; + } + + _onAskToJoin: () => void; + + /** + * Callback to be invoked when the user submits the joining request. + * + * @returns {void} + */ + _onAskToJoin() { + this.props.dispatch(startKnocking(this.state.password)); + + return false; + } + + _onCancel: () => boolean; + + /** + * Callback to be invoked when the user cancels the dialog. + * + * @private + * @returns {boolean} + */ + _onCancel() { + this.props.dispatch(cancelKnocking()); + + return true; + } + + _onChangeDisplayName: Object => void; + + /** + * Callback to be invoked when the user changes its display name. + * + * @param {SyntheticEvent} event - The SyntheticEvent instance of the change. + * @returns {void} + */ + _onChangeDisplayName(event) { + const displayName = getFieldValue(event); + + this.setState({ + displayName + }, () => { + this.props.dispatch(updateSettings({ + displayName + })); + }); + } + + _onChangeEmail: Object => void; + + /** + * Callback to be invoked when the user changes its email. + * + * @param {SyntheticEvent} event - The SyntheticEvent instance of the change. + * @returns {void} + */ + _onChangeEmail(event) { + const email = getFieldValue(event); + + this.setState({ + email + }, () => { + this.props.dispatch(updateSettings({ + email + })); + }); + } + + _onChangePassword: Object => void; + + /** + * Callback to be invoked when the user changes the password. + * + * @param {SyntheticEvent} event - The SyntheticEvent instance of the change. + * @returns {void} + */ + _onChangePassword(event) { + this.setState({ + password: getFieldValue(event) + }); + } + + _onEnableEdit: () => void; + + /** + * Callback to be invoked for the edit button. + * + * @returns {void} + */ + _onEnableEdit() { + this.setState({ + screenState: SCREEN_STATES.EDIT + }); + } + + _onSwitchToKnockMode: () => void; + + /** + * Callback to be invoked for the enter (go back to) knocking mode button. + * + * @returns {void} + */ + _onSwitchToKnockMode() { + this.setState({ + screenState: this.state.displayName ? SCREEN_STATES.VIEW : SCREEN_STATES.EDIT + }); + } + + _onSwitchToPasswordMode: () => void; + + /** + * Callback to be invoked for the enter password button. + * + * @returns {void} + */ + _onSwitchToPasswordMode() { + this.setState({ + screenState: SCREEN_STATES.PASSWORD + }); + } + + /** + * Renders the content of the dialog. + * + * @returns {React$Element} + */ + _renderContent() { + const { _knocking } = this.props; + const { password, screenState } = this.state; + const withPassword = Boolean(password); + + if (_knocking) { + return this._renderJoining(withPassword); + } + + return ( + <> + { screenState === SCREEN_STATES.VIEW && this._renderParticipantInfo() } + { screenState === SCREEN_STATES.EDIT && this._renderParticipantForm() } + { screenState === SCREEN_STATES.PASSWORD && this._renderPasswordForm() } + + { (screenState === SCREEN_STATES.VIEW || screenState === SCREEN_STATES.EDIT) + && this._renderStandardButtons() } + { screenState === SCREEN_STATES.PASSWORD && this._renderPasswordJoinButtons() } + + ); + } + + /** + * Renders the joining (waiting) fragment of the screen. + * + * @param {boolean} withPassword - True if we're joining with a password. False otherwise. + * @returns {React$Element} + */ + _renderJoining: boolean => React$Element<*>; + + /** + * Renders the participant form to let the knocking participant enter its details. + * + * @returns {React$Element} + */ + _renderParticipantForm: () => React$Element<*>; + + /** + * Renders the participant info fragment when we have all the required details of the user. + * + * @returns {React$Element} + */ + _renderParticipantInfo: () => React$Element<*>; + + /** + * Renders the password form to let the participant join by using a password instead of knocking. + * + * @returns {React$Element} + */ + _renderPasswordForm: () => React$Element<*>; + + /** + * Renders the password join button (set). + * + * @returns {React$Element} + */ + _renderPasswordJoinButtons: () => React$Element<*>; + + /** + * Renders the standard button set. + * + * @returns {React$Element} + */ + _renderStandardButtons: () => React$Element<*>; +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +export function _mapStateToProps(state: Object): $Shape { + const localParticipant = getLocalParticipant(state); + const participantId = localParticipant?.id; + + return { + _knocking: state['features/lobby'].knocking, + _meetingName: getConferenceName(state), + _participantEmail: localParticipant.email, + _participantId: participantId, + _participantName: localParticipant.name + }; +} diff --git a/react/features/lobby/components/LobbyModeButton.js b/react/features/lobby/components/LobbyModeButton.js new file mode 100644 index 0000000000..d4f26c5e0c --- /dev/null +++ b/react/features/lobby/components/LobbyModeButton.js @@ -0,0 +1,76 @@ +// @flow + +import { translate } from '../../base/i18n'; +import { IconMeetingUnlocked, IconMeetingLocked } from '../../base/icons'; +import { isLocalParticipantModerator } from '../../base/participants'; +import { connect } from '../../base/redux'; +import AbstractButton, { type Props as AbstractProps } from '../../base/toolbox/components/AbstractButton'; +import { showDisableLobbyModeDialog, showEnableLobbyModeDialog } from '../actions'; + +type Props = AbstractProps & { + + /** + * The Redux Dispatch function. + */ + dispatch: Function, + + /** + * True if the lobby mode is currently enabled for this conference. + */ + lobbyEnabled: boolean +}; + +/** + * Component to render the lobby mode initiator button. + */ +class LobbyModeButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.lobbyButton'; + icon = IconMeetingUnlocked; + label = 'toolbar.lobbyButtonEnable'; + toggledLabel = 'toolbar.lobbyButtonDisable' + toggledIcon = IconMeetingLocked; + + /** + * Callback for the click event of the button. + * + * @returns {void} + */ + _handleClick() { + const { dispatch } = this.props; + + if (this._isToggled()) { + dispatch(showDisableLobbyModeDialog()); + } else { + dispatch(showEnableLobbyModeDialog()); + } + } + + /** + * Function to define the button state. + * + * @returns {boolean} + */ + _isToggled() { + return this.props.lobbyEnabled; + } +} + +/** + * Maps part of the Redux store to the props of this component. + * + * @param {Object} state - The Redux state. + * @param {Props} ownProps - The own props of the component. + * @returns {Props} + */ +export function _mapStateToProps(state: Object): $Shape { + const { conference } = state['features/base/conference']; + const { lobbyEnabled } = state['features/lobby']; + const lobbySupported = conference && conference.isLobbySupported(); + + return { + lobbyEnabled, + visible: lobbySupported && isLocalParticipantModerator(state) + }; +} + +export default translate(connect(_mapStateToProps)(LobbyModeButton)); diff --git a/react/features/lobby/components/index.native.js b/react/features/lobby/components/index.native.js new file mode 100644 index 0000000000..0110d19168 --- /dev/null +++ b/react/features/lobby/components/index.native.js @@ -0,0 +1,5 @@ +// @flow + +export * from './native'; + +export { default as LobbyModeButton } from './LobbyModeButton'; diff --git a/react/features/lobby/components/index.web.js b/react/features/lobby/components/index.web.js new file mode 100644 index 0000000000..b6693c5155 --- /dev/null +++ b/react/features/lobby/components/index.web.js @@ -0,0 +1,5 @@ +// @flow + +export * from './web'; + +export { default as LobbyModeButton } from './LobbyModeButton'; diff --git a/react/features/lobby/components/native/DisableLobbyModeDialog.js b/react/features/lobby/components/native/DisableLobbyModeDialog.js new file mode 100644 index 0000000000..b102cbe83d --- /dev/null +++ b/react/features/lobby/components/native/DisableLobbyModeDialog.js @@ -0,0 +1,30 @@ +// @flow + +import React from 'react'; + +import { ConfirmDialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractDisableLobbyModeDialog from '../AbstractDisableLobbyModeDialog'; + +/** + * Implements a dialog that lets the user disable the lobby mode. + */ +class DisableLobbyModeDialog extends AbstractDisableLobbyModeDialog { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + return ( + + ); + } + + _onDisableLobbyMode: () => void; +} + +export default translate(connect()(DisableLobbyModeDialog)); diff --git a/react/features/lobby/components/native/EnableLobbyModeDialog.js b/react/features/lobby/components/native/EnableLobbyModeDialog.js new file mode 100644 index 0000000000..384bbf131b --- /dev/null +++ b/react/features/lobby/components/native/EnableLobbyModeDialog.js @@ -0,0 +1,77 @@ +// @flow + +import React from 'react'; +import { Text, TextInput, View } from 'react-native'; + +import { ColorSchemeRegistry } from '../../../base/color-scheme'; +import { CustomSubmitDialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import { StyleType } from '../../../base/styles'; +import AbstractEnableLobbyModeDialog, { type Props as AbstractProps } from '../AbstractEnableLobbyModeDialog'; + +import styles from './styles'; + +type Props = AbstractProps & { + + /** + * Color schemed common style of the dialog feature. + */ + _dialogStyles: StyleType +}; + +/** + * Implements a dialog that lets the user enable the lobby mode. + */ +class EnableLobbyModeDialog extends AbstractEnableLobbyModeDialog { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _dialogStyles, t } = this.props; + + return ( + + + + { t('lobby.enableDialogText') } + + + + { t('lobby.enableDialogPasswordField') } + + + + + + ); + } + + _onChangePassword: Object => void; + + _onEnableLobbyMode: () => void; +} + +/** + * Maps part of the Redux state to the props of this component. + * + * @param {Object} state - The Redux state. + * @returns {Props} + */ +function _mapStateToProps(state: Object): Object { + return { + _dialogStyles: ColorSchemeRegistry.get(state, 'Dialog') + }; +} + +export default translate(connect(_mapStateToProps)(EnableLobbyModeDialog)); diff --git a/react/features/lobby/components/native/KnockingParticipantList.js b/react/features/lobby/components/native/KnockingParticipantList.js new file mode 100644 index 0000000000..2a1bc31570 --- /dev/null +++ b/react/features/lobby/components/native/KnockingParticipantList.js @@ -0,0 +1,78 @@ +// @flow + +import React from 'react'; +import { ScrollView, Text, View, TouchableOpacity } from 'react-native'; + +import { Avatar } from '../../../base/avatar'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractKnockingParticipantList, { mapStateToProps } from '../AbstractKnockingParticipantList'; + +import styles from './styles'; + +/** + * Component to render a list for the actively knocking participants. + */ +class KnockingParticipantList extends AbstractKnockingParticipantList { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _participants, t } = this.props; + + // On mobile we only show a portion of the list for screen real estate reasons + const participants = _participants.slice(0, 2); + + return ( + + { participants.map(p => ( + + + + + { p.name } + + { p.email && ( + + { p.email } + + ) } + + + + { t('lobby.allow') } + + + + + { t('lobby.reject') } + + + + )) } + + ); + } + + _onRespondToParticipant: (string, boolean) => Function; +} + +export default translate(connect(mapStateToProps)(KnockingParticipantList)); diff --git a/react/features/lobby/components/native/LobbyScreen.js b/react/features/lobby/components/native/LobbyScreen.js new file mode 100644 index 0000000000..3f4c1d1279 --- /dev/null +++ b/react/features/lobby/components/native/LobbyScreen.js @@ -0,0 +1,234 @@ +// @flow + +import React from 'react'; +import { Text, View, TouchableOpacity, TextInput } from 'react-native'; + +import { Avatar } from '../../../base/avatar'; +import { CustomDialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { Icon, IconEdit } from '../../../base/icons'; +import { LoadingIndicator } from '../../../base/react'; +import { connect } from '../../../base/redux'; +import AbstractLobbyScreen, { _mapStateToProps } from '../AbstractLobbyScreen'; + +import styles from './styles'; + +/** + * Implements a waiting screen that represents the participant being in the lobby. + */ +class LobbyScreen extends AbstractLobbyScreen { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _meetingName, t } = this.props; + + return ( + + + { t(this._getScreenTitleKey()) } + + + { _meetingName } + + { this._renderContent() } + + ); + } + + _getScreenTitleKey: () => string; + + _onAskToJoin: () => void; + + _onCancel: () => boolean; + + _onChangeDisplayName: Object => void; + + _onChangeEmail: Object => void; + + _onChangePassword: Object => void; + + _onEnableEdit: () => void; + + _onSwitchToKnockMode: () => void; + + _onSwitchToPasswordMode: () => void; + + _renderContent: () => React$Element<*>; + + /** + * Renders the joining (waiting) fragment of the screen. + * + * @inheritdoc + */ + _renderJoining() { + return ( + <> + + + { this.props.t('lobby.joiningMessage') } + + + ); + } + + /** + * Renders the participant form to let the knocking participant enter its details. + * + * @inheritdoc + */ + _renderParticipantForm() { + const { t } = this.props; + const { displayName, email } = this.state; + + return ( + + + { t('lobby.nameField') } + + + + { t('lobby.emailField') } + + + + ); + } + + /** + * Renders the participant info fragment when we have all the required details of the user. + * + * @inheritdoc + */ + _renderParticipantInfo() { + const { displayName, email } = this.state; + + return ( + + + + + + + { displayName } + + { Boolean(email) && + { email } + } + + ); + } + + /** + * Renders the password form to let the participant join by using a password instead of knocking. + * + * @inheritdoc + */ + _renderPasswordForm() { + return ( + + + { this.props.t('lobby.passwordField') } + + + + ); + } + + /** + * Renders the password join button (set). + * + * @inheritdoc + */ + _renderPasswordJoinButtons() { + const { t } = this.props; + + return ( + <> + + + { t('lobby.passwordJoinButton') } + + + + + { t('lobby.backToKnockModeButton') } + + + + ); + } + + /** + * Renders the standard button set. + * + * @inheritdoc + */ + _renderStandardButtons() { + const { t } = this.props; + + return ( + <> + + + { t('lobby.knockButton') } + + + + + { t('lobby.enterPasswordButton') } + + + + ); + } +} + +export default translate(connect(_mapStateToProps)(LobbyScreen)); diff --git a/react/features/lobby/components/native/index.js b/react/features/lobby/components/native/index.js new file mode 100644 index 0000000000..af5b7f6339 --- /dev/null +++ b/react/features/lobby/components/native/index.js @@ -0,0 +1,6 @@ +// @flow + +export { default as DisableLobbyModeDialog } from './DisableLobbyModeDialog'; +export { default as EnableLobbyModeDialog } from './EnableLobbyModeDialog'; +export { default as KnockingParticipantList } from './KnockingParticipantList'; +export { default as LobbyScreen } from './LobbyScreen'; diff --git a/react/features/lobby/components/native/styles.js b/react/features/lobby/components/native/styles.js new file mode 100644 index 0000000000..be85e3c942 --- /dev/null +++ b/react/features/lobby/components/native/styles.js @@ -0,0 +1,139 @@ +// @flow + +const SECONDARY_COLOR = '#B8C7E0'; + +export default { + avatar: { + borderColor: 'red' + }, + + button: { + alignItems: 'center', + borderRadius: 4, + marginVertical: 8, + paddingVertical: 10 + }, + + contentWrapper: { + alignItems: 'center', + flexDirection: 'column', + padding: 32 + }, + + dialogTitle: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 10 + }, + + displayNameText: { + fontWeight: 'bold', + marginVertical: 10 + }, + + editButton: { + alignSelf: 'flex-end', + paddingHorizontal: 10 + }, + + editIcon: { + color: 'black', + fontSize: 16 + }, + + field: { + borderColor: SECONDARY_COLOR, + borderRadius: 4, + borderWidth: 1, + marginVertical: 8, + padding: 8 + }, + + fieldRow: { + paddingTop: 16 + }, + + fieldLabel: { + textAlign: 'center' + }, + + formWrapper: { + alignItems: 'stretch', + alignSelf: 'stretch', + paddingVertical: 16 + }, + + joiningMessage: { + textAlign: 'center' + }, + + loadingIndicator: { + marginVertical: 36 + }, + + participantBox: { + alignItems: 'center', + alignSelf: 'stretch', + borderColor: SECONDARY_COLOR, + borderRadius: 4, + borderWidth: 1, + marginVertical: 18, + paddingVertical: 12 + }, + + primaryButton: { + alignSelf: 'stretch', + backgroundColor: 'rgb(3, 118, 218)' + }, + + primaryButtonText: { + color: 'white' + }, + + secondaryButton: { + alignSelf: 'stretch', + backgroundColor: 'transparent' + }, + + secondaryText: { + color: 'rgba(0, 0, 0, .7)' + }, + + // KnockingParticipantList + + knockingParticipantList: { + alignSelf: 'stretch', + backgroundColor: 'rgba(22, 38, 55, 0.8)', + flexDirection: 'column' + }, + + knockingParticipantListButton: { + borderRadius: 4, + marginHorizontal: 3, + paddingHorizontal: 10, + paddingVertical: 5 + }, + + knockingParticipantListDetails: { + flex: 1, + marginLeft: 10 + }, + + knockingParticipantListEntry: { + alignItems: 'center', + flexDirection: 'row', + padding: 10 + }, + + knockingParticipantListPrimaryButton: { + backgroundColor: 'rgb(3, 118, 218)' + }, + + knockingParticipantListSecondaryButton: { + backgroundColor: 'transparent' + }, + + knockingParticipantListText: { + color: 'white' + } +}; diff --git a/react/features/lobby/components/web/DisableLobbyModeDialog.js b/react/features/lobby/components/web/DisableLobbyModeDialog.js new file mode 100644 index 0000000000..96ced1bfce --- /dev/null +++ b/react/features/lobby/components/web/DisableLobbyModeDialog.js @@ -0,0 +1,36 @@ +// @flow + +import React from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractDisableLobbyModeDialog from '../AbstractDisableLobbyModeDialog'; + +/** + * Implements a dialog that lets the user disable the lobby mode. + */ +class DisableLobbyModeDialog extends AbstractDisableLobbyModeDialog { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { t } = this.props; + + return ( + + { t('lobby.disableDialogContent') } + + ); + } + + _onDisableLobbyMode: () => void; +} + +export default translate(connect()(DisableLobbyModeDialog)); diff --git a/react/features/lobby/components/web/EnableLobbyModeDialog.js b/react/features/lobby/components/web/EnableLobbyModeDialog.js new file mode 100644 index 0000000000..246eb77992 --- /dev/null +++ b/react/features/lobby/components/web/EnableLobbyModeDialog.js @@ -0,0 +1,51 @@ +// @flow + +import React from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractEnableLobbyModeDialog from '../AbstractEnableLobbyModeDialog'; + +/** + * Implements a dialog that lets the user enable the lobby mode. + */ +class EnableLobbyModeDialog extends AbstractEnableLobbyModeDialog { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { t } = this.props; + + return ( + +
+ + { t('lobby.enableDialogText') } + +
+ + +
+
+
+ ); + } + + _onChangePassword: Object => void; + + _onEnableLobbyMode: () => void; +} + +export default translate(connect()(EnableLobbyModeDialog)); diff --git a/react/features/lobby/components/web/KnockingParticipantList.js b/react/features/lobby/components/web/KnockingParticipantList.js new file mode 100644 index 0000000000..fd930169c5 --- /dev/null +++ b/react/features/lobby/components/web/KnockingParticipantList.js @@ -0,0 +1,72 @@ +// @flow + +import React from 'react'; + +import { Avatar } from '../../../base/avatar'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractKnockingParticipantList, { mapStateToProps } from '../AbstractKnockingParticipantList'; + +/** + * Component to render a list for the actively knocking participants. + */ +class KnockingParticipantList extends AbstractKnockingParticipantList { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _participants, _toolboxVisible, _visible, t } = this.props; + + if (!_visible) { + return null; + } + + return ( +
+ + Knocking participant list + +
    + { _participants.map(p => ( +
  • + +
    + + { p.name } + + { p.email && ( + + { p.email } + + ) } +
    + + +
  • + )) } +
+
+ ); + } + + _onRespondToParticipant: (string, boolean) => Function; +} + +export default translate(connect(mapStateToProps)(KnockingParticipantList)); diff --git a/react/features/lobby/components/web/LobbyScreen.js b/react/features/lobby/components/web/LobbyScreen.js new file mode 100644 index 0000000000..88cb4c4c20 --- /dev/null +++ b/react/features/lobby/components/web/LobbyScreen.js @@ -0,0 +1,219 @@ +// @flow + +import React from 'react'; + +import { Avatar } from '../../../base/avatar'; +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { Icon, IconEdit } from '../../../base/icons'; +import { LoadingIndicator } from '../../../base/react'; +import { connect } from '../../../base/redux'; +import AbstractLobbyScreen, { _mapStateToProps } from '../AbstractLobbyScreen'; + +/** + * Implements a waiting screen that represents the participant being in the lobby. + */ +class LobbyScreen extends AbstractLobbyScreen { + /** + * Implements {@code PureComponent#render}. + * + * @inheritdoc + */ + render() { + const { _meetingName, t } = this.props; + + return ( + +
+ + { t(this._getScreenTitleKey()) } + + + { _meetingName } + + { this._renderContent() } +
+
+ ); + } + + _getScreenTitleKey: () => string; + + _onAskToJoin: () => boolean; + + _onCancel: () => boolean; + + _onChangeDisplayName: Object => void; + + _onChangeEmail: Object => void; + + _onChangePassword: Object => void; + + _onEnableEdit: () => void; + + _onSubmit: () => boolean; + + _onSwitchToKnockMode: () => void; + + _onSwitchToPasswordMode: () => void; + + _renderContent: () => React$Element<*>; + + /** + * Renders the joining (waiting) fragment of the screen. + * + * @inheritdoc + */ + _renderJoining(withPassword) { + return ( +
+ + + { this.props.t(`lobby.${withPassword ? 'joinWithPasswordMessage' : 'joiningMessage'}`) } + +
+ ); + } + + /** + * Renders the participant form to let the knocking participant enter its details. + * + * @inheritdoc + */ + _renderParticipantForm() { + const { t } = this.props; + const { displayName, email } = this.state; + + return ( +
+ + { t('lobby.nameField') } + + + + { t('lobby.emailField') } + + +
+ ); + } + + /** + * Renders the participant info fragment when we have all the required details of the user. + * + * @inheritdoc + */ + _renderParticipantInfo() { + const { displayName, email } = this.state; + const { _participantId } = this.props; + + return ( +
+
+ +
+ + + { displayName } + + + { email } + +
+ ); + } + + /** + * Renders the password form to let the participant join by using a password instead of knocking. + * + * @inheritdoc + */ + _renderPasswordForm() { + return ( +
+ + { this.props.t('lobby.passwordField') } + + +
+ ); + } + + /** + * Renders the password join button (set). + * + * @inheritdoc + */ + _renderPasswordJoinButtons() { + const { t } = this.props; + + return ( + <> + + + + ); + } + + /** + * Renders the standard button set. + * + * @inheritdoc + */ + _renderStandardButtons() { + const { t } = this.props; + + return ( + <> + + + + ); + } +} + +export default translate(connect(_mapStateToProps)(LobbyScreen)); diff --git a/react/features/lobby/components/web/index.js b/react/features/lobby/components/web/index.js new file mode 100644 index 0000000000..af5b7f6339 --- /dev/null +++ b/react/features/lobby/components/web/index.js @@ -0,0 +1,6 @@ +// @flow + +export { default as DisableLobbyModeDialog } from './DisableLobbyModeDialog'; +export { default as EnableLobbyModeDialog } from './EnableLobbyModeDialog'; +export { default as KnockingParticipantList } from './KnockingParticipantList'; +export { default as LobbyScreen } from './LobbyScreen'; diff --git a/react/features/lobby/functions.js b/react/features/lobby/functions.js new file mode 100644 index 0000000000..402b696bf0 --- /dev/null +++ b/react/features/lobby/functions.js @@ -0,0 +1,39 @@ +// @flow + +declare var interfaceConfig: Object; + +/** + * Returns a displayable name for the knocking participant. + * + * @param {string} name - The received name. + * @returns {string} + */ +export function getKnockingParticipantDisplayName(name: string) { + if (name) { + return name; + } + + return typeof interfaceConfig === 'object' + ? interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME + : 'Fellow Jitster'; +} + +/** + * Approves (lets in) or rejects a knocking participant. + * + * @param {Function} getState - Function to get the Redux state. + * @param {string} id - The id of the knocking participant. + * @param {boolean} approved - True if the participant is approved, false otherwise. + * @returns {Function} + */ +export function setKnockingParticipantApproval(getState: Function, id: string, approved: boolean) { + const { conference } = getState()['features/base/conference']; + + if (conference) { + if (approved) { + conference.lobbyApproveAccess(id); + } else { + conference.lobbyDenyAccess(id); + } + } +} diff --git a/react/features/lobby/index.js b/react/features/lobby/index.js new file mode 100644 index 0000000000..e4e0b51e5a --- /dev/null +++ b/react/features/lobby/index.js @@ -0,0 +1,6 @@ +// @flow + +import './middleware'; +import './reducer'; + +export * from './components'; diff --git a/react/features/lobby/logger.js b/react/features/lobby/logger.js new file mode 100644 index 0000000000..964c0263da --- /dev/null +++ b/react/features/lobby/logger.js @@ -0,0 +1,5 @@ +// @flow + +import { getLogger } from '../base/logging/functions'; + +export default getLogger('features/lobby'); diff --git a/react/features/lobby/middleware.js b/react/features/lobby/middleware.js new file mode 100644 index 0000000000..1f11d64059 --- /dev/null +++ b/react/features/lobby/middleware.js @@ -0,0 +1,137 @@ +// @flow + +import { CONFERENCE_FAILED, CONFERENCE_JOINED } from '../base/conference'; +import { hideDialog } from '../base/dialog'; +import { JitsiConferenceErrors, JitsiConferenceEvents } from '../base/lib-jitsi-meet'; +import { getFirstLoadableAvatarUrl } from '../base/participants'; +import { MiddlewareRegistry, StateListenerRegistry } from '../base/redux'; +import { NOTIFICATION_TYPE, showNotification } from '../notifications'; + +import { KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED } from './actionTypes'; +import { + knockingParticipantLeft, + openLobbyScreen, + participantIsKnockingOrUpdated, + setLobbyModeEnabled +} from './actions'; +import { LobbyScreen } from './components'; + +MiddlewareRegistry.register(store => next => action => { + switch (action.type) { + case CONFERENCE_FAILED: + return _conferenceFailed(store, next, action); + case CONFERENCE_JOINED: + return _conferenceJoined(store, next, action); + case KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED: { + // We need the full update result to be in the store already + const result = next(action); + + _findLoadableAvatarForKnockingParticipant(store, action.participant); + + return result; + } + } + + return next(action); +}); + +/** + * Registers a change handler for state['features/base/conference'].conference to + * set the event listeners needed for the lobby feature to operate. + */ +StateListenerRegistry.register( + state => state['features/base/conference'].conference, + (conference, { dispatch }, previousConference) => { + if (conference && !previousConference) { + conference.on(JitsiConferenceEvents.MEMBERS_ONLY_CHANGED, enabled => { + dispatch(setLobbyModeEnabled(enabled)); + }); + + conference.on(JitsiConferenceEvents.LOBBY_USER_JOINED, (id, name) => { + dispatch(participantIsKnockingOrUpdated({ + id, + name + })); + }); + + conference.on(JitsiConferenceEvents.LOBBY_USER_UPDATED, (id, participant) => { + dispatch(participantIsKnockingOrUpdated({ + ...participant, + id + })); + }); + + conference.on(JitsiConferenceEvents.LOBBY_USER_LEFT, id => { + dispatch(knockingParticipantLeft(id)); + }); + } + }); + +/** + * Function to handle the conference failed event and navigate the user to the lobby screen + * based on the failure reason. + * + * @param {Object} store - The Redux store. + * @param {Function} next - The Redux next function. + * @param {Object} action - The Redux action. + * @returns {Object} + */ +function _conferenceFailed({ dispatch }, next, action) { + const { error } = action; + + if (error.name === JitsiConferenceErrors.MEMBERS_ONLY_ERROR) { + if (typeof error.recoverable === 'undefined') { + error.recoverable = true; + } + + dispatch(openLobbyScreen()); + } else { + dispatch(hideDialog(LobbyScreen)); + + if (error.name === JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED) { + dispatch(showNotification({ + appearance: NOTIFICATION_TYPE.ERROR, + hideErrorSupportLink: true, + titleKey: 'lobby.joinRejectedMessage' + })); + } + } + + return next(action); +} + +/** + * Handles cleanup of lobby state when a conference is joined. + * + * @param {Object} store - The Redux store. + * @param {Function} next - The Redux next function. + * @param {Object} action - The Redux action. + * @returns {Object} + */ +function _conferenceJoined({ dispatch }, next, action) { + dispatch(hideDialog(LobbyScreen)); + + return next(action); +} + +/** + * Finds the loadable avatar URL and updates the participant accordingly. + * + * @param {Object} store - The Redux store. + * @param {Object} participant - The knocking participant. + * @returns {void} + */ +function _findLoadableAvatarForKnockingParticipant({ dispatch, getState }, { id }) { + const updatedParticipant = getState()['features/lobby'].knockingParticipants.find(p => p.id === id); + + if (updatedParticipant && !updatedParticipant.loadableAvatarUrl) { + getFirstLoadableAvatarUrl(updatedParticipant).then(loadableAvatarUrl => { + if (loadableAvatarUrl) { + dispatch(participantIsKnockingOrUpdated({ + loadableAvatarUrl, + id + })); + } + }); + } +} diff --git a/react/features/lobby/reducer.js b/react/features/lobby/reducer.js new file mode 100644 index 0000000000..932795f02b --- /dev/null +++ b/react/features/lobby/reducer.js @@ -0,0 +1,80 @@ +// @flow + +import { CONFERENCE_FAILED, CONFERENCE_JOINED, CONFERENCE_LEFT } from '../base/conference'; +import { ReducerRegistry } from '../base/redux'; + +import { + KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED, + KNOCKING_PARTICIPANT_LEFT, + SET_KNOCKING_STATE, + SET_LOBBY_MODE_ENABLED +} from './actionTypes'; + +const DEFAULT_STATE = { + knocking: false, + knockingParticipants: [], + lobbyEnabled: false +}; + +/** + * Reduces redux actions which affect the display of notifications. + * + * @param {Object} state - The current redux state. + * @param {Object} action - The redux action to reduce. + * @returns {Object} The next redux state which is the result of reducing the + * specified {@code action}. + */ +ReducerRegistry.register('features/lobby', (state = DEFAULT_STATE, action) => { + switch (action.type) { + case CONFERENCE_FAILED: + case CONFERENCE_JOINED: + case CONFERENCE_LEFT: + return { + ...state, + knocking: false + }; + case KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED: + return _knockingParticipantArrivedOrUpdated(action.participant, state); + case KNOCKING_PARTICIPANT_LEFT: + return { + ...state, + knockingParticipants: state.knockingParticipants.filter(p => p.id !== action.id) + }; + case SET_KNOCKING_STATE: + return { + ...state, + knocking: action.knocking + }; + case SET_LOBBY_MODE_ENABLED: + return { + ...state, + lobbyEnabled: action.enabled + }; + } + + return state; +}); + +/** + * Stores or updates a knocking participant. + * + * @param {Object} participant - The arrived or updated knocking participant. + * @param {Object} state - The current Redux state of the feature. + * @returns {Object} + */ +function _knockingParticipantArrivedOrUpdated(participant, state) { + let existingParticipant = state.knockingParticipants.find(p => p.id === participant.id); + + existingParticipant = { + ...existingParticipant, + ...participant + }; + + return { + ...state, + knockingParticipants: [ + ...state.knockingParticipants.filter(p => p.id !== participant.id), + existingParticipant + ] + }; +} diff --git a/react/features/overlay/middleware.js b/react/features/overlay/middleware.js index d0615eecdc..90ce1385a7 100644 --- a/react/features/overlay/middleware.js +++ b/react/features/overlay/middleware.js @@ -1,11 +1,21 @@ // @flow +import { JitsiConferenceErrors } from '../base/lib-jitsi-meet'; import { StateListenerRegistry } from '../base/redux'; import { setFatalError } from './actions'; declare var APP: Object; +/** + * List of errors that are not fatal (or handled differently) so then the overlays won't kick in. + */ +const NON_FATAR_ERRORS = [ + JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED, + JitsiConferenceErrors.CONFERENCE_DESTROYED, + JitsiConferenceErrors.CONNECTION_ERROR +]; + /** * State listener which emits the {@code fatalErrorOccurred} action which works * as a catch all for critical errors which have not been claimed by any other @@ -21,6 +31,7 @@ StateListenerRegistry.register( }, /* listener */ (error, { dispatch }) => { error + && NON_FATAR_ERRORS.indexOf(error.name) === -1 && typeof error.recoverable === 'undefined' && dispatch(setFatalError(error)); } diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 27ebd127b4..a2a8988cf3 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -11,6 +11,7 @@ import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; import { SharedDocumentButton } from '../../../etherpad'; import { InviteButton } from '../../../invite'; +import { LobbyModeButton } from '../../../lobby'; import { AudioRouteButton } from '../../../mobile/audio-mode'; import { LiveStreamButton, RecordButton } from '../../../recording'; import { RoomLockButton } from '../../../room-lock'; @@ -128,6 +129,7 @@ class OverflowMenu extends PureComponent { + diff --git a/react/features/toolbox/components/web/Toolbox.js b/react/features/toolbox/components/web/Toolbox.js index 5aacc46c8a..eccdd9149d 100644 --- a/react/features/toolbox/components/web/Toolbox.js +++ b/react/features/toolbox/components/web/Toolbox.js @@ -38,6 +38,7 @@ import { SharedDocumentButton } from '../../../etherpad'; import { openFeedbackDialog } from '../../../feedback'; import { beginAddPeople } from '../../../invite'; import { openKeyboardShortcutsDialog } from '../../../keyboard-shortcuts'; +import { LobbyModeButton } from '../../../lobby'; import { LocalRecordingButton, LocalRecordingInfoDialog @@ -1188,6 +1189,9 @@ class Toolbox extends Component { if (this._shouldShowButton('closedcaptions')) { buttonsLeft.push('closedcaptions'); } + if (this._shouldShowButton('lobby')) { + buttonsRight.push('lobby'); + } if (overflowHasItems) { buttonsRight.push('overflowmenu'); } @@ -1271,6 +1275,8 @@ class Toolbox extends Component { { this._renderVideoButton() }
+ { (buttonsRight.indexOf('lobby') !== -1) + && } { buttonsRight.indexOf('localrecording') !== -1 &&