diff --git a/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap b/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap
index 5f63d971d6..6ff158d68e 100644
--- a/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap
+++ b/webapp/channels/src/components/hint-toast/__snapshots__/hint_toast.test.tsx.snap
@@ -2,26 +2,23 @@
exports[`components/HintToast should match snapshot 1`] = `
-
- A hint
-
-
-
-
+ A hint
+
+
+
`;
diff --git a/webapp/channels/src/components/hint-toast/hint_toast.scss b/webapp/channels/src/components/hint-toast/hint_toast.scss
index 01c04a6f59..7c31752e01 100644
--- a/webapp/channels/src/components/hint-toast/hint_toast.scss
+++ b/webapp/channels/src/components/hint-toast/hint_toast.scss
@@ -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;
}
diff --git a/webapp/channels/src/components/hint-toast/hint_toast.tsx b/webapp/channels/src/components/hint-toast/hint_toast.tsx
index 4512809955..fc1706b240 100644
--- a/webapp/channels/src/components/hint-toast/hint_toast.tsx
+++ b/webapp/channels/src/components/hint-toast/hint_toast.tsx
@@ -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 = ({children, onDismiss}: Props) => {
};
return (
-
-
-
- {children}
-
-
-
-
+
);
diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx
index 51d037407c..ce44adce45 100644
--- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx
+++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx
@@ -141,6 +141,8 @@ type State = {
isSearchHintDismissed: boolean;
isMobileView?: boolean;
isNewMessageLineReached: boolean;
+ showScrollToBottomToast: boolean;
+ isScrollToBottomDismissed: boolean;
}
export default class PostList extends React.PureComponent
{
@@ -175,6 +177,8 @@ export default class PostList extends React.PureComponent {
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 {
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 {
showSearchHint: offsetFromBottom > this.showSearchHintThreshold,
});
}
+
+ this.updateScrollToBottomToastVisibility(scrollOffset, scrollHeight, clientHeight);
};
getShowSearchHintThreshold = () => {
@@ -468,9 +474,11 @@ export default class PostList extends React.PureComponent {
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 {
});
};
+ 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 {
initScrollOffsetFromBottom={this.state.initScrollOffsetFromBottom}
onSearchHintDismiss={this.handleSearchHintDismiss}
showSearchHintToast={this.state.showSearchHint}
+ showScrollToBottomToast={this.state.showScrollToBottomToast}
+ onScrollToBottomToastDismiss={this.handleScrollToBottomToastDismiss}
+ hideScrollToBottomToast={this.hideScrollToBottomToast}
/>
);
};
diff --git a/webapp/channels/src/components/scroll_to_bottom_toast/index.tsx b/webapp/channels/src/components/scroll_to_bottom_toast/index.tsx
new file mode 100644
index 0000000000..7f12d00883
--- /dev/null
+++ b/webapp/channels/src/components/scroll_to_bottom_toast/index.tsx
@@ -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;
diff --git a/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.scss b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.scss
new file mode 100644
index 0000000000..45478bb488
--- /dev/null
+++ b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.scss
@@ -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;
+ }
+ }
+}
diff --git a/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.test.tsx b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.test.tsx
new file mode 100644
index 0000000000..171b70d6ce
--- /dev/null
+++ b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.test.tsx
@@ -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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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(
+ ,
+ );
+
+ // 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();
+ });
+});
+
diff --git a/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.tsx b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.tsx
new file mode 100644
index 0000000000..8388bcd2ee
--- /dev/null
+++ b/webapp/channels/src/components/scroll_to_bottom_toast/scroll_to_bottom_toast.tsx
@@ -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 = (e) => {
+ e.preventDefault();
+ onClick();
+ };
+
+ const handleDismiss: React.MouseEventHandler = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ onDismiss();
+ };
+
+ return (
+
+
+ {jumpToRecentsMessage}
+
+
+
+
+ );
+};
+
+export default ScrollToBottomToast;
diff --git a/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss b/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss
new file mode 100644
index 0000000000..23bfa1ea2b
--- /dev/null
+++ b/webapp/channels/src/components/toast_wrapper/toast__wrapper.scss
@@ -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;
+}
diff --git a/webapp/channels/src/components/toast_wrapper/toast_wrapper.test.tsx b/webapp/channels/src/components/toast_wrapper/toast_wrapper.test.tsx
index b6ddce84ba..f5de26ced1 100644
--- a/webapp/channels/src/components/toast_wrapper/toast_wrapper.test.tsx
+++ b/webapp/channels/src/components/toast_wrapper/toast_wrapper.test.tsx
@@ -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();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ 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();
+ const scrollToBottomToastDismiss = screen.getByTestId(SCROLL_TO_BOTTOM_DISMISS_BUTTON_TESTID);
+ fireEvent.click(scrollToBottomToastDismiss);
+
+ expect(baseProps.onScrollToBottomToastDismiss).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx b/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx
index 3812d57e12..0991a1be12 100644
--- a/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx
+++ b/webapp/channels/src/components/toast_wrapper/toast_wrapper.tsx
@@ -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;
@@ -67,6 +73,7 @@ type State = {
showNewMessagesToast?: boolean;
showMessageHistoryToast?: boolean;
showUnreadWithBottomStartToast?: boolean;
+ showScrollToBottomToast?: boolean;
};
export class ToastWrapperClass extends React.PureComponent {
@@ -368,6 +375,7 @@ export class ToastWrapperClass extends React.PureComponent {
scrollToLatestMessages();
this.hideUnreadToast();
+ this.props.hideScrollToBottomToast?.();
};
scrollToUnreadMessages = () => {
@@ -376,7 +384,7 @@ export class ToastWrapperClass extends React.PureComponent {
};
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 {
);
}
+ const toasts = [];
+ if (showScrollToBottomToast) {
+ toasts.push(
+ ,
+ );
+ }
+
if (showSearchHintToast) {
- return (
+ toasts.push(
{this.getSearchHintToastText()}
-
+ ,
+ );
+ }
+
+ if (toasts.length > 0) {
+ return (
+
+ {toasts}
+
);
}