diff --git a/webapp/channels/src/actions/views/lhs.ts b/webapp/channels/src/actions/views/lhs.ts index 8b9927699d..78e61c84e7 100644 --- a/webapp/channels/src/actions/views/lhs.ts +++ b/webapp/channels/src/actions/views/lhs.ts @@ -7,8 +7,38 @@ import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/tea import {GlobalState} from 'types/store'; import {LhsItemType} from 'types/store/lhs'; -import {ActionTypes} from 'utils/constants'; +import Constants, {ActionTypes} from 'utils/constants'; import {getHistory} from 'utils/browser_history'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; + +export const setLhsSize = (sidebarSize?: SidebarSize) => { + let newSidebarSize = sidebarSize; + if (!sidebarSize) { + const width = window.innerWidth; + + switch (true) { + case width <= Constants.SMALL_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.SMALL; + break; + } + case width > Constants.SMALL_SIDEBAR_BREAKPOINT && width <= Constants.MEDIUM_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.MEDIUM; + break; + } + case width > Constants.MEDIUM_SIDEBAR_BREAKPOINT && width <= Constants.LARGE_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.LARGE; + break; + } + default: { + newSidebarSize = SidebarSize.XLARGE; + } + } + } + return { + type: ActionTypes.SET_LHS_SIZE, + size: newSidebarSize, + }; +}; export const toggle = () => ({ type: ActionTypes.TOGGLE_LHS, diff --git a/webapp/channels/src/actions/views/rhs.ts b/webapp/channels/src/actions/views/rhs.ts index 908d08a2be..a5cf9860b2 100644 --- a/webapp/channels/src/actions/views/rhs.ts +++ b/webapp/channels/src/actions/views/rhs.ts @@ -33,6 +33,7 @@ import {GlobalState} from 'types/store'; import {getPostsByIds, getPost as fetchPost} from 'mattermost-redux/actions/posts'; import {getChannel} from 'mattermost-redux/actions/channels'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; function selectPostFromRightHandSideSearchWithPreviousState(post: Post, previousRhsState?: RhsState) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { @@ -138,6 +139,35 @@ export function updateSearchTerms(terms: string) { }; } +export function setRhsSize(rhsSize?: SidebarSize) { + let newSidebarSize = rhsSize; + if (!newSidebarSize) { + const width = window.innerWidth; + + switch (true) { + case width <= Constants.SMALL_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.SMALL; + break; + } + case width > Constants.SMALL_SIDEBAR_BREAKPOINT && width <= Constants.MEDIUM_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.MEDIUM; + break; + } + case width > Constants.MEDIUM_SIDEBAR_BREAKPOINT && width <= Constants.LARGE_SIDEBAR_BREAKPOINT: { + newSidebarSize = SidebarSize.LARGE; + break; + } + default: { + newSidebarSize = SidebarSize.XLARGE; + } + } + } + return { + type: ActionTypes.SET_RHS_SIZE, + size: newSidebarSize, + }; +} + export function updateSearchTermsForShortcut() { return (dispatch: DispatchFunc, getState: GetStateFunc) => { const currentChannelName = getCurrentChannelNameForSearchShortcut(getState()); diff --git a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx index 4be681dc21..0cf56ef967 100644 --- a/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx +++ b/webapp/channels/src/components/advanced_text_editor/formatting_bar/formatting_bar.tsx @@ -129,6 +129,8 @@ interface FormattingBarProps { additionalControls?: React.ReactNodeArray; } +const DEFAULT_MIN_MODE_X_COORD = 55; + const FormattingBar = (props: FormattingBarProps): JSX.Element => { const { applyMarkdown, @@ -224,10 +226,12 @@ const FormattingBar = (props: FormattingBarProps): JSX.Element => { } }, [getCurrentSelection, getCurrentMessage, applyMarkdown, showHiddenControls, toggleHiddenControls, disableControls]); + const leftPosition = wideMode === 'min' ? (x ?? 0) + DEFAULT_MIN_MODE_X_COORD : x ?? 0; + const hiddenControlsContainerStyles: React.CSSProperties = { position: strategy, top: y ?? 0, - left: x ?? 0, + left: leftPosition, }; const showSeparators = wideMode === 'wide'; diff --git a/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx b/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx index b2c3f97d6b..d29d2f78c5 100644 --- a/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx +++ b/webapp/channels/src/components/advanced_text_editor/formatting_bar/hooks.tsx @@ -1,14 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useLayoutEffect, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useState} from 'react'; import {Instance} from '@popperjs/core'; import {debounce} from 'lodash'; import {MarkdownMode} from 'utils/markdown/apply_markdown'; -type WideMode = 'wide' | 'normal' | 'narrow'; +type WideMode = 'wide' | 'normal' | 'narrow' | 'min'; export function useGetLatest(val: T) { const ref = React.useRef(val); @@ -18,23 +18,24 @@ export function useGetLatest(val: T) { const useResponsiveFormattingBar = (ref: React.RefObject): WideMode => { const [wideMode, setWideMode] = useState('wide'); - const handleResize = debounce(() => { + const handleResize = useCallback(debounce(() => { if (ref.current?.clientWidth === undefined) { return; } - if (ref.current.clientWidth > 640) { setWideMode('wide'); } - if (ref.current.clientWidth >= 424 && ref.current.clientWidth <= 640) { setWideMode('normal'); } - if (ref.current.clientWidth < 424) { setWideMode('narrow'); } - }, 10); + + if (ref.current.clientWidth < 310) { + setWideMode('min'); + } + }, 10), []); useLayoutEffect(() => { if (!ref.current) { @@ -58,6 +59,7 @@ const MAP_WIDE_MODE_TO_CONTROLS_QUANTITY: {[key in WideMode]: number} = { wide: 9, normal: 5, narrow: 3, + min: 1, }; export const useFormattingBarControls = ( diff --git a/webapp/channels/src/components/resizable_sidebar/constants.ts b/webapp/channels/src/components/resizable_sidebar/constants.ts new file mode 100644 index 0000000000..bb65810257 --- /dev/null +++ b/webapp/channels/src/components/resizable_sidebar/constants.ts @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export enum SidebarSize { + SMALL='small', + MEDIUM= 'medium', + LARGE= 'large', + XLARGE= 'xLarge', +} + +export enum ResizeDirection { + LEFT = 'left', + RIGHT = 'right', +} + +export enum CssVarKeyForResizable { + LHS = 'overrideLhsWidth', + RHS = 'overrideRhsWidth', +} + +export const SIDEBAR_SNAP_SIZE = 16; +export const SIDEBAR_SNAP_SPEED_LIMIT = 5; + +export const DEFAULT_LHS_WIDTH = 240; + +export const RHS_MIN_MAX_WIDTH: { [size in SidebarSize]: { min: number; max: number; default: number}} = { + [SidebarSize.SMALL]: { + min: 400, + max: 400, + default: 400, + }, + [SidebarSize.MEDIUM]: { + min: 304, + max: 400, + default: 400, + }, + [SidebarSize.LARGE]: { + min: 304, + max: 464, + default: 400, + }, + [SidebarSize.XLARGE]: { + min: 304, + max: 776, + default: 500, + }, +}; diff --git a/webapp/channels/src/components/resizable_sidebar/resizable_divider.tsx b/webapp/channels/src/components/resizable_sidebar/resizable_divider.tsx new file mode 100644 index 0000000000..15afe69874 --- /dev/null +++ b/webapp/channels/src/components/resizable_sidebar/resizable_divider.tsx @@ -0,0 +1,267 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {MouseEventHandler, RefObject, useEffect, useRef, useState} from 'react'; +import styled, {createGlobalStyle, css} from 'styled-components'; +import {CssVarKeyForResizable, ResizeDirection} from './constants'; +import {useGlobalState} from 'stores/hooks'; +import {isSizeLessThanSnapSize, isSnapableSpeed, shouldSnapWhenSizeGrown, shouldSnapWhenSizeShrunk} from './utils'; +import {useSelector} from 'react-redux'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +type Props = { + id?: string; + className?: string; + name: string; + disabled?: boolean; + defaultWidth: number; + globalCssVar: CssVarKeyForResizable; + dir: ResizeDirection; + containerRef: RefObject; + onResizeStart?: (startWidth: number) => void; + onResize?: (width: number, cssVarProperty: string, cssVarValue: string) => void; + onResizeEnd?: (finalWidth: number, cssVarProperty: string, cssVarValue: string) => void; + onDividerDoubleClick?: (prevWidth: number, cssVarProperty: string) => void; +} + +const Divider = styled.div<{isActive: boolean}>` + position: absolute; + z-index: 50; + top: 0; + width: 16px; + height: 100%; + cursor: col-resize; + &.left { + right: -8px; + } + + &.right { + left: -8px; + } + &::after { + position: absolute; + left: 6px; + width: 4px; + height: 100%; + background-color: ${({isActive}) => (isActive ? 'var(--sidebar-text-active-border)' : 'transparent')}; + content: ''; + } + &:hover { + &::after { + background-color: var(--sidebar-text-active-border); + transition: background-color 400ms step-end; + } + } + &.snapped { + &::after { + animation: emphasis-sidebar-resize-line 800ms; + } + } +`; + +const ResizableDividerGlobalStyle = createGlobalStyle<{active: boolean; varName: CssVarKeyForResizable; width: number | null}>` + ${({active}) => active && css` + body { + cursor: col-resize; + user-select: none; + } + `} + ${({varName, width}) => width && css` + :root { + --${varName}: ${width}px; + } + `} +`; + +function ResizableDivider({ + id, + name, + className, + disabled, + dir, + containerRef, + defaultWidth, + onResize, + onResizeStart, + onResizeEnd, + onDividerDoubleClick, + ...props +}: Props) { + const resizeLineRef = useRef(null); + + const startWidth = useRef(0); + const lastWidth = useRef(null); + + const previousClientX = useRef(0); + + const cssVarKey = `--${props.globalCssVar}`; + + const currentUserID = useSelector(getCurrentUserId); + + const [isActive, setIsActive] = useState(false); + const [width, setWidth] = useGlobalState(null, `resizable_${name}:`, currentUserID); + + const defaultOnResizeChange = (width: number, cssVarProp: string, cssVarValue: string) => { + containerRef.current?.style.setProperty(cssVarProp, cssVarValue); + lastWidth.current = width; + }; + + const handleOnResize = (width: number, cssVarProp: string, cssVarValue: string) => { + if (onResize) { + onResize(width, cssVarProp, cssVarValue); + } + + defaultOnResizeChange(width, cssVarProp, cssVarValue); + }; + + const reset = () => { + startWidth.current = 0; + previousClientX.current = 0; + lastWidth.current = null; + }; + + const onMouseDown: MouseEventHandler = (e) => { + if (disabled || !containerRef.current) { + return; + } + previousClientX.current = e.clientX; + startWidth.current = containerRef.current.getBoundingClientRect().width; + + setIsActive(true); + + if (onResizeStart) { + onResizeStart(startWidth.current); + } + + handleOnResize(startWidth.current, cssVarKey, `${startWidth.current}px`); + + document.body.classList.add('layout-changing'); + }; + + useEffect(() => { + if (!isActive || disabled) { + reset(); + return undefined; + } + const onMouseMove = (e: MouseEvent) => { + const resizeLine = resizeLineRef.current; + + if (!isActive) { + return; + } + + if (!resizeLine) { + return; + } + + e.preventDefault(); + + const previousWidth = lastWidth.current ?? 0; + let widthDiff = 0; + + switch (dir) { + case ResizeDirection.LEFT: + widthDiff = e.clientX - previousClientX.current; + break; + case ResizeDirection.RIGHT: + widthDiff = previousClientX.current - e.clientX; + break; + } + + const newWidth = previousWidth + widthDiff; + + if (resizeLine.classList.contains('snapped')) { + const diff = newWidth - defaultWidth; + + if (isSizeLessThanSnapSize(diff)) { + return; + } + + handleOnResize(newWidth, cssVarKey, `${newWidth}px`); + + resizeLine.classList.remove('snapped'); + previousClientX.current = e.clientX; + + return; + } + + previousClientX.current = e.clientX; + + const shouldSnap = shouldSnapWhenSizeGrown(newWidth, previousWidth, defaultWidth) || shouldSnapWhenSizeShrunk(newWidth, previousWidth, defaultWidth); + + if (isSnapableSpeed(newWidth - previousWidth) && shouldSnap) { + switch (dir) { + case ResizeDirection.LEFT: + previousClientX.current += defaultWidth - newWidth; + break; + case ResizeDirection.RIGHT: + previousClientX.current += newWidth - defaultWidth; + break; + } + + handleOnResize(defaultWidth, cssVarKey, `${defaultWidth}px`); + + resizeLine.classList.add('snapped'); + return; + } + + handleOnResize(newWidth, cssVarKey, `${newWidth}px`); + }; + + const onMouseUp = () => { + const finalWidth = containerRef.current?.getBoundingClientRect().width; + if (isActive && finalWidth) { + setWidth(finalWidth); + onResizeEnd?.(finalWidth, cssVarKey, `${finalWidth}px`); + } + + containerRef.current?.style.removeProperty(cssVarKey); + document.body.classList.remove('layout-changing'); + reset(); + setIsActive(false); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [isActive, disabled]); + + const onDoubleClick = () => { + onDividerDoubleClick?.(width ?? 0, cssVarKey); + reset(); + setWidth(null); + }; + + if (disabled) { + return null; + } + + return ( + <> + + + + + ); +} + +export default ResizableDivider; diff --git a/webapp/channels/src/components/resizable_sidebar/resizable_lhs/index.tsx b/webapp/channels/src/components/resizable_sidebar/resizable_lhs/index.tsx new file mode 100644 index 0000000000..02dc32382e --- /dev/null +++ b/webapp/channels/src/components/resizable_sidebar/resizable_lhs/index.tsx @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {HTMLAttributes, useRef} from 'react'; + +import {DEFAULT_LHS_WIDTH, CssVarKeyForResizable, ResizeDirection} from '../constants'; +import ResizableDivider from '../resizable_divider'; + +interface Props extends HTMLAttributes<'div'> { + children: React.ReactNode; + disabled?: boolean; +} + +function ResizableLhs({ + children, + disabled, + id, + className, +}: Props) { + const containerRef = useRef(null); + + return ( +
+ {children} + +
+ ); +} + +export default ResizableLhs; diff --git a/webapp/channels/src/components/resizable_sidebar/resizable_rhs/index.tsx b/webapp/channels/src/components/resizable_sidebar/resizable_rhs/index.tsx new file mode 100644 index 0000000000..ecb9d88666 --- /dev/null +++ b/webapp/channels/src/components/resizable_sidebar/resizable_rhs/index.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {HTMLAttributes, useEffect, useRef, useState} from 'react'; +import {useSelector} from 'react-redux'; + +import {getIsRhsExpanded, getRhsSize} from 'selectors/rhs'; + +import {shouldRhsOverlapChannelView} from '../utils'; +import {CssVarKeyForResizable, RHS_MIN_MAX_WIDTH, ResizeDirection} from '../constants'; +import ResizableDivider from '../resizable_divider'; + +interface Props extends HTMLAttributes<'div'> { + children: React.ReactNode; + rightWidthHolderRef: React.RefObject; +} + +function ResizableRhs({ + role, + children, + id, + className, + rightWidthHolderRef, +}: Props) { + const containerRef = useRef(null); + + const rhsSize = useSelector(getRhsSize); + const isRhsExpanded = useSelector(getIsRhsExpanded); + + const [previousRhsExpanded, setPreviousRhsExpanded] = useState(false); + + const defaultWidth = RHS_MIN_MAX_WIDTH[rhsSize].default; + + const shouldRhsOverlap = shouldRhsOverlapChannelView(rhsSize); + + const handleResize = (_: number, cssVarProp: string, cssVarValue: string) => { + const rightWidthHolderRefElement = rightWidthHolderRef.current; + + if (!rightWidthHolderRefElement) { + return; + } + + if (!shouldRhsOverlap) { + rightWidthHolderRefElement.style.setProperty(cssVarProp, cssVarValue); + } + }; + + const handleResizeEnd = (_: number, cssVarProp: string) => { + const rightWidthHolderRefElement = rightWidthHolderRef.current; + + if (!rightWidthHolderRefElement) { + return; + } + + rightWidthHolderRefElement.style.removeProperty(cssVarProp); + }; + + const handleDividerDoubleClick = (_: number, cssVarProp: string) => { + handleResizeEnd(_, cssVarProp); + + document.body.classList.add('layout-changing'); + + setTimeout(() => { + document.body.classList.remove('layout-changing'); + }, 1000); + }; + + // If max-width is applied immediately when expanded is collapsed, the transition will not work correctly. + useEffect(() => { + const containerRefElement = containerRef.current; + + if (!containerRefElement) { + return; + } + + setPreviousRhsExpanded(isRhsExpanded); + + if (previousRhsExpanded && !isRhsExpanded) { + containerRefElement.classList.add('resize-disabled'); + + setTimeout(() => { + containerRefElement.classList.remove('resize-disabled'); + }, 1000); + } + }, [isRhsExpanded]); + + return ( +
+ {children} + +
+ ); +} + +export default ResizableRhs; diff --git a/webapp/channels/src/components/resizable_sidebar/utils.ts b/webapp/channels/src/components/resizable_sidebar/utils.ts new file mode 100644 index 0000000000..6bcfafca66 --- /dev/null +++ b/webapp/channels/src/components/resizable_sidebar/utils.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SidebarSize, SIDEBAR_SNAP_SIZE, SIDEBAR_SNAP_SPEED_LIMIT} from './constants'; + +export const isSizeLessThanSnapSize = (size: number) => { + return Math.abs(size) <= SIDEBAR_SNAP_SIZE; +}; + +export const isSnapableSpeed = (speed: number) => { + return Math.abs(speed) <= SIDEBAR_SNAP_SPEED_LIMIT; +}; + +export const shouldSnapWhenSizeGrown = (newWidth: number, prevWidth: number, defaultWidth: number) => { + const diff = defaultWidth - newWidth; + const isGrowing = newWidth > prevWidth; + + return diff >= 0 && diff <= SIDEBAR_SNAP_SIZE && isGrowing; +}; + +export const shouldSnapWhenSizeShrunk = (newWidth: number, prevWidth: number, defaultWidth: number) => { + const diff = newWidth - defaultWidth; + const isShrinking = newWidth < prevWidth; + + return diff >= 0 && diff <= SIDEBAR_SNAP_SIZE && isShrinking; +}; + +export const shouldRhsOverlapChannelView = (size: SidebarSize) => size === SidebarSize.MEDIUM; + 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 e9dca9d2f5..18f3c6f36c 100644 --- a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap +++ b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap @@ -90,6 +90,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = ` + diff --git a/webapp/channels/src/components/root/root.test.tsx b/webapp/channels/src/components/root/root.test.tsx index eae461b83c..ee0e9c6e6c 100644 --- a/webapp/channels/src/components/root/root.test.tsx +++ b/webapp/channels/src/components/root/root.test.tsx @@ -6,6 +6,8 @@ import {RouteComponentProps} from 'react-router-dom'; import {shallow} from 'enzyme'; import rudderAnalytics from 'rudder-sdk-js'; +import matchMedia from 'tests/helpers/match_media.mock'; + import {Theme} from 'mattermost-redux/selectors/entities/preferences'; import {Client4} from 'mattermost-redux/client'; @@ -14,7 +16,6 @@ import {GeneralTypes} from 'mattermost-redux/action_types'; import Root from 'components/root/root'; import * as GlobalActions from 'actions/global_actions'; import Constants, {StoragePrefixes, WindowSizes} from 'utils/constants'; -import matchMedia from 'tests/helpers/match_media.mock'; import {ProductComponent} from 'types/store/plugins'; import {ServiceEnvironment} from '@mattermost/types/config'; diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index 53b627609b..056c98361c 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -91,6 +91,7 @@ import {applyLuxonDefaults} from './effects'; import RootProvider from './root_provider'; import RootRedirect from './root_redirect'; +import WindowSizeObserver from 'components/window_size_observer/WindowSizeObserver'; import {ServiceEnvironment} from '@mattermost/types/config'; const CreateTeam = makeAsyncComponent('CreateTeam', LazyCreateTeam); @@ -644,6 +645,7 @@ export default class Root extends React.PureComponent { transitionDirection={Animations.Reasons.EnterFromBefore} /> )} + diff --git a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap index eb1032bad5..d88256786d 100644 --- a/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap +++ b/webapp/channels/src/components/sidebar/__snapshots__/sidebar.test.tsx.snap @@ -3,8 +3,9 @@ exports[`components/sidebar should match empty div snapshot when teamId is missing 1`] = `
`; exports[`components/sidebar should match snapshot 1`] = ` -
-
+ `; exports[`components/sidebar should match snapshot when direct channels modal is open 1`] = ` -
-
+ `; exports[`components/sidebar should match snapshot when more channels modal is open 1`] = ` -
-
+ `; diff --git a/webapp/channels/src/components/sidebar/sidebar.tsx b/webapp/channels/src/components/sidebar/sidebar.tsx index d25e06f119..011cd5d39c 100644 --- a/webapp/channels/src/components/sidebar/sidebar.tsx +++ b/webapp/channels/src/components/sidebar/sidebar.tsx @@ -29,6 +29,7 @@ import ChannelNavigator from './channel_navigator'; import SidebarList from './sidebar_list'; import SidebarHeader from './sidebar_header'; import MobileSidebarHeader from './mobile_sidebar_header'; +import ResizableLhs from 'components/resizable_sidebar/resizable_lhs'; type Props = { teamId: string; @@ -229,12 +230,14 @@ export default class Sidebar extends React.PureComponent { const ariaLabel = Utils.localizeMessage('accessibility.sections.lhsNavigator', 'channel navigator region'); return ( -
{this.props.isMobileView ? : ( { /> {this.renderModals()} -
+ ); } } diff --git a/webapp/channels/src/components/sidebar/sidebar_header/sidebar_header.tsx b/webapp/channels/src/components/sidebar/sidebar_header/sidebar_header.tsx index 2c47d11247..1f1b4d2ddf 100644 --- a/webapp/channels/src/components/sidebar/sidebar_header/sidebar_header.tsx +++ b/webapp/channels/src/components/sidebar/sidebar_header/sidebar_header.tsx @@ -40,6 +40,7 @@ const SidebarHeaderContainer = styled(Flex).attrs(() => ({ }))` height: 52px; padding: 0 16px; + gap: 8px; .dropdown-menu { position: absolute; @@ -54,11 +55,6 @@ const SidebarHeaderContainer = styled(Flex).attrs(() => ({ } `; -const HEADING_WIDTH = 200; -const CHEVRON_WIDTH = 26; -const ADD_CHANNEL_DROPDOWN_WIDTH = 28; -const TITLE_WIDTH = (HEADING_WIDTH - CHEVRON_WIDTH - ADD_CHANNEL_DROPDOWN_WIDTH).toString(); - const SidebarHeading = styled(Heading).attrs(() => ({ element: 'h1', margin: 'none', @@ -69,7 +65,6 @@ const SidebarHeading = styled(Heading).attrs(() => ({ display: flex; .title { - max-width: ${TITLE_WIDTH}px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx index 5399e083a3..4a8609e67d 100644 --- a/webapp/channels/src/components/sidebar_right/sidebar_right.tsx +++ b/webapp/channels/src/components/sidebar_right/sidebar_right.tsx @@ -26,6 +26,7 @@ import PostEditHistory from 'components/post_edit_history'; import LoadingScreen from 'components/loading_screen'; import RhsPlugin from 'plugins/rhs_plugin'; +import ResizableRhs from 'components/resizable_sidebar/resizable_rhs'; type Props = { isExpanded: boolean; @@ -65,13 +66,15 @@ type State = { export default class SidebarRight extends React.PureComponent { sidebarRight: React.RefObject; + sidebarRightWidthHolder: React.RefObject; previous: Partial | undefined = undefined; focusSearchBar?: () => void; constructor(props: Props) { super(props); - this.sidebarRight = React.createRef(); + this.sidebarRightWidthHolder = React.createRef(); + this.sidebarRight = React.createRef(); this.state = { isOpened: false, }; @@ -262,14 +265,20 @@ export default class SidebarRight extends React.PureComponent { return ( <> -
+ -
+
{isRHSLoading ? (
{/* Sometimes the channel/team is not loaded yet, so we need to wait for it */} @@ -286,7 +295,7 @@ export default class SidebarRight extends React.PureComponent { )}
-
+ ); } diff --git a/webapp/channels/src/components/window_size_observer/WindowSizeObserver.tsx b/webapp/channels/src/components/window_size_observer/WindowSizeObserver.tsx new file mode 100644 index 0000000000..2efc6e4350 --- /dev/null +++ b/webapp/channels/src/components/window_size_observer/WindowSizeObserver.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect} from 'react'; + +import {useDispatch} from 'react-redux'; + +import throttle from 'lodash/throttle'; + +import {setLhsSize} from 'actions/views/lhs'; +import {setRhsSize} from 'actions/views/rhs'; + +import Constants from 'utils/constants'; +import {SidebarSize} from 'components/resizable_sidebar/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 updateSidebarSize = useCallback(() => { + switch (true) { + case xLargeSidebarMediaQuery.matches: + dispatch(setLhsSize(SidebarSize.XLARGE)); + dispatch(setRhsSize(SidebarSize.XLARGE)); + break; + case largeSidebarMediaQuery.matches: + dispatch(setLhsSize(SidebarSize.LARGE)); + dispatch(setRhsSize(SidebarSize.LARGE)); + break; + case mediumSidebarMediaQuery.matches: + dispatch(setLhsSize(SidebarSize.MEDIUM)); + dispatch(setRhsSize(SidebarSize.MEDIUM)); + break; + case smallSidebarMediaQuery.matches: + dispatch(setLhsSize(SidebarSize.SMALL)); + dispatch(setRhsSize(SidebarSize.SMALL)); + break; + } + }, [dispatch]); + + const setSidebarSizeWhenWindowResized = useCallback(throttle(() => { + dispatch(setLhsSize()); + dispatch(setRhsSize()); + }, 100), []); + + const handleSidebarMediaQueryChangeEvent = useCallback((e: MediaQueryListEvent) => { + if (e.matches) { + updateSidebarSize(); + } + }, [updateSidebarSize]); + + useEffect(() => { + updateSidebarSize(); + + if (smallSidebarMediaQuery.addEventListener) { + xLargeSidebarMediaQuery.addEventListener('change', handleSidebarMediaQueryChangeEvent); + largeSidebarMediaQuery.addEventListener('change', handleSidebarMediaQueryChangeEvent); + mediumSidebarMediaQuery.addEventListener('change', handleSidebarMediaQueryChangeEvent); + smallSidebarMediaQuery.addEventListener('change', handleSidebarMediaQueryChangeEvent); + } else if (smallSidebarMediaQuery.addListener) { + xLargeSidebarMediaQuery.addListener(handleSidebarMediaQueryChangeEvent); + largeSidebarMediaQuery.addListener(handleSidebarMediaQueryChangeEvent); + mediumSidebarMediaQuery.addListener(handleSidebarMediaQueryChangeEvent); + smallSidebarMediaQuery.addListener(handleSidebarMediaQueryChangeEvent); + } else { + window.addEventListener('resize', setSidebarSizeWhenWindowResized); + } + + return () => { + if (smallSidebarMediaQuery.removeEventListener) { + xLargeSidebarMediaQuery.removeEventListener('change', handleSidebarMediaQueryChangeEvent); + largeSidebarMediaQuery.removeEventListener('change', handleSidebarMediaQueryChangeEvent); + mediumSidebarMediaQuery.removeEventListener('change', handleSidebarMediaQueryChangeEvent); + smallSidebarMediaQuery.removeEventListener('change', handleSidebarMediaQueryChangeEvent); + } else if (smallSidebarMediaQuery.removeListener) { + xLargeSidebarMediaQuery.removeListener(handleSidebarMediaQueryChangeEvent); + largeSidebarMediaQuery.removeListener(handleSidebarMediaQueryChangeEvent); + mediumSidebarMediaQuery.removeListener(handleSidebarMediaQueryChangeEvent); + smallSidebarMediaQuery.removeListener(handleSidebarMediaQueryChangeEvent); + } else { + window.removeEventListener('resize', setSidebarSizeWhenWindowResized); + } + }; + }, [handleSidebarMediaQueryChangeEvent, setSidebarSizeWhenWindowResized, updateSidebarSize]); + + return null; +} + +export default WindowSizeObserver; diff --git a/webapp/channels/src/reducers/views/lhs.test.ts b/webapp/channels/src/reducers/views/lhs.test.ts index 2918629626..ffaea1e77e 100644 --- a/webapp/channels/src/reducers/views/lhs.test.ts +++ b/webapp/channels/src/reducers/views/lhs.test.ts @@ -11,6 +11,7 @@ describe('Reducers.LHS', () => { const initialState = { isOpen: false, currentStaticPageId: '', + size: 'medium', }; test('initial state', () => { @@ -18,6 +19,7 @@ describe('Reducers.LHS', () => { { isOpen: false, currentStaticPageId: '', + size: 'medium', }, {} as GenericAction, ); @@ -30,6 +32,7 @@ describe('Reducers.LHS', () => { { isOpen: true, currentStaticPageId: '', + size: 'medium', }, { type: ActionTypes.TOGGLE_LHS, @@ -47,6 +50,7 @@ describe('Reducers.LHS', () => { { isOpen: false, currentStaticPageId: '', + size: 'medium', }, { type: ActionTypes.TOGGLE_LHS, @@ -64,6 +68,7 @@ describe('Reducers.LHS', () => { { isOpen: false, currentStaticPageId: '', + size: 'medium', }, { type: ActionTypes.OPEN_LHS, @@ -81,6 +86,7 @@ describe('Reducers.LHS', () => { { isOpen: true, currentStaticPageId: '', + size: 'medium', }, { type: ActionTypes.CLOSE_LHS, @@ -104,6 +110,7 @@ describe('Reducers.LHS', () => { { isOpen: true, currentStaticPageId: '', + size: 'medium', }, { type: action, diff --git a/webapp/channels/src/reducers/views/lhs.ts b/webapp/channels/src/reducers/views/lhs.ts index 497164ddfb..467ee49f3d 100644 --- a/webapp/channels/src/reducers/views/lhs.ts +++ b/webapp/channels/src/reducers/views/lhs.ts @@ -6,6 +6,7 @@ import {combineReducers} from 'redux'; import {TeamTypes, UserTypes} from 'mattermost-redux/action_types'; import type {GenericAction} from 'mattermost-redux/types/actions'; import {ActionTypes} from 'utils/constants'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; function isOpen(state = false, action: GenericAction) { switch (action.type) { @@ -29,6 +30,15 @@ function isOpen(state = false, action: GenericAction) { } } +function size(state = SidebarSize.MEDIUM, action: GenericAction) { + switch (action.type) { + case ActionTypes.SET_LHS_SIZE: + return action.size; + default: + return state; + } +} + function currentStaticPageId(state = '', action: GenericAction) { switch (action.type) { case ActionTypes.SELECT_STATIC_PAGE: @@ -42,6 +52,6 @@ function currentStaticPageId(state = '', action: GenericAction) { export default combineReducers({ isOpen, - + size, currentStaticPageId, }); diff --git a/webapp/channels/src/reducers/views/rhs.test.js b/webapp/channels/src/reducers/views/rhs.test.js index 91b93d53a4..82f1f31b11 100644 --- a/webapp/channels/src/reducers/views/rhs.test.js +++ b/webapp/channels/src/reducers/views/rhs.test.js @@ -19,6 +19,7 @@ describe('Reducers.RHS', () => { searchTerms: '', searchType: '', searchResultsTerms: '', + size: 'medium', pluggableId: '', isSearchingFlaggedPost: false, isSearchingPinnedPost: false, @@ -609,6 +610,7 @@ describe('Reducers.RHS', () => { searchTerms: 'user_id', searchType: '', searchResultsTerms: 'user id', + size: 'medium', pluggableId: 'pluggable_id', isSearchingFlaggedPost: true, isSearchingPinnedPost: true, diff --git a/webapp/channels/src/reducers/views/rhs.ts b/webapp/channels/src/reducers/views/rhs.ts index c03d08f1cf..0a4a86e427 100644 --- a/webapp/channels/src/reducers/views/rhs.ts +++ b/webapp/channels/src/reducers/views/rhs.ts @@ -14,6 +14,7 @@ import type {GenericAction} from 'mattermost-redux/types/actions'; import type {RhsState} from 'types/store/rhs'; import {ActionTypes, RHSStates} from 'utils/constants'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; function selectedPostId(state = '', action: GenericAction) { switch (action.type) { @@ -182,6 +183,15 @@ function rhsState(state: RhsState = null, action: GenericAction) { } } +function size(state: SidebarSize = SidebarSize.MEDIUM, action: GenericAction) { + switch (action.type) { + case ActionTypes.SET_RHS_SIZE: + return action.size; + default: + return state; + } +} + function searchTerms(state = '', action: GenericAction) { switch (action.type) { case ActionTypes.UPDATE_RHS_SEARCH_TERMS: @@ -381,6 +391,7 @@ export default combineReducers({ searchTerms, searchType, searchResultsTerms, + size, pluggableId, isSearchingFlaggedPost, isSearchingPinnedPost, diff --git a/webapp/channels/src/sass/layout/_sidebar-left.scss b/webapp/channels/src/sass/layout/_sidebar-left.scss index 1bfdc7b59c..5470c2d36b 100644 --- a/webapp/channels/src/sass/layout/_sidebar-left.scss +++ b/webapp/channels/src/sass/layout/_sidebar-left.scss @@ -24,15 +24,31 @@ $sidebarOpacityAnimationDuration: 0.15s; } #SidebarContainer { + position: relative; z-index: 16; left: 0; display: flex; - width: 240px; + width: var(--overrideLhsWidth, 240px); + min-width: 240px; + max-width: 240px; height: 100%; flex-direction: column; border-right: 1px solid rgba(var(--center-channel-color-rgb), 0.12); background-color: var(--sidebar-bg); + @media screen and (min-width: 769px) { + min-width: 200px; + max-width: 264px; + } + + @media screen and (min-width: 1201px) { + max-width: 304px; + } + + @media screen and (min-width: 1681px) { + max-width: 440px; + } + h1, h2, h3 { @@ -376,11 +392,13 @@ $sidebarOpacityAnimationDuration: 0.15s; .sidebar-header { display: flex; + overflow: hidden; flex-direction: row; align-items: center; } .SidebarHeaderMenuWrapper { + overflow: hidden; padding-left: 5px; background: transparent; border-radius: 4px; @@ -1159,7 +1177,7 @@ $sidebarOpacityAnimationDuration: 0.15s; .SidebarChannel .SidebarLink { position: relative; display: flex; - width: 240px; + width: 100%; height: 32px; align-items: center; padding: 7px 16px 7px 19px; @@ -1361,8 +1379,6 @@ $sidebarOpacityAnimationDuration: 0.15s; .multi-teams { #SidebarContainer { - left: 65px; - +.inner-wrap #app-content { margin-left: 305px; } @@ -1396,11 +1412,15 @@ $sidebarOpacityAnimationDuration: 0.15s; } // Manual override of the global menu (and items) styling for the sidebar dropdown menu -#sidebarDropdownMenu .MenuItem { - >button, - >div, - >a { - padding: 0 32px 0 24px; +#sidebarDropdownMenu { + position: fixed; + + .MenuItem { + >button, + >div, + >a { + padding: 0 32px 0 24px; + } } } diff --git a/webapp/channels/src/sass/layout/_sidebar-right.scss b/webapp/channels/src/sass/layout/_sidebar-right.scss index 6eb8dcab40..694286b69b 100644 --- a/webapp/channels/src/sass/layout/_sidebar-right.scss +++ b/webapp/channels/src/sass/layout/_sidebar-right.scss @@ -5,18 +5,49 @@ display: none; } - width: 400px; - transition: width 0.25s 0s ease-in, z-index 0.25s 0s step-end; + body:not(.layout-changing) & { + transition: width 0.25s 0s ease-in, z-index 0.25s 0s step-end; + } + + width: var(--overrideRhsWidth, 400px); } .sidebar--right { - width: 400px; + width: var(--overrideRhsWidth, 400px); + + &:not(.expanded):not(.resize-disabled) { + min-width: 400px; + max-width: 400px; + + &.sidebar--right--width-holder { + @media screen and (min-width: 901px) and (max-width: 1200px) { + min-width: 304px; + max-width: 304px; + } + } + + @media screen and (min-width: 901px) { + min-width: 304px; + max-width: 400px; + } + + @media screen and (min-width: 1201px) { + max-width: 464px; + } + + @media screen and (min-width: 1681px) { + max-width: 776px; + } + } + height: 100%; padding: 0; transform: translateX(100%); &.is-open { - transition: width 0.25s 0s ease-in, z-index 0.25s 0s step-end; + body:not(.layout-changing) & { + transition: width 0.25s 0s ease-in, z-index 0.25s 0s step-end; + } } &.expanded { @@ -27,7 +58,7 @@ overflow: hidden; } - &.is-open { + body:not(.layout-changing) & { transition: width 0.25s 0s ease-in, z-index 0.15s 0.1s step-start; } } diff --git a/webapp/channels/src/sass/responsive/_desktop.scss b/webapp/channels/src/sass/responsive/_desktop.scss index 70a25651c5..2c41aa3f24 100644 --- a/webapp/channels/src/sass/responsive/_desktop.scss +++ b/webapp/channels/src/sass/responsive/_desktop.scss @@ -3,7 +3,7 @@ @media screen and (min-width: 1680px) { .sidebar--right, .sidebar--right--width-holder { - width: 500px; + width: var(--overrideRhsWidth, 500px); .sidebar--right__title__channel { max-width: 30rem; diff --git a/webapp/channels/src/sass/utils/_mixins.scss b/webapp/channels/src/sass/utils/_mixins.scss index bb4ef5b0a0..91416d37a4 100644 --- a/webapp/channels/src/sass/utils/_mixins.scss +++ b/webapp/channels/src/sass/utils/_mixins.scss @@ -373,6 +373,16 @@ } } +@keyframes emphasis-sidebar-resize-line { + 50% { + transform: scaleX(1.5); + } + + 100% { + transform: scaleX(1); + } +} + @mixin simple-in-and-out($classPrefix, $transition_time: 300ms) { @include simple-in-and-out-before($classPrefix, $transition_time); @include simple-in-and-out-after($classPrefix, $transition_time); diff --git a/webapp/channels/src/selectors/lhs.ts b/webapp/channels/src/selectors/lhs.ts index d1f9765368..a1ebc73d77 100644 --- a/webapp/channels/src/selectors/lhs.ts +++ b/webapp/channels/src/selectors/lhs.ts @@ -9,11 +9,16 @@ import {makeGetDraftsCount} from 'selectors/drafts'; import { isCollapsedThreadsEnabled, } from 'mattermost-redux/selectors/entities/preferences'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; export function getIsLhsOpen(state: GlobalState): boolean { return state.views.lhs.isOpen; } +export function getLhsSize(state: GlobalState): SidebarSize { + return state.views.lhs.size; +} + export function getCurrentStaticPageId(state: GlobalState): string { return state.views.lhs.currentStaticPageId; } diff --git a/webapp/channels/src/selectors/rhs.ts b/webapp/channels/src/selectors/rhs.ts index a54c631dad..7a55a48449 100644 --- a/webapp/channels/src/selectors/rhs.ts +++ b/webapp/channels/src/selectors/rhs.ts @@ -13,11 +13,16 @@ import {localizeMessage} from 'utils/utils'; import {GlobalState} from 'types/store'; import {RhsState, FakePost, SearchType} from 'types/store/rhs'; import {PostDraft} from 'types/store/draft'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; export function getSelectedPostId(state: GlobalState): Post['id'] { return state.views.rhs.selectedPostId; } +export function getRhsSize(state: GlobalState): SidebarSize { + return state.views.rhs.size; +} + export function getSelectedPostFocussedAt(state: GlobalState): number { return state.views.rhs.selectedPostFocussedAt; } diff --git a/webapp/channels/src/stores/hooks.ts b/webapp/channels/src/stores/hooks.ts index 7134de0905..7cb45093c9 100644 --- a/webapp/channels/src/stores/hooks.ts +++ b/webapp/channels/src/stores/hooks.ts @@ -11,7 +11,7 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {makeGetGlobalItem} from 'selectors/storage'; import {setGlobalItem} from 'actions/storage'; -export const currentUserAndTeamSuffix = createSelector('currentUserAndTeamSuffix', [ +const currentUserAndTeamSuffix = createSelector('currentUserAndTeamSuffix', [ getCurrentUserId, getCurrentTeamId, ], ( @@ -21,14 +21,6 @@ export const currentUserAndTeamSuffix = createSelector('currentUserAndTeamSuffix return `:${userId}:${teamId}`; }); -export const currentUserSuffix = createSelector('currentUserSuffix', [ - getCurrentUserId, -], ( - userId, -) => { - return `:${userId}`; -}); - /** * * @param initialValue @@ -38,10 +30,12 @@ export const currentUserSuffix = createSelector('currentUserSuffix', [ export function useGlobalState( initialValue: TVal, name: string, + suffix?: string, ): [TVal, (value: TVal) => ReturnType] { const dispatch = useDispatch(); - const suffix = useSelector(currentUserAndTeamSuffix); - const storedKey = `${name}${suffix}`; + const defaultSuffix = useSelector(currentUserAndTeamSuffix); + const suffixToUse = suffix || defaultSuffix; + const storedKey = `${name}${suffixToUse}`; const value = useSelector(makeGetGlobalItem(storedKey, initialValue), shallowEqual); const setValue = useCallback((newValue) => dispatch(setGlobalItem(storedKey, newValue)), [storedKey]); diff --git a/webapp/channels/src/types/store/lhs.ts b/webapp/channels/src/types/store/lhs.ts index 61fa131a7d..86edba0b70 100644 --- a/webapp/channels/src/types/store/lhs.ts +++ b/webapp/channels/src/types/store/lhs.ts @@ -1,10 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {SidebarSize} from 'components/resizable_sidebar/constants'; + export type LhsViewState = { isOpen: boolean; - // Static pages (e.g. Threads, etc.) + size: SidebarSize; + + // Static pages (e.g. Threads, Insights, etc.) currentStaticPageId: string; } diff --git a/webapp/channels/src/types/store/rhs.ts b/webapp/channels/src/types/store/rhs.ts index a3b09abb7d..065c8e64cb 100644 --- a/webapp/channels/src/types/store/rhs.ts +++ b/webapp/channels/src/types/store/rhs.ts @@ -6,6 +6,7 @@ import {Channel} from '@mattermost/types/channels'; import {UserProfile} from '@mattermost/types/users'; import {RHSStates} from 'utils/constants'; +import {SidebarSize} from 'components/resizable_sidebar/constants'; export type SearchType = '' | 'files' | 'messages'; @@ -37,6 +38,7 @@ export type RhsViewState = { isSidebarExpanded: boolean; isMenuOpen: boolean; editChannelMembers: boolean; + size: SidebarSize; }; export type RhsState = typeof RHSStates[keyof typeof RHSStates] | null; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 077a501e95..6b683149af 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -199,6 +199,8 @@ export const ActionTypes = keyMirror({ UPDATE_RHS_SEARCH_TYPE: null, UPDATE_RHS_SEARCH_RESULTS_TERMS: null, + SET_RHS_SIZE: null, + RHS_GO_BACK: null, SET_RHS_EXPANDED: null, @@ -255,6 +257,7 @@ export const ActionTypes = keyMirror({ TOGGLE_LHS: null, OPEN_LHS: null, CLOSE_LHS: null, + SET_LHS_SIZE: null, SELECT_STATIC_PAGE: null, SET_SHOW_PREVIEW_ON_CREATE_COMMENT: null, @@ -1491,6 +1494,10 @@ export const Constants = { TABLET_SCREEN_WIDTH: 1020, MOBILE_SCREEN_WIDTH: 768, + SMALL_SIDEBAR_BREAKPOINT: 900, + MEDIUM_SIDEBAR_BREAKPOINT: 1200, + LARGE_SIDEBAR_BREAKPOINT: 1680, + POST_MODAL_PADDING: 170, SCROLL_DELAY: 2000, SCROLL_PAGE_FRACTION: 3,