feat: lobby feature

The lobby feature adds the possibility to lock a meeting and only allow people in after virtually knocking and going through formal approval
This commit is contained in:
Bettenbuk Zoltan 2020-04-15 15:13:43 +02:00 committed by Zoltan Bettenbuk
parent 338c960215
commit 475a2ae596
56 changed files with 2399 additions and 47 deletions

View File

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

211
css/_lobby.scss Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}
/**

View File

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

View File

@ -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,
/**

View File

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

View File

@ -9,6 +9,7 @@ import { configureInitialDevices } from '../devices';
import { getBackendSafeRoomName } from '../util';
export {
connectionDisconnected,
connectionEstablished,
connectionFailed,
setLocationURL

View File

@ -57,14 +57,13 @@ class BaseDialog<P: Props, S: State> extends AbstractDialog<P, S> {
<KeyboardAvoidingView
behavior = 'height'
style = { [
styles.overlay,
style
styles.overlay
] }>
<View
pointerEvents = 'box-none'
style = { [
_dialogStyles.dialog,
this.props.style
style
] }>
<TouchableOpacity
onPress = { this._onCancel }

View File

@ -34,7 +34,7 @@ class BaseSubmitDialog<P: Props, S: *> extends BaseDialog<P, S> {
* @returns {string}
*/
_getSubmitButtonKey() {
return 'dialog.Ok';
return this.props.okKey || 'dialog.Ok';
}
/**

View File

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

View File

@ -33,6 +33,11 @@ type Props = {
*/
customHeader?: React$Element<any> | 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<Props> {
return;
}
if (event.key === 'Enter') {
if (event.key === 'Enter' && !this.props.disableEnter) {
event.preventDefault();
event.stopPropagation();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 287 B

View File

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M11 11h-1v2h2v-1l9.73 9.73L20.46 23 14 16.54V21H3v-2h2V7.54l-4-4 1.27-1.27L11 11zm3 .49L5.51 3H14v1h5v12.49l-2-2V6h-3v5.49z"/></svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M14 6v15H3v-2h2V3h9v1h5v15h2v2h-4V6h-3zm-4 5v2h2v-2h-2z"/></svg>

After

Width:  |  Height:  |  Size: 156 B

View File

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

View File

@ -1,3 +1,5 @@
export * from './components';
export * from './functions';
export { default as Platform } from './Platform';
export * from './Types';

View File

@ -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<Props, *> {
style = { styles.navBarSafeView }>
<NavigationBar />
{ this._renderNotificationsContainer() }
<KnockingParticipantList />
</SafeAreaView>
<TestConnectionInfo />
@ -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),

View File

@ -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<Props, *> {
<InviteMore />
<div id = 'videospace'>
<LargeVideo />
{ hideLabels
|| <Labels /> }
<KnockingParticipantList />
{ hideLabels || <Labels /> }
<Filmstrip filmstripOnly = { filmstripOnly } />
</div>

View File

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

View File

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

View File

@ -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<any>, 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<any>, 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<any>, 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<any>, getState: Function) => {
const { conference } = getState()['features/base/conference'];
if (enabled) {
conference.enableLobby(password);
} else {
conference.disableLobby();
}
};
}

View File

@ -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<P: Props = Props> extends PureComponent<P> {
/**
* 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;
}
}

View File

@ -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<P: Props = Props> extends PureComponent<P, State> {
/**
* 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;
}
}

View File

@ -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<Object>,
/**
* 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<Props> {
/**
* 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<Props> {
const _participants = state['features/lobby'].knockingParticipants;
return {
_participants,
_toolboxVisible: isToolboxVisible(state),
_visible: isLocalParticipantModerator(state) && Boolean(_participants?.length)
};
}

View File

@ -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<Props, State> {
/**
* 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<Props> {
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
};
}

View File

@ -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<Props, any> {
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<Props> {
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));

View File

@ -0,0 +1,5 @@
// @flow
export * from './native';
export { default as LobbyModeButton } from './LobbyModeButton';

View File

@ -0,0 +1,5 @@
// @flow
export * from './web';
export { default as LobbyModeButton } from './LobbyModeButton';

View File

@ -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 (
<ConfirmDialog
contentKey = 'lobby.disableDialogContent'
onSubmit = { this._onDisableLobbyMode } />
);
}
_onDisableLobbyMode: () => void;
}
export default translate(connect()(DisableLobbyModeDialog));

View File

@ -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<Props> {
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
*/
render() {
const { _dialogStyles, t } = this.props;
return (
<CustomSubmitDialog
okKey = 'lobby.enableDialogSubmit'
onSubmit = { this._onEnableLobbyMode }
titleKey = 'lobby.dialogTitle'>
<View style = { styles.formWrapper }>
<Text>
{ t('lobby.enableDialogText') }
</Text>
<View style = { styles.fieldRow }>
<Text>
{ t('lobby.enableDialogPasswordField') }
</Text>
<TextInput
autoCapitalize = 'none'
autoCompleteType = 'off'
onChangeText = { this._onChangePassword }
secureTextEntry = { true }
style = { _dialogStyles.field } />
</View>
</View>
</CustomSubmitDialog>
);
}
_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));

View File

@ -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 (
<ScrollView
style = { styles.knockingParticipantList }>
{ participants.map(p => (
<View
key = { p.id }
style = { styles.knockingParticipantListEntry }>
<Avatar
displayName = { p.name }
size = { 48 }
url = { p.loadableAvatarUrl } />
<View style = { styles.knockingParticipantListDetails }>
<Text style = { styles.knockingParticipantListText }>
{ p.name }
</Text>
{ p.email && (
<Text style = { styles.knockingParticipantListText }>
{ p.email }
</Text>
) }
</View>
<TouchableOpacity
onPress = { this._onRespondToParticipant(p.id, true) }
style = { [
styles.knockingParticipantListButton,
styles.knockingParticipantListPrimaryButton
] }>
<Text style = { styles.knockingParticipantListText }>
{ t('lobby.allow') }
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress = { this._onRespondToParticipant(p.id, false) }
style = { [
styles.knockingParticipantListButton,
styles.knockingParticipantListSecondaryButton
] }>
<Text style = { styles.knockingParticipantListText }>
{ t('lobby.reject') }
</Text>
</TouchableOpacity>
</View>
)) }
</ScrollView>
);
}
_onRespondToParticipant: (string, boolean) => Function;
}
export default translate(connect(mapStateToProps)(KnockingParticipantList));

View File

@ -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 (
<CustomDialog
onCancel = { this._onCancel }
style = { styles.contentWrapper }>
<Text style = { styles.dialogTitle }>
{ t(this._getScreenTitleKey()) }
</Text>
<Text style = { styles.secondaryText }>
{ _meetingName }
</Text>
{ this._renderContent() }
</CustomDialog>
);
}
_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 (
<>
<LoadingIndicator
color = 'black'
style = { styles.loadingIndicator } />
<Text style = { styles.joiningMessage }>
{ this.props.t('lobby.joiningMessage') }
</Text>
</>
);
}
/**
* Renders the participant form to let the knocking participant enter its details.
*
* @inheritdoc
*/
_renderParticipantForm() {
const { t } = this.props;
const { displayName, email } = this.state;
return (
<View style = { styles.formWrapper }>
<Text style = { styles.fieldLabel }>
{ t('lobby.nameField') }
</Text>
<TextInput
onChangeText = { this._onChangeDisplayName }
style = { styles.field }
value = { displayName } />
<Text style = { styles.fieldLabel }>
{ t('lobby.emailField') }
</Text>
<TextInput
onChangeText = { this._onChangeEmail }
style = { styles.field }
value = { email } />
</View>
);
}
/**
* Renders the participant info fragment when we have all the required details of the user.
*
* @inheritdoc
*/
_renderParticipantInfo() {
const { displayName, email } = this.state;
return (
<View style = { styles.participantBox }>
<TouchableOpacity
onPress = { this._onEnableEdit }
style = { styles.editButton }>
<Icon
src = { IconEdit }
style = { styles.editIcon } />
</TouchableOpacity>
<Avatar
participantId = { this.props._participantId }
size = { 64 }
style = { styles.avatar } />
<Text style = { styles.displayNameText }>
{ displayName }
</Text>
{ Boolean(email) && <Text style = { styles.secondaryText }>
{ email }
</Text> }
</View>
);
}
/**
* Renders the password form to let the participant join by using a password instead of knocking.
*
* @inheritdoc
*/
_renderPasswordForm() {
return (
<View style = { styles.formWrapper }>
<Text style = { styles.fieldLabel }>
{ this.props.t('lobby.passwordField') }
</Text>
<TextInput
autoCapitalize = 'none'
autoCompleteType = 'off'
onChangeText = { this._onChangePassword }
secureTextEntry = { true }
style = { styles.field }
value = { this.state.password } />
</View>
);
}
/**
* Renders the password join button (set).
*
* @inheritdoc
*/
_renderPasswordJoinButtons() {
const { t } = this.props;
return (
<>
<TouchableOpacity
disabled = { !this.state.password }
onPress = { this._onAskToJoin }
style = { [
styles.button,
styles.primaryButton
] }>
<Text style = { styles.primaryButtonText }>
{ t('lobby.passwordJoinButton') }
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress = { this._onSwitchToKnockMode }
style = { [
styles.button,
styles.secondaryButton
] }>
<Text>
{ t('lobby.backToKnockModeButton') }
</Text>
</TouchableOpacity>
</>
);
}
/**
* Renders the standard button set.
*
* @inheritdoc
*/
_renderStandardButtons() {
const { t } = this.props;
return (
<>
<TouchableOpacity
disabled = { !this.state.displayName }
onPress = { this._onAskToJoin }
style = { [
styles.button,
styles.primaryButton
] }>
<Text style = { styles.primaryButtonText }>
{ t('lobby.knockButton') }
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress = { this._onSwitchToPasswordMode }
style = { [
styles.button,
styles.secondaryButton
] }>
<Text>
{ t('lobby.enterPasswordButton') }
</Text>
</TouchableOpacity>
</>
);
}
}
export default translate(connect(_mapStateToProps)(LobbyScreen));

View File

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

View File

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

View File

@ -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 (
<Dialog
className = 'lobby-screen'
okKey = 'lobby.disableDialogSubmit'
onSubmit = { this._onDisableLobbyMode }
titleKey = 'lobby.dialogTitle'>
{ t('lobby.disableDialogContent') }
</Dialog>
);
}
_onDisableLobbyMode: () => void;
}
export default translate(connect()(DisableLobbyModeDialog));

View File

@ -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 (
<Dialog
className = 'lobby-screen'
okKey = 'lobby.enableDialogSubmit'
onSubmit = { this._onEnableLobbyMode }
titleKey = 'lobby.dialogTitle'>
<div id = 'lobby-dialog'>
<span className = 'description'>
{ t('lobby.enableDialogText') }
</span>
<div className = 'field'>
<label htmlFor = 'password'>
{ t('lobby.enableDialogPasswordField') }
</label>
<input
onChange = { this._onChangePassword }
type = 'password'
value = { this.state.password } />
</div>
</div>
</Dialog>
);
}
_onChangePassword: Object => void;
_onEnableLobbyMode: () => void;
}
export default translate(connect()(EnableLobbyModeDialog));

View File

@ -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 (
<div
className = { _toolboxVisible ? 'toolbox-visible' : '' }
id = 'knocking-participant-list'>
<span className = 'title'>
Knocking participant list
</span>
<ul>
{ _participants.map(p => (
<li key = { p.id }>
<Avatar
displayName = { p.name }
size = { 48 }
url = { p.loadableAvatarUrl } />
<div className = 'details'>
<span>
{ p.name }
</span>
{ p.email && (
<span>
{ p.email }
</span>
) }
</div>
<button
className = 'primary'
onClick = { this._onRespondToParticipant(p.id, true) }
type = 'button'>
{ t('lobby.allow') }
</button>
<button
className = 'borderLess'
onClick = { this._onRespondToParticipant(p.id, false) }
type = 'button'>
{ t('lobby.reject') }
</button>
</li>
)) }
</ul>
</div>
);
}
_onRespondToParticipant: (string, boolean) => Function;
}
export default translate(connect(mapStateToProps)(KnockingParticipantList));

View File

@ -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 (
<Dialog
disableBlanketClickDismiss = { false }
disableEnter = { true }
hideCancelButton = { true }
isModal = { false }
onCancel = { this._onCancel }
submitDisabled = { true }
width = 'small'>
<div id = 'lobby-screen'>
<span className = 'title'>
{ t(this._getScreenTitleKey()) }
</span>
<span className = 'roomName'>
{ _meetingName }
</span>
{ this._renderContent() }
</div>
</Dialog>
);
}
_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 (
<div className = 'joiningContainer'>
<LoadingIndicator />
<span>
{ this.props.t(`lobby.${withPassword ? 'joinWithPasswordMessage' : 'joiningMessage'}`) }
</span>
</div>
);
}
/**
* Renders the participant form to let the knocking participant enter its details.
*
* @inheritdoc
*/
_renderParticipantForm() {
const { t } = this.props;
const { displayName, email } = this.state;
return (
<div className = 'form'>
<span>
{ t('lobby.nameField') }
</span>
<input
onChange = { this._onChangeDisplayName }
type = 'text'
value = { displayName } />
<span>
{ t('lobby.emailField') }
</span>
<input
onChange = { this._onChangeEmail }
type = 'email'
value = { email } />
</div>
);
}
/**
* 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 (
<div className = 'participantInfo'>
<div className = 'editButton'>
<button
onClick = { this._onEnableEdit }
type = 'button'>
<Icon src = { IconEdit } />
</button>
</div>
<Avatar
participantId = { _participantId }
size = { 64 } />
<span className = 'displayName'>
{ displayName }
</span>
<span className = 'email'>
{ email }
</span>
</div>
);
}
/**
* Renders the password form to let the participant join by using a password instead of knocking.
*
* @inheritdoc
*/
_renderPasswordForm() {
return (
<div className = 'form'>
<span>
{ this.props.t('lobby.passwordField') }
</span>
<input
onChange = { this._onChangePassword }
type = 'password'
value = { this.state.password } />
</div>
);
}
/**
* Renders the password join button (set).
*
* @inheritdoc
*/
_renderPasswordJoinButtons() {
const { t } = this.props;
return (
<>
<button
className = 'primary'
disabled = { !this.state.password }
onClick = { this._onAskToJoin }
type = 'submit'>
{ t('lobby.passwordJoinButton') }
</button>
<button
className = 'borderLess'
onClick = { this._onSwitchToKnockMode }
type = 'button'>
{ t('lobby.backToKnockModeButton') }
</button>
</>
);
}
/**
* Renders the standard button set.
*
* @inheritdoc
*/
_renderStandardButtons() {
const { t } = this.props;
return (
<>
<button
className = 'primary'
disabled = { !this.state.displayName }
onClick = { this._onAskToJoin }
type = 'submit'>
{ t('lobby.knockButton') }
</button>
<button
className = 'borderLess'
onClick = { this._onSwitchToPasswordMode }
type = 'button'>
{ t('lobby.enterPasswordButton') }
</button>
</>
);
}
}
export default translate(connect(_mapStateToProps)(LobbyScreen));

View File

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

View File

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

View File

@ -0,0 +1,6 @@
// @flow
import './middleware';
import './reducer';
export * from './components';

View File

@ -0,0 +1,5 @@
// @flow
import { getLogger } from '../base/logging/functions';
export default getLogger('features/lobby');

View File

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

View File

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

View File

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

View File

@ -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<Props, State> {
<InviteButton { ...buttonProps } />
<AudioOnlyButton { ...buttonProps } />
<RaiseHandButton { ...buttonProps } />
<LobbyModeButton { ...buttonProps } />
<MoreOptionsButton { ...moreOptionsButtonProps } />
<Collapsible collapsed = { !showMore }>
<ToggleCameraButton { ...buttonProps } />

View File

@ -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<Props, State> {
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<Props, State> {
{ this._renderVideoButton() }
</div>
<div className = 'button-group-right'>
{ (buttonsRight.indexOf('lobby') !== -1)
&& <LobbyModeButton /> }
{ buttonsRight.indexOf('localrecording') !== -1
&& <LocalRecordingButton
onClick = {