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,76 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveParticipantAudio, approveParticipantDesktop, approveParticipantVideo } from '../../../av-moderation/actions';
import { IconMic, IconScreenshare, IconVideo } from '../../../base/icons/svg';
import { MEDIA_TYPE, MediaType } from '../../../base/media/constants';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
interface IProps extends IButtonProps {
buttonType: MediaType;
}
/**
* Implements a React {@link Component} which displays a button that
* allows the moderator to request from a participant to mute themselves.
*
* @returns {JSX.Element}
*/
const AskToUnmuteButton = ({
buttonType,
notifyMode,
notifyClick,
participantID
}: IProps): JSX.Element => {
const dispatch = useDispatch();
const { t } = useTranslation();
const _onClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
if (buttonType === MEDIA_TYPE.AUDIO) {
dispatch(approveParticipantAudio(participantID));
} else if (buttonType === MEDIA_TYPE.VIDEO) {
dispatch(approveParticipantVideo(participantID));
} else if (buttonType === MEDIA_TYPE.SCREENSHARE) {
dispatch(approveParticipantDesktop(participantID));
}
}, [ buttonType, dispatch, notifyClick, notifyMode, participantID ]);
const text = useMemo(() => {
if (buttonType === MEDIA_TYPE.AUDIO) {
return t('participantsPane.actions.askUnmute');
} else if (buttonType === MEDIA_TYPE.VIDEO) {
return t('participantsPane.actions.allowVideo');
} else if (buttonType === MEDIA_TYPE.SCREENSHARE) {
return t('participantsPane.actions.allowDesktop');
}
return '';
}, [ buttonType ]);
const icon = useMemo(() => {
if (buttonType === MEDIA_TYPE.AUDIO) {
return IconMic;
} else if (buttonType === MEDIA_TYPE.VIDEO) {
return IconVideo;
} else if (buttonType === MEDIA_TYPE.SCREENSHARE) {
return IconScreenshare;
}
}, [ buttonType ]);
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { icon }
onClick = { _onClick }
testId = { `unmute-${buttonType}-${participantID}` }
text = { text } />
);
};
export default AskToUnmuteButton;

View File

@@ -0,0 +1,42 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { IconInfoCircle } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { renderConnectionStatus } from '../../actions.web';
import { IButtonProps } from '../../types';
/**
* Implements a React {@link Component} which displays a button that shows
* the connection status for the given participant.
*
* @returns {JSX.Element}
*/
const ConnectionStatusButton = ({
notifyClick,
notifyMode
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(e => {
e.stopPropagation();
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(renderConnectionStatus(true));
}, [ dispatch, notifyClick, notifyMode ]);
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.connectionInfo') }
icon = { IconInfoCircle }
onClick = { handleClick }
text = { t('videothumbnail.connectionInfo') } />
);
};
export default ConnectionStatusButton;

View File

@@ -0,0 +1,27 @@
import React, { useCallback } from 'react';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
const CustomOptionButton = (
{ icon: iconSrc, onClick, text }:
{
icon: string;
onClick: (e?: React.MouseEvent<Element, MouseEvent> | undefined) => void;
text: string;
}
) => {
const icon = useCallback(props => (<img
src = { iconSrc }
{ ...props } />), [ iconSrc ]);
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { icon }
onClick = { onClick }
text = { text } />
);
};
export default CustomOptionButton;

View File

@@ -0,0 +1,63 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { openDialog } from '../../../base/dialog/actions';
import { IconUsers } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import DemoteToVisitorDialog from './DemoteToVisitorDialog';
interface IProps extends IButtonProps {
/**
* Button text class name.
*/
className?: string;
/**
* Whether the icon should be hidden or not.
*/
noIcon?: boolean;
/**
* Click handler executed aside from the main action.
*/
onClick?: Function;
}
/**
* Implements a React {@link Component} which displays a button for demoting a participant to visitor.
*
* @returns {JSX.Element}
*/
export default function DemoteToVisitorButton({
className,
noIcon = false,
notifyClick,
notifyMode,
participantID
}: IProps): JSX.Element {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(openDialog(DemoteToVisitorDialog, { participantID }));
}, [ dispatch, notifyClick, notifyMode, participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.demote') }
className = { className || 'demotelink' } // can be used in tests
icon = { noIcon ? null : IconUsers }
id = { `demotelink_${participantID}` }
onClick = { handleClick }
text = { t('videothumbnail.demote') } />
);
}

View File

@@ -0,0 +1,40 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { DialogProps } from '../../../base/dialog/constants';
import Dialog from '../../../base/ui/components/web/Dialog';
import { demoteRequest } from '../../../visitors/actions';
interface IProps extends DialogProps {
/**
* The ID of the remote participant to be demoted.
*/
participantID: string;
}
/**
* Dialog to confirm a remote participant demote action.
*
* @returns {JSX.Element}
*/
export default function DemoteToVisitorDialog({ participantID }: IProps): JSX.Element {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleSubmit = useCallback(() => {
dispatch(demoteRequest(participantID));
}, [ dispatch, participantID ]);
return (
<Dialog
ok = {{ translationKey: 'dialog.confirm' }}
onSubmit = { handleSubmit }
titleKey = 'dialog.demoteParticipantTitle'>
<div>
{ t('dialog.demoteParticipantDialog') }
</div>
</Dialog>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import TogglePinToStageButton from '../../../../features/video-menu/components/web/TogglePinToStageButton';
import Avatar from '../../../base/avatar/components/Avatar';
import { IconPlay } from '../../../base/icons/svg';
import { isWhiteboardParticipant } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
import { stopSharedVideo } from '../../../shared-video/actions';
import { getParticipantMenuButtonsWithNotifyClick, showOverflowDrawer } from '../../../toolbox/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { setWhiteboardOpen } from '../../../whiteboard/actions';
import { WHITEBOARD_ID } from '../../../whiteboard/constants';
import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants';
interface IProps {
/**
* Class name for the context menu.
*/
className?: string;
/**
* Closes a drawer if open.
*/
closeDrawer?: () => void;
/**
* The participant for which the drawer is open.
* It contains the displayName & participantID.
*/
drawerParticipant?: {
displayName: string;
participantID: string;
};
/**
* Shared video local participant owner.
*/
localVideoOwner?: boolean;
/**
* Target elements against which positioning calculations are made.
*/
offsetTarget?: HTMLElement;
/**
* Callback for the mouse entering the component.
*/
onEnter?: (e?: React.MouseEvent) => void;
/**
* Callback for the mouse leaving the component.
*/
onLeave?: (e?: React.MouseEvent) => void;
/**
* Callback for making a selection in the menu.
*/
onSelect: (value?: boolean | React.MouseEvent) => void;
/**
* Participant reference.
*/
participant: IParticipant;
/**
* Whether or not the menu is displayed in the thumbnail remote video menu.
*/
thumbnailMenu?: boolean;
}
const FakeParticipantContextMenu = ({
className,
closeDrawer,
drawerParticipant,
localVideoOwner,
offsetTarget,
onEnter,
onLeave,
onSelect,
participant,
thumbnailMenu
}: IProps) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick);
const notifyClick = useCallback(
(buttonKey: string, participantId?: string) => {
const notifyMode = buttonsWithNotifyClick?.get(buttonKey);
if (!notifyMode) {
return;
}
APP.API.notifyParticipantMenuButtonClicked(
buttonKey,
participantId,
notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
);
}, [ buttonsWithNotifyClick ]);
const clickHandler = useCallback(() => onSelect(true), [ onSelect ]);
const _onStopSharedVideo = useCallback(() => {
clickHandler();
dispatch(stopSharedVideo());
}, [ stopSharedVideo ]);
const _onHideWhiteboard = useCallback(() => {
clickHandler();
dispatch(setWhiteboardOpen(false));
}, [ setWhiteboardOpen ]);
const _getActions = useCallback(() => {
if (isWhiteboardParticipant(participant)) {
return [ {
accessibilityLabel: t('toolbar.hideWhiteboard'),
icon: IconPlay,
onClick: _onHideWhiteboard,
text: t('toolbar.hideWhiteboard')
} ];
}
if (localVideoOwner) {
return [ {
accessibilityLabel: t('toolbar.stopSharedVideo'),
icon: IconPlay,
onClick: _onStopSharedVideo,
text: t('toolbar.stopSharedVideo')
} ];
}
}, [ localVideoOwner, participant.fakeParticipant ]);
return (
<ContextMenu
className = { className }
entity = { participant }
hidden = { thumbnailMenu ? false : undefined }
inDrawer = { thumbnailMenu && _overflowDrawer }
isDrawerOpen = { Boolean(drawerParticipant) }
offsetTarget = { offsetTarget }
onClick = { onSelect }
onDrawerClose = { thumbnailMenu ? onSelect : closeDrawer }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{!thumbnailMenu && _overflowDrawer && drawerParticipant && <ContextMenuItemGroup
actions = { [ {
accessibilityLabel: drawerParticipant.displayName,
customIcon: <Avatar
participantId = { drawerParticipant.participantID }
size = { 20 } />,
text: drawerParticipant.displayName
} ] } />}
<ContextMenuItemGroup
actions = { _getActions() }>
{isWhiteboardParticipant(participant) && (
<TogglePinToStageButton
key = 'pinToStage'
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.PIN_TO_STAGE, WHITEBOARD_ID) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.PIN_TO_STAGE) }
participantID = { WHITEBOARD_ID } />
)}
</ContextMenuItemGroup>
</ContextMenu>
);
};
export default FakeParticipantContextMenu;

View File

@@ -0,0 +1,125 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { updateSettings } from '../../../base/settings/actions';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
/**
* The type of the React {@code Component} props of {@link FlipLocalVideoButton}.
*/
interface IProps extends WithTranslation {
/**
* The current local flip x status.
*/
_localFlipX: boolean;
/**
* Button text class name.
*/
className: string;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Callback to execute when the button is clicked.
*/
notifyClick?: Function;
/**
* Notify mode for `participantMenuButtonClicked` event -
* whether to only notify or to also prevent button click routine.
*/
notifyMode?: string;
/**
* Click handler executed aside from the main action.
*/
onClick?: Function;
}
/**
* Implements a React {@link Component} which displays a button for flipping the local viedo.
*
* @augments Component
*/
class FlipLocalVideoButton extends PureComponent<IProps> {
/**
* Initializes a new {@code FlipLocalVideoButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {null|ReactElement}
*/
override render() {
const {
className,
t
} = this.props;
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.flip') }
className = 'fliplink'
id = 'flipLocalVideoButton'
onClick = { this._onClick }
text = { t('videothumbnail.flip') }
textClassName = { className } />
);
}
/**
* Flips the local video.
*
* @private
* @returns {void}
*/
_onClick() {
const { _localFlipX, dispatch, notifyClick, notifyMode, onClick } = this.props;
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
onClick?.();
dispatch(updateSettings({
localFlipX: !_localFlipX
}));
}
}
/**
* Maps (parts of) the Redux state to the associated {@code FlipLocalVideoButton}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { localFlipX } = state['features/base/settings'];
return {
_localFlipX: Boolean(localFlipX)
};
}
export default translate(connect(_mapStateToProps)(FlipLocalVideoButton));

View File

@@ -0,0 +1,56 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { IconModerator } from '../../../base/icons/svg';
import { PARTICIPANT_ROLE } from '../../../base/participants/constants';
import { getLocalParticipant, getParticipantById, isParticipantModerator } from '../../../base/participants/functions';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import GrantModeratorDialog from './GrantModeratorDialog';
/**
* Implements a React {@link Component} which displays a button for granting
* moderator to a participant.
*
* @returns {JSX.Element|null}
*/
const GrantModeratorButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element | null => {
const { t } = useTranslation();
const dispatch = useDispatch();
const localParticipant = useSelector(getLocalParticipant);
const targetParticipant = useSelector((state: IReduxState) => getParticipantById(state, participantID));
const visible = useMemo(() => Boolean(localParticipant?.role === PARTICIPANT_ROLE.MODERATOR)
&& !isParticipantModerator(targetParticipant), [ isParticipantModerator, localParticipant, targetParticipant ]);
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(openDialog(GrantModeratorDialog, { participantID }));
}, [ dispatch, notifyClick, notifyMode, participantID ]);
if (!visible) {
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('toolbar.accessibilityLabel.grantModerator') }
className = 'grantmoderatorlink'
icon = { IconModerator }
onClick = { handleClick }
text = { t('videothumbnail.grantModerator') } />
);
};
export default GrantModeratorButton;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import AbstractGrantModeratorDialog, { abstractMapStateToProps } from '../AbstractGrantModeratorDialog';
/**
* Dialog to confirm a grant moderator action.
*/
class GrantModeratorDialog extends AbstractGrantModeratorDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.Yes' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.grantModeratorTitle'>
<div>
{ this.props.t('dialog.grantModeratorDialog', { participantName: this.props.participantName }) }
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(GrantModeratorDialog));

View File

@@ -0,0 +1,124 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { updateSettings } from '../../../base/settings/actions';
import { getHideSelfView } from '../../../base/settings/functions';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
/**
* The type of the React {@code Component} props of {@link HideSelfViewVideoButton}.
*/
interface IProps extends WithTranslation {
/**
* Button text class name.
*/
className: string;
/**
* Whether or not to hide the self view.
*/
disableSelfView: boolean;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Callback to execute when the button is clicked.
*/
notifyClick?: Function;
/**
* Notify mode for `participantMenuButtonClicked` event -
* whether to only notify or to also prevent button click routine.
*/
notifyMode?: string;
/**
* Click handler executed aside from the main action.
*/
onClick?: Function;
}
/**
* Implements a React {@link Component} which displays a button for hiding the local video.
*
* @augments Component
*/
class HideSelfViewVideoButton extends PureComponent<IProps> {
/**
* Initializes a new {@code HideSelfViewVideoButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {null|ReactElement}
*/
override render() {
const {
className,
t
} = this.props;
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.hideSelfView') }
className = 'hideselflink'
id = 'hideselfviewButton'
onClick = { this._onClick }
text = { t('videothumbnail.hideSelfView') }
textClassName = { className } />
);
}
/**
* Hides the local video.
*
* @private
* @returns {void}
*/
_onClick() {
const { disableSelfView, dispatch, notifyClick, notifyMode, onClick } = this.props;
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
onClick?.();
dispatch(updateSettings({
disableSelfView: !disableSelfView
}));
}
}
/**
* Maps (parts of) the Redux state to the associated {@code HideSelfViewVideoButton}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
return {
disableSelfView: Boolean(getHideSelfView(state))
};
}
export default translate(connect(_mapStateToProps)(HideSelfViewVideoButton));

View File

@@ -0,0 +1,46 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { openDialog } from '../../../base/dialog/actions';
import { IconUserDeleted } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import KickRemoteParticipantDialog from './KickRemoteParticipantDialog';
/**
* Implements a React {@link Component} which displays a button for kicking out
* a participant from the conference.
*
* @returns {JSX.Element}
*/
const KickButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(openDialog(KickRemoteParticipantDialog, { participantID }));
}, [ dispatch, notifyClick, notifyMode, participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.kick') }
className = 'kicklink'
icon = { IconUserDeleted }
id = { `ejectlink_${participantID}` }
onClick = { handleClick }
text = { t('videothumbnail.kick') } />
);
};
export default KickButton;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import AbstractKickRemoteParticipantDialog from '../AbstractKickRemoteParticipantDialog';
/**
* Dialog to confirm a remote participant kick action.
*/
class KickRemoteParticipantDialog extends AbstractKickRemoteParticipantDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.kickParticipantButton' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.kickParticipantTitle'>
<div>
{ this.props.t('dialog.kickParticipantDialog') }
</div>
</Dialog>
);
}
}
export default translate(connect()(KickRemoteParticipantDialog));

View File

@@ -0,0 +1,305 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { batch, connect, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { IconDotsHorizontal } from '../../../base/icons/svg';
import { getLocalParticipant, getParticipantCount } from '../../../base/participants/functions';
import Popover from '../../../base/popover/components/Popover.web';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import { getHideSelfView } from '../../../base/settings/functions.web';
import { getLocalVideoTrack } from '../../../base/tracks/functions';
import Button from '../../../base/ui/components/web/Button';
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent';
import { THUMBNAIL_TYPE } from '../../../filmstrip/constants';
import { isStageFilmstripAvailable } from '../../../filmstrip/functions.web';
import { getParticipantMenuButtonsWithNotifyClick } from '../../../toolbox/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { renderConnectionStatus } from '../../actions.web';
import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants';
import ConnectionStatusButton from './ConnectionStatusButton';
import DemoteToVisitorButton from './DemoteToVisitorButton';
import FlipLocalVideoButton from './FlipLocalVideoButton';
import HideSelfViewVideoButton from './HideSelfViewVideoButton';
import TogglePinToStageButton from './TogglePinToStageButton';
/**
* The type of the React {@code Component} props of
* {@link LocalVideoMenuTriggerButton}.
*/
interface IProps {
/**
* The id of the local participant.
*/
_localParticipantId: string;
/**
* The position relative to the trigger the local video menu should display
* from.
*/
_menuPosition: string;
/**
* Whether to display the Popover as a drawer.
*/
_overflowDrawer: boolean;
/**
* Whether to render the connection info pane.
*/
_showConnectionInfo: boolean;
/**
* Shows/hides the local switch to visitor button.
*/
_showDemote: boolean;
/**
* Whether to render the hide self view button.
*/
_showHideSelfViewButton: boolean;
/**
* Shows/hides the local video flip button.
*/
_showLocalVideoFlipButton: boolean;
/**
* Whether to render the pin to stage button.
*/
_showPinToStage: boolean;
/**
* Whether or not the button should be visible.
*/
buttonVisible: boolean;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Hides popover.
*/
hidePopover?: Function;
/**
* Whether the popover is visible or not.
*/
popoverVisible?: boolean;
/**
* Shows popover.
*/
showPopover?: Function;
/**
* The type of the thumbnail.
*/
thumbnailType: string;
}
const useStyles = makeStyles()(() => {
return {
triggerButton: {
padding: '3px !important',
borderRadius: '4px',
'& svg': {
width: '18px',
height: '18px'
}
},
contextMenu: {
position: 'relative',
marginTop: 0,
right: 'auto',
padding: '0',
minWidth: '200px'
},
flipText: {
marginLeft: '36px'
}
};
});
const LocalVideoMenuTriggerButton = ({
_localParticipantId,
_menuPosition,
_overflowDrawer,
_showConnectionInfo,
_showDemote,
_showHideSelfViewButton,
_showLocalVideoFlipButton,
_showPinToStage,
buttonVisible,
dispatch,
hidePopover,
showPopover,
popoverVisible
}: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick);
const visitorsSupported = useSelector((state: IReduxState) => state['features/visitors'].supported);
const notifyClick = useCallback(
(buttonKey: string) => {
const notifyMode = buttonsWithNotifyClick?.get(buttonKey);
if (!notifyMode) {
return;
}
APP.API.notifyParticipantMenuButtonClicked(
buttonKey,
_localParticipantId,
notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
);
}, [ buttonsWithNotifyClick ]);
const _onPopoverOpen = useCallback(() => {
showPopover?.();
dispatch(setParticipantContextMenuOpen(true));
}, []);
const _onPopoverClose = useCallback(() => {
hidePopover?.();
batch(() => {
dispatch(setParticipantContextMenuOpen(false));
dispatch(renderConnectionStatus(false));
});
}, []);
const content = _showConnectionInfo
? <ConnectionIndicatorContent participantId = { _localParticipantId } />
: (
<ContextMenu
className = { classes.contextMenu }
hidden = { false }
inDrawer = { _overflowDrawer }>
<ContextMenuItemGroup>
{_showLocalVideoFlipButton
&& <FlipLocalVideoButton
className = { _overflowDrawer ? classes.flipText : '' }
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.FLIP_LOCAL_VIDEO) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.FLIP_LOCAL_VIDEO) }
onClick = { hidePopover } />
}
{_showHideSelfViewButton
&& <HideSelfViewVideoButton
className = { _overflowDrawer ? classes.flipText : '' }
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.HIDE_SELF_VIEW) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.HIDE_SELF_VIEW) }
onClick = { hidePopover } />
}
{
_showPinToStage && <TogglePinToStageButton
className = { _overflowDrawer ? classes.flipText : '' }
noIcon = { true }
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.PIN_TO_STAGE) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.PIN_TO_STAGE) }
onClick = { hidePopover }
participantID = { _localParticipantId } />
}
{
_showDemote && visitorsSupported && <DemoteToVisitorButton
className = { _overflowDrawer ? classes.flipText : '' }
noIcon = { true }
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.DEMOTE) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.DEMOTE) }
onClick = { hidePopover }
participantID = { _localParticipantId } />
}
{
isMobileBrowser() && <ConnectionStatusButton
// eslint-disable-next-line react/jsx-no-bind
notifyClick = { () => notifyClick(BUTTONS.CONN_STATUS) }
notifyMode = { buttonsWithNotifyClick?.get(BUTTONS.CONN_STATUS) }
participantID = { _localParticipantId } />
}
</ContextMenuItemGroup>
</ContextMenu>
);
return (
isMobileBrowser() || _showLocalVideoFlipButton || _showHideSelfViewButton
? <Popover
content = { content }
headingLabel = { t('dialog.localUserControls') }
id = 'local-video-menu-trigger'
onPopoverClose = { _onPopoverClose }
onPopoverOpen = { _onPopoverOpen }
position = { _menuPosition }
visible = { Boolean(popoverVisible) }>
{buttonVisible && !isMobileBrowser() && (
<Button
accessibilityLabel = { t('dialog.localUserControls') }
className = { classes.triggerButton }
icon = { IconDotsHorizontal }
size = 'small' />
)}
</Popover>
: null
);
};
/**
* Maps (parts of) the Redux state to the associated {@code LocalVideoMenuTriggerButton}'s props.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
const { thumbnailType } = ownProps;
const localParticipant = getLocalParticipant(state);
const { disableLocalVideoFlip, disableSelfDemote, disableSelfViewSettings } = state['features/base/config'];
const videoTrack = getLocalVideoTrack(state['features/base/tracks']);
const { overflowDrawer } = state['features/toolbox'];
const { showConnectionInfo } = state['features/base/connection'];
const showHideSelfViewButton = !disableSelfViewSettings && !getHideSelfView(state);
let _menuPosition;
switch (thumbnailType) {
case THUMBNAIL_TYPE.TILE:
_menuPosition = 'left-start';
break;
case THUMBNAIL_TYPE.VERTICAL:
_menuPosition = 'left-start';
break;
case THUMBNAIL_TYPE.HORIZONTAL:
_menuPosition = 'top-start';
break;
default:
_menuPosition = 'auto';
}
return {
_menuPosition,
_showDemote: !disableSelfDemote && getParticipantCount(state) > 1,
_showLocalVideoFlipButton: !disableLocalVideoFlip && videoTrack?.videoType !== 'desktop',
_showHideSelfViewButton: showHideSelfViewButton,
_overflowDrawer: overflowDrawer,
_localParticipantId: localParticipant?.id ?? '',
_showConnectionInfo: Boolean(showConnectionInfo),
_showPinToStage: isStageFilmstripAvailable(state)
};
}
export default connect(_mapStateToProps)(LocalVideoMenuTriggerButton);

View File

@@ -0,0 +1,56 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { getCurrentConference } from '../../../base/conference/functions';
import { IconRaiseHand } from '../../../base/icons/svg';
import { raiseHand } from '../../../base/participants/actions';
import { LOWER_HAND_MESSAGE } from '../../../base/tracks/constants';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
interface IProps {
/**
* The ID of the participant that's linked to the button.
*/
participantID?: String;
}
/**
* Implements a React {@link Component} which displays a button for notifying certain
* participant who raised hand to lower hand.
*
* @returns {JSX.Element}
*/
const LowerHandButton = ({
participantID = ''
}: IProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentConference = useSelector(getCurrentConference);
const accessibilityText = participantID
? t('participantsPane.actions.lowerHand')
: t('participantsPane.actions.lowerAllHands');
const handleClick = useCallback(() => {
if (!participantID) {
dispatch(raiseHand(false));
}
currentConference?.sendEndpointMessage(
participantID,
{
name: LOWER_HAND_MESSAGE
}
);
}, [ participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { accessibilityText }
icon = { IconRaiseHand }
onClick = { handleClick }
text = { accessibilityText } />
);
};
export default LowerHandButton;

View File

@@ -0,0 +1,65 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createRemoteVideoMenuButtonEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { rejectParticipantAudio } from '../../../av-moderation/actions';
import { IconMicSlash } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { isRemoteTrackMuted } from '../../../base/tracks/functions.any';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { muteRemote } from '../../actions.any';
import { IButtonProps } from '../../types';
/**
* Implements a React {@link Component} which displays a button for audio muting
* a participant in the conference.
*
* @returns {JSX.Element|null}
*/
const MuteButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element | null => {
const { t } = useTranslation();
const dispatch = useDispatch();
const tracks = useSelector((state: IReduxState) => state['features/base/tracks']);
const audioTrackMuted = useMemo(
() => isRemoteTrackMuted(tracks, MEDIA_TYPE.AUDIO, participantID),
[ isRemoteTrackMuted, participantID, tracks ]
);
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createRemoteVideoMenuButtonEvent(
'mute',
{
'participant_id': participantID
}));
dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO));
dispatch(rejectParticipantAudio(participantID));
}, [ dispatch, notifyClick, notifyMode, participantID, sendAnalytics ]);
if (audioTrackMuted) {
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('dialog.muteParticipantButton') }
className = 'mutelink'
icon = { IconMicSlash }
onClick = { handleClick }
text = { t('dialog.muteParticipantButton') } />
);
};
export default MuteButton;

View File

@@ -0,0 +1,67 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createRemoteVideoMenuButtonEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { IconScreenshare } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { isRemoteTrackMuted } from '../../../base/tracks/functions.any';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import MuteRemoteParticipantsDesktopDialog from './MuteRemoteParticipantsDesktopDialog';
/**
* Implements a React {@link Component} which displays a button for disabling
* the desktop share of a participant in the conference.
*
* @returns {JSX.Element|null}
*/
const MuteDesktopButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element | null => {
const { t } = useTranslation();
const dispatch = useDispatch();
const tracks = useSelector((state: IReduxState) => state['features/base/tracks']);
// TODO: review if we shouldn't be using isParticipantMediaMuted.
const trackMuted = useMemo(
() => isRemoteTrackMuted(tracks, MEDIA_TYPE.SCREENSHARE, participantID),
[ isRemoteTrackMuted, participantID, tracks ]
);
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createRemoteVideoMenuButtonEvent(
'desktop.mute.button',
{
'participant_id': participantID
}));
dispatch(openDialog(MuteRemoteParticipantsDesktopDialog, { participantID }));
}, [ dispatch, notifyClick, notifyClick, participantID, sendAnalytics ]);
if (trackMuted) {
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('participantsPane.actions.stopDesktop') }
className = 'mutedesktoplink'
icon = { IconScreenshare }
onClick = { handleClick }
text = { t('participantsPane.actions.stopDesktop') } />
);
};
export default MuteDesktopButton;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Switch from '../../../base/ui/components/web/Switch';
import AbstractMuteEveryoneDialog, { type IProps, abstractMapStateToProps }
from '../AbstractMuteEveryoneDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before muting all remote participants.
*
* @augments AbstractMuteEveryoneDialog
*/
class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.muteParticipantButton' }}
onSubmit = { this._onSubmit }
title = { this.props.title }>
<div className = 'mute-dialog'>
{this.state.content}
{
this.props.isModerationSupported
&& this.props.exclude.length === 0
&& !this.props.isEveryoneModerator && (
<>
<div className = 'separator-line' />
<div className = 'control-row'>
<label htmlFor = 'moderation-switch'>
{this.props.t('dialog.moderationAudioLabel')}
</label>
<Switch
checked = { !this.state.audioModerationEnabled }
id = 'moderation-switch'
onChange = { this._onToggleModeration } />
</div>
</>
)}
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteEveryoneDialog));

View File

@@ -0,0 +1,48 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { openDialog } from '../../../base/dialog/actions';
import { IconMicSlash } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import MuteEveryoneDialog from './MuteEveryoneDialog';
/**
* Implements a React {@link Component} which displays a button for audio muting
* every participant in the conference except the one with the given
* participantID.
*
* @returns {JSX.Element}
*/
const MuteEveryoneElseButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createToolbarEvent('mute.everyoneelse.pressed'));
dispatch(openDialog(MuteEveryoneDialog, { exclude: [ participantID ] }));
}, [ dispatch, notifyMode, notifyClick, participantID, sendAnalytics ]);
return (
<ContextMenuItem
accessibilityLabel = { t('toolbar.accessibilityLabel.muteEveryoneElse') }
icon = { IconMicSlash }
onClick = { handleClick }
text = { t('videothumbnail.domuteOthers') } />
);
};
export default MuteEveryoneElseButton;

View File

@@ -0,0 +1,48 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { openDialog } from '../../../base/dialog/actions';
import { IconScreenshare } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import MuteEveryonesDesktopDialog from './MuteEveryonesDesktopDialog';
/**
* Implements a React {@link Component} which displays a button for audio muting
* every participant in the conference except the one with the given
* participantID.
*
* @returns {JSX.Element}
*/
const MuteEveryoneElsesDesktopButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createToolbarEvent('mute.everyoneelsesdesktop.pressed'));
dispatch(openDialog(MuteEveryonesDesktopDialog, { exclude: [ participantID ] }));
}, [ notifyClick, notifyMode, participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { t('toolbar.accessibilityLabel.muteEveryoneElsesDesktopStream') }
icon = { IconScreenshare }
onClick = { handleClick }
text = { t('videothumbnail.domuteDesktopOfOthers') } />
);
};
export default MuteEveryoneElsesDesktopButton;

View File

@@ -0,0 +1,48 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { createToolbarEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { openDialog } from '../../../base/dialog/actions';
import { IconVideoOff } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import MuteEveryonesVideoDialog from './MuteEveryonesVideoDialog';
/**
* Implements a React {@link Component} which displays a button for audio muting
* every participant in the conference except the one with the given
* participantID.
*
* @returns {JSX.Element}
*/
const MuteEveryoneElsesVideoButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createToolbarEvent('mute.everyoneelsesvideo.pressed'));
dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ participantID ] }));
}, [ notifyClick, notifyMode, participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { t('toolbar.accessibilityLabel.muteEveryoneElsesVideoStream') }
icon = { IconVideoOff }
onClick = { handleClick }
text = { t('videothumbnail.domuteVideoOfOthers') } />
);
};
export default MuteEveryoneElsesVideoButton;

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Switch from '../../../base/ui/components/web/Switch';
import AbstractMuteEveryonesDesktopDialog, { type IProps, abstractMapStateToProps }
from '../AbstractMuteEveryonesDesktopDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before disabling all remote participants cameras.
*
* @augments AbstractMuteEveryonesDesktopDialog
*/
class MuteEveryonesDesktopDialog extends AbstractMuteEveryonesDesktopDialog<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.muteParticipantsDesktopButton' }}
onSubmit = { this._onSubmit }
title = { this.props.title }>
<div className = 'mute-dialog'>
{this.state.content}
{ this.props.isModerationSupported && this.props.exclude.length === 0 && (
<>
<div className = 'separator-line' />
<div className = 'control-row'>
<label htmlFor = 'moderation-switch'>
{this.props.t('dialog.moderationDesktopLabel')}
</label>
<Switch
checked = { !this.state.moderationEnabled }
id = 'moderation-switch'
onChange = { this._onToggleModeration } />
</div>
</>
)}
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteEveryonesDesktopDialog));

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import Switch from '../../../base/ui/components/web/Switch';
import AbstractMuteEveryonesVideoDialog, { type IProps, abstractMapStateToProps }
from '../AbstractMuteEveryonesVideoDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before disabling all remote participants cameras.
*
* @augments AbstractMuteEveryonesVideoDialog
*/
class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<IProps> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.muteParticipantsVideoButton' }}
onSubmit = { this._onSubmit }
title = { this.props.title }>
<div className = 'mute-dialog'>
{this.state.content}
{ this.props.isModerationSupported && this.props.exclude.length === 0 && (
<>
<div className = 'separator-line' />
<div className = 'control-row'>
<label htmlFor = 'moderation-switch'>
{this.props.t('dialog.moderationVideoLabel')}
</label>
<Switch
checked = { !this.state.moderationEnabled }
id = 'moderation-switch'
onChange = { this._onToggleModeration } />
</div>
</>
)}
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteEveryonesVideoDialog));

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import AbstractMuteRemoteParticipantsDesktopDialog, {
abstractMapStateToProps
} from '../AbstractMuteRemoteParticipantsDesktopDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before disabling a remote participants camera.
*
* @augments Component
*/
class MuteRemoteParticipantsDesktopDialog extends AbstractMuteRemoteParticipantsDesktopDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.muteParticipantsDesktopButton' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.muteParticipantsDesktopTitle'>
<div>
{this.props.t(this.props.isModerationOn
? 'dialog.muteParticipantsDesktopBodyModerationOn'
: 'dialog.muteParticipantsDesktopBody'
) }
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteRemoteParticipantsDesktopDialog));

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
import AbstractMuteRemoteParticipantsVideoDialog, {
abstractMapStateToProps
} from '../AbstractMuteRemoteParticipantsVideoDialog';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before disabling a remote participants camera.
*
* @augments Component
*/
class MuteRemoteParticipantsVideoDialog extends AbstractMuteRemoteParticipantsVideoDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<Dialog
ok = {{ translationKey: 'dialog.muteParticipantsVideoButton' }}
onSubmit = { this._onSubmit }
titleKey = 'dialog.muteParticipantsVideoTitle'>
<div>
{this.props.t(this.props.isVideoModerationOn
? 'dialog.muteParticipantsVideoBodyModerationOn'
: 'dialog.muteParticipantsVideoBody'
) }
</div>
</Dialog>
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteRemoteParticipantsVideoDialog));

View File

@@ -0,0 +1,66 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { createRemoteVideoMenuButtonEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { IconVideoOff } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { isRemoteTrackMuted } from '../../../base/tracks/functions.any';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
import MuteRemoteParticipantsVideoDialog from './MuteRemoteParticipantsVideoDialog';
/**
* Implements a React {@link Component} which displays a button for disabling
* the camera of a participant in the conference.
*
* @returns {JSX.Element|null}
*/
const MuteVideoButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element | null => {
const { t } = useTranslation();
const dispatch = useDispatch();
const tracks = useSelector((state: IReduxState) => state['features/base/tracks']);
const videoTrackMuted = useMemo(
() => isRemoteTrackMuted(tracks, MEDIA_TYPE.VIDEO, participantID),
[ isRemoteTrackMuted, participantID, tracks ]
);
const handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
sendAnalytics(createRemoteVideoMenuButtonEvent(
'video.mute.button',
{
'participant_id': participantID
}));
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { participantID }));
}, [ dispatch, notifyClick, notifyClick, participantID, sendAnalytics ]);
if (videoTrackMuted) {
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('participantsPane.actions.stopVideo') }
className = 'mutevideolink'
icon = { IconVideoOff }
onClick = { handleClick }
text = { t('participantsPane.actions.stopVideo') } />
);
};
export default MuteVideoButton;

View File

@@ -0,0 +1,388 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../../av-moderation/constants';
import { isSupported as isAvModerationSupported, isForceMuted } from '../../../av-moderation/functions';
import Avatar from '../../../base/avatar/components/Avatar';
import { isIosMobileBrowser, isMobileBrowser } from '../../../base/environment/utils';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { PARTICIPANT_ROLE } from '../../../base/participants/constants';
import { getLocalParticipant, hasRaisedHand, isPrivateChatEnabled } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import { isParticipantAudioMuted } from '../../../base/tracks/functions.any';
import ContextMenu from '../../../base/ui/components/web/ContextMenu';
import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuItemGroup';
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { IRoom } from '../../../breakout-rooms/types';
import { displayVerification } from '../../../e2ee/functions';
import { setVolume } from '../../../filmstrip/actions.web';
import { isStageFilmstripAvailable } from '../../../filmstrip/functions.web';
import { QUICK_ACTION_BUTTON } from '../../../participants-pane/constants';
import { getQuickActionButtonType } from '../../../participants-pane/functions';
import { requestRemoteControl, stopController } from '../../../remote-control/actions';
import { getParticipantMenuButtonsWithNotifyClick, showOverflowDrawer } from '../../../toolbox/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants';
import AskToUnmuteButton from './AskToUnmuteButton';
import ConnectionStatusButton from './ConnectionStatusButton';
import CustomOptionButton from './CustomOptionButton';
import DemoteToVisitorButton from './DemoteToVisitorButton';
import GrantModeratorButton from './GrantModeratorButton';
import KickButton from './KickButton';
import LowerHandButton from './LowerHandButton';
import MuteButton from './MuteButton';
import MuteDesktopButton from './MuteDesktopButton';
import MuteEveryoneElseButton from './MuteEveryoneElseButton';
import MuteEveryoneElsesDesktopButton from './MuteEveryoneElsesDesktopButton';
import MuteEveryoneElsesVideoButton from './MuteEveryoneElsesVideoButton';
import MuteVideoButton from './MuteVideoButton';
import PrivateMessageMenuButton from './PrivateMessageMenuButton';
import RemoteControlButton, { REMOTE_CONTROL_MENU_STATES } from './RemoteControlButton';
import SendToRoomButton from './SendToRoomButton';
import TogglePinToStageButton from './TogglePinToStageButton';
import VerifyParticipantButton from './VerifyParticipantButton';
import VolumeSlider from './VolumeSlider';
interface IProps {
/**
* Class name for the context menu.
*/
className?: string;
/**
* Closes a drawer if open.
*/
closeDrawer?: () => void;
/**
* The participant for which the drawer is open.
* It contains the displayName & participantID.
*/
drawerParticipant?: {
displayName: string;
participantID: string;
};
/**
* Target elements against which positioning calculations are made.
*/
offsetTarget?: HTMLElement;
/**
* Callback for the mouse entering the component.
*/
onEnter?: (e?: React.MouseEvent) => void;
/**
* Callback for the mouse leaving the component.
*/
onLeave?: (e?: React.MouseEvent) => void;
/**
* Callback for making a selection in the menu.
*/
onSelect: (value?: boolean | React.MouseEvent) => void;
/**
* Participant reference.
*/
participant: IParticipant;
/**
* The current state of the participant's remote control session.
*/
remoteControlState?: number;
/**
* Whether or not the menu is displayed in the thumbnail remote video menu.
*/
thumbnailMenu?: boolean;
}
const useStyles = makeStyles()(theme => {
return {
text: {
color: theme.palette.text02,
padding: '10px 16px',
height: '40px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
boxSizing: 'border-box'
}
};
});
const ParticipantContextMenu = ({
className,
closeDrawer,
drawerParticipant,
offsetTarget,
onEnter,
onLeave,
onSelect,
participant,
remoteControlState,
thumbnailMenu
}: IProps) => {
const dispatch: IStore['dispatch'] = useDispatch();
const { t } = useTranslation();
const { classes: styles } = useStyles();
const localParticipant = useSelector(getLocalParticipant);
const _isModerator = Boolean(localParticipant?.role === PARTICIPANT_ROLE.MODERATOR);
const _isVideoForceMuted = useSelector<IReduxState>(state =>
isForceMuted(participant, AVM_MEDIA_TYPE.VIDEO, state));
const _isDesktopForceMuted = useSelector<IReduxState>(state =>
isForceMuted(participant, AVM_MEDIA_TYPE.DESKTOP, state));
const _isAudioMuted = useSelector((state: IReduxState) => isParticipantAudioMuted(participant, state));
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
const { remoteVideoMenu = {}, disableRemoteMute, startSilent, customParticipantMenuButtons }
= useSelector((state: IReduxState) => state['features/base/config']);
const visitorsSupported = useSelector((state: IReduxState) => state['features/visitors'].supported);
const { disableDemote, disableKick, disableGrantModerator } = remoteVideoMenu;
const { participantsVolume } = useSelector((state: IReduxState) => state['features/filmstrip']);
const _volume = (participant?.local ?? true ? undefined
: participant?.id ? participantsVolume[participant?.id] : undefined) ?? 1;
const isBreakoutRoom = useSelector(isInBreakoutRoom);
const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state));
const raisedHands = hasRaisedHand(participant);
const stageFilmstrip = useSelector(isStageFilmstripAvailable);
const shouldDisplayVerification = useSelector((state: IReduxState) => displayVerification(state, participant?.id));
const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick);
const enablePrivateChat = useSelector((state: IReduxState) => isPrivateChatEnabled(participant, state));
const _currentRoomId = useSelector(getCurrentRoomId);
const _rooms: IRoom[] = Object.values(useSelector(getBreakoutRooms));
const _onVolumeChange = useCallback(value => {
dispatch(setVolume(participant.id, value));
}, [ setVolume, dispatch ]);
const _getCurrentParticipantId = useCallback(() => {
const drawer = _overflowDrawer && !thumbnailMenu;
return (drawer ? drawerParticipant?.participantID : participant?.id) ?? '';
}
, [ thumbnailMenu, _overflowDrawer, drawerParticipant, participant ]);
const notifyClick = useCallback(
(buttonKey: string) => {
const notifyMode = buttonsWithNotifyClick?.get(buttonKey);
if (!notifyMode) {
return;
}
APP.API.notifyParticipantMenuButtonClicked(
buttonKey,
_getCurrentParticipantId(),
notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY
);
}, [ buttonsWithNotifyClick, _getCurrentParticipantId ]);
const onBreakoutRoomButtonClick = useCallback(() => {
onSelect(true);
}, [ onSelect ]);
const isClickedFromParticipantPane = useMemo(
() => !_overflowDrawer && !thumbnailMenu,
[ _overflowDrawer, thumbnailMenu ]);
const quickActionButtonType = useSelector((state: IReduxState) =>
getQuickActionButtonType(participant, state));
const buttons: JSX.Element[] = [];
const buttons2: JSX.Element[] = [];
const showVolumeSlider = !startSilent
&& !isIosMobileBrowser()
&& (_overflowDrawer || thumbnailMenu)
&& typeof _volume === 'number'
&& !isNaN(_volume);
const getButtonProps = useCallback((key: string) => {
const notifyMode = buttonsWithNotifyClick?.get(key);
const shouldNotifyClick = typeof notifyMode !== 'undefined';
return {
key,
notifyMode,
notifyClick: shouldNotifyClick ? () => notifyClick(key) : undefined,
participantID: _getCurrentParticipantId()
};
}, [ _getCurrentParticipantId, buttonsWithNotifyClick, notifyClick ]);
if (_isModerator) {
if (isModerationSupported) {
if (_isAudioMuted && !participant.isSilent
&& !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ASK_TO_UNMUTE)) {
buttons.push(<AskToUnmuteButton
{ ...getButtonProps(BUTTONS.ASK_UNMUTE) }
buttonType = { MEDIA_TYPE.AUDIO } />
);
}
if (_isVideoForceMuted
&& !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ALLOW_VIDEO)) {
buttons.push(<AskToUnmuteButton
{ ...getButtonProps(BUTTONS.ALLOW_VIDEO) }
buttonType = { MEDIA_TYPE.VIDEO } />
);
}
if (_isDesktopForceMuted
&& !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ALLOW_DESKTOP)) {
buttons.push(<AskToUnmuteButton
{ ...getButtonProps(BUTTONS.ALLOW_DESKTOP) }
buttonType = { MEDIA_TYPE.SCREENSHARE } />
);
}
}
if (!disableRemoteMute && !participant.isSilent) {
if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.MUTE)) {
buttons.push(<MuteButton { ...getButtonProps(BUTTONS.MUTE) } />);
}
buttons.push(<MuteEveryoneElseButton { ...getButtonProps(BUTTONS.MUTE_OTHERS) } />);
if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.STOP_VIDEO)) {
buttons.push(<MuteVideoButton { ...getButtonProps(BUTTONS.MUTE_VIDEO) } />);
}
buttons.push(<MuteEveryoneElsesVideoButton { ...getButtonProps(BUTTONS.MUTE_OTHERS_VIDEO) } />);
if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.STOP_DESKTOP)) {
buttons.push(<MuteDesktopButton { ...getButtonProps(BUTTONS.MUTE_DESKTOP) } />);
}
buttons.push(<MuteEveryoneElsesDesktopButton { ...getButtonProps(BUTTONS.MUTE_OTHERS_DESKTOP) } />);
}
if (raisedHands) {
buttons2.push(<LowerHandButton { ...getButtonProps(BUTTONS.LOWER_PARTICIPANT_HAND) } />);
}
if (!disableGrantModerator && !isBreakoutRoom) {
buttons2.push(<GrantModeratorButton { ...getButtonProps(BUTTONS.GRANT_MODERATOR) } />);
}
if (!disableDemote && visitorsSupported && _isModerator) {
buttons2.push(<DemoteToVisitorButton { ...getButtonProps(BUTTONS.DEMOTE) } />);
}
if (!disableKick) {
buttons2.push(<KickButton { ...getButtonProps(BUTTONS.KICK) } />);
}
if (shouldDisplayVerification) {
buttons2.push(<VerifyParticipantButton { ...getButtonProps(BUTTONS.VERIFY) } />);
}
}
if (stageFilmstrip) {
buttons2.push(<TogglePinToStageButton { ...getButtonProps(BUTTONS.PIN_TO_STAGE) } />);
}
if (enablePrivateChat) {
buttons2.push(<PrivateMessageMenuButton { ...getButtonProps(BUTTONS.PRIVATE_MESSAGE) } />);
}
if (thumbnailMenu && isMobileBrowser()) {
buttons2.push(<ConnectionStatusButton { ...getButtonProps(BUTTONS.CONN_STATUS) } />);
}
if (thumbnailMenu && remoteControlState) {
const onRemoteControlToggle = useCallback(() => {
if (remoteControlState === REMOTE_CONTROL_MENU_STATES.STARTED) {
dispatch(stopController(true));
} else if (remoteControlState === REMOTE_CONTROL_MENU_STATES.NOT_STARTED) {
dispatch(requestRemoteControl(_getCurrentParticipantId()));
}
}, [ dispatch, remoteControlState, stopController, requestRemoteControl ]);
buttons2.push(<RemoteControlButton
{ ...getButtonProps(BUTTONS.REMOTE_CONTROL) }
onClick = { onRemoteControlToggle }
remoteControlState = { remoteControlState } />
);
}
if (customParticipantMenuButtons) {
customParticipantMenuButtons.forEach(
({ icon, id, text }) => {
buttons2.push(
<CustomOptionButton
icon = { icon }
key = { id }
// eslint-disable-next-line react/jsx-no-bind
onClick = { () => notifyClick(id) }
text = { text } />
);
}
);
}
const breakoutRoomsButtons: any = [];
if (!thumbnailMenu && _isModerator) {
_rooms.forEach(room => {
if (room.id !== _currentRoomId) {
breakoutRoomsButtons.push(
<SendToRoomButton
{ ...getButtonProps(BUTTONS.SEND_PARTICIPANT_TO_ROOM) }
key = { room.id }
onClick = { onBreakoutRoomButtonClick }
room = { room } />
);
}
});
}
return (
<ContextMenu
className = { className }
entity = { participant }
hidden = { thumbnailMenu ? false : undefined }
inDrawer = { thumbnailMenu && _overflowDrawer }
isDrawerOpen = { Boolean(drawerParticipant) }
offsetTarget = { offsetTarget }
onClick = { onSelect }
onDrawerClose = { thumbnailMenu ? onSelect : closeDrawer }
onMouseEnter = { onEnter }
onMouseLeave = { onLeave }>
{!thumbnailMenu && _overflowDrawer && drawerParticipant && <ContextMenuItemGroup
actions = { [ {
accessibilityLabel: drawerParticipant.displayName,
customIcon: <Avatar
participantId = { drawerParticipant.participantID }
size = { 20 } />,
text: drawerParticipant.displayName
} ] } />}
{buttons.length > 0 && (
<ContextMenuItemGroup>
{buttons}
</ContextMenuItemGroup>
)}
<ContextMenuItemGroup>
{buttons2}
</ContextMenuItemGroup>
{showVolumeSlider && (
<ContextMenuItemGroup>
<VolumeSlider
initialValue = { _volume }
key = 'volume-slider'
onChange = { _onVolumeChange } />
</ContextMenuItemGroup>
)}
{breakoutRoomsButtons.length > 0 && (
<ContextMenuItemGroup>
<div className = { styles.text }>
{t('breakoutRooms.actions.sendToBreakoutRoom')}
</div>
{breakoutRoomsButtons}
</ContextMenuItemGroup>
)}
</ContextMenu>
);
};
export default ParticipantContextMenu;

View File

@@ -0,0 +1,112 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import { CHAT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import { IconMessage } from '../../../base/icons/svg';
import { getParticipantById } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { openChat } from '../../../chat/actions.web';
import { isButtonEnabled } from '../../../toolbox/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
interface IProps extends IButtonProps, WithTranslation {
/**
* True if the private chat functionality is disabled, hence the button is not visible.
*/
_hidden: boolean;
/**
* The participant to send the message to.
*/
_participant?: IParticipant;
/**
* Redux dispatch function.
*/
dispatch: IStore['dispatch'];
}
/**
* A custom implementation of the PrivateMessageButton specialized for
* the web version of the remote video menu. When the web platform starts to use
* the {@code AbstractButton} component for the remote video menu, we can get rid
* of this component and use the generic button in the chat feature.
*/
class PrivateMessageMenuButton extends Component<IProps> {
/**
* Instantiates a new Component instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _hidden, t } = this.props;
if (_hidden) {
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('toolbar.accessibilityLabel.privateMessage') }
icon = { IconMessage }
onClick = { this._onClick }
text = { t('toolbar.privateMessage') } />
);
}
/**
* Callback to be invoked on pressing the button.
*
* @param {React.MouseEvent|undefined} e - The click event.
* @returns {void}
*/
_onClick() {
const { _participant, dispatch, notifyClick, notifyMode } = this.props;
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(openChat(_participant));
}
}
/**
* Maps part of the Redux store 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 enabled = getFeatureFlag(state, CHAT_ENABLED, true);
const { visible = enabled } = ownProps;
return {
_participant: getParticipantById(state, ownProps.participantID),
visible,
_hidden: typeof interfaceConfig !== 'undefined'
&& (interfaceConfig.DISABLE_PRIVATE_MESSAGES || !isButtonEnabled('chat', state))
};
}
export default translate(connect(_mapStateToProps)(PrivateMessageMenuButton));

View File

@@ -0,0 +1,148 @@
import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createRemoteVideoMenuButtonEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { translate } from '../../../base/i18n/functions';
import { IconRemoteControlStart, IconRemoteControlStop } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
// TODO: Move these enums into the store after further reactification of the
// non-react RemoteVideo component.
export const REMOTE_CONTROL_MENU_STATES = {
NOT_SUPPORTED: 0,
NOT_STARTED: 1,
REQUESTING: 2,
STARTED: 3
};
/**
* The type of the React {@code Component} props of {@link RemoteControlButton}.
*/
interface IProps extends WithTranslation {
/**
* Callback to execute when the button is clicked.
*/
notifyClick?: Function;
/**
* Notify mode for `participantMenuButtonClicked` event -
* whether to only notify or to also prevent button click routine.
*/
notifyMode?: string;
/**
* The callback to invoke when the component is clicked.
*/
onClick: (() => void) | null;
/**
* The ID of the participant linked to the onClick callback.
*/
participantID: string;
/**
* The current status of remote control. Should be a number listed in the
* enum REMOTE_CONTROL_MENU_STATES.
*/
remoteControlState: number;
}
/**
* Implements a React {@link Component} which displays a button showing the
* current state of remote control for a participant and can start or stop a
* remote control session.
*
* @augments Component
*/
class RemoteControlButton extends Component<IProps> {
/**
* Initializes a new {@code RemoteControlButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {null|ReactElement}
*/
override render() {
const { remoteControlState, t } = this.props;
let disabled = false, icon;
switch (remoteControlState) {
case REMOTE_CONTROL_MENU_STATES.NOT_STARTED:
icon = IconRemoteControlStart;
break;
case REMOTE_CONTROL_MENU_STATES.REQUESTING:
disabled = true;
icon = IconRemoteControlStart;
break;
case REMOTE_CONTROL_MENU_STATES.STARTED:
icon = IconRemoteControlStop;
break;
case REMOTE_CONTROL_MENU_STATES.NOT_SUPPORTED:
// Intentionally fall through.
default:
return null;
}
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.remoteControl') }
className = 'kicklink'
disabled = { disabled }
icon = { icon }
onClick = { this._onClick }
text = { t('videothumbnail.remoteControl') } />
);
}
/**
* Sends analytics event for pressing the button and executes the passed
* onClick handler.
*
* @private
* @returns {void}
*/
_onClick() {
const { notifyClick, notifyMode, onClick, participantID, remoteControlState } = this.props;
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
// TODO: What do we do in case the state is e.g. "requesting"?
if (remoteControlState === REMOTE_CONTROL_MENU_STATES.STARTED
|| remoteControlState === REMOTE_CONTROL_MENU_STATES.NOT_STARTED) {
const enable
= remoteControlState === REMOTE_CONTROL_MENU_STATES.NOT_STARTED;
sendAnalytics(createRemoteVideoMenuButtonEvent(
'remote.control.button',
{
enable,
'participant_id': participantID
}));
}
onClick?.();
}
}
export default translate(RemoteControlButton);

View File

@@ -0,0 +1,273 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { batch, connect } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState, IStore } from '../../../app/types';
import { isMobileBrowser } from '../../../base/environment/utils';
import { IconDotsHorizontal } from '../../../base/icons/svg';
import { getLocalParticipant, getParticipantById } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import Popover from '../../../base/popover/components/Popover.web';
import { setParticipantContextMenuOpen } from '../../../base/responsive-ui/actions';
import Button from '../../../base/ui/components/web/Button';
import ConnectionIndicatorContent from
'../../../connection-indicator/components/web/ConnectionIndicatorContent';
import { THUMBNAIL_TYPE } from '../../../filmstrip/constants';
import { renderConnectionStatus } from '../../actions.web';
import FakeParticipantContextMenu from './FakeParticipantContextMenu';
import ParticipantContextMenu from './ParticipantContextMenu';
import { REMOTE_CONTROL_MENU_STATES } from './RemoteControlButton';
/**
* The type of the React {@code Component} props of
* {@link RemoteVideoMenuTriggerButton}.
*/
interface IProps {
/**
* Whether the remote video context menu is disabled.
*/
_disabled: Boolean;
/**
* Shared video local participant owner.
*/
_localVideoOwner?: boolean;
/**
* The position relative to the trigger the remote menu should display
* from.
*/
_menuPosition: string;
/**
* Participant reference.
*/
_participant: IParticipant;
/**
* The ID for the participant on which the remote video menu will act.
*/
_participantDisplayName: string;
/**
* The current state of the participant's remote control session.
*/
_remoteControlState?: number;
/**
* Whether the popover should render the Connection Info stats.
*/
_showConnectionInfo: Boolean;
/**
* Whether or not the button should be visible.
*/
buttonVisible: boolean;
/**
* The redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Hides popover.
*/
hidePopover?: Function;
/**
* The ID for the participant on which the remote video menu will act.
*/
participantID: string;
/**
* Whether the popover is visible or not.
*/
popoverVisible?: boolean;
/**
* Shows popover.
*/
showPopover?: Function;
/**
* The type of the thumbnail.
*/
thumbnailType: string;
}
const useStyles = makeStyles()(() => {
return {
triggerButton: {
padding: '3px !important',
borderRadius: '4px',
'& svg': {
width: '18px',
height: '18px'
}
},
contextMenu: {
position: 'relative',
marginTop: 0,
right: 'auto',
marginRight: '4px',
marginBottom: '4px'
}
};
});
const RemoteVideoMenuTriggerButton = ({
_disabled,
_localVideoOwner,
_menuPosition,
_participant,
_participantDisplayName,
_remoteControlState,
_showConnectionInfo,
buttonVisible,
dispatch,
hidePopover,
participantID,
popoverVisible,
showPopover
}: IProps) => {
const { classes } = useStyles();
const { t } = useTranslation();
const _onPopoverOpen = useCallback(() => {
showPopover?.();
dispatch(setParticipantContextMenuOpen(true));
}, []);
const _onPopoverClose = useCallback(() => {
hidePopover?.();
batch(() => {
dispatch(setParticipantContextMenuOpen(false));
dispatch(renderConnectionStatus(false));
});
}, []);
// eslint-disable-next-line react/no-multi-comp
const _renderRemoteVideoMenu = () => {
const props = {
className: classes.contextMenu,
onSelect: _onPopoverClose,
participant: _participant,
thumbnailMenu: true
};
if (_participant?.fakeParticipant) {
return (
<FakeParticipantContextMenu
{ ...props }
localVideoOwner = { _localVideoOwner } />
);
}
return (
<ParticipantContextMenu
{ ...props }
remoteControlState = { _remoteControlState } />
);
};
let content;
if (_showConnectionInfo) {
content = <ConnectionIndicatorContent participantId = { participantID } />;
} else if (!_disabled) {
content = _renderRemoteVideoMenu();
}
if (!content) {
return null;
}
const username = _participantDisplayName;
return (
<Popover
content = { content }
headingLabel = { t('dialog.remoteUserControls', { username }) }
id = 'remote-video-menu-trigger'
onPopoverClose = { _onPopoverClose }
onPopoverOpen = { _onPopoverOpen }
position = { _menuPosition }
visible = { Boolean(popoverVisible) }>
{buttonVisible && !_disabled && (
!isMobileBrowser() && <Button
accessibilityLabel = { t('dialog.remoteUserControls', { username }) }
className = { classes.triggerButton }
icon = { IconDotsHorizontal }
size = 'small' />
)}
</Popover>
);
};
/**
* Maps (parts of) the Redux state to the associated {@code RemoteVideoMenuTriggerButton}'s props.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: Partial<IProps>) {
const { participantID, thumbnailType } = ownProps;
let _remoteControlState;
const localParticipantId = getLocalParticipant(state)?.id;
const participant = getParticipantById(state, participantID ?? '');
const _participantDisplayName = participant?.name;
const _isRemoteControlSessionActive = participant?.remoteControlSessionStatus ?? false;
const _supportsRemoteControl = participant?.supportsRemoteControl ?? false;
const { active, controller } = state['features/remote-control'];
const { requestedParticipant, controlled } = controller;
const activeParticipant = requestedParticipant || controlled;
const { showConnectionInfo } = state['features/base/connection'];
const { remoteVideoMenu } = state['features/base/config'];
const { ownerId } = state['features/shared-video'];
if (_supportsRemoteControl
&& ((!active && !_isRemoteControlSessionActive) || activeParticipant === participantID)) {
if (requestedParticipant === participantID) {
_remoteControlState = REMOTE_CONTROL_MENU_STATES.REQUESTING;
} else if (controlled) {
_remoteControlState = REMOTE_CONTROL_MENU_STATES.STARTED;
} else {
_remoteControlState = REMOTE_CONTROL_MENU_STATES.NOT_STARTED;
}
}
let _menuPosition;
switch (thumbnailType) {
case THUMBNAIL_TYPE.TILE:
_menuPosition = 'left-start';
break;
case THUMBNAIL_TYPE.VERTICAL:
_menuPosition = 'left-end';
break;
case THUMBNAIL_TYPE.HORIZONTAL:
_menuPosition = 'top';
break;
default:
_menuPosition = 'auto';
}
return {
_disabled: Boolean(remoteVideoMenu?.disabled),
_localVideoOwner: Boolean(ownerId === localParticipantId),
_menuPosition,
_participant: participant ?? { id: '' },
_participantDisplayName: _participantDisplayName ?? '',
_remoteControlState,
_showConnectionInfo: Boolean(showConnectionInfo)
};
}
export default connect(_mapStateToProps)(RemoteVideoMenuTriggerButton);

View File

@@ -0,0 +1,57 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { createBreakoutRoomsEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IconRingGroup } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
import { IRoom } from '../../../breakout-rooms/types';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
interface IProps extends IButtonProps {
/**
* Click handler.
*/
onClick?: Function;
/**
* The room to send the participant to.
*/
room: IRoom;
}
const SendToRoomButton = ({
notifyClick,
notifyMode,
onClick,
participantID,
room
}: IProps) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const _onClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
onClick?.();
sendAnalytics(createBreakoutRoomsEvent('send.participant.to.room'));
dispatch(sendParticipantToRoom(participantID, room.id));
}, [ dispatch, notifyClick, notifyMode, onClick, participantID, room, sendAnalytics ]);
const roomName = room.name || t('breakoutRooms.mainRoom');
return (
<ContextMenuItem
accessibilityLabel = { roomName }
icon = { IconRingGroup }
onClick = { _onClick }
text = { roomName } />
);
};
export default SendToRoomButton;

View File

@@ -0,0 +1,67 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { IconPin, IconPinned } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { togglePinStageParticipant } from '../../../filmstrip/actions.web';
import { getPinnedActiveParticipants } from '../../../filmstrip/functions.web';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
interface IProps extends IButtonProps {
/**
* Button text class name.
*/
className?: string;
/**
* Whether the icon should be hidden or not.
*/
noIcon?: boolean;
/**
* Click handler executed aside from the main action.
*/
onClick?: Function;
}
const TogglePinToStageButton = ({
className,
noIcon = false,
notifyClick,
notifyMode,
onClick,
participantID
}: IProps): JSX.Element => {
const dispatch = useDispatch();
const { t } = useTranslation();
const isActive = Boolean(useSelector(getPinnedActiveParticipants)
.find(p => p.participantId === participantID));
const _onClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(togglePinStageParticipant(participantID));
onClick?.();
}, [ dispatch, isActive, notifyClick, onClick, participantID ]);
const text = isActive
? t('videothumbnail.unpinFromStage')
: t('videothumbnail.pinToStage');
const icon = isActive ? IconPinned : IconPin;
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { noIcon ? null : icon }
onClick = { _onClick }
text = { text }
textClassName = { className } />
);
};
export default TogglePinToStageButton;

View File

@@ -0,0 +1,45 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { IconCheck } from '../../../base/icons/svg';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
import { startVerification } from '../../../e2ee/actions';
import { NOTIFY_CLICK_MODE } from '../../../toolbox/types';
import { IButtonProps } from '../../types';
/**
* Implements a React {@link Component} which displays a button that
* verifies the participant.
*
* @returns {JSX.Element}
*/
const VerifyParticipantButton = ({
notifyClick,
notifyMode,
participantID
}: IButtonProps): JSX.Element => {
const { t } = useTranslation();
const dispatch = useDispatch();
const _handleClick = useCallback(() => {
notifyClick?.();
if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) {
return;
}
dispatch(startVerification(participantID));
}, [ dispatch, notifyClick, notifyMode, participantID ]);
return (
<ContextMenuItem
accessibilityLabel = { t('videothumbnail.verify') }
className = 'verifylink'
icon = { IconCheck }
id = { `verifylink_${participantID}` }
// eslint-disable-next-line react/jsx-handler-names
onClick = { _handleClick }
text = { t('videothumbnail.verify') } />
);
};
export default VerifyParticipantButton;

View File

@@ -0,0 +1,111 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../base/icons/components/Icon';
import { IconVolumeUp } from '../../../base/icons/svg';
import { VOLUME_SLIDER_SCALE } from '../../constants';
/**
* The type of the React {@code Component} props of {@link VolumeSlider}.
*/
interface IProps {
/**
* The value of the audio slider should display at when the component first
* mounts. Changes will be stored in state. The value should be a number
* between 0 and 1.
*/
initialValue: number;
/**
* The callback to invoke when the audio slider value changes.
*/
onChange: Function;
}
const useStyles = makeStyles()(theme => {
return {
container: {
minHeight: '40px',
minWidth: '180px',
width: '100%',
boxSizing: 'border-box',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: '10px 16px',
'&:hover': {
backgroundColor: theme.palette.ui02
}
},
icon: {
minWidth: '20px',
marginRight: '16px',
position: 'relative'
},
sliderContainer: {
position: 'relative',
width: '100%'
},
slider: {
position: 'absolute',
width: '100%',
top: '50%',
transform: 'translate(0, -50%)'
}
};
});
const _onClick = (e: React.MouseEvent) => {
e.stopPropagation();
};
const VolumeSlider = ({
initialValue,
onChange
}: IProps) => {
const { classes, cx } = useStyles();
const { t } = useTranslation();
const [ volumeLevel, setVolumeLevel ] = useState((initialValue || 0) * VOLUME_SLIDER_SCALE);
const _onVolumeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newVolumeLevel = Number(event.currentTarget.value);
onChange(newVolumeLevel / VOLUME_SLIDER_SCALE);
setVolumeLevel(newVolumeLevel);
}, [ onChange ]);
return (
<div
aria-label = { t('volumeSlider') }
className = { cx('popupmenu__contents', classes.container) }
onClick = { _onClick }>
<span className = { classes.icon }>
<Icon
size = { 22 }
src = { IconVolumeUp } />
</span>
<div className = { classes.sliderContainer }>
<input
aria-valuemax = { VOLUME_SLIDER_SCALE }
aria-valuemin = { 0 }
aria-valuenow = { volumeLevel }
className = { cx('popupmenu__volume-slider', classes.slider) }
max = { VOLUME_SLIDER_SCALE }
min = { 0 }
onChange = { _onVolumeChange }
tabIndex = { 0 }
type = 'range'
value = { volumeLevel } />
</div>
</div>
);
};
export default VolumeSlider;