From b85cd2348f0137bf4ff4a12231d9de5ce869245d Mon Sep 17 00:00:00 2001 From: Gabriel Imre Date: Wed, 15 Jul 2020 13:13:28 +0300 Subject: [PATCH] feat: add grant moderator functionality --- lang/main.json | 4 ++ react/features/base/icons/svg/crown.svg | 3 + react/features/base/icons/svg/index.js | 1 + .../features/base/participants/actionTypes.js | 10 +++ react/features/base/participants/actions.js | 17 +++++ react/features/base/participants/functions.js | 18 +++-- .../features/base/participants/middleware.js | 8 +++ .../testing/components/TestConnectionInfo.js | 11 ++- .../AbstractGrantModeratorButton.js | 70 +++++++++++++++++++ .../AbstractGrantModeratorDialog.js | 66 +++++++++++++++++ .../components/native/GrantModeratorButton.js | 9 +++ .../components/native/GrantModeratorDialog.js | 32 +++++++++ .../components/native/RemoteVideoMenu.js | 3 + .../components/native/index.js | 3 + .../components/web/GrantModeratorButton.js | 60 ++++++++++++++++ .../components/web/GrantModeratorDialog.js | 38 ++++++++++ .../web/RemoteVideoMenuTriggerButton.js | 7 ++ .../remote-video-menu/components/web/index.js | 4 ++ 18 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 react/features/base/icons/svg/crown.svg create mode 100644 react/features/remote-video-menu/components/AbstractGrantModeratorButton.js create mode 100644 react/features/remote-video-menu/components/AbstractGrantModeratorDialog.js create mode 100644 react/features/remote-video-menu/components/native/GrantModeratorButton.js create mode 100644 react/features/remote-video-menu/components/native/GrantModeratorDialog.js create mode 100644 react/features/remote-video-menu/components/web/GrantModeratorButton.js create mode 100644 react/features/remote-video-menu/components/web/GrantModeratorDialog.js diff --git a/lang/main.json b/lang/main.json index 7ba137976..4758bc7dd 100644 --- a/lang/main.json +++ b/lang/main.json @@ -203,6 +203,8 @@ "enterDisplayName": "Please enter your name here", "error": "Error", "gracefulShutdown": "Our service is currently down for maintenance. Please try again later.", + "grantModeratorDialog": "Are you sure you want to make this participant a moderator?", + "grantModeratorTitle": "Grant moderator", "IamHost": "I am the host", "incorrectRoomLockPassword": "Incorrect password", "incorrectPassword": "Incorrect username or password", @@ -669,6 +671,7 @@ "e2ee": "End-to-End Encryption", "feedback": "Leave feedback", "fullScreen": "Toggle full screen", + "grantModerator": "Grant Moderator", "hangup": "Leave the call", "help": "Help", "invite": "Invite people", @@ -817,6 +820,7 @@ "domute": "Mute", "domuteOthers": "Mute everyone else", "flip": "Flip", + "grantModerator": "Grant Moderator", "kick": "Kick out", "moderator": "Moderator", "mute": "Participant is muted", diff --git a/react/features/base/icons/svg/crown.svg b/react/features/base/icons/svg/crown.svg new file mode 100644 index 000000000..c35d74923 --- /dev/null +++ b/react/features/base/icons/svg/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/react/features/base/icons/svg/index.js b/react/features/base/icons/svg/index.js index 11ee2e1b8..b9f38b58f 100644 --- a/react/features/base/icons/svg/index.js +++ b/react/features/base/icons/svg/index.js @@ -23,6 +23,7 @@ export { default as IconClosedCaption } from './closed_caption.svg'; export { default as IconConnectionActive } from './gsm-bars.svg'; export { default as IconConnectionInactive } from './ninja.svg'; export { default as IconCopy } from './copy.svg'; +export { default as IconCrown } from './crown.svg'; export { default as IconDeviceBluetooth } from './bluetooth.svg'; export { default as IconDeviceEarpiece } from './phone-talk.svg'; export { default as IconDeviceHeadphone } from './headset.svg'; diff --git a/react/features/base/participants/actionTypes.js b/react/features/base/participants/actionTypes.js index 377a45b5e..3e3eb1b7c 100644 --- a/react/features/base/participants/actionTypes.js +++ b/react/features/base/participants/actionTypes.js @@ -12,6 +12,16 @@ */ export const DOMINANT_SPEAKER_CHANGED = 'DOMINANT_SPEAKER_CHANGED'; +/** + * Create an action for granting moderator to a participant. + * + * { + * type: GRANT_MODERATOR, + * id: string + * } + */ +export const GRANT_MODERATOR = 'GRANT_MODERATOR'; + /** * Create an action for removing a participant from the conference. * diff --git a/react/features/base/participants/actions.js b/react/features/base/participants/actions.js index efb1f8382..c5b2dd32c 100644 --- a/react/features/base/participants/actions.js +++ b/react/features/base/participants/actions.js @@ -5,6 +5,7 @@ import { DOMINANT_SPEAKER_CHANGED, HIDDEN_PARTICIPANT_JOINED, HIDDEN_PARTICIPANT_LEFT, + GRANT_MODERATOR, KICK_PARTICIPANT, MUTE_REMOTE_PARTICIPANT, PARTICIPANT_ID_CHANGED, @@ -47,6 +48,22 @@ export function dominantSpeakerChanged(id, conference) { }; } +/** + * Create an action for granting moderator to a participant. + * + * @param {string} id - Participant's ID. + * @returns {{ + * type: GRANT_MODERATOR, + * id: string + * }} + */ +export function grantModerator(id) { + return { + type: GRANT_MODERATOR, + id + }; +} + /** * Create an action for removing a participant from the conference. * diff --git a/react/features/base/participants/functions.js b/react/features/base/participants/functions.js index afb1aa78a..718a21fe7 100644 --- a/react/features/base/participants/functions.js +++ b/react/features/base/participants/functions.js @@ -259,6 +259,16 @@ export function getYoutubeParticipant(stateful: Object | Function) { return participants.filter(p => p.isFakeParticipant)[0]; } +/** + * Returns true if the participant is a moderator. + * + * @param {string} participant - Participant object. + * @returns {boolean} + */ +export function isParticipantModerator(participant: Object) { + return participant?.role === PARTICIPANT_ROLE.MODERATOR; +} + /** * Returns true if all of the meeting participants are moderators. * @@ -269,13 +279,7 @@ export function getYoutubeParticipant(stateful: Object | Function) { export function isEveryoneModerator(stateful: Object | Function) { const participants = _getAllParticipants(stateful); - for (const participant of participants) { - if (participant.role !== PARTICIPANT_ROLE.MODERATOR) { - return false; - } - } - - return true; + return participants.every(isParticipantModerator); } /** diff --git a/react/features/base/participants/middleware.js b/react/features/base/participants/middleware.js index e30062a73..01cac6f90 100644 --- a/react/features/base/participants/middleware.js +++ b/react/features/base/participants/middleware.js @@ -15,6 +15,7 @@ import { playSound, registerSound, unregisterSound } from '../sounds'; import { DOMINANT_SPEAKER_CHANGED, + GRANT_MODERATOR, KICK_PARTICIPANT, MUTE_REMOTE_PARTICIPANT, PARTICIPANT_DISPLAY_NAME_CHANGED, @@ -86,6 +87,13 @@ MiddlewareRegistry.register(store => next => action => { break; } + case GRANT_MODERATOR: { + const { conference } = store.getState()['features/base/conference']; + + conference.grantOwner(action.id); + break; + } + case KICK_PARTICIPANT: { const { conference } = store.getState()['features/base/conference']; diff --git a/react/features/base/testing/components/TestConnectionInfo.js b/react/features/base/testing/components/TestConnectionInfo.js index 6d1993114..c0e9952a1 100644 --- a/react/features/base/testing/components/TestConnectionInfo.js +++ b/react/features/base/testing/components/TestConnectionInfo.js @@ -36,6 +36,11 @@ type Props = { */ _localUserId: string, + /** + * The local participant's role. + */ + _localUserRole: string, + /** * Indicates whether or not the test mode is currently on. Otherwise the * TestConnectionInfo component will not render. @@ -179,6 +184,9 @@ class TestConnectionInfo extends Component { + @@ -208,7 +216,8 @@ function _mapStateToProps(state) { return { _conferenceConnectionState: state['features/testing'].connectionState, _conferenceJoinedState: conferenceJoined.toString(), - _localUserId: localParticipant && localParticipant.id, + _localUserId: localParticipant?.id, + _localUserRole: localParticipant?.role, _testMode: isTestModeEnabled(state) }; } diff --git a/react/features/remote-video-menu/components/AbstractGrantModeratorButton.js b/react/features/remote-video-menu/components/AbstractGrantModeratorButton.js new file mode 100644 index 000000000..426f71890 --- /dev/null +++ b/react/features/remote-video-menu/components/AbstractGrantModeratorButton.js @@ -0,0 +1,70 @@ +// @flow + +import { openDialog } from '../../base/dialog'; +import { IconCrown } from '../../base/icons'; +import { + getParticipantById, + isLocalParticipantModerator, + isParticipantModerator +} from '../../base/participants'; +import { AbstractButton } from '../../base/toolbox'; +import type { AbstractButtonProps } from '../../base/toolbox'; + +import { GrantModeratorDialog } from '.'; + +export type Props = AbstractButtonProps & { + + /** + * The redux {@code dispatch} function. + */ + dispatch: Function, + + /** + * The ID of the participant for whom to grant moderator status. + */ + participantID: string, + + /** + * The function to be used to translate i18n labels. + */ + t: Function +}; + +/** + * An abstract remote video menu button which kicks the remote participant. + */ +export default class AbstractGrantModeratorButton extends AbstractButton { + accessibilityLabel = 'toolbar.accessibilityLabel.grantModerator'; + icon = IconCrown; + label = 'videothumbnail.grantModerator'; + + /** + * Handles clicking / pressing the button, and kicks the participant. + * + * @private + * @returns {void} + */ + _handleClick() { + const { dispatch, participantID } = this.props; + + dispatch(openDialog(GrantModeratorDialog, { participantID })); + } +} + +/** + * Function that maps parts of Redux state tree into component props. + * + * @param {Object} state - Redux state. + * @param {Object} ownProps - Properties of component. + * @private + * @returns {{ + * visible: boolean + * }} + */ +export function _mapStateToProps(state: Object, ownProps: Props) { + const { participantID } = ownProps; + + return { + visible: isLocalParticipantModerator(state) && !isParticipantModerator(getParticipantById(state, participantID)) + }; +} diff --git a/react/features/remote-video-menu/components/AbstractGrantModeratorDialog.js b/react/features/remote-video-menu/components/AbstractGrantModeratorDialog.js new file mode 100644 index 000000000..74c7064fd --- /dev/null +++ b/react/features/remote-video-menu/components/AbstractGrantModeratorDialog.js @@ -0,0 +1,66 @@ +// @flow + +import { Component } from 'react'; + +import { + createRemoteVideoMenuButtonEvent, + sendAnalytics +} from '../../analytics'; +import { grantModerator } from '../../base/participants'; + +type Props = { + + /** + * The Redux dispatch function. + */ + dispatch: Function, + + /** + * The ID of the remote participant to be granted moderator rights. + */ + participantID: string, + + /** + * Function to translate i18n labels. + */ + t: Function +}; + +/** + * Abstract dialog to confirm granting moderator to a participant. + */ +export default class AbstractGrantModeratorDialog + extends Component { + /** + * Initializes a new {@code AbstractGrantModeratorDialog} instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onSubmit = this._onSubmit.bind(this); + } + + _onSubmit: () => boolean; + + /** + * Callback for the confirm button. + * + * @private + * @returns {boolean} - True (to note that the modal should be closed). + */ + _onSubmit() { + const { dispatch, participantID } = this.props; + + sendAnalytics(createRemoteVideoMenuButtonEvent( + 'grant.moderator.button', + { + 'participant_id': participantID + })); + + dispatch(grantModerator(participantID)); + + return true; + } +} diff --git a/react/features/remote-video-menu/components/native/GrantModeratorButton.js b/react/features/remote-video-menu/components/native/GrantModeratorButton.js new file mode 100644 index 000000000..0f02a15d6 --- /dev/null +++ b/react/features/remote-video-menu/components/native/GrantModeratorButton.js @@ -0,0 +1,9 @@ +// @flow + +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractGrantModeratorButton, { + _mapStateToProps +} from '../AbstractGrantModeratorButton'; + +export default translate(connect(_mapStateToProps)(AbstractGrantModeratorButton)); diff --git a/react/features/remote-video-menu/components/native/GrantModeratorDialog.js b/react/features/remote-video-menu/components/native/GrantModeratorDialog.js new file mode 100644 index 000000000..4e36bc63d --- /dev/null +++ b/react/features/remote-video-menu/components/native/GrantModeratorDialog.js @@ -0,0 +1,32 @@ +// @flow + +import React from 'react'; + +import { ConfirmDialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractGrantModeratorDialog + from '../AbstractGrantModeratorDialog'; + +/** + * Dialog to confirm a remote participant kick action. + */ +class GrantModeratorDialog extends AbstractGrantModeratorDialog { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + + ); + } + + _onSubmit: () => boolean; +} + +export default translate(connect()(GrantModeratorDialog)); diff --git a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js index 1fe8da96c..42602f868 100644 --- a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js +++ b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js @@ -12,6 +12,7 @@ import { StyleType } from '../../../base/styles'; import { PrivateMessageButton } from '../../../chat'; import { hideRemoteVideoMenu } from '../../actions'; +import GrantModeratorButton from './GrantModeratorButton'; import KickButton from './KickButton'; import MuteButton from './MuteButton'; import PinButton from './PinButton'; @@ -98,6 +99,8 @@ class RemoteVideoMenu extends Component { buttons.push(); } + buttons.push(); + if (!_disableKick) { buttons.push(); } diff --git a/react/features/remote-video-menu/components/native/index.js b/react/features/remote-video-menu/components/native/index.js index 6c509ecae..085d8d196 100644 --- a/react/features/remote-video-menu/components/native/index.js +++ b/react/features/remote-video-menu/components/native/index.js @@ -1,5 +1,8 @@ // @flow +export { + default as GrantModeratorDialog +} from './GrantModeratorDialog'; export { default as KickRemoteParticipantDialog } from './KickRemoteParticipantDialog'; diff --git a/react/features/remote-video-menu/components/web/GrantModeratorButton.js b/react/features/remote-video-menu/components/web/GrantModeratorButton.js new file mode 100644 index 000000000..37906203d --- /dev/null +++ b/react/features/remote-video-menu/components/web/GrantModeratorButton.js @@ -0,0 +1,60 @@ +/* @flow */ + +import React from 'react'; + +import { translate } from '../../../base/i18n'; +import { IconCrown } from '../../../base/icons'; +import { connect } from '../../../base/redux'; +import AbstractGrantModeratorButton, { + _mapStateToProps, + type Props +} from '../AbstractGrantModeratorButton'; + +import RemoteVideoMenuButton from './RemoteVideoMenuButton'; + +declare var interfaceConfig: Object; + +/** + * Implements a React {@link Component} which displays a button for granting + * moderator to a participant. + */ +class GrantModeratorButton extends AbstractGrantModeratorButton { + /** + * Instantiates a new {@code GrantModeratorButton}. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._handleClick = this._handleClick.bind(this); + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { participantID, t, visible } = this.props; + + if (!visible) { + return null; + } + + return ( + + ); + } + + _handleClick: () => void +} + +export default translate(connect(_mapStateToProps)(GrantModeratorButton)); diff --git a/react/features/remote-video-menu/components/web/GrantModeratorDialog.js b/react/features/remote-video-menu/components/web/GrantModeratorDialog.js new file mode 100644 index 000000000..b99275ebe --- /dev/null +++ b/react/features/remote-video-menu/components/web/GrantModeratorDialog.js @@ -0,0 +1,38 @@ +// @flow + +import React from 'react'; + +import { Dialog } from '../../../base/dialog'; +import { translate } from '../../../base/i18n'; +import { connect } from '../../../base/redux'; +import AbstractGrantModeratorDialog + from '../AbstractGrantModeratorDialog'; + +/** + * Dialog to confirm a grant moderator action. + */ +class GrantModeratorDialog extends AbstractGrantModeratorDialog { + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + return ( + +
+ { this.props.t('dialog.grantModeratorDialog') } +
+
+ ); + } + + _onSubmit: () => boolean; +} + +export default translate(connect()(GrantModeratorDialog)); diff --git a/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js b/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js index b1ed1947c..d569d058f 100644 --- a/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js +++ b/react/features/remote-video-menu/components/web/RemoteVideoMenuTriggerButton.js @@ -8,6 +8,7 @@ import { Popover } from '../../../base/popover'; import { connect } from '../../../base/redux'; import { + GrantModeratorButton, MuteButton, MuteEveryoneElseButton, KickButton, @@ -195,6 +196,12 @@ class RemoteVideoMenuTriggerButton extends Component { ); } + buttons.push( + + ); + if (!_disableKick) { buttons.push(