This commit is contained in:
15
react/features/base/premeeting/actionTypes.ts
Normal file
15
react/features/base/premeeting/actionTypes.ts
Normal 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'
|
||||
67
react/features/base/premeeting/actions.web.ts
Normal file
67
react/features/base/premeeting/actions.web.ts
Normal 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
|
||||
};
|
||||
}
|
||||
242
react/features/base/premeeting/components/web/ActionButton.tsx
Normal file
242
react/features/base/premeeting/components/web/ActionButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
99
react/features/base/premeeting/components/web/Preview.tsx
Normal file
99
react/features/base/premeeting/components/web/Preview.tsx
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
8
react/features/base/premeeting/constants.ts
Normal file
8
react/features/base/premeeting/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CONNECTION_TYPE = {
|
||||
FAILED: 'failed',
|
||||
GOOD: 'good',
|
||||
NON_OPTIMAL: 'nonOptimal',
|
||||
NONE: 'none',
|
||||
POOR: 'poor',
|
||||
RUNNING: 'running'
|
||||
};
|
||||
271
react/features/base/premeeting/functions.ts
Normal file
271
react/features/base/premeeting/functions.ts
Normal 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;
|
||||
}
|
||||
3
react/features/base/premeeting/logger.ts
Normal file
3
react/features/base/premeeting/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getLogger } from '../logging/functions';
|
||||
|
||||
export default getLogger('features/base/premeeting');
|
||||
42
react/features/base/premeeting/reducer.web.ts
Normal file
42
react/features/base/premeeting/reducer.web.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
|
||||
25
react/features/base/premeeting/types.ts
Normal file
25
react/features/base/premeeting/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user