diff --git a/e2e-tests/playwright/lib/src/ui/pages/drafts.ts b/e2e-tests/playwright/lib/src/ui/pages/drafts.ts index aea9604bb0..797493a522 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/drafts.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/drafts.ts @@ -6,7 +6,7 @@ import {Page, expect} from '@playwright/test'; export default class DraftPage { readonly page: Page; - readonly badgeCountOnScheduledTab; + readonly tab; readonly confirmbutton; readonly datePattern; readonly deleteIcon; @@ -27,8 +27,7 @@ export default class DraftPage { this.datePattern = /(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/; - this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-0 div.drafts_tab_title span.MuiBadge-badge'); - // this.scheduledDraftPageInfo = page.locator('span:has-text("Send on")'); + this.tab = page.getByRole('tab', {name: 'Drafts'}); this.scheduledDraftPageInfo = page.locator('.PanelHeader__info'); this.scheduledDraftPanel = (messageContent: string) => page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`); @@ -51,8 +50,10 @@ export default class DraftPage { } async assertBadgeCountOnTab(badgeCount: string) { - await this.badgeCountOnScheduledTab.isVisible(); - await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount); + await this.tab.isVisible(); + const badge = this.tab.locator('span.MuiBadge-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toHaveText(badgeCount); } async assertDraftBody(draftMessage: string) { diff --git a/e2e-tests/playwright/lib/src/ui/pages/scheduled_draft.ts b/e2e-tests/playwright/lib/src/ui/pages/scheduled_draft.ts index b1bc9bfecc..bf10d58c17 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/scheduled_draft.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/scheduled_draft.ts @@ -6,7 +6,7 @@ import {Page, expect} from '@playwright/test'; export default class ScheduledDraftPage { readonly page: Page; - readonly badgeCountOnScheduledTab; + readonly tab; readonly confirmbutton; readonly copyIcon; readonly copyIconToolTip; @@ -31,7 +31,7 @@ export default class ScheduledDraftPage { this.datePattern = /(Today|Tomorrow|(?:January|February|March|April|May|June|July|August|September|October|November|December) \d{1,2}) at \d{1,2}:\d{2} [AP]M/; this.scheduledDraftBody = page.locator('div.post__body'); - this.badgeCountOnScheduledTab = page.locator('a#draft_tabs-tab-1 div.drafts_tab_title span.MuiBadge-badge'); + this.tab = page.getByRole('tab', {name: 'Scheduled'}); this.scheduledDraftPageInfo = page.locator('.PanelHeader__info'); this.scheduledDraftPanel = (messageContent: string) => page.locator(`article.Panel:has(div.post__body:has-text("${messageContent}"))`); @@ -56,8 +56,10 @@ export default class ScheduledDraftPage { } async assertBadgeCountOnTab(badgeCount: string) { - await this.badgeCountOnScheduledTab.isVisible(); - await expect(this.badgeCountOnScheduledTab).toHaveText(badgeCount); + await this.tab.isVisible(); + const badge = this.tab.locator('span.MuiBadge-badge'); + await expect(badge).toBeVisible(); + await expect(badge).toHaveText(badgeCount); } async assertscheduledDraftBody(draftMessage: string) { diff --git a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png index dea565c780..d5413750a4 100644 Binary files a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png and b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png index b25e3a197b..7c62418ffb 100644 Binary files a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png and b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png index c9eb967e51..b4bf1f59c0 100644 Binary files a/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png and b/e2e-tests/playwright/specs/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png differ diff --git a/webapp/channels/package.json b/webapp/channels/package.json index f9a234aa9d..63efc4b017 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -74,9 +74,9 @@ "react-router-dom": "5.3.4", "react-select": "5.9.0", "react-transition-group": "4.4.5", - "react-virtualized-auto-sizer": "1.0.7", - "react-window": "1.8.8", - "react-window-infinite-loader": "1.0.8", + "react-virtualized-auto-sizer": "1.0.25", + "react-window": "1.8.11", + "react-window-infinite-loader": "1.0.10", "rebound": "0.1.0", "redux": "4.2.0", "redux-batched-actions": "0.5.0", diff --git a/webapp/channels/src/components/custom_status/custom_status_modal.test.tsx b/webapp/channels/src/components/custom_status/custom_status_modal.test.tsx index 80cbe72f5d..81ecef7f1e 100644 --- a/webapp/channels/src/components/custom_status/custom_status_modal.test.tsx +++ b/webapp/channels/src/components/custom_status/custom_status_modal.test.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import type {AutoSizerProps} from 'react-virtualized-auto-sizer'; +import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer'; import type {DeepPartial} from '@mattermost/types/utilities'; @@ -16,7 +16,7 @@ import type {GlobalState} from 'types/store'; import CustomStatusModal from './custom_status_modal'; -jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100})); +jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100})); jest.mock('images/img_trans.gif', () => 'img_trans.gif'); describe('CustomStatusModal', () => { diff --git a/webapp/channels/src/components/drafts/__snapshots__/drafts.test.tsx.snap b/webapp/channels/src/components/drafts/__snapshots__/drafts.test.tsx.snap deleted file mode 100644 index f07a9a7c1d..0000000000 --- a/webapp/channels/src/components/drafts/__snapshots__/drafts.test.tsx.snap +++ /dev/null @@ -1,85 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/drafts/drafts should match snapshot 1`] = ` - - - -`; - -exports[`components/drafts/drafts should match snapshot for local drafts disabled 1`] = ` - - - -`; diff --git a/webapp/channels/src/components/drafts/draft_list/draft_list.tsx b/webapp/channels/src/components/drafts/draft_list/draft_list.tsx deleted file mode 100644 index c17a8f8448..0000000000 --- a/webapp/channels/src/components/drafts/draft_list/draft_list.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import classNames from 'classnames'; -import React from 'react'; -import {useIntl} from 'react-intl'; - -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import type {Draft} from 'selectors/drafts'; - -import DraftRow from 'components/drafts/draft_row'; -import DraftsIllustration from 'components/drafts/drafts_illustration'; -import NoResultsIndicator from 'components/no_results_indicator'; - -type Props = { - drafts: Draft[]; - user: UserProfile; - displayName: string; - draftRemotes: Record; - status: UserStatus['status']; - className?: string; -} - -export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) { - const {formatMessage} = useIntl(); - - return ( -
- {drafts.map((d) => ( - - ))} - {drafts.length === 0 && ( - - )} -
- ); -} diff --git a/webapp/channels/src/components/drafts/draft_list/empty_draft_list.tsx b/webapp/channels/src/components/drafts/draft_list/empty_draft_list.tsx new file mode 100644 index 0000000000..163a60a3cf --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_list/empty_draft_list.tsx @@ -0,0 +1,30 @@ +// 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 NoResultsIndicator from 'components/no_results_indicator/no_results_indicator'; + +import EmptyDraftListIllustration from './empty_draft_list_illustration'; + +export default function EmptyDraftList() { + const {formatMessage} = useIntl(); + + return ( +
+ +
+ ); +} diff --git a/webapp/channels/src/components/drafts/drafts_illustration.tsx b/webapp/channels/src/components/drafts/draft_list/empty_draft_list_illustration.tsx similarity index 100% rename from webapp/channels/src/components/drafts/drafts_illustration.tsx rename to webapp/channels/src/components/drafts/draft_list/empty_draft_list_illustration.tsx diff --git a/webapp/channels/src/components/drafts/draft_list/index.test.tsx b/webapp/channels/src/components/drafts/draft_list/index.test.tsx new file mode 100644 index 0000000000..3906d10c62 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_list/index.test.tsx @@ -0,0 +1,107 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer'; + +import type {Draft} from 'selectors/drafts'; + +import {renderWithContext, screen} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import type {PostDraft} from 'types/store/draft'; + +import DraftList from './index'; + +jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100})); + +jest.mock('components/drafts/draft_row', () => { + return function MockDraftRow(props: {item: PostDraft}) { + return ( +
+ {props.item.message} +
+ ); + }; +}); + +describe('components/drafts/draft_list', () => { + const currentUser = TestHelper.getUserMock({id: 'user1'}); + + const mockDrafts: Draft[] = [ + { + id: 'channel1', + type: 'channel', + key: 'draft_channel1', + value: TestHelper.getPostDraftMock({ + message: 'What you seek is seeking you', + }), + timestamp: new Date(), + }, + { + id: 'channel2', + type: 'channel', + key: 'draft_channel2', + value: TestHelper.getPostDraftMock({ + message: 'Where there is ruin, there is hope for a treasure.', + }), + timestamp: new Date(), + }, + ]; + + const initialState = { + views: { + drafts: { + remotes: { + draft_channel1: false, + }, + }, + }, + }; + + test('should render empty draft list when no drafts are provided', () => { + renderWithContext( + , + initialState, + ); + + expect(screen.getByText('No drafts at the moment')).toBeInTheDocument(); + }); + + test('should handle undefined drafts', () => { + renderWithContext( + , + initialState, + ); + + expect(screen.getByText('No drafts at the moment')).toBeInTheDocument(); + }); + + test('should render virtualized draft list when drafts are provided', () => { + renderWithContext( + , + initialState, + ); + + expect(screen.getByText('What you seek is seeking you')).toBeInTheDocument(); + expect(screen.getByText('Where there is ruin, there is hope for a treasure.')).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/drafts/draft_list/index.tsx b/webapp/channels/src/components/drafts/draft_list/index.tsx new file mode 100644 index 0000000000..b3e76bb249 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_list/index.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useSelector} from 'react-redux'; + +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import {getDraftRemotes, type Draft} from 'selectors/drafts'; + +import EmptyDraftList from './empty_draft_list'; +import VirtualizedDraftList from './virtualized_draft_list'; + +type Props = { + drafts: Draft[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; +} + +export default function DraftList(props: Props) { + const draftRemotes = useSelector(getDraftRemotes); + + if (props.drafts.length === 0) { + return ; + } + + return ( + + ); +} diff --git a/webapp/channels/src/components/drafts/draft_list/virtualized_draft_list.tsx b/webapp/channels/src/components/drafts/draft_list/virtualized_draft_list.tsx new file mode 100644 index 0000000000..5df293a087 --- /dev/null +++ b/webapp/channels/src/components/drafts/draft_list/virtualized_draft_list.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import debounce from 'lodash/debounce'; +import type {CSSProperties} from 'react'; +import React, {useMemo, useRef, useCallback, useEffect, memo} from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import {VariableSizeList} from 'react-window'; + +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import type {Draft} from 'selectors/drafts'; + +import DraftRow from 'components/drafts/draft_row'; + +const OVERSCAN_ROW_COUNT = 6; // no. of rows +const ROW_HEIGHT_CHANGE_TOLERANCE = 4; // in px + +const RESIZE_DEBOUNCE_TIME = 120; // in ms + +type Props = { + drafts: Draft[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; + draftRemotes: Record; +} + +export default function VirtualizedDraftList(props: Props) { + const listRef = useRef(null); + const itemHeightCacheMap = useRef>(new Map()); + + // Function to set row height after measurement, we also reset the list after setting the height + const setRowHeight = useCallback((index: number, draftKey: string, size: number) => { + const currentItemHeight = itemHeightCacheMap.current.get(draftKey); + + // If current height is not cached or if there is a significant difference, update the cache + // This prevents layout thrashing when the height variations are small + if (!currentItemHeight || (Math.abs(currentItemHeight - size) > ROW_HEIGHT_CHANGE_TOLERANCE)) { + itemHeightCacheMap.current.set(draftKey, size); + + // Reset the list UI in response to the row height changing + if (listRef.current) { + listRef.current.resetAfterIndex(index); + } + } + }, []); + + const getItemSize = useCallback((index: number) => { + const draftKey = index < props.drafts.length ? props.drafts[index].key : ''; + return draftKey ? (itemHeightCacheMap.current.get(draftKey) || 0) : 0; + }, [props.drafts]); + + const itemData = useMemo(() => ({ + drafts: props.drafts, + userDisplayName: props.userDisplayName, + draftRemotes: props.draftRemotes, + currentUser: props.currentUser, + userStatus: props.userStatus, + setRowHeight, + }), [props.drafts, props.userDisplayName, props.draftRemotes, props.currentUser, props.userStatus, setRowHeight]); + + return ( +
+ + {({height, width}) => ( + + {Row} + + )} + +
+ ); +} + +interface RowProps { + index: number; + style: CSSProperties; + data: { + drafts: Draft[]; + userDisplayName: string; + draftRemotes: Record; + currentUser: UserProfile; + userStatus: string; + setRowHeight: (index: number, draftKey: string, size: number) => void; + }; +} + +// Row component for dynamic height measurement +// This component is rendered for each visible draft item by react-window's virtualization +function RowComponent({index, style, data: {drafts, userDisplayName, draftRemotes, currentUser, userStatus, setRowHeight}}: RowProps) { + const draft = drafts[index]; + + // Reference to the DOM element we'll measure + const rowRef = useRef(null); + + // These refs store the current values for use in callbacks + // This prevents stale closures in the ResizeObserver callback + // and ensures event handlers always have the latest values + // we also update these refs whenever their source values change + const indexRef = useRef(index); + const draftKeyRef = useRef(draft.key); + const setRowHeightRef = useRef(setRowHeight); + useEffect(() => { + indexRef.current = index; + draftKeyRef.current = draft.key; + setRowHeightRef.current = setRowHeight; + }, [index, draft.key, setRowHeight]); + + // This effect performs the initial height measurement on first render + // or whenever the draft content changes + useEffect(() => { + if (!rowRef.current) { + return undefined; + } + + // Use requestAnimationFrame to measure after the browser has painted + const rafId = requestAnimationFrame(() => { + if (!rowRef.current) { + return; + } + + const height = rowRef.current.getBoundingClientRect().height; + + // Inform the virtualized list about this row's height + setRowHeight(index, draft.key, height); + }); + + return () => { + cancelAnimationFrame(rafId); + }; + }, [draft, setRowHeight, index]); + + // This effect sets up a ResizeObserver to track height changes on the row element + useEffect(() => { + if (!rowRef.current) { + return undefined; + } + + // Flag to track whether we're still observing (prevents updates after unmount) + let isObservingResize = true; + + // Create a debounced function to update height measurements + // This prevents excessive updates when height changes rapidly + const debouncedUpdateHeight = debounce((height: number) => { + if (!isObservingResize || !rowRef.current) { + return; + } + + // Notify the parent list about this row's height + setRowHeightRef.current( + indexRef.current, + draftKeyRef.current, + height, + ); + }, RESIZE_DEBOUNCE_TIME); + + // This ResizeObserver API notifies us when the element's size changes + const resizeObserver = new ResizeObserver((resizeEntries) => { + if (!isObservingResize || !rowRef.current) { + return; + } + + // Since we're observing a single row, we can safely assume that the first entry is the one we want + if (resizeEntries.length === 1 && resizeEntries[0].target === rowRef.current) { + const height = resizeEntries[0].borderBoxSize[0].blockSize; + debouncedUpdateHeight(height); + } + }); + + // Start observing size changes on the row element + resizeObserver.observe(rowRef.current); + + return () => { + isObservingResize = false; + debouncedUpdateHeight.cancel(); + resizeObserver.disconnect(); + }; + }, []); + + return ( +
{/* To avoid interference with virtualized list styles we are not using this div for height measurement */} +
+ +
+
+ ); +} + +const Row = memo(RowComponent); diff --git a/webapp/channels/src/components/drafts/draft_row.scss b/webapp/channels/src/components/drafts/draft_row.scss deleted file mode 100644 index 45b72ca545..0000000000 --- a/webapp/channels/src/components/drafts/draft_row.scss +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -.Panel { - .post--editing__wrapper { - margin: 16px; - } -} diff --git a/webapp/channels/src/components/drafts/draft_row.tsx b/webapp/channels/src/components/drafts/draft_row.tsx index 078d859534..a147619229 100644 --- a/webapp/channels/src/components/drafts/draft_row.tsx +++ b/webapp/channels/src/components/drafts/draft_row.tsx @@ -50,8 +50,6 @@ import PanelBody from './panel/panel_body'; import Header from './panel/panel_header'; import {getErrorStringFromCode} from './utils'; -import './draft_row.scss'; - type Props = { user: UserProfile; status: UserStatus['status']; @@ -59,6 +57,7 @@ type Props = { item: PostDraft | ScheduledPost; isRemote?: boolean; scrollIntoView?: boolean; + containerClassName?: string; } const mockLastBlurAt = {current: 0}; @@ -70,6 +69,7 @@ function DraftRow({ displayName, isRemote, scrollIntoView, + containerClassName, }: Props) { const [isEditing, setIsEditing] = useState(false); @@ -366,6 +366,7 @@ function DraftRow({ hasError={Boolean(postError)} innerRef={scrollIntoView ? alertRef : undefined} isHighlighted={scrollIntoView} + className={containerClassName} > {({hover}) => ( <> diff --git a/webapp/channels/src/components/drafts/drafts.test.tsx b/webapp/channels/src/components/drafts/drafts.test.tsx deleted file mode 100644 index 50fb7238c4..0000000000 --- a/webapp/channels/src/components/drafts/drafts.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; -import {Provider} from 'react-redux'; - -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import type {Draft} from 'selectors/drafts'; - -import mockStore from 'tests/test_store'; - -import Drafts from './drafts'; - -describe('components/drafts/drafts', () => { - const baseProps = { - drafts: [] as Draft[], - user: {} as UserProfile, - displayName: 'display_name', - status: {} as UserStatus['status'], - draftRemotes: {}, - }; - - it('should match snapshot', () => { - const store = mockStore(); - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should match snapshot for local drafts disabled', () => { - const store = mockStore(); - - const props = { - ...baseProps, - }; - - const wrapper = shallow( - - - , - ); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/drafts/drafts.tsx b/webapp/channels/src/components/drafts/drafts.tsx deleted file mode 100644 index 9f99d81c5e..0000000000 --- a/webapp/channels/src/components/drafts/drafts.tsx +++ /dev/null @@ -1,208 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Badge} from '@mui/base'; -import React, {memo, useCallback, useEffect, useMemo} from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; -import {type match, useHistory, useRouteMatch} from 'react-router-dom'; - -import type {ScheduledPost} from '@mattermost/types/schedule_post'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import { - isScheduledPostsEnabled, - makeGetScheduledPostsByTeam, -} from 'mattermost-redux/selectors/entities/scheduled_posts'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; - -import {selectLhsItem} from 'actions/views/lhs'; -import {suppressRHS, unsuppressRHS} from 'actions/views/rhs'; -import type {Draft} from 'selectors/drafts'; - -import DraftList from 'components/drafts/draft_list/draft_list'; -import ScheduledPostList from 'components/drafts/scheduled_post_list/scheduled_post_list'; -import Tab from 'components/tabs/tab'; -import Tabs from 'components/tabs/tabs'; -import Header from 'components/widgets/header'; - -import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; -import {LhsItemType, LhsPage} from 'types/store/lhs'; - -import './drafts.scss'; - -const EMPTY_LIST: ScheduledPost[] = []; - -type Props = { - drafts: Draft[]; - user: UserProfile; - displayName: string; - status: UserStatus['status']; - draftRemotes: Record; -} - -function Drafts({ - displayName, - drafts, - draftRemotes, - status, - user, -}: Props) { - const dispatch = useDispatch(); - - const history = useHistory(); - const match: match<{team: string}> = useRouteMatch(); - const isDraftsTab = useRouteMatch('/:team/drafts'); - - const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX); - - const currentTeamId = useSelector(getCurrentTeamId); - const getScheduledPostsByTeam = useMemo(() => makeGetScheduledPostsByTeam(), []); - const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true)); - const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled); - - useEffect(() => { - dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts)); - dispatch(suppressRHS); - - return () => { - dispatch(unsuppressRHS); - }; - }, [dispatch]); - - const handleSwitchTabs = useCallback((key) => { - if (key === 0 && isScheduledPostsTab) { - history.push(`/${match.params.team}/drafts`); - } else if (key === 1 && isDraftsTab) { - history.push(`/${match.params.team}/scheduled_posts`); - } - }, [history, isDraftsTab, isScheduledPostsTab, match]); - - const scheduledPostsTabHeading = useMemo(() => { - return ( -
- - - { - scheduledPosts?.length > 0 && - - } -
- ); - }, [scheduledPosts?.length]); - - const draftTabHeading = useMemo(() => { - return ( -
- - - { - drafts.length > 0 && - - } -
- ); - }, [drafts?.length]); - - const heading = useMemo(() => { - return ( - - ); - }, []); - - const subtitle = useMemo(() => { - return ( - - ); - }, []); - - const activeTab = isDraftsTab ? 0 : 1; - - return ( -
-
- - { - isScheduledPostEnabled && - - - - - - - - - - } - - { - !isScheduledPostEnabled && - - } -
- ); -} - -export default memo(Drafts); diff --git a/webapp/channels/src/components/drafts/drafts.scss b/webapp/channels/src/components/drafts/drafts_and_schedule_posts.scss similarity index 87% rename from webapp/channels/src/components/drafts/drafts.scss rename to webapp/channels/src/components/drafts/drafts_and_schedule_posts.scss index c867f6b8fd..accfd43bc1 100644 --- a/webapp/channels/src/components/drafts/drafts.scss +++ b/webapp/channels/src/components/drafts/drafts_and_schedule_posts.scss @@ -18,9 +18,8 @@ display: flex; overflow: auto; width: 100%; - height: 100%; + height: calc(100% - 56px); flex-flow: column nowrap; - padding: 24px; } display: grid; @@ -72,6 +71,7 @@ .DraftList.Drafts__main { top: 0; + height: calc(100%); } } } @@ -81,6 +81,14 @@ top: -2px; } + .virtualizedVariableListRowWrapper { + padding: 0 24px 16px 24px; + + &.firstRow { + padding-top: 24px; + } + } + @media screen and (max-width: 768px) { grid-template-rows: 0 1fr; @@ -92,4 +100,11 @@ border-top: var(--border-default); } } + + .Panel { + .post--editing__wrapper { + margin: 16px; + } + } + } diff --git a/webapp/channels/src/components/drafts/drafts_and_schedule_posts_page_header.tsx b/webapp/channels/src/components/drafts/drafts_and_schedule_posts_page_header.tsx new file mode 100644 index 0000000000..b2b7c7266d --- /dev/null +++ b/webapp/channels/src/components/drafts/drafts_and_schedule_posts_page_header.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import type {ReactNode} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import Header from 'components/widgets/header'; + +type Props = { + children: ReactNode; +} + +export default function DraftsAndSchedulePostsPageHeader(props: Props) { + return ( +
+
+ } + subtitle={ + + } + /> + {props.children} +
+ ); +} diff --git a/webapp/channels/src/components/drafts/drafts_and_schedule_posts_tabs.tsx b/webapp/channels/src/components/drafts/drafts_and_schedule_posts_tabs.tsx new file mode 100644 index 0000000000..9abff02f41 --- /dev/null +++ b/webapp/channels/src/components/drafts/drafts_and_schedule_posts_tabs.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Badge} from '@mui/base'; +import React, {useCallback, useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; +import {useHistory, useLocation} from 'react-router-dom'; + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import {makeGetScheduledPostsByTeam} from 'mattermost-redux/selectors/entities/scheduled_posts'; +import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; + +import {type Draft} from 'selectors/drafts'; + +import DraftList from 'components/drafts/draft_list'; +import ScheduledPostList from 'components/drafts/scheduled_post_list'; +import Tab from 'components/tabs/tab'; +import Tabs from 'components/tabs/tabs'; + +import {DRAFT_URL_SUFFIX, SCHEDULED_POST_URL_SUFFIX} from 'utils/constants'; + +import type {GlobalState} from 'types/store'; + +const EMPTY_SCHEDULED_POSTS: ScheduledPost[] = []; + +const TAB_KEYS = { + DRAFTS: 'drafts', + SCHEDULED_POSTS: 'scheduled_posts', +}; + +type Props = { + drafts: Draft[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; +} + +export default function DraftsAndSchedulePostsTabs(props: Props) { + const history = useHistory(); + const location = useLocation(); + const isDraftsTab = location.pathname.includes(DRAFT_URL_SUFFIX); + const isScheduledPostsTab = location.pathname.includes(SCHEDULED_POST_URL_SUFFIX); + + const currentTeam = useSelector(getCurrentTeam); + const currentTeamName = currentTeam?.name ?? ''; + const currentTeamId = currentTeam?.id ?? ''; + + const getScheduledPostsByTeam = useMemo(() => makeGetScheduledPostsByTeam(), []); + const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true)); + + const handleSwitchTabs = useCallback((key) => { + if (key === TAB_KEYS.DRAFTS) { + history.push(`/${currentTeamName}/drafts`); + } else if (key === TAB_KEYS.SCHEDULED_POSTS) { + history.push(`/${currentTeamName}/scheduled_posts`); + } + }, [history, currentTeamName]); + + const scheduledPostsTabHeading = useMemo(() => { + return ( +
+ + {scheduledPosts?.length > 0 && ( + + )} +
+ ); + }, [scheduledPosts?.length]); + + const draftTabHeading = useMemo(() => { + return ( +
+ + {props.drafts.length > 0 && ( + + )} +
+ ); + }, [props.drafts.length]); + + const activeTab = useMemo(() => { + if (isDraftsTab) { + return TAB_KEYS.DRAFTS; + } else if (isScheduledPostsTab) { + return TAB_KEYS.SCHEDULED_POSTS; + } + return ''; + }, [isDraftsTab, isScheduledPostsTab]); + + return ( + + + + + + + + + ); +} + diff --git a/webapp/channels/src/components/drafts/index.ts b/webapp/channels/src/components/drafts/index.ts deleted file mode 100644 index 264ff391be..0000000000 --- a/webapp/channels/src/components/drafts/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; - -import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; -import {displayUsername} from 'mattermost-redux/utils/user_utils'; - -import {makeGetDrafts} from 'selectors/drafts'; - -import type {GlobalState} from 'types/store'; - -import Drafts from './drafts'; - -function makeMapStateToProps() { - const getDrafts = makeGetDrafts(); - return (state: GlobalState) => { - const user = getCurrentUser(state); - const status = getStatusForUserId(state, user.id); - - return { - displayName: displayUsername(user, getTeammateNameDisplaySetting(state)), - drafts: getDrafts(state), - draftRemotes: state.views.drafts.remotes, - status, - user, - }; - }; -} - -export default connect(makeMapStateToProps)(Drafts); diff --git a/webapp/channels/src/components/drafts/index.tsx b/webapp/channels/src/components/drafts/index.tsx new file mode 100644 index 0000000000..255dd38c56 --- /dev/null +++ b/webapp/channels/src/components/drafts/index.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {memo, useEffect, useMemo} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts'; +import {getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import {selectLhsItem} from 'actions/views/lhs'; +import {suppressRHS, unsuppressRHS} from 'actions/views/rhs'; +import type {Draft} from 'selectors/drafts'; +import {makeGetDrafts} from 'selectors/drafts'; + +import DraftList from 'components/drafts/draft_list'; + +import type {GlobalState} from 'types/store'; +import {LhsItemType, LhsPage} from 'types/store/lhs'; + +import DraftsAndSchedulePostsPageHeader from './drafts_and_schedule_posts_page_header'; +import DraftsAndSchedulePostsTabs from './drafts_and_schedule_posts_tabs'; + +import './drafts_and_schedule_posts.scss'; + +const EMPTY_DRAFTS: Draft[] = []; + +function Drafts() { + const dispatch = useDispatch(); + + const scheduledPostsEnabled = useSelector(isScheduledPostsEnabled); + + // We would need to get drafts here early since its needed by the draft list component + const getDrafts = useMemo(() => makeGetDrafts(), []); + const drafts = useSelector(getDrafts); + + const currentUser = useSelector(getCurrentUser); + const userStatus = useSelector((state: GlobalState) => getStatusForUserId(state, currentUser.id)); + + const teammateNameDisplaySetting = useSelector(getTeammateNameDisplaySetting); + const userDisplayName = useMemo(() => displayUsername(currentUser, teammateNameDisplaySetting), [currentUser, teammateNameDisplaySetting]); + + // When Drafts component mounts, select Drafts in the LHS + // and suppress the RHS and restore RHS when component unmounts + useEffect(() => { + dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts)); + dispatch(suppressRHS); + + return () => { + dispatch(unsuppressRHS); + }; + }, [dispatch]); + + if (scheduledPostsEnabled) { + return ( + + + + ); + } + + return ( + + + + ); +} + +export default memo(Drafts); diff --git a/webapp/channels/src/components/drafts/panel/panel.tsx b/webapp/channels/src/components/drafts/panel/panel.tsx index 04c84e3851..4a18f8268d 100644 --- a/webapp/channels/src/components/drafts/panel/panel.tsx +++ b/webapp/channels/src/components/drafts/panel/panel.tsx @@ -14,6 +14,8 @@ type Props = { hasError: boolean; innerRef?: React.Ref; isHighlighted?: boolean; + style?: React.CSSProperties; + className?: string; }; const isEligibleForClick = makeIsEligibleForClick('.hljs, code'); @@ -24,6 +26,8 @@ function Panel({ hasError, innerRef, isHighlighted, + style, + className, }: Props) { const [hover, setHover] = useState(false); @@ -49,7 +53,9 @@ function Panel({ draftError: hasError, highlighted: isHighlighted, }, + className, )} + style={style} onMouseOver={handleMouseOver} onClick={handleOnClick} onMouseLeave={handleMouseLeave} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list.tsx new file mode 100644 index 0000000000..46a0905a25 --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/empty_scheduled_post_list.tsx @@ -0,0 +1,29 @@ +// 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 NoResultsIndicator from 'components/no_results_indicator'; + +import NoScheduledPostsIllustration from './empty_scheduled_post_list_illustration'; + +export default function EmptyScheduledPostList() { + const {formatMessage} = useIntl(); + + return ( + + ); +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/index.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/index.tsx new file mode 100644 index 0000000000..4300e4d43e --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/index.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import {fetchMissingChannels} from 'mattermost-redux/actions/channels'; +import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts'; +import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; + +import type {GlobalState} from 'types/store'; + +import EmptyScheduledPostList from './empty_scheduled_post_list'; +import NonVirtualizedScheduledPostList from './non_virtualized_scheduled_post_list'; +import ScheduledPostError from './scheduled_post_error'; + +import './scheduled_post_list.scss'; + +type Props = { + scheduledPosts: ScheduledPost[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; +}; + +export default function ScheduledPostList(props: Props) { + const dispatch = useDispatch(); + + const currentTeamId = useSelector(getCurrentTeamId); + + const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId)); + + useEffect(() => { + if (props.scheduledPosts.length > 0) { + dispatch(fetchMissingChannels(props.scheduledPosts.map((post) => post.channel_id))); + } + }, [dispatch, props.scheduledPosts]); + + if (props.scheduledPosts.length === 0) { + return ( + + ); + } + + return ( +
+ {scheduledPostsHasError && ()} + + +
+ ); +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/non_virtualized_scheduled_post_list.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/non_virtualized_scheduled_post_list.tsx new file mode 100644 index 0000000000..7ab70a210d --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/non_virtualized_scheduled_post_list.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useRef} from 'react'; + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import DraftRow from 'components/drafts/draft_row'; + +import {useQuery} from 'utils/http_utils'; + +type Props = { + scheduledPosts: ScheduledPost[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; +} + +export default function NonVirtualizedScheduledPostList(props: Props) { + const query = useQuery(); + const targetId = query.get('target_id'); + const targetScheduledPostId = useRef(); + + return ( + <> + { + props.scheduledPosts.map((scheduledPost, index) => { + // find the first scheduled posst with the target and no error + const isInTargetChannelOrThread = scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId; + const hasError = Boolean(scheduledPost.error_code); + const scrollIntoView = !targetScheduledPostId.current && (isInTargetChannelOrThread && !hasError); + if (scrollIntoView) { + // if found, save the scheduled post's ID + targetScheduledPostId.current = scheduledPost.id; + } + + return ( + + ); + }) + } + + ); +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_error.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_error.tsx new file mode 100644 index 0000000000..58456bd6bf --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_error.tsx @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import AlertBanner from 'components/alert_banner'; + +export default function ScheduledPostError() { + return ( + + } + /> + ); +} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/style.scss b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.scss similarity index 72% rename from webapp/channels/src/components/drafts/scheduled_post_list/style.scss rename to webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.scss index c57bdbf154..91a36367ee 100644 --- a/webapp/channels/src/components/drafts/scheduled_post_list/style.scss +++ b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.scss @@ -2,17 +2,26 @@ // See LICENSE.txt for license information. .ScheduledPostList { - position: absolute; - width: 100%; height: 100%; - padding: 24px; - overflow-y: auto; + + &.nonVirtualizedScheduledPostList { + position: absolute; + overflow-y: auto; + } + + .nonVirtualizedScheduledPostRow { + margin: 0 24px 16px 24px; + + &.firstRow { + margin-top: 24px; + } + } .scheduledPostListErrorIndicator { display: flex; flex-direction: row; align-items: center; - margin-bottom: 16px; + margin: 24px 24px 16px 24px; font-weight: bold; padding-block: 10px; } @@ -35,6 +44,4 @@ .Panel.highlighted { animation: borderColorAnimation 10s; } - - } diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx deleted file mode 100644 index 24015b1676..0000000000 --- a/webapp/channels/src/components/drafts/scheduled_post_list/scheduled_post_list.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect, useRef} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import type {ScheduledPost} from '@mattermost/types/schedule_post'; -import type {UserProfile, UserStatus} from '@mattermost/types/users'; - -import {fetchMissingChannels} from 'mattermost-redux/actions/channels'; -import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts'; -import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; - -import AlertBanner from 'components/alert_banner'; -import NoScheduledPostsIllustration from 'components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration'; -import NoResultsIndicator from 'components/no_results_indicator'; - -import {useQuery} from 'utils/http_utils'; - -import type {GlobalState} from 'types/store'; - -import DraftRow from '../draft_row'; - -import './style.scss'; - -type Props = { - scheduledPosts: ScheduledPost[]; - user: UserProfile; - displayName: string; - status: UserStatus['status']; -} - -export default function ScheduledPostList({ - scheduledPosts, - user, - displayName, - status, -}: Props) { - const {formatMessage} = useIntl(); - - const currentTeamId = useSelector(getCurrentTeamId); - const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId)); - - const query = useQuery(); - const targetId = query.get('target_id'); - const targetScheduledPostId = useRef(); - - const dispatch = useDispatch(); - useEffect(() => { - dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id))); - }, [scheduledPosts]); - - return ( -
- { - scheduledPostsHasError && - - } - /> - } - - { - scheduledPosts.map((scheduledPost) => { - // find the first scheduled posst with the target and no error - const isInTargetChannelOrThread = scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId; - const hasError = Boolean(scheduledPost.error_code); - const scrollIntoView = !targetScheduledPostId.current && (isInTargetChannelOrThread && !hasError); - if (scrollIntoView) { - // if found, save the scheduled post's ID - targetScheduledPostId.current = scheduledPost.id; - } - - return ( - - ); - }) - } - - { - scheduledPosts.length === 0 && ( - - ) - } -
- ); -} diff --git a/webapp/channels/src/components/drafts/scheduled_post_list/virtualized_scheduled_post_list.tsx b/webapp/channels/src/components/drafts/scheduled_post_list/virtualized_scheduled_post_list.tsx new file mode 100644 index 0000000000..0490c734ed --- /dev/null +++ b/webapp/channels/src/components/drafts/scheduled_post_list/virtualized_scheduled_post_list.tsx @@ -0,0 +1,275 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import debounce from 'lodash/debounce'; +import type {CSSProperties} from 'react'; +import React, {useMemo, useRef, useCallback, useEffect} from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import {VariableSizeList} from 'react-window'; + +import type {ScheduledPost} from '@mattermost/types/schedule_post'; +import type {UserProfile, UserStatus} from '@mattermost/types/users'; + +import DraftRow from 'components/drafts/draft_row'; + +import {useQuery} from 'utils/http_utils'; + +const TARGET_ID_QUERY_PARAM = 'target_id'; +const OVERSCAN_ROW_COUNT = 10; // no. of rows +const ROW_HEIGHT_CHANGE_TOLERANCE = 2; // in px + +const FRAME_RATE = 60; // in Hz +const RESIZE_DEBOUNCE_TIME = Math.round(1000 / FRAME_RATE); // in ms + +type Props = { + scheduledPosts: ScheduledPost[]; + currentUser: UserProfile; + userDisplayName: string; + userStatus: UserStatus['status']; +} + +export default function ScheduledPostList(props: Props) { + const query = useQuery(); + const scheduledPostTargetId = query.get(TARGET_ID_QUERY_PARAM); + const targetScheduledPostId = useRef(); + const listRef = useRef(null); + const itemHeightCacheMap = useRef>(new Map()); + + // Function to set row height after measurement, we also reset the list after setting the height + const setRowHeight = useCallback((index: number, postId: string, size: number) => { + const currentItemHeight = itemHeightCacheMap.current.get(postId); + + // If current height is not cached or if there is a significant difference, update the cache + // This prevents layout thrashing when the height variations are small + if (!currentItemHeight || Math.abs(currentItemHeight - size) > ROW_HEIGHT_CHANGE_TOLERANCE) { + itemHeightCacheMap.current.set(postId, size); + + // Reset the list UI in response to the row height changing + if (listRef.current) { + listRef.current.resetAfterIndex(index); + } + } + }, []); + + const getItemSize = useCallback((index: number) => { + const postId = index < props.scheduledPosts.length ? props.scheduledPosts[index].id : ''; + return postId ? (itemHeightCacheMap.current.get(postId) || 0) : 0; + }, [props.scheduledPosts]); + + // Update cached sizes when list items change + useEffect(() => { + if (itemHeightCacheMap.current.size > 0) { + const updatedItemHeightCacheMap = new Map(); + + // Only keep height entries for items that still exist + for (const post of props.scheduledPosts) { + const height = itemHeightCacheMap.current.get(post.id); + if (height) { + updatedItemHeightCacheMap.set(post.id, height); + } + } + + // Replace the old height cache with the new one + itemHeightCacheMap.current = updatedItemHeightCacheMap; + } + + // Reset list UI in response to list items changing + if (listRef.current) { + listRef.current.resetAfterIndex(0); + } + }, [props.scheduledPosts]); + + // Find the target post index for scrolling + useEffect(() => { + if (!scheduledPostTargetId || !listRef.current) { + return; + } + + // Find the target post index + const targetIndex = props.scheduledPosts.findIndex((post) => { + const isInTargetChannelOrThread = post.channel_id === scheduledPostTargetId || post.root_id === scheduledPostTargetId; + const hasError = Boolean(post.error_code); + return isInTargetChannelOrThread && !hasError && !targetScheduledPostId.current; + }); + + if (targetIndex !== -1) { + targetScheduledPostId.current = props.scheduledPosts[targetIndex].id; + listRef.current.scrollToItem(targetIndex, 'center'); + } + }, [props.scheduledPosts, scheduledPostTargetId]); + + const itemData = useMemo(() => ({ + scheduledPosts: props.scheduledPosts, + userDisplayName: props.userDisplayName, + currentUser: props.currentUser, + userStatus: props.userStatus, + setRowHeight, + targetScheduledPostId: targetScheduledPostId.current, + scheduledPostTargetId, + }), [props.scheduledPosts, props.userDisplayName, props.currentUser, props.userStatus, setRowHeight, scheduledPostTargetId]); + + return ( + + {({height, width}) => ( + + {Row} + + )} + + ); +} + +interface RowProps { + index: number; + style: CSSProperties; + data: { + scheduledPosts: ScheduledPost[]; + userDisplayName: string; + currentUser: UserProfile; + userStatus: string; + setRowHeight: (index: number, postId: string, size: number) => void; + targetScheduledPostId?: string; + scheduledPostTargetId?: string | null; + }; +} + +// Row component for dynamic height measurement +function Row({index, style, data: {scheduledPosts, userDisplayName, currentUser, userStatus, setRowHeight, targetScheduledPostId, scheduledPostTargetId}}: RowProps) { + const scheduledPost = scheduledPosts[index]; + + // Reference to the DOM element we'll measure + const rowRef = useRef(null); + + // Cache the last measured height to avoid unnecessary updates + const lastMeasuredHeightRef = useRef(null); + + // These refs store the current values for use in callbacks + const indexRef = useRef(index); + const postIdRef = useRef(scheduledPost.id); + const setRowHeightRef = useRef(setRowHeight); + useEffect(() => { + indexRef.current = index; + postIdRef.current = scheduledPost.id; + setRowHeightRef.current = setRowHeight; + }, [index, scheduledPost.id, setRowHeight]); + + // Calculate if this row should scroll into view + const isInTargetChannelOrThread = scheduledPost.channel_id === scheduledPostTargetId || scheduledPost.root_id === scheduledPostTargetId; + const hasError = Boolean(scheduledPost.error_code); + const scrollIntoView = targetScheduledPostId === scheduledPost.id || (isInTargetChannelOrThread && !hasError && !targetScheduledPostId); + + // This effect performs the initial height measurement on first render + useEffect(() => { + if (!rowRef.current) { + return undefined; + } + + // Use requestAnimationFrame to measure after the browser has painted + const rafId = requestAnimationFrame(() => { + if (!rowRef.current) { + return; + } + + // Get the rendered height and enforce minimum height + const height = Math.max(rowRef.current.getBoundingClientRect().height); + lastMeasuredHeightRef.current = height; + + // Inform the virtualized list about this row's height + setRowHeight(index, scheduledPost.id, height); + }); + + return () => { + cancelAnimationFrame(rafId); + }; + }, [scheduledPost, setRowHeight, index, scheduledPost.id]); + + // This effect sets up a ResizeObserver to track height changes on the row element + useEffect(() => { + if (!rowRef.current) { + return undefined; + } + + // Flag to track whether we're still observing (prevents updates after unmount) + let isObservingResize = true; + + // Create a debounced function to update height measurements + const debouncedUpdateHeight = debounce((height: number) => { + // Skip if component was unmounted or ref is gone + if (!isObservingResize || !rowRef.current) { + return; + } + + // Skip if the height hasn't changed significantly + if (lastMeasuredHeightRef.current !== null && Math.abs((lastMeasuredHeightRef.current - height)) <= ROW_HEIGHT_CHANGE_TOLERANCE) { + return; + } + + // Update our cached height + lastMeasuredHeightRef.current = height; + + // Notify the parent list about the new height + setRowHeightRef.current( + indexRef.current, + postIdRef.current, + height, + ); + }, RESIZE_DEBOUNCE_TIME); + + // This ResizeObserver API notifies us when the element's size changes + const resizeObserver = new ResizeObserver((entries) => { + if (!isObservingResize || !rowRef.current) { + return; + } + + // Process all resize entries (typically just one) + for (const entry of entries) { + // Double-check that the entry is for our element + if (entry.target === rowRef.current) { + // Get the new content height and enforce minimum + const height = entry.borderBoxSize[0].blockSize; + + // Update height with debouncing to prevent layout thrashing + debouncedUpdateHeight(height); + } + } + }); + + // Start observing size changes on the row element + resizeObserver.observe(rowRef.current); + + return () => { + isObservingResize = false; + debouncedUpdateHeight.cancel(); + resizeObserver.disconnect(); + }; + }, []); + + return ( +
+
+ +
+
+ ); +} diff --git a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap index f0809400ee..54408bbd68 100644 --- a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap +++ b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap @@ -142,20 +142,6 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = `
-
-
-
-
-
-
+ `; diff --git a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx index cbb0a62a34..edc649392b 100644 --- a/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx +++ b/webapp/channels/src/components/threading/virtualized_thread_viewer/virtualized_thread_viewer.tsx @@ -431,7 +431,7 @@ class ThreadViewerVirtualized extends PureComponent { role='application' aria-label={Utils.localizeMessage({id: 'accessibility.sections.rhsContent', defaultMessage: 'message details complimentary region'})} className='post-right__content a11y__region' - style={{height: '100%'}} + style={{height: '100%', position: 'relative'}} data-a11y-sort-order='3' data-a11y-focus-child={true} data-a11y-order-reversed={true} diff --git a/webapp/channels/src/selectors/drafts.ts b/webapp/channels/src/selectors/drafts.ts index d89292fd3b..c559447f19 100644 --- a/webapp/channels/src/selectors/drafts.ts +++ b/webapp/channels/src/selectors/drafts.ts @@ -141,3 +141,7 @@ export function makeGetDraft() { }, ); } + +export function getDraftRemotes(state: GlobalState) { + return state.views.drafts.remotes; +} diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 1826eee776..2f14cc4a1d 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -2249,6 +2249,7 @@ export const PageLoadContext = { RECONNECT: 'reconnect', } as const; +export const DRAFT_URL_SUFFIX = 'drafts'; export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts'; export const scheduledPosts = { diff --git a/webapp/channels/src/utils/http_utils.ts b/webapp/channels/src/utils/http_utils.ts index c7d1cdcc8e..b697f89c86 100644 --- a/webapp/channels/src/utils/http_utils.ts +++ b/webapp/channels/src/utils/http_utils.ts @@ -1,8 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {useMemo} from 'react'; import {useLocation} from 'react-router-dom'; export function useQuery() { - return new URLSearchParams(useLocation().search); + const {search} = useLocation(); + + const params = useMemo(() => { + return new URLSearchParams(search); + }, [search]); + + return params; } diff --git a/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx index 27aa17e91e..ee8bcc217e 100644 --- a/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx +++ b/webapp/channels/src/utils/performance_telemetry/element_identification.test.tsx @@ -3,7 +3,7 @@ import {createMemoryHistory} from 'history'; import React from 'react'; -import type {AutoSizerProps} from 'react-virtualized-auto-sizer'; +import type {Props as AutoSizerProps} from 'react-virtualized-auto-sizer'; import {Permissions} from 'mattermost-redux/constants'; @@ -14,7 +14,7 @@ import {TestHelper} from 'utils/test_helper'; import {identifyElementRegion} from './element_identification'; -jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100})); +jest.mock('react-virtualized-auto-sizer', () => (props: AutoSizerProps) => props.children({height: 100, width: 100, scaledHeight: 100, scaledWidth: 100})); describe('identifyElementRegion', () => { test('should be able to identify various elements in the app', async () => { diff --git a/webapp/package-lock.json b/webapp/package-lock.json index a009dc3568..1b7b4f4071 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -128,9 +128,9 @@ "react-router-dom": "5.3.4", "react-select": "5.9.0", "react-transition-group": "4.4.5", - "react-virtualized-auto-sizer": "1.0.7", - "react-window": "1.8.8", - "react-window-infinite-loader": "1.0.8", + "react-virtualized-auto-sizer": "1.0.25", + "react-window": "1.8.11", + "react-window-infinite-loader": "1.0.10", "rebound": "0.1.0", "redux": "4.2.0", "redux-batched-actions": "0.5.0", @@ -515,6 +515,48 @@ } } }, + "channels/node_modules/react-virtualized-auto-sizer": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.25.tgz", + "integrity": "sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "channels/node_modules/react-window": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "channels/node_modules/react-window-infinite-loader": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.10.tgz", + "integrity": "sha512-NO/csdHlxjWqA2RJZfzQgagAjGHspbO2ik9GtWZb0BY1Nnapq0auG8ErI+OhGCzpjYJsCYerqUlK6hkq9dfAAA==", + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "channels/node_modules/react-window/node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "channels/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -23459,51 +23501,6 @@ "tslib": "*" } }, - "node_modules/react-virtualized-auto-sizer": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.7.tgz", - "integrity": "sha512-Mxi6lwOmjwIjC1X4gABXMJcKHsOo0xWl3E3ugOgufB8GJU+MqrtY35aBuvCYv/razQ1Vbp7h1gWJjGjoNN5pmA==", - "engines": { - "node": ">8.0.0" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0-rc" - } - }, - "node_modules/react-window": { - "version": "1.8.8", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.8.tgz", - "integrity": "sha512-D4IiBeRtGXziZ1n0XklnFGu7h9gU684zepqyKzgPNzrsrk7xOCxni+TCckjg2Nr/DiaEEGVVmnhYSlT2rB47dQ==", - "dependencies": { - "@babel/runtime": "^7.0.0", - "memoize-one": ">=3.1.1 <6" - }, - "engines": { - "node": ">8.0.0" - }, - "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-window-infinite-loader": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/react-window-infinite-loader/-/react-window-infinite-loader-1.0.8.tgz", - "integrity": "sha512-907ZLAiZZfBHuZyiY0V7uiSL4P/rI6UQyCF9wES1cDWTeyNLgGLaxu+BZkcUW3R5tSCQcbCcWBl0jVIpYzrKGQ==", - "engines": { - "node": ">8.0.0" - }, - "peerDependencies": { - "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0", - "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0" - } - }, - "node_modules/react-window/node_modules/memoize-one": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", - "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" - }, "node_modules/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",