feat(thumbnails) Add changes to mobile context menu

- long touch on thumbnail opens context menu
- hide context menu icon
- add button for connection info to context menu
This commit is contained in:
hmuresan 2021-06-30 19:12:12 +03:00 committed by Horatiu Muresan
parent 0507f8c2f9
commit b995221a2b
15 changed files with 817 additions and 241 deletions

View File

@ -45,6 +45,10 @@
@extend .connection-info__icon;
}
&__mobile {
margin: 15px;
}
.connection-actions {
margin: 10px auto;
text-align: center;

View File

@ -53,3 +53,5 @@ export const CONNECTION_WILL_CONNECT = 'CONNECTION_WILL_CONNECT';
* }
*/
export const SET_LOCATION_URL = 'SET_LOCATION_URL';
export const SHOW_CONNECTION_INFO = 'SHOW_CONNECTION_INFO';

View File

@ -9,7 +9,8 @@ import {
CONNECTION_ESTABLISHED,
CONNECTION_FAILED,
CONNECTION_WILL_CONNECT,
SET_LOCATION_URL
SET_LOCATION_URL,
SHOW_CONNECTION_INFO
} from './actionTypes';
import type { ConnectionFailedError } from './actions.native';
@ -37,6 +38,9 @@ ReducerRegistry.register(
case SET_ROOM:
return _setRoom(state);
case SHOW_CONNECTION_INFO:
return _setShowConnectionInfo(state, action);
}
return state;
@ -195,3 +199,19 @@ function _setRoom(state: Object) {
passwordRequired: undefined
});
}
/**
* Reduces a specific redux action {@link SHOW_CONNECTION_INFO} of the feature
* base/connection.
*
* @param {Object} state - The redux state of the feature base/connection.
* @param {Action} action - The redux action {@code SHOW_CONNECTION_INFO} to reduce.
* @private
* @returns {Object} The new state of the feature base/connection after the
* reduction of the specified action.
*/
function _setShowConnectionInfo(
state: Object,
{ showConnectionInfo }: { showConnectionInfo: boolean }) {
return set(state, 'showConnectionInfo', showConnectionInfo);
}

View File

@ -4,6 +4,7 @@ import InlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react';
import { Drawer, DrawerPortal } from '../../../toolbox/components/web';
import { isMobileBrowser } from '../../environment/utils';
/**
* A map of dialog positions, relative to trigger, to css classes used to
@ -63,6 +64,11 @@ type Props = {
*/
id: string,
/**
* Callback to invoke when the popover has closed.
*/
onPopoverClose: Function,
/**
* Callback to invoke when the popover has opened.
*/
@ -134,6 +140,16 @@ class Popover extends Component<Props, State> {
this._onEscKey = this._onEscKey.bind(this);
}
/**
* Public method for triggering showing the context menu dialog.
*
* @returns {void}
* @public
*/
showDialog() {
this.setState({ showDialog: true });
}
/**
* Sets up an event listener to open a drawer when clicking, rather than entering the
* overflow area.
@ -145,7 +161,7 @@ class Popover extends Component<Props, State> {
* @returns {void}
*/
componentDidMount() {
if (this._drawerContainerRef && this._drawerContainerRef.current) {
if (this._drawerContainerRef && this._drawerContainerRef.current && !isMobileBrowser()) {
this._drawerContainerRef.current.addEventListener('click', this._onShowDialog);
}
}
@ -232,6 +248,10 @@ class Popover extends Component<Props, State> {
*/
_onHideDialog() {
this.setState({ showDialog: false });
if (this.props.onPopoverClose) {
this.props.onPopoverClose();
}
}
_onShowDialog: (Object) => void;

View File

@ -6,20 +6,16 @@ import type { Dispatch } from 'redux';
import { translate } from '../../../base/i18n';
import { Icon, IconConnectionActive, IconConnectionInactive } from '../../../base/icons';
import { JitsiParticipantConnectionStatus } from '../../../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../../../base/media';
import { getLocalParticipant, getParticipantById } from '../../../base/participants';
import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux';
import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
import { ConnectionStatsTable } from '../../../connection-stats';
import { saveLogs } from '../../actions';
import AbstractConnectionIndicator, {
INDICATOR_DISPLAY_THRESHOLD,
type Props as AbstractProps,
type State as AbstractState
} from '../AbstractConnectionIndicator';
declare var interfaceConfig: Object;
import ConnectionIndicatorContent from './ConnectionIndicatorContent';
/**
* An array of display configurations for the connection indicator and its bars.
@ -84,17 +80,6 @@ type Props = AbstractProps & {
*/
dispatch: Dispatch<any>,
/**
* Whether or not should display the "Save Logs" link in the local video
* stats table.
*/
enableSaveLogs: boolean,
/**
* Whether or not should display the "Show More" link in the local video
* stats table.
*/
disableShowMoreStats: boolean,
/**
* Whether or not clicking the indicator should display a popover for more
@ -122,27 +107,6 @@ type Props = AbstractProps & {
* Invoked to obtain translated strings.
*/
t: Function,
/**
* The video SSRC of this client.
*/
videoSsrc: number,
/**
* Invoked to save the conference logs.
*/
_onSaveLogs: Function
};
/**
* The type of the React {@code Component} state of {@link ConnectionIndicator}.
*/
type State = AbstractState & {
/**
* Whether or not the popover content should display additional statistics.
*/
showMoreStats: boolean
};
/**
@ -151,7 +115,7 @@ type State = AbstractState & {
*
* @extends {Component}
*/
class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
class ConnectionIndicator extends AbstractConnectionIndicator<Props, AbstractState> {
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
@ -164,12 +128,8 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
this.state = {
autoHideTimeout: undefined,
showIndicator: false,
showMoreStats: false,
stats: {}
};
// Bind event handlers so they are only bound once for every instance.
this._onToggleShowMore = this._onToggleShowMore.bind(this);
}
/**
@ -189,7 +149,7 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
return (
<Popover
className = { rootClassNames }
content = { this._renderStatisticsTable() }
content = { <ConnectionIndicatorContent participantId = { this.props.participantId } /> }
disablePopover = { !this.props.enableStatsDisplay }
position = { this.props.statsPopoverPosition }>
<div className = 'popover-trigger'>
@ -228,43 +188,6 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
return this._getDisplayConfiguration(percent).colorClass;
}
/**
* Returns a string that describes the current connection status.
*
* @private
* @returns {string}
*/
_getConnectionStatusTip() {
let tipKey;
switch (this.props._connectionStatus) {
case JitsiParticipantConnectionStatus.INTERRUPTED:
tipKey = 'connectionindicator.quality.lost';
break;
case JitsiParticipantConnectionStatus.INACTIVE:
tipKey = 'connectionindicator.quality.inactive';
break;
default: {
const { percent } = this.state.stats;
if (typeof percent === 'undefined') {
// If percentage is undefined then there are no stats available
// yet, likely because only a local connection has been
// established so far. Assume a strong connection to start.
tipKey = 'connectionindicator.quality.good';
} else {
const config = this._getDisplayConfiguration(percent);
tipKey = config.tip;
}
}
}
return this.props.t(tipKey);
}
/**
* Get the icon configuration from QUALITY_TO_WIDTH which has a percentage
* that matches or exceeds the passed in percentage. The implementation
@ -297,19 +220,6 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
? 'show-connection-indicator' : 'hide-connection-indicator';
}
_onToggleShowMore: () => void;
/**
* Callback to invoke when the show more link in the popover content is
* clicked. Sets the state which will determine if the popover should show
* additional statistics about the connection.
*
* @returns {void}
*/
_onToggleShowMore() {
this.setState({ showMoreStats: !this.state.showMoreStats });
}
/**
* Creates a ReactElement for displaying an icon that represents the current
* connection quality.
@ -367,80 +277,8 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, State> {
</span>
];
}
/**
* Creates a {@code ConnectionStatisticsTable} instance.
*
* @returns {ReactElement}
*/
_renderStatisticsTable() {
const {
bandwidth,
bitrate,
bridgeCount,
codec,
e2eRtt,
framerate,
maxEnabledResolution,
packetLoss,
region,
resolution,
serverRegion,
transport
} = this.state.stats;
return (
<ConnectionStatsTable
audioSsrc = { this.props.audioSsrc }
bandwidth = { bandwidth }
bitrate = { bitrate }
bridgeCount = { bridgeCount }
codec = { codec }
connectionSummary = { this._getConnectionStatusTip() }
disableShowMoreStats = { this.props.disableShowMoreStats }
e2eRtt = { e2eRtt }
enableSaveLogs = { this.props.enableSaveLogs }
framerate = { framerate }
isLocalVideo = { this.props.isLocalVideo }
maxEnabledResolution = { maxEnabledResolution }
onSaveLogs = { this.props._onSaveLogs }
onShowMore = { this._onToggleShowMore }
packetLoss = { packetLoss }
participantId = { this.props.participantId }
region = { region }
resolution = { resolution }
serverRegion = { serverRegion }
shouldShowMore = { this.state.showMoreStats }
transport = { transport }
videoSsrc = { this.props.videoSsrc } />
);
}
}
/**
* Maps redux actions to the props of the component.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* _onSaveLogs: Function,
* }}
* @private
*/
export function _mapDispatchToProps(dispatch: Dispatch<any>) {
return {
/**
* Saves the conference logs.
*
* @returns {Function}
*/
_onSaveLogs() {
dispatch(saveLogs());
}
};
}
/**
* Maps part of the Redux state to the props of this component.
*
@ -450,30 +288,11 @@ export function _mapDispatchToProps(dispatch: Dispatch<any>) {
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
const { participantId } = ownProps;
const conference = state['features/base/conference'].conference;
const participant
= typeof participantId === 'undefined' ? getLocalParticipant(state) : getParticipantById(state, participantId);
const props = {
_connectionStatus: participant?.connectionStatus,
enableSaveLogs: state['features/base/config'].enableSaveLogs,
disableShowMoreStats: state['features/base/config'].disableShowMoreStats
};
if (conference) {
const firstVideoTrack = getTrackByMediaTypeAndParticipant(
state['features/base/tracks'], MEDIA_TYPE.VIDEO, participantId);
const firstAudioTrack = getTrackByMediaTypeAndParticipant(
state['features/base/tracks'], MEDIA_TYPE.AUDIO, participantId);
return {
...props,
audioSsrc: firstAudioTrack ? conference.getSsrcByTrack(firstAudioTrack.jitsiTrack) : undefined,
videoSsrc: firstVideoTrack ? conference.getSsrcByTrack(firstVideoTrack.jitsiTrack) : undefined
};
}
= participantId ? getParticipantById(state, participantId) : getLocalParticipant(state);
return {
...props
_connectionStatus: participant?.connectionStatus
};
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ConnectionIndicator));
export default translate(connect(_mapStateToProps)(ConnectionIndicator));

View File

@ -0,0 +1,325 @@
// @flow
import React from 'react';
import type { Dispatch } from 'redux';
import { translate } from '../../../base/i18n';
import { JitsiParticipantConnectionStatus } from '../../../base/lib-jitsi-meet';
import { MEDIA_TYPE } from '../../../base/media';
import { getLocalParticipant, getParticipantById } from '../../../base/participants';
import { connect } from '../../../base/redux';
import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
import { ConnectionStatsTable } from '../../../connection-stats';
import { saveLogs } from '../../actions';
import AbstractConnectionIndicator, {
INDICATOR_DISPLAY_THRESHOLD,
type Props as AbstractProps,
type State as AbstractState
} from '../AbstractConnectionIndicator';
/**
* An array of display configurations for the connection indicator and its bars.
* The ordering is done specifically for faster iteration to find a matching
* configuration to the current connection strength percentage.
*
* @type {Object[]}
*/
const QUALITY_TO_WIDTH: Array<Object> = [
// Full (3 bars)
{
colorClass: 'status-high',
percent: INDICATOR_DISPLAY_THRESHOLD,
tip: 'connectionindicator.quality.good',
width: '100%'
},
// 2 bars
{
colorClass: 'status-med',
percent: 10,
tip: 'connectionindicator.quality.nonoptimal',
width: '66%'
},
// 1 bar
{
colorClass: 'status-low',
percent: 0,
tip: 'connectionindicator.quality.poor',
width: '33%'
}
// Note: we never show 0 bars as long as there is a connection.
];
/**
* The type of the React {@code Component} props of {@link ConnectionIndicator}.
*/
type Props = AbstractProps & {
/**
* The current condition of the user's connection, matching one of the
* enumerated values in the library.
*/
_connectionStatus: string,
/**
* The audio SSRC of this client.
*/
audioSsrc: number,
/**
* Css class to apply on container
*/
className: string,
/**
* The Redux dispatch function.
*/
dispatch: Dispatch<any>,
/**
* Whether or not should display the "Show More" link in the local video
* stats table.
*/
disableShowMoreStats: boolean,
/**
* Whether or not should display the "Save Logs" link in the local video
* stats table.
*/
enableSaveLogs: boolean,
/**
* Whether or not the displays stats are for local video.
*/
isLocalVideo: boolean,
/**
* Invoked to obtain translated strings.
*/
t: Function,
/**
* The video SSRC of this client.
*/
videoSsrc: number,
/**
* Invoked to save the conference logs.
*/
_onSaveLogs: Function
};
/**
* The type of the React {@code Component} state of {@link ConnectionIndicator}.
*/
type State = AbstractState & {
/**
* Whether or not the popover content should display additional statistics.
*/
showMoreStats: boolean
};
/**
* Implements a React {@link Component} which displays the current connection
* quality percentage and has a popover to show more detailed connection stats.
*
* @extends {Component}
*/
class ConnectionIndicatorContent extends AbstractConnectionIndicator<Props, State> {
/**
* Initializes a new {@code ConnectionIndicator} instance.
*
* @param {Object} props - The read-only properties with which the new
* instance is to be initialized.
*/
constructor(props: Props) {
super(props);
this.state = {
autoHideTimeout: undefined,
showIndicator: false,
showMoreStats: false,
stats: {}
};
// Bind event handlers so they are only bound once for every instance.
this._onToggleShowMore = this._onToggleShowMore.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
bandwidth,
bitrate,
bridgeCount,
codec,
e2eRtt,
framerate,
maxEnabledResolution,
packetLoss,
region,
resolution,
serverRegion,
transport
} = this.state.stats;
return (
<ConnectionStatsTable
audioSsrc = { this.props.audioSsrc }
bandwidth = { bandwidth }
bitrate = { bitrate }
bridgeCount = { bridgeCount }
codec = { codec }
connectionSummary = { this._getConnectionStatusTip() }
disableShowMoreStats = { this.props.disableShowMoreStats }
e2eRtt = { e2eRtt }
enableSaveLogs = { this.props.enableSaveLogs }
framerate = { framerate }
isLocalVideo = { this.props.isLocalVideo }
maxEnabledResolution = { maxEnabledResolution }
onSaveLogs = { this.props._onSaveLogs }
onShowMore = { this._onToggleShowMore }
packetLoss = { packetLoss }
participantId = { this.props.participantId }
region = { region }
resolution = { resolution }
serverRegion = { serverRegion }
shouldShowMore = { this.state.showMoreStats }
transport = { transport }
videoSsrc = { this.props.videoSsrc } />
);
}
/**
* Returns a string that describes the current connection status.
*
* @private
* @returns {string}
*/
_getConnectionStatusTip() {
let tipKey;
switch (this.props._connectionStatus) {
case JitsiParticipantConnectionStatus.INTERRUPTED:
tipKey = 'connectionindicator.quality.lost';
break;
case JitsiParticipantConnectionStatus.INACTIVE:
tipKey = 'connectionindicator.quality.inactive';
break;
default: {
const { percent } = this.state.stats;
if (typeof percent === 'undefined') {
// If percentage is undefined then there are no stats available
// yet, likely because only a local connection has been
// established so far. Assume a strong connection to start.
tipKey = 'connectionindicator.quality.good';
} else {
const config = this._getDisplayConfiguration(percent);
tipKey = config.tip;
}
}
}
return this.props.t(tipKey);
}
/**
* Get the icon configuration from QUALITY_TO_WIDTH which has a percentage
* that matches or exceeds the passed in percentage. The implementation
* assumes QUALITY_TO_WIDTH 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}
*/
_getDisplayConfiguration(percent: number): Object {
return QUALITY_TO_WIDTH.find(x => percent >= x.percent) || {};
}
_onToggleShowMore: () => void;
/**
* Callback to invoke when the show more link in the popover content is
* clicked. Sets the state which will determine if the popover should show
* additional statistics about the connection.
*
* @returns {void}
*/
_onToggleShowMore() {
this.setState({ showMoreStats: !this.state.showMoreStats });
}
}
/**
* Maps redux actions to the props of the component.
*
* @param {Function} dispatch - The redux action {@code dispatch} function.
* @returns {{
* _onSaveLogs: Function,
* }}
* @private
*/
export function _mapDispatchToProps(dispatch: Dispatch<any>) {
return {
/**
* Saves the conference logs.
*
* @returns {Function}
*/
_onSaveLogs() {
dispatch(saveLogs());
}
};
}
/**
* Maps part of the Redux state to the props of this component.
*
* @param {Object} state - The Redux state.
* @param {Props} ownProps - The own props of the component.
* @returns {Props}
*/
export function _mapStateToProps(state: Object, ownProps: Props) {
const { participantId } = ownProps;
const conference = state['features/base/conference'].conference;
const participant
= participantId ? getParticipantById(state, participantId) : getLocalParticipant(state);
const props = {
_connectionStatus: participant?.connectionStatus,
enableSaveLogs: state['features/base/config'].enableSaveLogs,
disableShowMoreStats: state['features/base/config'].disableShowMoreStats
};
if (conference) {
const firstVideoTrack = getTrackByMediaTypeAndParticipant(
state['features/base/tracks'], MEDIA_TYPE.VIDEO, participantId);
const firstAudioTrack = getTrackByMediaTypeAndParticipant(
state['features/base/tracks'], MEDIA_TYPE.AUDIO, participantId);
return {
...props,
audioSsrc: firstAudioTrack ? conference.getSsrcByTrack(firstAudioTrack.jitsiTrack) : undefined,
videoSsrc: firstVideoTrack ? conference.getSsrcByTrack(firstVideoTrack.jitsiTrack) : undefined
};
}
return props;
}
export default translate(connect(_mapStateToProps, _mapDispatchToProps)(ConnectionIndicatorContent));

View File

@ -2,6 +2,7 @@
import React, { Component } from 'react';
import { isMobileBrowser } from '../../../features/base/environment/utils';
import { translate } from '../../base/i18n';
/**
@ -176,10 +177,11 @@ class ConnectionStatsTable extends Component<Props> {
*/
render() {
const { isLocalVideo, enableSaveLogs, disableShowMoreStats } = this.props;
const className = isMobileBrowser() ? 'connection-info connection-info__mobile' : 'connection-info';
return (
<div
className = 'connection-info'
className = { className }
onClick = { onClick }>
{ this._renderStatistics() }
<div className = 'connection-actions'>

View File

@ -2,6 +2,7 @@
import React, { Component } from 'react';
import { isMobileBrowser } from '../../../../../react/features/base/environment/utils';
import { createScreenSharingIssueEvent, sendAnalytics } from '../../../analytics';
import { AudioLevelIndicator } from '../../../audio-level-indicator';
import { Avatar } from '../../../base/avatar';
@ -33,7 +34,8 @@ import {
DISPLAY_MODE_TO_STRING,
DISPLAY_VIDEO,
DISPLAY_VIDEO_WITH_NAME,
VIDEO_TEST_EVENTS
VIDEO_TEST_EVENTS,
SHOW_TOOLBAR_CONTEXT_MENU_AFTER
} from '../../constants';
import { isVideoPlayable, computeDisplayMode } from '../../functions';
import logger from '../../logger';
@ -237,6 +239,16 @@ function onClick(event) {
* @extends Component
*/
class Thumbnail extends Component<Props, State> {
/**
* The long touch setTimeout handler.
*/
timeoutHandle: Object;
/**
* Reference to local or remote Video Menu trigger button instance.
*/
videoMenuTriggerRef: Object;
/**
* Initializes a new Thumbnail instance.
*
@ -257,7 +269,10 @@ class Thumbnail extends Component<Props, State> {
...state,
displayMode: computeDisplayMode(Thumbnail.getDisplayModeInput(props, state))
};
this.timeoutHandle = null;
this.videoMenuTriggerRef = null;
this._setInstance = this._setInstance.bind(this);
this._updateAudioLevel = this._updateAudioLevel.bind(this);
this._onCanPlay = this._onCanPlay.bind(this);
this._onClick = this._onClick.bind(this);
@ -265,6 +280,10 @@ class Thumbnail extends Component<Props, State> {
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);
this._onTestingEvent = this._onTestingEvent.bind(this);
this._onTouchStart = this._onTouchStart.bind(this);
this._onTouchEnd = this._onTouchEnd.bind(this);
this._onTouchMove = this._onTouchMove.bind(this);
this._showPopupMenu = this._showPopupMenu.bind(this);
}
/**
@ -539,6 +558,54 @@ class Thumbnail extends Component<Props, State> {
this.setState({ isHovered: false });
}
_showPopupMenu: () => void;
/**
* Triggers showing the popover context menu.
*
* @returns {void}
*/
_showPopupMenu() {
if (this.videoMenuTriggerRef) {
this.videoMenuTriggerRef.showContextMenu();
}
}
_onTouchStart: () => void;
/**
* Set showing popover context menu after x miliseconds.
*
* @returns {void}
*/
_onTouchStart() {
this.timeoutHandle = setTimeout(this._showPopupMenu, SHOW_TOOLBAR_CONTEXT_MENU_AFTER);
}
_onTouchEnd: () => void;
/**
* Cancel showing popover context menu after x miliseconds if the no. Of miliseconds is not reached yet,
* or just clears the timeout.
*
* @returns {void}
*/
_onTouchEnd() {
clearTimeout(this.timeoutHandle);
}
_onTouchMove: () => void;
/**
* Cancel showing Context menu after x miliseconds if the number of miliseconds is not reached
* before a touch move(drag), or just clears the timeout.
*
* @returns {void}
*/
_onTouchMove() {
clearTimeout(this.timeoutHandle);
}
/**
* Renders a fake participant (youtube video) thumbnail.
*
@ -709,6 +776,11 @@ class Thumbnail extends Component<Props, State> {
onClick = { this._onClick }
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
{ ...(isMobileBrowser() ? {
onTouchEnd: this._onTouchEnd,
onTouchMove: this._onTouchMove,
onTouchStart: this._onTouchStart
} : {}) }
style = { styles.thumbnail }>
<div className = 'videocontainer__background' />
<span id = 'localVideoWrapper'>
@ -738,8 +810,10 @@ class Thumbnail extends Component<Props, State> {
<AudioLevelIndicator audioLevel = { audioLevel } />
</span>
<span className = 'localvideomenu'>
<LocalVideoMenuTriggerButton />
<LocalVideoMenuTriggerButton
getRef = { this._setInstance } />
</span>
</span>
);
}
@ -783,6 +857,19 @@ class Thumbnail extends Component<Props, State> {
dispatch(updateLastTrackVideoMediaEvent(jitsiVideoTrack, event.type));
}
_setInstance: Object => void;
/**
* Stores the local or remote video menu button instance in a variable.
*
* @param {Object} instance - The local or remote video menu trigger instance.
*
* @returns {void}
*/
_setInstance(instance) {
this.videoMenuTriggerRef = instance;
}
/**
* Renders a remote participant's 'thumbnail.
*
@ -826,6 +913,11 @@ class Thumbnail extends Component<Props, State> {
onClick = { this._onClick }
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
{ ...(isMobileBrowser() ? {
onTouchEnd: this._onTouchEnd,
onTouchMove: this._onTouchMove,
onTouchStart: this._onTouchStart
} : {}) }
style = { styles.thumbnail }>
{
_videoTrack && <VideoTrack
@ -859,6 +951,7 @@ class Thumbnail extends Component<Props, State> {
</span>
<span className = 'remotevideomenu'>
<RemoteVideoMenuTriggerButton
getRef = { this._setInstance }
initialVolumeValue = { _volume }
onVolumeChange = { onVolumeChange }
participantID = { id } />
@ -982,7 +1075,7 @@ function _mapStateToProps(state, ownProps): Object {
return {
_audioTrack,
_connectionIndicatorAutoHideEnabled: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED,
_connectionIndicatorDisabled: interfaceConfig.CONNECTION_INDICATOR_DISABLED,
_connectionIndicatorDisabled: isMobileBrowser() || interfaceConfig.CONNECTION_INDICATOR_DISABLED,
_currentLayout,
_defaultLocalDisplayName: interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME,
_disableLocalVideoFlip: Boolean(disableLocalVideoFlip),

View File

@ -208,3 +208,10 @@ export const VERTICAL_FILMSTRIP_MIN_HORIZONTAL_MARGIN = 10;
* @type {number}
*/
export const HORIZONTAL_FILMSTRIP_MARGIN = 39;
/**
* Sets after how many ms to show the thumbnail context menu on long touch on mobile.
*
* @type {number}
*/
export const SHOW_TOOLBAR_CONTEXT_MENU_AFTER = 600;

View File

@ -48,22 +48,24 @@ function Drawer({
const drawerRef: Object = useRef(null);
/**
* Closes the drawer when clicking outside of it.
* Closes the drawer when clicking or touching outside of it.
*
* @param {Event} event - Mouse down event object.
* @param {Event} event - Mouse down/start touch event object.
* @returns {void}
*/
function handleOutsideClick(event: MouseEvent) {
function handleOutsideClickOrTouch(event: Event) {
if (drawerRef.current && !drawerRef.current.contains(event.target)) {
onClose();
}
}
useEffect(() => {
window.addEventListener('mousedown', handleOutsideClick);
window.addEventListener('mousedown', handleOutsideClickOrTouch);
window.addEventListener('touchstart', handleOutsideClickOrTouch);
return () => {
window.removeEventListener('mousedown', handleOutsideClick);
window.removeEventListener('mousedown', handleOutsideClickOrTouch);
window.removeEventListener('touchstart', handleOutsideClickOrTouch);
};
}, [ drawerRef ]);

View File

@ -1,2 +1,18 @@
// @flow
import { SHOW_CONNECTION_INFO } from '../base/connection/actionTypes';
export * from './actions.any';
/**
* Sets whether to render the connnection 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,48 @@
// @flow
import React, { useCallback } from 'react';
import { translate } from '../../../base/i18n';
import { IconInfo } from '../../../base/icons';
import { connect } from '../../../base/redux';
import { renderConnectionStatus } from '../../actions.web';
import VideoMenuButton from './VideoMenuButton';
type Props = {
/**
* The Redux dispatch function.
*/
dispatch: Function,
/**
* The ID of the participant for which to show connection stats.
*/
participantId: string,
/**
* The function to be used to translate i18n labels.
*/
t: Function
};
const ConnectionStatusButton = ({
dispatch,
participantId,
t
}: Props) => {
const onClick = useCallback(() => {
dispatch(renderConnectionStatus(true));
}, [ dispatch ]);
return (
<VideoMenuButton
buttonText = { t('videothumbnail.connectionInfo') }
icon = { IconInfo }
id = { `connstatus_${participantId}` }
onClick = { onClick } />
);
};
export default translate(connect()(ConnectionStatusButton));

View File

@ -1,23 +1,46 @@
// @flow
import React from 'react';
import React, { Component } from 'react';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n';
import { Icon, IconMenuThumb } from '../../../base/icons';
import {
getLocalParticipant
} from '../../../base/participants';
import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux';
import { getLocalVideoTrack } from '../../../base/tracks';
import ConnectionIndicatorContent from '../../../connection-indicator/components/web/ConnectionIndicatorContent';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web';
import ConnectionStatusButton from './ConnectionStatusButton';
import FlipLocalVideoButton from './FlipLocalVideoButton';
import VideoMenu from './VideoMenu';
/**
* The type of the React {@code Component} props of
* {@link LocalVideoMenuTriggerButton}.
*/
type Props = {
/**
* The redux dispatch function.
*/
dispatch: Function,
/**
* Gets a ref to the current component instance.
*/
getRef: Function,
/**
* The id of the local participant.
*/
_localParticipantId: string,
/**
* The position relative to the trigger the local video menu should display
* from. Valid values are those supported by AtlasKit
@ -30,6 +53,11 @@ type Props = {
*/
_overflowDrawer: boolean,
/**
* Whether to render the connection info pane.
*/
_showConnectionInfo: boolean,
/**
* Shows/hides the local video flip button.
*/
@ -45,33 +73,124 @@ type Props = {
* React Component for displaying an icon associated with opening the
* the video menu for the local participant.
*
* @param {Props} props - The props passed to the component.
* @returns {ReactElement}
* @extends {Component}
*/
function LocalVideoMenuTriggerButton(props: Props) {
return (
props._showLocalVideoFlipButton
? <Popover
content = {
<VideoMenu id = 'localVideoMenu'>
<FlipLocalVideoButton />
</VideoMenu>
}
overflowDrawer = { props._overflowDrawer }
position = { props._menuPosition }>
<span
className = 'popover-trigger local-video-menu-trigger'>
<Icon
ariaLabel = { props.t('dialog.localUserControls') }
role = 'button'
size = '1em'
src = { IconMenuThumb }
tabIndex = { 0 }
title = { props.t('dialog.localUserControls') } />
</span>
</Popover>
: null
);
class LocalVideoMenuTriggerButton extends Component<Props> {
/**
* Reference to the Popover instance.
*/
popoverRef: Object;
/**
* Initializes a new LocalVideoMenuTriggerButton instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: Props) {
super(props);
this.popoverRef = React.createRef();
this._onPopoverClose = this._onPopoverClose.bind(this);
}
/**
* Triggers showing the popover's context menu.
*
* @returns {void}
*/
showContextMenu() {
if (this.popoverRef && this.popoverRef.current) {
this.popoverRef.current.showDialog();
}
}
/**
* Calls the ref(instance) getter.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
if (this.props.getRef) {
this.props.getRef(this);
}
}
/**
* Calls the ref(instance) getter.
*
* @inheritdoc
* @returns {void}
*/
componentWillUnmount() {
if (this.props.getRef) {
this.props.getRef(null);
}
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
const {
_localParticipantId,
_menuPosition,
_showConnectionInfo,
_overflowDrawer,
_showLocalVideoFlipButton,
t
} = this.props;
const content = _showConnectionInfo
? <ConnectionIndicatorContent participantId = { _localParticipantId } />
: (
<VideoMenu id = 'localVideoMenu'>
<FlipLocalVideoButton />
{ isMobileBrowser()
&& <ConnectionStatusButton participantId = { _localParticipantId } />
}
</VideoMenu>
);
return (
isMobileBrowser() || _showLocalVideoFlipButton
? <Popover
content = { content }
onPopoverClose = { this._onPopoverClose }
overflowDrawer = { _overflowDrawer }
position = { _menuPosition }
ref = { this.popoverRef }>
{!isMobileBrowser() && (
<span
className = 'popover-trigger local-video-menu-trigger'>
<Icon
ariaLabel = { t('dialog.localUserControls') }
role = 'button'
size = '1em'
src = { IconMenuThumb }
tabIndex = { 0 }
title = { t('dialog.localUserControls') } />
</span>
)}
</Popover>
: null
);
}
_onPopoverClose: () => void;
/**
* Render normal context menu next time popover dialog opens.
*
* @returns {void}
*/
_onPopoverClose() {
this.props.dispatch(renderConnectionStatus(false));
}
}
/**
@ -83,9 +202,12 @@ function LocalVideoMenuTriggerButton(props: Props) {
*/
function _mapStateToProps(state) {
const currentLayout = getCurrentLayout(state);
const localParticipant = getLocalParticipant(state);
const { disableLocalVideoFlip } = state['features/base/config'];
const videoTrack = getLocalVideoTrack(state['features/base/tracks']);
const { overflowDrawer } = state['features/toolbox'];
const { showConnectionInfo } = state['features/base/connection'];
let _menuPosition;
switch (currentLayout) {
@ -102,7 +224,9 @@ function _mapStateToProps(state) {
return {
_menuPosition,
_showLocalVideoFlipButton: !disableLocalVideoFlip && videoTrack?.videoType !== 'desktop',
_overflowDrawer: overflowDrawer
_overflowDrawer: overflowDrawer,
_localParticipantId: localParticipant.id,
_showConnectionInfo: showConnectionInfo
};
}

View File

@ -2,6 +2,9 @@
import React, { Component } from 'react';
import ConnectionIndicatorContent from
'../../../../features/connection-indicator/components/web/ConnectionIndicatorContent';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n';
import { Icon, IconMenuThumb } from '../../../base/icons';
import { getLocalParticipant, getParticipantById, PARTICIPANT_ROLE } from '../../../base/participants';
@ -9,11 +12,14 @@ import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux';
import { requestRemoteControl, stopController } from '../../../remote-control';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { renderConnectionStatus } from '../../actions.web';
import ConnectionStatusButton from './ConnectionStatusButton';
import MuteEveryoneElseButton from './MuteEveryoneElseButton';
import MuteEveryoneElsesVideoButton from './MuteEveryoneElsesVideoButton';
import { REMOTE_CONTROL_MENU_STATES } from './RemoteControlButton';
import {
GrantModeratorButton,
MuteButton,
@ -26,7 +32,6 @@ import {
} from './';
declare var $: Object;
declare var interfaceConfig: Object;
/**
* The type of the React {@code Component} props of
@ -71,12 +76,16 @@ type Props = {
*/
_remoteControlState: number,
/**
* The redux dispatch function.
*/
dispatch: Function,
/**
* Gets a ref to the current component instance.
*/
getRef: Function,
/**
* A value between 0 and 1 indicating the volume of the participant's
* audio element.
@ -99,6 +108,11 @@ type Props = {
*/
_participantDisplayName: string,
/**
* Whether the popover should render the Connection Info stats.
*/
_showConnectionInfo: Boolean,
/**
* Invoked to obtain translated strings.
*/
@ -112,6 +126,59 @@ type Props = {
* @extends {Component}
*/
class RemoteVideoMenuTriggerButton extends Component<Props> {
/**
* Reference to the Popover instance.
*/
popoverRef: Object;
/**
* Initializes a new RemoteVideoMenuTriggerButton instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: Props) {
super(props);
this.popoverRef = React.createRef();
this._onPopoverClose = this._onPopoverClose.bind(this);
}
/**
* Triggers showing the popover's context menu.
*
* @returns {void}
*/
showContextMenu() {
if (this.popoverRef && this.popoverRef.current) {
this.popoverRef.current.showDialog();
}
}
/**
* Calls the ref(instance) getter.
*
* @inheritdoc
* @returns {void}
*/
componentDidMount() {
if (this.props.getRef) {
this.props.getRef(this);
}
}
/**
* Calls the ref(instance) getter.
*
* @inheritdoc
* @returns {void}
*/
componentWillUnmount() {
if (this.props.getRef) {
this.props.getRef(null);
}
}
/**
* Implements React's {@link Component#render()}.
*
@ -119,32 +186,50 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
* @returns {ReactElement}
*/
render() {
const content = this._renderRemoteVideoMenu();
const { _showConnectionInfo, _participantDisplayName, participantID } = this.props;
const content = _showConnectionInfo
? <ConnectionIndicatorContent participantId = { participantID } />
: this._renderRemoteVideoMenu();
if (!content) {
return null;
}
const username = this.props._participantDisplayName;
const username = _participantDisplayName;
return (
<Popover
content = { content }
onPopoverClose = { this._onPopoverClose }
overflowDrawer = { this.props._overflowDrawer }
position = { this.props._menuPosition }>
<span className = 'popover-trigger remote-video-menu-trigger'>
<Icon
ariaLabel = { this.props.t('dialog.remoteUserControls', { username }) }
role = 'button'
size = '1.4em'
src = { IconMenuThumb }
tabIndex = { 0 }
title = { this.props.t('dialog.remoteUserControls', { username }) } />
</span>
position = { this.props._menuPosition }
ref = { this.popoverRef }>
{!isMobileBrowser() && (
<span className = 'popover-trigger remote-video-menu-trigger'>
<Icon
ariaLabel = { this.props.t('dialog.remoteUserControls', { username }) }
role = 'button'
size = '1.4em'
src = { IconMenuThumb }
tabIndex = { 0 }
title = { this.props.t('dialog.remoteUserControls', { username }) } />
</span>
)}
</Popover>
);
}
_onPopoverClose: () => void;
/**
* Render normal context menu next time popover dialog opens.
*
* @returns {void}
*/
_onPopoverClose() {
this.props.dispatch(renderConnectionStatus(false));
}
/**
* Creates a new {@code VideoMenu} with buttons for interacting with
* the remote participant.
@ -232,6 +317,12 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
participantID = { participantID } />
);
if (isMobileBrowser()) {
buttons.push(
<ConnectionStatusButton
participantId = { participantID } />
);
}
if (onVolumeChange && typeof initialVolumeValue === 'number' && !isNaN(initialVolumeValue)) {
buttons.push(
@ -276,6 +367,7 @@ function _mapStateToProps(state, ownProps) {
const { requestedParticipant, controlled } = controller;
const activeParticipant = requestedParticipant || controlled;
const { overflowDrawer } = state['features/toolbox'];
const { showConnectionInfo } = state['features/base/connection'];
if (_supportsRemoteControl
&& ((!active && !_isRemoteControlSessionActive) || activeParticipant === participantID)) {
@ -310,7 +402,8 @@ function _mapStateToProps(state, ownProps) {
_menuPosition,
_overflowDrawer: overflowDrawer,
_participantDisplayName,
_disableGrantModerator: Boolean(disableGrantModerator)
_disableGrantModerator: Boolean(disableGrantModerator),
_showConnectionInfo: showConnectionInfo
};
}

View File

@ -1,5 +1,6 @@
// @flow
export { default as ConnectionStatusButton } from './ConnectionStatusButton';
export { default as GrantModeratorButton } from './GrantModeratorButton';
export { default as GrantModeratorDialog } from './GrantModeratorDialog';
export { default as KickButton } from './KickButton';