From b995221a2bbe5c9f9bd188b41361e99ba4cb1cff Mon Sep 17 00:00:00 2001 From: hmuresan Date: Wed, 30 Jun 2021 19:12:12 +0300 Subject: [PATCH] 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 --- css/_connection-info.scss | 4 + react/features/base/connection/actionTypes.js | 2 + react/features/base/connection/reducer.js | 22 +- .../base/popover/components/Popover.web.js | 22 +- .../components/web/ConnectionIndicator.js | 193 +---------- .../web/ConnectionIndicatorContent.js | 325 ++++++++++++++++++ .../components/ConnectionStatsTable.js | 4 +- .../filmstrip/components/web/Thumbnail.js | 99 +++++- react/features/filmstrip/constants.js | 7 + .../features/toolbox/components/web/Drawer.js | 12 +- react/features/video-menu/actions.web.js | 16 + .../components/web/ConnectionStatusButton.js | 48 +++ .../web/LocalVideoMenuTriggerButton.js | 180 ++++++++-- .../web/RemoteVideoMenuTriggerButton.js | 123 ++++++- .../video-menu/components/web/index.js | 1 + 15 files changed, 817 insertions(+), 241 deletions(-) create mode 100644 react/features/connection-indicator/components/web/ConnectionIndicatorContent.js create mode 100644 react/features/video-menu/components/web/ConnectionStatusButton.js diff --git a/css/_connection-info.scss b/css/_connection-info.scss index 4f054cd6b..95aa1e2fc 100644 --- a/css/_connection-info.scss +++ b/css/_connection-info.scss @@ -45,6 +45,10 @@ @extend .connection-info__icon; } + &__mobile { + margin: 15px; + } + .connection-actions { margin: 10px auto; text-align: center; diff --git a/react/features/base/connection/actionTypes.js b/react/features/base/connection/actionTypes.js index ab61df68c..bb5dfb33d 100644 --- a/react/features/base/connection/actionTypes.js +++ b/react/features/base/connection/actionTypes.js @@ -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'; diff --git a/react/features/base/connection/reducer.js b/react/features/base/connection/reducer.js index d010fc76f..0fef894b8 100644 --- a/react/features/base/connection/reducer.js +++ b/react/features/base/connection/reducer.js @@ -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); +} diff --git a/react/features/base/popover/components/Popover.web.js b/react/features/base/popover/components/Popover.web.js index 7eeac35c2..cb517d1aa 100644 --- a/react/features/base/popover/components/Popover.web.js +++ b/react/features/base/popover/components/Popover.web.js @@ -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 { 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 { * @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 { */ _onHideDialog() { this.setState({ showDialog: false }); + + if (this.props.onPopoverClose) { + this.props.onPopoverClose(); + } } _onShowDialog: (Object) => void; diff --git a/react/features/connection-indicator/components/web/ConnectionIndicator.js b/react/features/connection-indicator/components/web/ConnectionIndicator.js index 513cfe5de..0a1a8e24e 100644 --- a/react/features/connection-indicator/components/web/ConnectionIndicator.js +++ b/react/features/connection-indicator/components/web/ConnectionIndicator.js @@ -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, - /** - * 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 { +class ConnectionIndicator extends AbstractConnectionIndicator { /** * Initializes a new {@code ConnectionIndicator} instance. * @@ -164,12 +128,8 @@ class ConnectionIndicator extends AbstractConnectionIndicator { 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 { return ( } disablePopover = { !this.props.enableStatsDisplay } position = { this.props.statsPopoverPosition }>
@@ -228,43 +188,6 @@ class ConnectionIndicator extends AbstractConnectionIndicator { 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 { ? '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 { ]; } - - /** - * 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 ( - - ); - } } - -/** - * 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) { - 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) { */ 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)); diff --git a/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js b/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js new file mode 100644 index 000000000..412ff08bb --- /dev/null +++ b/react/features/connection-indicator/components/web/ConnectionIndicatorContent.js @@ -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 = [ + + // 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, + + /** + * 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 { + /** + * 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 ( + + ); + } + + /** + * 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) { + 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)); diff --git a/react/features/connection-stats/components/ConnectionStatsTable.js b/react/features/connection-stats/components/ConnectionStatsTable.js index 267d784c8..35d96ce94 100644 --- a/react/features/connection-stats/components/ConnectionStatsTable.js +++ b/react/features/connection-stats/components/ConnectionStatsTable.js @@ -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 { */ render() { const { isLocalVideo, enableSaveLogs, disableShowMoreStats } = this.props; + const className = isMobileBrowser() ? 'connection-info connection-info__mobile' : 'connection-info'; return (
{ this._renderStatistics() }
diff --git a/react/features/filmstrip/components/web/Thumbnail.js b/react/features/filmstrip/components/web/Thumbnail.js index 9630a47b7..bdf2b3958 100644 --- a/react/features/filmstrip/components/web/Thumbnail.js +++ b/react/features/filmstrip/components/web/Thumbnail.js @@ -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 { + /** + * 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 { ...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 { 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 { 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 { onClick = { this._onClick } onMouseEnter = { this._onMouseEnter } onMouseLeave = { this._onMouseLeave } + { ...(isMobileBrowser() ? { + onTouchEnd: this._onTouchEnd, + onTouchMove: this._onTouchMove, + onTouchStart: this._onTouchStart + } : {}) } style = { styles.thumbnail }>
@@ -738,8 +810,10 @@ class Thumbnail extends Component { - + + ); } @@ -783,6 +857,19 @@ class Thumbnail extends Component { 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 { onClick = { this._onClick } onMouseEnter = { this._onMouseEnter } onMouseLeave = { this._onMouseLeave } + { ...(isMobileBrowser() ? { + onTouchEnd: this._onTouchEnd, + onTouchMove: this._onTouchMove, + onTouchStart: this._onTouchStart + } : {}) } style = { styles.thumbnail }> { _videoTrack && { @@ -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), diff --git a/react/features/filmstrip/constants.js b/react/features/filmstrip/constants.js index 4cab13208..4f3300621 100644 --- a/react/features/filmstrip/constants.js +++ b/react/features/filmstrip/constants.js @@ -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; diff --git a/react/features/toolbox/components/web/Drawer.js b/react/features/toolbox/components/web/Drawer.js index bd0660a82..2b9f12dc3 100644 --- a/react/features/toolbox/components/web/Drawer.js +++ b/react/features/toolbox/components/web/Drawer.js @@ -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 ]); diff --git a/react/features/video-menu/actions.web.js b/react/features/video-menu/actions.web.js index 5800cb128..caebe8626 100644 --- a/react/features/video-menu/actions.web.js +++ b/react/features/video-menu/actions.web.js @@ -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 + }; +} diff --git a/react/features/video-menu/components/web/ConnectionStatusButton.js b/react/features/video-menu/components/web/ConnectionStatusButton.js new file mode 100644 index 000000000..a60bcb00f --- /dev/null +++ b/react/features/video-menu/components/web/ConnectionStatusButton.js @@ -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 ( + + ); +}; + +export default translate(connect()(ConnectionStatusButton)); diff --git a/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.js b/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.js index 223e84941..e2f3bf4ba 100644 --- a/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.js +++ b/react/features/video-menu/components/web/LocalVideoMenuTriggerButton.js @@ -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 - ? - - - } - overflowDrawer = { props._overflowDrawer } - position = { props._menuPosition }> - - - - - : null - ); +class LocalVideoMenuTriggerButton extends Component { + /** + * 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 + ? + : ( + + + { isMobileBrowser() + && + } + + ); + + return ( + isMobileBrowser() || _showLocalVideoFlipButton + ? + {!isMobileBrowser() && ( + + + + )} + + : 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 }; } diff --git a/react/features/video-menu/components/web/RemoteVideoMenuTriggerButton.js b/react/features/video-menu/components/web/RemoteVideoMenuTriggerButton.js index df04de331..28552eb78 100644 --- a/react/features/video-menu/components/web/RemoteVideoMenuTriggerButton.js +++ b/react/features/video-menu/components/web/RemoteVideoMenuTriggerButton.js @@ -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 { + /** + * 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 { * @returns {ReactElement} */ render() { - const content = this._renderRemoteVideoMenu(); + const { _showConnectionInfo, _participantDisplayName, participantID } = this.props; + const content = _showConnectionInfo + ? + : this._renderRemoteVideoMenu(); if (!content) { return null; } - const username = this.props._participantDisplayName; + const username = _participantDisplayName; return ( - - - + position = { this.props._menuPosition } + ref = { this.popoverRef }> + {!isMobileBrowser() && ( + + + + )} ); } + _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 { participantID = { participantID } /> ); + if (isMobileBrowser()) { + buttons.push( + + ); + } 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 }; } diff --git a/react/features/video-menu/components/web/index.js b/react/features/video-menu/components/web/index.js index cbf949b4d..48e6eb4e1 100644 --- a/react/features/video-menu/components/web/index.js +++ b/react/features/video-menu/components/web/index.js @@ -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';