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,152 @@
import React, { useCallback, useRef } from 'react';
import { WithTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import { v4 as uuidv4 } from 'uuid';
import { translate } from '../../base/i18n/functions';
import Icon from '../../base/icons/components/Icon';
import { IconPlus } from '../../base/icons/svg';
import { type Image, VIRTUAL_BACKGROUND_TYPE } from '../constants';
import { resizeImage } from '../functions';
import logger from '../logger';
interface IProps extends WithTranslation {
/**
* Callback used to set the 'loading' state of the parent component.
*/
setLoading: Function;
/**
* Callback used to set the options.
*/
setOptions: Function;
/**
* Callback used to set the storedImages array.
*/
setStoredImages: Function;
/**
* If a label should be displayed alongside the button.
*/
showLabel: boolean;
/**
* A list of images locally stored.
*/
storedImages: Array<Image>;
}
const useStyles = makeStyles()(theme => {
return {
label: {
...theme.typography.bodyShortBold,
color: theme.palette.link01,
marginBottom: theme.spacing(3),
cursor: 'pointer',
display: 'flex',
alignItems: 'center'
},
addBackground: {
marginRight: theme.spacing(3),
'& svg': {
fill: `${theme.palette.link01} !important`
}
},
input: {
display: 'none'
}
};
});
/**
* Component used to upload an image.
*
* @param {Object} Props - The props of the component.
* @returns {React$Node}
*/
function UploadImageButton({
setLoading,
setOptions,
setStoredImages,
showLabel,
storedImages,
t
}: IProps) {
const { classes } = useStyles();
const uploadImageButton = useRef<HTMLInputElement>(null);
const uploadImageKeyPress = useCallback(e => {
if (uploadImageButton.current && (e.key === ' ' || e.key === 'Enter')) {
e.preventDefault();
uploadImageButton.current.click();
}
}, [ uploadImageButton.current ]);
const uploadImage = useCallback(async e => {
const imageFile = e.target.files;
if (imageFile.length === 0) {
return;
}
const reader = new FileReader();
reader.readAsDataURL(imageFile[0]);
reader.onload = async () => {
const url = await resizeImage(reader.result);
const uuId = uuidv4();
setStoredImages([
...storedImages,
{
id: uuId,
src: url
}
]);
setOptions({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
selectedThumbnail: uuId,
virtualSource: url
});
};
logger.info('New virtual background image uploaded!');
reader.onerror = () => {
setLoading(false);
logger.error('Failed to upload virtual image!');
};
}, [ storedImages ]);
return (
<>
{showLabel && <label
className = { classes.label }
htmlFor = 'file-upload'
onKeyPress = { uploadImageKeyPress }
tabIndex = { 0 } >
<Icon
className = { classes.addBackground }
size = { 24 }
src = { IconPlus } />
{t('virtualBackground.addBackground')}
</label>}
<input
accept = 'image/*'
className = { classes.input }
id = 'file-upload'
onChange = { uploadImage }
ref = { uploadImageButton }
role = 'button'
type = 'file' />
</>
);
}
export default translate(UploadImageButton);

View File

@@ -0,0 +1,77 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../app/types';
import { translate } from '../../base/i18n/functions';
import { IconImage } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { isScreenVideoShared } from '../../screen-share/functions';
import { openSettingsDialog } from '../../settings/actions';
import { SETTINGS_TABS } from '../../settings/constants';
import { checkBlurSupport, checkVirtualBackgroundEnabled } from '../functions';
/**
* The type of the React {@code Component} props of {@link VideoBackgroundButton}.
*/
interface IProps extends AbstractButtonProps {
/**
* True if the video background is blurred or false if it is not.
*/
_isBackgroundEnabled: boolean;
}
/**
* An abstract implementation of a button that toggles the video background dialog.
*/
class VideoBackgroundButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.selectBackground';
override icon = IconImage;
override label = 'toolbar.selectBackground';
override tooltip = 'toolbar.selectBackground';
/**
* Handles clicking / pressing the button, and toggles the virtual background dialog
* state accordingly.
*
* @protected
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
dispatch(openSettingsDialog(SETTINGS_TABS.VIRTUAL_BACKGROUND));
}
/**
* Returns {@code boolean} value indicating if the background effect is
* enabled or not.
*
* @protected
* @returns {boolean}
*/
override _isToggled() {
return this.props._isBackgroundEnabled;
}
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code VideoBackgroundButton} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{
* _isBackgroundEnabled: boolean
* }}
*/
function _mapStateToProps(state: IReduxState) {
return {
_isBackgroundEnabled: Boolean(state['features/virtual-background'].backgroundEffectEnabled),
visible: checkBlurSupport()
&& !isScreenVideoShared(state)
&& checkVirtualBackgroundEnabled(state)
};
}
export default translate(connect(_mapStateToProps)(VideoBackgroundButton));

View File

@@ -0,0 +1,305 @@
import { Theme } from '@mui/material';
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { withStyles } from 'tss-react/mui';
import { IStore } from '../../app/types';
import { hideDialog } from '../../base/dialog/actions';
import { translate } from '../../base/i18n/functions';
import { Video } from '../../base/media/components/index';
import { equals } from '../../base/redux/functions';
import { createLocalTracksF } from '../../base/tracks/functions';
import Spinner from '../../base/ui/components/web/Spinner';
import { showWarningNotification } from '../../notifications/actions';
import { NOTIFICATION_TIMEOUT_TYPE } from '../../notifications/constants';
import { toggleBackgroundEffect } from '../actions';
import logger from '../logger';
import { IVirtualBackground } from '../reducer';
/**
* The type of the React {@code PureComponent} props of {@link VirtualBackgroundPreview}.
*/
export interface IProps extends WithTranslation {
/**
* An object containing the CSS classes.
*/
classes?: Partial<Record<keyof ReturnType<typeof styles>, string>>;
/**
* The redux {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* Dialog callback that indicates if the background preview was loaded.
*/
loadedPreview: Function;
/**
* Represents the virtual background set options.
*/
options: IVirtualBackground;
/**
* The id of the selected video device.
*/
selectedVideoInputId: string;
}
/**
* The type of the React {@code Component} state of {@link VirtualBackgroundPreview}.
*/
interface IState {
/**
* Activate the selected device camera only.
*/
jitsiTrack: Object | null;
/**
* Loader activated on setting virtual background.
*/
loading: boolean;
/**
* Flag that indicates if the local track was loaded.
*/
localTrackLoaded: boolean;
}
/**
* Creates the styles for the component.
*
* @param {Object} theme - The current UI theme.
*
* @returns {Object}
*/
const styles = (theme: Theme) => {
return {
virtualBackgroundPreview: {
height: 'auto',
width: '100%',
overflow: 'hidden',
marginBottom: theme.spacing(3),
zIndex: 2,
borderRadius: '3px',
backgroundColor: theme.palette.uiBackground,
position: 'relative' as const
},
previewLoader: {
height: '220px',
'& svg': {
position: 'absolute' as const,
top: '40%',
left: '45%'
}
},
previewVideo: {
height: '100%',
width: '100%',
objectFit: 'cover' as const
},
error: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '220px',
position: 'relative' as const
}
};
};
/**
* Implements a React {@link PureComponent} which displays the virtual
* background preview.
*
* @augments PureComponent
*/
class VirtualBackgroundPreview extends PureComponent<IProps, IState> {
_componentWasUnmounted: boolean;
/**
* Initializes a new {@code VirtualBackgroundPreview} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
loading: false,
localTrackLoaded: false,
jitsiTrack: null
};
}
/**
* Destroys the jitsiTrack object.
*
* @param {Object} jitsiTrack - The track that needs to be disposed.
* @returns {Promise<void>}
*/
_stopStream(jitsiTrack: any) {
if (jitsiTrack) {
jitsiTrack.dispose();
}
}
/**
* Creates and updates the track data.
*
* @returns {void}
*/
async _setTracks() {
try {
this.setState({ loading: true });
const [ jitsiTrack ] = await createLocalTracksF({
cameraDeviceId: this.props.selectedVideoInputId,
devices: [ 'video' ]
});
this.setState({ localTrackLoaded: true });
// In case the component gets unmounted before the tracks are created
// avoid a leak by not setting the state
if (this._componentWasUnmounted) {
this._stopStream(jitsiTrack);
return;
}
this.setState({
jitsiTrack,
loading: false
});
this.props.loadedPreview(true);
} catch (error) {
this.props.dispatch(hideDialog());
this.props.dispatch(
showWarningNotification({
titleKey: 'virtualBackground.backgroundEffectError',
descriptionKey: 'deviceError.cameraError'
}, NOTIFICATION_TIMEOUT_TYPE.LONG)
);
logger.error('Failed to access camera device. Error on apply background effect.');
return;
}
}
/**
* Apply background effect on video preview.
*
* @returns {Promise}
*/
async _applyBackgroundEffect() {
this.setState({ loading: true });
this.props.loadedPreview(false);
await this.props.dispatch(toggleBackgroundEffect(this.props.options, this.state.jitsiTrack));
this.props.loadedPreview(true);
this.setState({ loading: false });
}
/**
* Apply video preview loader.
*
* @returns {Promise}
*/
_loadVideoPreview() {
const classes = withStyles.getClasses(this.props);
return (
<div className = { classes.previewLoader }>
<Spinner size = 'large' />
</div>
);
}
/**
* Renders a preview entry.
*
* @param {Object} data - The track data.
* @returns {React$Node}
*/
_renderPreviewEntry(data: Object) {
const { t } = this.props;
const classes = withStyles.getClasses(this.props);
if (this.state.loading) {
return this._loadVideoPreview();
}
if (!data) {
return (
<div className = { classes.error }>{t('deviceSelection.previewUnavailable')}</div>
);
}
return (
<Video
className = { classes.previewVideo }
id = 'virtual_background_preview'
playsinline = { true }
videoTrack = {{ jitsiTrack: data }} />
);
}
/**
* Implements React's {@link Component#componentDidMount}.
*
* @inheritdoc
*/
override componentDidMount() {
this._setTracks();
}
/**
* Implements React's {@link Component#componentWillUnmount}.
*
* @inheritdoc
*/
override componentWillUnmount() {
this._componentWasUnmounted = true;
this._stopStream(this.state.jitsiTrack);
}
/**
* Implements React's {@link Component#componentDidUpdate}.
*
* @inheritdoc
*/
override async componentDidUpdate(prevProps: IProps) {
if (!equals(this.props.selectedVideoInputId, prevProps.selectedVideoInputId)) {
this._setTracks();
}
if (!equals(this.props.options, prevProps.options) && this.state.localTrackLoaded) {
this._applyBackgroundEffect();
}
}
/**
* Implements React's {@link Component#render}.
*
* @inheritdoc
*/
override render() {
const { jitsiTrack } = this.state;
const classes = withStyles.getClasses(this.props);
return (
<div className = { classes.virtualBackgroundPreview }>
{jitsiTrack
? this._renderPreviewEntry(jitsiTrack)
: this._loadVideoPreview()
}</div>
);
}
}
export default translate(connect()(withStyles(VirtualBackgroundPreview, styles)));

View File

@@ -0,0 +1,503 @@
// @ts-ignore
import { jitsiLocalStorage } from '@jitsi/js-utils/jitsi-local-storage';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import { safeJsonParse } from '@jitsi/js-utils/json';
import React, { useCallback, useEffect, useState } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../app/types';
import { translate } from '../../base/i18n/functions';
import Icon from '../../base/icons/components/Icon';
import { IconCloseLarge } from '../../base/icons/svg';
import Tooltip from '../../base/tooltip/components/Tooltip';
import Spinner from '../../base/ui/components/web/Spinner';
import { BACKGROUNDS_LIMIT, IMAGES, type Image, VIRTUAL_BACKGROUND_TYPE } from '../constants';
import { toDataURL } from '../functions';
import logger from '../logger';
import { IVirtualBackground } from '../reducer';
import UploadImageButton from './UploadImageButton';
import VirtualBackgroundPreview from './VirtualBackgroundPreview';
/* eslint-enable lines-around-comment */
interface IProps extends WithTranslation {
/**
* The list of Images to choose from.
*/
_images: Array<Image>;
/**
* If the upload button should be displayed or not.
*/
_showUploadButton: boolean;
/**
* The redux {@code dispatch} function.
*/
dispatch: IStore['dispatch'];
/**
* Options change handler.
*/
onOptionsChange: Function;
/**
* Virtual background options.
*/
options: IVirtualBackground;
/**
* Returns the selected thumbnail identifier.
*/
selectedThumbnail: string;
/**
* The id of the selected video device.
*/
selectedVideoInputId: string;
}
const onError = (event: any) => {
event.target.style.display = 'none';
};
const useStyles = makeStyles()(theme => {
return {
virtualBackgroundLoading: {
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '50px'
},
container: {
width: '100%',
display: 'flex',
flexDirection: 'column'
},
thumbnailContainer: {
width: '100%',
display: 'inline-grid',
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
gap: theme.spacing(1),
'@media (min-width: 608px) and (max-width: 712px)': {
gridTemplateColumns: '1fr 1fr 1fr 1fr'
},
'@media (max-width: 607px)': {
gridTemplateColumns: '1fr 1fr 1fr',
gap: theme.spacing(2)
}
},
thumbnail: {
height: '54px',
width: '100%',
borderRadius: '4px',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
...theme.typography.labelBold,
color: theme.palette.text01,
objectFit: 'cover',
[[ '&:hover', '&:focus' ] as any]: {
opacity: 0.5,
cursor: 'pointer',
'& ~ .delete-image-icon': {
display: 'block'
}
},
'@media (max-width: 607px)': {
height: '70px'
}
},
selectedThumbnail: {
border: `2px solid ${theme.palette.action01Hover}`
},
noneThumbnail: {
backgroundColor: theme.palette.ui04
},
slightBlur: {
boxShadow: 'inset 0 0 12px #000000',
background: '#a4a4a4'
},
blur: {
boxShadow: 'inset 0 0 12px #000000',
background: '#7e8287'
},
storedImageContainer: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
'&:focus-within .delete-image-container': {
display: 'block'
}
},
deleteImageIcon: {
position: 'absolute',
top: '3px',
right: '3px',
background: theme.palette.ui03,
borderRadius: '3px',
cursor: 'pointer',
display: 'none',
'@media (max-width: 607px)': {
display: 'block',
padding: '3px'
},
[[ '&:hover', '&:focus' ] as any]: {
display: 'block'
}
}
};
});
/**
* Renders virtual background dialog.
*
* @returns {ReactElement}
*/
function VirtualBackgrounds({
_images,
_showUploadButton,
onOptionsChange,
options,
selectedVideoInputId,
t
}: IProps) {
const { classes, cx } = useStyles();
const [ previewIsLoaded, setPreviewIsLoaded ] = useState(false);
const localImages = jitsiLocalStorage.getItem('virtualBackgrounds');
const [ storedImages, setStoredImages ] = useState<Array<Image>>((localImages && safeJsonParse(localImages)) || []);
const [ loading, setLoading ] = useState(false);
const deleteStoredImage = useCallback(e => {
const imageId = e.currentTarget.getAttribute('data-imageid');
setStoredImages(storedImages.filter(item => item.id !== imageId));
}, [ storedImages ]);
const deleteStoredImageKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
deleteStoredImage(e);
}
}, [ deleteStoredImage ]);
/**
* Updates stored images on local storage.
*/
useEffect(() => {
try {
jitsiLocalStorage.setItem('virtualBackgrounds', JSON.stringify(storedImages));
} catch (err) {
// Preventing localStorage QUOTA_EXCEEDED_ERR
err && setStoredImages(storedImages.slice(1));
}
if (storedImages.length === BACKGROUNDS_LIMIT) {
setStoredImages(storedImages.slice(1));
}
}, [ storedImages ]);
const enableBlur = useCallback(async () => {
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
blurValue: 25,
selectedThumbnail: 'blur'
});
logger.info('"Blur" option set for virtual background preview!');
}, []);
const enableBlurKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
enableBlur();
}
}, [ enableBlur ]);
const enableSlideBlur = useCallback(async () => {
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.BLUR,
blurValue: 8,
selectedThumbnail: 'slight-blur'
});
logger.info('"Slight-blur" option set for virtual background preview!');
}, []);
const enableSlideBlurKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
enableSlideBlur();
}
}, [ enableSlideBlur ]);
const removeBackground = useCallback(async () => {
onOptionsChange({
backgroundEffectEnabled: false,
selectedThumbnail: 'none'
});
logger.info('"None" option set for virtual background preview!');
}, []);
const removeBackgroundKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
removeBackground();
}
}, [ removeBackground ]);
const setUploadedImageBackground = useCallback(async e => {
const imageId = e.currentTarget.getAttribute('data-imageid');
const image = storedImages.find(img => img.id === imageId);
if (image) {
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
selectedThumbnail: image.id,
virtualSource: image.src
});
logger.info('Uploaded image set for virtual background preview!');
}
}, [ storedImages ]);
const setImageBackground = useCallback(async e => {
const imageId = e.currentTarget.getAttribute('data-imageid');
const image = _images.find(img => img.id === imageId);
if (image) {
try {
const url = await toDataURL(image.src);
onOptionsChange({
backgroundEffectEnabled: true,
backgroundType: VIRTUAL_BACKGROUND_TYPE.IMAGE,
selectedThumbnail: image.id,
virtualSource: url
});
logger.info('Image set for virtual background preview!');
} catch (err) {
logger.error('Could not fetch virtual background image:', err);
}
setLoading(false);
}
}, []);
const setImageBackgroundKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setImageBackground(e);
}
}, [ setImageBackground ]);
const setUploadedImageBackgroundKeyPress = useCallback(e => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setUploadedImageBackground(e);
}
}, [ setUploadedImageBackground ]);
const loadedPreviewState = useCallback(async loaded => {
await setPreviewIsLoaded(loaded);
}, []);
// create a full list of {backgroundId: backgroundLabel} to easily retrieve label of selected background
const labelsMap: Record<string, string> = {
none: t('virtualBackground.none'),
'slight-blur': t('virtualBackground.slightBlur'),
blur: t('virtualBackground.blur'),
..._images.reduce<Record<string, string>>((acc, image) => {
acc[image.id] = image.tooltip ? t(`virtualBackground.${image.tooltip}`) : '';
return acc;
}, {}),
...storedImages.reduce<Record<string, string>>((acc, image, index) => {
acc[image.id] = t('virtualBackground.uploadedImage', { index: index + 1 });
return acc;
}, {})
};
const currentBackgroundLabel = options?.selectedThumbnail ? labelsMap[options.selectedThumbnail] : labelsMap.none;
const isThumbnailSelected = useCallback(thumbnail => options?.selectedThumbnail === thumbnail, [ options ]);
const getSelectedThumbnailClass = useCallback(
thumbnail => isThumbnailSelected(thumbnail) && classes.selectedThumbnail, [ isThumbnailSelected, options ]
);
return (
<>
<VirtualBackgroundPreview
loadedPreview = { loadedPreviewState }
options = { options }
selectedVideoInputId = { selectedVideoInputId } />
{loading ? (
<div className = { classes.virtualBackgroundLoading }>
<Spinner />
</div>
) : (
<div className = { classes.container }>
<span
className = 'sr-only'
id = 'virtual-background-current-info'>
{ t('virtualBackground.accessibilityLabel.currentBackground', {
background: currentBackgroundLabel
}) }
</span>
{_showUploadButton
&& <UploadImageButton
setLoading = { setLoading }
setOptions = { onOptionsChange }
setStoredImages = { setStoredImages }
showLabel = { previewIsLoaded }
storedImages = { storedImages } />}
<div
aria-describedby = 'virtual-background-current-info'
aria-label = { t('virtualBackground.accessibilityLabel.selectBackground') }
className = { classes.thumbnailContainer }
role = 'radiogroup'
tabIndex = { -1 }>
<Tooltip
content = { t('virtualBackground.removeBackground') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('none') }
aria-label = { t('virtualBackground.removeBackground') }
className = { cx(classes.thumbnail, classes.noneThumbnail,
getSelectedThumbnailClass('none')) }
onClick = { removeBackground }
onKeyPress = { removeBackgroundKeyPress }
role = 'radio'
tabIndex = { 0 } >
{t('virtualBackground.none')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.slightBlur') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('slight-blur') }
aria-label = { t('virtualBackground.slightBlur') }
className = { cx(classes.thumbnail, classes.slightBlur,
getSelectedThumbnailClass('slight-blur')) }
onClick = { enableSlideBlur }
onKeyPress = { enableSlideBlurKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.slightBlur')}
</div>
</Tooltip>
<Tooltip
content = { t('virtualBackground.blur') }
position = { 'top' }>
<div
aria-checked = { isThumbnailSelected('blur') }
aria-label = { t('virtualBackground.blur') }
className = { cx(classes.thumbnail, classes.blur,
getSelectedThumbnailClass('blur')) }
onClick = { enableBlur }
onKeyPress = { enableBlurKeyPress }
role = 'radio'
tabIndex = { 0 }>
{t('virtualBackground.blur')}
</div>
</Tooltip>
{_images.map(image => (
<Tooltip
content = { (image.tooltip && t(`virtualBackground.${image.tooltip}`)) ?? '' }
key = { image.id }
position = { 'top' }>
<img
alt = { image.tooltip && t(`virtualBackground.${image.tooltip}`) }
aria-checked = { isThumbnailSelected(image.id) }
className = { cx(classes.thumbnail,
getSelectedThumbnailClass(image.id)) }
data-imageid = { image.id }
onClick = { setImageBackground }
onError = { onError }
onKeyPress = { setImageBackgroundKeyPress }
role = 'radio'
src = { image.src }
tabIndex = { 0 } />
</Tooltip>
))}
{storedImages.map((image, index) => (
<div
className = { classes.storedImageContainer }
key = { image.id }>
<img
alt = { t('virtualBackground.uploadedImage', { index: index + 1 }) }
aria-checked = { isThumbnailSelected(image.id) }
className = { cx(classes.thumbnail,
getSelectedThumbnailClass(image.id)) }
data-imageid = { image.id }
onClick = { setUploadedImageBackground }
onError = { onError }
onKeyPress = { setUploadedImageBackgroundKeyPress }
role = 'radio'
src = { image.src }
tabIndex = { 0 } />
<Icon
ariaLabel = { t('virtualBackground.deleteImage') }
className = { cx(classes.deleteImageIcon, 'delete-image-icon') }
data-imageid = { image.id }
onClick = { deleteStoredImage }
onKeyPress = { deleteStoredImageKeyPress }
role = 'button'
size = { 16 }
src = { IconCloseLarge }
tabIndex = { 0 } />
</div>
))}
</div>
</div>
)}
</>
);
}
/**
* Maps (parts of) the redux state to the associated props for the
* {@code VirtualBackground} component.
*
* @param {Object} state - The Redux state.
* @private
* @returns {{Props}}
*/
function _mapStateToProps(state: IReduxState) {
const dynamicBrandingImages = state['features/dynamic-branding'].virtualBackgrounds;
const hasBrandingImages = Boolean(dynamicBrandingImages.length);
return {
_images: (hasBrandingImages && dynamicBrandingImages) || IMAGES,
_showUploadButton: !state['features/base/config'].disableAddingBackgroundImages
};
}
export default connect(_mapStateToProps)(translate(VirtualBackgrounds));