From 0fb6e5169deaaea63adf8fba503df40b34333f7f Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Thu, 14 Sep 2023 17:02:31 -0400 Subject: [PATCH] Move logic to track mobile view into its own component (#24272) * Move logic to track mobile view into its own component * Address feedback --- .../components/mobile_view_watcher/index.ts | 17 ++++ .../mobile_view_watcher.test.tsx | 68 ++++++++++++++ .../mobile_view_watcher.tsx | 55 ++++++++++++ .../root/__snapshots__/root.test.tsx.snap | 1 + webapp/channels/src/components/root/index.ts | 2 - .../src/components/root/root.test.tsx | 88 +------------------ webapp/channels/src/components/root/root.tsx | 75 +--------------- .../WindowSizeObserver.tsx | 12 +-- 8 files changed, 152 insertions(+), 166 deletions(-) create mode 100644 webapp/channels/src/components/mobile_view_watcher/index.ts create mode 100644 webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.test.tsx create mode 100644 webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.tsx diff --git a/webapp/channels/src/components/mobile_view_watcher/index.ts b/webapp/channels/src/components/mobile_view_watcher/index.ts new file mode 100644 index 0000000000..aea9e79ce2 --- /dev/null +++ b/webapp/channels/src/components/mobile_view_watcher/index.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import type {ConnectedProps} from 'react-redux'; + +import {emitBrowserWindowResized} from 'actions/views/browser'; + +import MobileViewWatcher from './mobile_view_watcher'; + +const mapDispatchToProps = { + emitBrowserWindowResized, +}; + +const connector = connect(null, mapDispatchToProps); +export type PropsFromRedux = ConnectedProps; +export default connector(MobileViewWatcher); diff --git a/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.test.tsx b/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.test.tsx new file mode 100644 index 0000000000..94c6c8195e --- /dev/null +++ b/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.test.tsx @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {render} from '@testing-library/react'; +import React from 'react'; + +import matchMedia from 'tests/helpers/match_media.mock'; +import Constants, {WindowSizes} from 'utils/constants'; + +import MobileViewWatcher from './mobile_view_watcher'; + +describe('window.matchMedia', () => { + const baseProps = { + emitBrowserWindowResized: jest.fn(), + }; + + afterEach(() => { + matchMedia.clear(); + }); + + test('should update redux when the desktop media query matches', () => { + render(); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(0); + + matchMedia.useMediaQuery(`(min-width: ${Constants.DESKTOP_SCREEN_WIDTH + 1}px)`); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(1); + + expect(baseProps.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.DESKTOP_VIEW); + }); + + test('should update redux when the small desktop media query matches', () => { + render(); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(0); + + matchMedia.useMediaQuery(`(min-width: ${Constants.TABLET_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.DESKTOP_SCREEN_WIDTH}px)`); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(1); + + expect(baseProps.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.SMALL_DESKTOP_VIEW); + }); + + test('should update redux when the tablet media query matches', () => { + render(); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(0); + + matchMedia.useMediaQuery(`(min-width: ${Constants.MOBILE_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.TABLET_SCREEN_WIDTH}px)`); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(1); + + expect(baseProps.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.TABLET_VIEW); + }); + + test('should update redux when the mobile media query matches', () => { + render(); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(0); + + matchMedia.useMediaQuery(`(max-width: ${Constants.MOBILE_SCREEN_WIDTH}px)`); + + expect(baseProps.emitBrowserWindowResized).toBeCalledTimes(1); + + expect(baseProps.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.MOBILE_VIEW); + }); +}); diff --git a/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.tsx b/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.tsx new file mode 100644 index 0000000000..970a5eb7a1 --- /dev/null +++ b/webapp/channels/src/components/mobile_view_watcher/mobile_view_watcher.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; + +import Constants, {WindowSizes} from 'utils/constants'; + +import type {PropsFromRedux} from './index'; + +type Props = PropsFromRedux; + +export default function MobileViewWatcher(props: Props) { + const desktopMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.DESKTOP_SCREEN_WIDTH + 1}px)`)); + const smallDesktopMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.TABLET_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.DESKTOP_SCREEN_WIDTH}px)`)); + const tabletMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.MOBILE_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.TABLET_SCREEN_WIDTH}px)`)); + const mobileMediaQuery = useRef(window.matchMedia(`(max-width: ${Constants.MOBILE_SCREEN_WIDTH}px)`)); + + const updateWindowSize = useCallback(() => { + if (desktopMediaQuery.current.matches) { + props.emitBrowserWindowResized(WindowSizes.DESKTOP_VIEW); + } else if (smallDesktopMediaQuery.current.matches) { + props.emitBrowserWindowResized(WindowSizes.SMALL_DESKTOP_VIEW); + } else if (tabletMediaQuery.current.matches) { + props.emitBrowserWindowResized(WindowSizes.TABLET_VIEW); + } else if (mobileMediaQuery.current.matches) { + props.emitBrowserWindowResized(WindowSizes.MOBILE_VIEW); + } + }, []); + + useLayoutEffect(() => { + updateWindowSize(); + }, [updateWindowSize]); + + useEffect(() => { + const handleMediaQueryChangeEvent = (e: MediaQueryListEvent) => { + if (e.matches) { + updateWindowSize(); + } + }; + + desktopMediaQuery.current.addEventListener('change', handleMediaQueryChangeEvent); + smallDesktopMediaQuery.current.addEventListener('change', handleMediaQueryChangeEvent); + tabletMediaQuery.current.addEventListener('change', handleMediaQueryChangeEvent); + mobileMediaQuery.current.addEventListener('change', handleMediaQueryChangeEvent); + + return () => { + desktopMediaQuery.current.removeEventListener('change', handleMediaQueryChangeEvent); + smallDesktopMediaQuery.current.removeEventListener('change', handleMediaQueryChangeEvent); + tabletMediaQuery.current.removeEventListener('change', handleMediaQueryChangeEvent); + mobileMediaQuery.current.removeEventListener('change', handleMediaQueryChangeEvent); + }; + }, [updateWindowSize]); + + return null; +} diff --git a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap index 6119c1f261..63fca055e8 100644 --- a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap +++ b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap @@ -2,6 +2,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = ` + , Actions>({ loadConfigAndMe, - emitBrowserWindowResized, getFirstAdminSetupComplete, getProfiles, migrateRecentEmojis, diff --git a/webapp/channels/src/components/root/root.test.tsx b/webapp/channels/src/components/root/root.test.tsx index 6ade2e7284..103be24665 100644 --- a/webapp/channels/src/components/root/root.test.tsx +++ b/webapp/channels/src/components/root/root.test.tsx @@ -1,9 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// side-effect necessary before other imports -import matchMedia from 'tests/helpers/match_media.mock'; // eslint-disable-line import/order - import {shallow} from 'enzyme'; import React from 'react'; import type {RouteComponentProps} from 'react-router-dom'; @@ -20,7 +17,7 @@ import store from 'stores/redux_store.jsx'; import Root from 'components/root/root'; -import Constants, {StoragePrefixes, WindowSizes} from 'utils/constants'; +import {StoragePrefixes} from 'utils/constants'; import type {ProductComponent} from 'types/store/plugins'; @@ -67,7 +64,6 @@ describe('components/Root', () => { data: false, }); }), - emitBrowserWindowResized: () => {}, getFirstAdminSetupComplete: jest.fn(() => Promise.resolve({ type: GeneralTypes.FIRST_ADMIN_COMPLETE_SETUP_RECEIVED, data: true, @@ -270,88 +266,6 @@ describe('components/Root', () => { }); }); - describe('window.matchMedia', () => { - afterEach(() => { - matchMedia.clear(); - }); - - test('should update redux when the desktop media query matches', () => { - const props = { - ...baseProps, - actions: { - ...baseProps.actions, - emitBrowserWindowResized: jest.fn(), - }, - }; - const wrapper = shallow(); - - matchMedia.useMediaQuery(`(min-width: ${Constants.DESKTOP_SCREEN_WIDTH + 1}px)`); - - expect(props.actions.emitBrowserWindowResized).toBeCalledTimes(1); - - expect(props.actions.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.DESKTOP_VIEW); - - wrapper.unmount(); - }); - - test('should update redux when the small desktop media query matches', () => { - const props = { - ...baseProps, - actions: { - ...baseProps.actions, - emitBrowserWindowResized: jest.fn(), - }, - }; - const wrapper = shallow(); - - matchMedia.useMediaQuery(`(min-width: ${Constants.TABLET_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.DESKTOP_SCREEN_WIDTH}px)`); - - expect(props.actions.emitBrowserWindowResized).toBeCalledTimes(1); - - expect(props.actions.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.SMALL_DESKTOP_VIEW); - - wrapper.unmount(); - }); - - test('should update redux when the tablet media query matches', () => { - const props = { - ...baseProps, - actions: { - ...baseProps.actions, - emitBrowserWindowResized: jest.fn(), - }, - }; - const wrapper = shallow(); - - matchMedia.useMediaQuery(`(min-width: ${Constants.MOBILE_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.TABLET_SCREEN_WIDTH}px)`); - - expect(props.actions.emitBrowserWindowResized).toBeCalledTimes(1); - - expect(props.actions.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.TABLET_VIEW); - - wrapper.unmount(); - }); - - test('should update redux when the mobile media query matches', () => { - const props = { - ...baseProps, - actions: { - ...baseProps.actions, - emitBrowserWindowResized: jest.fn(), - }, - }; - const wrapper = shallow(); - - matchMedia.useMediaQuery(`(max-width: ${Constants.MOBILE_SCREEN_WIDTH}px)`); - - expect(props.actions.emitBrowserWindowResized).toBeCalledTimes(1); - - expect(props.actions.emitBrowserWindowResized.mock.calls[0][0]).toBe(WindowSizes.MOBILE_VIEW); - - wrapper.unmount(); - }); - }); - describe('Routes', () => { test('Should mount public product routes', () => { const mainComponent = () => (

{'TestMainComponent'}

); diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 0b5971191e..b363a1495a 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -3,7 +3,6 @@ import classNames from 'classnames'; import deepEqual from 'fast-deep-equal'; -import throttle from 'lodash/throttle'; import React from 'react'; import {Route, Switch, Redirect} from 'react-router-dom'; import type {RouteComponentProps} from 'react-router-dom'; @@ -27,7 +26,7 @@ import {loadRecentlyUsedCustomEmojis} from 'actions/emoji_actions'; import * as GlobalActions from 'actions/global_actions'; import {measurePageLoadTelemetry, trackEvent, trackSelectorMetrics} from 'actions/telemetry_actions.jsx'; import BrowserStore from 'stores/browser_store'; -import store from 'stores/redux_store.jsx'; +import store from 'stores/redux_store'; import AccessProblem from 'components/access_problem'; import AnnouncementBarController from 'components/announcement_bar'; @@ -40,6 +39,7 @@ import OpenPricingModalPost from 'components/custom_open_pricing_modal_post_rend import GlobalHeader from 'components/global_header/global_header'; import {HFRoute} from 'components/header_footer_route/header_footer_route'; import {HFTRoute, LoggedInHFTRoute} from 'components/header_footer_template_route'; +import MobileViewWatcher from 'components/mobile_view_watcher'; import ModalController from 'components/modal_controller'; import LaunchingWorkspace, {LAUNCHING_WORKSPACE_FULLSCREEN_Z_INDEX} from 'components/preparing_workspace/launching_workspace'; import {Animations} from 'components/preparing_workspace/steps'; @@ -53,7 +53,7 @@ import webSocketClient from 'client/web_websocket_client.jsx'; import {initializePlugins} from 'plugins'; import Pluggable from 'plugins/pluggable'; import A11yController from 'utils/a11y_controller'; -import Constants, {StoragePrefixes, WindowSizes} from 'utils/constants'; +import {StoragePrefixes} from 'utils/constants'; import {EmojiIndicesByAlias} from 'utils/emoji'; import {getSiteURL} from 'utils/url'; import * as UserAgent from 'utils/user_agent'; @@ -134,7 +134,6 @@ function LoggedInRoute(props: LoggedInRouteProps) { const noop = () => {}; export type Actions = { - emitBrowserWindowResized: (size?: string) => void; getFirstAdminSetupComplete: () => Promise; getProfiles: (page?: number, pageSize?: number, options?: Record) => Promise; migrateRecentEmojis: () => void; @@ -165,10 +164,6 @@ interface State { } export default class Root extends React.PureComponent { - private desktopMediaQuery: MediaQueryList; - private smallDesktopMediaQuery: MediaQueryList; - private tabletMediaQuery: MediaQueryList; - private mobileMediaQuery: MediaQueryList; private mounted: boolean; // The constructor adds a bunch of event listeners, @@ -211,14 +206,6 @@ export default class Root extends React.PureComponent { this.a11yController = new A11yController(); - // set initial window size state - this.desktopMediaQuery = window.matchMedia(`(min-width: ${Constants.DESKTOP_SCREEN_WIDTH + 1}px)`); - this.smallDesktopMediaQuery = window.matchMedia(`(min-width: ${Constants.TABLET_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.DESKTOP_SCREEN_WIDTH}px)`); - this.tabletMediaQuery = window.matchMedia(`(min-width: ${Constants.MOBILE_SCREEN_WIDTH + 1}px) and (max-width: ${Constants.TABLET_SCREEN_WIDTH}px)`); - this.mobileMediaQuery = window.matchMedia(`(max-width: ${Constants.MOBILE_SCREEN_WIDTH}px)`); - - this.updateWindowSize(); - store.subscribe(() => applyLuxonDefaults(store.getState())); } @@ -436,20 +423,6 @@ export default class Root extends React.PureComponent { this.props.actions.registerCustomPostRenderer('custom_up_notification', OpenPricingModalPost, 'upgrade_post_message_renderer'); this.props.actions.registerCustomPostRenderer('custom_pl_notification', OpenPluginInstallPost, 'plugin_install_post_message_renderer'); - if (this.desktopMediaQuery.addEventListener) { - this.desktopMediaQuery.addEventListener('change', this.handleMediaQueryChangeEvent); - this.smallDesktopMediaQuery.addEventListener('change', this.handleMediaQueryChangeEvent); - this.tabletMediaQuery.addEventListener('change', this.handleMediaQueryChangeEvent); - this.mobileMediaQuery.addEventListener('change', this.handleMediaQueryChangeEvent); - } else if (this.desktopMediaQuery.addListener) { - this.desktopMediaQuery.addListener(this.handleMediaQueryChangeEvent); - this.smallDesktopMediaQuery.addListener(this.handleMediaQueryChangeEvent); - this.tabletMediaQuery.addListener(this.handleMediaQueryChangeEvent); - this.mobileMediaQuery.addListener(this.handleMediaQueryChangeEvent); - } else { - window.addEventListener('resize', this.handleWindowResizeEvent); - } - measurePageLoadTelemetry(); trackSelectorMetrics(); } @@ -457,20 +430,6 @@ export default class Root extends React.PureComponent { componentWillUnmount() { this.mounted = false; window.removeEventListener('storage', this.handleLogoutLoginSignal); - - if (this.desktopMediaQuery.removeEventListener) { - this.desktopMediaQuery.removeEventListener('change', this.handleMediaQueryChangeEvent); - this.smallDesktopMediaQuery.removeEventListener('change', this.handleMediaQueryChangeEvent); - this.tabletMediaQuery.removeEventListener('change', this.handleMediaQueryChangeEvent); - this.mobileMediaQuery.removeEventListener('change', this.handleMediaQueryChangeEvent); - } else if (this.desktopMediaQuery.removeListener) { - this.desktopMediaQuery.removeListener(this.handleMediaQueryChangeEvent); - this.smallDesktopMediaQuery.removeListener(this.handleMediaQueryChangeEvent); - this.tabletMediaQuery.removeListener(this.handleMediaQueryChangeEvent); - this.mobileMediaQuery.removeListener(this.handleMediaQueryChangeEvent); - } else { - window.removeEventListener('resize', this.handleWindowResizeEvent); - } } handleLogoutLoginSignal = (e: StorageEvent) => { @@ -498,16 +457,6 @@ export default class Root extends React.PureComponent { } }; - handleWindowResizeEvent = throttle(() => { - this.props.actions.emitBrowserWindowResized(); - }, 100); - - handleMediaQueryChangeEvent = (e: MediaQueryListEvent) => { - if (e.matches) { - this.updateWindowSize(); - } - }; - setRootMeta = () => { const root = document.getElementById('root')!; @@ -520,23 +469,6 @@ export default class Root extends React.PureComponent { } }; - updateWindowSize = () => { - switch (true) { - case this.desktopMediaQuery.matches: - this.props.actions.emitBrowserWindowResized(WindowSizes.DESKTOP_VIEW); - break; - case this.smallDesktopMediaQuery.matches: - this.props.actions.emitBrowserWindowResized(WindowSizes.SMALL_DESKTOP_VIEW); - break; - case this.tabletMediaQuery.matches: - this.props.actions.emitBrowserWindowResized(WindowSizes.TABLET_VIEW); - break; - case this.mobileMediaQuery.matches: - this.props.actions.emitBrowserWindowResized(WindowSizes.MOBILE_VIEW); - break; - } - }; - render() { if (!this.state.configLoaded) { return
; @@ -544,6 +476,7 @@ export default class Root extends React.PureComponent { return ( + { switch (true) { case xLargeSidebarMediaQuery.matches: