diff --git a/config.js b/config.js index ea9a54080e..3c4ecb406b 100644 --- a/config.js +++ b/config.js @@ -826,6 +826,42 @@ var config = { // 'whiteboard', // ], + // Participant context menu buttons which have their click/tap event exposed through the API on + // `participantMenuButtonClick`. Passing a string for the button key will + // prevent execution of the click/tap routine; passing an object with `key` and + // `preventExecution` flag on false will not prevent execution of the click/tap + // routine. Below array with mixed mode for passing the buttons. + // participantMenuButtonsWithNotifyClick: [ + // 'allow-video', + // { + // key: 'ask-unmute', + // preventExecution: false + // }, + // 'conn-status', + // 'flip-local-video', + // 'grant-moderator', + // { + // key: 'kick', + // preventExecution: true + // }, + // { + // key: 'hide-self-view', + // preventExecution: false + // }, + // 'mute', + // 'mute-others', + // 'mute-others-video', + // 'mute-video', + // 'pinToStage', + // 'privateMessage', + // { + // key: 'remote-control', + // preventExecution: false + // }, + // 'send-participant-to-room', + // 'verify', + // ], + // List of pre meeting screens buttons to hide. The values must be one or more of the 5 allowed buttons: // 'microphone', 'camera', 'select-background', 'invite', 'settings' // hiddenPremeetingButtons: [], diff --git a/modules/API/API.js b/modules/API/API.js index cd328c8ac0..cd12dc4529 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -2002,14 +2002,16 @@ class API { * Notify external application ( if API is enabled) that a participant menu button was clicked. * * @param {string} key - The key of the participant menu button. - * @param {string} participantId - The ID of the participant for with the participant menu button was clicked. + * @param {string} participantId - The ID of the participant for whom the participant menu button was clicked. + * @param {boolean} preventExecution - Whether execution of the button click was prevented or not. * @returns {void} */ - notifyParticipantMenuButtonClicked(key, participantId) { + notifyParticipantMenuButtonClicked(key, participantId, preventExecution) { this._sendEvent({ name: 'participant-menu-button-clicked', key, - participantId + participantId, + preventExecution }); } diff --git a/react/features/base/config/configType.ts b/react/features/base/config/configType.ts index 6960b05817..2ff77b8407 100644 --- a/react/features/base/config/configType.ts +++ b/react/features/base/config/configType.ts @@ -68,6 +68,33 @@ type ButtonsWithNotifyClick = 'camera' | 'add-passcode' | '__end'; +type ParticipantMenuButtonsWithNotifyClick = 'allow-video' | + 'ask-unmute' | + 'conn-status' | + 'flip-local-video' | + 'grant-moderator' | + 'hide-self-view' | + 'kick' | + 'mute' | + 'mute-others' | + 'mute-others-video' | + 'mute-video' | + 'pinToStage' | + 'privateMessage' | + 'remote-control' | + 'send-participant-to-room' | + 'verify'; + +type NotifyClickButtonKey = string | + ButtonsWithNotifyClick | + ParticipantMenuButtonsWithNotifyClick; + +export type NotifyClickButton = NotifyClickButtonKey | + { + key: NotifyClickButtonKey; + preventExecution: boolean; + }; + export type Sounds = 'ASKED_TO_UNMUTE_SOUND' | 'E2EE_OFF_SOUND' | 'E2EE_ON_SOUND' | @@ -468,6 +495,10 @@ export interface IConfig { mobileCodecPreferenceOrder?: Array; stunServers?: Array<{ urls: string; }>; }; + participantMenuButtonsWithNotifyClick?: Array; participantsPane?: { hideModeratorSettingsTab?: boolean; hideMoreActionsButton?: boolean; diff --git a/react/features/base/config/configWhitelist.ts b/react/features/base/config/configWhitelist.ts index f7a160d975..1572f70478 100644 --- a/react/features/base/config/configWhitelist.ts +++ b/react/features/base/config/configWhitelist.ts @@ -194,6 +194,7 @@ export default [ 'openSharedDocumentOnJoin', 'opusMaxAverageBitrate', 'p2p', + 'participantMenuButtonsWithNotifyClick', 'participantsPane', 'pcStatsInterval', 'prejoinConfig', diff --git a/react/features/base/config/functions.web.ts b/react/features/base/config/functions.web.ts index 2690972915..0008a87691 100644 --- a/react/features/base/config/functions.web.ts +++ b/react/features/base/config/functions.web.ts @@ -1,7 +1,14 @@ import { IReduxState } from '../../app/types'; import JitsiMeetJS from '../../base/lib-jitsi-meet'; - -import { IConfig, IDeeplinkingConfig, IDeeplinkingMobileConfig, IDeeplinkingPlatformConfig } from './configType'; +import { NOTIFY_CLICK_MODE } from '../../toolbox/constants'; + +import { + IConfig, + IDeeplinkingConfig, + IDeeplinkingMobileConfig, + IDeeplinkingPlatformConfig, + NotifyClickButton +} from './configType'; import { TOOLBAR_BUTTONS } from './constants'; export * from './functions.any'; @@ -120,14 +127,21 @@ export function _setDeeplinkingDefaults(deeplinking: IDeeplinkingConfig) { } /** - * Returns the list of buttons that have that notify the api when clicked. + * Common logic to gather buttons that have to notify the api when clicked. * - * @param {Object} state - The redux state. - * @returns {Array} - The list of buttons. + * @param {Array} buttonsWithNotifyClick - The array of systme buttons that need to notify the api. + * @param {Array} customButtons - The custom buttons. + * @returns {Array} */ -export function getButtonsWithNotifyClick(state: IReduxState): Array<{ key: string; preventExecution: boolean; }> { - const { buttonsWithNotifyClick, customToolbarButtons } = state['features/base/config']; - const customButtons = customToolbarButtons?.map(({ id }) => { +const buildButtonsArray = ( + buttonsWithNotifyClick?: NotifyClickButton[], + customButtons?: { + icon: string; + id: string; + text: string; + }[] +): NotifyClickButton[] => { + const customButtonsWithNotifyClick = customButtons?.map(({ id }) => { return { key: id, preventExecution: false @@ -135,13 +149,69 @@ export function getButtonsWithNotifyClick(state: IReduxState): Array<{ key: stri }); const buttons = Array.isArray(buttonsWithNotifyClick) - ? buttonsWithNotifyClick as Array<{ key: string; preventExecution: boolean; }> + ? buttonsWithNotifyClick as NotifyClickButton[] : []; - if (customButtons) { - buttons.push(...customButtons); + if (customButtonsWithNotifyClick) { + buttons.push(...customButtonsWithNotifyClick); } return buttons; +}; + +/** + * Returns the list of toolbar buttons that have to notify the api when clicked. + * + * @param {Object} state - The redux state. + * @returns {Array} - The list of buttons. + */ +export function getButtonsWithNotifyClick( + state: IReduxState +): NotifyClickButton[] { + const { buttonsWithNotifyClick, customToolbarButtons } = state['features/base/config']; + + return buildButtonsArray( + buttonsWithNotifyClick, + customToolbarButtons + ); +} + +/** + * Returns the list of participant menu buttons that have that notify the api when clicked. + * + * @param {Object} state - The redux state. + * @returns {Array} - The list of participant menu buttons. + */ +export function getParticipantMenuButtonsWithNotifyClick( + state: IReduxState +): NotifyClickButton[] { + const { participantMenuButtonsWithNotifyClick, customParticipantMenuButtons } = state['features/base/config']; + + return buildButtonsArray( + participantMenuButtonsWithNotifyClick, + customParticipantMenuButtons + ); } +/** + * Returns the notify mode for the specified button. + * + * @param {string} buttonKey - The button key. + * @param {Array} buttonsWithNotifyClick - The buttons with notify click. + * @returns {string|undefined} + */ +export const getButtonNotifyMode = ( + buttonKey: string, + buttonsWithNotifyClick?: NotifyClickButton[] +): string | undefined => { + const notify = buttonsWithNotifyClick?.find( + (btn: NotifyClickButton) => + (typeof btn === 'string' && btn === buttonKey) || (typeof btn === 'object' && btn.key === buttonKey) + ); + + if (notify) { + return typeof notify === 'string' || notify.preventExecution + ? NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + : NOTIFY_CLICK_MODE.ONLY_NOTIFY; + } +}; diff --git a/react/features/base/toolbox/components/AbstractButton.tsx b/react/features/base/toolbox/components/AbstractButton.tsx index 423f45cdde..8f86a1cb80 100644 --- a/react/features/base/toolbox/components/AbstractButton.tsx +++ b/react/features/base/toolbox/components/AbstractButton.tsx @@ -337,12 +337,12 @@ export default class AbstractButton

extends Component

| GestureResponderEvent) { - const { afterClick, handleClick, notifyMode, buttonKey } = this.props; + _onClick(e?: React.MouseEvent | GestureResponderEvent) { + const { afterClick, buttonKey, handleClick, notifyMode } = this.props; if (typeof APP !== 'undefined' && notifyMode) { APP.API.notifyToolbarButtonClicked( - buttonKey, notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + buttonKey, notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY ); } diff --git a/react/features/participants-pane/components/breakout-rooms/components/web/RoomParticipantContextMenu.tsx b/react/features/participants-pane/components/breakout-rooms/components/web/RoomParticipantContextMenu.tsx index 37f293a8a3..ef3bfc2403 100644 --- a/react/features/participants-pane/components/breakout-rooms/components/web/RoomParticipantContextMenu.tsx +++ b/react/features/participants-pane/components/breakout-rooms/components/web/RoomParticipantContextMenu.tsx @@ -4,12 +4,18 @@ import { useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import Avatar from '../../../../../base/avatar/components/Avatar'; +import { + getButtonNotifyMode, + getParticipantMenuButtonsWithNotifyClick +} from '../../../../../base/config/functions.web'; import { isLocalParticipantModerator } from '../../../../../base/participants/functions'; import ContextMenu from '../../../../../base/ui/components/web/ContextMenu'; import ContextMenuItemGroup from '../../../../../base/ui/components/web/ContextMenuItemGroup'; import { getBreakoutRooms } from '../../../../../breakout-rooms/functions'; +import { NOTIFY_CLICK_MODE } from '../../../../../toolbox/constants'; import { showOverflowDrawer } from '../../../../../toolbox/functions.web'; import SendToRoomButton from '../../../../../video-menu/components/web/SendToRoomButton'; +import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../../../../video-menu/constants'; import { AVATAR_SIZE } from '../../../../constants'; @@ -72,11 +78,30 @@ export const RoomParticipantContextMenu = ({ const lowerMenu = useCallback(() => onSelect(true), [ onSelect ]); const rooms: Object = useSelector(getBreakoutRooms); const overflowDrawer = useSelector(showOverflowDrawer); + const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick); + + const notifyClick = useCallback( + (buttonKey: string, participantId?: string) => { + const notifyMode = getButtonNotifyMode(buttonKey, buttonsWithNotifyClick); + + if (!notifyMode) { + return; + } + + APP.API.notifyParticipantMenuButtonClicked( + buttonKey, + participantId, + notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + ); + }, [ buttonsWithNotifyClick, getButtonNotifyMode ]); const breakoutRoomsButtons = useMemo(() => Object.values(rooms || {}).map((room: any) => { if (room.id !== entity?.room?.id) { return ( notifyClick(BUTTONS.SEND_PARTICIPANT_TO_ROOM, entity?.jid) } + notifyMode = { getButtonNotifyMode(BUTTONS.SEND_PARTICIPANT_TO_ROOM, buttonsWithNotifyClick) } onClick = { lowerMenu } participantID = { entity?.jid ?? '' } room = { room } />); diff --git a/react/features/toolbox/components/web/Toolbox.tsx b/react/features/toolbox/components/web/Toolbox.tsx index 1ad2fd7c82..3c0ec26181 100644 --- a/react/features/toolbox/components/web/Toolbox.tsx +++ b/react/features/toolbox/components/web/Toolbox.tsx @@ -4,8 +4,10 @@ import { connect } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { IReduxState, IStore } from '../../../app/types'; +import { NotifyClickButton } from '../../../base/config/configType'; import { VISITORS_MODE_BUTTONS } from '../../../base/config/constants'; import { + getButtonNotifyMode, getButtonsWithNotifyClick, getToolbarButtons, isToolbarButtonEnabled @@ -22,7 +24,7 @@ import { setToolbarHovered, showToolbox } from '../../actions.web'; -import { NOTIFY_CLICK_MODE, NOT_APPLICABLE, THRESHOLDS } from '../../constants'; +import { NOT_APPLICABLE, THRESHOLDS } from '../../constants'; import { getAllToolboxButtons, getJwtDisabledButtons, @@ -46,10 +48,7 @@ interface IProps extends WithTranslation { /** * Toolbar buttons which have their click exposed through the API. */ - _buttonsWithNotifyClick?: Array; + _buttonsWithNotifyClick?: NotifyClickButton[]; /** * Whether or not the chat feature is currently displayed. @@ -262,26 +261,6 @@ const Toolbox = ({ } }, [ _hangupMenuVisible, _overflowMenuVisible ]); - /** - * Returns the notify mode of the given toolbox button. - * - * @param {string} btnName - The toolbar button's name. - * @returns {string|undefined} - The button's notify mode. - */ - function getButtonNotifyMode(btnName: string) { - const notify = _buttonsWithNotifyClick?.find( - btn => - (typeof btn === 'string' && btn === btnName) - || (typeof btn === 'object' && btn.key === btnName) - ); - - if (notify) { - return typeof notify === 'string' || notify.preventExecution - ? NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY - : NOTIFY_CLICK_MODE.ONLY_NOTIFY; - } - } - /** * Sets the notify click mode for the buttons. * @@ -295,7 +274,7 @@ const Toolbox = ({ Object.values(buttons).forEach((button: any) => { if (typeof button === 'object') { - button.notifyMode = getButtonNotifyMode(button.key); + button.notifyMode = getButtonNotifyMode(button.key, _buttonsWithNotifyClick); } }); } @@ -452,7 +431,7 @@ const Toolbox = ({ ariaControls = 'hangup-menu' isOpen = { _hangupMenuVisible } key = 'hangup-menu' - notifyMode = { getButtonNotifyMode('hangup-menu') } + notifyMode = { getButtonNotifyMode('hangup-menu', _buttonsWithNotifyClick) } onVisibilityChange = { onSetHangupVisible }> + notifyMode = { getButtonNotifyMode( + 'end-meeting', + _buttonsWithNotifyClick + ) } /> + notifyMode = { getButtonNotifyMode('hangup', _buttonsWithNotifyClick) } /> : )} diff --git a/react/features/video-menu/components/web/AskToUnmuteButton.tsx b/react/features/video-menu/components/web/AskToUnmuteButton.tsx index 8d3048a36b..1c73ab806f 100644 --- a/react/features/video-menu/components/web/AskToUnmuteButton.tsx +++ b/react/features/video-menu/components/web/AskToUnmuteButton.tsx @@ -6,27 +6,38 @@ import { approveParticipantAudio, approveParticipantVideo } from '../../../av-mo import { IconMic, 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/constants'; +import { IButtonProps } from '../../types'; -interface IProps { - +interface IProps extends IButtonProps { buttonType: MediaType; - - /** - * The ID for the participant on which the button will act. - */ - participantID: string; } -const AskToUnmuteButton = ({ buttonType, participantID }: IProps) => { +/** + * 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)); } - }, [ participantID, buttonType ]); + }, [ buttonType, dispatch, notifyClick, notifyMode, participantID ]); const text = useMemo(() => { if (buttonType === MEDIA_TYPE.AUDIO) { diff --git a/react/features/video-menu/components/web/ConnectionStatusButton.tsx b/react/features/video-menu/components/web/ConnectionStatusButton.tsx index 22654a2b08..ae2a8e1508 100644 --- a/react/features/video-menu/components/web/ConnectionStatusButton.tsx +++ b/react/features/video-menu/components/web/ConnectionStatusButton.tsx @@ -1,43 +1,42 @@ import React, { useCallback } from 'react'; -import { WithTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; -import { IStore } from '../../../app/types'; -import { translate } from '../../../base/i18n/functions'; import { IconInfoCircle } from '../../../base/icons/svg'; import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; import { renderConnectionStatus } from '../../actions.web'; - -interface IProps extends WithTranslation { - - /** - * The Redux dispatch function. - */ - dispatch: IStore['dispatch']; - - /** - * The ID of the participant for which to show connection stats. - */ - participantId: string; -} - - +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 = ({ - dispatch, - t -}: IProps) => { - const onClick = useCallback(e => { + 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 ]); + }, [ dispatch, notifyClick, notifyMode ]); return ( ); }; -export default translate(connect()(ConnectionStatusButton)); +export default ConnectionStatusButton; diff --git a/react/features/video-menu/components/web/FakeParticipantContextMenu.tsx b/react/features/video-menu/components/web/FakeParticipantContextMenu.tsx index 231fe501ae..c61d42981c 100644 --- a/react/features/video-menu/components/web/FakeParticipantContextMenu.tsx +++ b/react/features/video-menu/components/web/FakeParticipantContextMenu.tsx @@ -4,15 +4,18 @@ import { useDispatch, useSelector } from 'react-redux'; import TogglePinToStageButton from '../../../../features/video-menu/components/web/TogglePinToStageButton'; import Avatar from '../../../base/avatar/components/Avatar'; +import { getButtonNotifyMode, getParticipantMenuButtonsWithNotifyClick } from '../../../base/config/functions.web'; 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.any'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; import { showOverflowDrawer } from '../../../toolbox/functions.web'; import { setWhiteboardOpen } from '../../../whiteboard/actions'; import { WHITEBOARD_ID } from '../../../whiteboard/constants'; +import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants'; interface IProps { @@ -86,6 +89,23 @@ const FakeParticipantContextMenu = ({ const dispatch = useDispatch(); const { t } = useTranslation(); const _overflowDrawer: boolean = useSelector(showOverflowDrawer); + const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick); + + const notifyClick = useCallback( + (buttonKey: string, participantId?: string) => { + const notifyMode = getButtonNotifyMode(buttonKey, buttonsWithNotifyClick); + + if (!notifyMode) { + return; + } + + APP.API.notifyParticipantMenuButtonClicked( + buttonKey, + participantId, + notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + ); + }, [ buttonsWithNotifyClick, getButtonNotifyMode ]); + const clickHandler = useCallback(() => onSelect(true), [ onSelect ]); @@ -145,6 +165,9 @@ const FakeParticipantContextMenu = ({ {isWhiteboardParticipant(participant) && ( notifyClick(BUTTONS.PIN_TO_STAGE, WHITEBOARD_ID) } + notifyMode = { getButtonNotifyMode(BUTTONS.PIN_TO_STAGE, buttonsWithNotifyClick) } participantID = { WHITEBOARD_ID } /> )} diff --git a/react/features/video-menu/components/web/FlipLocalVideoButton.tsx b/react/features/video-menu/components/web/FlipLocalVideoButton.tsx index 58b1a5882d..63690355cf 100644 --- a/react/features/video-menu/components/web/FlipLocalVideoButton.tsx +++ b/react/features/video-menu/components/web/FlipLocalVideoButton.tsx @@ -6,6 +6,7 @@ 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/constants'; /** * The type of the React {@code Component} props of {@link FlipLocalVideoButton}. @@ -27,6 +28,17 @@ interface IProps extends WithTranslation { */ 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. */ @@ -82,8 +94,12 @@ class FlipLocalVideoButton extends PureComponent { * @returns {void} */ _onClick() { - const { _localFlipX, dispatch, onClick } = this.props; + const { _localFlipX, dispatch, notifyClick, notifyMode, onClick } = this.props; + notifyClick?.(); + if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) { + return; + } onClick?.(); dispatch(updateSettings({ localFlipX: !_localFlipX diff --git a/react/features/video-menu/components/web/GrantModeratorButton.tsx b/react/features/video-menu/components/web/GrantModeratorButton.tsx index 2a3adb7166..583f75e8da 100644 --- a/react/features/video-menu/components/web/GrantModeratorButton.tsx +++ b/react/features/video-menu/components/web/GrantModeratorButton.tsx @@ -1,52 +1,56 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +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 AbstractGrantModeratorButton, { IProps, _mapStateToProps } from '../AbstractGrantModeratorButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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} */ -class GrantModeratorButton extends AbstractGrantModeratorButton { - /** - * Instantiates a new {@code GrantModeratorButton}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { t, visible } = this.props; - - if (!visible) { - return 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 ]); - return ( - - ); + if (!visible) { + return null; } - _handleClick: () => void; -} - -export default translate(connect(_mapStateToProps)(GrantModeratorButton)); + return ( + + ); +}; + +export default GrantModeratorButton; diff --git a/react/features/video-menu/components/web/HideSelfViewVideoButton.tsx b/react/features/video-menu/components/web/HideSelfViewVideoButton.tsx index f7c5bd6af9..133e3e5d47 100644 --- a/react/features/video-menu/components/web/HideSelfViewVideoButton.tsx +++ b/react/features/video-menu/components/web/HideSelfViewVideoButton.tsx @@ -7,6 +7,7 @@ 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/constants'; /** * The type of the React {@code Component} props of {@link HideSelfViewVideoButton}. @@ -28,6 +29,17 @@ interface IProps extends WithTranslation { */ 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. */ @@ -83,8 +95,12 @@ class HideSelfViewVideoButton extends PureComponent { * @returns {void} */ _onClick() { - const { disableSelfView, dispatch, onClick } = this.props; + const { disableSelfView, dispatch, notifyClick, notifyMode, onClick } = this.props; + notifyClick?.(); + if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) { + return; + } onClick?.(); dispatch(updateSettings({ disableSelfView: !disableSelfView diff --git a/react/features/video-menu/components/web/KickButton.tsx b/react/features/video-menu/components/web/KickButton.tsx index 41368a6a32..e8303b1b20 100644 --- a/react/features/video-menu/components/web/KickButton.tsx +++ b/react/features/video-menu/components/web/KickButton.tsx @@ -1,54 +1,46 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +import { openDialog } from '../../../base/dialog/actions'; import { IconUserDeleted } from '../../../base/icons/svg'; import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem'; -import AbstractKickButton, { IProps } from '../AbstractKickButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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. * - * NOTE: At the time of writing this is a button that doesn't use the - * {@code AbstractButton} base component, but is inherited from the same - * super class ({@code AbstractKickButton} that extends {@code AbstractButton}) - * for the sake of code sharing between web and mobile. Once web uses the - * {@code AbstractButton} base component, this can be fully removed. + * @returns {JSX.Element} */ -class KickButton extends AbstractKickButton { - /** - * Instantiates a new {@code Component}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } +const KickButton = ({ + notifyClick, + notifyMode, + participantID +}: IButtonProps): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useDispatch(); - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { participantID, t } = this.props; + const handleClick = useCallback(() => { + notifyClick?.(); + if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) { + return; + } + dispatch(openDialog(KickRemoteParticipantDialog, { participantID })); + }, [ dispatch, notifyClick, notifyMode, participantID ]); - return ( - - ); - } + return ( + + ); +}; - _handleClick: () => void; -} -export default translate(connect()(KickButton)); +export default KickButton; diff --git a/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.tsx b/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.tsx index 4a5ffd37f0..ec789a9edf 100644 --- a/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.tsx +++ b/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.tsx @@ -1,9 +1,10 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { batch, connect } from 'react-redux'; +import { batch, connect, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; import { IReduxState, IStore } from '../../../app/types'; +import { getButtonNotifyMode, getParticipantMenuButtonsWithNotifyClick } from '../../../base/config/functions.web'; import { isMobileBrowser } from '../../../base/environment/utils'; import { IconDotsHorizontal } from '../../../base/icons/svg'; import { getLocalParticipant } from '../../../base/participants/functions'; @@ -17,7 +18,9 @@ import ContextMenuItemGroup from '../../../base/ui/components/web/ContextMenuIte import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent'; import { THUMBNAIL_TYPE } from '../../../filmstrip/constants'; import { isStageFilmstripAvailable } from '../../../filmstrip/functions.web'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; import { renderConnectionStatus } from '../../actions.web'; +import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants'; import ConnectionStatusButton from './ConnectionStatusButton'; import FlipLocalVideoButton from './FlipLocalVideoButton'; @@ -139,6 +142,22 @@ const LocalVideoMenuTriggerButton = ({ }: IProps) => { const { classes } = useStyles(); const { t } = useTranslation(); + const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick); + + const notifyClick = useCallback( + (buttonKey: string) => { + const notifyMode = getButtonNotifyMode(buttonKey, buttonsWithNotifyClick); + + if (!notifyMode) { + return; + } + + APP.API.notifyParticipantMenuButtonClicked( + buttonKey, + _localParticipantId, + notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + ); + }, [ buttonsWithNotifyClick, getButtonNotifyMode ]); const _onPopoverOpen = useCallback(() => { showPopover?.(); @@ -164,22 +183,35 @@ const LocalVideoMenuTriggerButton = ({ {_showLocalVideoFlipButton && notifyClick(BUTTONS.FLIP_LOCAL_VIDEO) } + notifyMode = { getButtonNotifyMode(BUTTONS.FLIP_LOCAL_VIDEO, buttonsWithNotifyClick) } onClick = { hidePopover } /> } {_showHideSelfViewButton && notifyClick(BUTTONS.HIDE_SELF_VIEW) } + notifyMode = { getButtonNotifyMode(BUTTONS.HIDE_SELF_VIEW, buttonsWithNotifyClick) } onClick = { hidePopover } /> } { _showPinToStage && notifyClick(BUTTONS.PIN_TO_STAGE) } + notifyMode = { getButtonNotifyMode(BUTTONS.PIN_TO_STAGE, buttonsWithNotifyClick) } onClick = { hidePopover } participantID = { _localParticipantId } /> } - {isMobileBrowser() - && + { + isMobileBrowser() && notifyClick(BUTTONS.CONN_STATUS) } + notifyMode = { getButtonNotifyMode(BUTTONS.CONN_STATUS, buttonsWithNotifyClick) } + participantID = { _localParticipantId } /> } diff --git a/react/features/video-menu/components/web/MuteButton.tsx b/react/features/video-menu/components/web/MuteButton.tsx index 9782b2d751..1c1f66c981 100644 --- a/react/features/video-menu/components/web/MuteButton.tsx +++ b/react/features/video-menu/components/web/MuteButton.tsx @@ -1,59 +1,65 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +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 AbstractMuteButton, { IProps, _mapStateToProps } from '../AbstractMuteButton'; - +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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. * - * NOTE: At the time of writing this is a button that doesn't use the - * {@code AbstractButton} base component, but is inherited from the same - * super class ({@code AbstractMuteButton} that extends {@code AbstractButton}) - * for the sake of code sharing between web and mobile. Once web uses the - * {@code AbstractButton} base component, this can be fully removed. + * @returns {JSX.Element|null} */ -class MuteButton extends AbstractMuteButton { - /** - * Instantiates a new {@code Component}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } +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 ] + ); - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { _audioTrackMuted, t } = this.props; - - if (_audioTrackMuted) { - return null; + 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 ]); - return ( - - ); + if (audioTrackMuted) { + return null; } - _handleClick: () => void; -} + return ( + + ); +}; -export default translate(connect(_mapStateToProps)(MuteButton)); +export default MuteButton; diff --git a/react/features/video-menu/components/web/MuteEveryoneElseButton.tsx b/react/features/video-menu/components/web/MuteEveryoneElseButton.tsx index 82831bb650..77b9e79c06 100644 --- a/react/features/video-menu/components/web/MuteEveryoneElseButton.tsx +++ b/react/features/video-menu/components/web/MuteEveryoneElseButton.tsx @@ -1,48 +1,48 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +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 AbstractMuteEveryoneElseButton, { IProps } from '../AbstractMuteEveryoneElseButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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} */ -class MuteEveryoneElseButton extends AbstractMuteEveryoneElseButton { - /** - * Instantiates a new {@code Component}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { t } = this.props; +const MuteEveryoneElseButton = ({ + notifyClick, + notifyMode, + participantID +}: IButtonProps): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useDispatch(); - return ( - - ); - } + 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 ]); - _handleClick: () => void; -} + return ( + + ); +}; -export default translate(connect()(MuteEveryoneElseButton)); +export default MuteEveryoneElseButton; diff --git a/react/features/video-menu/components/web/MuteEveryoneElsesVideoButton.tsx b/react/features/video-menu/components/web/MuteEveryoneElsesVideoButton.tsx index ca9e20eb66..daad6d735e 100644 --- a/react/features/video-menu/components/web/MuteEveryoneElsesVideoButton.tsx +++ b/react/features/video-menu/components/web/MuteEveryoneElsesVideoButton.tsx @@ -1,48 +1,48 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +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 AbstractMuteEveryoneElsesVideoButton, { IProps } from '../AbstractMuteEveryoneElsesVideoButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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} */ -class MuteEveryoneElsesVideoButton extends AbstractMuteEveryoneElsesVideoButton { - /** - * Instantiates a new {@code Component}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } - - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { t } = this.props; +const MuteEveryoneElsesVideoButton = ({ + notifyClick, + notifyMode, + participantID +}: IButtonProps): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useDispatch(); - return ( - - ); - } + 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 ]); - _handleClick: () => void; -} + return ( + + ); +}; -export default translate(connect()(MuteEveryoneElsesVideoButton)); +export default MuteEveryoneElsesVideoButton; diff --git a/react/features/video-menu/components/web/MuteVideoButton.tsx b/react/features/video-menu/components/web/MuteVideoButton.tsx index 77abae6450..1c5120183e 100644 --- a/react/features/video-menu/components/web/MuteVideoButton.tsx +++ b/react/features/video-menu/components/web/MuteVideoButton.tsx @@ -1,58 +1,66 @@ -import React from 'react'; -import { connect } from 'react-redux'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; -import { translate } from '../../../base/i18n/functions'; +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 AbstractMuteVideoButton, { IProps, _mapStateToProps } from '../AbstractMuteVideoButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; +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. * - * NOTE: At the time of writing this is a button that doesn't use the - * {@code AbstractButton} base component, but is inherited from the same - * super class ({@code AbstractMuteVideoButton} that extends {@code AbstractButton}) - * for the sake of code sharing between web and mobile. Once web uses the - * {@code AbstractButton} base component, this can be fully removed. + * @returns {JSX.Element|null} */ -class MuteVideoButton extends AbstractMuteVideoButton { - /** - * Instantiates a new {@code Component}. - * - * @inheritdoc - */ - constructor(props: IProps) { - super(props); - - this._handleClick = this._handleClick.bind(this); - } +const MuteVideoButton = ({ + notifyClick, + notifyMode, + participantID +}: IButtonProps): JSX.Element | null => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const tracks = useSelector((state: IReduxState) => state['features/base/tracks']); - /** - * Implements React's {@link Component#render()}. - * - * @inheritdoc - * @returns {ReactElement} - */ - render() { - const { _videoTrackMuted, t } = this.props; - - if (_videoTrackMuted) { - return null; + 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 ]); - return ( - - ); + if (videoTrackMuted) { + return null; } - _handleClick: () => void; -} + return ( + + ); +}; -export default translate(connect(_mapStateToProps)(MuteVideoButton)); +export default MuteVideoButton; diff --git a/react/features/video-menu/components/web/ParticipantContextMenu.tsx b/react/features/video-menu/components/web/ParticipantContextMenu.tsx index 73122e54ea..ae5da0cb35 100644 --- a/react/features/video-menu/components/web/ParticipantContextMenu.tsx +++ b/react/features/video-menu/components/web/ParticipantContextMenu.tsx @@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui'; import { IReduxState, IStore } from '../../../app/types'; import { isSupported as isAvModerationSupported } from '../../../av-moderation/functions'; import Avatar from '../../../base/avatar/components/Avatar'; +import { getButtonNotifyMode, getParticipantMenuButtonsWithNotifyClick } from '../../../base/config/functions.web'; import { isIosMobileBrowser, isMobileBrowser } from '../../../base/environment/utils'; import { MEDIA_TYPE } from '../../../base/media/constants'; import { PARTICIPANT_ROLE } from '../../../base/participants/constants'; @@ -15,14 +16,17 @@ import { isParticipantAudioMuted, isParticipantVideoMuted } from '../../../base/ 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, isForceMuted } from '../../../participants-pane/functions'; import { requestRemoteControl, stopController } from '../../../remote-control/actions'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; import { showOverflowDrawer } from '../../../toolbox/functions.web'; import { iAmVisitor } from '../../../visitors/functions'; +import { PARTICIPANT_MENU_BUTTONS as BUTTONS } from '../../constants'; import AskToUnmuteButton from './AskToUnmuteButton'; import ConnectionStatusButton from './ConnectionStatusButton'; @@ -145,16 +149,15 @@ const ParticipantContextMenu = ({ const isModerationSupported = useSelector((state: IReduxState) => isAvModerationSupported()(state)); const stageFilmstrip = useSelector(isStageFilmstripAvailable); const shouldDisplayVerification = useSelector((state: IReduxState) => displayVerification(state, participant?.id)); + const buttonsWithNotifyClick = useSelector(getParticipantMenuButtonsWithNotifyClick); const _currentRoomId = useSelector(getCurrentRoomId); - const _rooms = Object.values(useSelector(getBreakoutRooms)); + const _rooms: IRoom[] = Object.values(useSelector(getBreakoutRooms)); const _onVolumeChange = useCallback(value => { dispatch(setVolume(participant.id, value)); }, [ setVolume, dispatch ]); - const clickHandler = useCallback(() => onSelect(true), [ onSelect ]); - const _getCurrentParticipantId = useCallback(() => { const drawer = _overflowDrawer && !thumbnailMenu; @@ -162,6 +165,25 @@ const ParticipantContextMenu = ({ } , [ thumbnailMenu, _overflowDrawer, drawerParticipant, participant ]); + const notifyClick = useCallback( + (buttonKey: string) => { + const notifyMode = getButtonNotifyMode(buttonKey, buttonsWithNotifyClick); + + if (!notifyMode) { + return; + } + + APP.API.notifyParticipantMenuButtonClicked( + buttonKey, + _getCurrentParticipantId(), + notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY + ); + }, [ buttonsWithNotifyClick, getButtonNotifyMode, _getCurrentParticipantId ]); + + const onBreakoutRoomButtonClick = useCallback(() => { + onSelect(true); + }, [ onSelect ]); + const isClickedFromParticipantPane = useMemo( () => !_overflowDrawer && !thumbnailMenu, [ _overflowDrawer, thumbnailMenu ]); @@ -177,128 +199,98 @@ const ParticipantContextMenu = ({ && typeof _volume === 'number' && !isNaN(_volume); + const getButtonProps = useCallback((key: string) => { + const notifyMode = getButtonNotifyMode(key, buttonsWithNotifyClick); + const shouldNotifyClick = notifyMode !== NOTIFY_CLICK_MODE.ONLY_NOTIFY + || notifyMode !== NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY; + + return { + key, + notifyMode, + notifyClick: shouldNotifyClick ? () => notifyClick(key) : undefined, + participantID: _getCurrentParticipantId() + }; + }, [ _getCurrentParticipantId, buttonsWithNotifyClick, getButtonNotifyMode, notifyClick ]); + if (_isModerator) { if (isModerationSupported) { if (_isAudioMuted && !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ASK_TO_UNMUTE)) { buttons.push( + { ...getButtonProps(BUTTONS.ASK_UNMUTE) } + buttonType = { MEDIA_TYPE.AUDIO } /> ); } if (_isVideoForceMuted && !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ALLOW_VIDEO)) { buttons.push( + { ...getButtonProps(BUTTONS.ALLOW_VIDEO) } + buttonType = { MEDIA_TYPE.VIDEO } /> ); } } + if (!disableRemoteMute) { if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.MUTE)) { - buttons.push( - - ); + buttons.push(); } - buttons.push( - - ); + buttons.push(); if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.STOP_VIDEO)) { - buttons.push( - - ); + buttons.push(); } - buttons.push( - - ); + buttons.push(); } if (!disableGrantModerator && !isBreakoutRoom) { - buttons2.push( - - ); + buttons2.push(); } if (!disableKick) { - buttons2.push( - - ); + buttons2.push(); } if (shouldDisplayVerification) { - buttons2.push( - - ); + buttons2.push(); } - } if (stageFilmstrip) { - buttons2.push(); + buttons2.push(); } if (!disablePrivateChat && !visitorsMode) { - buttons2.push( - ); + buttons2.push(); } if (thumbnailMenu && isMobileBrowser()) { - buttons2.push( - - ); + buttons2.push(); } if (thumbnailMenu && remoteControlState) { - let onRemoteControlToggle = null; - - if (remoteControlState === REMOTE_CONTROL_MENU_STATES.STARTED) { - onRemoteControlToggle = () => dispatch(stopController(true)); - } else if (remoteControlState === REMOTE_CONTROL_MENU_STATES.NOT_STARTED) { - onRemoteControlToggle = () => dispatch(requestRemoteControl(_getCurrentParticipantId())); - } + 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( - + buttons2.push( ); } if (customParticipantMenuButtons) { customParticipantMenuButtons.forEach( ({ icon, id, text }) => { - const onClick = useCallback( - () => APP.API.notifyParticipantMenuButtonClicked(id, _getCurrentParticipantId()), []); - buttons2.push( notifyClick(id) } text = { text } /> ); } @@ -312,9 +304,9 @@ const ParticipantContextMenu = ({ if (room.id !== _currentRoomId) { breakoutRoomsButtons.push( ); } diff --git a/react/features/video-menu/components/web/PrivateMessageMenuButton.tsx b/react/features/video-menu/components/web/PrivateMessageMenuButton.tsx index a2b91a9ced..145fed1b50 100644 --- a/react/features/video-menu/components/web/PrivateMessageMenuButton.tsx +++ b/react/features/video-menu/components/web/PrivateMessageMenuButton.tsx @@ -11,10 +11,11 @@ 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 { IProps as AbstractProps } from '../../../chat/components/web/PrivateMessageButton'; +import { NOTIFY_CLICK_MODE } from '../../../toolbox/constants'; import { isButtonEnabled } from '../../../toolbox/functions.web'; +import { IButtonProps } from '../../types'; -interface IProps extends AbstractProps, WithTranslation { +interface IProps extends IButtonProps, WithTranslation { /** * True if the private chat functionality is disabled, hence the button is not visible. @@ -57,7 +58,7 @@ class PrivateMessageMenuButton extends Component { * @returns {ReactElement} */ render() { - const { t, _hidden } = this.props; + const { _hidden, t } = this.props; if (_hidden) { return null; @@ -75,11 +76,16 @@ class PrivateMessageMenuButton extends Component { /** * Callback to be invoked on pressing the button. * + * @param {React.MouseEvent|undefined} e - The click event. * @returns {void} */ _onClick() { - const { dispatch, _participant } = this.props; + const { _participant, dispatch, notifyClick, notifyMode } = this.props; + notifyClick?.(); + if (notifyMode === NOTIFY_CLICK_MODE.PREVENT_AND_NOTIFY) { + return; + } dispatch(openChat(_participant)); } } diff --git a/react/features/video-menu/components/web/RemoteControlButton.tsx b/react/features/video-menu/components/web/RemoteControlButton.tsx index 7eae103584..b541e25031 100644 --- a/react/features/video-menu/components/web/RemoteControlButton.tsx +++ b/react/features/video-menu/components/web/RemoteControlButton.tsx @@ -6,6 +6,7 @@ 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/constants'; // TODO: Move these enums into the store after further reactification of the // non-react RemoteVideo component. @@ -21,6 +22,17 @@ export const REMOTE_CONTROL_MENU_STATES = { */ 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. */ @@ -66,10 +78,7 @@ class RemoteControlButton extends Component { * @returns {null|ReactElement} */ render() { - const { - remoteControlState, - t - } = this.props; + const { remoteControlState, t } = this.props; let disabled = false, icon; @@ -110,7 +119,12 @@ class RemoteControlButton extends Component { * @returns {void} */ _onClick() { - const { onClick, participantID, remoteControlState } = this.props; + 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 @@ -126,10 +140,8 @@ class RemoteControlButton extends Component { 'participant_id': participantID })); } + onClick?.(); - if (onClick) { - onClick(); - } } } diff --git a/react/features/video-menu/components/web/SendToRoomButton.tsx b/react/features/video-menu/components/web/SendToRoomButton.tsx index b604b7f5e5..2cc7b80469 100644 --- a/react/features/video-menu/components/web/SendToRoomButton.tsx +++ b/react/features/video-menu/components/web/SendToRoomButton.tsx @@ -8,33 +8,40 @@ 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/constants'; +import { IButtonProps } from '../../types'; -interface IProps { +interface IProps extends IButtonProps { /** * Click handler. */ onClick?: Function; - /** - * The ID for the participant on which the button will act. - */ - participantID: string; - /** * The room to send the participant to. */ room: IRoom; } -const SendToRoomButton = ({ onClick, participantID, room }: IProps) => { +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)); - }, [ participantID, room ]); + }, [ dispatch, notifyClick, notifyMode, onClick, participantID, room, sendAnalytics ]); const roomName = room.name || t('breakoutRooms.mainRoom'); diff --git a/react/features/video-menu/components/web/TogglePinToStageButton.tsx b/react/features/video-menu/components/web/TogglePinToStageButton.tsx index ac30ead758..f46d129d48 100644 --- a/react/features/video-menu/components/web/TogglePinToStageButton.tsx +++ b/react/features/video-menu/components/web/TogglePinToStageButton.tsx @@ -6,8 +6,10 @@ 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/constants'; +import { IButtonProps } from '../../types'; -interface IProps { +interface IProps extends IButtonProps { /** * Button text class name. @@ -23,22 +25,28 @@ interface IProps { * Click handler executed aside from the main action. */ onClick?: Function; - - /** - * The ID for the participant on which the button will act. - */ - participantID: string; } -const TogglePinToStageButton = ({ className, noIcon = false, onClick, participantID }: IProps) => { +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?.(); - }, [ participantID, isActive ]); + }, [ dispatch, isActive, notifyClick, onClick, participantID ]); const text = isActive ? t('videothumbnail.unpinFromStage') diff --git a/react/features/video-menu/components/web/VerifyParticipantButton.tsx b/react/features/video-menu/components/web/VerifyParticipantButton.tsx index e90a57b417..00d9ba2374 100644 --- a/react/features/video-menu/components/web/VerifyParticipantButton.tsx +++ b/react/features/video-menu/components/web/VerifyParticipantButton.tsx @@ -1,38 +1,34 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { connect } from 'react-redux'; +import { useDispatch } from 'react-redux'; -import { IReduxState, IStore } from '../../../app/types'; 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/constants'; +import { IButtonProps } from '../../types'; /** - * The type of the React {@code Component} props of - * {@link VerifyParticipantButton}. + * Implements a React {@link Component} which displays a button that + * verifies the participant. + * + * @returns {JSX.Element} */ -interface IProps { - - /** - * The redux {@code dispatch} function. - */ - dispatch: IStore['dispatch']; - - /** - * The ID of the participant that this button is supposed to verified. - */ - participantID: string; -} - const VerifyParticipantButton = ({ - dispatch, + notifyClick, + notifyMode, participantID -}: IProps) => { +}: 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)); - }, [ participantID ]); + }, [ dispatch, notifyClick, notifyMode, participantID ]); return ( ) { - const { participantID } = ownProps; - - return { - _participantID: participantID - }; -} - -export default connect(_mapStateToProps)(VerifyParticipantButton); +export default VerifyParticipantButton; diff --git a/react/features/video-menu/constants.ts b/react/features/video-menu/constants.ts index a293bd3622..7c001cb94a 100644 --- a/react/features/video-menu/constants.ts +++ b/react/features/video-menu/constants.ts @@ -12,3 +12,25 @@ export const NATIVE_VOLUME_SLIDER_SCALE = 19; * recognizes whole numbers. */ export const VOLUME_SLIDER_SCALE = 100; + +/** + * Participant context menu button keys. + */ +export const PARTICIPANT_MENU_BUTTONS = { + ALLOW_VIDEO: 'allow-video', + ASK_UNMUTE: 'ask-unmute', + CONN_STATUS: 'conn-status', + FLIP_LOCAL_VIDEO: 'flip-local-video', + GRANT_MODERATOR: 'grant-moderator', + HIDE_SELF_VIEW: 'hide-self-view', + KICK: 'kick', + MUTE: 'mute', + MUTE_OTHERS: 'mute-others', + MUTE_OTHERS_VIDEO: 'mute-others-video', + MUTE_VIDEO: 'mute-video', + PIN_TO_STAGE: 'pinToStage', + PRIVATE_MESSAGE: 'privateMessage', + REMOTE_CONTROL: 'remote-control', + SEND_PARTICIPANT_TO_ROOM: 'send-participant-to-room', + VERIFY: 'verify' +}; diff --git a/react/features/video-menu/types.ts b/react/features/video-menu/types.ts new file mode 100644 index 0000000000..3f50fabd4a --- /dev/null +++ b/react/features/video-menu/types.ts @@ -0,0 +1,18 @@ +export interface IButtonProps { + + /** + * Callback to execute when the button is clicked. + */ + notifyClick?: Function; + + /** + * Notify mode for the `participantMenuButtonClicked` event - + * whether to only notify or to also prevent button click routine. + */ + notifyMode?: string; + + /** + * The ID of the participant that's linked to the button. + */ + participantID: string; +}