diff --git a/lang/main.json b/lang/main.json
index dccc9d897..b5f594d5f 100644
--- a/lang/main.json
+++ b/lang/main.json
@@ -258,6 +258,7 @@
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.",
"muteParticipantButton": "Mute",
"muteParticipantDialog": "Are you sure you want to mute this participant? You won't be able to unmute them, but they can unmute themselves at any time.",
+ "muteParticipantsVideoDialog": "Are you sure you want to turn off this participant's camera? You won't be able to turn the camera back on, but they can turn it back on at any time.",
"muteParticipantTitle": "Mute this participant?",
"muteParticipantsVideoButton": "Disable camera",
"muteParticipantsVideoTitle": "Disable camera of this participant?",
diff --git a/package-lock.json b/package-lock.json
index bf7db9f56..44a2c73d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3102,6 +3102,11 @@
"resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-4.1.5.tgz",
"integrity": "sha512-lagdZr9UiVAccNXYfTEj+aUcPCx9ykbMe9puffeIyF3JsRuMmlu3BjHYx1klUHX7wNRmFNC8qVP0puxUt1sZ0A=="
},
+ "@react-native-community/slider": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-3.0.3.tgz",
+ "integrity": "sha512-8IeHfDwJ9/CTUwFs6x90VlobV3BfuPgNLjTgC6dRZovfCWigaZwVNIFFJnHBakK3pW2xErAPwhdvNR4JeNoYbw=="
+ },
"@svgr/babel-plugin-add-jsx-attribute": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz",
diff --git a/package.json b/package.json
index 97acfe2cf..3a7fe9a37 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"@react-native-async-storage/async-storage": "1.13.2",
"@react-native-community/google-signin": "3.0.1",
"@react-native-community/netinfo": "4.1.5",
+ "@react-native-community/slider": "^3.0.3",
"@svgr/webpack": "4.3.2",
"amplitude-js": "8.2.1",
"base64-js": "1.3.1",
diff --git a/react/features/participants-pane/components/native/ContextMenuLobbyParticipantReject.js b/react/features/participants-pane/components/native/ContextMenuLobbyParticipantReject.js
index 322e9f4b8..f1ea3664b 100644
--- a/react/features/participants-pane/components/native/ContextMenuLobbyParticipantReject.js
+++ b/react/features/participants-pane/components/native/ContextMenuLobbyParticipantReject.js
@@ -39,7 +39,7 @@ export const ContextMenuLobbyParticipantReject = ({ participant: p }: Props) =>
+ size = { 24 } />
{ displayName }
diff --git a/react/features/participants-pane/components/native/ContextMenuMeetingParticipantDetails.js b/react/features/participants-pane/components/native/ContextMenuMeetingParticipantDetails.js
index 4671fe46b..013ce34f2 100644
--- a/react/features/participants-pane/components/native/ContextMenuMeetingParticipantDetails.js
+++ b/react/features/participants-pane/components/native/ContextMenuMeetingParticipantDetails.js
@@ -4,18 +4,27 @@ import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { TouchableOpacity, View } from 'react-native';
import { Text } from 'react-native-paper';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { Avatar } from '../../../base/avatar';
-import { hideDialog } from '../../../base/dialog';
+import { isToolbarButtonEnabled } from '../../../base/config';
+import { hideDialog, openDialog } from '../../../base/dialog';
import BottomSheet from '../../../base/dialog/components/native/BottomSheet';
import {
Icon, IconCloseCircle, IconConnectionActive, IconMessage,
IconMicrophoneEmptySlash,
IconMuteEveryoneElse, IconVideoOff
} from '../../../base/icons';
-import { MEDIA_TYPE } from '../../../base/media';
-import { muteRemote } from '../../../video-menu/actions.any';
+import { isLocalParticipantModerator } from '../../../base/participants';
+import { getIsParticipantVideoMuted } from '../../../base/tracks';
+import { openChat } from '../../../chat/actions.native';
+import {
+ KickRemoteParticipantDialog,
+ MuteEveryoneDialog,
+ MuteRemoteParticipantDialog,
+ MuteRemoteParticipantsVideoDialog,
+ VolumeSlider
+} from '../../../video-menu';
import styles from './styles';
@@ -31,7 +40,32 @@ export const ContextMenuMeetingParticipantDetails = ({ participant: p }: Props)
const dispatch = useDispatch();
const cancel = useCallback(() => dispatch(hideDialog()), [ dispatch ]);
const displayName = p.name;
- const muteAudio = useCallback(() => dispatch(muteRemote(p.id, MEDIA_TYPE.AUDIO)), [ dispatch ]);
+ const isLocalModerator = useSelector(isLocalParticipantModerator);
+ const isChatButtonEnabled = useSelector(isToolbarButtonEnabled('chat'));
+ const isParticipantVideoMuted = useSelector(getIsParticipantVideoMuted(p));
+ const kickRemoteParticipant = useCallback(() => {
+ dispatch(openDialog(KickRemoteParticipantDialog, {
+ participantID: p.id
+ }));
+ }, [ dispatch, p ]);
+ const muteAudio = useCallback(() => {
+ dispatch(openDialog(MuteRemoteParticipantDialog, {
+ participantID: p.id
+ }));
+ }, [ dispatch, p ]);
+ const muteEveryoneElse = useCallback(() => {
+ dispatch(openDialog(MuteEveryoneDialog, {
+ exclude: [ p.id ]
+ }));
+ }, [ dispatch, p ]);
+ const muteVideo = useCallback(() => {
+ dispatch(openDialog(MuteRemoteParticipantsVideoDialog, {
+ participantID: p.id
+ }));
+ }, [ dispatch, p ]);
+ const sendPrivateMessage = useCallback(() => {
+ dispatch(openChat(p));
+ }, [ dispatch, p ]);
const { t } = useTranslation();
return (
@@ -43,55 +77,85 @@ export const ContextMenuMeetingParticipantDetails = ({ participant: p }: Props)
+ size = { 24 } />
{ displayName }
-
-
- { t('participantsPane.actions.mute') }
-
-
-
- { t('participantsPane.actions.muteEveryoneElse') }
-
-
-
- { t('participantsPane.actions.stopVideo') }
-
-
-
- { t('videothumbnail.kick') }
-
-
-
- { t('toolbar.accessibilityLabel.privateMessage') }
-
+ {
+ isLocalModerator
+ &&
+
+
+ { t('participantsPane.actions.mute') }
+
+
+ }
+ {
+ isLocalModerator
+ &&
+
+
+ { t('participantsPane.actions.muteEveryoneElse') }
+
+
+ }
+ {
+ isLocalModerator && (
+ isParticipantVideoMuted
+ ||
+
+
+ { t('participantsPane.actions.stopVideo') }
+
+
+ )
+ }
+ {
+ isLocalModerator
+ &&
+
+
+ { t('videothumbnail.kick') }
+
+
+ }
+ {
+ isChatButtonEnabled
+ &&
+
+
+ { t('toolbar.accessibilityLabel.privateMessage') }
+
+
+ }
{ t('participantsPane.actions.networkStats') }
+
);
};
diff --git a/react/features/video-menu/components/native/MuteRemoteParticipantsVideoDialog.js b/react/features/video-menu/components/native/MuteRemoteParticipantsVideoDialog.js
new file mode 100644
index 000000000..89f7161e2
--- /dev/null
+++ b/react/features/video-menu/components/native/MuteRemoteParticipantsVideoDialog.js
@@ -0,0 +1,32 @@
+// @flow
+
+import React from 'react';
+
+import { ConfirmDialog } from '../../../base/dialog';
+import { translate } from '../../../base/i18n';
+import { connect } from '../../../base/redux';
+import AbstractMuteRemoteParticipantsVideoDialog
+ 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}
+ */
+ render() {
+ return (
+
+ );
+ }
+
+ _onSubmit: () => boolean;
+}
+
+export default translate(connect()(MuteRemoteParticipantsVideoDialog));
diff --git a/react/features/video-menu/components/native/VolumeSlider.js b/react/features/video-menu/components/native/VolumeSlider.js
index e69de29bb..acf8cc713 100644
--- a/react/features/video-menu/components/native/VolumeSlider.js
+++ b/react/features/video-menu/components/native/VolumeSlider.js
@@ -0,0 +1,119 @@
+// @flow
+
+import Slider from '@react-native-community/slider';
+import React, { Component } from 'react';
+import { View } from 'react-native';
+import { withTheme } from 'react-native-paper';
+
+import { Icon, IconVolumeEmpty } from '../../../base/icons';
+import { VOLUME_SLIDER_SCALE } from '../../constants';
+
+import styles from './styles';
+
+/**
+ * The type of the React {@code Component} props of {@link VolumeSlider}.
+ */
+type Props = {
+
+ /**
+ * 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,
+
+ /**
+ * Theme used for styles.
+ */
+ theme: Object
+};
+
+/**
+ * The type of the React {@code Component} state of {@link VolumeSlider}.
+ */
+type State = {
+
+ /**
+ * 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}
+ */
+class VolumeSlider extends Component {
+
+ /**
+ * Initializes a new {@code VolumeSlider} instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ volumeLevel: (props.initialValue || 0) * VOLUME_SLIDER_SCALE
+ };
+
+ // Bind event handlers so they are only bound once for every instance.
+ this._onVolumeChange = this._onVolumeChange.bind(this);
+ }
+
+ /**
+ * Implements React's {@link Component#render()}.
+ *
+ * @inheritdoc
+ * @returns {ReactElement}
+ */
+ render() {
+ const { volumeLevel } = this.state;
+ const { palette } = this.props.theme;
+
+ return (
+
+
+
+
+
+
+
+ );
+ }
+
+ _onVolumeChange: (Object) => void;
+
+ /**
+ * 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) {
+ this.setState({ volumeLevel });
+ }
+}
+
+export default withTheme(VolumeSlider);
+
diff --git a/react/features/video-menu/components/native/index.js b/react/features/video-menu/components/native/index.js
index a00f563a1..c013bf2b0 100644
--- a/react/features/video-menu/components/native/index.js
+++ b/react/features/video-menu/components/native/index.js
@@ -5,4 +5,6 @@ export { default as KickRemoteParticipantDialog } from './KickRemoteParticipantD
export { default as MuteEveryoneDialog } from './MuteEveryoneDialog';
export { default as MuteEveryonesVideoDialog } from './MuteEveryonesVideoDialog';
export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantDialog';
+export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as RemoteVideoMenu } from './RemoteVideoMenu';
+export { default as VolumeSlider } from './VolumeSlider';
diff --git a/react/features/video-menu/components/native/styles.js b/react/features/video-menu/components/native/styles.js
index 255e4861e..219ca149f 100644
--- a/react/features/video-menu/components/native/styles.js
+++ b/react/features/video-menu/components/native/styles.js
@@ -6,6 +6,7 @@ import {
MD_ITEM_MARGIN_PADDING
} from '../../../base/dialog';
import { ColorPalette, createStyleSheet } from '../../../base/styles';
+import BaseTheme from '../../../base/ui/components/BaseTheme.native';
export default createStyleSheet({
participantNameContainer: {
@@ -48,5 +49,22 @@ export default createStyleSheet({
statsWrapper: {
marginVertical: 10
+ },
+
+ volumeSliderContainer: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ marginBottom: BaseTheme.spacing[3],
+ marginTop: BaseTheme.spacing[3],
+ width: '100%'
+ },
+
+ volumeSliderIcon: {
+ marginLeft: BaseTheme.spacing[1]
+ },
+
+ sliderContainer: {
+ marginLeft: BaseTheme.spacing[3],
+ width: 342
}
});
diff --git a/react/features/video-menu/components/web/VolumeSlider.js b/react/features/video-menu/components/web/VolumeSlider.js
index d70fa50d1..518891340 100644
--- a/react/features/video-menu/components/web/VolumeSlider.js
+++ b/react/features/video-menu/components/web/VolumeSlider.js
@@ -4,13 +4,7 @@ import React, { Component } from 'react';
import { translate } from '../../../base/i18n';
import { Icon, IconVolume } from '../../../base/icons';
-
-/**
- * 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.
- */
-const VOLUME_SLIDER_SCALE = 100;
+import { VOLUME_SLIDER_SCALE } from '../../constants';
/**
* The type of the React {@code Component} props of {@link VolumeSlider}.
diff --git a/react/features/video-menu/constants.js b/react/features/video-menu/constants.js
new file mode 100644
index 000000000..25f6de2c0
--- /dev/null
+++ b/react/features/video-menu/constants.js
@@ -0,0 +1,8 @@
+// @flow
+
+/**
+ * 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;