Move logic to track mobile view into its own component (#24272)

* Move logic to track mobile view into its own component

* Address feedback
This commit is contained in:
Harrison Healey 2023-09-14 17:02:31 -04:00 committed by GitHub
parent 17ff2049ec
commit 0fb6e5169d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 152 additions and 166 deletions

View File

@ -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<typeof connector>;
export default connector(MobileViewWatcher);

View File

@ -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(<MobileViewWatcher {...baseProps}/>);
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(<MobileViewWatcher {...baseProps}/>);
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(<MobileViewWatcher {...baseProps}/>);
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(<MobileViewWatcher {...baseProps}/>);
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);
});
});

View File

@ -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;
}

View File

@ -2,6 +2,7 @@
exports[`components/Root Routes Should mount public product routes 1`] = `
<RootProvider>
<Connect(MobileViewWatcher) />
<Switch>
<Route
component={[Function]}

View File

@ -15,7 +15,6 @@ import {shouldShowTermsOfService, getCurrentUserId} from 'mattermost-redux/selec
import type {Action} from 'mattermost-redux/types/actions';
import {migrateRecentEmojis} from 'actions/emoji_actions';
import {emitBrowserWindowResized} from 'actions/views/browser';
import {loadConfigAndMe, registerCustomPostRenderer} from 'actions/views/root';
import {getShowLaunchingWorkspace} from 'selectors/onboarding';
import {shouldShowAppBar} from 'selectors/plugins';
@ -65,7 +64,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Actions>({
loadConfigAndMe,
emitBrowserWindowResized,
getFirstAdminSetupComplete,
getProfiles,
migrateRecentEmojis,

View File

@ -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(<Root {...props}/>);
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(<Root {...props}/>);
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(<Root {...props}/>);
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(<Root {...props}/>);
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 = () => (<p>{'TestMainComponent'}</p>);

View File

@ -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<T>(props: LoggedInRouteProps<T>) {
const noop = () => {};
export type Actions = {
emitBrowserWindowResized: (size?: string) => void;
getFirstAdminSetupComplete: () => Promise<ActionResult>;
getProfiles: (page?: number, pageSize?: number, options?: Record<string, any>) => Promise<ActionResult>;
migrateRecentEmojis: () => void;
@ -165,10 +164,6 @@ interface State {
}
export default class Root extends React.PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
}
};
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<Props, State> {
}
};
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 <div/>;
@ -544,6 +476,7 @@ export default class Root extends React.PureComponent<Props, State> {
return (
<RootProvider>
<MobileViewWatcher/>
<Switch>
<Route
path={'/error'}

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import throttle from 'lodash/throttle';
import {useCallback, useEffect} from 'react';
import {useCallback, useEffect, useRef} from 'react';
import {useDispatch} from 'react-redux';
import {setLhsSize} from 'actions/views/lhs';
@ -12,14 +12,14 @@ import {SidebarSize} from 'components/resizable_sidebar/constants';
import Constants from 'utils/constants';
const smallSidebarMediaQuery = window.matchMedia(`(max-width: ${Constants.SMALL_SIDEBAR_BREAKPOINT}px)`);
const mediumSidebarMediaQuery = window.matchMedia(`(min-width: ${Constants.SMALL_SIDEBAR_BREAKPOINT + 1}px) and (max-width: ${Constants.MEDIUM_SIDEBAR_BREAKPOINT}px)`);
const largeSidebarMediaQuery = window.matchMedia(`(min-width: ${Constants.MEDIUM_SIDEBAR_BREAKPOINT + 1}px) and (max-width: ${Constants.LARGE_SIDEBAR_BREAKPOINT}px)`);
const xLargeSidebarMediaQuery = window.matchMedia(`(min-width: ${Constants.LARGE_SIDEBAR_BREAKPOINT + 1}px)`);
function WindowSizeObserver() {
const dispatch = useDispatch();
const smallSidebarMediaQuery = useRef(window.matchMedia(`(max-width: ${Constants.SMALL_SIDEBAR_BREAKPOINT}px)`)).current;
const mediumSidebarMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.SMALL_SIDEBAR_BREAKPOINT + 1}px) and (max-width: ${Constants.MEDIUM_SIDEBAR_BREAKPOINT}px)`)).current;
const largeSidebarMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.MEDIUM_SIDEBAR_BREAKPOINT + 1}px) and (max-width: ${Constants.LARGE_SIDEBAR_BREAKPOINT}px)`)).current;
const xLargeSidebarMediaQuery = useRef(window.matchMedia(`(min-width: ${Constants.LARGE_SIDEBAR_BREAKPOINT + 1}px)`)).current;
const updateSidebarSize = useCallback(() => {
switch (true) {
case xLargeSidebarMediaQuery.matches: