init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
/**
* 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';
/**
* Action type to set the lobby visibility.
*/
export const SET_LOBBY_VISIBILITY = 'TOGGLE_LOBBY_VISIBILITY';
/**
* Action type to set the password join failed status.
*/
export const SET_PASSWORD_JOIN_FAILED = 'SET_PASSWORD_JOIN_FAILED';
/**
* Action type to set a lobby chat participant's state to chatting
*/
export const SET_LOBBY_PARTICIPANT_CHAT_STATE = 'SET_LOBBY_PARTICIPANT_CHAT_STATE';
/**
* Action type to remove chattingWithModerator field
*/
export const REMOVE_LOBBY_CHAT_WITH_MODERATOR = 'REMOVE_LOBBY_CHAT_WITH_MODERATOR';

View File

@@ -0,0 +1,429 @@
import { IStore } from '../app/types';
import { conferenceWillJoin, setPassword } from '../base/conference/actions';
import { getCurrentConference, sendLocalParticipant } from '../base/conference/functions';
import { getLocalParticipant } from '../base/participants/functions';
import { IParticipant } from '../base/participants/types';
import { onLobbyChatInitialized, removeLobbyChatParticipant, sendMessage } from '../chat/actions.any';
import { LOBBY_CHAT_MESSAGE } from '../chat/constants';
import { handleLobbyMessageReceived } from '../chat/middleware';
import { hideNotification, showNotification } from '../notifications/actions';
import { LOBBY_NOTIFICATION_ID } from '../notifications/constants';
import { joinConference } from '../prejoin/actions';
import {
KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED,
KNOCKING_PARTICIPANT_LEFT,
REMOVE_LOBBY_CHAT_WITH_MODERATOR,
SET_KNOCKING_STATE,
SET_LOBBY_MODE_ENABLED,
SET_LOBBY_PARTICIPANT_CHAT_STATE,
SET_LOBBY_VISIBILITY,
SET_PASSWORD_JOIN_FAILED
} from './actionTypes';
import { LOBBY_CHAT_INITIALIZED, MODERATOR_IN_CHAT_WITH_LEFT } from './constants';
import { getKnockingParticipants, getLobbyConfig, getLobbyEnabled, isEnablingLobbyAllowed } from './functions';
import logger from './logger';
import { IKnockingParticipant } from './types';
/**
* Tries to join with a preset password.
*
* @param {string} password - The password to join with.
* @returns {Function}
*/
export function joinWithPassword(password: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
dispatch(setPassword(conference, conference?.join, password));
};
}
/**
* 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 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: IKnockingParticipant | Object) {
return {
participant,
type: KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED
};
}
/**
* Handles a knocking participant and dismisses the notification.
*
* @param {string} id - The id of the knocking participant.
* @param {boolean} approved - True if the participant is approved, false otherwise.
* @returns {Function}
*/
export function answerKnockingParticipant(id: string, approved: boolean) {
return (dispatch: IStore['dispatch']) => {
dispatch(setKnockingParticipantApproval(id, approved));
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
};
}
/**
* 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: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
if (conference) {
if (approved) {
conference.lobbyApproveAccess(id);
} else {
conference.lobbyDenyAccess(id);
}
}
};
}
/**
* Action used to admit multiple participants in the conference.
*
* @param {Array<Object>} participants - A list of knocking participants.
* @returns {void}
*/
export function admitMultiple(participants: Array<IKnockingParticipant>) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.lobbyApproveAccess(participants.map(p => p.id));
};
}
/**
* Approves the request of a knocking participant to join the meeting.
*
* @param {string} id - The id of the knocking participant.
* @returns {Function}
*/
export function approveKnockingParticipant(id: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.lobbyApproveAccess(id);
};
}
/**
* Denies the request of a knocking participant to join the meeting.
*
* @param {string} id - The id of the knocking participant.
* @returns {Function}
*/
export function rejectKnockingParticipant(id: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.lobbyDenyAccess(id);
};
}
/**
* 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
};
}
/**
* 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 be dispatched when we failed to join with a password.
*
* @param {boolean} failed - True of recent password join failed.
* @returns {{
* failed: boolean,
* type: SET_PASSWORD_JOIN_FAILED
* }}
*/
export function setPasswordJoinFailed(failed: boolean) {
return {
failed,
type: SET_PASSWORD_JOIN_FAILED
};
}
/**
* Starts knocking and waiting for approval.
*
* @returns {Function}
*/
export function startKnocking() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { membersOnly } = state['features/base/conference'];
logger.info(`Lobby starting knocking (membersOnly = ${membersOnly})`);
if (!membersOnly) {
// let's hide the notification (the case with denied access and retrying)
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
// no membersOnly, this means we got lobby screen shown as someone
// tried to join a conference that has lobby enabled without setting display name
// join conference should trigger the lobby/member_only path after setting the display name
// this is possible only for web, where we can join without a prejoin screen
dispatch(joinConference());
return;
}
const localParticipant = getLocalParticipant(state);
dispatch(conferenceWillJoin(membersOnly));
// We need to update the conference object with the current display name, if approved
// we want to send that display name, it was not updated in case when pre-join is disabled
sendLocalParticipant(state, membersOnly);
membersOnly?.joinLobby(localParticipant?.name, localParticipant?.email);
dispatch(setLobbyMessageListener());
dispatch(setKnockingState(true));
};
}
/**
* Action to toggle lobby mode on or off.
*
* @param {boolean} enabled - The desired (new) state of the lobby mode.
* @returns {Function}
*/
export function toggleLobbyMode(enabled: boolean) {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
if (enabled) {
if (isEnablingLobbyAllowed(getState())) {
conference?.enableLobby();
} else {
logger.info('Ignoring enable lobby request because there are visitors in the call already.');
}
} else {
conference?.disableLobby();
}
};
}
/**
* Action to open the lobby screen.
*
* @returns {openDialog}
*/
export function openLobbyScreen() {
return {
type: SET_LOBBY_VISIBILITY,
visible: true
};
}
/**
* Action to hide the lobby screen.
*
* @returns {hideDialog}
*/
export function hideLobbyScreen() {
return {
type: SET_LOBBY_VISIBILITY,
visible: false
};
}
/**
* Action to handle chat initialized in the lobby room.
*
* @param {Object} payload - The payload received,
* contains the information about the two participants
* that will chat with each other in the lobby room.
*
* @returns {Promise<void>}
*/
export function handleLobbyChatInitialized(payload: { attendee: IParticipant; moderator: IParticipant; }) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const conference = getCurrentConference(state);
const id = conference?.myLobbyUserId();
dispatch({
type: SET_LOBBY_PARTICIPANT_CHAT_STATE,
participant: payload.attendee,
moderator: payload.moderator
});
dispatch(onLobbyChatInitialized(payload));
const attendeeIsKnocking = getKnockingParticipants(state).some(p => p.id === payload.attendee.id);
if (attendeeIsKnocking && conference?.getRole() === 'moderator' && payload.moderator.id !== id) {
dispatch(showNotification({
titleKey: 'lobby.lobbyChatStartedNotification',
titleArguments: {
moderator: payload.moderator.name ?? '',
attendee: payload.attendee.name ?? ''
}
}));
}
};
}
/**
* Action to send message to the moderator.
*
* @param {string} message - The message to be sent.
*
* @returns {Promise<void>}
*/
export function onSendMessage(message: string) {
return (dispatch: IStore['dispatch']) => {
dispatch(sendMessage(message));
};
}
/**
* Action to send lobby message to every participant. Only allowed for moderators.
*
* @param {Object} message - The message to be sent.
*
* @returns {Promise<void>}
*/
export function sendLobbyChatMessage(message: Object) {
return async (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const conference = getCurrentConference(getState);
conference?.sendLobbyMessage(message);
};
}
/**
* Sets lobby listeners if lobby has been enabled.
*
* @returns {Function}
*/
export function maybeSetLobbyChatMessageListener() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const lobbyEnabled = getLobbyEnabled(state);
if (lobbyEnabled) {
dispatch(setLobbyMessageListener());
}
};
}
/**
* Action to handle the event when a moderator leaves during lobby chat.
*
* @param {string} participantId - The participant id of the moderator who left.
* @returns {Function}
*/
export function updateLobbyParticipantOnLeave(participantId: string) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const { knocking, knockingParticipants } = state['features/lobby'];
const { lobbyMessageRecipient } = state['features/chat'];
const { conference } = state['features/base/conference'];
if (knocking && lobbyMessageRecipient && lobbyMessageRecipient.id === participantId) {
return dispatch(removeLobbyChatParticipant(true));
}
if (!knocking) {
// inform knocking participant when their moderator leaves
const participantToNotify = knockingParticipants.find(p => p.chattingWithModerator === participantId);
if (participantToNotify) {
conference?.sendLobbyMessage({
type: MODERATOR_IN_CHAT_WITH_LEFT,
moderatorId: participantToNotify.chattingWithModerator
}, participantToNotify.id);
}
dispatch({
type: REMOVE_LOBBY_CHAT_WITH_MODERATOR,
moderatorId: participantId
});
}
};
}
/**
* Handles all messages received in the lobby room.
*
* @returns {Function}
*/
export function setLobbyMessageListener() {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
const conference = getCurrentConference(state);
const { enableChat = true } = getLobbyConfig(state);
if (!enableChat) {
return;
}
conference?.addLobbyMessageListener((message: any, participantId: string) => {
if (message.type === LOBBY_CHAT_MESSAGE) {
return dispatch(handleLobbyMessageReceived(message.message, participantId));
}
if (message.type === LOBBY_CHAT_INITIALIZED) {
return dispatch(handleLobbyChatInitialized(message));
}
if (message.type === MODERATOR_IN_CHAT_WITH_LEFT) {
return dispatch(updateLobbyParticipantOnLeave(message.moderatorId));
}
});
};
}

View File

@@ -0,0 +1,23 @@
import { batch } from 'react-redux';
import { appNavigate } from '../app/actions.native';
import { IStore } from '../app/types';
import { hideLobbyScreen, setKnockingState } from './actions.any';
export * from './actions.any';
/**
* Cancels the ongoing knocking and abandons the join flow.
*
* @returns {Function}
*/
export function cancelKnocking() {
return (dispatch: IStore['dispatch']) => {
batch(() => {
dispatch(setKnockingState(false));
dispatch(hideLobbyScreen());
dispatch(appNavigate(undefined));
});
};
}

View File

@@ -0,0 +1,18 @@
import { maybeRedirectToWelcomePage } from '../app/actions.web';
import { IStore } from '../app/types';
export * from './actions.any';
/**
* Cancels the ongoing knocking and abandons the join flow.
*
* @returns {Function}
*/
export function cancelKnocking() {
return async (dispatch: IStore['dispatch']) => {
// when we are redirecting the library should handle any
// unload and clean of the connection.
APP.API.notifyReadyToClose();
dispatch(maybeRedirectToWelcomePage());
};
}

View File

@@ -0,0 +1,467 @@
import React, { PureComponent } from 'react';
import { IReduxState, IStore } from '../../app/types';
import { conferenceWillJoin } from '../../base/conference/actions';
import { getConferenceName } from '../../base/conference/functions';
import { IJitsiConference } from '../../base/conference/reducer';
import { getSecurityUiConfig } from '../../base/config/functions.any';
import { INVITE_ENABLED } from '../../base/flags/constants';
import { getFeatureFlag } from '../../base/flags/functions';
import { getLocalParticipant } from '../../base/participants/functions';
import { getFieldValue } from '../../base/react/functions';
import { updateSettings } from '../../base/settings/actions';
import { IMessage } from '../../chat/types';
import { isDeviceStatusVisible } from '../../prejoin/functions';
import { cancelKnocking, joinWithPassword, onSendMessage, setPasswordJoinFailed, startKnocking } from '../actions';
export const SCREEN_STATES = {
EDIT: 1,
PASSWORD: 2,
VIEW: 3
};
export interface IProps {
/**
* Indicates whether the device status should be visible.
*/
_deviceStatusVisible: boolean;
/**
* Indicates whether the message that display name is required is shown.
*/
_isDisplayNameRequiredActive: boolean;
/**
* True if moderator initiated a chat session with the participant.
*/
_isLobbyChatActive: boolean;
/**
* True if knocking is already happening, so we're waiting for a response.
*/
_knocking: boolean;
/**
* Lobby messages between moderator and the participant.
*/
_lobbyChatMessages: IMessage[];
/**
* Name of the lobby chat recipient.
*/
_lobbyMessageRecipient?: string;
/**
* The name of the meeting we're about to join.
*/
_meetingName: string;
/**
* The members only conference if any,.
*/
_membersOnlyConference?: IJitsiConference;
/**
* 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;
/**
* True if a recent attempt to join with password failed.
*/
_passwordJoinFailed: boolean;
/**
* True if the password field should be available for lobby participants.
*/
_renderPassword: boolean;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Indicates whether the copy url button should be shown.
*/
showCopyUrlButton: boolean;
/**
* Function to be used to translate i18n labels.
*/
t: Function;
}
interface IState {
/**
* The display name value entered into the field.
*/
displayName: string;
/**
* The email value entered into the field.
*/
email: string;
/**
* True if lobby chat widget is open.
*/
isChatOpen: boolean;
/**
* The password value entered into the field.
*/
password: string;
/**
* True if a recent attempt to join with password failed.
*/
passwordJoinFailed: boolean;
/**
* 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<P extends IProps = IProps> extends PureComponent<P, IState> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: P) {
super(props);
this.state = {
displayName: props._participantName || '',
email: props._participantEmail || '',
isChatOpen: true,
password: '',
passwordJoinFailed: false,
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._onJoinWithPassword = this._onJoinWithPassword.bind(this);
this._onSendMessage = this._onSendMessage.bind(this);
this._onSwitchToKnockMode = this._onSwitchToKnockMode.bind(this);
this._onSwitchToPasswordMode = this._onSwitchToPasswordMode.bind(this);
this._onToggleChat = this._onToggleChat.bind(this);
}
/**
* Implements {@code PureComponent.getDerivedStateFromProps}.
*
* @inheritdoc
*/
static getDerivedStateFromProps(props: IProps, state: IState) {
if (props._passwordJoinFailed && !state.passwordJoinFailed) {
return {
password: '',
passwordJoinFailed: true
};
}
return null;
}
/**
* Returns the screen title.
*
* @returns {string}
*/
_getScreenTitleKey() {
const { screenState } = this.state;
const passwordPrompt = screenState === SCREEN_STATES.PASSWORD;
return !passwordPrompt && this.props._knocking
? this.props._isLobbyChatActive ? 'lobby.lobbyChatStartedTitle' : 'lobby.joiningTitle'
: passwordPrompt ? 'lobby.enterPasswordTitle' : 'lobby.joinTitle';
}
/**
* Callback to be invoked when the user submits the joining request.
*
* @returns {void}
*/
_onAskToJoin() {
this.setState({
password: ''
});
this.props.dispatch(startKnocking());
return false;
}
/**
* Callback to be invoked when the user cancels the dialog.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(cancelKnocking());
return true;
}
/**
* Callback to be invoked when the user changes its display name.
*
* @param {SyntheticEvent} event - The SyntheticEvent instance of the change.
* @returns {void}
*/
_onChangeDisplayName(event: { target: { value: string; }; } | string) {
const displayName = getFieldValue(event);
this.setState({
displayName
}, () => {
this.props.dispatch(updateSettings({
displayName
}));
});
}
/**
* Callback to be invoked when the user changes its email.
*
* @param {SyntheticEvent} event - The SyntheticEvent instance of the change.
* @returns {void}
*/
_onChangeEmail(event: { target: { value: string; }; } | string) {
const email = getFieldValue(event);
this.setState({
email
}, () => {
this.props.dispatch(updateSettings({
email
}));
});
}
/**
* Callback to be invoked when the user changes the password.
*
* @param {SyntheticEvent} event - The SyntheticEvent instance of the change.
* @returns {void}
*/
_onChangePassword(event: { target: { value: string; }; } | string) {
this.setState({
password: getFieldValue(event)
});
}
/**
* Callback to be invoked for the edit button.
*
* @returns {void}
*/
_onEnableEdit() {
this.setState({
screenState: SCREEN_STATES.EDIT
});
}
/**
* Callback to be invoked when the user tries to join using a preset password.
*
* @returns {void}
*/
_onJoinWithPassword() {
this.setState({
passwordJoinFailed: false
});
this.props.dispatch(joinWithPassword(this.state.password));
}
/**
* Callback to be invoked for sending lobby chat messages.
*
* @param {string} message - Message to be sent.
* @returns {void}
*/
_onSendMessage(message: string) {
this.props.dispatch(onSendMessage(message));
}
/**
* Callback to be invoked for the enter (go back to) knocking mode button.
*
* @returns {void}
*/
_onSwitchToKnockMode() {
this.setState({
password: '',
screenState: this.state.displayName ? SCREEN_STATES.VIEW : SCREEN_STATES.EDIT
});
this.props.dispatch(setPasswordJoinFailed(false));
// let's return to the correct state after password failed
this.props.dispatch(conferenceWillJoin(this.props._membersOnlyConference));
}
/**
* Callback to be invoked for the enter password button.
*
* @returns {void}
*/
_onSwitchToPasswordMode() {
this.setState({
screenState: SCREEN_STATES.PASSWORD
});
}
/**
* Callback to be invoked for toggling lobby chat visibility.
*
* @returns {void}
*/
_onToggleChat() {
this.setState(_prevState => {
return {
isChatOpen: !_prevState.isChatOpen
};
});
}
/**
* Renders the content of the dialog.
*
* @returns {React$Element}
*/
_renderContent() {
const { _knocking } = this.props;
const { screenState } = this.state;
if (screenState !== SCREEN_STATES.PASSWORD && _knocking) {
return this._renderJoining();
}
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.
*
* @returns {React$Element}
*/
_renderJoining() {
return <></>;
}
/**
* Renders the participant form to let the knocking participant enter its details.
*
* @returns {React$Element}
*/
_renderParticipantForm() {
return <></>;
}
/**
* Renders the participant info fragment when we have all the required details of the user.
*
* @returns {React$Element}
*/
_renderParticipantInfo() {
return <></>;
}
/**
* Renders the password form to let the participant join by using a password instead of knocking.
*
* @returns {React$Element}
*/
_renderPasswordForm() {
return <></>;
}
/**
* Renders the password join button (set).
*
* @returns {React$Element}
*/
_renderPasswordJoinButtons() {
return <></>;
}
/**
* Renders the standard (pre-knocking) button set.
*
* @returns {React$Element}
*/
_renderStandardButtons() {
return <></>;
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
export function _mapStateToProps(state: IReduxState) {
const localParticipant = getLocalParticipant(state);
const participantId = localParticipant?.id;
const inviteEnabledFlag = getFeatureFlag(state, INVITE_ENABLED, true);
const { disableInviteFunctions } = state['features/base/config'];
const { isDisplayNameRequiredError, knocking, passwordJoinFailed } = state['features/lobby'];
const { iAmSipGateway } = state['features/base/config'];
const { disableLobbyPassword } = getSecurityUiConfig(state);
const showCopyUrlButton = inviteEnabledFlag || !disableInviteFunctions;
const deviceStatusVisible = isDeviceStatusVisible(state);
const { membersOnly, lobbyWaitingForHost } = state['features/base/conference'];
const { isLobbyChatActive, lobbyMessageRecipient, messages } = state['features/chat'];
return {
_deviceStatusVisible: deviceStatusVisible,
_isDisplayNameRequiredActive: Boolean(isDisplayNameRequiredError),
_knocking: knocking,
_lobbyChatMessages: messages,
_lobbyMessageRecipient: lobbyMessageRecipient?.name,
_isLobbyChatActive: isLobbyChatActive,
_meetingName: getConferenceName(state),
_membersOnlyConference: membersOnly,
_participantEmail: localParticipant?.email,
_participantId: participantId,
_participantName: localParticipant?.name,
_passwordJoinFailed: passwordJoinFailed,
_renderPassword: !iAmSipGateway && !disableLobbyPassword && !lobbyWaitingForHost,
showCopyUrlButton
};
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import ChatInputBar from '../../../chat/components/native/ChatInputBar';
import MessageContainer from '../../../chat/components/native/MessageContainer';
import AbstractLobbyScreen, {
IProps as AbstractProps,
_mapStateToProps as abstractMapStateToProps
} from '../AbstractLobbyScreen';
import styles from './styles';
/**
* Implements a chat screen that appears when communication is started
* between the moderator and the participant being in the lobby.
*/
class LobbyChatScreen extends
AbstractLobbyScreen<AbstractProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _lobbyChatMessages } = this.props;
return (
<JitsiScreen
hasBottomTextInput = { true }
hasExtraHeaderHeight = { true }
style = { styles.lobbyChatWrapper }>
{/* @ts-ignore */}
<MessageContainer messages = { _lobbyChatMessages } />
<ChatInputBar onSend = { this._onSendMessage } />
</JitsiScreen>
);
}
_onSendMessage: () => void;
}
export default translate(connect(abstractMapStateToProps)(LobbyChatScreen));

View File

@@ -0,0 +1,271 @@
import React from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getConferenceName } from '../../../base/conference/functions';
import { translate } from '../../../base/i18n/functions';
import JitsiScreen from '../../../base/modal/components/JitsiScreen';
import LoadingIndicator from '../../../base/react/components/native/LoadingIndicator';
import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import Button from '../../../base/ui/components/native/Button';
import Input from '../../../base/ui/components/native/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.native';
import BrandingImageBackground from '../../../dynamic-branding/components/native/BrandingImageBackground';
import LargeVideo from '../../../large-video/components/LargeVideo.native';
import { navigate }
from '../../../mobile/navigation/components/lobby/LobbyNavigationContainerRef';
import { screen } from '../../../mobile/navigation/routes';
import { preJoinStyles } from '../../../prejoin/components/native/styles';
import AudioMuteButton from '../../../toolbox/components/native/AudioMuteButton';
import VideoMuteButton from '../../../toolbox/components/native/VideoMuteButton';
import AbstractLobbyScreen, {
IProps as AbstractProps,
_mapStateToProps as abstractMapStateToProps } from '../AbstractLobbyScreen';
import styles from './styles';
interface IProps extends AbstractProps {
/**
* The current aspect ratio of the screen.
*/
_aspectRatio: Symbol;
/**
* The room name.
*/
_roomName: string;
}
/**
* Implements a waiting screen that represents the participant being in the lobby.
*/
class LobbyScreen extends AbstractLobbyScreen<IProps> {
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
*/
override render() {
const { _aspectRatio, _roomName } = this.props;
let contentWrapperStyles;
let contentContainerStyles;
let largeVideoContainerStyles;
if (_aspectRatio === ASPECT_RATIO_NARROW) {
contentWrapperStyles = preJoinStyles.contentWrapper;
largeVideoContainerStyles = preJoinStyles.largeVideoContainer;
contentContainerStyles = styles.contentContainer;
} else {
contentWrapperStyles = preJoinStyles.contentWrapperWide;
largeVideoContainerStyles = preJoinStyles.largeVideoContainerWide;
contentContainerStyles = preJoinStyles.contentContainerWide;
}
return (
<JitsiScreen
safeAreaInsets = { [ 'right' ] }
style = { contentWrapperStyles }>
<BrandingImageBackground />
<View style = { largeVideoContainerStyles as ViewStyle }>
<View style = { preJoinStyles.displayRoomNameBackdrop as ViewStyle }>
<Text
numberOfLines = { 1 }
style = { preJoinStyles.preJoinRoomName }>
{ _roomName }
</Text>
</View>
<LargeVideo />
</View>
<View style = { contentContainerStyles as ViewStyle }>
{ this._renderToolbarButtons() }
{ this._renderContent() }
</View>
</JitsiScreen>
);
}
/**
* Navigates to the lobby chat screen.
*
* @private
* @returns {void}
*/
_onNavigateToLobbyChat() {
navigate(screen.lobby.chat);
}
/**
* Renders the joining (waiting) fragment of the screen.
*
* @inheritdoc
*/
_renderJoining() {
return (
<View style = { styles.lobbyWaitingFragmentContainer }>
<Text style = { styles.lobbyTitle }>
{ this.props.t('lobby.joiningTitle') }
</Text>
<LoadingIndicator
color = { BaseTheme.palette.icon01 }
style = { styles.loadingIndicator } />
<Text style = { styles.joiningMessage as TextStyle }>
{ this.props.t('lobby.joiningMessage') }
</Text>
{ this._renderStandardButtons() }
</View>
);
}
/**
* Renders the participant form to let the knocking participant enter its details.
*
* @inheritdoc
*/
_renderParticipantForm() {
const { t } = this.props;
const { displayName } = this.state;
return (
<Input
customStyles = {{ input: preJoinStyles.customInput }}
onChange = { this._onChangeDisplayName }
placeholder = { t('lobby.nameField') }
value = { displayName } />
);
}
/**
* Renders the participant info fragment when we have all the required details of the user.
*
* @inheritdoc
*/
_renderParticipantInfo() {
return this._renderParticipantForm();
}
/**
* Renders the password form to let the participant join by using a password instead of knocking.
*
* @inheritdoc
*/
_renderPasswordForm() {
const { _passwordJoinFailed, t } = this.props;
return (
<Input
autoCapitalize = 'none'
customStyles = {{ input: styles.customInput }}
error = { _passwordJoinFailed }
onChange = { this._onChangePassword }
placeholder = { t('lobby.enterPasswordButton') }
secureTextEntry = { true }
value = { this.state.password } />
);
}
/**
* Renders the password join button (set).
*
* @inheritdoc
*/
_renderPasswordJoinButtons() {
return (
<View style = { styles.passwordJoinButtons }>
<Button
accessibilityLabel = 'lobby.passwordJoinButton'
disabled = { !this.state.password }
labelKey = { 'lobby.passwordJoinButton' }
onClick = { this._onJoinWithPassword }
style = { preJoinStyles.joinButton }
type = { BUTTON_TYPES.PRIMARY } />
<Button
accessibilityLabel = 'lobby.backToKnockModeButton'
labelKey = 'lobby.backToKnockModeButton'
onClick = { this._onSwitchToKnockMode }
style = { preJoinStyles.joinButton }
type = { BUTTON_TYPES.TERTIARY } />
</View>
);
}
/**
* Renders the toolbar buttons menu.
*
* @inheritdoc
*/
_renderToolbarButtons() {
return (
<View style = { preJoinStyles.toolboxContainer as ViewStyle }>
<AudioMuteButton
styles = { preJoinStyles.buttonStylesBorderless } />
<VideoMuteButton
styles = { preJoinStyles.buttonStylesBorderless } />
</View>
);
}
/**
* Renders the standard button set.
*
* @inheritdoc
*/
_renderStandardButtons() {
const { _knocking, _renderPassword, _isLobbyChatActive } = this.props;
const { displayName } = this.state;
return (
<View style = { styles.formWrapper as ViewStyle }>
{
_knocking && _isLobbyChatActive
&& <Button
accessibilityLabel = 'toolbar.openChat'
labelKey = 'toolbar.openChat'
onClick = { this._onNavigateToLobbyChat }
style = { preJoinStyles.joinButton }
type = { BUTTON_TYPES.PRIMARY } />
}
{
_knocking
|| <Button
accessibilityLabel = 'lobby.knockButton'
disabled = { !displayName }
labelKey = 'lobby.knockButton'
onClick = { this._onAskToJoin }
style = { preJoinStyles.joinButton }
type = { BUTTON_TYPES.PRIMARY } />
}
{
_renderPassword
&& <Button
accessibilityLabel = 'lobby.enterPasswordButton'
labelKey = 'lobby.enterPasswordButton'
onClick = { this._onSwitchToPasswordMode }
style = { preJoinStyles.joinButton }
type = { BUTTON_TYPES.PRIMARY } />
}
</View>
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {IProps} ownProps - The own props of the component.
* @returns {{
* _aspectRatio: Symbol
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
...abstractMapStateToProps(state),
_aspectRatio: state['features/base/responsive-ui'].aspectRatio,
_roomName: getConferenceName(state)
};
}
export default translate(connect(_mapStateToProps)(LobbyScreen));

View File

@@ -0,0 +1,98 @@
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export default {
lobbyChatWrapper: {
backgroundColor: BaseTheme.palette.ui01,
flex: 1
},
passwordJoinButtons: {
top: 40
},
contentContainer: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.uiBackground,
bottom: 0,
display: 'flex',
height: 388,
justifyContent: 'center',
position: 'absolute',
width: '100%',
zIndex: 1
},
formWrapper: {
alignItems: 'center',
justifyContent: 'center'
},
customInput: {
position: 'relative',
textAlign: 'center',
top: BaseTheme.spacing[6],
width: 352
},
joiningMessage: {
color: BaseTheme.palette.text01,
marginHorizontal: BaseTheme.spacing[3],
textAlign: 'center'
},
loadingIndicator: {
marginBottom: BaseTheme.spacing[3]
},
// KnockingParticipantList
knockingParticipantList: {
backgroundColor: BaseTheme.palette.ui01
},
knockingParticipantListDetails: {
flex: 1,
marginLeft: BaseTheme.spacing[2]
},
knockingParticipantListEntry: {
alignItems: 'center',
backgroundColor: BaseTheme.palette.ui01,
flexDirection: 'row'
},
knockingParticipantListText: {
color: 'white'
},
lobbyButtonAdmit: {
position: 'absolute',
right: 184,
top: 6
},
lobbyButtonChat: {
position: 'absolute',
right: 104,
top: 6
},
lobbyButtonReject: {
position: 'absolute',
right: 16,
top: 6
},
lobbyTitle: {
...BaseTheme.typography.heading5,
color: BaseTheme.palette.text01,
marginBottom: BaseTheme.spacing[3],
textAlign: 'center'
},
lobbyWaitingFragmentContainer: {
height: 260
}
};

View File

@@ -0,0 +1,284 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Icon from '../../../base/icons/components/Icon';
import { IconCloseLarge } from '../../../base/icons/svg';
import PreMeetingScreen from '../../../base/premeeting/components/web/PreMeetingScreen';
import LoadingIndicator from '../../../base/react/components/web/LoadingIndicator';
import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import ChatInput from '../../../chat/components/web/ChatInput';
import MessageContainer from '../../../chat/components/web/MessageContainer';
import AbstractLobbyScreen, {
IProps,
_mapStateToProps
} from '../AbstractLobbyScreen';
/**
* Implements a waiting screen that represents the participant being in the lobby.
*/
class LobbyScreen extends AbstractLobbyScreen<IProps> {
/**
* Reference to the React Component for displaying chat messages. Used for
* scrolling to the end of the chat messages.
*/
_messageContainerRef: React.RefObject<MessageContainer>;
/**
* Initializes a new {@code LobbyScreen} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this._messageContainerRef = React.createRef<MessageContainer>();
}
/**
* Implements {@code Component#componentDidMount}.
*
* @inheritdoc
*/
override componentDidMount() {
this._scrollMessageContainerToBottom(true);
}
/**
* Implements {@code Component#componentDidUpdate}.
*
* @inheritdoc
*/
override componentDidUpdate(prevProps: IProps) {
if (this.props._lobbyChatMessages !== prevProps._lobbyChatMessages) {
this._scrollMessageContainerToBottom(true);
} else if (this.props._isLobbyChatActive && !prevProps._isLobbyChatActive) {
this._scrollMessageContainerToBottom(false);
}
}
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
*/
override render() {
const { _deviceStatusVisible, showCopyUrlButton, t } = this.props;
return (
<PreMeetingScreen
className = 'lobby-screen'
showCopyUrlButton = { showCopyUrlButton }
showDeviceStatus = { _deviceStatusVisible }
title = { t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }>
{ this._renderContent() }
</PreMeetingScreen>
);
}
/**
* Renders the joining (waiting) fragment of the screen.
*
* @inheritdoc
*/
override _renderJoining() {
const { _isLobbyChatActive } = this.props;
return (
<div className = 'lobby-screen-content'>
{_isLobbyChatActive
? this._renderLobbyChat()
: (
<>
<div className = 'spinner'>
<LoadingIndicator size = 'large' />
</div>
<span className = 'joining-message'>
{ this.props.t('lobby.joiningMessage') }
</span>
</>
)}
{ this._renderStandardButtons() }
</div>
);
}
/**
* Renders the widget to chat with the moderator before allowed in.
*
* @inheritdoc
*/
_renderLobbyChat() {
const { _lobbyChatMessages, t } = this.props;
const { isChatOpen } = this.state;
return (
<div className = { `lobby-chat-container ${isChatOpen ? 'hidden' : ''}` }>
<div className = 'lobby-chat-header'>
<h1 className = 'title'>
{ t(this._getScreenTitleKey(), { moderator: this.props._lobbyMessageRecipient }) }
</h1>
<Icon
ariaLabel = { t('toolbar.closeChat') }
onClick = { this._onToggleChat }
role = 'button'
src = { IconCloseLarge } />
</div>
<MessageContainer
messages = { _lobbyChatMessages }
ref = { this._messageContainerRef } />
<ChatInput onSend = { this._onSendMessage } />
</div>
);
}
/**
* Renders the participant form to let the knocking participant enter its details.
*
* NOTE: We don't use edit action on web since the prejoin functionality got merged.
* Mobile won't use it either once prejoin gets implemented there too.
*
* @inheritdoc
*/
override _renderParticipantForm() {
return this._renderParticipantInfo();
}
/**
* Renders the participant info fragment when we have all the required details of the user.
*
* @inheritdoc
*/
override _renderParticipantInfo() {
const { displayName } = this.state;
const { _isDisplayNameRequiredActive, t } = this.props;
const showError = _isDisplayNameRequiredActive && !displayName;
return (
<>
<Input
autoFocus = { true }
className = 'lobby-prejoin-input'
error = { showError }
id = 'lobby-name-field'
onChange = { this._onChangeDisplayName }
placeholder = { t('lobby.nameField') }
testId = 'lobby.nameField'
value = { displayName } />
{ showError && <div
className = 'lobby-prejoin-error'
data-testid = 'lobby.errorMessage'>{t('prejoin.errorMissingName')}</div>}
</>
);
}
/**
* Renders the password form to let the participant join by using a password instead of knocking.
*
* @inheritdoc
*/
override _renderPasswordForm() {
const { _passwordJoinFailed, t } = this.props;
return (
<>
<Input
className = { `lobby-prejoin-input ${_passwordJoinFailed ? 'error' : ''}` }
id = 'lobby-password-input'
onChange = { this._onChangePassword }
placeholder = { t('lobby.enterPasswordButton') }
testId = 'lobby.password'
type = 'password'
value = { this.state.password } />
{_passwordJoinFailed && <div
className = 'lobby-prejoin-error'
data-testid = 'lobby.errorMessage'>{t('lobby.invalidPassword')}</div>}
</>
);
}
/**
* Renders the password join button (set).
*
* @inheritdoc
*/
override _renderPasswordJoinButtons() {
return (
<>
<Button
className = 'lobby-button-margin'
fullWidth = { true }
labelKey = 'prejoin.joinMeeting'
onClick = { this._onJoinWithPassword }
testId = 'lobby.passwordJoinButton'
type = 'primary' />
<Button
className = 'lobby-button-margin'
fullWidth = { true }
labelKey = 'lobby.backToKnockModeButton'
onClick = { this._onSwitchToKnockMode }
testId = 'lobby.backToKnockModeButton'
type = 'secondary' />
</>
);
}
/**
* Renders the standard button set.
*
* @inheritdoc
*/
override _renderStandardButtons() {
const { _knocking, _isLobbyChatActive, _renderPassword } = this.props;
return (
<>
{_knocking || <Button
className = 'lobby-button-margin'
disabled = { !this.state.displayName }
fullWidth = { true }
labelKey = 'lobby.knockButton'
onClick = { this._onAskToJoin }
testId = 'lobby.knockButton'
type = 'primary' />
}
{(_knocking && _isLobbyChatActive) && <Button
className = 'lobby-button-margin open-chat-button'
fullWidth = { true }
labelKey = 'toolbar.openChat'
onClick = { this._onToggleChat }
testId = 'toolbar.openChat'
type = 'primary' />
}
{_renderPassword && <Button
className = 'lobby-button-margin'
fullWidth = { true }
labelKey = 'lobby.enterPasswordButton'
onClick = { this._onSwitchToPasswordMode }
testId = 'lobby.enterPasswordButton'
type = 'secondary' />
}
</>
);
}
/**
* Scrolls the chat messages so the latest message is visible.
*
* @param {boolean} withAnimation - Whether or not to show a scrolling
* animation.
* @private
* @returns {void}
*/
_scrollMessageContainerToBottom(withAnimation: boolean) {
if (this._messageContainerRef.current) {
this._messageContainerRef.current.scrollToElement(withAnimation, null);
}
}
}
export default translate(connect(_mapStateToProps)(LobbyScreen));

View File

@@ -0,0 +1,122 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import Switch from '../../../base/ui/components/web/Switch';
import { toggleLobbyMode } from '../../actions';
interface IProps extends WithTranslation {
/**
* True if lobby is currently enabled in the conference.
*/
_lobbyEnabled: boolean;
/**
* The Redux Dispatch function.
*/
dispatch: IStore['dispatch'];
}
interface IState {
/**
* True if the lobby switch is toggled on.
*/
lobbyEnabled: boolean;
}
/**
* Implements a security feature section to control lobby mode.
*/
class LobbySection extends PureComponent<IProps, IState> {
/**
* Instantiates a new component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this.state = {
lobbyEnabled: props._lobbyEnabled
};
this._onToggleLobby = this._onToggleLobby.bind(this);
}
/**
* Implements React's {@link Component#getDerivedStateFromProps()}.
*
* @inheritdoc
*/
static getDerivedStateFromProps(props: IProps, state: IState) {
if (props._lobbyEnabled !== state.lobbyEnabled) {
return {
lobbyEnabled: props._lobbyEnabled
};
}
return null;
}
/**
* Implements {@code PureComponent#render}.
*
* @inheritdoc
*/
override render() {
const { t } = this.props;
return (
<div id = 'lobby-section'>
<p
className = 'description'
role = 'banner'>
{ t('lobby.enableDialogText') }
</p>
<div className = 'control-row'>
<label htmlFor = 'lobby-section-switch'>
{ t('lobby.toggleLabel') }
</label>
<Switch
checked = { this.state.lobbyEnabled }
id = 'lobby-section-switch'
onChange = { this._onToggleLobby } />
</div>
</div>
);
}
/**
* Callback to be invoked when the user toggles the lobby feature on or off.
*
* @returns {void}
*/
_onToggleLobby() {
const newValue = !this.state.lobbyEnabled;
this.setState({
lobbyEnabled: newValue
});
this.props.dispatch(toggleLobbyMode(newValue));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
return {
_lobbyEnabled: state['features/lobby'].lobbyEnabled
};
}
export default translate(connect(mapStateToProps)(LobbySection));

View File

@@ -0,0 +1,25 @@
/**
* Hide these emails when trying to join a lobby.
*/
export const HIDDEN_EMAILS = [ 'inbound-sip-jibri@jitsi.net', 'outbound-sip-jibri@jitsi.net' ];
/**
* The identifier of the sound to be played when a participant joins lobby.
*
* @type {string}
*/
export const KNOCKING_PARTICIPANT_SOUND_ID = 'KNOCKING_PARTICIPANT_SOUND';
/**
* Lobby chat initialized message type.
*
* @type {string}
*/
export const LOBBY_CHAT_INITIALIZED = 'LOBBY_CHAT_INITIALIZED';
/**
* Event message sent to knocking participant when moderator in chat with leaves.
*
* @type {string}
*/
export const MODERATOR_IN_CHAT_WITH_LEFT = 'MODERATOR_IN_CHAT_WITH_LEFT';

View File

@@ -0,0 +1,105 @@
import { IReduxState } from '../app/types';
import { getCurrentConference } from '../base/conference/functions';
import { getVisitorsCount } from '../visitors/functions';
import { IKnockingParticipant } from './types';
/**
* Selector to return lobby enable state.
*
* @param {IReduxState} state - State object.
* @returns {boolean}
*/
export function getLobbyEnabled(state: IReduxState) {
return state['features/lobby'].lobbyEnabled;
}
/**
* Selector to return a list of knocking participants.
*
* @param {IReduxState} state - State object.
* @returns {Array<Object>}
*/
export function getKnockingParticipants(state: IReduxState) {
return state['features/lobby'].knockingParticipants;
}
/**
* Selector to return lobby visibility.
*
* @param {IReduxState} state - State object.
* @returns {any}
*/
export function getIsLobbyVisible(state: IReduxState) {
return state['features/lobby'].lobbyVisible;
}
/**
* Selector to return array with knocking participant ids.
*
* @param {IReduxState} state - State object.
* @returns {Array}
*/
export function getKnockingParticipantsById(state: IReduxState) {
return getKnockingParticipants(state).map(participant => participant.id);
}
/**
* Selector to return the lobby config.
*
* @param {IReduxState} state - State object.
* @returns {Object}
*/
export function getLobbyConfig(state: IReduxState) {
return state['features/base/config']?.lobby || {};
}
/**
* Function that handles the visibility of the lobby chat message.
*
* @param {Object} participant - Lobby Participant.
* @returns {Function}
*/
export function showLobbyChatButton(
participant: IKnockingParticipant
) {
return function(state: IReduxState) {
const { enableChat = true } = getLobbyConfig(state);
const { lobbyMessageRecipient, isLobbyChatActive } = state['features/chat'];
const conference = getCurrentConference(state);
const lobbyLocalId = conference?.myLobbyUserId();
if (!enableChat) {
return false;
}
if (!isLobbyChatActive
&& (!participant.chattingWithModerator
|| participant.chattingWithModerator === lobbyLocalId)
) {
return true;
}
if (isLobbyChatActive && lobbyMessageRecipient
&& participant.id !== lobbyMessageRecipient.id
&& (!participant.chattingWithModerator
|| participant.chattingWithModerator === lobbyLocalId)) {
return true;
}
return false;
};
}
/**
* Returns true if enabling lobby is allowed and false otherwise.
*
* @param {IReduxState} state - State object.
* @returns {boolean}
*/
export function isEnablingLobbyAllowed(state: IReduxState) {
return getVisitorsCount(state) <= 0;
}

View File

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

View File

@@ -0,0 +1,436 @@
import i18n from 'i18next';
import { batch } from 'react-redux';
import { AnyAction } from 'redux';
import { IStore } from '../app/types';
import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../base/app/actionTypes';
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
ENDPOINT_MESSAGE_RECEIVED
} from '../base/conference/actionTypes';
import { conferenceWillJoin } from '../base/conference/actions';
import {
JitsiConferenceErrors,
JitsiConferenceEvents
} from '../base/lib-jitsi-meet';
import {
getFirstLoadableAvatarUrl,
getParticipantDisplayName
} from '../base/participants/functions';
import MiddlewareRegistry from '../base/redux/MiddlewareRegistry';
import StateListenerRegistry from '../base/redux/StateListenerRegistry';
import {
playSound,
registerSound,
unregisterSound
} from '../base/sounds/actions';
import { isTestModeEnabled } from '../base/testing/functions';
import { BUTTON_TYPES } from '../base/ui/constants.any';
import { openChat } from '../chat/actions';
import {
handleLobbyChatInitialized,
removeLobbyChatParticipant
} from '../chat/actions.any';
import { arePollsDisabled } from '../conference/functions.any';
import { hideNotification, showNotification } from '../notifications/actions';
import {
LOBBY_NOTIFICATION_ID,
NOTIFICATION_ICON,
NOTIFICATION_TIMEOUT_TYPE,
NOTIFICATION_TYPE
} from '../notifications/constants';
import { INotificationProps } from '../notifications/types';
import { open as openParticipantsPane } from '../participants-pane/actions';
import { getParticipantsPaneOpen } from '../participants-pane/functions';
import { PREJOIN_JOINING_IN_PROGRESS } from '../prejoin/actionTypes';
import {
isPrejoinEnabledInConfig,
isPrejoinPageVisible,
shouldAutoKnock
} from '../prejoin/functions';
import {
KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED,
KNOCKING_PARTICIPANT_LEFT
} from './actionTypes';
import {
approveKnockingParticipant,
hideLobbyScreen,
knockingParticipantLeft,
openLobbyScreen,
participantIsKnockingOrUpdated,
rejectKnockingParticipant,
setLobbyMessageListener,
setLobbyModeEnabled,
setPasswordJoinFailed,
startKnocking
} from './actions';
import { updateLobbyParticipantOnLeave } from './actions.any';
import { KNOCKING_PARTICIPANT_SOUND_ID } from './constants';
import { getKnockingParticipants, showLobbyChatButton } from './functions';
import { KNOCKING_PARTICIPANT_FILE } from './sounds';
import { IKnockingParticipant } from './types';
MiddlewareRegistry.register(store => next => action => {
switch (action.type) {
case APP_WILL_MOUNT:
store.dispatch(registerSound(KNOCKING_PARTICIPANT_SOUND_ID, KNOCKING_PARTICIPANT_FILE));
break;
case APP_WILL_UNMOUNT:
store.dispatch(unregisterSound(KNOCKING_PARTICIPANT_SOUND_ID));
break;
case CONFERENCE_FAILED:
return _conferenceFailed(store, next, action);
case CONFERENCE_JOINED:
return _conferenceJoined(store, next, action);
case ENDPOINT_MESSAGE_RECEIVED: {
const { participant, data } = action;
_maybeSendLobbyNotification(participant, data, store);
break;
}
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);
_handleLobbyNotification(store);
return result;
}
case KNOCKING_PARTICIPANT_LEFT: {
// We need the full update result to be in the store already
const result = next(action);
_handleLobbyNotification(store);
return result;
}
case PREJOIN_JOINING_IN_PROGRESS: {
if (action.value) {
// let's hide the notification (the case with denied access and retrying) when prejoin is enabled
store.dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
}
break;
}
}
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, getState }, previousConference) => {
if (conference && !previousConference) {
conference.on(JitsiConferenceEvents.MEMBERS_ONLY_CHANGED, (enabled: boolean) => {
dispatch(setLobbyModeEnabled(enabled));
if (enabled) {
dispatch(setLobbyMessageListener());
}
});
conference.on(JitsiConferenceEvents.LOBBY_USER_JOINED, (id: string, name: string) => {
const { soundsParticipantKnocking } = getState()['features/base/settings'];
batch(() => {
dispatch(
participantIsKnockingOrUpdated({
id,
name
})
);
if (soundsParticipantKnocking) {
dispatch(playSound(KNOCKING_PARTICIPANT_SOUND_ID));
}
const isParticipantsPaneVisible = getParticipantsPaneOpen(getState());
if (typeof APP !== 'undefined') {
APP.API.notifyKnockingParticipant({
id,
name
});
}
if (isParticipantsPaneVisible || navigator.product === 'ReactNative') {
return;
}
_handleLobbyNotification({
dispatch,
getState
});
});
});
conference.on(JitsiConferenceEvents.LOBBY_USER_UPDATED, (id: string, participant: IKnockingParticipant) => {
dispatch(
participantIsKnockingOrUpdated({
...participant,
id
})
);
});
conference.on(JitsiConferenceEvents.LOBBY_USER_LEFT, (id: string) => {
batch(() => {
dispatch(knockingParticipantLeft(id));
dispatch(removeLobbyChatParticipant());
dispatch(updateLobbyParticipantOnLeave(id));
});
});
}
}
);
/**
* Function to handle the lobby notification.
*
* @param {Object} store - The Redux store.
* @returns {void}
*/
function _handleLobbyNotification(store: IStore) {
const { dispatch, getState } = store;
const knockingParticipants = getKnockingParticipants(getState());
if (knockingParticipants.length === 0) {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
return;
}
let notificationTitle;
let customActionNameKey;
let customActionHandler;
let customActionType;
let descriptionKey;
let icon;
if (knockingParticipants.length === 1) {
const firstParticipant = knockingParticipants[0];
const showChat = showLobbyChatButton(firstParticipant)(getState());
descriptionKey = 'notify.participantWantsToJoin';
notificationTitle = firstParticipant.name;
icon = NOTIFICATION_ICON.PARTICIPANT;
customActionNameKey = [ 'participantsPane.actions.admit', 'participantsPane.actions.reject' ];
customActionType = [ BUTTON_TYPES.PRIMARY, BUTTON_TYPES.DESTRUCTIVE ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(approveKnockingParticipant(firstParticipant.id));
}),
() => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(rejectKnockingParticipant(firstParticipant.id));
}) ];
// This checks if lobby chat button is available
// and, if so, it adds it to the customActionNameKey array
if (showChat) {
customActionNameKey.splice(1, 0, 'lobby.chat');
customActionType.splice(1, 0, BUTTON_TYPES.SECONDARY);
customActionHandler.splice(1, 0, () => batch(() => {
dispatch(handleLobbyChatInitialized(firstParticipant.id));
dispatch(openChat({}, arePollsDisabled(getState())));
}));
}
} else {
descriptionKey = 'notify.participantsWantToJoin';
notificationTitle = i18n.t('notify.waitingParticipants', {
waitingParticipants: knockingParticipants.length
});
icon = NOTIFICATION_ICON.PARTICIPANTS;
customActionNameKey = [ 'notify.viewLobby' ];
customActionType = [ BUTTON_TYPES.PRIMARY ];
customActionHandler = [ () => batch(() => {
dispatch(hideNotification(LOBBY_NOTIFICATION_ID));
dispatch(openParticipantsPane());
}) ];
}
dispatch(showNotification({
title: notificationTitle,
descriptionKey,
uid: LOBBY_NOTIFICATION_ID,
customActionNameKey,
customActionType,
customActionHandler,
icon
}, NOTIFICATION_TIMEOUT_TYPE.STICKY));
}
/**
* 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, getState }: IStore, next: Function, action: AnyAction) {
const { error } = action;
const state = getState();
const { lobbyError, membersOnly } = state['features/base/conference'];
const nonFirstFailure = Boolean(membersOnly);
if (error.name === JitsiConferenceErrors.MEMBERS_ONLY_ERROR) {
if (typeof error.recoverable === 'undefined') {
error.recoverable = true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [ _lobbyJid, lobbyWaitingForHost ] = error.params;
const result = next(action);
dispatch(openLobbyScreen());
// if there was an error about display name and pre-join is not enabled
if (shouldAutoKnock(state)
|| (lobbyError && !isPrejoinEnabledInConfig(state))
|| lobbyWaitingForHost) {
dispatch(startKnocking());
}
// In case of wrong password we need to be in the right state if in the meantime someone allows us to join
if (nonFirstFailure) {
dispatch(conferenceWillJoin(membersOnly));
}
dispatch(setPasswordJoinFailed(nonFirstFailure));
return result;
} else if (error.name === JitsiConferenceErrors.DISPLAY_NAME_REQUIRED) {
const [ isLobbyEnabled ] = error.params;
const result = next(action);
// if the error is due to required display name because lobby is enabled for the room
// if not showing the prejoin page then show lobby UI
if (isLobbyEnabled && !isPrejoinPageVisible(state)) {
dispatch(openLobbyScreen());
}
return result;
}
// if both are available pre-join is with priority (the case when pre-join is enabled)
// when pre-join is disabled, and we are in lobby with error, we want to end up in lobby UI
// instead of hiding it and showing conference UI. Still in lobby the user can retry
// after we show the error notification
if (isPrejoinPageVisible(state)) {
dispatch(hideLobbyScreen());
}
// we want to finish this action before showing the notification
// as the conference will be cleared which will clear all notifications, including this one
const result = next(action);
if (error.name === JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED) {
dispatch(
showNotification({
appearance: NOTIFICATION_TYPE.ERROR,
hideErrorSupportLink: true,
titleKey: 'lobby.joinRejectedTitle',
uid: LOBBY_NOTIFICATION_ID,
descriptionKey: 'lobby.joinRejectedMessage'
}, NOTIFICATION_TIMEOUT_TYPE.STICKY)
);
}
return result;
}
/**
* 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 }: IStore, next: Function, action: AnyAction) {
dispatch(hideLobbyScreen());
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(store: IStore, { id }: { id: string; }) {
const { dispatch, getState } = store;
const updatedParticipant = getState()['features/lobby'].knockingParticipants.find(p => p.id === id);
const { disableThirdPartyRequests } = getState()['features/base/config'];
if (!disableThirdPartyRequests && updatedParticipant && !updatedParticipant.loadableAvatarUrl) {
getFirstLoadableAvatarUrl(updatedParticipant, store).then((result: { isUsingCORS: boolean; src: string; }) => {
if (result) {
const { isUsingCORS, src } = result;
dispatch(
participantIsKnockingOrUpdated({
loadableAvatarUrl: src,
id,
isUsingCORS
})
);
}
});
}
}
/**
* Check the endpoint message that arrived through the conference and
* sends a lobby notification, if the message belongs to the feature.
*
* @param {Object} origin - The origin (initiator) of the message.
* @param {Object} message - The actual message.
* @param {Object} store - The Redux store.
* @returns {void}
*/
function _maybeSendLobbyNotification(origin: any, message: any, { dispatch, getState }: IStore) {
if (!origin?._id || message?.type !== 'lobby-notify') {
return;
}
const notificationProps: INotificationProps = {
descriptionArguments: {
originParticipantName: getParticipantDisplayName(getState, origin._id),
targetParticipantName: message.name
},
titleKey: 'lobby.notificationTitle'
};
switch (message.event) {
case 'LOBBY-ENABLED':
notificationProps.descriptionKey = `lobby.notificationLobby${message.value ? 'En' : 'Dis'}abled`;
break;
case 'LOBBY-ACCESS-GRANTED':
notificationProps.descriptionKey = 'lobby.notificationLobbyAccessGranted';
break;
case 'LOBBY-ACCESS-DENIED':
notificationProps.descriptionKey = 'lobby.notificationLobbyAccessDenied';
break;
}
dispatch(
showNotification(
notificationProps,
isTestModeEnabled(getState()) ? NOTIFICATION_TIMEOUT_TYPE.STICKY : NOTIFICATION_TIMEOUT_TYPE.MEDIUM
)
);
}

View File

@@ -0,0 +1,166 @@
import {
CONFERENCE_FAILED,
CONFERENCE_JOINED,
CONFERENCE_LEFT,
SET_PASSWORD
} from '../base/conference/actionTypes';
import { JitsiConferenceErrors } from '../base/lib-jitsi-meet';
import ReducerRegistry from '../base/redux/ReducerRegistry';
import {
KNOCKING_PARTICIPANT_ARRIVED_OR_UPDATED,
KNOCKING_PARTICIPANT_LEFT,
REMOVE_LOBBY_CHAT_WITH_MODERATOR,
SET_KNOCKING_STATE,
SET_LOBBY_MODE_ENABLED,
SET_LOBBY_PARTICIPANT_CHAT_STATE,
SET_LOBBY_VISIBILITY,
SET_PASSWORD_JOIN_FAILED
} from './actionTypes';
import { IKnockingParticipant } from './types';
const DEFAULT_STATE = {
isDisplayNameRequiredError: false,
knocking: false,
knockingParticipants: [],
lobbyEnabled: false,
lobbyVisible: false,
passwordJoinFailed: false
};
export interface ILobbyState {
/**
* A conference error when we tried to join into a room with no display name
* when lobby is enabled in the room.
*/
isDisplayNameRequiredError: boolean;
knocking: boolean;
knockingParticipants: IKnockingParticipant[];
lobbyEnabled: boolean;
lobbyVisible: boolean;
passwordJoinFailed: boolean;
}
/**
* 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<ILobbyState>('features/lobby', (state = DEFAULT_STATE, action): ILobbyState => {
switch (action.type) {
case CONFERENCE_FAILED: {
if (action.error.name === JitsiConferenceErrors.DISPLAY_NAME_REQUIRED) {
return {
...state,
isDisplayNameRequiredError: true
};
} else if (action.error.name === JitsiConferenceErrors.CONFERENCE_ACCESS_DENIED) {
return {
...state,
knocking: false
};
}
return state;
}
case CONFERENCE_JOINED:
case CONFERENCE_LEFT:
return {
...state,
isDisplayNameRequiredError: false,
knocking: false,
passwordJoinFailed: 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,
passwordJoinFailed: false
};
case SET_LOBBY_MODE_ENABLED:
return {
...state,
lobbyEnabled: action.enabled
};
case SET_LOBBY_VISIBILITY:
return {
...state,
lobbyVisible: action.visible
};
case SET_PASSWORD:
return {
...state,
passwordJoinFailed: false
};
case SET_PASSWORD_JOIN_FAILED:
return {
...state,
passwordJoinFailed: action.failed
};
case SET_LOBBY_PARTICIPANT_CHAT_STATE:
return {
...state,
knockingParticipants: state.knockingParticipants.map(participant => {
if (participant.id === action.participant.id) {
return {
...participant,
chattingWithModerator: action.moderator.id
};
}
return participant;
})
};
case REMOVE_LOBBY_CHAT_WITH_MODERATOR:
return {
...state,
knockingParticipants: state.knockingParticipants.map(participant => {
if (participant.chattingWithModerator === action.moderatorId) {
return {
...participant,
chattingWithModerator: undefined
};
}
return participant;
})
};
}
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: IKnockingParticipant, state: ILobbyState) {
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

@@ -0,0 +1,5 @@
/**
* The name of the bundled sound file which will be played when a
* participant enters lobby.
*/
export const KNOCKING_PARTICIPANT_FILE = 'knock.mp3';

View File

@@ -0,0 +1,5 @@
import { IParticipant } from '../base/participants/types';
export interface IKnockingParticipant extends IParticipant {
chattingWithModerator?: string;
}