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,15 @@
/**
* 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.
*
* {
* type: SET_UNSAFE_ROOM_CONSENT,
* consent: boolean
* }
*/
export const SET_UNSAFE_ROOM_CONSENT = 'SET_UNSAFE_ROOM_CONSENT'

View File

@@ -0,0 +1,67 @@
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.
*
* @param {boolean} consent - The user's consent.
* @returns {{
* type: SET_UNSAFE_ROOM_CONSENT,
* consent: boolean
* }}
*/
export function setUnsafeRoomConsent(consent: boolean) {
return {
type: SET_UNSAFE_ROOM_CONSENT,
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
};
}

View File

@@ -0,0 +1,242 @@
import React, { ReactNode, useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
import { IconArrowDown } from '../../../icons/svg';
interface IProps {
/**
* Icon to display in the options section.
*/
OptionsIcon?: Function;
/**
* The Label of the child element.
*/
ariaDropDownLabel?: string;
/**
* The Label of the current element.
*/
ariaLabel?: string;
/**
* To give a aria-pressed to the icon.
*/
ariaPressed?: boolean;
/**
* Text of the button.
*/
children: ReactNode;
/**
* Text css class of the button.
*/
className?: string;
/**
* If the button is disabled or not.
*/
disabled?: boolean;
/**
* If the button has options.
*/
hasOptions?: boolean;
/**
* OnClick button handler.
*/
onClick?: (e?: React.MouseEvent) => void;
/**
* Click handler for options.
*/
onOptionsClick?: (e?: React.KeyboardEvent | React.MouseEvent) => void;
/**
* To give a role to the icon.
*/
role?: string;
/**
* To navigate with the keyboard.
*/
tabIndex?: number;
/**
* TestId of the button. Can be used to locate element when testing UI.
*/
testId?: string;
/**
* The type of th button: primary, secondary, text.
*/
type: string;
}
const useStyles = makeStyles()(theme => {
return {
actionButton: {
...theme.typography.bodyLongBold,
borderRadius: theme.shape.borderRadius,
boxSizing: 'border-box',
color: theme.palette.text01,
cursor: 'pointer',
display: 'inline-block',
marginBottom: '16px',
padding: '7px 16px',
position: 'relative' as const,
textAlign: 'center',
width: '100%',
border: 0,
'&.primary': {
background: theme.palette.action01,
color: theme.palette.text01,
'&:hover': {
backgroundColor: theme.palette.action01Hover
}
},
'&.secondary': {
background: theme.palette.action02,
color: theme.palette.text04,
'&:hover': {
backgroundColor: theme.palette.action02Hover
}
},
'&.text': {
width: 'auto',
fontSize: '0.875rem',
margin: '0',
padding: '0'
},
'&.disabled': {
background: theme.palette.disabled01,
border: '1px solid #5E6D7A',
color: '#AFB6BC',
cursor: 'initial',
'.icon': {
'& > svg': {
fill: '#AFB6BC'
}
}
},
[theme.breakpoints.down(400)]: {
fontSize: '1rem',
marginBottom: 8,
padding: '11px 16px'
}
},
options: {
borderRadius: Number(theme.shape.borderRadius) / 2,
alignItems: 'center',
display: 'flex',
height: '100%',
justifyContent: 'center',
position: 'absolute' as const,
right: 0,
top: 0,
width: 36,
'&:hover': {
backgroundColor: '#0262B6'
},
'& svg': {
pointerEvents: 'none'
}
}
};
});
/**
* Button used for pre meeting actions.
*
* @returns {ReactElement}
*/
function ActionButton({
children,
className = '',
disabled,
hasOptions,
OptionsIcon = IconArrowDown,
testId,
type = 'primary',
onClick,
onOptionsClick,
tabIndex,
role,
ariaPressed,
ariaLabel,
ariaDropDownLabel
}: IProps) {
const { classes, cx } = useStyles();
const onKeyPressHandler = useCallback(e => {
if (onClick && !disabled && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
onClick(e);
}
}, [ onClick, disabled ]);
const onOptionsKeyPressHandler = useCallback(e => {
if (onOptionsClick && !disabled && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
e.stopPropagation();
onOptionsClick(e);
}
}, [ onOptionsClick, disabled ]);
const containerClasses = cx(
classes.actionButton,
className && className,
type,
disabled && 'disabled'
);
return (
<div
aria-disabled = { disabled }
aria-label = { ariaLabel }
className = { containerClasses }
data-testid = { testId ? testId : undefined }
onClick = { disabled ? undefined : onClick }
onKeyPress = { onKeyPressHandler }
role = 'button'
tabIndex = { 0 } >
{children}
{ hasOptions
&& <div
aria-disabled = { disabled }
aria-haspopup = 'true'
aria-label = { ariaDropDownLabel }
aria-pressed = { ariaPressed }
className = { classes.options }
data-testid = 'prejoin.joinOptions'
onClick = { disabled ? undefined : onOptionsClick }
onKeyPress = { onOptionsKeyPressHandler }
role = { role }
tabIndex = { tabIndex }>
<Icon
className = 'icon'
size = { 24 }
src = { OptionsIcon } />
</div>
}
</div>
);
}
export default ActionButton;

View File

@@ -0,0 +1,236 @@
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 Icon from '../../../icons/components/Icon';
import { IconArrowDown, IconCloseCircle, IconWifi1Bar, IconWifi2Bars, IconWifi3Bars } from '../../../icons/svg';
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';
const useStyles = makeStyles()(theme => {
return {
connectionStatus: {
color: '#fff',
...theme.typography.bodyShortRegular,
position: 'absolute',
width: '100%',
[theme.breakpoints.down(400)]: {
margin: 0,
width: '100%'
},
'@media (max-width: 720px)': {
margin: `${theme.spacing(4)} auto`,
position: 'fixed',
top: 0,
width: PREJOIN_DEFAULT_CONTENT_WIDTH
},
// mobile phone landscape
'@media (max-height: 420px)': {
display: 'none'
},
'& .con-status-header': {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
alignItems: 'center',
display: 'flex',
padding: '12px 16px',
borderRadius: theme.shape.borderRadius
},
'& .con-status-circle': {
borderRadius: '50%',
display: 'inline-block',
padding: theme.spacing(1),
marginRight: theme.spacing(2)
},
'& .con-status--good': {
background: '#31B76A'
},
'& .con-status--failed': {
background: '#E12D2D'
},
'& .con-status--poor': {
background: '#E12D2D'
},
'& .con-status--non-optimal': {
background: '#E39623'
},
'& .con-status-arrow': {
marginLeft: 'auto',
transition: 'background-color 0.16s ease-out'
},
'& .con-status-arrow--up': {
transform: 'rotate(180deg)'
},
'& .con-status-arrow > svg': {
cursor: 'pointer'
},
'& .con-status-arrow:hover': {
backgroundColor: 'rgba(1, 1, 1, 0.1)'
},
'& .con-status-text': {
textAlign: 'center'
},
'& .con-status-details': {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderTop: '1px solid #5E6D7A',
padding: theme.spacing(3),
transition: 'opacity 0.16s ease-out'
},
'& .con-status-details-visible': {
opacity: 1
},
'& .con-status-details-hidden': {
opacity: 0
}
}
};
});
const CONNECTION_TYPE_MAP: {
[key: string]: {
connectionClass: string;
connectionText: string;
icon: Function;
};
} = {
[CONNECTION_TYPE.FAILED]: {
connectionClass: 'con-status--failed',
icon: IconCloseCircle,
connectionText: 'prejoin.connection.failed'
},
[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 {IProps} props - The props of the component.
* @returns {ReactElement}
*/
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';
const detailsText = connectionDetails?.map(d => t(d)).join(' ');
const detailsClassName = showDetails
? 'con-status-details-visible'
: 'con-status-details-hidden';
const onToggleDetails = useCallback(e => {
e.preventDefault();
toggleDetails(!showDetails);
}, [ showDetails, toggleDetails ]);
const onKeyPressToggleDetails = useCallback(e => {
if (toggleDetails && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
toggleDetails(!showDetails);
}
}, [ showDetails, toggleDetails ]);
if (connectionType === CONNECTION_TYPE.NONE) {
return null;
}
if (connectionType === CONNECTION_TYPE.RUNNING) {
return (
<div className = { classes.connectionStatus }>
<div
aria-level = { 1 }
className = 'con-status-header'
role = 'heading'>
<div className = 'con-status-circle'>
<Spinner
color = { 'green' }
size = 'medium' />
</div>
<span
className = 'con-status-text'
id = 'connection-status-description'>{t('prejoin.connection.running')}</span>
</div>
</div>
);
}
const { connectionClass, icon, connectionText } = CONNECTION_TYPE_MAP[connectionType ?? ''];
return (
<div className = { classes.connectionStatus }>
<div
aria-level = { 1 }
className = 'con-status-header'
role = 'heading'>
<div className = { `con-status-circle ${connectionClass}` }>
<Icon
size = { 16 }
src = { icon } />
</div>
<span
aria-hidden = { !showDetails }
className = 'con-status-text'
id = 'connection-status-description'>{t(connectionText)}</span>
<Icon
ariaDescribedBy = 'connection-status-description'
ariaPressed = { showDetails }
className = { arrowClassName }
onClick = { onToggleDetails }
onKeyPress = { onKeyPressToggleDetails }
role = 'button'
size = { 24 }
src = { IconArrowDown }
tabIndex = { 0 } />
</div>
<div
aria-level = { 2 }
className = { `con-status-details ${detailsClassName}` }
role = 'heading'>
{detailsText}</div>
</div>
);
};
export default ConnectionStatus;

View File

@@ -0,0 +1,294 @@
import clsx from 'clsx';
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../../app/types';
import DeviceStatus from '../../../../prejoin/components/web/preview/DeviceStatus';
import { isRoomNameEnabled } from '../../../../prejoin/functions.web';
import Toolbox from '../../../../toolbox/components/web/Toolbox';
import { isButtonEnabled } from '../../../../toolbox/functions.web';
import { getConferenceName } from '../../../conference/functions';
import { PREMEETING_BUTTONS, THIRD_PARTY_PREJOIN_BUTTONS } from '../../../config/constants';
import Tooltip from '../../../tooltip/components/Tooltip';
import { isPreCallTestEnabled } from '../../functions';
import ConnectionStatus from './ConnectionStatus';
import Preview from './Preview';
import RecordingWarning from './RecordingWarning';
import UnsafeRoomWarning from './UnsafeRoomWarning';
interface IProps {
/**
* The list of toolbar buttons to render.
*/
_buttons: Array<string>;
/**
* Determine if pre call test is enabled.
*/
_isPreCallTestEnabled?: boolean;
/**
* The branding background of the premeeting screen(lobby/prejoin).
*/
_premeetingBackground: string;
/**
* The name of the meeting that is about to be joined.
*/
_roomName: string;
/**
* Children component(s) to be rendered on the screen.
*/
children?: ReactNode;
/**
* Additional CSS class names to set on the icon container.
*/
className?: string;
/**
* The name of the participant.
*/
name?: string;
/**
* Indicates whether the copy url button should be shown.
*/
showCopyUrlButton?: boolean;
/**
* Indicates whether the device status should be shown.
*/
showDeviceStatus: boolean;
/**
* Indicates whether to display the recording warning.
*/
showRecordingWarning?: boolean;
/**
* If should show unsafe room warning when joining.
*/
showUnsafeRoomWarning?: boolean;
/**
* The 'Skip prejoin' button to be rendered (if any).
*/
skipPrejoinButton?: ReactNode;
/**
* Whether it's used in the 3rdParty prejoin screen or not.
*/
thirdParty?: boolean;
/**
* Title of the screen.
*/
title?: string;
/**
* True if the preview overlay should be muted, false otherwise.
*/
videoMuted?: boolean;
/**
* The video track to render as preview (if omitted, the default local track will be rendered).
*/
videoTrack?: Object;
}
const useStyles = makeStyles()(theme => {
return {
container: {
height: '100%',
position: 'absolute',
inset: '0 0 0 0',
display: 'flex',
backgroundColor: theme.palette.ui01,
zIndex: 252,
'@media (max-width: 720px)': {
flexDirection: 'column-reverse'
}
},
content: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
flexShrink: 0,
boxSizing: 'border-box',
margin: '0 48px',
padding: '24px 0 16px',
position: 'relative',
width: '300px',
height: '100%',
zIndex: 252,
'@media (max-width: 720px)': {
height: 'auto',
margin: '0 auto'
},
// mobile phone landscape
'@media (max-width: 420px)': {
padding: '16px 16px 0 16px',
width: '100%'
},
'@media (max-width: 400px)': {
padding: '16px'
}
},
contentControls: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
margin: 'auto',
width: '100%'
},
title: {
...theme.typography.heading4,
color: `${theme.palette.text01}!important`,
marginBottom: theme.spacing(3),
textAlign: 'center',
'@media (max-width: 400px)': {
display: 'none'
}
},
roomNameContainer: {
width: '100%',
textAlign: 'center',
marginBottom: theme.spacing(4)
},
roomName: {
...theme.typography.heading5,
color: theme.palette.text01,
display: 'inline-block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
}
};
});
const PreMeetingScreen = ({
_buttons,
_isPreCallTestEnabled,
_premeetingBackground,
_roomName,
children,
className,
showDeviceStatus,
showRecordingWarning,
showUnsafeRoomWarning,
skipPrejoinButton,
title,
videoMuted,
videoTrack
}: IProps) => {
const { classes } = useStyles();
const style = _premeetingBackground ? {
background: _premeetingBackground,
backgroundPosition: 'center',
backgroundSize: 'cover'
} : {};
const roomNameRef = useRef<HTMLSpanElement | null>(null);
const [ isOverflowing, setIsOverflowing ] = useState(false);
useEffect(() => {
if (roomNameRef.current) {
const element = roomNameRef.current;
const elementStyles = window.getComputedStyle(element);
const elementWidth = Math.floor(parseFloat(elementStyles.width));
setIsOverflowing(element.scrollWidth > elementWidth + 1);
}
}, [ _roomName ]);
return (
<div className = { clsx('premeeting-screen', classes.container, className) }>
<div style = { style }>
<div className = { classes.content }>
{_isPreCallTestEnabled && <ConnectionStatus />}
<div className = { classes.contentControls }>
<h1 className = { classes.title }>
{title}
</h1>
{_roomName && (
<span className = { classes.roomNameContainer }>
{isOverflowing ? (
<Tooltip content = { _roomName }>
<span
className = { classes.roomName }
ref = { roomNameRef }>
{_roomName}
</span>
</Tooltip>
) : (
<span
className = { classes.roomName }
ref = { roomNameRef }>
{_roomName}
</span>
)}
</span>
)}
{children}
{_buttons.length && <Toolbox toolbarButtons = { _buttons } />}
{skipPrejoinButton}
{showUnsafeRoomWarning && <UnsafeRoomWarning />}
{showDeviceStatus && <DeviceStatus />}
{showRecordingWarning && <RecordingWarning />}
</div>
</div>
</div>
<Preview
videoMuted = { videoMuted }
videoTrack = { videoTrack } />
</div>
);
};
/**
* Maps (parts of) the redux state to the React {@code Component} props.
*
* @param {Object} state - The redux state.
* @param {Object} ownProps - The props passed to the component.
* @returns {Object}
*/
function mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
const { hiddenPremeetingButtons } = state['features/base/config'];
const { toolbarButtons } = state['features/toolbox'];
const premeetingButtons = (ownProps.thirdParty
? THIRD_PARTY_PREJOIN_BUTTONS
: PREMEETING_BUTTONS).filter((b: any) => !(hiddenPremeetingButtons || []).includes(b));
const { premeetingBackground } = state['features/dynamic-branding'];
return {
// For keeping backwards compat.: if we pass an empty hiddenPremeetingButtons
// array through external api, we have all prejoin buttons present on premeeting
// screen regardless of passed values into toolbarButtons config overwrite.
// If hiddenPremeetingButtons is missing, we hide the buttons according to
// toolbarButtons config overwrite.
_buttons: hiddenPremeetingButtons
? premeetingButtons
: premeetingButtons.filter(b => isButtonEnabled(b, toolbarButtons)),
_isPreCallTestEnabled: isPreCallTestEnabled(state),
_premeetingBackground: premeetingBackground,
_roomName: isRoomNameEnabled(state) ? getConferenceName(state) : ''
};
}
export default connect(mapStateToProps)(PreMeetingScreen);

View File

@@ -0,0 +1,99 @@
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { IReduxState } from '../../../../app/types';
import Avatar from '../../../avatar/components/Avatar';
import Video from '../../../media/components/web/Video';
import { getLocalParticipant } from '../../../participants/functions';
import { getDisplayName } from '../../../settings/functions.web';
import { getLocalVideoTrack } from '../../../tracks/functions.web';
export interface IProps {
/**
* Local participant id.
*/
_participantId: string;
/**
* Flag controlling whether the video should be flipped or not.
*/
flipVideo: boolean;
/**
* The name of the user that is about to join.
*/
name: string;
/**
* Flag signaling the visibility of camera preview.
*/
videoMuted: boolean;
/**
* The JitsiLocalTrack to display.
*/
videoTrack?: Object;
}
/**
* Component showing the video preview and device status.
*
* @param {IProps} props - The props of the component.
* @returns {ReactElement}
*/
function Preview(props: IProps) {
const { _participantId, flipVideo, name, videoMuted, videoTrack } = props;
const className = flipVideo ? 'flipVideoX' : '';
useEffect(() => {
APP.API.notifyPrejoinVideoVisibilityChanged(Boolean(!videoMuted && videoTrack));
}, [ videoMuted, videoTrack ]);
useEffect(() => {
APP.API.notifyPrejoinLoaded();
return () => APP.API.notifyPrejoinVideoVisibilityChanged(false);
}, []);
return (
<div id = 'preview'>
{!videoMuted && videoTrack
? (
<Video
className = { className }
id = 'prejoinVideo'
videoTrack = {{ jitsiTrack: videoTrack }} />
)
: (
<Avatar
className = 'premeeting-screen-avatar'
displayName = { name }
participantId = { _participantId }
size = { 200 } />
)}
</div>
);
}
/**
* 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 {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const name = getDisplayName(state);
const { id: _participantId } = getLocalParticipant(state) ?? {};
return {
_participantId: _participantId ?? '',
flipVideo: Boolean(state['features/base/settings'].localFlipX),
name,
videoMuted: ownProps.videoTrack ? ownProps.videoMuted : state['features/base/media'].video.muted,
videoTrack: ownProps.videoTrack || getLocalVideoTrack(state['features/base/tracks'])?.jitsiTrack
};
}
export default connect(_mapStateToProps)(Preview);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
const useStyles = makeStyles()(theme => {
return {
warning: {
bottom: 0,
color: theme.palette.text03,
display: 'flex',
justifyContent: 'center',
...theme.typography.bodyShortRegular,
marginBottom: theme.spacing(3),
marginTop: theme.spacing(2),
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
position: 'absolute',
width: '100%',
'@media (max-width: 720px)': {
position: 'relative'
}
}
};
});
const RecordingWarning = () => {
const { t } = useTranslation();
const { classes } = useStyles();
return (
<div className = { classes.warning }>
{t('prejoin.recordingWarning')}
</div>
);
};
export default RecordingWarning;

View File

@@ -0,0 +1,53 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../../app/types';
import Checkbox from '../../../ui/components/web/Checkbox';
import getUnsafeRoomText from '../../../util/getUnsafeRoomText.web';
import { setUnsafeRoomConsent } from '../../actions.web';
const useStyles = makeStyles()(theme => {
return {
warning: {
backgroundColor: theme.palette.warning01,
color: theme.palette.text04,
...theme.typography.bodyShortRegular,
padding: theme.spacing(3),
borderRadius: theme.shape.borderRadius,
marginBottom: theme.spacing(3)
},
consent: {
padding: `0 ${theme.spacing(3)}`,
'@media (max-width: 720px)': {
marginBottom: theme.spacing(3)
}
}
};
});
const UnsafeRoomWarning = () => {
const { t } = useTranslation();
const { classes } = useStyles();
const dispatch = useDispatch();
const { unsafeRoomConsent } = useSelector((state: IReduxState) => state['features/base/premeeting']);
const toggleConsent = useCallback(
() => dispatch(setUnsafeRoomConsent(!unsafeRoomConsent))
, [ unsafeRoomConsent, dispatch ]);
return (
<>
<div className = { classes.warning }>
{getUnsafeRoomText(t, 'prejoin')}
</div>
<Checkbox
checked = { unsafeRoomConsent }
className = { classes.consent }
label = { t('prejoin.unsafeRoomConsent') }
onChange = { toggleConsent } />
</>
);
};
export default UnsafeRoomWarning;

View File

@@ -0,0 +1,8 @@
export const CONNECTION_TYPE = {
FAILED: 'failed',
GOOD: 'good',
NON_OPTIMAL: 'nonOptimal',
NONE: 'none',
POOR: 'poor',
RUNNING: 'running'
};

View File

@@ -0,0 +1,271 @@
import { findIndex } from 'lodash-es';
import { IReduxState } from '../../app/types';
import { CONNECTION_TYPE } from './constants';
import logger from './logger';
import { IPreCallResult, PreCallTestStatus } from './types';
/**
* The avatar size to container size ration.
*/
const ratio = 1 / 3;
/**
* The max avatar size.
*/
const maxSize = 190;
/**
* The window limit height over which the avatar should have the default dimension.
*/
const upperHeightLimit = 760;
/**
* The window limit height under which the avatar should not be resized anymore.
*/
const lowerHeightLimit = 460;
/**
* The default top margin of the avatar.
*/
const defaultMarginTop = '10%';
/**
* The top margin of the avatar when its dimension is small.
*/
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.
*
* @param {number} height - The window height.
* @returns {{
* marginTop: string,
* size: number
* }}
*/
export function calculateAvatarDimensions(height: number) {
if (height > upperHeightLimit) {
return {
size: maxSize,
marginTop: defaultMarginTop
};
}
if (height > lowerHeightLimit) {
const diff = height - lowerHeightLimit;
const percent = diff * ratio;
const size = Math.floor(maxSize * percent / 100);
let marginTop = defaultMarginTop;
if (height < 600) {
marginTop = smallMarginTop;
}
return {
size,
marginTop
};
}
return {
size: 0,
marginTop: '0'
};
}
/**
* Returns the level based on a list of thresholds.
*
* @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}
*/
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<string> = [];
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,
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;
}

View File

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

View File

@@ -0,0 +1,42 @@
import ReducerRegistry from '../redux/ReducerRegistry';
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
};
/**
* Listen for actions which changes the state of known and used devices.
*
* @param {IDevicesState} state - The Redux state of the feature features/base/devices.
* @param {Object} action - Action object.
* @param {string} action.type - Type of action.
* @returns {IPreMeetingState}
*/
ReducerRegistry.register<IPreMeetingState>(
'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,
unsafeRoomConsent: action.consent
};
}
default:
return state;
}
});

View File

@@ -0,0 +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;
}