diff --git a/config.js b/config.js index f8ea743d0563..d6974da4a944 100644 --- a/config.js +++ b/config.js @@ -766,6 +766,11 @@ var config = { // hideDisplayName: false, // // List of buttons to hide from the extra join options dropdown. // hideExtraJoinButtons: ['no-audio', 'by-phone'], + // // Configuration for pre-call test + // // By setting preCallTestEnabled, you enable the pre-call test in the prejoin page. + // // ICE server credentials need to be provided over the preCallTestICEUrl + // preCallTestEnabled: false, + // preCallTestICEUrl: '' // }, // When 'true', the user cannot edit the display name. diff --git a/lang/main.json b/lang/main.json index b3914fc852b5..9041d1b02a01 100644 --- a/lang/main.json +++ b/lang/main.json @@ -922,9 +922,11 @@ "configuringDevices": "Configuring devices...", "connectedWithAudioQ": "You’re connected with audio?", "connection": { + "failed": "Connection test failed!", "good": "Your internet connection looks good!", "nonOptimal": "Your internet connection is not optimal", - "poor": "You have a poor internet connection" + "poor": "You have a poor internet connection", + "running": "Running connection test..." }, "connectionDetails": { "audioClipping": "We expect your audio to be clipped.", @@ -933,6 +935,7 @@ "goodQuality": "Awesome! Your media quality is going to be great.", "noMediaConnectivity": "We could not find a way to establish media connectivity for this test. This is typically caused by a firewall or NAT.", "noVideo": "We expect that your video will be terrible.", + "testFailed": "The connection test encountered unexpected issues, but this might not impact your experience.", "undetectable": "If you still can not make calls in browser, we recommend that you make sure your speakers, microphone and camera are properly set up, that you have granted your browser rights to use your microphone and camera, and that your browser version is up-to-date. If you still have trouble calling, you should contact the web application developer.", "veryPoorConnection": "We expect your call quality to be really terrible.", "videoFreezing": "We expect your video to freeze, turn black, and be pixelated.", diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index c867891f05ed..78250a7b6874 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -485,6 +485,8 @@ export interface IConfig { enabled?: boolean; hideDisplayName?: boolean; hideExtraJoinButtons?: Array; + preCallTestEnabled?: boolean; + preCallTestICEUrl?: string; }; prejoinPageEnabled?: boolean; raisedHands?: { diff --git a/react/features/base/premeeting/actionTypes.ts b/react/features/base/premeeting/actionTypes.ts index 82400d3564c4..a6aa0dfa2176 100644 --- a/react/features/base/premeeting/actionTypes.ts +++ b/react/features/base/premeeting/actionTypes.ts @@ -1,3 +1,9 @@ + +/** + * Action type to set the precall test data. + */ +export const SET_PRECALL_TEST_RESULTS = 'SET_PRECALL_TEST_RESULTS'; + /** * Type for setting the user's consent for unsafe room joining. * @@ -6,4 +12,4 @@ * consent: boolean * } */ -export const SET_UNSAFE_ROOM_CONSENT = 'SET_UNSAFE_ROOM_CONSENT' \ No newline at end of file +export const SET_UNSAFE_ROOM_CONSENT = 'SET_UNSAFE_ROOM_CONSENT' diff --git a/react/features/base/premeeting/actions.web.ts b/react/features/base/premeeting/actions.web.ts index 79013f08b472..6efecb352ebd 100644 --- a/react/features/base/premeeting/actions.web.ts +++ b/react/features/base/premeeting/actions.web.ts @@ -1,4 +1,10 @@ -import { SET_UNSAFE_ROOM_CONSENT } from './actionTypes'; +import { IStore } from '../../app/types'; +import JitsiMeetJS from '../lib-jitsi-meet'; + +import { SET_PRECALL_TEST_RESULTS, SET_UNSAFE_ROOM_CONSENT } from './actionTypes'; +import { getPreCallICEUrl } from './functions'; +import logger from './logger'; +import { IPreCallResult, IPreCallTestState, PreCallTestStatus } from './types'; /** * Sets the consent of the user for joining the unsafe room. @@ -15,3 +21,47 @@ export function setUnsafeRoomConsent(consent: boolean) { consent }; } + +/** + * Initializes the 'precallTest' and executes one test, storing the results. + * + * @returns {Function} + */ +export function runPreCallTest() { + return async function(dispatch: Function, getState: IStore['getState']) { + try { + + dispatch(setPreCallTestResults({ status: PreCallTestStatus.RUNNING })); + + const turnCredentialsUrl = getPreCallICEUrl(getState()); + + if (!turnCredentialsUrl) { + throw new Error('No TURN credentials URL provided in config'); + } + + const turnCredentials = await fetch(turnCredentialsUrl); + const { iceServers } = await turnCredentials.json(); + const result: IPreCallResult = await JitsiMeetJS.runPreCallTest(iceServers); + + dispatch(setPreCallTestResults({ status: PreCallTestStatus.FINISHED, + result })); + } catch (error) { + logger.error('Failed to run pre-call test', error); + + dispatch(setPreCallTestResults({ status: PreCallTestStatus.FAILED })); + } + }; +} + +/** + * Action used to set data from precall test. + * + * @param {IPreCallTestState} value - The precall test results. + * @returns {Object} + */ +export function setPreCallTestResults(value: IPreCallTestState) { + return { + type: SET_PRECALL_TEST_RESULTS, + value + }; +} diff --git a/react/features/base/premeeting/components/web/ConnectionStatus.tsx b/react/features/base/premeeting/components/web/ConnectionStatus.tsx index 43ec1af2d59b..8276dfdc41e7 100644 --- a/react/features/base/premeeting/components/web/ConnectionStatus.tsx +++ b/react/features/base/premeeting/components/web/ConnectionStatus.tsx @@ -1,29 +1,17 @@ -import React, { useCallback, useState } from 'react'; -import { WithTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; -import { translate } from '../../../i18n/functions'; import Icon from '../../../icons/components/Icon'; -import { IconArrowDown, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons/svg'; +import { IconArrowDown, IconCloseCircle, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons/svg'; import { withPixelLineHeight } from '../../../styles/functions.web'; import { PREJOIN_DEFAULT_CONTENT_WIDTH } from '../../../ui/components/variables'; +import Spinner from '../../../ui/components/web/Spinner'; +import { runPreCallTest } from '../../actions.web'; import { CONNECTION_TYPE } from '../../constants'; import { getConnectionData } from '../../functions'; -interface IProps extends WithTranslation { - - /** - * List of strings with details about the connection. - */ - connectionDetails?: string[]; - - /** - * The type of the connection. Can be: 'none', 'poor', 'nonOptimal' or 'good'. - */ - connectionType?: string; -} - const useStyles = makeStyles()(theme => { return { connectionStatus: { @@ -68,6 +56,10 @@ const useStyles = makeStyles()(theme => { background: '#31B76A' }, + '& .con-status--failed': { + background: '#E12D2D' + }, + '& .con-status--poor': { background: '#E12D2D' }, @@ -122,6 +114,11 @@ const CONNECTION_TYPE_MAP: { icon: Function; }; } = { + [CONNECTION_TYPE.FAILED]: { + connectionClass: 'con-status--failed', + icon: IconCloseCircle, + connectionText: 'prejoin.connection.failed' + }, [CONNECTION_TYPE.POOR]: { connectionClass: 'con-status--poor', icon: IconWifi1Bar, @@ -145,10 +142,17 @@ const CONNECTION_TYPE_MAP: { * @param {IProps} props - The props of the component. * @returns {ReactElement} */ -function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) { +const ConnectionStatus = () => { const { classes } = useStyles(); - + const dispatch = useDispatch(); + const { t } = useTranslation(); + const { connectionType, connectionDetails } = useSelector(getConnectionData); const [ showDetails, toggleDetails ] = useState(false); + + useEffect(() => { + dispatch(runPreCallTest()); + }, []); + const arrowClassName = showDetails ? 'con-status-arrow con-status-arrow--up' : 'con-status-arrow'; @@ -173,6 +177,26 @@ function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) { return null; } + if (connectionType === CONNECTION_TYPE.RUNNING) { + return ( +
+
+
+ +
+ {t('prejoin.connection.running')} +
+
+ ); + } + const { connectionClass, icon, connectionText } = CONNECTION_TYPE_MAP[connectionType ?? '']; return ( @@ -208,21 +232,6 @@ function ConnectionStatus({ connectionDetails, t, connectionType }: IProps) { {detailsText} ); -} - -/** - * Maps (parts of) the redux state to the React {@code Component} props. - * - * @param {Object} state - The redux state. - * @returns {Object} - */ -function mapStateToProps() { - const { connectionDetails, connectionType } = getConnectionData(); - - return { - connectionDetails, - connectionType - }; -} +}; -export default translate(connect(mapStateToProps)(ConnectionStatus)); +export default ConnectionStatus; diff --git a/react/features/base/premeeting/components/web/PreMeetingScreen.tsx b/react/features/base/premeeting/components/web/PreMeetingScreen.tsx index 50e45ba73223..4ea1f665531a 100644 --- a/react/features/base/premeeting/components/web/PreMeetingScreen.tsx +++ b/react/features/base/premeeting/components/web/PreMeetingScreen.tsx @@ -11,6 +11,7 @@ import { isButtonEnabled } from '../../../../toolbox/functions.web'; import { getConferenceName } from '../../../conference/functions'; import { PREMEETING_BUTTONS, THIRD_PARTY_PREJOIN_BUTTONS } from '../../../config/constants'; import { withPixelLineHeight } from '../../../styles/functions.web'; +import { isPreCallTestEnabled } from '../../functions'; import ConnectionStatus from './ConnectionStatus'; import Preview from './Preview'; @@ -24,6 +25,11 @@ interface IProps { */ _buttons: Array; + /** + * Determine if pre call test is enabled. + */ + _isPreCallTestEnabled?: boolean; + /** * The branding background of the premeeting screen(lobby/prejoin). */ @@ -169,6 +175,7 @@ const useStyles = makeStyles()(theme => { const PreMeetingScreen = ({ _buttons, + _isPreCallTestEnabled, _premeetingBackground, _roomName, children, @@ -188,11 +195,13 @@ const PreMeetingScreen = ({ backgroundSize: 'cover' } : {}; + console.log('Rendering premeeting....'); + return (
- + {_isPreCallTestEnabled && }

@@ -245,6 +254,7 @@ function mapStateToProps(state: IReduxState, ownProps: Partial) { _buttons: hiddenPremeetingButtons ? premeetingButtons : premeetingButtons.filter(b => isButtonEnabled(b, toolbarButtons)), + _isPreCallTestEnabled: isPreCallTestEnabled(state), _premeetingBackground: premeetingBackground, _roomName: isRoomNameEnabled(state) ? getConferenceName(state) : '' }; diff --git a/react/features/base/premeeting/constants.ts b/react/features/base/premeeting/constants.ts index f9f7c9b925ae..d2ea17dac365 100644 --- a/react/features/base/premeeting/constants.ts +++ b/react/features/base/premeeting/constants.ts @@ -1,6 +1,8 @@ export const CONNECTION_TYPE = { + FAILED: 'failed', GOOD: 'good', NON_OPTIMAL: 'nonOptimal', NONE: 'none', - POOR: 'poor' + POOR: 'poor', + RUNNING: 'running' }; diff --git a/react/features/base/premeeting/functions.ts b/react/features/base/premeeting/functions.ts index 745ededd4dbc..2ac52e9dd04c 100644 --- a/react/features/base/premeeting/functions.ts +++ b/react/features/base/premeeting/functions.ts @@ -1,4 +1,10 @@ +import { findIndex } from 'lodash-es'; + +import { IReduxState } from '../../app/types'; + import { CONNECTION_TYPE } from './constants'; +import logger from './logger'; +import { IPreCallResult, PreCallTestStatus } from './types'; /** @@ -31,6 +37,14 @@ const defaultMarginTop = '10%'; */ const smallMarginTop = '5%'; +// loss in percentage overall the test duration +const LOSS_AUDIO_THRESHOLDS = [ 0.33, 0.05 ]; +const LOSS_VIDEO_THRESHOLDS = [ 0.33, 0.1, 0.05 ]; + +// throughput in kbps +const THROUGHPUT_AUDIO_THRESHOLDS = [ 8, 20 ]; +const THROUGHPUT_VIDEO_THRESHOLDS = [ 60, 750 ]; + /** * Calculates avatar dimensions based on window height and position. * @@ -71,16 +85,187 @@ export function calculateAvatarDimensions(height: number) { } /** - * Selector for determining the connection type & details. + * Returns the level based on a list of thresholds. * - * @returns {{ - * connectionType: string, - * connectionDetails: string[] - * }} + * @param {number[]} thresholds - The thresholds array. + * @param {number} value - The value against which the level is calculated. + * @param {boolean} descending - The order based on which the level is calculated. + * + * @returns {number} */ -export function getConnectionData() { +function _getLevel(thresholds: number[], value: number, descending = true) { + let predicate; + + if (descending) { + predicate = function(threshold: number) { + return value > threshold; + }; + } else { + predicate = function(threshold: number) { + return value < threshold; + }; + } + + const i = findIndex(thresholds, predicate); + + if (i === -1) { + return thresholds.length; + } + + return i; +} + +/** + * Returns the connection details from the test results. + * + * @param {number} testResults.fractionalLoss - Factional loss. + * @param {number} testResults.throughput - Throughput. + * + * @returns {{ +* connectionType: string, +* connectionDetails: string[] +* }} +*/ +function _getConnectionDataFromTestResults({ fractionalLoss: l, throughput: t, mediaConnectivity }: IPreCallResult) { + let connectionType = CONNECTION_TYPE.FAILED; + const connectionDetails: Array = []; + + if (!mediaConnectivity) { + connectionType = CONNECTION_TYPE.POOR; + connectionDetails.push('prejoin.connectionDetails.noMediaConnectivity'); + + return { + connectionType, + connectionDetails + }; + } + + const loss = { + audioQuality: _getLevel(LOSS_AUDIO_THRESHOLDS, l), + videoQuality: _getLevel(LOSS_VIDEO_THRESHOLDS, l) + }; + const throughput = { + audioQuality: _getLevel(THROUGHPUT_AUDIO_THRESHOLDS, t, false), + videoQuality: _getLevel(THROUGHPUT_VIDEO_THRESHOLDS, t, false) + }; + + if (throughput.audioQuality === 0 || loss.audioQuality === 0) { + // Calls are impossible. + connectionType = CONNECTION_TYPE.POOR; + connectionDetails.push('prejoin.connectionDetails.veryPoorConnection'); + } else if ( + throughput.audioQuality === 2 + && throughput.videoQuality === 2 + && loss.audioQuality === 2 + && loss.videoQuality === 3 + ) { + // Ideal conditions for both audio and video. Show only one message. + connectionType = CONNECTION_TYPE.GOOD; + connectionDetails.push('prejoin.connectionDetails.goodQuality'); + } else { + connectionType = CONNECTION_TYPE.NON_OPTIMAL; + + if (throughput.audioQuality === 1) { + // Minimum requirements for a call are met. + connectionDetails.push('prejoin.connectionDetails.audioLowNoVideo'); + } else { + // There are two paragraphs: one saying something about audio and the other about video. + if (loss.audioQuality === 1) { + connectionDetails.push('prejoin.connectionDetails.audioClipping'); + } else { + connectionDetails.push('prejoin.connectionDetails.audioHighQuality'); + } + + if (throughput.videoQuality === 0 || loss.videoQuality === 0) { + connectionDetails.push('prejoin.connectionDetails.noVideo'); + } else if (throughput.videoQuality === 1) { + connectionDetails.push('prejoin.connectionDetails.videoLowQuality'); + } else if (loss.videoQuality === 1) { + connectionDetails.push('prejoin.connectionDetails.videoFreezing'); + } else if (loss.videoQuality === 2) { + connectionDetails.push('prejoin.connectionDetails.videoTearing'); + } else { + connectionDetails.push('prejoin.connectionDetails.videoHighQuality'); + } + } + connectionDetails.push('prejoin.connectionDetails.undetectable'); + } + return { - connectionType: CONNECTION_TYPE.NONE, - connectionDetails: [] + connectionType, + connectionDetails }; } + +/** + * Selector for determining the connection type & details. + * + * @param {Object} state - The state of the app. + * @returns {{ +* connectionType: string, +* connectionDetails: string[] +* }} +*/ +export function getConnectionData(state: IReduxState) { + const { preCallTestState: { status, result } } = state['features/base/premeeting']; + + switch (status) { + case PreCallTestStatus.INITIAL: + return { + connectionType: CONNECTION_TYPE.NONE, + connectionDetails: [] + }; + case PreCallTestStatus.RUNNING: + return { + connectionType: CONNECTION_TYPE.RUNNING, + connectionDetails: [] + }; + case PreCallTestStatus.FAILED: + // A failed test means that something went wrong with our business logic and not necessarily + // that the connection is bad. For instance, the endpoint providing the ICE credentials could be down. + return { + connectionType: CONNECTION_TYPE.FAILED, + connectionDetails: [ 'prejoin.connectionDetails.testFailed' ] + }; + case PreCallTestStatus.FINISHED: + if (result) { + return _getConnectionDataFromTestResults(result); + } + + logger.error('Pre-call test finished but no test results were available'); + + return { + connectionType: CONNECTION_TYPE.FAILED, + connectionDetails: [ 'prejoin.connectionDetails.testFailed' ] + }; + default: + return { + connectionType: CONNECTION_TYPE.NONE, + connectionDetails: [] + }; + } +} + +/** + * Selector for determining if the pre-call test is enabled. + * + * @param {Object} state - The state of the app. + * @returns {boolean} + */ +export function isPreCallTestEnabled(state: IReduxState): boolean { + const { prejoinConfig } = state['features/base/config']; + + return prejoinConfig?.preCallTestEnabled ?? false; +} + +/** + * Selector for retrieving the pre-call test ICE URL. + * + * @param {Object} state - The state of the app. + * @returns {string | undefined} + */ +export function getPreCallICEUrl(state: IReduxState): string | undefined { + const { prejoinConfig } = state['features/base/config']; + + return prejoinConfig?.preCallTestICEUrl; +} diff --git a/react/features/base/premeeting/reducer.web.ts b/react/features/base/premeeting/reducer.web.ts index 37ce364373c5..78da220a2219 100644 --- a/react/features/base/premeeting/reducer.web.ts +++ b/react/features/base/premeeting/reducer.web.ts @@ -1,10 +1,13 @@ import ReducerRegistry from '../redux/ReducerRegistry'; -import { SET_UNSAFE_ROOM_CONSENT } from './actionTypes'; -import { IPreMeetingState } from './types'; +import { SET_PRECALL_TEST_RESULTS, SET_UNSAFE_ROOM_CONSENT } from './actionTypes'; +import { IPreMeetingState, PreCallTestStatus } from './types'; const DEFAULT_STATE: IPreMeetingState = { + preCallTestState: { + status: PreCallTestStatus.INITIAL + }, unsafeRoomConsent: false }; @@ -20,6 +23,12 @@ ReducerRegistry.register( 'features/base/premeeting', (state = DEFAULT_STATE, action): IPreMeetingState => { switch (action.type) { + case SET_PRECALL_TEST_RESULTS: + return { + ...state, + preCallTestState: action.value + }; + case SET_UNSAFE_ROOM_CONSENT: { return { ...state, diff --git a/react/features/base/premeeting/types.ts b/react/features/base/premeeting/types.ts index ff431750ec9d..398d1a554d21 100644 --- a/react/features/base/premeeting/types.ts +++ b/react/features/base/premeeting/types.ts @@ -1,3 +1,25 @@ + +export enum PreCallTestStatus { + FAILED = 'FAILED', + FINISHED = 'FINISHED', + INITIAL = 'INITIAL', + RUNNING = 'RUNNING' +} + export interface IPreMeetingState { + preCallTestState: IPreCallTestState; unsafeRoomConsent?: boolean; } + +export interface IPreCallTestState { + result?: IPreCallResult; + status: PreCallTestStatus; +} + +export interface IPreCallResult { + fractionalLoss: number; + jitter: number; + mediaConnectivity: boolean; + rtt: number; + throughput: number; +}