diff --git a/app/components/safe_area_view/safe_area_view.ios.js b/app/components/safe_area_view/safe_area_view.ios.js index 91a38d238..7270415e5 100644 --- a/app/components/safe_area_view/safe_area_view.ios.js +++ b/app/components/safe_area_view/safe_area_view.ios.js @@ -3,15 +3,17 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Keyboard, NativeModules, View} from 'react-native'; +import {Dimensions, Keyboard, NativeModules, View} from 'react-native'; import SafeArea from 'react-native-safe-area'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; -import {DeviceTypes} from 'app/constants'; +import {DeviceTypes, ViewTypes} from 'app/constants'; import mattermostManaged from 'app/mattermost_managed'; +import EphemeralStore from 'app/store/ephemeral_store'; const {StatusBarManager} = NativeModules; +const {PORTRAIT, LANDSCAPE} = ViewTypes; export default class SafeAreaIos extends PureComponent { static propTypes = { @@ -57,8 +59,12 @@ export default class SafeAreaIos extends PureComponent { componentDidMount() { this.mounted = true; - SafeArea.addEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); + Dimensions.addEventListener('change', this.getSafeAreaInsets); EventEmitter.on('update_safe_area_view', this.getSafeAreaInsets); + if (EphemeralStore.safeAreaInsets[PORTRAIT] === null || EphemeralStore.safeAreaInsets[LANDSCAPE] === null) { + SafeArea.addEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); + } + this.keyboardDidShowListener = Keyboard.addListener('keyboardWillShow', this.keyboardWillShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardWillHide', this.keyboardWillHide); @@ -66,13 +72,39 @@ export default class SafeAreaIos extends PureComponent { } componentWillUnmount() { - SafeArea.removeEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); + Dimensions.removeEventListener('change', this.getSafeAreaInsets); EventEmitter.off('update_safe_area_view', this.getSafeAreaInsets); + SafeArea.removeEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); this.keyboardDidShowListener.remove(); this.keyboardDidHideListener.remove(); this.mounted = false; } + getSafeAreaInsets = async (dimensions) => { + this.getStatusBarHeight(); + + const safeAreaInsetsStored = EphemeralStore.safeAreaInsets[PORTRAIT] !== null && EphemeralStore.safeAreaInsets[LANDSCAPE] !== null; + if ((DeviceTypes.IS_IPHONE_WITH_INSETS || mattermostManaged.hasSafeAreaInsets) && !safeAreaInsetsStored) { + const window = dimensions?.window || Dimensions.get('window'); + const landscape = window.width > window.length; + const {safeAreaInsets} = await SafeArea.getSafeAreaInsetsForRootView(); + this.setSafeAreaInsets(safeAreaInsets, landscape); + } + } + + setSafeAreaInsets = (safeAreaInsets, landscape) => { + const orientation = landscape ? LANDSCAPE : PORTRAIT; + if (EphemeralStore.safeAreaInsets[orientation] === null) { + EphemeralStore.safeAreaInsets[orientation] = safeAreaInsets; + } + + if (this.mounted) { + this.setState({ + safeAreaInsets: EphemeralStore.safeAreaInsets[orientation], + }); + } + } + getStatusBarHeight = () => { try { StatusBarManager.getHeight( @@ -87,26 +119,18 @@ export default class SafeAreaIos extends PureComponent { } }; - getSafeAreaInsets = () => { - this.getStatusBarHeight(); + onSafeAreaInsetsForRootViewChange = ({safeAreaInsets}) => { + if (EphemeralStore.safeAreaInsets[PORTRAIT] !== null && EphemeralStore.safeAreaInsets[LANDSCAPE] !== null) { + SafeArea.removeEventListener('safeAreaInsetsForRootViewDidChange', this.onSafeAreaInsetsForRootViewChange); + return; + } if (DeviceTypes.IS_IPHONE_WITH_INSETS || mattermostManaged.hasSafeAreaInsets) { - SafeArea.getSafeAreaInsetsForRootView().then((result) => { - const {safeAreaInsets} = result; - - if (this.mounted) { - this.setState({safeAreaInsets}); - } - }); - } - }; - - onSafeAreaInsetsForRootViewChange = (result) => { - const {safeAreaInsets} = result; - - if (this.mounted && (DeviceTypes.IS_IPHONE_WITH_INSETS || mattermostManaged.hasSafeAreaInsets)) { this.getStatusBarHeight(); - this.setState({safeAreaInsets}); + + const {width, height} = Dimensions.get('window'); + const landscape = width > height; + this.setSafeAreaInsets(safeAreaInsets, landscape); } } diff --git a/app/components/safe_area_view/safe_area_view.ios.test.js b/app/components/safe_area_view/safe_area_view.ios.test.js index 85c64b572..45861f552 100644 --- a/app/components/safe_area_view/safe_area_view.ios.test.js +++ b/app/components/safe_area_view/safe_area_view.ios.test.js @@ -6,11 +6,14 @@ import {shallow} from 'enzyme'; import Preferences from 'mattermost-redux/constants/preferences'; -import {DeviceTypes} from 'app/constants'; +import {DeviceTypes, ViewTypes} from 'app/constants'; import mattermostManaged from 'app/mattermost_managed'; +import EphemeralStore from 'app/store/ephemeral_store'; import SafeAreaIos from './safe_area_view.ios'; +const {PORTRAIT, LANDSCAPE} = ViewTypes; + describe('SafeAreaIos', () => { const baseProps = { children: [], @@ -37,10 +40,44 @@ describe('SafeAreaIos', () => { }, }; + const PORTRAIT_INSETS = { + safeAreaInsets: { + top: 111, + left: 111, + bottom: 111, + right: 111, + }, + }; + + const LANDSCAPE_INSETS = { + safeAreaInsets: { + top: 222, + left: 222, + bottom: 222, + right: 222, + }, + }; + + const IGNORED_INSETS = { + safeAreaInsets: { + top: 333, + left: 333, + bottom: 333, + right: 333, + }, + }; + SafeArea.getSafeAreaInsetsForRootView = jest.fn().mockImplementation(() => { return Promise.resolve(TEST_INSETS_1); }); + beforeEach(() => { + EphemeralStore.safeAreaInsets = { + [PORTRAIT]: null, + [LANDSCAPE]: null, + }; + }); + test('should match snapshot', () => { const wrapper = shallow( @@ -148,4 +185,144 @@ describe('SafeAreaIos', () => { instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_2); expect(wrapper.state().safeAreaInsets).not.toEqual(TEST_INSETS_2.safeAreaInsets); }); + + test('should set portrait safe area insets', () => { + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(PORTRAIT_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + + const landscape = false; + const instance = wrapper.instance(); + instance.setSafeAreaInsets(PORTRAIT_INSETS.safeAreaInsets, landscape); + expect(wrapper.state().safeAreaInsets).toEqual(PORTRAIT_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(PORTRAIT_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + }); + + test('should set portrait safe area insets from EphemeralStore', () => { + const wrapper = shallow( + + ); + + EphemeralStore.safeAreaInsets[PORTRAIT] = PORTRAIT_INSETS.safeAreaInsets; + expect(wrapper.state().safeAreaInsets).not.toEqual(PORTRAIT_INSETS.safeAreaInsets); + + const landscape = false; + const instance = wrapper.instance(); + instance.setSafeAreaInsets(IGNORED_INSETS.safeAreaInsets, landscape); + expect(wrapper.state().safeAreaInsets).toEqual(PORTRAIT_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(PORTRAIT_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + }); + + test('should set landscape safe area insets', () => { + const wrapper = shallow( + + ); + + expect(wrapper.state().safeAreaInsets).not.toEqual(LANDSCAPE_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + + const landscape = true; + const instance = wrapper.instance(); + instance.setSafeAreaInsets(LANDSCAPE_INSETS.safeAreaInsets, landscape); + expect(wrapper.state().safeAreaInsets).toEqual(LANDSCAPE_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(LANDSCAPE_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + }); + + test('should set landscape safe area insets from EphemeralStore', () => { + const wrapper = shallow( + + ); + + EphemeralStore.safeAreaInsets[LANDSCAPE] = LANDSCAPE_INSETS.safeAreaInsets; + expect(wrapper.state().safeAreaInsets).not.toEqual(LANDSCAPE_INSETS.safeAreaInsets); + + const landscape = true; + const instance = wrapper.instance(); + instance.setSafeAreaInsets(IGNORED_INSETS.safeAreaInsets, landscape); + expect(wrapper.state().safeAreaInsets).toEqual(LANDSCAPE_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(LANDSCAPE_INSETS.safeAreaInsets); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + }); + + test('should add safeAreaInsetsForRootViewDidChange listener when EphemeralStore values are not set', () => { + const addEventListener = jest.spyOn(SafeArea, 'addEventListener'); + + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + let wrapper = shallow( + + ); + let instance = wrapper.instance(); + expect(addEventListener).toHaveBeenCalledWith('safeAreaInsetsForRootViewDidChange', instance.onSafeAreaInsetsForRootViewChange); + addEventListener.mockClear(); + + EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets; + wrapper = shallow( + + ); + instance = wrapper.instance(); + expect(addEventListener).toHaveBeenCalledWith('safeAreaInsetsForRootViewDidChange', instance.onSafeAreaInsetsForRootViewChange); + addEventListener.mockClear(); + + EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets; + EphemeralStore.safeAreaInsets[LANDSCAPE] = TEST_INSETS_1.safeAreaInsets; + wrapper = shallow( + + ); + instance = wrapper.instance(); + expect(addEventListener).not.toHaveBeenCalled(); + }); + + test('should remove safeAreaInsetsForRootViewDidChange listener when EphemeralStore values are set', () => { + const removeEventListener = jest.spyOn(SafeArea, 'removeEventListener'); + + const wrapper = shallow( + + ); + const instance = wrapper.instance(); + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_1); + expect(removeEventListener).not.toHaveBeenCalled(); + + EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets; + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_1); + expect(removeEventListener).not.toHaveBeenCalled(); + + EphemeralStore.safeAreaInsets[LANDSCAPE] = TEST_INSETS_1.safeAreaInsets; + instance.onSafeAreaInsetsForRootViewChange(TEST_INSETS_1); + expect(removeEventListener).toHaveBeenCalledWith('safeAreaInsetsForRootViewDidChange', instance.onSafeAreaInsetsForRootViewChange); + }); + + test('getSafeAreaInsets should set safe area insets when not already in ephemeral store', async () => { + const wrapper = shallow( + + ); + const instance = wrapper.instance(); + const setSafeAreaInsets = jest.spyOn(instance, 'setSafeAreaInsets'); + + expect(EphemeralStore.safeAreaInsets[PORTRAIT]).toEqual(null); + expect(EphemeralStore.safeAreaInsets[LANDSCAPE]).toEqual(null); + await instance.getSafeAreaInsets(); + expect(setSafeAreaInsets).toHaveBeenCalled(); + setSafeAreaInsets.mockClear(); + + EphemeralStore.safeAreaInsets[PORTRAIT] = TEST_INSETS_1.safeAreaInsets; + await instance.getSafeAreaInsets(); + expect(setSafeAreaInsets).toHaveBeenCalled(); + setSafeAreaInsets.mockClear(); + + EphemeralStore.safeAreaInsets[LANDSCAPE] = TEST_INSETS_1.safeAreaInsets; + await instance.getSafeAreaInsets(); + expect(setSafeAreaInsets).not.toHaveBeenCalled(); + }); }); diff --git a/app/constants/view.js b/app/constants/view.js index e661dfe01..06e92db5e 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -95,6 +95,9 @@ const ViewTypes = keyMirror({ SELECTED_ACTION_MENU: null, SUBMIT_ATTACHMENT_MENU_ACTION: null, SELECT_CHANNEL_WITH_MEMBER: null, + + PORTRAIT: null, + LANDSCAPE: null, }); export default { diff --git a/app/store/ephemeral_store.js b/app/store/ephemeral_store.js index 574fb029f..3639ecdda 100644 --- a/app/store/ephemeral_store.js +++ b/app/store/ephemeral_store.js @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {ViewTypes} from 'app/constants'; + class EphemeralStore { constructor() { this.appStarted = false; @@ -9,6 +11,10 @@ class EphemeralStore { this.navigationComponentIdStack = []; this.allNavigationComponentIds = []; this.currentServerUrl = null; + this.safeAreaInsets = { + [ViewTypes.PORTRAIT]: null, + [ViewTypes.LANDSCAPE]: null, + }; } getNavigationTopComponentId = () => this.navigationComponentIdStack[0];