MM-27936: Allow resizing the channel sidebar and right-hand sidebar (#23117)

* MM-27936: Resize sidebar

* separate sidebar related constants into different file

* reset style when sidebar expanded

* change not to save user_id when saving to local_storage

* prevent a separation between the rhs and the main center panel

* change variable name

* migrate to css-based paradigm

* delete unnecessary files

* update snapshot

* increase the resizable range

* fix transition

* Made RHS and LHS widths not be saved scoped by team

* scope cleanup

* fix lhs header menu ellipsis/tolerances

* fix lint

* remove a ref from a dependency

* return null instead of empty jsx

* return null when disabled

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: harshil Sharma <harshilsharma63@gmail.com>
Co-authored-by: Caleb Roseland <caleb@calebroseland.com>
This commit is contained in:
KyeongSoo Kim 2023-08-31 04:34:48 +09:00 committed by GitHub
parent b84236d555
commit 61473efb93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 832 additions and 57 deletions

View File

@ -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,

View File

@ -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());

View File

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

View File

@ -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<T>(val: T) {
const ref = React.useRef<T>(val);
@ -18,23 +18,24 @@ export function useGetLatest<T>(val: T) {
const useResponsiveFormattingBar = (ref: React.RefObject<HTMLDivElement>): WideMode => {
const [wideMode, setWideMode] = useState<WideMode>('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 = (

View File

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

View File

@ -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<HTMLElement>;
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<HTMLDivElement>(null);
const startWidth = useRef(0);
const lastWidth = useRef<number | null>(null);
const previousClientX = useRef(0);
const cssVarKey = `--${props.globalCssVar}`;
const currentUserID = useSelector(getCurrentUserId);
const [isActive, setIsActive] = useState(false);
const [width, setWidth] = useGlobalState<number | null>(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<HTMLDivElement> = (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 (
<>
<Divider
id={id}
className={classNames(className, {
left: dir === ResizeDirection.LEFT,
right: dir === ResizeDirection.RIGHT,
})}
ref={resizeLineRef}
isActive={isActive}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
/>
<ResizableDividerGlobalStyle
varName={props.globalCssVar}
width={width}
active={isActive}
/>
</>
);
}
export default ResizableDivider;

View File

@ -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<HTMLDivElement>(null);
return (
<div
id={id}
className={className}
ref={containerRef}
>
{children}
<ResizableDivider
name={'lhsResizeHandle'}
globalCssVar={CssVarKeyForResizable.LHS}
disabled={disabled}
defaultWidth={DEFAULT_LHS_WIDTH}
dir={ResizeDirection.LEFT}
containerRef={containerRef}
/>
</div>
);
}
export default ResizableLhs;

View File

@ -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<HTMLDivElement>;
}
function ResizableRhs({
role,
children,
id,
className,
rightWidthHolderRef,
}: Props) {
const containerRef = useRef<HTMLDivElement>(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 (
<div
id={id}
className={className}
role={role}
ref={containerRef}
>
{children}
<ResizableDivider
name='rhsResizeHandle'
globalCssVar={CssVarKeyForResizable.RHS}
defaultWidth={defaultWidth}
dir={ResizeDirection.RIGHT}
disabled={isRhsExpanded}
containerRef={containerRef}
onResize={handleResize}
onResizeEnd={handleResizeEnd}
onDividerDoubleClick={handleDividerDoubleClick}
/>
</div>
);
}
export default ResizableRhs;

View File

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

View File

@ -90,6 +90,7 @@ exports[`components/Root Routes Should mount public product routes 1`] = `
<CompassThemeProvider
theme={Object {}}
>
<WindowSizeObserver />
<Connect(ModalController) />
<Connect(_class) />
<Connect(SystemNotice) />

View File

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

View File

@ -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<Props, State> {
transitionDirection={Animations.Reasons.EnterFromBefore}
/>
)}
<WindowSizeObserver/>
<ModalController/>
<AnnouncementBarController/>
<SystemNotice/>

View File

@ -3,8 +3,9 @@
exports[`components/sidebar should match empty div snapshot when teamId is missing 1`] = `<div />`;
exports[`components/sidebar should match snapshot 1`] = `
<div
<ResizableLhs
className=""
disabled={false}
id="SidebarContainer"
>
<SidebarHeader
@ -42,12 +43,13 @@ exports[`components/sidebar should match snapshot 1`] = `
onDragStart={[Function]}
/>
<Connect(DataPrefetch) />
</div>
</ResizableLhs>
`;
exports[`components/sidebar should match snapshot when direct channels modal is open 1`] = `
<div
<ResizableLhs
className=""
disabled={false}
id="SidebarContainer"
>
<SidebarHeader
@ -89,12 +91,13 @@ exports[`components/sidebar should match snapshot when direct channels modal is
isExistingChannel={false}
onModalDismissed={[Function]}
/>
</div>
</ResizableLhs>
`;
exports[`components/sidebar should match snapshot when more channels modal is open 1`] = `
<div
<ResizableLhs
className=""
disabled={false}
id="SidebarContainer"
>
<SidebarHeader
@ -132,5 +135,5 @@ exports[`components/sidebar should match snapshot when more channels modal is op
onDragStart={[Function]}
/>
<Connect(DataPrefetch) />
</div>
</ResizableLhs>
`;

View File

@ -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<Props, State> {
const ariaLabel = Utils.localizeMessage('accessibility.sections.lhsNavigator', 'channel navigator region');
return (
<div
<ResizableLhs
id='SidebarContainer'
className={classNames({
'move--right': this.props.isOpen && this.props.isMobileView,
dragging: this.state.isDragging,
})}
disabled={this.props.isMobileView}
>
{this.props.isMobileView ? <MobileSidebarHeader/> : (
<SidebarHeader
@ -270,7 +273,7 @@ export default class Sidebar extends React.PureComponent<Props, State> {
/>
<DataPrefetch/>
{this.renderModals()}
</div>
</ResizableLhs>
);
}
}

View File

@ -40,6 +40,7 @@ const SidebarHeaderContainer = styled(Flex).attrs(() => ({
}))<SidebarHeaderContainerProps>`
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;

View File

@ -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<Props, State> {
sidebarRight: React.RefObject<HTMLDivElement>;
sidebarRightWidthHolder: React.RefObject<HTMLDivElement>;
previous: Partial<Props> | undefined = undefined;
focusSearchBar?: () => void;
constructor(props: Props) {
super(props);
this.sidebarRight = React.createRef();
this.sidebarRightWidthHolder = React.createRef<HTMLDivElement>();
this.sidebarRight = React.createRef<HTMLDivElement>();
this.state = {
isOpened: false,
};
@ -262,14 +265,20 @@ export default class SidebarRight extends React.PureComponent<Props, State> {
return (
<>
<div className={'sidebar--right sidebar--right--width-holder'}/>
<div
className={'sidebar--right sidebar--right--width-holder'}
ref={this.sidebarRightWidthHolder}
/>
<ResizableRhs
className={containerClassName}
id='sidebar-right'
role='complementary'
ref={this.sidebarRight}
rightWidthHolderRef={this.sidebarRightWidthHolder}
>
<div className='sidebar-right-container'>
<div
className='sidebar-right-container'
ref={this.sidebarRight}
>
{isRHSLoading ? (
<div className='sidebar-right__body'>
{/* 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<Props, State> {
</Search>
)}
</div>
</div>
</ResizableRhs>
</>
);
}

View File

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

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<TVal>(
initialValue: TVal,
name: string,
suffix?: string,
): [TVal, (value: TVal) => ReturnType<typeof setGlobalItem>] {
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]);

View File

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

View File

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

View File

@ -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,