diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index 97d92d818..df58b59c2 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -16,6 +16,7 @@ import { import { updateSettings } from '../../base/settings'; import '../../google-api'; import '../../mobile/audio-mode'; +import '../../mobile/back-button'; import '../../mobile/background'; import '../../mobile/call-integration'; import '../../mobile/external-api'; diff --git a/react/features/base/react/components/native/SlidingView.js b/react/features/base/react/components/native/SlidingView.js index e8f5ebd89..62e18dac9 100644 --- a/react/features/base/react/components/native/SlidingView.js +++ b/react/features/base/react/components/native/SlidingView.js @@ -8,6 +8,8 @@ import { View } from 'react-native'; +import { BackButtonRegistry } from '../../../../mobile/back-button'; + import { type StyleType } from '../../../styles'; import styles from './slidingviewstyles'; @@ -110,6 +112,7 @@ export default class SlidingView extends PureComponent { }; // Bind event handlers so they are only bound once per instance. + this._onHardwareBackPress = this._onHardwareBackPress.bind(this); this._onHide = this._onHide.bind(this); } @@ -119,6 +122,8 @@ export default class SlidingView extends PureComponent { * @inheritdoc */ componentDidMount() { + BackButtonRegistry.addListener(this._onHardwareBackPress, true); + this._mounted = true; this._setShow(this.props.show); } @@ -142,6 +147,8 @@ export default class SlidingView extends PureComponent { * @inheritdoc */ componentWillUnmount() { + BackButtonRegistry.removeListener(this._onHardwareBackPress); + this._mounted = false; } @@ -215,6 +222,23 @@ export default class SlidingView extends PureComponent { return style; } + _onHardwareBackPress: () => boolean; + + /** + * Callback to handle the hardware back button. + * + * @returns {boolean} + */ + _onHardwareBackPress() { + const { onHide } = this.props; + + if (typeof onHide === 'function') { + return onHide(); + } + + return false; + } + _onHide: () => void; /** diff --git a/react/features/chat/components/native/Chat.js b/react/features/chat/components/native/Chat.js index 8109c6c9e..79b7c6846 100644 --- a/react/features/chat/components/native/Chat.js +++ b/react/features/chat/components/native/Chat.js @@ -23,6 +23,17 @@ import styles from './styles'; * the mobile client. */ class Chat extends AbstractChat { + /** + * Instantiates a new instance. + * + * @inheritdoc + */ + constructor(props: Props) { + super(props); + + this._onClose = this._onClose.bind(this); + } + /** * Implements React's {@link Component#render()}. * @@ -31,6 +42,7 @@ class Chat extends AbstractChat { render() { return ( { style = { styles.chatContainer }> + onPressBack = { this._onClose } /> @@ -47,6 +59,23 @@ class Chat extends AbstractChat { ); } + + _onClose: () => boolean + + /** + * Closes the chat window. + * + * @returns {boolean} + */ + _onClose() { + if (this.props._isOpen) { + this.props._onToggleChat(); + + return true; + } + + return false; + } } export default translate(connect(_mapStateToProps, _mapDispatchToProps)(Chat)); diff --git a/react/features/conference/components/native/Conference.js b/react/features/conference/components/native/Conference.js index 7bda3dcba..08cef9e56 100644 --- a/react/features/conference/components/native/Conference.js +++ b/react/features/conference/components/native/Conference.js @@ -1,7 +1,7 @@ // @flow import React from 'react'; -import { BackHandler, NativeModules, SafeAreaView, StatusBar, View } from 'react-native'; +import { NativeModules, SafeAreaView, StatusBar, View } from 'react-native'; import { appNavigate } from '../../../app'; import { PIP_ENABLED, getFeatureFlag } from '../../../base/flags'; @@ -23,6 +23,7 @@ import { TileView } from '../../../filmstrip'; import { LargeVideo } from '../../../large-video'; +import { BackButtonRegistry } from '../../../mobile/back-button'; import { AddPeopleDialog, CalleeInfoContainer } from '../../../invite'; import { Captions } from '../../../subtitles'; import { setToolboxVisible, Toolbox } from '../../../toolbox'; @@ -144,7 +145,7 @@ class Conference extends AbstractConference { * @returns {void} */ componentDidMount() { - BackHandler.addEventListener('hardwareBackPress', this._onHardwareBackPress); + BackButtonRegistry.addListener(this._onHardwareBackPress); // Show the toolbox if we are the only participant; otherwise, the whole // UI looks too unpopulated the LargeVideo visible. @@ -186,7 +187,7 @@ class Conference extends AbstractConference { */ componentWillUnmount() { // Tear handling any hardware button presses for back navigation down. - BackHandler.removeEventListener('hardwareBackPress', this._onHardwareBackPress); + BackButtonRegistry.removeListener(this._onHardwareBackPress); } /** diff --git a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js index d73a27c59..87f2acc20 100644 --- a/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js +++ b/react/features/invite/components/add-people-dialog/native/AddPeopleDialog.js @@ -150,6 +150,7 @@ class AddPeopleDialog extends AbstractAddPeopleDialog { return ( { this._onTypeQuery(''); } - _onCloseAddPeopleDialog: () => void + _onCloseAddPeopleDialog: () => boolean /** * Closes the dialog. * - * @returns {void} + * @returns {boolean} */ _onCloseAddPeopleDialog() { - this.props.dispatch(setAddPeopleDialogVisible(false)); + if (this.props._isVisible) { + this.props.dispatch(setAddPeopleDialogVisible(false)); + + return true; + } + + return false; } _onInvite: () => void diff --git a/react/features/invite/components/dial-in-summary/native/DialInSummary.js b/react/features/invite/components/dial-in-summary/native/DialInSummary.js index 0eb0d7707..e9f4cdb85 100644 --- a/react/features/invite/components/dial-in-summary/native/DialInSummary.js +++ b/react/features/invite/components/dial-in-summary/native/DialInSummary.js @@ -59,6 +59,7 @@ class DialInSummary extends Component { return ( @@ -77,15 +78,21 @@ class DialInSummary extends Component { ); } - _onCloseView: () => void; + _onCloseView: () => boolean; /** * Closes the view. * - * @returns {void} + * @returns {boolean} */ _onCloseView() { - this.props.dispatch(hideDialInSummary()); + if (this.props._summaryUrl) { + this.props.dispatch(hideDialInSummary()); + + return true; + } + + return false; } _onError: () => void; diff --git a/react/features/mobile/back-button/BackButtonRegistry.js b/react/features/mobile/back-button/BackButtonRegistry.js new file mode 100644 index 000000000..9b526751d --- /dev/null +++ b/react/features/mobile/back-button/BackButtonRegistry.js @@ -0,0 +1,66 @@ +// @flow + +/** + * An registry that dispatches hardware back button events for subscribers with a custom logic. + */ +class BackButtonRegistry { + _listeners: Array; + + /** + * Instantiates a new instance of the registry. + */ + constructor() { + this._listeners = []; + } + + /** + * Adds a listener to the registry. + * + * NOTE: Due to the different order of component mounts, we allow a component to register + * its listener to the top of the list, so then that will be invoked before other, 'non-top' + * listeners. For example a 'non-top' listener can be the one that puts the app into PiP mode, + * while a 'top' listener is the one that closes a modal in a conference. + * + * @param {Function} listener - The listener function. + * @param {boolean?} top - If true, the listener will be put on the top (eg for modal-like components). + * @returns {void} + */ + addListener(listener: Function, top: boolean = false) { + if (top) { + this._listeners.splice(0, 0, listener); + } else { + this._listeners.push(listener); + } + } + + /** + * Removes a listener from the registry. + * + * @param {Function} listener - The listener to remove. + * @returns {void} + */ + removeListener(listener: Function) { + this._listeners = this._listeners.filter(f => f !== listener); + } + + onHardwareBackPress: () => boolean + + /** + * Callback for the back button press event. + * + * @returns {boolean} + */ + onHardwareBackPress() { + for (const listener of this._listeners) { + const result = listener(); + + if (result === true) { + return true; + } + } + + return false; + } +} + +export default new BackButtonRegistry(); diff --git a/react/features/mobile/back-button/index.js b/react/features/mobile/back-button/index.js new file mode 100644 index 000000000..9e4c5488e --- /dev/null +++ b/react/features/mobile/back-button/index.js @@ -0,0 +1,5 @@ +// @flow + +export { default as BackButtonRegistry } from './BackButtonRegistry'; + +import './middleware'; diff --git a/react/features/mobile/back-button/middleware.js b/react/features/mobile/back-button/middleware.js new file mode 100644 index 000000000..07f4b96f7 --- /dev/null +++ b/react/features/mobile/back-button/middleware.js @@ -0,0 +1,36 @@ +// @flow + +import { BackHandler } from 'react-native'; + +import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app'; +import { MiddlewareRegistry } from '../../base/redux'; + +import BackButtonRegistry from './BackButtonRegistry'; + +// Binding function to the proper instance, so then the event emitter won't replace the +// underlying instance. +BackButtonRegistry.onHardwareBackPress = BackButtonRegistry.onHardwareBackPress.bind(BackButtonRegistry); + +/** + * Middleware that captures App lifetime actions and subscribes to application + * state changes. When the application state changes it will fire the action + * required to mute or unmute the local video in case the application goes to + * the background or comes back from it. + * + * @param {Store} store - The redux store. + * @returns {Function} + * @see {@link https://facebook.github.io/react-native/docs/appstate.html} + */ +MiddlewareRegistry.register(() => next => action => { + switch (action.type) { + case APP_WILL_MOUNT: + BackHandler.addEventListener('hardwareBackPress', BackButtonRegistry.onHardwareBackPress); + break; + + case APP_WILL_UNMOUNT: + BackHandler.removeEventListener('hardwareBackPress', BackButtonRegistry.onHardwareBackPress); + break; + } + + return next(action); +}); diff --git a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js index e4c1dafd3..3df3c103c 100644 --- a/react/features/remote-video-menu/components/native/RemoteVideoMenu.js +++ b/react/features/remote-video-menu/components/native/RemoteVideoMenu.js @@ -5,9 +5,7 @@ import { Text, View } from 'react-native'; import { Avatar } from '../../../base/avatar'; import { ColorSchemeRegistry } from '../../../base/color-scheme'; -import { - BottomSheet -} from '../../../base/dialog'; +import { BottomSheet, isDialogOpen } from '../../../base/dialog'; import { getParticipantDisplayName } from '../../../base/participants'; import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; @@ -41,12 +39,20 @@ type Props = { */ _bottomSheetStyles: StyleType, + /** + * True if the menu is currently open, false otherwise. + */ + _isOpen: boolean, + /** * Display name of the participant retreived from Redux. */ _participantDisplayName: string } +// eslint-disable-next-line prefer-const +let RemoteVideoMenu_; + /** * Class to implement a popup menu that opens upon long pressing a thumbnail. */ @@ -93,16 +99,22 @@ class RemoteVideoMenu extends Component { ); } - _onCancel: () => void; + _onCancel: () => boolean; /** * Callback to hide the {@code RemoteVideoMenu}. * * @private - * @returns {void} + * @returns {boolean} */ _onCancel() { - this.props.dispatch(hideRemoteVideoMenu()); + if (this.props._isOpen) { + this.props.dispatch(hideRemoteVideoMenu()); + + return true; + } + + return false; } } @@ -112,10 +124,7 @@ class RemoteVideoMenu extends Component { * @param {Object} state - Redux state. * @param {Object} ownProps - Properties of component. * @private - * @returns {{ - * _bottomSheetStyles: StyleType, - * _participantDisplayName: string - * }} + * @returns {Props} */ function _mapStateToProps(state, ownProps) { const { participant } = ownProps; @@ -123,9 +132,12 @@ function _mapStateToProps(state, ownProps) { return { _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'), + _isOpen: isDialogOpen(state, RemoteVideoMenu_), _participantDisplayName: getParticipantDisplayName( state, participant.id) }; } -export default connect(_mapStateToProps)(RemoteVideoMenu); +RemoteVideoMenu_ = connect(_mapStateToProps)(RemoteVideoMenu); + +export default RemoteVideoMenu_; diff --git a/react/features/toolbox/components/native/OverflowMenu.js b/react/features/toolbox/components/native/OverflowMenu.js index 2043390df..1ededee0b 100644 --- a/react/features/toolbox/components/native/OverflowMenu.js +++ b/react/features/toolbox/components/native/OverflowMenu.js @@ -4,7 +4,7 @@ import React, { Component } from 'react'; import { Platform } from 'react-native'; import { ColorSchemeRegistry } from '../../../base/color-scheme'; -import { BottomSheet, hideDialog } from '../../../base/dialog'; +import { BottomSheet, hideDialog, isDialogOpen } from '../../../base/dialog'; import { CHAT_ENABLED, IOS_RECORDING_ENABLED, getFeatureFlag } from '../../../base/flags'; import { connect } from '../../../base/redux'; import { StyleType } from '../../../base/styles'; @@ -34,6 +34,11 @@ type Props = { */ _chatEnabled: boolean, + /** + * True if the overflow menu is currently visible, false otherwise. + */ + _isOpen: boolean, + /** * Whether the recoding button should be enabled or not. */ @@ -107,16 +112,22 @@ class OverflowMenu extends Component { ); } - _onCancel: () => void; + _onCancel: () => boolean; /** * Hides this {@code OverflowMenu}. * * @private - * @returns {void} + * @returns {boolean} */ _onCancel() { - this.props.dispatch(hideDialog(OverflowMenu_)); + if (this.props._isOpen) { + this.props.dispatch(hideDialog(OverflowMenu_)); + + return true; + } + + return false; } } @@ -125,17 +136,14 @@ class OverflowMenu extends Component { * * @param {Object} state - Redux state. * @private - * @returns {{ - * _bottomSheetStyles: StyleType, - * _chatEnabled: boolean, - * _recordingEnabled: boolean - * }} + * @returns {Props} */ function _mapStateToProps(state) { return { _bottomSheetStyles: ColorSchemeRegistry.get(state, 'BottomSheet'), _chatEnabled: getFeatureFlag(state, CHAT_ENABLED, true), + _isOpen: isDialogOpen(state, OverflowMenu_), _recordingEnabled: Platform.OS !== 'ios' || getFeatureFlag(state, IOS_RECORDING_ENABLED) }; }