[RN] Add remote video menu

This commit is contained in:
Bettenbuk Zoltan 2018-12-19 19:40:17 +01:00 committed by Zoltan Bettenbuk
parent d4c0840659
commit 6b68fba220
27 changed files with 582 additions and 65 deletions

View File

@ -385,8 +385,10 @@
"externalInstallationMsg": "You need to install our desktop sharing extension.",
"inlineInstallationMsg": "You need to install our desktop sharing extension.",
"inlineInstallExtension": "Install now",
"kickParticipantDialog": "Are you sure you want to kick this participant?",
"muteParticipantTitle": "Mute this member?",
"muteParticipantBody": "You won't be able to unmute them, but they can unmute themselves at any time.",
"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.",
"muteParticipantButton": "Mute",
"liveStreamingDisabledTooltip": "Start live stream disabled.",
"liveStreamingDisabledForGuestTooltip": "Guests can't start live streaming.",

View File

@ -12,6 +12,47 @@ const DIALOG_BORDER_COLOR = 'rgba(255, 255, 255, 0.2)';
export const FIELD_UNDERLINE = ColorPalette.transparent;
export const PLACEHOLDER_COLOR = ColorPalette.lightGrey;
/**
* Default styles for the items of a {@code BottomSheet}-based menu.
*
* These have been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
const bottomSheetItemStyles = createStyleSheet({
/**
* Container style for a generic item rendered in the menu.
*/
style: {
alignItems: 'center',
flexDirection: 'row',
height: 48
},
/**
* Style for the {@code Icon} element in a generic item of the menu.
*/
iconStyle: {
color: ColorPalette.white,
fontSize: 24
},
/**
* Style for the label in a generic item rendered in the menu.
*/
labelStyle: {
color: ColorPalette.white,
flexShrink: 1,
fontSize: 16,
marginLeft: 32,
opacity: 0.90
}
});
export const bottomSheetItemStylesCombined = {
...bottomSheetItemStyles,
underlayColor: ColorPalette.overflowMenuItemUnderlay
};
/**
* The React {@code Component} styles of {@code BottomSheet}. These have
* been implemented as per the Material Design guidelines:

View File

@ -31,6 +31,12 @@ export type Props = {
*/
onClick?: ?Function,
/**
* The event handler/listener to be invoked when this
* {@code AbstractContainer} is long pressed on React Native.
*/
onLongPress?: ?Function,
/**
* The style (as in stylesheet) to be applied to this
* {@code AbstractContainer}.

View File

@ -27,6 +27,7 @@ export default class Container<P: Props> extends AbstractContainer<P> {
accessibilityLabel,
accessible,
onClick,
onLongPress,
touchFeedback = onClick,
underlayColor,
visible = true,
@ -38,7 +39,7 @@ export default class Container<P: Props> extends AbstractContainer<P> {
return null;
}
const onClickOrTouchFeedback = onClick || touchFeedback;
const onClickOrTouchFeedback = onClick || onLongPress || touchFeedback;
let element
= super._render(
View,
@ -57,6 +58,7 @@ export default class Container<P: Props> extends AbstractContainer<P> {
{
accessibilityLabel,
accessible,
onLongPress,
onPress: onClick,
...touchFeedback && { underlayColor }
},

View File

@ -3,15 +3,19 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { openDialog } from '../../../base/dialog';
import { Audio, MEDIA_TYPE } from '../../../base/media';
import {
PARTICIPANT_ROLE,
ParticipantView,
isLocalParticipantModerator,
pinParticipant
} from '../../../base/participants';
import { Container } from '../../../base/react';
import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
import { RemoteVideoMenu } from '../../../remote-video-menu';
import AudioMutedIndicator from './AudioMutedIndicator';
import DominantSpeakerIndicator from './DominantSpeakerIndicator';
import ModeratorIndicator from './ModeratorIndicator';
@ -29,6 +33,11 @@ type Props = {
*/
_audioTrack: Object,
/**
* True if the local participant is a moderator.
*/
_isModerator: boolean,
/**
* The Redux representation of the state "features/large-video".
*/
@ -84,6 +93,7 @@ class Thumbnail extends Component<Props> {
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
this._onShowRemoteVideoMenu = this._onShowRemoteVideoMenu.bind(this);
}
/**
@ -95,6 +105,7 @@ class Thumbnail extends Component<Props> {
render() {
const {
_audioTrack: audioTrack,
_isModerator,
_largeVideo: largeVideo,
_videoTrack: videoTrack,
disablePin,
@ -114,10 +125,13 @@ class Thumbnail extends Component<Props> {
const participantInLargeVideo
= participantId === largeVideo.participantId;
const videoMuted = !videoTrack || videoTrack.muted;
const showRemoteVideoMenu = _isModerator && !participant.local;
return (
<Container
onClick = { disablePin ? undefined : this._onClick }
onLongPress = {
showRemoteVideoMenu && this._onShowRemoteVideoMenu }
style = { [
styles.thumbnail,
participant.pinned && !disablePin
@ -167,6 +181,21 @@ class Thumbnail extends Component<Props> {
// TODO The following currently ignores interfaceConfig.filmStripOnly.
dispatch(pinParticipant(participant.pinned ? null : participant.id));
}
_onShowRemoteVideoMenu: () => void;
/**
* Handles long press on the thumbnail.
*
* @returns {void}
*/
_onShowRemoteVideoMenu() {
const { dispatch, participant } = this.props;
dispatch(openDialog(RemoteVideoMenu, {
participant
}));
}
}
/**
@ -177,6 +206,7 @@ class Thumbnail extends Component<Props> {
* @private
* @returns {{
* _audioTrack: Track,
* _isModerator: boolean,
* _largeVideo: Object,
* _videoTrack: Track
* }}
@ -195,6 +225,7 @@ function _mapStateToProps(state, ownProps) {
return {
_audioTrack: audioTrack,
_isModerator: isLocalParticipantModerator(state),
_largeVideo: largeVideo,
_videoTrack: videoTrack
};

View File

@ -0,0 +1,14 @@
// @flow
import { hideDialog } from '../base/dialog';
import { RemoteVideoMenu } from './components';
/**
* Hides the remote video menu.
*
* @returns {Function}
*/
export function hideRemoteVideoMenu() {
return hideDialog(RemoteVideoMenu);
}

View File

@ -0,0 +1,3 @@
// @flow
export * from './native';

View File

@ -0,0 +1,3 @@
// @flow
export * from './web';

View File

@ -0,0 +1,46 @@
// @flow
import { connect } from 'react-redux';
import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { AbstractButton } from '../../../base/toolbox';
import type { AbstractButtonProps } from '../../../base/toolbox';
import KickRemoteParticipantDialog from './KickRemoteParticipantDialog';
type Props = AbstractButtonProps & {
/**
* The redux {@code dispatch} function.
*/
dispatch: Function,
/**
* The participant object that this button is supposed to kick.
*/
participant: Object
};
/**
* A remote video menu button which kicks the remote participant.
*/
class KickButton extends AbstractButton<Props, *> {
accessibilityLabel = 'toolbar.accessibilityLabel.audioRoute';
iconName = 'icon-kick';
label = 'videothumbnail.kick';
/**
* Handles clicking / pressing the button, and kicks the participant.
*
* @private
* @returns {void}
*/
_handleClick() {
const { dispatch, participant } = this.props;
dispatch(openDialog(KickRemoteParticipantDialog, { participant }));
}
}
export default translate(connect()(KickButton));

View File

@ -0,0 +1,84 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../../analytics';
import { ConfirmDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { kickParticipant } from '../../../base/participants';
type Props = {
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The remote participant to be kicked.
*/
participant: Object,
/**
* Function to translate i18n labels.
*/
t: Function
};
/**
* Dialog to confirm a remote participant kick action.
*/
class KickRemoteParticipantDialog extends Component<Props> {
/**
* Initializes a new {@code KickRemoteParticipantDialog} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<ConfirmDialog
contentKey = 'dialog.kickParticipantDialog'
onSubmit = { this._onSubmit } />
);
}
_onSubmit: () => boolean;
/**
* Callback for the confirm button.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
const { dispatch, participant } = this.props;
sendAnalytics(createRemoteVideoMenuButtonEvent(
'kick.button',
{
'participant_id': participant.id
}));
dispatch(kickParticipant(participant.id));
return true;
}
}
export default translate(connect()(KickRemoteParticipantDialog));

View File

@ -0,0 +1,116 @@
// @flow
import { connect } from 'react-redux';
import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../../analytics';
import { openDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { MEDIA_TYPE } from '../../../base/media';
import {
AbstractButton,
type AbstractButtonProps
} from '../../../base/toolbox';
import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
import MuteRemoteParticipantDialog from './MuteRemoteParticipantDialog';
type Props = AbstractButtonProps & {
/**
* The audio track of the participant.
*/
_audioTrack: ?Object,
/**
* The redux {@code dispatch} function.
*/
dispatch: Function,
/**
* The participant object that this button is supposed to mute/unmute.
*/
participant: Object
};
/**
* A remote video menu button which mutes the remote participant.
*/
class MuteButton extends AbstractButton<Props, *> {
accessibilityLabel = 'toolbar.accessibilityLabel.audioRoute';
iconName = 'icon-mic-disabled';
label = 'videothumbnail.domute';
toggledLabel = 'videothumbnail.muted';
/**
* Handles clicking / pressing the button, and mutes the participant.
*
* @private
* @returns {void}
*/
_handleClick() {
const { dispatch, participant } = this.props;
sendAnalytics(createRemoteVideoMenuButtonEvent(
'mute.button',
{
'participant_id': participant.id
}));
dispatch(openDialog(MuteRemoteParticipantDialog, { participant }));
}
/**
* Renders the item disabled if the participant is muted.
*
* @inheritdoc
*/
_isDisabled() {
return this._isMuted();
}
/**
* Returns true if the participant is muted, false otherwise.
*
* @returns {boolean}
*/
_isMuted() {
const { _audioTrack } = this.props;
return !_audioTrack || _audioTrack.muted;
}
/**
* Renders the item toggled if the participant is muted.
*
* @inheritdoc
*/
_isToggled() {
return this._isMuted();
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {{
* _audioTrack: Track
* }}
*/
function _mapStateToProps(state, ownProps) {
const tracks = state['features/base/tracks'];
const audioTrack
= getTrackByMediaTypeAndParticipant(
tracks, MEDIA_TYPE.AUDIO, ownProps.participant.id);
return {
_audioTrack: audioTrack
};
}
export default translate(connect(_mapStateToProps)(MuteButton));

View File

@ -0,0 +1,80 @@
// @flow
import React, { Component } from 'react';
import { connect } from 'react-redux';
import {
createRemoteMuteConfirmedEvent,
sendAnalytics
} from '../../../analytics';
import { ConfirmDialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import { muteRemoteParticipant } from '../../../base/participants';
type Props = {
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The remote participant to be muted.
*/
participant: Object,
/**
* Function to translate i18n labels.
*/
t: Function
};
/**
* Dialog to confirm a remote participant mute action.
*/
class MuteRemoteParticipantDialog extends Component<Props> {
/**
* Initializes a new {@code MuteRemoteParticipantDialog} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onSubmit = this._onSubmit.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<ConfirmDialog
contentKey = 'dialog.muteParticipantDialog'
onSubmit = { this._onSubmit } />
);
}
_onSubmit: () => boolean;
/**
* Callback for the confirm button.
*
* @private
* @returns {boolean} - True (to note that the modal should be closed).
*/
_onSubmit() {
const { dispatch, participant } = this.props;
sendAnalytics(createRemoteMuteConfirmedEvent(participant.id));
dispatch(muteRemoteParticipant(participant.id));
return true;
}
}
export default translate(connect()(MuteRemoteParticipantDialog));

View File

@ -0,0 +1,109 @@
// @flow
import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import {
BottomSheet,
bottomSheetItemStylesCombined
} from '../../../base/dialog';
import { getParticipantDisplayName } from '../../../base/participants';
import { hideRemoteVideoMenu } from '../../actions';
import KickButton from './KickButton';
import MuteButton from './MuteButton';
import styles from './styles';
type Props = {
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The participant for which this menu opened for.
*/
participant: Object,
/**
* Display name of the participant retreived from Redux.
*/
_participantDisplayName: string
}
/**
* Class to implement a popup menu that opens upon long pressing a thumbnail.
*/
class RemoteVideoMenu extends Component<Props> {
/**
* Constructor of the component.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onCancel = this._onCancel.bind(this);
}
/**
* Implements {@code Component#render}.
*
* @inheritdoc
*/
render() {
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
participant: this.props.participant,
styles: bottomSheetItemStylesCombined
};
return (
<BottomSheet onCancel = { this._onCancel }>
<View style = { styles.participantNameContainer }>
<Text style = { styles.participantNameLabel }>
{ this.props._participantDisplayName }
</Text>
</View>
<MuteButton { ...buttonProps } />
<KickButton { ...buttonProps } />
</BottomSheet>
);
}
_onCancel: () => void;
/**
* Callback to hide the {@code RemoteVideoMenu}.
*
* @private
* @returns {void}
*/
_onCancel() {
this.props.dispatch(hideRemoteVideoMenu());
}
}
/**
* Function that maps parts of Redux state tree into component props.
*
* @param {Object} state - Redux state.
* @param {Object} ownProps - Properties of component.
* @private
* @returns {{
* _participantDisplayName: string
* }}
*/
function _mapStateToProps(state, ownProps) {
const { id } = ownProps.participant;
return {
_participantDisplayName: getParticipantDisplayName(state, id)
};
}
export default connect(_mapStateToProps)(RemoteVideoMenu);

View File

@ -0,0 +1,3 @@
// @flow
export { default as RemoteVideoMenu } from './RemoteVideoMenu';

View File

@ -0,0 +1,20 @@
// @flow
import { ColorPalette, createStyleSheet } from '../../../base/styles';
export default createStyleSheet({
participantNameContainer: {
alignItems: 'center',
borderBottomColor: ColorPalette.darkGrey,
borderBottomWidth: 1,
flexDirection: 'row',
height: 48
},
participantNameLabel: {
color: ColorPalette.lightGrey,
flexShrink: 1,
fontSize: 16,
opacity: 0.90
}
});

View File

@ -6,9 +6,9 @@ import { connect } from 'react-redux';
import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../analytics';
import { translate } from '../../base/i18n';
import { kickParticipant } from '../../base/participants';
} from '../../../analytics';
import { translate } from '../../../base/i18n';
import { kickParticipant } from '../../../base/participants';
import RemoteVideoMenuButton from './RemoteVideoMenuButton';

View File

@ -6,9 +6,9 @@ import { connect } from 'react-redux';
import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../analytics';
import { translate } from '../../base/i18n';
import { openDialog } from '../../base/dialog';
} from '../../../analytics';
import { translate } from '../../../base/i18n';
import { openDialog } from '../../../base/dialog';
import RemoteVideoMenuButton from './RemoteVideoMenuButton';
import MuteRemoteParticipantDialog from './MuteRemoteParticipantDialog';

View File

@ -3,14 +3,14 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { Dialog } from '../../base/dialog';
import { translate } from '../../base/i18n';
import { Dialog } from '../../../base/dialog';
import { translate } from '../../../base/i18n';
import {
createRemoteMuteConfirmedEvent,
sendAnalytics
} from '../../analytics';
import { muteRemoteParticipant } from '../../base/participants';
} from '../../../analytics';
import { muteRemoteParticipant } from '../../../base/participants';
/**
* The type of the React {@code Component} props of

View File

@ -5,8 +5,8 @@ import React, { Component } from 'react';
import {
createRemoteVideoMenuButtonEvent,
sendAnalytics
} from '../../analytics';
import { translate } from '../../base/i18n';
} from '../../../analytics';
import { translate } from '../../../base/i18n';
import RemoteVideoMenuButton from './RemoteVideoMenuButton';

View File

@ -2,7 +2,7 @@
import React, { Component } from 'react';
import { Popover } from '../../base/popover';
import { Popover } from '../../../base/popover';
import {
MuteButton,

View File

@ -1,3 +1,5 @@
// @flow
export { default as KickButton } from './KickButton';
export { default as MuteButton } from './MuteButton';
export {

View File

@ -4,7 +4,11 @@ import React, { Component } from 'react';
import { Platform } from 'react-native';
import { connect } from 'react-redux';
import { BottomSheet, hideDialog } from '../../../base/dialog';
import {
BottomSheet,
bottomSheetItemStylesCombined,
hideDialog
} from '../../../base/dialog';
import { AudioRouteButton } from '../../../mobile/audio-mode';
import { PictureInPictureButton } from '../../../mobile/picture-in-picture';
import { LiveStreamButton, RecordButton } from '../../../recording';
@ -13,7 +17,6 @@ import { ClosedCaptionButton } from '../../../subtitles';
import { TileViewButton } from '../../../video-layout';
import AudioOnlyButton from './AudioOnlyButton';
import { overflowMenuItemStyles } from './styles';
import ToggleCameraButton from './ToggleCameraButton';
/**
@ -63,7 +66,7 @@ class OverflowMenu extends Component<Props> {
const buttonProps = {
afterClick: this._onCancel,
showLabel: true,
styles: overflowMenuItemStyles
styles: bottomSheetItemStylesCombined
};
return (

View File

@ -130,51 +130,3 @@ export const toolbarToggledButtonStyles = {
iconStyle: styles.whiteToolbarButtonIcon,
style: styles.whiteToolbarButton
};
// Overflow menu:
/**
* Styles for the {@code OverflowMenu} items.
*
* These have been implemented as per the Material Design guidelines:
* {@link https://material.io/guidelines/components/bottom-sheets.html}.
*/
const overflowMenuStyles = createStyleSheet({
/**
* Container style for a {@code ToolboxItem} rendered in the
* {@code OverflowMenu}.
*/
container: {
alignItems: 'center',
flexDirection: 'row',
height: 48
},
/**
* Style for the {@code Icon} element in a {@code ToolboxItem} rendered in
* the {@code OverflowMenu}.
*/
icon: {
color: ColorPalette.white,
fontSize: 24
},
/**
* Style for the label in a {@code ToolboxItem} rendered in the
* {@code OverflowMenu}.
*/
label: {
color: ColorPalette.white,
flexShrink: 1,
fontSize: 16,
marginLeft: 32,
opacity: 0.90
}
});
export const overflowMenuItemStyles = {
iconStyle: overflowMenuStyles.icon,
labelStyle: overflowMenuStyles.label,
style: overflowMenuStyles.container,
underlayColor: ColorPalette.overflowMenuItemUnderlay
};