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,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;