From 453c07cb173dcf36c4527b659e9f17d48fb48bc7 Mon Sep 17 00:00:00 2001 From: Vlad Piersec Date: Wed, 29 Jul 2020 13:27:32 +0300 Subject: [PATCH] feat(prejoin): Add precall connection quality indicator * Adds a dropdown indicator which displays the status of the internet connection. * It uses the same data as `https://network.callstats.io`. * The algorithm for the strings displayed to the user is also the one used on `network.callstas.io`. --- conference.js | 5 +- css/_connection-status.scss | 60 ++++++++ css/main.scss | 1 + lang/main.json | 19 +++ react/features/base/icons/svg/index.js | 3 + react/features/base/icons/svg/wifi-1.svg | 5 + react/features/base/icons/svg/wifi-2.svg | 5 + react/features/base/icons/svg/wifi-3.svg | 5 + .../components/web/ConnectionStatus.js | 104 +++++++++++++ .../components/web/PreMeetingScreen.js | 2 + react/features/base/premeeting/constants.js | 8 + react/features/base/premeeting/functions.js | 142 ++++++++++++++++++ react/features/prejoin/actionTypes.js | 5 + react/features/prejoin/actions.js | 32 ++++ react/features/prejoin/reducer.js | 7 + 15 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 css/_connection-status.scss create mode 100644 react/features/base/icons/svg/wifi-1.svg create mode 100644 react/features/base/icons/svg/wifi-2.svg create mode 100644 react/features/base/icons/svg/wifi-3.svg create mode 100644 react/features/base/premeeting/components/web/ConnectionStatus.js create mode 100644 react/features/base/premeeting/constants.js create mode 100644 react/features/base/premeeting/functions.js diff --git a/conference.js b/conference.js index d4a5c4790..a2be459e6 100644 --- a/conference.js +++ b/conference.js @@ -121,7 +121,8 @@ import { suspendDetected } from './react/features/power-monitor'; import { initPrejoin, isPrejoinPageEnabled, - isPrejoinPageVisible + isPrejoinPageVisible, + makePrecallTest } from './react/features/prejoin'; import { createRnnoiseProcessorPromise } from './react/features/rnnoise'; import { toggleScreenshotCaptureEffect } from './react/features/screenshot-capture'; @@ -767,6 +768,8 @@ export default { return c; }); + APP.store.dispatch(makePrecallTest(this._getConferenceOptions())); + const { tryCreateLocalTracks, errors } = this.createInitialLocalTracks(initialOptions); const tracks = await tryCreateLocalTracks; diff --git a/css/_connection-status.scss b/css/_connection-status.scss new file mode 100644 index 000000000..45a3078da --- /dev/null +++ b/css/_connection-status.scss @@ -0,0 +1,60 @@ +.con-status { + position: absolute; + top: 40px; + width: 100%; + z-index: $toolbarZ + 3; + + &-container { + background: rgba(28, 32, 37, .5); + border-radius: 3px; + color: #fff; + font-size: 13px; + line-height: 20px; + margin: 0 auto; + width: 304px; + } + + &-header { + align-items: center; + display: flex; + justify-content: space-between; + padding: 8px; + } + + &-circle { + border-radius: 50%; + display: inline-block; + padding: 4px; + } + + &--good { + background: #31B76A; + } + + &--poor { + background: #E12D2D; + } + + &--non-optimal { + background: #E39623; + } + + &-arrow { + &--up { + transform: rotate(180deg); + } + + &>svg { + cursor: pointer; + } + } + + &-text { + text-align: center; + } + + &-details { + border-top: 1px solid #5E6D7A; + padding: 16px; + } +} diff --git a/css/main.scss b/css/main.scss index 160342b7b..cf2346886 100644 --- a/css/main.scss +++ b/css/main.scss @@ -102,5 +102,6 @@ $flagsImagePath: "../images/"; @import 'premeeting-screens'; @import 'e2ee'; @import 'responsive'; +@import 'connection-status'; /* Modules END */ diff --git a/lang/main.json b/lang/main.json index 498635dd1..df29473f6 100644 --- a/lang/main.json +++ b/lang/main.json @@ -511,6 +511,25 @@ "callMeAtNumber": "Call me at this number:", "configuringDevices": "Configuring devices...", "connectedWithAudioQ": "You’re connected with audio?", + "connection": { + "good": "Your internet connection looks good!", + "nonOptimal": "Your internet connection is not optimal", + "poor": "You have a poor internet connection" + }, + "connectionDetails": { + "audioClipping": "We expect your audio to be clipped.", + "audioHighQuality": "We expect your audio to have excellent quality.", + "audioLowNoVideo": "We expect your audio quality to be low and no video.", + "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.", + "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.", + "videoHighQuality": "We expect your video to have good quality.", + "videoLowQuality": "We expect your video to have low quality in terms of frame rate and resolution.", + "videoTearing": "We expect your video to be pixelated or have visual artefacts." + }, "copyAndShare": "Copy & share meeting link", "dialInMeeting": "Dial into the meeting", "dialInPin": "Dial into the meeting and enter PIN code:", diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index e7f43a451..1f98e421a 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -98,4 +98,7 @@ export { default as IconVolume } from './volume.svg'; export { default as IconVolumeEmpty } from './volume-empty.svg'; export { default as IconVolumeOff } from './volume-off.svg'; export { default as IconWarning } from './warning.svg'; +export { default as IconWifi1Bar } from './wifi-1.svg'; +export { default as IconWifi2Bars } from './wifi-2.svg'; +export { default as IconWifi3Bars } from './wifi-3.svg'; export { default as IconYahoo } from './yahoo.svg'; diff --git a/react/features/base/icons/svg/wifi-1.svg b/react/features/base/icons/svg/wifi-1.svg new file mode 100644 index 000000000..74a875d36 --- /dev/null +++ b/react/features/base/icons/svg/wifi-1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/icons/svg/wifi-2.svg b/react/features/base/icons/svg/wifi-2.svg new file mode 100644 index 000000000..cdb6651cf --- /dev/null +++ b/react/features/base/icons/svg/wifi-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/icons/svg/wifi-3.svg b/react/features/base/icons/svg/wifi-3.svg new file mode 100644 index 000000000..85e628861 --- /dev/null +++ b/react/features/base/icons/svg/wifi-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/react/features/base/premeeting/components/web/ConnectionStatus.js b/react/features/base/premeeting/components/web/ConnectionStatus.js new file mode 100644 index 000000000..c5a5ca66f --- /dev/null +++ b/react/features/base/premeeting/components/web/ConnectionStatus.js @@ -0,0 +1,104 @@ +// @flow + +import React, { useState } from 'react'; + +import { translate } from '../../../i18n'; +import { Icon, IconArrowDownSmall, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons'; +import { connect } from '../../../redux'; +import { CONNECTION_TYPE } from '../../constants'; +import { getConnectionData } from '../../functions'; + +type Props = { + + /** + * List of strings with details about the connection. + */ + connectionDetails: string[], + + /** + * The type of the connection. Can be: 'none', 'poor', 'nonOptimal' or 'good'. + */ + connectionType: string, + + /** + * Used for translation. + */ + t: Function +} + +const CONNECTION_TYPE_MAP = { + [CONNECTION_TYPE.POOR]: { + connectionClass: 'con-status--poor', + icon: IconWifi1Bar, + connectionText: 'prejoin.connection.poor' + }, + [CONNECTION_TYPE.NON_OPTIMAL]: { + connectionClass: 'con-status--non-optimal', + icon: IconWifi2Bars, + connectionText: 'prejoin.connection.nonOptimal' + }, + [CONNECTION_TYPE.GOOD]: { + connectionClass: 'con-status--good', + icon: IconWifi3Bars, + connectionText: 'prejoin.connection.good' + } +}; + +/** + * Component displaying information related to the connection & audio/video quality. + * + * @param {Props} props - The props of the component. + * @returns {ReactElement} + */ +function ConnectionStatus({ connectionDetails, t, connectionType }: Props) { + if (connectionType === CONNECTION_TYPE.NONE) { + return null; + } + + const { connectionClass, icon, connectionText } = CONNECTION_TYPE_MAP[connectionType]; + const [ showDetails, toggleDetails ] = useState(false); + const arrowClassName = showDetails + ? 'con-status-arrow con-status-arrow--up' + : 'con-status-arrow'; + const detailsText = connectionDetails.map(t).join(' '); + + return ( +
+
+
+
+ +
+ {t(connectionText)} + toggleDetails(!showDetails) } + size = { 24 } + src = { IconArrowDownSmall } /> +
+ { showDetails + &&
{detailsText}
} +
+
+ ); +} + +/** + * Maps (parts of) the redux state to the React {@code Component} props. + * + * @param {Object} state - The redux state. + * @returns {Object} + */ +function mapStateToProps(state): Object { + const { connectionDetails, connectionType } = getConnectionData(state); + + return { + connectionDetails, + connectionType + }; +} + +export default translate(connect(mapStateToProps)(ConnectionStatus)); diff --git a/react/features/base/premeeting/components/web/PreMeetingScreen.js b/react/features/base/premeeting/components/web/PreMeetingScreen.js index dd361f1b8..536c749e8 100644 --- a/react/features/base/premeeting/components/web/PreMeetingScreen.js +++ b/react/features/base/premeeting/components/web/PreMeetingScreen.js @@ -4,6 +4,7 @@ import React, { PureComponent } from 'react'; import { AudioSettingsButton, VideoSettingsButton } from '../../../../toolbox/components/web'; +import ConnectionStatus from './ConnectionStatus'; import CopyMeetingUrl from './CopyMeetingUrl'; import Preview from './Preview'; @@ -82,6 +83,7 @@ export default class PreMeetingScreen extends PureComponent {
+ threshold; + }; + } else { + predicate = function(threshold) { + 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 {{ + * fractionalLoss: number, + * throughput: number + * }} testResults - The state of the app. + * + * @returns {{ + * connectionType: string, + * connectionDetails: string[] + * }} + */ +function _getConnectionDataFromTestResults({ fractionalLoss: l, throughput: t }) { + 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) + }; + let connectionType = CONNECTION_TYPE.NONE; + const connectionDetails = []; + + 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, + 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) { + const { precallTestResults } = state['features/prejoin']; + + if (precallTestResults) { + if (precallTestResults.mediaConnectivity) { + return _getConnectionDataFromTestResults(precallTestResults); + } + + return { + connectionType: CONNECTION_TYPE.POOR, + connectionDetails: [ 'prejoin.connectionDetails.noMediaConnectivity' ] + }; + } + + return { + connectionType: CONNECTION_TYPE.NONE, + connectionDetails: [] + }; +} diff --git a/react/features/prejoin/actionTypes.js b/react/features/prejoin/actionTypes.js index 6f9667170..1e9f0cd2d 100644 --- a/react/features/prejoin/actionTypes.js +++ b/react/features/prejoin/actionTypes.js @@ -39,6 +39,11 @@ export const SET_DIALOUT_STATUS = 'SET_DIALOUT_STATUS'; */ export const SET_JOIN_BY_PHONE_DIALOG_VISIBLITY = 'SET_JOIN_BY_PHONE_DIALOG_VISIBLITY'; +/** + * Action type to set the precall test data. + */ +export const SET_PRECALL_TEST_RESULTS = 'SET_PRECALL_TEST_RESULTS'; + /** * Action type to disable the audio while on prejoin page. */ diff --git a/react/features/prejoin/actions.js b/react/features/prejoin/actions.js index 7255f8025..e43ba5f02 100644 --- a/react/features/prejoin/actions.js +++ b/react/features/prejoin/actions.js @@ -1,5 +1,7 @@ // @flow +declare var JitsiMeetJS: Object; + import uuid from 'uuid'; import { getRoomName } from '../base/conference'; @@ -24,6 +26,7 @@ import { SET_PREJOIN_DISPLAY_NAME_REQUIRED, SET_SKIP_PREJOIN, SET_JOIN_BY_PHONE_DIALOG_VISIBLITY, + SET_PRECALL_TEST_RESULTS, SET_PREJOIN_DEVICE_ERRORS, SET_PREJOIN_PAGE_VISIBILITY } from './actionTypes'; @@ -231,6 +234,22 @@ export function joinConferenceWithoutAudio() { }; } +/** + * Initializes the 'precallTest' and executes one test, storing the results. + * + * @param {Object} conferenceOptions - The conference options. + * @returns {Function} + */ +export function makePrecallTest(conferenceOptions: Object) { + return async function(dispatch: Function) { + await JitsiMeetJS.precallTest.init(conferenceOptions); + + const results = await JitsiMeetJS.precallTest.execute(); + + dispatch(setPrecallTestResults(results)); + }; +} + /** * Opens an external page with all the dial in numbers. * @@ -397,6 +416,19 @@ export function setJoinByPhoneDialogVisiblity(value: boolean) { }; } +/** + * Action used to set data from precall test. + * + * @param {Object} value - The precall test results. + * @returns {Object} + */ +export function setPrecallTestResults(value: Object) { + return { + type: SET_PRECALL_TEST_RESULTS, + value + }; +} + /** * Action used to set the initial errors after creating the tracks. * diff --git a/react/features/prejoin/reducer.js b/react/features/prejoin/reducer.js index 599560807..c27ee025f 100644 --- a/react/features/prejoin/reducer.js +++ b/react/features/prejoin/reducer.js @@ -6,6 +6,7 @@ import { SET_DIALOUT_NUMBER, SET_DIALOUT_STATUS, SET_JOIN_BY_PHONE_DIALOG_VISIBLITY, + SET_PRECALL_TEST_RESULTS, SET_PREJOIN_DEVICE_ERRORS, SET_PREJOIN_DISPLAY_NAME_REQUIRED, SET_PREJOIN_PAGE_VISIBILITY, @@ -45,6 +46,12 @@ ReducerRegistry.register( }; } + case SET_PRECALL_TEST_RESULTS: + return { + ...state, + precallTestResults: action.value + }; + case SET_PREJOIN_PAGE_VISIBILITY: return { ...state,