init
Some checks failed
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2025-09-02 14:49:16 +08:00
commit 38ba663466
2885 changed files with 391107 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import {
AUDIO_MUTE,
DESKTOP_MUTE,
VIDEO_MUTE,
createRemoteMuteConfirmedEvent,
createToolbarEvent
} from '../analytics/AnalyticsEvents';
import { sendAnalytics } from '../analytics/functions';
import { IStore } from '../app/types';
import {
rejectParticipantAudio,
rejectParticipantDesktop,
rejectParticipantVideo
} from '../av-moderation/actions';
import { setAudioMuted, setScreenshareMuted, setVideoMuted } from '../base/media/actions';
import {
MEDIA_TYPE,
MediaType,
SCREENSHARE_MUTISM_AUTHORITY,
VIDEO_MUTISM_AUTHORITY
} from '../base/media/constants';
import { muteRemoteParticipant } from '../base/participants/actions';
import { getRemoteParticipants } from '../base/participants/functions';
import logger from './logger';
/**
* Mutes the local participant.
*
* @param {boolean} enable - Whether to mute or unmute.
* @param {MEDIA_TYPE} mediaType - The type of the media channel to mute.
* @returns {Function}
*/
export function muteLocal(enable: boolean, mediaType: MediaType) {
return (dispatch: IStore['dispatch']) => {
switch (mediaType) {
case MEDIA_TYPE.AUDIO: {
sendAnalytics(createToolbarEvent(AUDIO_MUTE, { enable }));
dispatch(setAudioMuted(enable, /* ensureTrack */ true));
break;
}
case MEDIA_TYPE.SCREENSHARE: {
sendAnalytics(createToolbarEvent(DESKTOP_MUTE, { enable }));
dispatch(setScreenshareMuted(enable, SCREENSHARE_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));
break;
}
case MEDIA_TYPE.VIDEO: {
sendAnalytics(createToolbarEvent(VIDEO_MUTE, { enable }));
dispatch(setVideoMuted(enable, VIDEO_MUTISM_AUTHORITY.USER, /* ensureTrack */ true));
break;
}
default: {
logger.error(`Unsupported media type: ${mediaType}`);
return;
}
}
};
}
/**
* Mutes the remote participant with the given ID.
*
* @param {string} participantId - ID of the participant to mute.
* @param {MEDIA_TYPE} mediaType - The type of the media channel to mute.
* @returns {Function}
*/
export function muteRemote(participantId: string, mediaType: MediaType) {
return (dispatch: IStore['dispatch']) => {
sendAnalytics(createRemoteMuteConfirmedEvent(participantId, mediaType));
// TODO(saghul): reconcile these 2 types.
const muteMediaType = mediaType === MEDIA_TYPE.SCREENSHARE ? 'desktop' : mediaType;
dispatch(muteRemoteParticipant(participantId, muteMediaType));
};
}
/**
* Mutes all participants.
*
* @param {Array<string>} exclude - Array of participant IDs to not mute.
* @param {MEDIA_TYPE} mediaType - The media type to mute.
* @returns {Function}
*/
export function muteAllParticipants(exclude: Array<string>, mediaType: MediaType) {
return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
const state = getState();
getRemoteParticipants(state).forEach((p, id) => {
if (exclude.includes(id)) {
return;
}
dispatch(muteRemote(id, mediaType));
if (mediaType === MEDIA_TYPE.AUDIO) {
dispatch(rejectParticipantAudio(id));
} else if (mediaType === MEDIA_TYPE.VIDEO) {
dispatch(rejectParticipantVideo(id));
} else if (mediaType === MEDIA_TYPE.SCREENSHARE) {
dispatch(rejectParticipantDesktop(id));
}
});
};
}

View File

@@ -0,0 +1 @@
export * from './actions.any';

View File

@@ -0,0 +1,17 @@
import { SHOW_CONNECTION_INFO } from '../base/connection/actionTypes';
export * from './actions.any';
/**
* Sets whether to render the connection status info into the Popover of the thumbnail or the context menu buttons.
*
* @param {boolean} showConnectionInfo - Whether it should show the connection
* info or the context menu buttons on thumbnail popover.
* @returns {Object}
*/
export function renderConnectionStatus(showConnectionInfo: boolean) {
return {
type: SHOW_CONNECTION_INFO,
showConnectionInfo
};
}

View File

@@ -0,0 +1,59 @@
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 AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { GrantModeratorDialog } from './';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the participant for whom to grant moderator status.
*/
participantID: string;
}
/**
* An abstract remote video menu button which kicks the remote participant.
*/
export default class AbstractGrantModeratorButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.grantModerator';
override icon = IconModerator;
override label = 'videothumbnail.grantModerator';
/**
* Handles clicking / pressing the button, and kicks the participant.
*
* @private
* @returns {void}
*/
override _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: IReduxState, ownProps: any) {
const { participantID } = ownProps;
const localParticipant = getLocalParticipant(state);
const targetParticipant = getParticipantById(state, participantID);
return {
visible: Boolean(localParticipant?.role === PARTICIPANT_ROLE.MODERATOR)
&& !isParticipantModerator(targetParticipant)
};
}

View File

@@ -0,0 +1,77 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createRemoteVideoMenuButtonEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IReduxState, IStore } from '../../app/types';
import { grantModerator } from '../../base/participants/actions';
import { getParticipantById } from '../../base/participants/functions';
interface IProps extends WithTranslation {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The ID of the remote participant to be granted moderator rights.
*/
participantID: string;
/**
* The name of the remote participant to be granted moderator rights.
*/
participantName?: string;
}
/**
* Abstract dialog to confirm granting moderator to a participant.
*/
export default class AbstractGrantModeratorDialog
extends Component<IProps> {
/**
* Initializes a new {@code AbstractGrantModeratorDialog} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* 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;
}
}
/**
* Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryoneDialog}'s props.
*
* @param {IReduxState} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @returns {IProps}
*/
export function abstractMapStateToProps(state: IReduxState, ownProps: IProps) {
return {
participantName: getParticipantById(state, ownProps.participantID)?.name
};
}

View File

@@ -0,0 +1,34 @@
import { openDialog } from '../../base/dialog/actions';
import { IconUserDeleted } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { KickRemoteParticipantDialog } from './';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the participant that this button is supposed to kick.
*/
participantID: string;
}
/**
* An abstract remote video menu button which kicks the remote participant.
*/
export default class AbstractKickButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.kick';
override icon = IconUserDeleted;
override label = 'videothumbnail.kick';
/**
* Handles clicking / pressing the button, and kicks the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
dispatch(openDialog(KickRemoteParticipantDialog, { participantID }));
}
}

View File

@@ -0,0 +1,57 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { createRemoteVideoMenuButtonEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { IStore } from '../../app/types';
import { kickParticipant } from '../../base/participants/actions';
interface IProps extends WithTranslation {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The ID of the remote participant to be kicked.
*/
participantID: string;
}
/**
* Abstract dialog to confirm a remote participant kick action.
*/
export default class AbstractKickRemoteParticipantDialog
extends Component<IProps> {
/**
* Initializes a new {@code AbstractKickRemoteParticipantDialog} instance.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* 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(
'kick.button',
{
'participant_id': participantID
}));
dispatch(kickParticipant(participantID));
return true;
}
}

View File

@@ -0,0 +1,90 @@
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 AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { isRemoteTrackMuted } from '../../base/tracks/functions.any';
import { muteRemote } from '../actions.any';
export interface IProps extends AbstractButtonProps {
/**
* Boolean to indicate if the audio track of the participant is muted or
* not.
*/
_audioTrackMuted: boolean;
/**
* The ID of the participant object that this button is supposed to
* mute/unmute.
*/
participantID: string;
}
/**
* An abstract remote video menu button which mutes the remote participant.
*/
export default class AbstractMuteButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.remoteMute';
override icon = IconMicSlash;
override label = 'videothumbnail.domute';
override toggledLabel = 'videothumbnail.muted';
/**
* Handles clicking / pressing the button, and mutes the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
sendAnalytics(createRemoteVideoMenuButtonEvent(
'mute',
{
'participant_id': participantID
}));
dispatch(muteRemote(participantID, MEDIA_TYPE.AUDIO));
dispatch(rejectParticipantAudio(participantID));
}
/**
* Renders the item disabled if the participant is muted.
*
* @inheritdoc
*/
override _isDisabled() {
return this.props._audioTrackMuted;
}
/**
* Renders the item toggled if the participant is muted.
*
* @inheritdoc
*/
override _isToggled() {
return this.props._audioTrackMuted;
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {{
* _audioTrackMuted: boolean
* }}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const tracks = state['features/base/tracks'];
return {
_audioTrackMuted: isRemoteTrackMuted(
tracks, MEDIA_TYPE.AUDIO, ownProps.participantID)
};
}

View File

@@ -0,0 +1,131 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { requestDisableAudioModeration, requestEnableAudioModeration } from '../../av-moderation/actions';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../av-moderation/constants';
import { isEnabledFromState, isSupported } from '../../av-moderation/functions';
import { MEDIA_TYPE } from '../../base/media/constants';
import { getLocalParticipant, getParticipantDisplayName, isEveryoneModerator } from '../../base/participants/functions';
import { muteAllParticipants } from '../actions';
/**
* The type of the React {@code Component} props of
* {@link AbstractMuteEveryoneDialog}.
*/
export interface IProps extends WithTranslation {
content?: string;
dispatch: IStore['dispatch'];
exclude: Array<string>;
isAudioModerationEnabled?: boolean;
isEveryoneModerator: boolean;
isModerationSupported?: boolean;
participantID: string;
showAdvancedModerationToggle: boolean;
title: string;
}
interface IState {
audioModerationEnabled?: boolean;
content: string;
}
/**
*
* An abstract Component with the contents for a dialog that asks for confirmation
* from the user before muting all remote participants.
*
*/
export default class AbstractMuteEveryoneDialog<P extends IProps> extends Component<P, IState> {
static defaultProps = {
exclude: [],
muteLocal: false
};
/**
* Initializes a new {@code AbstractMuteEveryoneDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
this.state = {
audioModerationEnabled: props.isAudioModerationEnabled,
content: props.content || props.t(props.isAudioModerationEnabled
? 'dialog.muteEveryoneDialogModerationOn' : 'dialog.muteEveryoneDialog'
)
};
// Bind event handlers so they are only bound once per instance.
this._onSubmit = this._onSubmit.bind(this);
this._onToggleModeration = this._onToggleModeration.bind(this);
}
/**
* Toggles advanced moderation switch.
*
* @returns {void}
*/
_onToggleModeration() {
this.setState(state => {
return {
audioModerationEnabled: !state.audioModerationEnabled,
content: this.props.t(state.audioModerationEnabled
? 'dialog.muteEveryoneDialog' : 'dialog.muteEveryoneDialogModerationOn'
)
};
});
}
/**
* Callback to be invoked when the value of this dialog is submitted.
*
* @returns {boolean}
*/
_onSubmit() {
const {
dispatch,
exclude
} = this.props;
dispatch(muteAllParticipants(exclude, MEDIA_TYPE.AUDIO));
if (this.state.audioModerationEnabled) {
dispatch(requestEnableAudioModeration());
} else if (this.state.audioModerationEnabled !== undefined) {
dispatch(requestDisableAudioModeration());
}
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryoneDialog}'s props.
*
* @param {IReduxState} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @returns {IProps}
*/
export function abstractMapStateToProps(state: IReduxState, ownProps: IProps) {
const { exclude = [], t } = ownProps;
const whom = exclude
// eslint-disable-next-line no-confusing-arrow
.map(id => id === getLocalParticipant(state)?.id
? t('dialog.muteEveryoneSelf')
: getParticipantDisplayName(state, id))
.join(', ');
return whom.length ? {
content: t('dialog.muteEveryoneElseDialog'),
title: t('dialog.muteEveryoneElseTitle', { whom }),
isEveryoneModerator: isEveryoneModerator(state)
} : {
title: t('dialog.muteEveryoneTitle'),
isAudioModerationEnabled: isEnabledFromState(AVM_MEDIA_TYPE.AUDIO, state),
isModerationSupported: isSupported()(state),
isEveryoneModerator: isEveryoneModerator(state)
};
}

View File

@@ -0,0 +1,37 @@
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { openDialog } from '../../base/dialog/actions';
import { IconMicSlash } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { MuteEveryoneDialog } from './';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the participant object that this button is supposed to keep unmuted.
*/
participantID: string;
}
/**
* An abstract remote video menu button which mutes all the other participants.
*/
export default class AbstractMuteEveryoneElseButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.muteEveryoneElse';
override icon = IconMicSlash;
override label = 'videothumbnail.domuteOthers';
/**
* Handles clicking / pressing the button, and opens a confirmation dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
sendAnalytics(createToolbarEvent('mute.everyoneelse.pressed'));
dispatch(openDialog(MuteEveryoneDialog, { exclude: [ participantID ] }));
}
}

View File

@@ -0,0 +1,37 @@
import { createToolbarEvent } from '../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../analytics/functions';
import { openDialog } from '../../base/dialog/actions';
import { IconVideoOff } from '../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { MuteEveryonesVideoDialog } from './';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the participant object that this button is supposed to keep unmuted.
*/
participantID: string;
}
/**
* An abstract remote video menu button which disables the camera of all the other participants.
*/
export default class AbstractMuteEveryoneElsesVideoButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.muteEveryoneElsesVideoStream';
override icon = IconVideoOff;
override label = 'videothumbnail.domuteVideoOfOthers';
/**
* Handles clicking / pressing the button, and opens a confirmation dialog.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
sendAnalytics(createToolbarEvent('mute.everyoneelsesvideo.pressed'));
dispatch(openDialog(MuteEveryonesVideoDialog, { exclude: [ participantID ] }));
}
}

View File

@@ -0,0 +1,130 @@
import { IReduxState } from '../../app/types';
import { requestDisableDesktopModeration, requestEnableDesktopModeration } from '../../av-moderation/actions';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../av-moderation/constants';
import { isEnabledFromState, isSupported } from '../../av-moderation/functions';
import { MEDIA_TYPE } from '../../base/media/constants';
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/functions';
import { muteAllParticipants } from '../actions';
import AbstractMuteRemoteParticipantsDesktopDialog, {
type IProps as AbstractProps
} from './AbstractMuteRemoteParticipantsDesktopDialog';
/**
* The type of the React {@code Component} props of
* {@link AbstractMuteEveryonesDesktopDialog}.
*/
export interface IProps extends AbstractProps {
content?: string;
exclude: Array<string>;
isModerationEnabled?: boolean;
isModerationSupported?: boolean;
showAdvancedModerationToggle: boolean;
title: string;
}
interface IState {
content: string;
moderationEnabled?: boolean;
}
/**
*
* An abstract Component with the contents for a dialog that asks for confirmation
* from the user before disabling all remote participants cameras.
*
* @augments AbstractMuteRemoteParticipantsDesktopDialog
*/
export default class AbstractMuteEveryonesDesktopDialog<P extends IProps>
extends AbstractMuteRemoteParticipantsDesktopDialog<P, IState> {
static defaultProps = {
exclude: [],
muteLocal: false
};
/**
* Initializes a new {@code AbstractMuteRemoteParticipantsDesktopDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
this.state = {
moderationEnabled: props.isModerationEnabled,
content: props.content || props.t(props.isModerationEnabled
? 'dialog.muteEveryonesDesktopDialogModerationOn' : 'dialog.muteEveryonesDesktopDialog'
)
};
// Bind event handlers so they are only bound once per instance.
this._onSubmit = this._onSubmit.bind(this);
this._onToggleModeration = this._onToggleModeration.bind(this);
}
/**
* Toggles advanced moderation switch.
*
* @returns {void}
*/
_onToggleModeration() {
this.setState(state => {
return {
moderationEnabled: !state.moderationEnabled,
content: this.props.t(state.moderationEnabled
? 'dialog.muteEveryonesDesktopDialog' : 'dialog.muteEveryonesDesktopDialogModerationOn'
)
};
});
}
/**
* Callback to be invoked when the value of this dialog is submitted.
*
* @returns {boolean}
*/
override _onSubmit() {
const {
dispatch,
exclude
} = this.props;
dispatch(muteAllParticipants(exclude, MEDIA_TYPE.SCREENSHARE));
if (this.state.moderationEnabled) {
dispatch(requestEnableDesktopModeration());
} else if (this.state.moderationEnabled !== undefined) {
dispatch(requestDisableDesktopModeration());
}
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryonesDesktopDialog}'s props.
*
* @param {IReduxState} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @returns {IProps}
*/
export function abstractMapStateToProps(state: IReduxState, ownProps: IProps) {
const { exclude = [], t } = ownProps;
const isModerationEnabled = isEnabledFromState(AVM_MEDIA_TYPE.DESKTOP, state);
const whom = exclude
// eslint-disable-next-line no-confusing-arrow
.map(id => id === getLocalParticipant(state)?.id
? t('dialog.muteEveryoneSelf')
: getParticipantDisplayName(state, id))
.join(', ');
return whom.length ? {
content: t('dialog.muteEveryoneElsesDesktopDialog'),
title: t('dialog.muteEveryoneElsesDesktopTitle', { whom })
} : {
title: t('dialog.muteEveryonesDesktopTitle'),
isModerationEnabled,
isModerationSupported: isSupported()(state)
};
}

View File

@@ -0,0 +1,130 @@
import { IReduxState } from '../../app/types';
import { requestDisableVideoModeration, requestEnableVideoModeration } from '../../av-moderation/actions';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../av-moderation/constants';
import { isEnabledFromState, isSupported } from '../../av-moderation/functions';
import { MEDIA_TYPE } from '../../base/media/constants';
import { getLocalParticipant, getParticipantDisplayName } from '../../base/participants/functions';
import { muteAllParticipants } from '../actions';
import AbstractMuteRemoteParticipantsVideoDialog, {
type IProps as AbstractProps
} from './AbstractMuteRemoteParticipantsVideoDialog';
/**
* The type of the React {@code Component} props of
* {@link AbstractMuteEveryonesVideoDialog}.
*/
export interface IProps extends AbstractProps {
content?: string;
exclude: Array<string>;
isModerationSupported?: boolean;
isVideoModerationEnabled?: boolean;
showAdvancedModerationToggle: boolean;
title: string;
}
interface IState {
content: string;
moderationEnabled?: boolean;
}
/**
*
* An abstract Component with the contents for a dialog that asks for confirmation
* from the user before disabling all remote participants cameras.
*
* @augments AbstractMuteRemoteParticipantsVideoDialog
*/
export default class AbstractMuteEveryonesVideoDialog<P extends IProps>
extends AbstractMuteRemoteParticipantsVideoDialog<P, IState> {
static defaultProps = {
exclude: [],
muteLocal: false
};
/**
* Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
this.state = {
moderationEnabled: props.isVideoModerationEnabled,
content: props.content || props.t(props.isVideoModerationEnabled
? 'dialog.muteEveryonesVideoDialogModerationOn' : 'dialog.muteEveryonesVideoDialog'
)
};
// Bind event handlers so they are only bound once per instance.
this._onSubmit = this._onSubmit.bind(this);
this._onToggleModeration = this._onToggleModeration.bind(this);
}
/**
* Toggles advanced moderation switch.
*
* @returns {void}
*/
_onToggleModeration() {
this.setState(state => {
return {
moderationEnabled: !state.moderationEnabled,
content: this.props.t(state.moderationEnabled
? 'dialog.muteEveryonesVideoDialog' : 'dialog.muteEveryonesVideoDialogModerationOn'
)
};
});
}
/**
* Callback to be invoked when the value of this dialog is submitted.
*
* @returns {boolean}
*/
override _onSubmit() {
const {
dispatch,
exclude
} = this.props;
dispatch(muteAllParticipants(exclude, MEDIA_TYPE.VIDEO));
if (this.state.moderationEnabled) {
dispatch(requestEnableVideoModeration());
} else if (this.state.moderationEnabled !== undefined) {
dispatch(requestDisableVideoModeration());
}
return true;
}
}
/**
* Maps (parts of) the Redux state to the associated {@code AbstractMuteEveryonesVideoDialog}'s props.
*
* @param {IReduxState} state - The redux state.
* @param {Object} ownProps - The properties explicitly passed to the component.
* @returns {IProps}
*/
export function abstractMapStateToProps(state: IReduxState, ownProps: IProps) {
const { exclude = [], t } = ownProps;
const isVideoModerationEnabled = isEnabledFromState(AVM_MEDIA_TYPE.VIDEO, state);
const whom = exclude
// eslint-disable-next-line no-confusing-arrow
.map(id => id === getLocalParticipant(state)?.id
? t('dialog.muteEveryoneSelf')
: getParticipantDisplayName(state, id))
.join(', ');
return whom.length ? {
content: t('dialog.muteEveryoneElsesVideoDialog'),
title: t('dialog.muteEveryoneElsesVideoTitle', { whom })
} : {
title: t('dialog.muteEveryonesVideoTitle'),
isVideoModerationEnabled,
isModerationSupported: isSupported()(state)
};
}

View File

@@ -0,0 +1,81 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { rejectParticipantDesktop } from '../../av-moderation/actions';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../av-moderation/constants';
import { isEnabledFromState } from '../../av-moderation/functions';
import { MEDIA_TYPE } from '../../base/media/constants';
import { muteRemote } from '../actions';
/**
* The type of the React {@code Component} props of
* {@link AbstractMuteRemoteParticipantsDesktopDialog}.
*/
export interface IProps extends WithTranslation {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not desktop moderation is on.
*/
isModerationOn: boolean;
/**
* The ID of the remote participant to be muted.
*/
participantID: string;
}
/**
* Abstract dialog to confirm a remote participant desktop mute action.
*
* @augments Component
*/
export default class AbstractMuteRemoteParticipantsDesktopDialog<P extends IProps = IProps, State=any>
extends Component<P, State> {
/**
* Initializes a new {@code AbstractMuteRemoteParticipantsDesktopDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Handles the submit button action.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
const { dispatch, participantID } = this.props;
dispatch(muteRemote(participantID, MEDIA_TYPE.SCREENSHARE));
dispatch(rejectParticipantDesktop(participantID));
return true;
}
}
/**
* Maps (parts of) the redux state to the associated
* {@code AbstractDialogContainer}'s props.
*
* @param {IReduxState} state - The redux state.
* @private
* @returns {Object}
*/
export function abstractMapStateToProps(state: IReduxState) {
return {
isModerationOn: isEnabledFromState(AVM_MEDIA_TYPE.DESKTOP, state)
};
}

View File

@@ -0,0 +1,81 @@
import { Component } from 'react';
import { WithTranslation } from 'react-i18next';
import { IReduxState, IStore } from '../../app/types';
import { rejectParticipantVideo } from '../../av-moderation/actions';
import { MEDIA_TYPE as AVM_MEDIA_TYPE } from '../../av-moderation/constants';
import { isEnabledFromState } from '../../av-moderation/functions';
import { MEDIA_TYPE } from '../../base/media/constants';
import { muteRemote } from '../actions';
/**
* The type of the React {@code Component} props of
* {@link AbstractMuteRemoteParticipantsVideoDialog}.
*/
export interface IProps extends WithTranslation {
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Whether or not video moderation is on.
*/
isVideoModerationOn: boolean;
/**
* The ID of the remote participant to be muted.
*/
participantID: string;
}
/**
* Abstract dialog to confirm a remote participant video ute action.
*
* @augments Component
*/
export default class AbstractMuteRemoteParticipantsVideoDialog<P extends IProps = IProps, State=any>
extends Component<P, State> {
/**
* Initializes a new {@code AbstractMuteRemoteParticipantsVideoDialog} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: P) {
super(props);
// Bind event handlers so they are only bound once per instance.
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Handles the submit button action.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
const { dispatch, participantID } = this.props;
dispatch(muteRemote(participantID, MEDIA_TYPE.VIDEO));
dispatch(rejectParticipantVideo(participantID));
return true;
}
}
/**
* Maps (parts of) the redux state to the associated
* {@code AbstractDialogContainer}'s props.
*
* @param {IReduxState} state - The redux state.
* @private
* @returns {Object}
*/
export function abstractMapStateToProps(state: IReduxState) {
return {
isVideoModerationOn: isEnabledFromState(AVM_MEDIA_TYPE.VIDEO, state)
};
}

View File

@@ -0,0 +1,90 @@
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 AbstractButton, { IProps as AbstractButtonProps } from '../../base/toolbox/components/AbstractButton';
import { isRemoteTrackMuted } from '../../base/tracks/functions.any';
import { MuteRemoteParticipantsVideoDialog } from './';
export interface IProps extends AbstractButtonProps {
/**
* Boolean to indicate if the video track of the participant is muted or
* not.
*/
_videoTrackMuted: boolean;
/**
* The ID of the participant object that this button is supposed to
* mute/unmute.
*/
participantID: string;
}
/**
* An abstract remote video menu button which mutes the remote participant.
*/
export default class AbstractMuteVideoButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.remoteVideoMute';
override icon = IconVideoOff;
override label = 'videothumbnail.domuteVideo';
override toggledLabel = 'videothumbnail.videoMuted';
/**
* Handles clicking / pressing the button, and mutes the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
sendAnalytics(createRemoteVideoMenuButtonEvent(
'video.mute.button',
{
'participant_id': participantID
}));
dispatch(openDialog(MuteRemoteParticipantsVideoDialog, { participantID }));
}
/**
* Renders the item disabled if the participant is muted.
*
* @inheritdoc
*/
override _isDisabled() {
return this.props._videoTrackMuted;
}
/**
* Renders the item toggled if the participant is muted.
*
* @inheritdoc
*/
override _isToggled() {
return this.props._videoTrackMuted;
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {{
* _videoTrackMuted: boolean
* }}
*/
export function _mapStateToProps(state: IReduxState, ownProps: any) {
const tracks = state['features/base/tracks'];
return {
_videoTrackMuted: isRemoteTrackMuted(
tracks, MEDIA_TYPE.VIDEO, ownProps.participantID)
};
}

View File

@@ -0,0 +1,13 @@
/* eslint-disable lines-around-comment */
// @ts-ignore
export { default as DemoteToVisitorDialog } from './native/DemoteToVisitorDialog';
// @ts-ignore
export { default as GrantModeratorDialog } from './native/GrantModeratorDialog';
// @ts-ignore
export { default as KickRemoteParticipantDialog } from './native/KickRemoteParticipantDialog';
// @ts-ignore
export { default as MuteEveryoneDialog } from './native/MuteEveryoneDialog';
// @ts-ignore
export { default as MuteEveryonesVideoDialog } from './native/MuteEveryonesVideoDialog';
// @ts-ignore
export { default as MuteRemoteParticipantsVideoDialog } from './native/MuteRemoteParticipantsVideoDialog';

View File

@@ -0,0 +1,6 @@
export { default as DemoteToVisitorDialog } from './web/DemoteToVisitorDialog';
export { default as GrantModeratorDialog } from './web/GrantModeratorDialog';
export { default as KickRemoteParticipantDialog } from './web/KickRemoteParticipantDialog';
export { default as MuteEveryoneDialog } from './web/MuteEveryoneDialog';
export { default as MuteEveryonesVideoDialog } from './web/MuteEveryonesVideoDialog';
export { default as MuteRemoteParticipantsVideoDialog } from './web/MuteRemoteParticipantsVideoDialog';

View File

@@ -0,0 +1,100 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { approveParticipant } from '../../../av-moderation/actions';
import { MEDIA_TYPE } from '../../../av-moderation/constants';
import { isForceMuted, isSupported } from '../../../av-moderation/functions';
import { translate } from '../../../base/i18n/functions';
import { IconMic, IconVideo } from '../../../base/icons/svg';
import { getParticipantById, isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
export interface IProps extends AbstractButtonProps {
/**
* Whether or not the participant is audio force muted.
*/
isAudioForceMuted: boolean;
/**
* Whether or not the participant is video force muted.
*/
isVideoForceMuted: boolean;
/**
* The ID of the participant object that this button is supposed to
* ask to unmute.
*/
participantID: string;
}
/**
* An abstract remote video menu button which asks the remote participant to unmute.
*/
class AskUnmuteButton extends AbstractButton<IProps> {
override accessibilityLabel = 'participantsPane.actions.askUnmute';
override icon = IconMic;
override label = 'participantsPane.actions.askUnmute';
/**
* Gets the current label.
*
* @returns {string}
*/
_getLabel() {
const { isAudioForceMuted, isVideoForceMuted } = this.props;
if (!isAudioForceMuted && isVideoForceMuted) {
return 'participantsPane.actions.allowVideo';
}
return this.label;
}
/**
* Gets the current icon.
*
* @returns {string}
*/
_getIcon() {
const { isAudioForceMuted, isVideoForceMuted } = this.props;
if (!isAudioForceMuted && isVideoForceMuted) {
return IconVideo;
}
return this.icon;
}
/**
* Handles clicking / pressing the button, and asks the participant to unmute.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
dispatch(approveParticipant(participantID));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - Properties of component.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState, ownProps: any) {
const { participantID } = ownProps;
const participant = getParticipantById(state, participantID);
return {
isAudioForceMuted: isForceMuted(participant, MEDIA_TYPE.AUDIO, state),
isVideoForceMuted: isForceMuted(participant, MEDIA_TYPE.VIDEO, state),
visible: isLocalParticipantModerator(state) && isSupported()(state)
};
}
export default translate(connect(mapStateToProps)(AskUnmuteButton));

View File

@@ -0,0 +1,36 @@
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import { IconInfoCircle } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { showConnectionStatus } from '../../../participants-pane/actions.native';
export interface IProps extends AbstractButtonProps {
/**
* The ID of the participant that this button is supposed to pin.
*/
participantID: string;
}
/**
* A remote video menu button which shows the connection statistics.
*/
class ConnectionStatusButton extends AbstractButton<IProps> {
override icon = IconInfoCircle;
override label = 'videothumbnail.connectionInfo';
/**
* Handles clicking / pressing the button, and kicks the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
dispatch(showConnectionStatus(participantID));
}
}
export default translate(connect()(ConnectionStatusButton));

View File

@@ -0,0 +1,476 @@
import React, { PureComponent } from 'react';
import { WithTranslation } from 'react-i18next';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { withTheme } from 'react-native-paper';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { hideSheet } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
import { translate } from '../../../base/i18n/functions';
import { IconArrowDownLarge, IconArrowUpLarge } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import { getParticipantDisplayName } from '../../../base/participants/functions';
import BaseIndicator from '../../../base/react/components/native/BaseIndicator';
import {
getTrackByMediaTypeAndParticipant
} from '../../../base/tracks/functions.native';
import {
isTrackStreamingStatusInactive,
isTrackStreamingStatusInterrupted
} from '../../../connection-indicator/functions';
import statsEmitter from '../../../connection-indicator/statsEmitter';
import styles from './styles';
/**
* Size of the rendered avatar in the menu.
*/
const AVATAR_SIZE = 25;
const CONNECTION_QUALITY = [
// Full (3 bars)
{
msg: 'connectionindicator.quality.good',
percent: 30 // INDICATOR_DISPLAY_THRESHOLD
},
// 2 bars.
{
msg: 'connectionindicator.quality.nonoptimal',
percent: 10
},
// 1 bar.
{
msg: 'connectionindicator.quality.poor',
percent: 0
}
];
interface IProps extends WithTranslation {
/**
* Whether this participant's connection is inactive.
*/
_isConnectionStatusInactive: boolean;
/**
* Whether this participant's connection is interrupted.
*/
_isConnectionStatusInterrupted: boolean;
/**
* True if the menu is currently open, false otherwise.
*/
_isOpen: boolean;
/**
* Display name of the participant retrieved from Redux.
*/
_participantDisplayName: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The ID of the participant that this button is supposed to pin.
*/
participantID: string;
/**
* Theme used for styles.
*/
theme: any;
}
/**
* The type of the React {@code Component} state of {@link ConnectionStatusComponent}.
*/
type IState = {
codecString: string;
connectionString: string;
downloadString: string;
packetLostDownloadString: string;
packetLostUploadString: string;
resolutionString: string;
serverRegionString: string;
uploadString: string;
};
/**
* Class to implement a popup menu that show the connection statistics.
*/
class ConnectionStatusComponent extends PureComponent<IProps, IState> {
/**
* Constructor of the component.
*
* @param {P} props - The read-only properties with which the new
* instance is to be initialized.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onStatsUpdated = this._onStatsUpdated.bind(this);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
this.state = {
resolutionString: 'N/A',
downloadString: 'N/A',
uploadString: 'N/A',
packetLostDownloadString: 'N/A',
packetLostUploadString: 'N/A',
serverRegionString: 'N/A',
codecString: 'N/A',
connectionString: 'N/A'
};
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactNode}
*/
override render() {
const { t, theme } = this.props;
const { palette } = theme;
return (
<BottomSheet
onCancel = { this._onCancel }
renderHeader = { this._renderMenuHeader }>
<View style = { styles.statsWrapper as ViewStyle }>
<View style = { styles.statsInfoCell as ViewStyle }>
<Text style = { styles.statsTitleText as TextStyle }>
{ t('connectionindicator.status') }
</Text>
<Text style = { styles.statsInfoText as TextStyle }>
{ t(this.state.connectionString) }
</Text>
</View>
<View style = { styles.statsInfoCell as ViewStyle }>
<Text style = { styles.statsTitleText as TextStyle }>
{ t('connectionindicator.bitrate') }
</Text>
<BaseIndicator
icon = { IconArrowDownLarge }
iconStyle = {{
color: palette.icon03
}} />
<Text style = { styles.statsInfoText as TextStyle }>
{ this.state.downloadString }
</Text>
<BaseIndicator
icon = { IconArrowUpLarge }
iconStyle = {{
color: palette.icon03
}} />
<Text style = { styles.statsInfoText as TextStyle }>
{ `${this.state.uploadString} Kbps` }
</Text>
</View>
<View style = { styles.statsInfoCell as ViewStyle }>
<Text style = { styles.statsTitleText as TextStyle }>
{ t('connectionindicator.packetloss') }
</Text>
<BaseIndicator
icon = { IconArrowDownLarge }
iconStyle = {{
color: palette.icon03
}} />
<Text style = { styles.statsInfoText as TextStyle }>
{ this.state.packetLostDownloadString }
</Text>
<BaseIndicator
icon = { IconArrowUpLarge }
iconStyle = {{
color: palette.icon03
}} />
<Text style = { styles.statsInfoText as TextStyle }>
{ this.state.packetLostUploadString }
</Text>
</View>
<View style = { styles.statsInfoCell as ViewStyle }>
<Text style = { styles.statsTitleText as TextStyle }>
{ t('connectionindicator.resolution') }
</Text>
<Text style = { styles.statsInfoText as TextStyle }>
{ this.state.resolutionString }
</Text>
</View>
<View style = { styles.statsInfoCell as ViewStyle }>
<Text style = { styles.statsTitleText as TextStyle }>
{ t('connectionindicator.codecs') }
</Text>
<Text style = { styles.statsInfoText as TextStyle }>
{ this.state.codecString }
</Text>
</View>
</View>
</BottomSheet>
);
}
/**
* Starts listening for stat updates.
*
* @inheritdoc
* returns {void}
*/
override componentDidMount() {
statsEmitter.subscribeToClientStats(this.props.participantID, this._onStatsUpdated);
}
/**
* Updates which user's stats are being listened to.
*
* @inheritdoc
* returns {void}
*/
override componentDidUpdate(prevProps: IProps) {
if (prevProps.participantID !== this.props.participantID) {
statsEmitter.unsubscribeToClientStats(
prevProps.participantID, this._onStatsUpdated);
statsEmitter.subscribeToClientStats(
this.props.participantID, this._onStatsUpdated);
}
}
/**
* Callback invoked when new connection stats associated with the passed in
* user ID are available. Will update the component's display of current
* statistics.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {void}
*/
_onStatsUpdated(stats = {}) {
const newState = this._buildState(stats);
this.setState(newState);
}
/**
* Extracts statistics and builds the state object.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {State}
*/
_buildState(stats: any) {
const { download: downloadBitrate, upload: uploadBitrate } = this._extractBitrate(stats) ?? {};
const { download: downloadPacketLost, upload: uploadPacketLost } = this._extractPacketLost(stats) ?? {};
return {
resolutionString: this._extractResolutionString(stats) ?? this.state.resolutionString,
downloadString: downloadBitrate ?? this.state.downloadString,
uploadString: uploadBitrate ?? this.state.uploadString,
packetLostDownloadString: downloadPacketLost === undefined
? this.state.packetLostDownloadString : `${downloadPacketLost}%`,
packetLostUploadString: uploadPacketLost === undefined
? this.state.packetLostUploadString : `${uploadPacketLost}%`,
serverRegionString: this._extractServer(stats) ?? this.state.serverRegionString,
codecString: this._extractCodecs(stats) ?? this.state.codecString,
connectionString: this._extractConnection(stats)
};
}
/**
* Extracts the resolution and framerate.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {string}
*/
_extractResolutionString(stats: any) {
const { framerate, resolution } = stats;
const resolutionString = Object.keys(resolution || {})
.map(ssrc => {
const { width, height } = resolution[ssrc];
return `${width}x${height}`;
})
.join(', ') || null;
const frameRateString = Object.keys(framerate || {})
.map(ssrc => framerate[ssrc])
.join(', ') || null;
return resolutionString && frameRateString ? `${resolutionString}@${frameRateString}fps` : undefined;
}
/**
* Extracts the download and upload bitrates.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {{ download, upload }}
*/
_extractBitrate(stats: any) {
return stats.bitrate;
}
/**
* Extracts the download and upload packet lost.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {{ download, upload }}
*/
_extractPacketLost(stats: any) {
return stats.packetLoss;
}
/**
* Extracts the server name.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {string}
*/
_extractServer(stats: any) {
return stats.serverRegion;
}
/**
* Extracts the audio and video codecs names.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {string}
*/
_extractCodecs(stats: any) {
const { codec } = stats;
let codecString;
if (codec) {
const audioCodecs = Object.values(codec)
.map((c: any) => c.audio)
.filter(Boolean);
const videoCodecs = Object.values(codec)
.map((c: any) => c.video)
.filter(Boolean);
if (audioCodecs.length || videoCodecs.length) {
// Use a Set to eliminate duplicates.
codecString = Array.from(new Set([ ...audioCodecs, ...videoCodecs ])).join(', ');
}
}
return codecString;
}
/**
* Extracts the connection percentage and sets connection quality.
*
* @param {Object} stats - Connection stats from the library.
* @private
* @returns {string}
*/
_extractConnection(stats: any) {
const { connectionQuality } = stats;
const {
_isConnectionStatusInactive,
_isConnectionStatusInterrupted
} = this.props;
if (_isConnectionStatusInactive) {
return 'connectionindicator.quality.inactive';
} else if (_isConnectionStatusInterrupted) {
return 'connectionindicator.quality.lost';
} else if (typeof connectionQuality === 'undefined') {
return 'connectionindicator.quality.good';
}
const qualityConfig = this._getQualityConfig(connectionQuality);
return qualityConfig.msg;
}
/**
* Get the quality configuration from CONNECTION_QUALITY which has a percentage
* that matches or exceeds the passed in percentage. The implementation
* assumes CONNECTION_QUALITY is already sorted by highest to lowest
* percentage.
*
* @param {number} percent - The connection percentage, out of 100, to find
* the closest matching configuration for.
* @private
* @returns {Object}
*/
_getQualityConfig(percent: number): any {
return CONNECTION_QUALITY.find(x => percent >= x.percent) || {};
}
/**
* Callback to hide the {@code ConnectionStatusComponent}.
*
* @private
* @returns {boolean}
*/
_onCancel() {
statsEmitter.unsubscribeToClientStats(this.props.participantID, this._onStatsUpdated);
this.props.dispatch(hideSheet());
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { participantID } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.participantNameContainer ] as ViewStyle[] }>
<Avatar
participantId = { participantID }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel as TextStyle }>
{ this.props._participantDisplayName }
</Text>
</View>
);
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState, ownProps: IProps) {
const { participantID } = ownProps;
const tracks = state['features/base/tracks'];
const _videoTrack = getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantID);
const _isConnectionStatusInactive = isTrackStreamingStatusInactive(_videoTrack);
const _isConnectionStatusInterrupted = isTrackStreamingStatusInterrupted(_videoTrack);
return {
_isConnectionStatusInactive,
_isConnectionStatusInterrupted,
_participantDisplayName: getParticipantDisplayName(state, participantID)
};
}
export default translate(connect(_mapStateToProps)(withTheme(ConnectionStatusComponent)));

View File

@@ -0,0 +1,52 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { openDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions';
import { IconUsers } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import DemoteToVisitorDialog from './DemoteToVisitorDialog';
interface IProps extends AbstractButtonProps {
/**
* The ID of the participant that this button is supposed to kick.
*/
participantID: string;
}
/**
* Implements a React {@link Component} which displays a button for demoting a participant to visitor.
*/
class DemoteToVisitorButton extends AbstractButton<IProps> {
override accessibilityLabel = 'videothumbnail.demote';
override icon = IconUsers;
override label = 'videothumbnail.demote';
/**
* Handles clicking / pressing the button, and demoting the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID } = this.props;
dispatch(openDialog(DemoteToVisitorDialog, { participantID }));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState) {
return {
visible: state['features/visitors'].supported
};
}
export default translate(connect(_mapStateToProps)(DemoteToVisitorButton));

View File

@@ -0,0 +1,38 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { DialogProps } from '../../../base/dialog/constants';
import { demoteRequest } from '../../../visitors/actions';
interface IProps extends DialogProps {
/**
* The ID of the remote participant to be demoted.
*/
participantID: string;
}
/**
* Dialog to confirm a remote participant demote to visitor action.
*
* @returns {JSX.Element}
*/
export default function DemoteToVisitorDialog({ participantID }: IProps): JSX.Element {
const dispatch = useDispatch();
const handleSubmit = useCallback(() => {
dispatch(demoteRequest(participantID));
return true; // close dialog
}, [ dispatch, participantID ]);
return (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.confirm'
descriptionKey = 'dialog.demoteParticipantDialog'
isConfirmDestructive = { true }
onSubmit = { handleSubmit }
title = 'dialog.demoteParticipantTitle' />
);
}

View File

@@ -0,0 +1,8 @@
import { connect } from 'react-redux';
import { translate } from '../../../base/i18n/functions';
import AbstractGrantModeratorButton, {
_mapStateToProps
} from '../AbstractGrantModeratorButton';
export default translate(connect(_mapStateToProps)(AbstractGrantModeratorButton));

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractGrantModeratorDialog, { abstractMapStateToProps }
from '../AbstractGrantModeratorDialog';
/**
* Dialog to confirm a remote participant kick action.
*/
class GrantModeratorDialog extends AbstractGrantModeratorDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
descriptionKey = {
`${this.props.t('dialog.grantModeratorDialog',
{ participantName: this.props.participantName })}`
}
onSubmit = { this._onSubmit } />
);
}
}
export default translate(connect(abstractMapStateToProps)(GrantModeratorDialog));

View File

@@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractKickButton from '../AbstractKickButton';
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState) {
return {
visible: isLocalParticipantModerator(state)
};
}
export default translate(connect(_mapStateToProps)(AbstractKickButton));

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractKickRemoteParticipantDialog
from '../AbstractKickRemoteParticipantDialog';
/**
* Dialog to confirm a remote participant kick action.
*/
class KickRemoteParticipantDialog extends AbstractKickRemoteParticipantDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
cancelLabel = 'dialog.Cancel'
confirmLabel = 'dialog.kickParticipantButton'
descriptionKey = 'dialog.kickParticipantDialog'
isConfirmDestructive = { true }
onSubmit = { this._onSubmit }
title = 'dialog.kickParticipantTitle' />
);
}
}
export default translate(connect()(KickRemoteParticipantDialog));

View File

@@ -0,0 +1,154 @@
import React, { PureComponent } from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { hideSheet } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
import { translate } from '../../../base/i18n/functions';
import {
getLocalParticipant,
getParticipantCount,
getParticipantDisplayName
} from '../../../base/participants/functions';
import { ILocalParticipant } from '../../../base/participants/types';
import ToggleSelfViewButton from '../../../toolbox/components/native/ToggleSelfViewButton';
import ConnectionStatusButton from './ConnectionStatusButton';
import DemoteToVisitorButton from './DemoteToVisitorButton';
import styles from './styles';
/**
* Size of the rendered avatar in the menu.
*/
const AVATAR_SIZE = 24;
interface IProps {
/**
* The local participant.
*/
_participant?: ILocalParticipant;
/**
* Display name of the participant retrieved from Redux.
*/
_participantDisplayName: string;
/**
* Shows/hides the local switch to visitor button.
*/
_showDemote: boolean;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* Translation function.
*/
t: Function;
}
/**
* Class to implement a popup menu that opens upon long pressing a thumbnail.
*/
class LocalVideoMenu extends PureComponent<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const { _participant, _showDemote } = this.props;
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participantID: _participant?.id ?? '',
styles: bottomSheetStyles.buttons
};
const connectionStatusButtonProps = {
...buttonProps,
afterClick: undefined
};
return (
<BottomSheet
renderHeader = { this._renderMenuHeader }
showSlidingView = { true }>
<ToggleSelfViewButton { ...buttonProps } />
{ _showDemote && <DemoteToVisitorButton { ...buttonProps } /> }
<ConnectionStatusButton { ...connectionStatusButtonProps } />
</BottomSheet>
);
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { _participant } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.participantNameContainer ] as ViewStyle[] }>
<Avatar
participantId = { _participant?.id }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel as TextStyle }>
{ this.props._participantDisplayName }
</Text>
</View>
);
}
/**
* Callback to hide the {@code RemoteVideoMenu}.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(hideSheet());
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { disableSelfDemote } = state['features/base/config'];
const participant = getLocalParticipant(state);
return {
_participant: participant,
_participantDisplayName: getParticipantDisplayName(state, participant?.id ?? ''),
_showDemote: !disableSelfDemote && getParticipantCount(state) > 1
};
}
export default translate(connect(_mapStateToProps)(LocalVideoMenu));

View File

@@ -0,0 +1,71 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { getCurrentConference } from '../../../base/conference/functions';
import { IJitsiConference } from '../../../base/conference/reducer';
import { translate } from '../../../base/i18n/functions';
import { IconRaiseHand } from '../../../base/icons/svg';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { LOWER_HAND_MESSAGE } from '../../../base/tracks/constants';
interface IProps extends AbstractButtonProps {
/**
* The current conference.
*/
_conference: IJitsiConference | undefined;
/**
* The ID of the participant object that this button is supposed to
* ask to lower the hand.
*/
participantId: String | undefined;
}
/**
* Implements a React {@link Component} which displays a button for lowering certain
* participant raised hands.
*
* @returns {JSX.Element}
*/
class LowerHandButton extends AbstractButton<IProps> {
override icon = IconRaiseHand;
override accessibilityLabel = 'participantsPane.actions.lowerHand';
override label = 'participantsPane.actions.lowerHand';
/**
* Handles clicking / pressing the button, and asks the participant to lower hand.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { participantId, _conference } = this.props;
_conference?.sendEndpointMessage(
participantId,
{
name: LOWER_HAND_MESSAGE
}
);
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - Properties of component.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState, ownProps: any) {
const { participantID } = ownProps;
const currentConference = getCurrentConference(state);
return {
_conference: currentConference,
participantId: participantID
};
}
export default translate(connect(mapStateToProps)(LowerHandButton));

View File

@@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractMuteButton, { _mapStateToProps as _abstractMapStateToProps } from '../AbstractMuteButton';
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - Properties of component.
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
return {
..._abstractMapStateToProps(state, ownProps),
visible: isLocalParticipantModerator(state)
};
}
export default translate(connect(_mapStateToProps)(AbstractMuteButton));

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { ViewStyle } from 'react-native';
import Dialog from 'react-native-dialog';
import { Divider } from 'react-native-paper';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractMuteEveryoneDialog, {
type IProps,
abstractMapStateToProps as _mapStateToProps } from '../AbstractMuteEveryoneDialog';
import styles from './styles';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before muting all remote participants.
*
* @augments AbstractMuteEveryoneDialog
*/
class MuteEveryoneDialog extends AbstractMuteEveryoneDialog<IProps> {
/**
* Renders the dialog switch.
*
* @returns {React$Component}
*/
_renderSwitch() {
return (
this.props.exclude.length === 0
&& <Dialog.Switch
label = { this.props.t('dialog.moderationAudioLabel') }
onValueChange = { this._onToggleModeration }
value = { !this.state.audioModerationEnabled } />
);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
return (
<ConfirmDialog
confirmLabel = 'dialog.muteParticipantButton'
descriptionKey = { this.state.content }
onSubmit = { this._onSubmit }
title = { this.props.title } >
{/* @ts-ignore */}
<Divider style = { styles.dividerDialog as ViewStyle } />
{ this._renderSwitch() }
</ConfirmDialog>
);
}
}
export default translate(connect(_mapStateToProps)(MuteEveryoneDialog));

View File

@@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractMuteEveryoneElseButton from '../AbstractMuteEveryoneElseButton';
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState) {
return {
visible: isLocalParticipantModerator(state)
};
}
export default translate(connect(_mapStateToProps)(AbstractMuteEveryoneElseButton));

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { ViewStyle } from 'react-native';
import Dialog from 'react-native-dialog';
import { Divider } from 'react-native-paper';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractMuteEveryonesVideoDialog, {
type IProps,
abstractMapStateToProps as _mapStateToProps } from '../AbstractMuteEveryonesVideoDialog';
import styles from './styles';
/**
* A React Component with the contents for a dialog that asks for confirmation
* from the user before muting all remote participants.
*
* @augments AbstractMuteEveryonesVideoDialog
*/
class MuteEveryonesVideoDialog extends AbstractMuteEveryonesVideoDialog<IProps> {
/**
* Renders the dialog switch.
*
* @returns {React$Component}
*/
_renderSwitch() {
return (
this.props.exclude.length === 0
&& <Dialog.Switch
label = { this.props.t('dialog.moderationVideoLabel') }
onValueChange = { this._onToggleModeration }
value = { !this.state.moderationEnabled } />
);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
return (
<ConfirmDialog
confirmLabel = 'dialog.muteEveryonesVideoDialogOk'
descriptionKey = { this.state.content }
onSubmit = { this._onSubmit }
title = { this.props.title }>
{/* @ts-ignore */}
<Divider style = { styles.dividerDialog as ViewStyle } />
{ this._renderSwitch() }
</ConfirmDialog>
);
}
}
export default translate(connect(_mapStateToProps)(MuteEveryonesVideoDialog));

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { connect } from 'react-redux';
import ConfirmDialog from '../../../base/dialog/components/native/ConfirmDialog';
import { translate } from '../../../base/i18n/functions';
import AbstractMuteRemoteParticipantsVideoDialog, {
abstractMapStateToProps
} from '../AbstractMuteRemoteParticipantsVideoDialog';
/**
* Dialog to confirm a remote participant's video stop action.
*/
class MuteRemoteParticipantsVideoDialog extends AbstractMuteRemoteParticipantsVideoDialog {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
return (
<ConfirmDialog
descriptionKey = { this.props.isVideoModerationOn
? 'dialog.muteParticipantsVideoDialogModerationOn'
: 'dialog.muteParticipantsVideoDialog'
}
onSubmit = { this._onSubmit } />
);
}
}
export default translate(connect(abstractMapStateToProps)(MuteRemoteParticipantsVideoDialog));

View File

@@ -0,0 +1,22 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractMuteVideoButton, { _mapStateToProps as _abstractMapStateToProps } from '../AbstractMuteVideoButton';
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - Properties of component.
* @returns {Props}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
return {
..._abstractMapStateToProps(state, ownProps),
visible: isLocalParticipantModerator(state)
};
}
export default translate(connect(_mapStateToProps)(AbstractMuteVideoButton));

View File

@@ -0,0 +1,59 @@
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconEnlarge } from '../../../base/icons/svg';
import { pinParticipant } from '../../../base/participants/actions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { shouldDisplayTileView } from '../../../video-layout/functions';
export interface IProps extends AbstractButtonProps {
/**
* True if tile view is currently enabled.
*/
_tileViewEnabled?: boolean;
/**
* The ID of the participant that this button is supposed to pin.
*/
participantID: string;
}
/**
* A remote video menu button which pins a participant and exist the tile view.
*/
class PinButton extends AbstractButton<IProps> {
override accessibilityLabel = 'toolbar.accessibilityLabel.show';
override icon = IconEnlarge;
override label = 'videothumbnail.show';
/**
* Handles clicking / pressing the button, and kicks the participant.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch } = this.props;
// Pin participant, it will automatically exit the tile view
dispatch(pinParticipant(this.props.participantID));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState) {
const { isOpen } = state['features/participants-pane'];
return {
visible: !isOpen && shouldDisplayTileView(state)
};
}
export default translate(connect(_mapStateToProps)(PinButton));

View File

@@ -0,0 +1,287 @@
/* eslint-disable lines-around-comment */
import React, { PureComponent } from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { Divider } from 'react-native-paper';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { hideSheet } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
import { KICK_OUT_ENABLED } from '../../../base/flags/constants';
import { getFeatureFlag } from '../../../base/flags/functions';
import { translate } from '../../../base/i18n/functions';
import {
getParticipantById,
getParticipantDisplayName,
hasRaisedHand,
isLocalParticipantModerator,
isPrivateChatEnabled
} from '../../../base/participants/functions';
import { getBreakoutRooms, getCurrentRoomId, isInBreakoutRoom } from '../../../breakout-rooms/functions';
import { IRoom } from '../../../breakout-rooms/types';
import PrivateMessageButton from '../../../chat/components/native/PrivateMessageButton';
import AskUnmuteButton from './AskUnmuteButton';
import ConnectionStatusButton from './ConnectionStatusButton';
import DemoteToVisitorButton from './DemoteToVisitorButton';
import GrantModeratorButton from './GrantModeratorButton';
import KickButton from './KickButton';
import LowerHandButton from './LowerHandButton';
import MuteButton from './MuteButton';
import MuteEveryoneElseButton from './MuteEveryoneElseButton';
import MuteVideoButton from './MuteVideoButton';
import PinButton from './PinButton';
import SendToBreakoutRoom from './SendToBreakoutRoom';
import VolumeSlider from './VolumeSlider';
import styles from './styles';
/**
* Size of the rendered avatar in the menu.
*/
const AVATAR_SIZE = 24;
interface IProps {
/**
* The id of the current room.
*/
_currentRoomId: string;
/**
* Whether or not to display the grant moderator button.
*/
_disableGrantModerator: boolean;
/**
* Whether or not to display the kick button.
*/
_disableKick: boolean;
/**
* Whether or not to display the remote mute buttons.
*/
_disableRemoteMute: boolean;
/**
* Whether or not to display the send private message button.
*/
_enablePrivateChat: boolean;
/**
* Whether or not the current room is a breakout room.
*/
_isBreakoutRoom: boolean;
/**
* Whether the participant is present in the room or not.
*/
_isParticipantAvailable?: boolean;
/**
* Whether or not the targeted participant joined without audio.
*/
_isParticipantSilent: boolean;
/**
* Whether the local participant is moderator or not.
*/
_moderator: boolean;
/**
* Display name of the participant retrieved from Redux.
*/
_participantDisplayName: string;
/**
* Whether the targeted participant raised hand or not.
*/
_raisedHand: boolean;
/**
* Array containing the breakout rooms.
*/
_rooms: Array<IRoom>;
/**
* Whether to display the demote button.
*/
_showDemote: boolean;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The ID of the participant for which this menu opened for.
*/
participantId: string;
/**
* Translation function.
*/
t: Function;
}
/**
* Class to implement a popup menu that opens upon long pressing a thumbnail.
*/
class RemoteVideoMenu extends PureComponent<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const {
_disableKick,
_disableRemoteMute,
_disableGrantModerator,
_enablePrivateChat,
_isBreakoutRoom,
_isParticipantAvailable,
_isParticipantSilent,
_moderator,
_raisedHand,
_rooms,
_showDemote,
_currentRoomId,
participantId,
t
} = this.props;
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participantID: participantId,
styles: bottomSheetStyles.buttons
};
const connectionStatusButtonProps = {
...buttonProps,
afterClick: undefined
};
return (
<BottomSheet
renderHeader = { this._renderMenuHeader }
showSlidingView = { _isParticipantAvailable }>
{!_isParticipantSilent && <AskUnmuteButton { ...buttonProps } />}
{ !_disableRemoteMute && <MuteButton { ...buttonProps } /> }
<MuteEveryoneElseButton { ...buttonProps } />
{ _moderator && _raisedHand && <LowerHandButton { ...buttonProps } /> }
{ !_disableRemoteMute && !_isParticipantSilent && <MuteVideoButton { ...buttonProps } /> }
{/* @ts-ignore */}
<Divider style = { styles.divider as ViewStyle } />
{ !_disableKick && <KickButton { ...buttonProps } /> }
{ !_disableGrantModerator && !_isBreakoutRoom && <GrantModeratorButton { ...buttonProps } /> }
<PinButton { ...buttonProps } />
{ _showDemote && <DemoteToVisitorButton { ...buttonProps } /> }
{ _enablePrivateChat && <PrivateMessageButton { ...buttonProps } /> }
<ConnectionStatusButton { ...connectionStatusButtonProps } />
{_moderator && _rooms.length > 1 && <>
{/* @ts-ignore */}
<Divider style = { styles.divider as ViewStyle } />
<View style = { styles.contextMenuItem as ViewStyle }>
<Text style = { styles.contextMenuItemText as TextStyle }>
{t('breakoutRooms.actions.sendToBreakoutRoom')}
</Text>
</View>
{_rooms.map(room => _currentRoomId !== room.id && (<SendToBreakoutRoom
key = { room.id }
room = { room }
{ ...buttonProps } />))}
</>}
<VolumeSlider participantID = { participantId } />
</BottomSheet>
);
}
/**
* Callback to hide the {@code RemoteVideoMenu}.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(hideSheet());
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { participantId } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.participantNameContainer ] as ViewStyle[] }>
<Avatar
participantId = { participantId }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel as TextStyle }>
{ this.props._participantDisplayName }
</Text>
</View>
);
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const kickOutEnabled = getFeatureFlag(state, KICK_OUT_ENABLED, true);
const { participantId } = ownProps;
const { remoteVideoMenu = {}, disableRemoteMute } = state['features/base/config'];
const participant = getParticipantById(state, participantId);
const { disableKick } = remoteVideoMenu;
const _rooms = Object.values(getBreakoutRooms(state));
const _currentRoomId = getCurrentRoomId(state);
const shouldDisableKick = disableKick || !kickOutEnabled;
const moderator = isLocalParticipantModerator(state);
const _iAmVisitor = state['features/visitors'].iAmVisitor;
const _isBreakoutRoom = isInBreakoutRoom(state);
const raisedHand = hasRaisedHand(participant);
return {
_currentRoomId,
_disableKick: Boolean(shouldDisableKick),
_disableRemoteMute: Boolean(disableRemoteMute),
_enablePrivateChat: isPrivateChatEnabled(participant, state),
_isBreakoutRoom,
_isParticipantAvailable: Boolean(participant),
_isParticipantSilent: Boolean(participant?.isSilent),
_moderator: moderator,
_participantDisplayName: getParticipantDisplayName(state, participantId),
_raisedHand: raisedHand,
_rooms,
_showDemote: moderator
};
}
export default translate(connect(_mapStateToProps)(RemoteVideoMenu));

View File

@@ -0,0 +1,71 @@
import { connect } from 'react-redux';
import { createBreakoutRoomsEvent } from '../../../analytics/AnalyticsEvents';
import { sendAnalytics } from '../../../analytics/functions';
import { IReduxState } from '../../../app/types';
import { translate } from '../../../base/i18n/functions';
import { IconRingGroup } from '../../../base/icons/svg';
import { isLocalParticipantModerator } from '../../../base/participants/functions';
import AbstractButton, { IProps as AbstractButtonProps } from '../../../base/toolbox/components/AbstractButton';
import { sendParticipantToRoom } from '../../../breakout-rooms/actions';
import { IRoom } from '../../../breakout-rooms/types';
export interface IProps extends AbstractButtonProps {
/**
* ID of the participant to send to breakout room.
*/
participantID: string;
/**
* Room to send participant to.
*/
room: IRoom;
}
/**
* An abstract remote video menu button which sends the remote participant to a breakout room.
*/
class SendToBreakoutRoom extends AbstractButton<IProps> {
override accessibilityLabel = 'breakoutRooms.actions.sendToBreakoutRoom';
override icon = IconRingGroup;
/**
* Gets the current label.
*
* @returns {string}
*/
_getLabel() {
const { t, room } = this.props;
return room.name || t('breakoutRooms.mainRoom');
}
/**
* Handles clicking / pressing the button, and asks the participant to unmute.
*
* @private
* @returns {void}
*/
override _handleClick() {
const { dispatch, participantID, room } = this.props;
sendAnalytics(createBreakoutRoomsEvent('send.participant.to.room'));
dispatch(sendParticipantToRoom(participantID, room.id));
}
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - Properties of component.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState) {
return {
visible: isLocalParticipantModerator(state)
};
}
export default translate(connect(mapStateToProps)(SendToBreakoutRoom));

View File

@@ -0,0 +1,149 @@
import React, { PureComponent } from 'react';
import { Text, TextStyle, View, ViewStyle } from 'react-native';
import { Divider } from 'react-native-paper';
import { connect } from 'react-redux';
import { IReduxState, IStore } from '../../../app/types';
import Avatar from '../../../base/avatar/components/Avatar';
import { hideSheet } from '../../../base/dialog/actions';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import { bottomSheetStyles } from '../../../base/dialog/components/native/styles';
import {
getParticipantById,
getParticipantDisplayName
} from '../../../base/participants/functions';
import SharedVideoButton from '../../../shared-video/components/native/SharedVideoButton';
import styles from './styles';
/**
* Size of the rendered avatar in the menu.
*/
const AVATAR_SIZE = 24;
interface IProps {
/**
* True if the menu is currently open, false otherwise.
*/
_isOpen: boolean;
/**
* Whether the participant is present in the room or not.
*/
_isParticipantAvailable?: boolean;
/**
* Display name of the participant retrieved from Redux.
*/
_participantDisplayName: string;
/**
* The Redux dispatch function.
*/
dispatch: IStore['dispatch'];
/**
* The ID of the participant for which this menu opened for.
*/
participantId: string;
}
/**
* Class to implement a popup menu that opens upon long pressing a fake participant thumbnail.
*/
class SharedVideoMenu extends PureComponent<IProps> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: IProps) {
super(props);
this._onCancel = this._onCancel.bind(this);
this._renderMenuHeader = this._renderMenuHeader.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
override render() {
const {
_isParticipantAvailable,
participantId
} = this.props;
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participantID: participantId,
styles: bottomSheetStyles.buttons
};
return (
<BottomSheet
renderHeader = { this._renderMenuHeader }
showSlidingView = { _isParticipantAvailable }>
{/* @ts-ignore */}
<Divider style = { styles.divider as ViewStyle } />
<SharedVideoButton { ...buttonProps } />
</BottomSheet>
);
}
/**
* Callback to hide the {@code SharedVideoMenu}.
*
* @private
* @returns {boolean}
*/
_onCancel() {
this.props.dispatch(hideSheet());
}
/**
* Function to render the menu's header.
*
* @returns {React$Element}
*/
_renderMenuHeader() {
const { participantId } = this.props;
return (
<View
style = { [
bottomSheetStyles.sheet,
styles.participantNameContainer ] as ViewStyle[] }>
<Avatar
participantId = { participantId }
size = { AVATAR_SIZE } />
<Text style = { styles.participantNameLabel as TextStyle }>
{ this.props._participantDisplayName }
</Text>
</View>
);
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {IProps}
*/
function _mapStateToProps(state: IReduxState, ownProps: any) {
const { participantId } = ownProps;
const isParticipantAvailable = getParticipantById(state, participantId);
return {
_isParticipantAvailable: Boolean(isParticipantAvailable),
_participantDisplayName: getParticipantDisplayName(state, participantId)
};
}
export default connect(_mapStateToProps)(SharedVideoMenu);

View File

@@ -0,0 +1,175 @@
/* eslint-disable lines-around-comment */
import Slider from '@react-native-community/slider';
import { throttle } from 'lodash-es';
import React, { PureComponent } from 'react';
import { View, ViewStyle } from 'react-native';
import { connect } from 'react-redux';
import { IReduxState } from '../../../app/types';
import Icon from '../../../base/icons/components/Icon';
import { IconVolumeUp } from '../../../base/icons/svg';
import { MEDIA_TYPE } from '../../../base/media/constants';
import {
getTrackByMediaTypeAndParticipant,
getTrackState
} from '../../../base/tracks/functions.native';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
import { setVolume } from '../../../participants-pane/actions.native';
import { NATIVE_VOLUME_SLIDER_SCALE } from '../../constants';
import styles from './styles';
/**
* The type of the React {@code Component} props of {@link VolumeSlider}.
*/
interface IProps {
/**
* Whether the participant enters the conference silent.
*/
_startSilent?: boolean;
/**
* Remote audio track.
*/
_track?: any;
/**
* The volume level for the participant.
*/
_volume?: number;
/**
* The redux dispatch function.
*/
dispatch?: Function;
/**
* The ID of the participant.
*/
participantID?: string;
}
/**
* The type of the React {@code Component} state of {@link VolumeSlider}.
*/
interface IState {
/**
* The volume of the participant's audio element. The value will
* be represented by a slider.
*/
volumeLevel: number;
}
/**
* Component that renders the volume slider.
*
* @returns {React$Element<any>}
*/
class VolumeSlider extends PureComponent<IProps, IState> {
_originalVolumeChange: Function;
/**
* Initializes a new {@code VolumeSlider} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: IProps) {
super(props);
this.state = {
volumeLevel: props._volume || Math.ceil(NATIVE_VOLUME_SLIDER_SCALE / 2)
};
this._originalVolumeChange = this._onVolumeChange;
this._onVolumeChange = throttle(
volumeLevel => this._originalVolumeChange(volumeLevel), 500
);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
override render() {
const { _startSilent } = this.props;
const { volumeLevel } = this.state;
const onVolumeChange = _startSilent ? undefined : this._onVolumeChange;
return (
<View style = { styles.volumeSliderContainer as ViewStyle } >
<Icon
size = { 24 }
src = { IconVolumeUp } />
<Slider
maximumTrackTintColor = { BaseTheme.palette.ui10 }
maximumValue = { NATIVE_VOLUME_SLIDER_SCALE }
minimumTrackTintColor = { BaseTheme.palette.action01 }
minimumValue = { 0 }
onValueChange = { onVolumeChange }
style = { styles.sliderContainer as ViewStyle }
thumbTintColor = { BaseTheme.palette.ui10 }
value = { volumeLevel } />
</View>
);
}
/**
* Sets the internal state of the volume level for the volume slider.
* Invokes the prop onVolumeChange to notify of volume changes.
*
* @param {number} volumeLevel - Selected volume on slider.
* @private
* @returns {void}
*/
_onVolumeChange(volumeLevel: any) {
const { _track, dispatch, participantID } = this.props;
const audioTrack = _track?.jitsiTrack.track;
let newVolumeLevel;
if (volumeLevel <= 10) {
newVolumeLevel = volumeLevel / 10;
} else {
newVolumeLevel = volumeLevel - 9;
}
audioTrack?._setVolume(newVolumeLevel);
// @ts-ignore
dispatch(setVolume(participantID, newVolumeLevel));
}
}
/**
* Maps (parts of) the Redux state to the associated props for the
* {@code VolumeSlider} component.
*
* @param {Object} state - The Redux state.
* @param {Object} ownProps - The own props of the component.
* @returns {IProps}
*/
function mapStateToProps(state: IReduxState, ownProps: IProps) {
const { participantID } = ownProps;
const { participantsVolume } = state['features/filmstrip'];
const { startSilent } = state['features/base/config'];
const tracks = getTrackState(state);
return {
_startSilent: Boolean(startSilent),
_track: getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantID),
_volume: participantID && participantsVolume[participantID]
};
}
// @ts-ignore
export default connect(mapStateToProps)(VolumeSlider);

View File

@@ -0,0 +1,89 @@
import {
MD_FONT_SIZE,
MD_ITEM_HEIGHT,
MD_ITEM_MARGIN_PADDING
} from '../../../base/dialog/components/native/styles';
import { ColorPalette } from '../../../base/styles/components/styles/ColorPalette';
import { createStyleSheet } from '../../../base/styles/functions.native';
import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export default createStyleSheet({
participantNameContainer: {
alignItems: 'center',
borderBottomColor: BaseTheme.palette.ui07,
borderBottomWidth: 0.4,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
flexDirection: 'row',
height: MD_ITEM_HEIGHT,
paddingLeft: MD_ITEM_MARGIN_PADDING
},
participantNameLabel: {
color: ColorPalette.lightGrey,
flexShrink: 1,
fontSize: MD_FONT_SIZE,
marginLeft: MD_ITEM_MARGIN_PADDING,
opacity: 0.90
},
statsTitleText: {
color: BaseTheme.palette.text01,
fontSize: 16,
fontWeight: 'bold',
marginRight: 3
},
statsInfoText: {
color: BaseTheme.palette.text01,
fontSize: 16,
marginRight: 2,
marginLeft: 2
},
statsInfoCell: {
alignItems: 'center',
flexDirection: 'row',
height: 30,
justifyContent: 'flex-start'
},
statsWrapper: {
margin: BaseTheme.spacing[3]
},
volumeSliderContainer: {
alignItems: 'center',
flexDirection: 'row',
marginHorizontal: BaseTheme.spacing[3],
marginVertical: BaseTheme.spacing[2]
},
sliderContainer: {
marginLeft: BaseTheme.spacing[3],
minWidth: '80%'
},
divider: {
backgroundColor: BaseTheme.palette.ui07
},
dividerDialog: {
backgroundColor: BaseTheme.palette.ui07,
marginBottom: BaseTheme.spacing[3]
},
contextMenuItem: {
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
height: BaseTheme.spacing[7],
marginLeft: BaseTheme.spacing[3]
},
contextMenuItemText: {
...BaseTheme.typography.bodyShortRegularLarge,
color: BaseTheme.palette.text01,
marginLeft: BaseTheme.spacing[4]
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
/**
* Used to set maximumValue for native volume slider.
* Slider double-precision floating-point number indicating the volume,
* from 0 mute to 1 max, which converts to 0 mute to 19 max in our case.
* 0 as muted, 10 as standard and 19 as max remote participant volume level.
*/
export const NATIVE_VOLUME_SLIDER_SCALE = 19;
/**
* Used to modify initialValue, which is expected to be a decimal value between
* 0 and 1, and converts it to a number representable by an input slider, which
* recognizes whole numbers.
*/
export const VOLUME_SLIDER_SCALE = 100;
/**
* Participant context menu button keys.
*/
export const PARTICIPANT_MENU_BUTTONS = {
ALLOW_DESKTOP: 'allow-desktop',
ALLOW_VIDEO: 'allow-video',
ASK_UNMUTE: 'ask-unmute',
CONN_STATUS: 'conn-status',
DEMOTE: 'demote',
FLIP_LOCAL_VIDEO: 'flip-local-video',
GRANT_MODERATOR: 'grant-moderator',
HIDE_SELF_VIEW: 'hide-self-view',
KICK: 'kick',
LOWER_PARTICIPANT_HAND: 'lower-participant-hand',
MUTE: 'mute',
MUTE_DESKTOP: 'mute-desktop',
MUTE_OTHERS: 'mute-others',
MUTE_OTHERS_DESKTOP: 'mute-others-desktop',
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'
};

View File

@@ -0,0 +1,3 @@
import { getLogger } from '../base/logging/functions';
export default getLogger('features/video-menu');

View File

@@ -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;
}