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} -
-
- -
+
+
+ {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} +
); }