[MM-63685] Virtualize drafts in the drafts list (#30563)

Этот коммит содержится в:
M-ZubairAhmed
2025-04-23 11:38:04 +05:30
коммит произвёл GitHub
родитель 282f0769e5
Коммит e9c54bf88e
39 изменённых файлов: 1207 добавлений и 652 удалений

Просмотреть файл

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

Просмотреть файл

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

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 68 KiB

После

Ширина:  |  Высота:  |  Размер: 67 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 107 KiB

После

Ширина:  |  Высота:  |  Размер: 106 KiB

Двоичный файл не отображается.

До

Ширина:  |  Высота:  |  Размер: 86 KiB

После

Ширина:  |  Высота:  |  Размер: 66 KiB

Просмотреть файл

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

Просмотреть файл

@@ -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', () => {

Просмотреть файл

@@ -1,85 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/drafts/drafts should match snapshot 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(Drafts)
displayName="display_name"
draftRemotes={Object {}}
drafts={Array []}
status={Object {}}
user={Object {}}
/>
</ContextProvider>
`;
exports[`components/drafts/drafts should match snapshot for local drafts disabled 1`] = `
<ContextProvider
value={
Object {
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"subscription": Subscription {
"handleChangeWrapper": [Function],
"listeners": Object {
"notify": [Function],
},
"onStateChange": [Function],
"parentSub": undefined,
"store": Object {
"clearActions": [Function],
"dispatch": [Function],
"getActions": [Function],
"getState": [Function],
"replaceReducer": [Function],
"subscribe": [Function],
},
"unsubscribe": null,
},
}
}
>
<Memo(Drafts)
displayName="display_name"
draftRemotes={Object {}}
drafts={Array []}
status={Object {}}
user={Object {}}
/>
</ContextProvider>
`;

Просмотреть файл

@@ -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<string, boolean>;
status: UserStatus['status'];
className?: string;
}
export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) {
const {formatMessage} = useIntl();
return (
<div className={classNames('DraftList Drafts__main', className)}>
{drafts.map((d) => (
<DraftRow
key={d.key}
displayName={displayName}
item={d.value}
isRemote={draftRemotes?.[d.key]}
user={user}
status={status}
/>
))}
{drafts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={DraftsIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
)}
</div>
);
}

Просмотреть файл

@@ -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 (
<div className='DraftList Drafts__main'>
<NoResultsIndicator
expanded={true}
iconGraphic={EmptyDraftListIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
</div>
);
}

Просмотреть файл

@@ -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 (
<div
data-testid='draft-row'
className='draft-row-mock'
>
{props.item.message}
</div>
);
};
});
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(
<DraftList
drafts={[]}
currentUser={currentUser}
userDisplayName='User One'
userStatus='online'
/>,
initialState,
);
expect(screen.getByText('No drafts at the moment')).toBeInTheDocument();
});
test('should handle undefined drafts', () => {
renderWithContext(
<DraftList
drafts={[]}
currentUser={currentUser}
userDisplayName='User One'
userStatus='online'
/>,
initialState,
);
expect(screen.getByText('No drafts at the moment')).toBeInTheDocument();
});
test('should render virtualized draft list when drafts are provided', () => {
renderWithContext(
<DraftList
drafts={mockDrafts}
currentUser={currentUser}
userDisplayName='User One'
userStatus='online'
/>,
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();
});
});

Просмотреть файл

@@ -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 <EmptyDraftList/>;
}
return (
<VirtualizedDraftList
drafts={props.drafts}
currentUser={props.currentUser}
userDisplayName={props.userDisplayName}
userStatus={props.userStatus}
draftRemotes={draftRemotes}
/>
);
}

Просмотреть файл

@@ -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<string, boolean>;
}
export default function VirtualizedDraftList(props: Props) {
const listRef = useRef<VariableSizeList>(null);
const itemHeightCacheMap = useRef<Map<string, number>>(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 (
<div className='DraftList Drafts__main'>
<AutoSizer>
{({height, width}) => (
<VariableSizeList
ref={listRef}
height={height}
width={width}
itemCount={props.drafts.length}
itemSize={getItemSize}
itemData={itemData}
overscanCount={OVERSCAN_ROW_COUNT}
>
{Row}
</VariableSizeList>
)}
</AutoSizer>
</div>
);
}
interface RowProps {
index: number;
style: CSSProperties;
data: {
drafts: Draft[];
userDisplayName: string;
draftRemotes: Record<string, boolean>;
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<HTMLDivElement>(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 (
<div style={style}> {/* To avoid interference with virtualized list styles we are not using this div for height measurement */}
<div
ref={rowRef}
className={classNames('virtualizedVariableListRowWrapper', {
firstRow: index === 0,
})}
>
<DraftRow
key={draft.key}
item={draft.value}
displayName={userDisplayName}
user={currentUser}
status={userStatus}
isRemote={draftRemotes?.[draft.key]}
/>
</div>
</div>
);
}
const Row = memo(RowComponent);

Просмотреть файл

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

Просмотреть файл

@@ -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}) => (
<>

Просмотреть файл

@@ -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(
<Provider store={store}>
<Drafts
{...baseProps}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
it('should match snapshot for local drafts disabled', () => {
const store = mockStore();
const props = {
...baseProps,
};
const wrapper = shallow(
<Provider store={store}>
<Drafts
{...props}
/>
</Provider>,
);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -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<string, boolean>;
}
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 (
<div className='drafts_tab_title'>
<FormattedMessage
id='schedule_post.tab.heading'
defaultMessage='Scheduled'
/>
{
scheduledPosts?.length > 0 &&
<Badge
className='badge'
badgeContent={scheduledPosts.length}
/>
}
</div>
);
}, [scheduledPosts?.length]);
const draftTabHeading = useMemo(() => {
return (
<div className='drafts_tab_title'>
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
{
drafts.length > 0 &&
<Badge
className='badge'
badgeContent={drafts.length}
/>
}
</div>
);
}, [drafts?.length]);
const heading = useMemo(() => {
return (
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
);
}, []);
const subtitle = useMemo(() => {
return (
<FormattedMessage
id={'drafts.subtitle'}
defaultMessage={'Any messages you\'ve started will show here'}
/>
);
}, []);
const activeTab = isDraftsTab ? 0 : 1;
return (
<div
id='app-content'
className='Drafts app__content'
>
<Header
level={2}
className='Drafts__header'
heading={heading}
subtitle={subtitle}
/>
{
isScheduledPostEnabled &&
<Tabs
id='draft_tabs'
activeKey={activeTab}
mountOnEnter={true}
unmountOnExit={false}
onSelect={handleSwitchTabs}
>
<Tab
eventKey={0}
title={draftTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
tabIndex={0}
>
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
</Tab>
<Tab
eventKey={1}
title={scheduledPostsTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
>
<ScheduledPostList
scheduledPosts={scheduledPosts || EMPTY_LIST}
user={user}
displayName={displayName}
status={status}
/>
</Tab>
</Tabs>
}
{
!isScheduledPostEnabled &&
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
}
</div>
);
}
export default memo(Drafts);

Просмотреть файл

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

Просмотреть файл

@@ -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 (
<div
id='app-content'
className='Drafts app__content'
>
<Header
level={2}
className='Drafts__header'
heading={
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
}
subtitle={
<FormattedMessage
id='drafts.subtitle'
defaultMessage="Any messages you've started will show here"
/>
}
/>
{props.children}
</div>
);
}

Просмотреть файл

@@ -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 (
<div className='drafts_tab_title'>
<FormattedMessage
id='schedule_post.tab.heading'
defaultMessage='Scheduled'
/>
{scheduledPosts?.length > 0 && (
<Badge
className='badge'
badgeContent={scheduledPosts.length}
/>
)}
</div>
);
}, [scheduledPosts?.length]);
const draftTabHeading = useMemo(() => {
return (
<div className='drafts_tab_title'>
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
{props.drafts.length > 0 && (
<Badge
className='badge'
badgeContent={props.drafts.length}
/>
)}
</div>
);
}, [props.drafts.length]);
const activeTab = useMemo(() => {
if (isDraftsTab) {
return TAB_KEYS.DRAFTS;
} else if (isScheduledPostsTab) {
return TAB_KEYS.SCHEDULED_POSTS;
}
return '';
}, [isDraftsTab, isScheduledPostsTab]);
return (
<Tabs
id='draft_tabs'
activeKey={activeTab}
mountOnEnter={true}
unmountOnExit={true}
onSelect={handleSwitchTabs}
>
<Tab
eventKey={TAB_KEYS.DRAFTS}
title={draftTabHeading}
unmountOnExit={true}
tabClassName='drafts_tab'
tabIndex={0}
>
<DraftList
drafts={props.drafts}
currentUser={props.currentUser}
userDisplayName={props.userDisplayName}
userStatus={props.userStatus}
/>
</Tab>
<Tab
eventKey={TAB_KEYS.SCHEDULED_POSTS}
title={scheduledPostsTabHeading}
unmountOnExit={true}
tabClassName='drafts_tab'
>
<ScheduledPostList
scheduledPosts={scheduledPosts || EMPTY_SCHEDULED_POSTS}
currentUser={props.currentUser}
userDisplayName={props.userDisplayName}
userStatus={props.userStatus}
/>
</Tab>
</Tabs>
);
}

Просмотреть файл

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

Просмотреть файл

@@ -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 (
<DraftsAndSchedulePostsPageHeader>
<DraftsAndSchedulePostsTabs
drafts={drafts || EMPTY_DRAFTS}
currentUser={currentUser}
userDisplayName={userDisplayName}
userStatus={userStatus}
/>
</DraftsAndSchedulePostsPageHeader>
);
}
return (
<DraftsAndSchedulePostsPageHeader>
<DraftList
drafts={drafts || EMPTY_DRAFTS}
currentUser={currentUser}
userDisplayName={userDisplayName}
userStatus={userStatus}
/>
</DraftsAndSchedulePostsPageHeader>
);
}
export default memo(Drafts);

Просмотреть файл

@@ -14,6 +14,8 @@ type Props = {
hasError: boolean;
innerRef?: React.Ref<HTMLElement>;
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}

Просмотреть файл

@@ -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 (
<NoResultsIndicator
expanded={true}
iconGraphic={NoScheduledPostsIllustration}
title={formatMessage({
id: 'Schedule_post.empty_state.title',
defaultMessage: 'No scheduled drafts at the moment',
})}
subtitle={formatMessage({
id: 'Schedule_post.empty_state.subtitle',
defaultMessage:
'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
})}
/>
);
}

Просмотреть файл

@@ -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 (
<EmptyScheduledPostList/>
);
}
return (
<div className='ScheduledPostList nonVirtualizedScheduledPostList'>
{scheduledPostsHasError && (<ScheduledPostError/>)}
<NonVirtualizedScheduledPostList
scheduledPosts={props.scheduledPosts}
currentUser={props.currentUser}
userDisplayName={props.userDisplayName}
userStatus={props.userStatus}
/>
</div>
);
}

Просмотреть файл

@@ -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<string>();
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 (
<DraftRow
key={scheduledPost.id}
item={scheduledPost}
displayName={props.userDisplayName}
status={props.userStatus}
user={props.currentUser}
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
containerClassName={classNames('nonVirtualizedScheduledPostRow', {firstRow: index === 0})}
/>
);
})
}
</>
);
}

Просмотреть файл

@@ -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 (
<AlertBanner
mode='danger'
className='scheduledPostListErrorIndicator'
message={
<FormattedMessage
id='scheduled_post.panel.error_indicator.message'
defaultMessage='One of your scheduled drafts cannot be sent.'
/>
}
/>
);
}

Просмотреть файл

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

Просмотреть файл

@@ -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<string>();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id)));
}, [scheduledPosts]);
return (
<div className='ScheduledPostList'>
{
scheduledPostsHasError &&
<AlertBanner
mode='danger'
className='scheduledPostListErrorIndicator'
message={
<FormattedMessage
id='scheduled_post.panel.error_indicator.message'
defaultMessage='One of your scheduled drafts cannot be sent.'
/>
}
/>
}
{
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 (
<DraftRow
key={scheduledPost.id}
item={scheduledPost}
displayName={displayName}
status={status}
user={user}
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
/>
);
})
}
{
scheduledPosts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={NoScheduledPostsIllustration}
title={formatMessage({
id: 'Schedule_post.empty_state.title',
defaultMessage: 'No scheduled drafts at the moment',
})}
subtitle={formatMessage({
id: 'Schedule_post.empty_state.subtitle',
defaultMessage: 'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
})}
/>
)
}
</div>
);
}

Просмотреть файл

@@ -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<string>();
const listRef = useRef<VariableSizeList>(null);
const itemHeightCacheMap = useRef<Map<string, number>>(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<string, number>();
// 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 (
<AutoSizer>
{({height, width}) => (
<VariableSizeList
ref={listRef}
height={height}
width={width}
itemCount={props.scheduledPosts.length}
itemSize={getItemSize}
itemData={itemData}
overscanCount={OVERSCAN_ROW_COUNT}
>
{Row}
</VariableSizeList>
)}
</AutoSizer>
);
}
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<HTMLDivElement>(null);
// Cache the last measured height to avoid unnecessary updates
const lastMeasuredHeightRef = useRef<number | null>(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 (
<div style={style}>
<div
ref={rowRef}
className={classNames('virtualizedVariableListRowWrapper', {
firstRow: index === 0,
})}
>
<DraftRow
key={scheduledPost.id}
item={scheduledPost}
displayName={userDisplayName}
user={currentUser}
status={userStatus}
scrollIntoView={scrollIntoView}
/>
</div>
</div>
);
}

Просмотреть файл

@@ -142,20 +142,6 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = `
<div
style="overflow: visible; height: 0px; width: 0px;"
/>
<div
class="resize-triggers"
>
<div
class="expand-trigger"
>
<div
style="width: 1px; height: 1px;"
/>
</div>
<div
class="contract-trigger"
/>
</div>
</div>
</div>
<div

Просмотреть файл

@@ -1,12 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/threading/global_threads/thread_list/virtualized_thread_list should match snapshot 1`] = `
<AutoSizer
disableHeight={false}
disableWidth={false}
onResize={[Function]}
style={Object {}}
>
<AutoSizer>
<Component />
</AutoSizer>
`;

Просмотреть файл

@@ -431,7 +431,7 @@ class ThreadViewerVirtualized extends PureComponent<Props, State> {
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}

Просмотреть файл

@@ -141,3 +141,7 @@ export function makeGetDraft() {
},
);
}
export function getDraftRemotes(state: GlobalState) {
return state.views.drafts.remotes;
}

Просмотреть файл

@@ -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 = {

Просмотреть файл

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

Просмотреть файл

@@ -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 () => {

93
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",