fix(av-moderation): buttons for participants pane (#12977)

* fix(av): buttons for participants pane

* fix tests

* fix lint

* rename cliked from participant pane
This commit is contained in:
Gabriel Borlea 2023-03-06 19:05:26 +02:00 committed by GitHub
parent f727b9295f
commit 02c232440e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 200 additions and 119 deletions

View File

@ -15,11 +15,15 @@ export type MediaType = 'audio' | 'video' | 'screenshare';
*
* @enum {string}
*/
export const MEDIA_TYPE: { [key: string]: MediaType; } = {
AUDIO: 'audio',
SCREENSHARE: 'screenshare',
VIDEO: 'video'
};
export const MEDIA_TYPE: {
AUDIO: MediaType;
SCREENSHARE: MediaType;
VIDEO: MediaType;
} = {
AUDIO: 'audio',
SCREENSHARE: 'screenshare',
VIDEO: 'video'
};
/* eslint-disable no-bitwise */

View File

@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { translate } from '../../../base/i18n';
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../../../base/media';
import {
@ -163,9 +162,9 @@ type Props = {
participantID: ?string,
/**
* The translate function.
*/
t: Function,
* Callback used to stop a participant's video.
*/
stopVideo: Function,
/**
* The translated "you" text.
@ -192,17 +191,15 @@ function MeetingParticipantItem({
_quickActionButtonType,
_raisedHand,
_videoMediaState,
askUnmuteText,
isHighlighted,
isInBreakoutRoom,
muteAudio,
muteParticipantButtonText,
onContextMenu,
onLeave,
openDrawerForParticipant,
overflowDrawer,
participantActionEllipsisLabel,
t,
stopVideo,
youText
}: Props) {
@ -242,12 +239,6 @@ function MeetingParticipantItem({
const audioMediaState = _audioMediaState === MEDIA_STATE.UNMUTED && hasAudioLevels
? MEDIA_STATE.DOMINANT_SPEAKER : _audioMediaState;
let askToUnmuteText = askUnmuteText;
if (_audioMediaState !== MEDIA_STATE.FORCE_MUTED && _videoMediaState === MEDIA_STATE.FORCE_MUTED) {
askToUnmuteText = t('participantsPane.actions.allowVideo');
}
return (
<ParticipantItem
actionsTrigger = { ACTION_TRIGGER.HOVER }
@ -273,16 +264,16 @@ function MeetingParticipantItem({
&& <>
{!isInBreakoutRoom && (
<ParticipantQuickAction
askUnmuteText = { askToUnmuteText }
buttonType = { _quickActionButtonType }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
participantID = { _participantID }
participantName = { _displayName } />
participantName = { _displayName }
stopVideo = { stopVideo } />
)}
<ParticipantActionEllipsis
accessibilityLabel = { participantActionEllipsisLabel }
onClick = { onContextMenu } />
onClick = { onContextMenu }
participantID = { _participantID } />
</>
}
@ -318,7 +309,7 @@ function _mapStateToProps(state, ownProps): Object {
const _isVideoMuted = isParticipantVideoMuted(participant, state);
const _audioMediaState = getParticipantAudioMediaState(participant, _isAudioMuted, state);
const _videoMediaState = getParticipantVideoMediaState(participant, _isVideoMuted, state);
const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, state);
const _quickActionButtonType = getQuickActionButtonType(participant, _isAudioMuted, _isVideoMuted, state);
const tracks = state['features/base/tracks'];
const _audioTrack = participantID === localParticipantId
@ -342,4 +333,4 @@ function _mapStateToProps(state, ownProps): Object {
};
}
export default translate(connect(_mapStateToProps)(MeetingParticipantItem));
export default connect(_mapStateToProps)(MeetingParticipantItem);

View File

@ -66,6 +66,11 @@ type Props = {
*/
searchString?: string,
/**
* Callback used to stop a participant's video.
*/
stopVideo: Function,
/**
* The translated "you" text.
*/
@ -78,28 +83,25 @@ type Props = {
* @returns {ReactNode}
*/
function MeetingParticipantItems({
askUnmuteText,
isInBreakoutRoom,
lowerMenu,
toggleMenu,
muteAudio,
muteParticipantButtonText,
participantIds,
openDrawerForParticipant,
overflowDrawer,
raiseContextId,
participantActionEllipsisLabel,
searchString,
stopVideo,
youText
}: Props) {
const renderParticipant = id => (
<MeetingParticipantItem
askUnmuteText = { askUnmuteText }
isHighlighted = { raiseContextId === id }
isInBreakoutRoom = { isInBreakoutRoom }
key = { id }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
onContextMenu = { toggleMenu(id) }
onLeave = { lowerMenu }
openDrawerForParticipant = { openDrawerForParticipant }
@ -107,6 +109,7 @@ function MeetingParticipantItems({
participantActionEllipsisLabel = { participantActionEllipsisLabel }
participantID = { id }
searchString = { searchString }
stopVideo = { stopVideo }
youText = { youText } />
);

View File

@ -5,7 +5,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../app/types';
import { rejectParticipantAudio } from '../../../av-moderation/actions';
import { rejectParticipantAudio, rejectParticipantVideo } from '../../../av-moderation/actions';
import participantsPaneTheme from '../../../base/components/themes/participantsPaneTheme.json';
import { isToolbarButtonEnabled } from '../../../base/config/functions.web';
import { MEDIA_TYPE } from '../../../base/media/constants';
@ -88,11 +88,14 @@ function MeetingParticipants({
const { t } = useTranslation();
const [ lowerMenu, , toggleMenu, menuEnter, menuLeave, raiseContext ] = useContextMenu();
const muteAudio = useCallback(id => () => {
dispatch(muteRemote(id, MEDIA_TYPE.AUDIO));
dispatch(rejectParticipantAudio(id));
}, [ dispatch ]);
const stopVideo = useCallback(id => () => {
dispatch(muteRemote(id, MEDIA_TYPE.VIDEO));
dispatch(rejectParticipantVideo(id));
}, [ dispatch ]);
const [ drawerParticipant, closeDrawer, openDrawerForParticipant ] = useParticipantDrawer();
// FIXME:
@ -103,8 +106,6 @@ function MeetingParticipants({
// mounted.
const participantActionEllipsisLabel = t('participantsPane.actions.moreParticipantOptions');
const youText = t('chat.you');
const askUnmuteText = t('participantsPane.actions.askUnmute');
const muteParticipantButtonText = t('dialog.muteParticipantButton');
const isBreakoutRoom = useSelector(isInBreakoutRoom);
const visitorsCount = useSelector((state: IReduxState) => state['features/visitors'].count);
@ -136,11 +137,9 @@ function MeetingParticipants({
value = { searchString } />
<div>
<MeetingParticipantItems
askUnmuteText = { askUnmuteText }
isInBreakoutRoom = { isBreakoutRoom }
lowerMenu = { lowerMenu }
muteAudio = { muteAudio }
muteParticipantButtonText = { muteParticipantButtonText }
openDrawerForParticipant = { openDrawerForParticipant }
overflowDrawer = { overflowDrawer }
participantActionEllipsisLabel = { participantActionEllipsisLabel }
@ -148,6 +147,7 @@ function MeetingParticipants({
participantsCount = { participantsCount }
raiseContextId = { raiseContext.entity }
searchString = { normalizeAccents(searchString) }
stopVideo = { stopVideo }
toggleMenu = { toggleMenu }
youText = { youText } />
</div>

View File

@ -14,14 +14,17 @@ interface IProps {
* Click handler function.
*/
onClick: () => void;
participantID?: string;
}
const ParticipantActionEllipsis = ({ accessibilityLabel, onClick }: IProps) => (
const ParticipantActionEllipsis = ({ accessibilityLabel, onClick, participantID }: IProps) => (
<Button
accessibilityLabel = { accessibilityLabel }
icon = { IconDotsHorizontal }
onClick = { onClick }
size = 'small' />
size = 'small'
testId = { participantID ? `participant-more-options-${participantID}` : undefined } />
);
export default ParticipantActionEllipsis;

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { approveParticipant } from '../../../av-moderation/actions';
import { approveParticipantAudio, approveParticipantVideo } from '../../../av-moderation/actions';
import Button from '../../../base/ui/components/web/Button';
import { QUICK_ACTION_BUTTON } from '../../constants';
@ -43,6 +43,12 @@ interface IProps {
* The name of the participant.
*/
participantName: string;
/**
* Callback used to stop a participant's video.
*/
stopVideo: Function;
}
const useStyles = makeStyles()(theme => {
@ -54,19 +60,22 @@ const useStyles = makeStyles()(theme => {
});
const ParticipantQuickAction = ({
askUnmuteText,
buttonType,
muteAudio,
muteParticipantButtonText,
participantID,
participantName
participantName,
stopVideo
}: IProps) => {
const { classes: styles } = useStyles();
const dispatch = useDispatch();
const { t } = useTranslation();
const askToUnmute = useCallback(() => {
dispatch(approveParticipant(participantID));
dispatch(approveParticipantAudio(participantID));
}, [ dispatch, participantID ]);
const allowVideo = useCallback(() => {
dispatch(approveParticipantVideo(participantID));
}, [ dispatch, participantID ]);
switch (buttonType) {
@ -75,10 +84,10 @@ const ParticipantQuickAction = ({
<Button
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
className = { styles.button }
label = { muteParticipantButtonText }
label = { t('participantsPane.actions.mute') }
onClick = { muteAudio(participantID) }
size = 'small'
testId = { `mute-${participantID}` } />
testId = { `mute-audio-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.ASK_TO_UNMUTE: {
@ -86,10 +95,32 @@ const ParticipantQuickAction = ({
<Button
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
className = { styles.button }
label = { askUnmuteText }
label = { t('participantsPane.actions.askUnmute') }
onClick = { askToUnmute }
size = 'small'
testId = { `unmute-${participantID}` } />
testId = { `unmute-audio-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.ALLOW_VIDEO: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.askUnmute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.allowVideo') }
onClick = { allowVideo }
size = 'small'
testId = { `unmute-video-${participantID}` } />
);
}
case QUICK_ACTION_BUTTON.STOP_VIDEO: {
return (
<Button
accessibilityLabel = { `${t('participantsPane.actions.mute')} ${participantName}` }
className = { styles.button }
label = { t('participantsPane.actions.stopVideo') }
onClick = { stopVideo(participantID) }
size = 'small'
testId = { `mute-video-${participantID}` } />
);
}
default: {

View File

@ -36,19 +36,23 @@ export const MEDIA_STATE: { [key: string]: MediaState; } = {
NONE: 'None'
};
export type QuickActionButtonType = 'Mute' | 'AskToUnmute' | 'None';
export type QuickActionButtonType = 'Mute' | 'AskToUnmute' | 'AllowVideo' | 'StopVideo' | 'None';
/**
* Enum of possible participant mute button states.
*/
export const QUICK_ACTION_BUTTON: {
ALLOW_VIDEO: QuickActionButtonType;
ASK_TO_UNMUTE: QuickActionButtonType;
MUTE: QuickActionButtonType;
NONE: QuickActionButtonType;
STOP_VIDEO: QuickActionButtonType;
} = {
ALLOW_VIDEO: 'AllowVideo',
MUTE: 'Mute',
ASK_TO_UNMUTE: 'AskToUnmute',
NONE: 'None'
NONE: 'None',
STOP_VIDEO: 'StopVideo'
};
/**

View File

@ -131,15 +131,28 @@ export const getParticipantsPaneOpen = (state: IReduxState) => Boolean(getState(
*
* @param {IParticipant} participant - The participant.
* @param {boolean} isAudioMuted - If audio is muted for the participant.
* @param {boolean} isVideoMuted - If audio is muted for the participant.
* @param {IReduxState} state - The redux state.
* @returns {string} - The type of the quick action button.
*/
export function getQuickActionButtonType(participant: IParticipant, isAudioMuted: Boolean, state: IReduxState) {
export function getQuickActionButtonType(
participant: IParticipant,
isAudioMuted: Boolean,
isVideoMuted: Boolean,
state: IReduxState) {
// handled only by moderators
const isVideoForceMuted = isForceMuted(participant, MEDIA_TYPE.VIDEO, state);
if (isLocalParticipantModerator(state)) {
if (!isAudioMuted) {
return QUICK_ACTION_BUTTON.MUTE;
}
if (!isVideoMuted) {
return QUICK_ACTION_BUTTON.STOP_VIDEO;
}
if (isVideoForceMuted) {
return QUICK_ACTION_BUTTON.ALLOW_VIDEO;
}
if (isSupported()(state)) {
return QUICK_ACTION_BUTTON.ASK_TO_UNMUTE;
}

View File

@ -1,49 +0,0 @@
// @flow
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveParticipant } from '../../../av-moderation/actions';
import { IconMic } from '../../../base/icons';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
type Props = {
/**
* Whether or not the participant is audio force muted.
*/
isAudioForceMuted: boolean,
/**
* Whether or not the participant is video force muted.
*/
isVideoForceMuted: boolean,
/**
* The ID for the participant on which the button will act.
*/
participantID: string
}
const AskToUnmuteButton = ({ isAudioForceMuted, isVideoForceMuted, participantID }: Props) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const _onClick = useCallback(() => {
dispatch(approveParticipant(participantID));
}, [ participantID ]);
const text = isAudioForceMuted || !isVideoForceMuted
? t('participantsPane.actions.askUnmute')
: t('participantsPane.actions.allowVideo');
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { IconMic }
onClick = { _onClick }
text = { text } />
);
};
export default AskToUnmuteButton;

View File

@ -0,0 +1,61 @@
// @flow
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { approveParticipantAudio, approveParticipantVideo } from '../../../av-moderation/actions';
import { IconMic, IconVideo } from '../../../base/icons/svg';
import { MEDIA_TYPE, MediaType } from '../../../base/media/constants';
import ContextMenuItem from '../../../base/ui/components/web/ContextMenuItem';
type Props = {
buttonType: MediaType;
/**
* The ID for the participant on which the button will act.
*/
participantID: string;
};
const AskToUnmuteButton = ({ buttonType, participantID }: Props) => {
const dispatch = useDispatch();
const { t } = useTranslation();
const _onClick = useCallback(() => {
if (buttonType === MEDIA_TYPE.AUDIO) {
dispatch(approveParticipantAudio(participantID));
} else if (buttonType === MEDIA_TYPE.VIDEO) {
dispatch(approveParticipantVideo(participantID));
}
}, [ participantID, buttonType ]);
const text = useMemo(() => {
if (buttonType === MEDIA_TYPE.AUDIO) {
return t('participantsPane.actions.askUnmute');
} else if (buttonType === MEDIA_TYPE.VIDEO) {
return t('participantsPane.actions.allowVideo');
}
return '';
}, [ buttonType ]);
const icon = useMemo(() => {
if (buttonType === MEDIA_TYPE.AUDIO) {
return IconMic;
} else if (buttonType === MEDIA_TYPE.VIDEO) {
return IconVideo;
}
}, [ buttonType ]);
return (
<ContextMenuItem
accessibilityLabel = { text }
icon = { icon }
onClick = { _onClick }
testId = { `unmute-${buttonType}-${participantID}` }
text = { text } />
);
};
export default AskToUnmuteButton;

View File

@ -1,6 +1,6 @@
/* eslint-disable lines-around-comment */
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
@ -14,14 +14,15 @@ import { MEDIA_TYPE } from '../../../base/media/constants';
import { PARTICIPANT_ROLE } from '../../../base/participants/constants';
import { getLocalParticipant } from '../../../base/participants/functions';
import { IParticipant } from '../../../base/participants/types';
import { isParticipantAudioMuted } from '../../../base/tracks/functions';
import { isParticipantAudioMuted, isParticipantVideoMuted } 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 { displayVerification } from '../../../e2ee/functions';
import { setVolume } from '../../../filmstrip/actions.web';
import { isStageFilmstripAvailable } from '../../../filmstrip/functions.web';
import { isForceMuted } from '../../../participants-pane/functions';
import { QUICK_ACTION_BUTTON } from '../../../participants-pane/constants';
import { getQuickActionButtonType, isForceMuted } from '../../../participants-pane/functions';
// @ts-ignore
import { requestRemoteControl, stopController } from '../../../remote-control';
import { showOverflowDrawer } from '../../../toolbox/functions.web';
@ -139,11 +140,10 @@ const ParticipantContextMenu = ({
const localParticipant = useSelector(getLocalParticipant);
const _isModerator = Boolean(localParticipant?.role === PARTICIPANT_ROLE.MODERATOR);
const _isAudioForceMuted = useSelector<IReduxState>(state =>
isForceMuted(participant, MEDIA_TYPE.AUDIO, state));
const _isVideoForceMuted = useSelector<IReduxState>(state =>
isForceMuted(participant, MEDIA_TYPE.VIDEO, state));
const _isAudioMuted = useSelector((state: IReduxState) => isParticipantAudioMuted(participant, state));
const _isVideoMuted = useSelector((state: IReduxState) => isParticipantVideoMuted(participant, state));
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
const { remoteVideoMenu = {}, disableRemoteMute, startSilent, customParticipantMenuButtons }
= useSelector((state: IReduxState) => state['features/base/config']);
@ -172,6 +172,12 @@ const ParticipantContextMenu = ({
}
, [ thumbnailMenu, _overflowDrawer, drawerParticipant, participant ]);
const isClickedFromParticipantPane = useMemo(
() => !_overflowDrawer && !thumbnailMenu,
[ _overflowDrawer, thumbnailMenu ]);
const quickActionButtonType = useSelector((state: IReduxState) =>
getQuickActionButtonType(participant, _isAudioMuted, _isVideoMuted, state));
const buttons: JSX.Element[] = [];
const buttons2: JSX.Element[] = [];
@ -182,30 +188,44 @@ const ParticipantContextMenu = ({
&& !isNaN(_volume);
if (_isModerator) {
if ((thumbnailMenu || _overflowDrawer) && isModerationSupported && _isAudioMuted) {
buttons.push(<AskToUnmuteButton
isAudioForceMuted = { _isAudioForceMuted }
isVideoForceMuted = { _isVideoForceMuted }
key = 'ask-unmute'
participantID = { _getCurrentParticipantId() } />
);
if (isModerationSupported) {
if (_isAudioMuted
&& !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ASK_TO_UNMUTE)) {
buttons.push(<AskToUnmuteButton
buttonType = { MEDIA_TYPE.AUDIO }
key = 'ask-unmute'
participantID = { _getCurrentParticipantId() } />
);
}
if (_isVideoForceMuted
&& !(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.ALLOW_VIDEO)) {
buttons.push(<AskToUnmuteButton
buttonType = { MEDIA_TYPE.VIDEO }
key = 'allow-video'
participantID = { _getCurrentParticipantId() } />
);
}
}
if (!disableRemoteMute) {
buttons.push(
<MuteButton
key = 'mute'
participantID = { _getCurrentParticipantId() } />
);
if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.MUTE)) {
buttons.push(
<MuteButton
key = 'mute'
participantID = { _getCurrentParticipantId() } />
);
}
buttons.push(
<MuteEveryoneElseButton
key = 'mute-others'
participantID = { _getCurrentParticipantId() } />
);
buttons.push(
<MuteVideoButton
key = 'mute-video'
participantID = { _getCurrentParticipantId() } />
);
if (!(isClickedFromParticipantPane && quickActionButtonType === QUICK_ACTION_BUTTON.STOP_VIDEO)) {
buttons.push(
<MuteVideoButton
key = 'mute-video'
participantID = { _getCurrentParticipantId() } />
);
}
buttons.push(
<MuteEveryoneElsesVideoButton
key = 'mute-others-video'