Compare commits

...

6 Commits

Author SHA1 Message Date
Horatiu Muresan
5389342793 fix(aot) remove dependency to store (#13910) 2023-10-05 13:08:30 +03:00
Horatiu Muresan
1cd20a3e1b fix(aot) fix icons creating deps to store (#13901) 2023-10-03 17:37:27 +03:00
Andrei Gavrilescu
e7577a0e81 chore(deps): Update LJM, allow watchRTC and rtcstats to coexist 2023-10-03 15:41:55 +03:00
Mihaela Dumitru
33f79f86f7 fix(prejoin) improve display name handling relative to configs (#13865) (#13899) 2023-10-03 14:31:11 +03:00
damencho
863b2acca1 fix: Fixes passing the hash params in state for token auth URL. 2023-09-29 12:17:57 -05:00
Дамян Минков
d31f7421db feat: Check jwt expiration and redirects to auth url if any. (#13879)
* feat: Check jwt expiration and redirects to auth url if any.

It may happen that the jwt had expired while being in the meeting and there is a network issue requiring to reload.

* squash: Fixes lint error.

* squash: Fixes comments.
2023-09-29 12:17:46 -05:00
31 changed files with 320 additions and 74 deletions

11
package-lock.json generated
View File

@@ -59,7 +59,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1691.0.0+255d8f49/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://git@github.com/jitsi/lib-jitsi-meet#3a7f8d8f7a24f077ca2cd454ead31192434965e3",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",
@@ -12855,8 +12855,8 @@
},
"node_modules/lib-jitsi-meet": {
"version": "0.0.0",
"resolved": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1691.0.0+255d8f49/lib-jitsi-meet.tgz",
"integrity": "sha512-olw/14fGI4BpQtM9p87VE49GSTa6838Lw6xbkx7YnxDv9WJURh/DfTHqvKr68AfHfcIFFgZTWAbibAsD/LXsIg==",
"resolved": "git+https://git@github.com/jitsi/lib-jitsi-meet.git#3a7f8d8f7a24f077ca2cd454ead31192434965e3",
"integrity": "sha512-Q2VwM0Pr2LPVtzLoTYHECknG+cFVG7NS+c3vus22TMuhD3kSHPpWkUVqAEBcVX21rVx8/dnHBQkLGy4JqfgEcQ==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
@@ -29902,8 +29902,9 @@
}
},
"lib-jitsi-meet": {
"version": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1691.0.0+255d8f49/lib-jitsi-meet.tgz",
"integrity": "sha512-olw/14fGI4BpQtM9p87VE49GSTa6838Lw6xbkx7YnxDv9WJURh/DfTHqvKr68AfHfcIFFgZTWAbibAsD/LXsIg==",
"version": "git+https://git@github.com/jitsi/lib-jitsi-meet.git#3a7f8d8f7a24f077ca2cd454ead31192434965e3",
"integrity": "sha512-Q2VwM0Pr2LPVtzLoTYHECknG+cFVG7NS+c3vus22TMuhD3kSHPpWkUVqAEBcVX21rVx8/dnHBQkLGy4JqfgEcQ==",
"from": "lib-jitsi-meet@https://git@github.com/jitsi/lib-jitsi-meet#3a7f8d8f7a24f077ca2cd454ead31192434965e3",
"requires": {
"@jitsi/js-utils": "2.1.3",
"@jitsi/logger": "2.0.2",

View File

@@ -65,7 +65,7 @@
"js-md5": "0.6.1",
"js-sha512": "0.8.0",
"jwt-decode": "2.2.0",
"lib-jitsi-meet": "https://github.com/jitsi/lib-jitsi-meet/releases/download/v1691.0.0+255d8f49/lib-jitsi-meet.tgz",
"lib-jitsi-meet": "https://git@github.com/jitsi/lib-jitsi-meet#3a7f8d8f7a24f077ca2cd454ead31192434965e3",
"lodash": "4.17.21",
"moment": "2.29.4",
"moment-duration-format": "2.2.2",

View File

@@ -4,6 +4,7 @@ import React, { Component } from 'react';
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import StatelessAvatar from '../base/avatar/components/web/StatelessAvatar';
import { getAvatarColor, getInitials } from '../base/avatar/functions';
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import Toolbar from './Toolbar';
@@ -184,6 +185,7 @@ export default class AlwaysOnTop extends Component<any, IState> {
<div id = 'avatarContainer'>
<StatelessAvatar
color = { getAvatarColor(displayName, customAvatarBackgrounds) }
iconUser = { DEFAULT_ICON.IconUser }
id = 'avatar'
initials = { getInitials(displayName) }
url = { avatarURL } />)

View File

@@ -2,7 +2,8 @@ import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { IconMic, IconMicSlash } from '../base/icons/svg';
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
@@ -31,8 +32,8 @@ type Props = Partial<IProps>;
* Stateless "mute/unmute audio" button for the Always-on-Top windows.
*/
export default class AudioMuteButton extends Component<Props, IState> {
icon = IconMic;
toggledIcon = IconMicSlash;
icon = DEFAULT_ICON.IconMic;
toggledIcon = DEFAULT_ICON.IconMicSlash;
accessibilityLabel = 'Audio mute';
/**

View File

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { IconHangup } from '../base/icons/svg';
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
@@ -17,7 +17,7 @@ type Props = Partial<IProps>;
export default class HangupButton extends Component<Props> {
accessibilityLabel = 'Hangup';
icon = IconHangup;
icon = DEFAULT_ICON.IconHangup;
/**
* Initializes a new {@code HangupButton} instance.

View File

@@ -2,7 +2,7 @@ import React, { Component } from 'react';
// We need to reference these files directly to avoid loading things that are not available
// in this environment (e.g. JitsiMeetJS or interfaceConfig)
import { IconVideo, IconVideoOff } from '../base/icons/svg';
import { DEFAULT_ICON } from '../base/icons/svg/constants';
import { IProps } from '../base/toolbox/components/AbstractButton';
import ToolbarButton from './ToolbarButton';
@@ -32,8 +32,8 @@ type State = {
*/
export default class VideoMuteButton extends Component<Props, State> {
icon = IconVideo;
toggledIcon = IconVideoOff;
icon = DEFAULT_ICON.IconVideo;
toggledIcon = DEFAULT_ICON.IconVideoOff;
accessibilityLabel = 'Video mute';
/**

View File

@@ -1,6 +1,14 @@
import { getLocationContextRoot } from '../base/util/uri';
// @ts-ignore
// eslint-disable-next-line
import { openTokenAuthUrl } from '../authentication/actions';
// @ts-ignore
import { getTokenAuthUrl, isTokenAuthEnabled } from '../authentication/functions';
import { getJwtExpirationDate } from '../base/jwt/functions';
import { getLocationContextRoot, parseURIString } from '../base/util/uri';
import { addTrackStateToURL } from './functions.any';
import logger from './logger';
import { IStore } from './types';
/**
@@ -83,4 +91,53 @@ export function reloadWithStoredParams() {
};
}
/**
* Checks whether tokenAuthUrl is set, we have a jwt token that will expire soon
* and redirect to the auth url to obtain new token if this is the case.
*
* @param {Dispatch} dispatch - The Redux dispatch function.
* @param {Function} getState - The Redux state.
* @param {Function} failureCallback - The callback on failure to obtain auth url.
* @returns {boolean} Whether we will redirect or not.
*/
export function maybeRedirectToTokenAuthUrl(
dispatch: IStore['dispatch'], getState: IStore['getState'], failureCallback: Function) {
const state = getState();
const config = state['features/base/config'];
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
if (!isTokenAuthEnabled(config)) {
return false;
}
// if tokenAuthUrl check jwt if is about to expire go through the url to get new token
const jwt = state['features/base/jwt'].jwt;
const expirationDate = getJwtExpirationDate(jwt);
// if there is jwt and its expiration time is less than 3 minutes away
// let's obtain new token
if (expirationDate && expirationDate.getTime() - Date.now() < 3 * 60 * 1000) {
const room = state['features/base/conference'].room;
const { tenant } = parseURIString(locationURL.href) || {};
getTokenAuthUrl(config, room, tenant, true, locationURL)
.then((tokenAuthServiceUrl: string | undefined) => {
if (!tokenAuthServiceUrl) {
logger.warn('Cannot handle login, token service URL is not set');
return Promise.reject();
}
return dispatch(openTokenAuthUrl(tokenAuthServiceUrl));
})
.catch(() => {
failureCallback();
});
return true;
}
return false;
}

View File

@@ -31,11 +31,11 @@ import { screen } from '../mobile/navigation/routes';
import { clearNotifications } from '../notifications/actions';
import { isUnsafeRoomWarningEnabled } from '../prejoin/functions';
import { maybeRedirectToTokenAuthUrl } from './actions.any';
import { addTrackStateToURL, getDefaultURL } from './functions.native';
import logger from './logger';
import { IReloadNowOptions, IStore } from './types';
export * from './actions.any';
/**
@@ -205,10 +205,18 @@ export function reloadNow() {
// @ts-ignore
const newURL = addTrackStateToURL(locationURL, state);
logger.info(`Reloading the conference using URL: ${locationURL}`);
const reloadAction = () => {
logger.info(`Reloading the conference using URL: ${locationURL}`);
dispatch(appNavigate(toURLString(newURL), {
hidePrejoin: true
}));
dispatch(appNavigate(toURLString(newURL), {
hidePrejoin: true
}));
};
if (maybeRedirectToTokenAuthUrl(dispatch, getState, reloadAction)) {
return;
}
reloadAction();
};
}

View File

@@ -15,6 +15,7 @@ import { NOTIFICATION_TIMEOUT_TYPE } from '../notifications/constants';
import { isWelcomePageEnabled } from '../welcome/functions';
import {
maybeRedirectToTokenAuthUrl,
redirectToStaticPage,
redirectWithStoredParams,
reloadWithStoredParams
@@ -170,8 +171,16 @@ export function reloadNow() {
const state = getState();
const { locationURL } = state['features/base/connection'];
logger.info(`Reloading the conference using URL: ${locationURL}`);
const reloadAction = () => {
logger.info(`Reloading the conference using URL: ${locationURL}`);
dispatch(reloadWithStoredParams());
dispatch(reloadWithStoredParams());
};
if (maybeRedirectToTokenAuthUrl(dispatch, getState, reloadAction)) {
return;
}
reloadAction();
};
}

View File

@@ -53,10 +53,10 @@ function _getWebConferenceRoute(state: IReduxState) {
if (!browser.isElectron() && config.tokenAuthUrl && config.tokenAuthUrlAutoRedirect
&& state['features/authentication'].tokenAuthUrlSuccessful
&& !state['features/base/jwt'].jwt && room) {
const { locationURL = { href: '' } } = state['features/base/connection'];
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
return getTokenAuthUrl(config, room, tenant)
return getTokenAuthUrl(config, room, tenant, false, locationURL)
.then((url: string | undefined) => {
route.href = url;

View File

@@ -16,12 +16,14 @@ export const isTokenAuthEnabled = (config: IConfig): boolean =>
* @param {string?} roomName - The room name.
* @param {string?} tenant - The tenant name if any.
* @param {boolean} skipPrejoin - Whether to skip pre-join page.
* @param {URL} locationURL - The location URL.
* @returns {Object} The state object.
*/
export const _getTokenAuthState = (
roomName: string | undefined,
tenant: string | undefined,
skipPrejoin: boolean | undefined = false): object => {
skipPrejoin: boolean | undefined = false,
locationURL: URL): object => {
const state = {
room: roomName,
roomSafe: getBackendSafeRoomName(roomName),
@@ -34,7 +36,7 @@ export const _getTokenAuthState = (
state['config.prejoinConfig.enabled'] = false;
}
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(locationURL.hash);
for (const [ key, value ] of params) {
// we allow only config and interfaceConfig overrides in the state

View File

@@ -17,6 +17,7 @@ export * from './functions.any';
* @param {string} roomName - The name of the conference room for which the user will be authenticated.
* @param {string} tenant - The name of the conference tenant.
* @param {string} skipPrejoin - The name of the conference room for which the user will be authenticated.
* @param {URL} locationURL - The location URL.
*
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
@@ -26,7 +27,9 @@ export const getTokenAuthUrl = (
config: IConfig,
roomName: string | undefined,
tenant: string | undefined,
skipPrejoin: boolean | undefined = false): Promise<string | undefined> => {
skipPrejoin: boolean | undefined = false,
// eslint-disable-next-line max-params
locationURL: URL): Promise<string | undefined> => {
let url = config.tokenAuthUrl;
@@ -35,7 +38,7 @@ export const getTokenAuthUrl = (
}
if (url.indexOf('{state}')) {
const state = _getTokenAuthState(roomName, tenant, skipPrejoin);
const state = _getTokenAuthState(roomName, tenant, skipPrejoin, locationURL);
// Append ios=true or android=true to the token URL.
// @ts-ignore

View File

@@ -35,6 +35,7 @@ function _cryptoRandom() {
* @param {string} roomName - The name of the conference room for which the user will be authenticated.
* @param {string} tenant - The name of the conference tenant.
* @param {string} skipPrejoin - The name of the conference room for which the user will be authenticated.
* @param {URL} locationURL - The current location URL.
*
* @returns {Promise<string|undefined>} - The URL pointing to JWT login service or
* <tt>undefined</tt> if the pattern stored in config is not a string and the URL can not be
@@ -44,7 +45,9 @@ export const getTokenAuthUrl = (
config: IConfig,
roomName: string | undefined,
tenant: string | undefined,
skipPrejoin: boolean | undefined = false): Promise<string | undefined> => {
skipPrejoin: boolean | undefined = false,
// eslint-disable-next-line max-params
locationURL: URL): Promise<string | undefined> => {
let url = config.tokenAuthUrl;
@@ -53,7 +56,7 @@ export const getTokenAuthUrl = (
}
if (url.indexOf('{state}')) {
const state = _getTokenAuthState(roomName, tenant, skipPrejoin);
const state = _getTokenAuthState(roomName, tenant, skipPrejoin, locationURL);
if (browser.isElectron()) {
// @ts-ignore

View File

@@ -255,7 +255,7 @@ function _handleLogin({ dispatch, getState }: IStore) {
const state = getState();
const config = state['features/base/config'];
const room = state['features/base/conference'].room;
const { locationURL = { href: '' } } = state['features/base/connection'];
const { locationURL = { href: '' } as URL } = state['features/base/connection'];
const { tenant } = parseURIString(locationURL.href) || {};
if (!room) {
@@ -270,7 +270,7 @@ function _handleLogin({ dispatch, getState }: IStore) {
return;
}
getTokenAuthUrl(config, room, tenant, true)
getTokenAuthUrl(config, room, tenant, true, locationURL)
.then((tokenAuthServiceUrl: string | undefined) => {
if (!tokenAuthServiceUrl) {
logger.warn('Cannot handle login, token service URL is not set');

View File

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { IconUser } from '../../icons/svg';
import { getParticipantById } from '../../participants/functions';
import { IParticipant } from '../../participants/types';
import { getAvatarColor, getInitials, isCORSAvatarURL } from '../functions';
@@ -182,6 +183,7 @@ class Avatar<P extends IProps> extends PureComponent<P, IState> {
const avatarProps: AbstractProps & {
className?: string;
iconUser?: any;
id?: string;
status?: string;
testId?: string;
@@ -226,6 +228,10 @@ class Avatar<P extends IProps> extends PureComponent<P, IState> {
avatarProps.initials = initials;
}
if (navigator.product !== 'ReactNative') {
avatarProps.iconUser = IconUser;
}
return (
<StatelessAvatar
{ ...avatarProps } />

View File

@@ -2,7 +2,6 @@ import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
import { IconUser } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { isIcon } from '../../functions';
import { IAvatarProps } from '../../types';
@@ -122,6 +121,7 @@ const useStyles = makeStyles()(theme => {
const StatelessAvatar = ({
className,
color,
iconUser,
id,
initials,
onAvatarLoadError,
@@ -212,7 +212,7 @@ const StatelessAvatar = ({
style = { _getAvatarStyle() }>
<Icon
size = { '50%' }
src = { IconUser } />
src = { iconUser } />
</div>
);
};

View File

@@ -5,6 +5,11 @@ export interface IAvatarProps {
*/
color?: string;
/**
* The user icon(browser only).
*/
iconUser?: any;
/**
* Initials to be used to render the initials based avatars.
*/

View File

@@ -16,6 +16,7 @@ import { removeLobbyChatParticipant } from '../../chat/actions.any';
import { openDisplayNamePrompt } from '../../display-name/actions';
import { showErrorNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { hasDisplayName } from '../../prejoin/utils';
import { stopLocalVideoRecording } from '../../recording/actions.any';
import LocalRecordingManager from '../../recording/components/Recording/LocalRecordingManager';
import { iAmVisitor } from '../../visitors/functions';
@@ -276,7 +277,9 @@ function _conferenceJoined({ dispatch, getState }: IStore, next: Function, actio
if (requireDisplayName
&& !getLocalParticipant(getState)?.name
&& !conference.isHidden()) {
dispatch(openDisplayNamePrompt(undefined));
dispatch(openDisplayNamePrompt({
validateInput: hasDisplayName
}));
}
return result;

View File

@@ -20,6 +20,11 @@ interface IProps extends AbstractProps, WithTranslation {
*/
descriptionKey?: string;
/**
* Whether to display the cancel button.
*/
disableCancel?: boolean;
/**
* An optional initial value to initiate the field with.
*/
@@ -52,6 +57,11 @@ interface IState extends AbstractState {
* The current value of the field.
*/
fieldValue?: string;
/**
* The result of the input validation.
*/
isValid: boolean;
}
/**
@@ -68,6 +78,7 @@ class InputDialog extends AbstractDialog<IProps, IState> {
this.state = {
fieldValue: props.initialValue,
isValid: props.validateInput ? props.validateInput(props.initialValue) : true,
submitting: false
};
@@ -115,10 +126,11 @@ class InputDialog extends AbstractDialog<IProps, IState> {
</Dialog.Description>
)
}
<Dialog.Button
{!this.props.disableCancel && <Dialog.Button
label = { t('dialog.Cancel') }
onPress = { this._onCancel } />
onPress = { this._onCancel } />}
<Dialog.Button
disabled = { !this.state.isValid }
label = { t('dialog.Ok') }
onPress = { this._onSubmitValue } />
</Dialog.Container>
@@ -132,10 +144,14 @@ class InputDialog extends AbstractDialog<IProps, IState> {
* @returns {void}
*/
_onChangeText(fieldValue: string) {
if (this.props.validateInput && !this.props.validateInput(fieldValue)) {
if (this.props.validateInput) {
this.setState({
isValid: this.props.validateInput(fieldValue),
fieldValue
});
return;
}
this.setState({
fieldValue
});

View File

@@ -186,3 +186,23 @@ export function validateJwt(jwt: string) {
return errors;
}
/**
* Extracts and returns the expiration date of jwt.
*
* @param {string|undefined} jwt - The jwt to check.
* @returns {Date} The expiration date of the jwt.
*/
export function getJwtExpirationDate(jwt: string | undefined) {
if (!jwt) {
return;
}
const payload = jwtDecode(jwt);
if (payload) {
const { exp } = payload;
return new Date(exp * 1000);
}
}

View File

@@ -136,6 +136,7 @@ export interface IProps {
description?: string;
disableBackdropClose?: boolean;
disableEnter?: boolean;
disableEscape?: boolean;
onClose?: () => void;
size?: 'large' | 'medium';
submit?: () => void;
@@ -150,6 +151,7 @@ const BaseDialog = ({
description,
disableBackdropClose,
disableEnter,
disableEscape,
onClose,
size = 'medium',
submit,
@@ -166,7 +168,7 @@ const BaseDialog = ({
}, [ disableBackdropClose, onClose ]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (e.key === 'Escape' && !disableEscape) {
onClose?.();
}
if (e.key === 'Enter' && !disableEnter) {

View File

@@ -92,6 +92,7 @@ const Dialog = ({
disableBackdropClose,
hideCloseButton,
disableEnter,
disableEscape,
ok = { translationKey: 'dialog.Ok' },
onCancel,
onSubmit,
@@ -125,6 +126,7 @@ const Dialog = ({
description = { description }
disableBackdropClose = { disableBackdropClose }
disableEnter = { disableEnter }
disableEscape = { disableEscape }
onClose = { onClose }
size = { size }
submit = { submit }
@@ -170,7 +172,7 @@ const Dialog = ({
id = 'modal-dialog-ok-button'
isSubmit = { true }
labelKey = { ok.translationKey }
onClick = { submit } />}
{ ...(!ok.disabled && { onClick: submit }) } />}
</div>
</BaseDialog>
);

View File

@@ -5,12 +5,17 @@ import { DisplayNamePrompt } from './components';
/**
* Signals to open a dialog with the {@code DisplayNamePrompt} component.
*
* @param {?Function} onPostSubmit - The function to invoke after a successful
* submit of the dialog.
* @param {Object} params - Map containing the callbacks to be executed in the prompt:
* - onPostSubmit - The function to invoke after a successful submit of the dialog.
* - validateInput - The function to invoke after a change in the display name value.
* @returns {Object}
*/
export function openDisplayNamePrompt(onPostSubmit?: Function) {
export function openDisplayNamePrompt({ onPostSubmit, validateInput }: {
onPostSubmit?: Function;
validateInput?: Function;
}) {
return openDialog(DisplayNamePrompt, {
onPostSubmit
onPostSubmit,
validateInput
});
}

View File

@@ -33,8 +33,10 @@ class DisplayNamePrompt extends Component<IProps> {
return (
<InputDialog
descriptionKey = 'dialog.enterDisplayName'
disableCancel = { true }
onSubmit = { this._onSetDisplayName }
titleKey = 'dialog.displayNameRequired' />
titleKey = 'dialog.displayNameRequired'
validateInput = { this.props.validateInput } />
);
}
}

View File

@@ -7,6 +7,8 @@ import Input from '../../../base/ui/components/web/Input';
import { onSetDisplayName } from '../../functions';
import { IProps } from '../../types';
const INITIAL_DISPLAY_NAME = '';
/**
* The type of the React {@code Component} props of {@link DisplayNamePrompt}.
*/
@@ -16,6 +18,11 @@ interface IState {
* The name to show in the display name text field.
*/
displayName: string;
/**
* The result of the input validation.
*/
isValid: boolean;
}
/**
@@ -37,7 +44,8 @@ class DisplayNamePrompt extends Component<IProps, IState> {
super(props);
this.state = {
displayName: ''
displayName: INITIAL_DISPLAY_NAME,
isValid: this.props.validateInput ? this.props.validateInput(INITIAL_DISPLAY_NAME) : true
};
// Bind event handlers so they are only bound once for every instance.
@@ -53,10 +61,19 @@ class DisplayNamePrompt extends Component<IProps, IState> {
* @returns {ReactElement}
*/
render() {
const disableCloseDialog = Boolean(this.props.validateInput);
return (
<Dialog
cancel = {{ translationKey: 'dialog.Cancel' }}
ok = {{ translationKey: 'dialog.Ok' }}
cancel = {{ hidden: true }}
disableBackdropClose = { disableCloseDialog }
disableEnter = { !this.state.isValid }
disableEscape = { disableCloseDialog }
hideCloseButton = { disableCloseDialog }
ok = {{
disabled: !this.state.isValid,
translationKey: 'dialog.Ok'
}}
onSubmit = { this._onSubmit }
titleKey = 'dialog.displayNameRequired'>
<Input
@@ -80,6 +97,14 @@ class DisplayNamePrompt extends Component<IProps, IState> {
* @returns {void}
*/
_onDisplayNameChange(value: string) {
if (this.props.validateInput) {
this.setState({
isValid: this.props.validateInput(value),
displayName: value
});
return;
}
this.setState({
displayName: value
});

View File

@@ -17,4 +17,9 @@ export interface IProps extends WithTranslation {
* Function to be invoked after a successful display name change.
*/
onPostSubmit?: Function;
/**
* Function to be invoked after a display name change.
*/
validateInput?: Function;
}

View File

@@ -1,5 +1,5 @@
import { useIsFocused } from '@react-navigation/native';
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
BackHandler,
@@ -16,6 +16,7 @@ import { appNavigate } from '../../../app/actions.native';
import { IReduxState } from '../../../app/types';
import { setAudioOnly } from '../../../base/audio-only/actions';
import { getConferenceName } from '../../../base/conference/functions';
import { isNameReadOnly } from '../../../base/config/functions.any';
import { connect } from '../../../base/connection/actions.native';
import { PREJOIN_PAGE_HIDE_DISPLAY_NAME } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
@@ -28,6 +29,7 @@ import { updateSettings } from '../../../base/settings/actions';
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 { openDisplayNamePrompt } from '../../../display-name/actions';
import BrandingImageBackground from '../../../dynamic-branding/components/native/BrandingImageBackground';
import LargeVideo from '../../../large-video/components/LargeVideo.native';
import HeaderNavigationButton from '../../../mobile/navigation/components/HeaderNavigationButton';
@@ -37,6 +39,7 @@ import AudioMuteButton from '../../../toolbox/components/native/AudioMuteButton'
import VideoMuteButton from '../../../toolbox/components/native/VideoMuteButton';
import { isDisplayNameRequired } from '../../functions';
import { IPrejoinProps } from '../../types';
import { hasDisplayName } from '../../utils';
import { preJoinStyles as styles } from './styles';
@@ -52,10 +55,19 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
const isDisplayNameMandatory = useSelector((state: IReduxState) => isDisplayNameRequired(state));
const isDisplayNameVisible
= useSelector((state: IReduxState) => !getFeatureFlag(state, PREJOIN_PAGE_HIDE_DISPLAY_NAME, false));
const isDisplayNameReadonly = !useSelector(isNameReadOnly);
const roomName = useSelector((state: IReduxState) => getConferenceName(state));
const participantName = localParticipant?.name;
const [ displayName, setDisplayName ]
= useState(participantName || '');
const isDisplayNameMissing = useMemo(
() => !displayName && isDisplayNameMandatory, [ displayName, isDisplayNameMandatory ]);
const showDisplayNameError = useMemo(
() => !isDisplayNameReadonly && isDisplayNameMissing && isDisplayNameVisible,
[ isDisplayNameMissing, isDisplayNameReadonly, isDisplayNameVisible ]);
const showDisplayNameInput = useMemo(
() => isDisplayNameVisible && (displayName || !isDisplayNameReadonly),
[ displayName, isDisplayNameReadonly, isDisplayNameVisible ]);
const [ isJoining, setIsJoining ]
= useState(false);
const onChangeDisplayName = useCallback(event => {
@@ -73,9 +85,20 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
navigateRoot(screen.conference.root);
}, [ dispatch ]);
const maybeJoin = useCallback(() => {
if (isDisplayNameMissing) {
dispatch(openDisplayNamePrompt({
onPostSubmit: onJoin,
validateInput: hasDisplayName
}));
} else {
onJoin();
}
}, [ dispatch, hasDisplayName, isDisplayNameMissing, onJoin ]);
const onJoinLowBandwidth = useCallback(() => {
dispatch(setAudioOnly(true));
onJoin();
maybeJoin();
}, [ dispatch ]);
const goBack = useCallback(() => {
@@ -85,7 +108,6 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
}, [ dispatch ]);
const { PRIMARY, TERTIARY } = BUTTON_TYPES;
const joinButtonDisabled = !displayName && isDisplayNameMandatory;
useEffect(() => {
BackHandler.addEventListener('hardwareBackPress', goBack);
@@ -158,25 +180,30 @@ const Prejoin: React.FC<IPrejoinProps> = ({ navigation }: IPrejoinProps) => {
styles = { styles.buttonStylesBorderless } />
</View>
{
isDisplayNameVisible
&& <Input
showDisplayNameInput && <Input
customStyles = {{ input: styles.customInput }}
disabled = { isDisplayNameReadonly }
error = { showDisplayNameError }
onChange = { onChangeDisplayName }
placeholder = { t('dialog.enterDisplayName') }
value = { displayName } />
}
{showDisplayNameError && (
<View style = { styles.errorContainer as StyleProp<TextStyle> }>
<Text style = { styles.error as StyleProp<TextStyle> }>{t('prejoin.errorMissingName')}</Text>
</View>)}
<Button
accessibilityLabel = 'prejoin.joinMeeting'
disabled = { joinButtonDisabled }
disabled = { showDisplayNameError }
labelKey = 'prejoin.joinMeeting'
onClick = { isJoining ? undefined : onJoin }
onClick = { isJoining ? undefined : maybeJoin }
style = { styles.joinButton }
type = { PRIMARY } />
<Button
accessibilityLabel = 'prejoin.joinMeetingInLowBandwidthMode'
disabled = { joinButtonDisabled }
disabled = { showDisplayNameError }
labelKey = 'prejoin.joinMeetingInLowBandwidthMode'
onClick = { onJoinLowBandwidth }
onClick = { isJoining ? undefined : onJoinLowBandwidth }
style = { styles.joinButton }
type = { TERTIARY } />
</View>

View File

@@ -83,6 +83,23 @@ export const preJoinStyles = {
width: 352
},
errorContainer: {
backgroundColor: BaseTheme.palette.actionDanger,
borderBottomRightRadius: BaseTheme.shape.borderRadius,
borderBottomLeftRadius: BaseTheme.shape.borderRadius,
boxSizing: 'border-box',
marginTop: -BaseTheme.spacing[2],
overflow: 'visible',
wordBreak: 'normal',
width: 352
},
error: {
padding: BaseTheme.spacing[1],
color: BaseTheme.palette.text01,
textAlign: 'center'
},
preJoinRoomName: {
...BaseTheme.typography.heading5,
color: BaseTheme.palette.text01,

View File

@@ -1,7 +1,7 @@
/* eslint-disable react/jsx-no-bind */
import React, { useRef, useState } from 'react';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
@@ -21,6 +21,7 @@ import Button from '../../../base/ui/components/web/Button';
import Input from '../../../base/ui/components/web/Input';
import { BUTTON_TYPES } from '../../../base/ui/constants.any';
import isInsecureRoomName from '../../../base/util/isInsecureRoomName';
import { openDisplayNamePrompt } from '../../../display-name/actions';
import { isUnsafeRoomWarningEnabled } from '../../../prejoin/functions';
import {
joinConference as joinConferenceAction,
@@ -34,16 +35,12 @@ import {
isJoinByPhoneDialogVisible,
isPrejoinDisplayNameVisible
} from '../../functions';
import { hasDisplayName } from '../../utils';
import JoinByPhoneDialog from './dialogs/JoinByPhoneDialog';
interface IProps {
/**
* Indicates whether the display name is editable.
*/
canEditDisplayName: boolean;
/**
* Flag signaling if the device status is visible or not.
*/
@@ -54,6 +51,11 @@ interface IProps {
*/
hasJoinByPhoneButton: boolean;
/**
* Flag signaling if the display name is visible or not.
*/
isDisplayNameVisible: boolean;
/**
* Joins the current meeting.
*/
@@ -193,9 +195,9 @@ const useStyles = makeStyles()(theme => {
});
const Prejoin = ({
canEditDisplayName,
deviceStatusVisible,
hasJoinByPhoneButton,
isDisplayNameVisible,
joinConference,
joinConferenceWithoutAudio,
joiningInProgress,
@@ -212,10 +214,16 @@ const Prejoin = ({
updateSettings: dispatchUpdateSettings,
videoTrack
}: IProps) => {
const showDisplayNameField = useRef(canEditDisplayName || showErrorOnJoin);
const showDisplayNameField = useMemo(
() => isDisplayNameVisible && !readOnlyName,
[ isDisplayNameVisible, readOnlyName ]);
const showErrorOnField = useMemo(
() => showDisplayNameField && showErrorOnJoin,
[ showDisplayNameField, showErrorOnJoin ]);
const [ showJoinByPhoneButtons, setShowJoinByPhoneButtons ] = useState(false);
const { classes } = useStyles();
const { t } = useTranslation();
const dispatch = useDispatch();
/**
* Handler for the join button.
@@ -225,6 +233,11 @@ const Prejoin = ({
*/
const onJoinButtonClick = () => {
if (showErrorOnJoin) {
dispatch(openDisplayNamePrompt({
onPostSubmit: joinConference,
validateInput: hasDisplayName
}));
return;
}
joinConference();
@@ -374,12 +387,12 @@ const Prejoin = ({
<div
className = { classes.inputContainer }
data-testid = 'prejoin.screen'>
{showDisplayNameField.current ? (<Input
{showDisplayNameField ? (<Input
accessibilityLabel = { t('dialog.enterDisplayName') }
autoComplete = { 'name' }
autoFocus = { true }
className = { classes.input }
error = { showErrorOnJoin }
error = { showErrorOnField }
id = 'premeeting-name-input'
onChange = { setName }
onKeyPress = { showUnsafeRoomWarning && !unsafeRoomConsent ? undefined : onInputKeyPress }
@@ -393,11 +406,11 @@ const Prejoin = ({
displayName = { name }
participantId = { participantId }
size = { 72 } />
<div className = { classes.avatarName }>{name}</div>
{isDisplayNameVisible && <div className = { classes.avatarName }>{name}</div>}
</div>
)}
{showErrorOnJoin && <div
{showErrorOnField && <div
className = { classes.error }
data-testid = 'prejoin.errorMessage'>{t('prejoin.errorMissingName')}</div>}
@@ -406,7 +419,7 @@ const Prejoin = ({
content = { hasExtraJoinButtons && <div className = { classes.dropdownButtons }>
{extraButtonsToRender.map(({ key, ...rest }) => (
<Button
disabled = { joiningInProgress }
disabled = { joiningInProgress || showErrorOnField }
fullWidth = { true }
key = { key }
type = { BUTTON_TYPES.SECONDARY }
@@ -422,7 +435,9 @@ const Prejoin = ({
ariaDropDownLabel = { t('prejoin.joinWithoutAudio') }
ariaLabel = { t('prejoin.joinMeeting') }
ariaPressed = { showJoinByPhoneButtons }
disabled = { joiningInProgress || (showUnsafeRoomWarning && !unsafeRoomConsent) }
disabled = { joiningInProgress
|| (showUnsafeRoomWarning && !unsafeRoomConsent)
|| showErrorOnField }
hasOptions = { hasExtraJoinButtons }
onClick = { onJoinButtonClick }
onOptionsClick = { onOptionsClick }
@@ -460,9 +475,9 @@ function mapStateToProps(state: IReduxState) {
const { unsafeRoomConsent } = state['features/base/premeeting'];
return {
canEditDisplayName: isPrejoinDisplayNameVisible(state),
deviceStatusVisible: isDeviceStatusVisible(state),
hasJoinByPhoneButton: isJoinByPhoneButtonVisible(state),
isDisplayNameVisible: isPrejoinDisplayNameVisible(state),
joiningInProgress,
name,
participantId,

View File

@@ -795,3 +795,11 @@ export function getCountryFromDialCodeText(text: string) {
|| null
);
}
/**
* Returns whether the display name is present.
*
* @param {string} value - The display name.
* @returns {boolean}
*/
export const hasDisplayName = (value: string): boolean => Boolean(value) && value.trim() !== '';

View File

@@ -4,6 +4,7 @@ import React from 'react';
import Avatar from '../../../base/avatar/components/Avatar';
import StatelessAvatar from '../../../base/avatar/components/web/StatelessAvatar';
import { getInitials } from '../../../base/avatar/functions';
import { IconUser } from '../../../base/icons/svg';
import BaseTheme from '../../../base/ui/components/BaseTheme.web';
import { FaceLandmarks } from '../../../face-landmarks/types';
@@ -78,6 +79,7 @@ const SpeakerStatsItem = (props: IProps) => {
<StatelessAvatar
className = 'userAvatar'
color = { BaseTheme.palette.ui04 }
iconUser = { IconUser }
initials = { getInitials(props.displayName) }
size = { 32 } />
) : (