MM-53360 : Scroll to the bottom of channel banner (#24682)

* feat(channel-chat): MM-53360 Scroll to the bottom of channel chat

This commit adds new toast to the channel chat, which allows users to
scroll to the bottom quickly.

As it is needed to be along side with the `Search hint toast`, I needed
to adjust the code inside `hint_toast, toast_wrapper` more than
expected.

- [x] Updated tests/snapshots

* refactor(scroll-to-bottom): MM-53360 Replace if block => separated func

* style: MM-53360 Fix order of imports

* refactor(scroll-to-bottom): MM-53360 Simplify hideScrollToBottonToast

* test(scroll-to-bottom): MM-53360 Migrate test from enzyme to react test

This commit migrates unit tests from `enzyme` to `react-testing-library`.

Besides, it adds new test ids for testing purposes.

* fixup! test(scroll-to-bottom): MM-53360 Migrate test from enzyme to react test

* style: MM-53360 Fix eslint error

* style(hint_toast): MM-53360 Update style to match the design

Decrease the size, and increase the font-weight of the shortcut key.
See more at: https://www.figma.com/file/gbnx8ydTX0bTFIbJ8NWGfR/MM-53360-Scroll-to-bottom-of-chat?type=design&node-id=1101-21615&mode=dev

* feat(scroll-to-bottom): MM-53360 Change condition to show toast

- Hide the toast after clicking "Jump to recents".
- Do not show the toast if the user dismissed it before.

* fixup! feat(scroll-to-bottom): MM-53360 Change condition to show toast

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Nhan Nguyen - Edward 2023-11-09 00:13:52 +07:00 committed by GitHub
parent 2c82ff85a5
commit 808c6ec6dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 437 additions and 47 deletions

View File

@ -2,26 +2,23 @@
exports[`components/HintToast should match snapshot 1`] = `
<div
className="hint-toast__wrapper"
className="hint-toast"
data-testid="hint-toast"
>
<div
className="hint-toast"
className="hint-toast__message"
>
<div
className="hint-toast__message"
>
A hint
</div>
<div
className="hint-toast__dismiss"
data-testid="dismissHintToast"
onClick={[Function]}
>
<CloseIcon
className="close-btn"
id="dismissHintToast"
/>
</div>
A hint
</div>
<div
className="hint-toast__dismiss"
data-testid="dismissHintToast"
onClick={[Function]}
>
<CloseIcon
className="close-btn"
id="dismissHintToast"
/>
</div>
</div>
`;

View File

@ -1,10 +1,7 @@
.hint-toast {
padding-top: 8px;
padding-bottom: 8px;
padding: 4px 4px 4px 12px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
margin-top: 16px;
margin-right: 0;
margin-left: 0;
margin: 0;
background-color: var(--center-channel-bg);
border-radius: 4px;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.12);
@ -14,15 +11,19 @@
.hint-toast__message {
display: inline-block;
padding-left: 12px;
line-height: 20px;
text-align: center;
vertical-align: middle;
.shortcut-key {
font-weight: 600;
}
}
.hint-toast__dismiss {
display: inline-block;
padding: 0 8px;
padding: 4px;
margin-left: 16px;
&:hover {
background-color: var(--center-channel-bg);
@ -33,6 +34,7 @@
}
svg {
padding-top: 2px; /* Vertical alignment fix */
fill: rgba(var(--center-channel-color-rgb), 0.56);
vertical-align: middle;
}

View File

@ -7,6 +7,8 @@ import CloseIcon from 'components/widgets/icons/close_icon';
import './hint_toast.scss';
export const HINT_TOAST_TESTID = 'hint-toast';
type Props = {
children: React.ReactNode;
onDismiss: () => void;
@ -20,23 +22,24 @@ export const HintToast: React.FC<Props> = ({children, onDismiss}: Props) => {
};
return (
<div className='hint-toast__wrapper'>
<div className='hint-toast'>
<div
className='hint-toast__message'
>
{children}
</div>
<div
className='hint-toast__dismiss'
onClick={handleDismiss}
data-testid='dismissHintToast'
>
<CloseIcon
className='close-btn'
id='dismissHintToast'
/>
</div>
<div
data-testid={HINT_TOAST_TESTID}
className='hint-toast'
>
<div
className='hint-toast__message'
>
{children}
</div>
<div
className='hint-toast__dismiss'
onClick={handleDismiss}
data-testid='dismissHintToast'
>
<CloseIcon
className='close-btn'
id='dismissHintToast'
/>
</div>
</div>
);

View File

@ -141,6 +141,8 @@ type State = {
isSearchHintDismissed: boolean;
isMobileView?: boolean;
isNewMessageLineReached: boolean;
showScrollToBottomToast: boolean;
isScrollToBottomDismissed: boolean;
}
export default class PostList extends React.PureComponent<Props, State> {
@ -175,6 +177,8 @@ export default class PostList extends React.PureComponent<Props, State> {
showSearchHint: false,
isSearchHintDismissed: false,
isNewMessageLineReached: false,
showScrollToBottomToast: false,
isScrollToBottomDismissed: false,
};
this.listRef = React.createRef();
@ -408,7 +412,7 @@ export default class PostList extends React.PureComponent<Props, State> {
const didUserScrollBackwards = scrollDirection === 'backward' && !scrollUpdateWasRequested;
const didUserScrollForwards = scrollDirection === 'forward' && !scrollUpdateWasRequested;
const isOffsetWithInRange = scrollOffset < HEIGHT_TRIGGER_FOR_MORE_POSTS;
const offsetFromBottom = (scrollHeight - clientHeight) - scrollOffset;
const offsetFromBottom = this.getOffsetFromBottom(scrollOffset, scrollHeight, clientHeight);
const shouldLoadNewPosts = offsetFromBottom < HEIGHT_TRIGGER_FOR_MORE_POSTS;
if (didUserScrollBackwards && isOffsetWithInRange && !this.props.atOldestPost) {
@ -458,6 +462,8 @@ export default class PostList extends React.PureComponent<Props, State> {
showSearchHint: offsetFromBottom > this.showSearchHintThreshold,
});
}
this.updateScrollToBottomToastVisibility(scrollOffset, scrollHeight, clientHeight);
};
getShowSearchHintThreshold = () => {
@ -468,9 +474,11 @@ export default class PostList extends React.PureComponent<Props, State> {
this.updateAtBottom(this.isAtBottom(scrollOffset, scrollHeight, clientHeight));
};
// Calculate how far the post list is from being scrolled to the bottom
getOffsetFromBottom = (scrollOffset: number, scrollHeight: number, clientHeight: number) => scrollHeight - clientHeight - scrollOffset;
isAtBottom = (scrollOffset: number, scrollHeight: number, clientHeight: number) => {
// Calculate how far the post list is from being scrolled to the bottom
const offsetFromBottom = scrollHeight - clientHeight - scrollOffset;
const offsetFromBottom = this.getOffsetFromBottom(scrollOffset, scrollHeight, clientHeight);
return offsetFromBottom <= BUFFER_TO_BE_CONSIDERED_BOTTOM && scrollHeight > 0;
};
@ -512,6 +520,40 @@ export default class PostList extends React.PureComponent<Props, State> {
});
};
handleScrollToBottomToastDismiss = () => {
this.setState({
showScrollToBottomToast: false,
isScrollToBottomDismissed: true,
});
};
hideScrollToBottomToast = () => {
this.setState({
showScrollToBottomToast: false,
});
};
/*
* - Show the scroll-to-bottom toast at the same time as the search-hint toast.
* - Only show if the user hasn't dismissed it before, within a session.
* - Hide it if the user is at the bottom of the list.
*/
updateScrollToBottomToastVisibility = (scrollOffset: number, scrollHeight: number, clientHeight: number) => {
if (this.state.showScrollToBottomToast && this.state.atBottom) {
this.setState({
showScrollToBottomToast: false,
});
return;
}
if (!this.state.isScrollToBottomDismissed) {
const offsetFromBottom = this.getOffsetFromBottom(scrollOffset, scrollHeight, clientHeight);
this.setState({
showScrollToBottomToast: offsetFromBottom > this.showSearchHintThreshold,
});
}
};
updateFloatingTimestamp = (visibleTopItem: number) => {
if (!this.props.isMobileView) {
return;
@ -627,6 +669,9 @@ export default class PostList extends React.PureComponent<Props, State> {
initScrollOffsetFromBottom={this.state.initScrollOffsetFromBottom}
onSearchHintDismiss={this.handleSearchHintDismiss}
showSearchHintToast={this.state.showSearchHint}
showScrollToBottomToast={this.state.showScrollToBottomToast}
onScrollToBottomToastDismiss={this.handleScrollToBottomToastDismiss}
hideScrollToBottomToast={this.hideScrollToBottomToast}
/>
);
};

View File

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import ScrollToBottomToast from './scroll_to_bottom_toast';
export default ScrollToBottomToast;

View File

@ -0,0 +1,63 @@
.scroll-to-bottom-toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px;
padding-left: 12px;
// background-color: var(--center-channel-color--88);
background: linear-gradient(0deg, rgba(63, 67, 80, 0.9) 0%, rgba(63, 67, 80, 0.9) 100%), #fff;
border-radius: 4px;
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.12);
color: var(--sidebar-text);
cursor: pointer;
font-size: 14px;
font-weight: 600;
gap: 8px;
&:hover {
background-color: var(--center-channel-color);
}
svg {
fill: var(--sidebar-text);
vertical-align: middle;
}
body.enable-animations & {
transition-duration: 0s;
transition-property: opacity, visibility;
transition-timing-function: ease-in-out;
}
.scroll-to-bottom-toast__message {
display: inline-block;
line-height: 20px;
vertical-align: middle;
> svg {
margin-right: 8px;
}
}
.scroll-to-bottom-toast__dismiss {
display: inline-block;
padding: 4px;
border-radius: 4px;
&:hover {
background-color: var(--center-channel-bg-08);
}
svg {
padding-top: 1px; // To align vertically
fill: rgba(var(--sidebar-text-rgb), 0.56);
vertical-align: middle;
}
.close-btn {
cursor: pointer;
line-height: normal;
}
}
}

View File

@ -0,0 +1,66 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import React from 'react';
import ScrollToBottomToast from './scroll_to_bottom_toast';
describe('ScrollToBottomToast Component', () => {
const mockOnDismiss = jest.fn();
const mockOnClick = jest.fn();
const mockOnClickEvent = {
preventDefault: jest.fn(),
stopPropagation: jest.fn(),
};
it('should render ScrollToBottomToast component', () => {
const wrapper = shallow(
<ScrollToBottomToast
onDismiss={mockOnDismiss}
onClick={mockOnClick}
/>,
);
// Assertions
expect(wrapper.find('.scroll-to-bottom-toast')).toHaveLength(1);
expect(wrapper.find('UnreadBelowIcon')).toHaveLength(1);
expect(wrapper.text()).toContain('Jump to recents');
expect(wrapper.find('.scroll-to-bottom-toast__dismiss')).toHaveLength(1);
expect(wrapper.find('.close-btn')).toHaveLength(1);
});
it('should call onClick when clicked', () => {
const wrapper = shallow(
<ScrollToBottomToast
onDismiss={mockOnDismiss}
onClick={mockOnClick}
/>,
);
// Simulate click
wrapper.simulate('click', mockOnClickEvent);
// Expect the onClick function to be called
expect(mockOnClick).toHaveBeenCalled();
});
it('should call onDismiss when close button is clicked', () => {
const wrapper = shallow(
<ScrollToBottomToast
onDismiss={mockOnDismiss}
onClick={mockOnClick}
/>,
);
// Simulate click on the close button
wrapper.find('.scroll-to-bottom-toast__dismiss').simulate('click', mockOnClickEvent);
// Expect the onDismiss function to be called
expect(mockOnDismiss).toHaveBeenCalled();
// Expect to stop propagation to avoid scrolling down on dismissing
expect(mockOnClickEvent.stopPropagation).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,61 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import CloseIcon from 'components/widgets/icons/close_icon';
import UnreadBelowIcon from 'components/widgets/icons/unread_below_icon';
import './scroll_to_bottom_toast.scss';
export const SCROLL_TO_BOTTOM_TOAST_TESTID = 'scroll-to-bottom-toast';
export const SCROLL_TO_BOTTOM_DISMISS_BUTTON_TESTID = 'scroll-to-bottom-toast--dismiss-button';
type ScrollToBottomToastProps = {
onDismiss: () => void;
onClick: () => void;
}
export const ScrollToBottomToast = ({onDismiss, onClick}: ScrollToBottomToastProps) => {
const {formatMessage} = useIntl();
const jumpToRecentsMessage = formatMessage({
id: 'postlist.toast.scrollToBottom',
defaultMessage: 'Jump to recents',
});
const handleScrollToBottom: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
onClick();
};
const handleDismiss: React.MouseEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
e.stopPropagation();
onDismiss();
};
return (
<div
data-testid={SCROLL_TO_BOTTOM_TOAST_TESTID}
className='scroll-to-bottom-toast'
onClick={handleScrollToBottom}
>
<UnreadBelowIcon/>
{jumpToRecentsMessage}
<div
className='scroll-to-bottom-toast__dismiss'
onClick={handleDismiss}
data-testid={SCROLL_TO_BOTTOM_DISMISS_BUTTON_TESTID}
>
<CloseIcon
className='close-btn'
id='dismissScrollToBottomToast'
/>
</div>
</div>
);
};
export default ScrollToBottomToast;

View File

@ -0,0 +1,10 @@
.toasts-wrapper {
position: absolute;
z-index: 3;
display: flex;
width: 100%;
align-items: center;
justify-content: center;
margin-top: 16px;
gap: 7px;
}

View File

@ -1,13 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fireEvent, screen} from '@testing-library/react';
import React from 'react';
import type {ComponentProps} from 'react';
import Preferences from 'mattermost-redux/constants/preferences';
import {DATE_LINE} from 'mattermost-redux/utils/post_list';
import {HINT_TOAST_TESTID} from 'components/hint-toast/hint_toast';
import {SCROLL_TO_BOTTOM_DISMISS_BUTTON_TESTID, SCROLL_TO_BOTTOM_TOAST_TESTID} from 'components/scroll_to_bottom_toast/scroll_to_bottom_toast';
import {shallowWithIntl} from 'tests/helpers/intl-test-helper';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {getHistory} from 'utils/browser_history';
import {PostListRowListIds} from 'utils/constants';
@ -45,6 +50,9 @@ describe('components/ToastWrapper', () => {
scrollToUnreadMessages: jest.fn(),
showSearchHintToast: true,
onSearchHintDismiss: jest.fn(),
showScrollToBottomToast: false,
onScrollToBottomToastDismiss: jest.fn(),
hideScrollToBottomToast: jest.fn(),
actions: {
updateToastStatus: jest.fn(),
},
@ -613,4 +621,105 @@ describe('components/ToastWrapper', () => {
expect(dismissHandler).toHaveBeenCalled();
});
});
describe('Scroll-to-bottom toast', () => {
test('should not be shown when unread toast should be shown', () => {
const props = {
...baseProps,
unreadCountInChannel: 10,
newRecentMessagesCount: 5,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.queryByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
expect(scrollToBottomToast).not.toBeInTheDocument();
});
test('should not be shown when history toast should be shown', () => {
const props = {
...baseProps,
focusedPostId: 'asdasd',
atLatestPost: false,
atBottom: false,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.queryByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
expect(scrollToBottomToast).not.toBeInTheDocument();
});
test('should NOT be shown if showScrollToBottomToast is false', () => {
const props = {
...baseProps,
showScrollToBottomToast: false,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.queryByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
expect(scrollToBottomToast).not.toBeInTheDocument();
});
test('should be shown when no other toasts are shown', () => {
const props = {
...baseProps,
showSearchHintToast: false,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.queryByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
expect(scrollToBottomToast).toBeInTheDocument();
});
test('should be shown along side with Search hint toast', () => {
const props = {
...baseProps,
showSearchHintToast: true,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.queryByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
const hintToast = screen.queryByTestId(HINT_TOAST_TESTID);
// Assert that both components exist
expect(scrollToBottomToast).toBeInTheDocument();
expect(hintToast).toBeInTheDocument();
});
test('should call scrollToLatestMessages on click, and hide this toast (do not call dismiss function)', () => {
const props = {
...baseProps,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToast = screen.getByTestId(SCROLL_TO_BOTTOM_TOAST_TESTID);
fireEvent.click(scrollToBottomToast);
expect(baseProps.scrollToLatestMessages).toHaveBeenCalledTimes(1);
// * Do not dismiss the toast, hide it only
expect(baseProps.onScrollToBottomToastDismiss).toHaveBeenCalledTimes(0);
expect(baseProps.hideScrollToBottomToast).toHaveBeenCalledTimes(1);
});
test('should call the dismiss callback', () => {
const props = {
...baseProps,
showScrollToBottomToast: true,
};
renderWithIntlAndStore(<ToastWrapper {...props}/>);
const scrollToBottomToastDismiss = screen.getByTestId(SCROLL_TO_BOTTOM_DISMISS_BUTTON_TESTID);
fireEvent.click(scrollToBottomToastDismiss);
expect(baseProps.onScrollToBottomToastDismiss).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -9,6 +9,7 @@ import type {RouteComponentProps} from 'react-router-dom';
import {Preferences} from 'mattermost-redux/constants';
import {HintToast} from 'components/hint-toast/hint_toast';
import ScrollToBottomToast from 'components/scroll_to_bottom_toast';
import {SearchShortcut} from 'components/search_shortcut';
import Timestamp, {RelativeRanges} from 'components/timestamp';
import Toast from 'components/toast/toast';
@ -20,6 +21,8 @@ import {isKeyPressed} from 'utils/keyboard';
import {isIdNotPost, getNewMessageIndex} from 'utils/post_utils';
import {localizeMessage} from 'utils/utils';
import './toast__wrapper.scss';
const TOAST_TEXT_COLLAPSE_WIDTH = 500;
const TOAST_REL_RANGES = [
@ -42,6 +45,9 @@ export type Props = WrappedComponentProps & RouteComponentProps<{team: string}>
updateLastViewedBottomAt: (lastViewedBottom?: number) => void;
showSearchHintToast: boolean;
onSearchHintDismiss: () => void;
showScrollToBottomToast: boolean;
onScrollToBottomToastDismiss: () => void;
hideScrollToBottomToast: () => void;
shouldStartFromBottomWhenUnread: boolean;
isNewMessageLineReached: boolean;
rootPosts: Record<string, boolean>;
@ -67,6 +73,7 @@ type State = {
showNewMessagesToast?: boolean;
showMessageHistoryToast?: boolean;
showUnreadWithBottomStartToast?: boolean;
showScrollToBottomToast?: boolean;
};
export class ToastWrapperClass extends React.PureComponent<Props, State> {
@ -368,6 +375,7 @@ export class ToastWrapperClass extends React.PureComponent<Props, State> {
scrollToLatestMessages();
this.hideUnreadToast();
this.props.hideScrollToBottomToast?.();
};
scrollToUnreadMessages = () => {
@ -376,7 +384,7 @@ export class ToastWrapperClass extends React.PureComponent<Props, State> {
};
getToastToRender() {
const {atLatestPost, atBottom, width, lastViewedAt, showSearchHintToast} = this.props;
const {atLatestPost, atBottom, width, lastViewedAt, showSearchHintToast, showScrollToBottomToast} = this.props;
const {showUnreadToast, showNewMessagesToast, showMessageHistoryToast, showUnreadWithBottomStartToast, unreadCount} = this.state;
const unreadToastProps = {
@ -449,13 +457,33 @@ export class ToastWrapperClass extends React.PureComponent<Props, State> {
);
}
const toasts = [];
if (showScrollToBottomToast) {
toasts.push(
<ScrollToBottomToast
key='scroll-to-bottom-toast'
onClick={this.scrollToLatestMessages}
onDismiss={this.props.onScrollToBottomToastDismiss}
/>,
);
}
if (showSearchHintToast) {
return (
toasts.push(
<HintToast
key='search-hint-toast'
onDismiss={this.hideSearchHintToast}
>
{this.getSearchHintToastText()}
</HintToast>
</HintToast>,
);
}
if (toasts.length > 0) {
return (
<div className='toasts-wrapper'>
{toasts}
</div>
);
}