This commit is contained in:
Sven Hüster 2026-05-25 05:44:06 +02:00 committed by GitHub
commit 177959a3f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1118 additions and 199 deletions

View file

@ -9,8 +9,10 @@ export default class ScheduleMessageMenu {
readonly tomorrowMenuItem;
readonly mondayMenuItem;
readonly nextMondayMenuItem;
readonly theirMorningMenuItem;
readonly recentlyUsedCustomTimeMenuItem;
readonly customTimeMenuItem;
readonly dmHeader;
constructor(container: Locator) {
this.container = container;
@ -18,8 +20,10 @@ export default class ScheduleMessageMenu {
this.tomorrowMenuItem = container.getByTestId('scheduling_time_tomorrow_9_am');
this.mondayMenuItem = container.getByTestId('scheduling_time_monday_9_am');
this.nextMondayMenuItem = container.getByTestId('scheduling_time_next_monday_9_am');
this.theirMorningMenuItem = container.getByTestId('scheduling_time_their_morning');
this.recentlyUsedCustomTimeMenuItem = container.getByTestId('recently_used_custom_time');
this.customTimeMenuItem = container.getByText('Choose a custom time');
this.customTimeMenuItem = container.getByText(/Choose a custom time/);
this.dmHeader = container.getByText(/Schedule for/);
}
async toBeVisible() {

View file

@ -11,6 +11,10 @@ export default class ScheduleMessageModal {
readonly closeButton: Locator;
readonly scheduleButton: Locator;
readonly cancelButton: Locator;
readonly perspectiveToggle: Locator;
readonly myTimeToggle: Locator;
readonly recipientTimeToggle: Locator;
readonly dualTimePreview: Locator;
constructor(container: Locator) {
this.container = container;
@ -20,6 +24,10 @@ export default class ScheduleMessageModal {
this.closeButton = container.getByRole('button', {name: 'Close'});
this.scheduleButton = container.locator('button:has-text("Schedule")');
this.cancelButton = container.locator('button:has-text("Cancel")');
this.perspectiveToggle = container.locator('.SchedulePerspectiveToggle');
this.myTimeToggle = container.getByRole('radio', {name: 'My time'});
this.recipientTimeToggle = container.getByRole('radio', {name: /time$/});
this.dualTimePreview = container.locator('.ScheduleDualTimePreview');
}
async toBeVisible() {

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import moment from 'moment-timezone';
import {
formatTimezoneOffsetShort,
getTheirMorningTimestamp,
hasRecipientTimezone,
reinterpretWallClock,
} from './schedule_message_dm_utils';
describe('schedule_message_dm_utils', () => {
describe('hasRecipientTimezone', () => {
it('returns false when timezone is missing', () => {
expect(hasRecipientTimezone({} as never)).toBe(false);
expect(hasRecipientTimezone(undefined)).toBe(false);
});
it('returns true when timezone is set', () => {
expect(hasRecipientTimezone({
timezone: {
useAutomaticTimezone: 'true',
automaticTimezone: 'America/New_York',
manualTimezone: '',
},
} as never)).toBe(true);
});
});
describe('getTheirMorningTimestamp', () => {
const tz = 'America/New_York';
it('returns today at 9am on weekday before 9am', () => {
const now = DateTime.fromObject({year: 2025, month: 5, day: 21, hour: 8}, {zone: tz});
const result = DateTime.fromMillis(getTheirMorningTimestamp(tz, now)).setZone(tz);
expect(result.toFormat('yyyy-MM-dd HH:mm')).toBe('2025-05-21 09:00');
});
it('returns next weekday at 9am after 9am on weekday', () => {
const now = DateTime.fromObject({year: 2025, month: 5, day: 21, hour: 10}, {zone: tz});
const result = DateTime.fromMillis(getTheirMorningTimestamp(tz, now)).setZone(tz);
expect(result.toFormat('yyyy-MM-dd HH:mm')).toBe('2025-05-22 09:00');
});
it('skips weekend from Friday evening', () => {
const now = DateTime.fromObject({year: 2025, month: 5, day: 23, hour: 18}, {zone: tz});
const result = DateTime.fromMillis(getTheirMorningTimestamp(tz, now)).setZone(tz);
expect(result.weekday).toBe(1);
expect(result.toFormat('yyyy-MM-dd HH:mm')).toBe('2025-05-26 09:00');
});
it('returns Monday 9am from Saturday', () => {
const now = DateTime.fromObject({year: 2025, month: 5, day: 24, hour: 12}, {zone: tz});
const result = DateTime.fromMillis(getTheirMorningTimestamp(tz, now)).setZone(tz);
expect(result.toFormat('yyyy-MM-dd HH:mm')).toBe('2025-05-26 09:00');
});
});
describe('reinterpretWallClock', () => {
it('preserves wall clock values when changing timezone', () => {
const original = moment.tz('2025-05-22 09:00', 'America/New_York');
const reinterpreted = reinterpretWallClock(original, 'Europe/London');
expect(reinterpreted.format('YYYY-MM-DD HH:mm')).toBe('2025-05-22 09:00');
expect(reinterpreted.tz()).toBe('Europe/London');
});
});
describe('formatTimezoneOffsetShort', () => {
it('formats whole-hour offsets', () => {
const at = moment.tz('2025-01-15 12:00', 'Europe/London');
expect(formatTimezoneOffsetShort('Europe/London', at)).toMatch(/^UTC[+-]\d+$/);
});
});
});

View file

@ -0,0 +1,112 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import type {Moment} from 'moment-timezone';
import moment from 'moment-timezone';
import type {UserProfile, UserTimezone} from '@mattermost/types/users';
import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels';
import {generateCurrentTimezoneLabel} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
import type {GlobalState} from 'types/store';
export type SchedulePerspective = 'mine' | 'theirs';
export function hasRecipientTimezone(teammate?: UserProfile): boolean {
return Boolean(teammate?.timezone);
}
export function getRecipientTimezoneString(teammateTimezone: UserTimezone): string {
return getUserCurrentTimezone(teammateTimezone);
}
export function getTheirMorningTimestamp(recipientTz: string, now?: DateTime): number {
const nowTheir = (now || DateTime.now()).setZone(recipientTz);
const isWeekday = nowTheir.weekday >= 1 && nowTheir.weekday <= 5;
if (isWeekday && nowTheir.hour < 9) {
return nowTheir.set({hour: 9, minute: 0, second: 0, millisecond: 0}).toMillis();
}
let candidate = nowTheir.plus({days: 1}).startOf('day');
while (candidate.weekday < 1 || candidate.weekday > 5) {
candidate = candidate.plus({days: 1});
}
return candidate.set({hour: 9, minute: 0, second: 0, millisecond: 0}).toMillis();
}
export function getRecipientLocationLabel(teammate: UserProfile | undefined, recipientTz: string): string {
const position = teammate?.position?.trim();
if (position) {
return position;
}
return generateCurrentTimezoneLabel(recipientTz);
}
export function formatTimezoneOffsetShort(timezone: string, at?: Moment): string {
const m = at ? moment(at).tz(timezone) : moment.tz(timezone);
const offsetMinutes = m.utcOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const absMinutes = Math.abs(offsetMinutes);
const hours = Math.floor(absMinutes / 60);
const minutes = absMinutes % 60;
if (minutes === 0) {
return `UTC${sign}${hours}`;
}
const paddedMinutes = String(minutes).padStart(2, '0');
return `UTC${sign}${hours}:${paddedMinutes}`;
}
export function reinterpretWallClock(dateTime: Moment, newTimezone: string): Moment {
return moment.tz({
year: dateTime.year(),
month: dateTime.month(),
date: dateTime.date(),
hour: dateTime.hour(),
minute: dateTime.minute(),
second: 0,
millisecond: 0,
}, newTimezone);
}
export function isDmScheduleRedesign(state: GlobalState, channelId: string): boolean {
const channel = getDirectChannel(state, channelId);
if (!channel?.teammate_id) {
return false;
}
const currentUserId = getCurrentUserId(state);
const teammate = getUser(state, channel.teammate_id);
if (!teammate || teammate.is_bot) {
return false;
}
if (channel.teammate_id === currentUserId) {
return false;
}
return hasRecipientTimezone(teammate);
}
export function getDefaultScheduleDateTime(
perspective: SchedulePerspective,
senderTimezone: string,
recipientTimezone: string,
): Moment {
const activeTimezone = perspective === 'theirs' ? recipientTimezone : senderTimezone;
return moment.tz(activeTimezone).add(1, 'days').set({
hour: 9,
minute: 0,
second: 0,
millisecond: 0,
});
}

View file

@ -0,0 +1,45 @@
.ScheduleDualTimePreview {
padding: 12px 16px;
border-radius: var(--radius-s);
margin-top: 16px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
&__row {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
gap: 12px;
line-height: 20px;
& + & {
margin-top: 8px;
}
&.primary {
color: var(--center-channel-color);
font-weight: 600;
.ScheduleDualTimePreview__value {
font-weight: 600;
}
}
&.secondary {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-weight: 400;
}
}
&__label {
flex-shrink: 0;
}
&__value {
text-align: right;
}
&__offset {
font-weight: 400;
}
}

View file

@ -0,0 +1,105 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import type {Moment} from 'moment-timezone';
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import type {SchedulePerspective} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import {formatTimezoneOffsetShort} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import Timestamp, {RelativeRanges} from 'components/timestamp';
import './schedule_dual_time_preview.scss';
type Props = {
selectedDateTime: Moment;
perspective: SchedulePerspective;
recipientName: string;
senderTimezone: string;
recipientTimezone: string;
showRecipientLine?: boolean;
}
const DATE_RANGES = [
RelativeRanges.TODAY_TITLE_CASE,
RelativeRanges.TOMORROW_TITLE_CASE,
];
function PreviewTime({value, timeZone}: {value: number; timeZone: string}) {
return (
<Timestamp
ranges={DATE_RANGES}
value={value}
timeZone={timeZone}
useTime={{
hour: 'numeric',
minute: 'numeric',
}}
/>
);
}
export default function ScheduleDualTimePreview({
selectedDateTime,
perspective,
recipientName,
senderTimezone,
recipientTimezone,
showRecipientLine = true,
}: Props) {
const scheduledAt = selectedDateTime.valueOf();
const senderOffset = useMemo(
() => formatTimezoneOffsetShort(senderTimezone, selectedDateTime),
[senderTimezone, selectedDateTime],
);
const recipientPrimary = perspective === 'theirs';
const senderPrimary = perspective === 'mine';
return (
<div
className='ScheduleDualTimePreview'
aria-live='polite'
>
{showRecipientLine && (
<div className={classNames('ScheduleDualTimePreview__row', {primary: recipientPrimary, secondary: !recipientPrimary})}>
<span className='ScheduleDualTimePreview__label'>
<FormattedMessage
id='schedule_post.custom_time_modal.preview.recipient_receives'
defaultMessage='{recipientName} receives at'
values={{recipientName}}
/>
</span>
<span className='ScheduleDualTimePreview__value'>
<PreviewTime
value={scheduledAt}
timeZone={recipientTimezone}
/>
</span>
</div>
)}
<div className={classNames('ScheduleDualTimePreview__row', {primary: senderPrimary, secondary: !senderPrimary})}>
<span className='ScheduleDualTimePreview__label'>
<FormattedMessage
id='schedule_post.custom_time_modal.preview.you_send'
defaultMessage='You send at'
/>
</span>
<span className='ScheduleDualTimePreview__value'>
<PreviewTime
value={scheduledAt}
timeZone={senderTimezone}
/>
{' '}
<span className='ScheduleDualTimePreview__offset'>
{'('}
{senderOffset}
{')'}
</span>
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,34 @@
.SchedulePerspectiveToggle {
display: flex;
width: fit-content;
align-items: center;
padding: 3px;
border: var(--border-default);
border-radius: var(--radius-m);
margin-bottom: 16px;
background-color: rgba(var(--center-channel-color-rgb), 0.04);
gap: 3px;
&__option {
display: flex;
padding: 6px 12px;
border: none;
border-radius: 4px;
background-color: transparent;
color: rgba(var(--center-channel-color-rgb), 0.75);
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 18px;
&[data-selected='true'] {
background-color: var(--center-channel-bg);
box-shadow: var(--elevation-1);
color: var(--center-channel-color);
}
&:hover {
color: var(--center-channel-color);
}
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import SchedulePerspectiveToggle from './schedule_perspective_toggle';
describe('SchedulePerspectiveToggle', () => {
it('renders radiogroup with both options', () => {
renderWithContext(
<SchedulePerspectiveToggle
perspective='theirs'
recipientFirstName='Sarah'
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
expect(screen.getByRole('radio', {name: 'My time'})).toBeInTheDocument();
expect(screen.getByRole('radio', {name: "Sarah's time"})).toHaveAttribute('aria-checked', 'true');
});
it('calls onChange when My time is selected', async () => {
const onChange = jest.fn();
renderWithContext(
<SchedulePerspectiveToggle
perspective='theirs'
recipientFirstName='Sarah'
onChange={onChange}
/>,
);
await userEvent.click(screen.getByRole('radio', {name: 'My time'}));
expect(onChange).toHaveBeenCalledWith('mine');
});
});

View file

@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import type {SchedulePerspective} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import './schedule_perspective_toggle.scss';
type Props = {
perspective: SchedulePerspective;
recipientFirstName: string;
onChange: (perspective: SchedulePerspective) => void;
}
export default function SchedulePerspectiveToggle({perspective, recipientFirstName, onChange}: Props) {
const handleMineClick = useCallback(() => onChange('mine'), [onChange]);
const handleTheirsClick = useCallback(() => onChange('theirs'), [onChange]);
return (
<div
className='SchedulePerspectiveToggle'
role='radiogroup'
aria-label='Schedule time perspective'
>
<button
type='button'
className='SchedulePerspectiveToggle__option'
role='radio'
aria-checked={perspective === 'mine'}
data-selected={perspective === 'mine'}
onClick={handleMineClick}
>
<FormattedMessage
id='schedule_post.custom_time_modal.perspective.mine'
defaultMessage='My time'
/>
</button>
<button
type='button'
className='SchedulePerspectiveToggle__option'
role='radio'
aria-checked={perspective === 'theirs'}
data-selected={perspective === 'theirs'}
onClick={handleTheirsClick}
>
<FormattedMessage
id='schedule_post.custom_time_modal.perspective.theirs'
defaultMessage="{name}'s time"
values={{name: recipientFirstName}}
/>
</button>
</div>
);
}

View file

@ -12,13 +12,26 @@ import {testingEnabled} from 'mattermost-redux/selectors/entities/general';
import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {
getDefaultScheduleDateTime,
isDmScheduleRedesign,
reinterpretWallClock,
type SchedulePerspective,
} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import {
DMUserTimezone,
} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone';
import ScheduleDualTimePreview from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview';
import SchedulePerspectiveToggle from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
import {scheduledPosts} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './scheduled_post_dm_custom_time_modal.scss';
const SCHEDULED_POST_CUSTOM_TIME_INTERVAL = 15; // minutes
type Props = {
@ -32,19 +45,53 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState<string>();
const userTimezone = useSelector(getCurrentTimezone);
const now = moment().tz(userTimezone);
const currentUserId = useSelector(getCurrentUserId);
const dispatch = useDispatch();
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
const {
teammateDisplayName,
teammateFirstName,
recipientTimezoneString,
} = useTimePostBoxIndicator(channelId);
const [perspective, setPerspective] = useState<SchedulePerspective>('theirs');
const activeTimezone = useMemo(() => {
if (!isDmRedesign) {
return userTimezone;
}
return perspective === 'theirs' ? recipientTimezoneString : userTimezone;
}, [isDmRedesign, perspective, recipientTimezoneString, userTimezone]);
const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => {
if (initialTime) {
return initialTime;
}
return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0});
if (isDmRedesign) {
return getDefaultScheduleDateTime('theirs', userTimezone, recipientTimezoneString);
}
return moment().tz(userTimezone).add(1, 'days').set({
hour: 9,
minute: 0,
second: 0,
millisecond: 0,
});
});
const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]);
const handlePerspectiveChange = useCallback((newPerspective: SchedulePerspective) => {
if (newPerspective === perspective) {
return;
}
const newTimezone = newPerspective === 'theirs' ? recipientTimezoneString : userTimezone;
setSelectedDateTime((current) => reinterpretWallClock(current, newTimezone));
setPerspective(newPerspective);
}, [perspective, recipientTimezoneString, userTimezone]);
const handleOnConfirm = useCallback(async (dateTime: Moment) => {
const selectedTime = dateTime.valueOf();
const response = await onConfirm(selectedTime);
@ -66,9 +113,13 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
} else {
onExited();
}
}, [onConfirm, onExited]);
}, [currentUserId, dispatch, onConfirm, onExited, userTimezone]);
const bodySuffix = useMemo(() => {
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
const timePickerInterval = useSelector(testingEnabled) ? 1 : SCHEDULED_POST_CUSTOM_TIME_INTERVAL;
const legacyBodySuffix = useMemo(() => {
return (
<DMUserTimezone
channelId={channelId}
@ -77,9 +128,68 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
);
}, [channelId, selectedDateTime]);
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
if (isDmRedesign) {
const bodyPrefix = (
<SchedulePerspectiveToggle
perspective={perspective}
recipientFirstName={teammateFirstName}
onChange={handlePerspectiveChange}
/>
);
const timePickerInterval = useSelector(testingEnabled) ? 1 : SCHEDULED_POST_CUSTOM_TIME_INTERVAL;
const bodySuffix = (
<ScheduleDualTimePreview
selectedDateTime={selectedDateTime}
perspective={perspective}
recipientName={teammateDisplayName}
senderTimezone={userTimezone}
recipientTimezone={recipientTimezoneString}
/>
);
return (
<DateTimePickerModal
className='scheduled_post_custom_time_modal scheduled_post_dm_custom_time_modal'
initialTime={selectedDateTime}
header={
<FormattedMessage
id='schedule_post.custom_time_modal.title'
defaultMessage='Schedule message'
/>
}
subheading={
<FormattedMessage
id='schedule_post.custom_time_modal.dm_subtitle'
defaultMessage='to {recipientName}'
values={{recipientName: teammateDisplayName}}
/>
}
confirmButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.confirm_button_text'
defaultMessage='Schedule'
/>
}
cancelButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.cancel_button_text'
defaultMessage='Cancel'
/>
}
ariaLabel={label}
onExited={onExited}
onConfirm={handleOnConfirm}
onChange={setSelectedDateTime}
bodyPrefix={bodyPrefix}
bodySuffix={bodySuffix}
relativeDate={true}
onCancel={onExited}
errorText={errorMessage}
timePickerInterval={timePickerInterval}
timezone={activeTimezone}
/>
);
}
return (
<DateTimePickerModal
@ -108,7 +218,7 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
onExited={onExited}
onConfirm={handleOnConfirm}
onChange={setSelectedDateTime}
bodySuffix={bodySuffix}
bodySuffix={legacyBodySuffix}
relativeDate={true}
onCancel={onExited}
errorText={errorMessage}

View file

@ -0,0 +1,6 @@
.scheduled_post_dm_custom_time_modal {
.modal-subheader {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 13px;
}
}

View file

@ -4,6 +4,7 @@
import {DateTime} from 'luxon';
import React from 'react';
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
@ -11,8 +12,13 @@ import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import CoreMenuOptions from './core_menu_options';
jest.mock('components/advanced_text_editor/send_button/schedule_message_dm_utils', () => ({
isDmScheduleRedesign: jest.fn(),
}));
jest.mock('components/advanced_text_editor/use_post_box_indicator');
const mockedUseTimePostBoxIndicator = jest.mocked(useTimePostBoxIndicator);
const mockedIsDmScheduleRedesign = jest.mocked(isDmScheduleRedesign);
const teammateDisplayName = 'John Doe';
const userCurrentTimezone = 'America/New_York';
@ -24,8 +30,13 @@ const teammateTimezone = {
const defaultUseTimePostBoxIndicatorReturnValue = {
userCurrentTimezone: 'America/New_York',
teammateTimezone,
recipientTimezoneString: 'Europe/London',
teammateDisplayName,
teammateFirstName: 'John',
teammate: undefined,
isDM: false,
isSelfDM: false,
isBot: false,
showRemoteUserHour: false,
currentUserTimesStamp: 0,
isScheduledPostEnabled: false,
@ -49,12 +60,8 @@ describe('CoreMenuOptions Component', () => {
beforeEach(() => {
handleOnSelect.mockReset();
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: false,
isSelfDM: false,
isBot: false,
});
mockedIsDmScheduleRedesign.mockReturnValue(false);
mockedUseTimePostBoxIndicator.mockReturnValue(defaultUseTimePostBoxIndicatorReturnValue as unknown as ReturnType<typeof useTimePostBoxIndicator>);
});
afterEach(() => {
@ -62,7 +69,7 @@ describe('CoreMenuOptions Component', () => {
});
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
renderWithContext(
return renderWithContext(
<WithTestMenuContext>
<CoreMenuOptions
handleOnSelect={handleOnSelectOverride}
@ -106,28 +113,14 @@ describe('CoreMenuOptions Component', () => {
expect(screen.queryByText(/Tomorrow at/)).not.toBeInTheDocument();
});
it('should include trailing element when isDM true', () => {
setMockDate(2); // Tuesday
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: true,
isSelfDM: false,
isBot: false,
});
it('should render nothing when DM schedule redesign is active', () => {
setMockDate(2);
mockedIsDmScheduleRedesign.mockReturnValue(true);
renderComponent();
// Check the trailing element is rendered in the component
expect(screen.getAllByText(/John Doe/)[0]).toBeInTheDocument();
});
it('should NOT include trailing element when isDM false', () => {
setMockDate(2); // Tuesday
renderComponent();
expect(screen.queryByText(/John Doe/)).not.toBeInTheDocument();
expect(screen.queryByText(/Tomorrow at/)).not.toBeInTheDocument();
expect(screen.queryByText(/Monday at/)).not.toBeInTheDocument();
});
it('should call handleOnSelect with the right timestamp if tomorrow option is clicked', () => {
@ -148,68 +141,4 @@ describe('CoreMenuOptions Component', () => {
expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expectedTimestamp);
});
it('should NOT include trailing element when isDM and isBot are true', () => {
setMockDate(2); // Tuesday
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: true,
isSelfDM: false,
isBot: true,
});
renderComponent();
// Check the trailing element is NOT rendered in the component as this is a bot
expect(screen.queryByText(/John Doe/)).toBeNull();
});
it('should NOT include trailing element when the DM is with oneself', () => {
setMockDate(2); // Tuesday
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: true,
isSelfDM: true,
isBot: false,
});
renderComponent();
// Check the trailing element is NOT rendered in the component as this is a bot
expect(screen.queryByText(/John Doe/)).toBeNull();
});
it('should format teammate time according to user locale', () => {
setMockDate(2); // Tuesday
const stateWithFrenchLocale = {
...initialState,
entities: {
...initialState.entities,
users: {
...initialState.entities.users,
profiles: {
currentUserId: {
locale: 'fr',
},
},
},
},
};
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: true,
isSelfDM: false,
isBot: false,
});
renderComponent(stateWithFrenchLocale);
// Verify French format (no AM/PM)
const timeTexts = screen.getAllByText(/\d{2}:\d{2}(?!\s*[AP]M)/);
expect(timeTexts.length).toBeGreaterThan(0);
});
});

View file

@ -6,40 +6,18 @@ import React, {memo, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCurrentLocale} from 'selectors/i18n';
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import * as Menu from 'components/menu';
import type {Props as MenuItemProps} from 'components/menu/menu_item';
import Timestamp from 'components/timestamp';
import RecentUsedCustomDate from './recent_used_custom_date';
import type {GlobalState} from 'types/store';
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
channelId: string;
}
/**
* Formats a timestamp in the teammate's timezone using the current user's locale.
* @param userCurrentTimestamp - Timestamp in milliseconds (UTC)
* @param teammateTimezoneString - IANA timezone string (e.g., "America/New_York")
* @param userLocale - User's locale code (e.g., "fr", "en", "de")
* @returns Formatted time string respecting the user's locale
* @example
* // US locale: "8:00 AM"
* // French locale: "08:00"
* getScheduledTimeInTeammateTimezone(1635768000000, 'Europe/Paris', 'fr')
*/
function getScheduledTimeInTeammateTimezone(userCurrentTimestamp: number, teammateTimezoneString: string, userLocale: string): string {
const scheduledTimeUTC = DateTime.fromMillis(userCurrentTimestamp, {zone: 'utc'});
const teammateScheduledTime = scheduledTimeUTC.setZone(teammateTimezoneString);
const formattedTime = teammateScheduledTime.
setLocale(userLocale).
toLocaleString(DateTime.TIME_SIMPLE);
return formattedTime;
}
function getNextWeekday(dateTime: DateTime, targetWeekday: number) {
const daysDifference = targetWeekday - dateTime.weekday;
const adjustedDays = (daysDifference + 7) % 7;
@ -48,16 +26,23 @@ function getNextWeekday(dateTime: DateTime, targetWeekday: number) {
}
function CoreMenuOptions({handleOnSelect, channelId}: Props) {
const {
userCurrentTimezone,
teammateTimezone,
teammateDisplayName,
isDM,
isSelfDM,
isBot,
} = useTimePostBoxIndicator(channelId);
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
if (isDmRedesign) {
return null;
}
return (
<LegacyCoreMenuOptions
handleOnSelect={handleOnSelect}
channelId={channelId}
/>
);
}
function LegacyCoreMenuOptions({handleOnSelect, channelId}: Props) {
const {userCurrentTimezone} = useTimePostBoxIndicator(channelId);
const locale = useSelector(getCurrentLocale);
const now = DateTime.now().setZone(userCurrentTimezone);
const tomorrow9amTime = DateTime.now().
setZone(userCurrentTimezone).
@ -79,29 +64,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
/>
);
const extraProps: Partial<MenuItemProps> = {};
if (isDM && !isBot && !isSelfDM) {
const teammateTimezoneString = teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
const scheduledTimeInTeammateTimezone = getScheduledTimeInTeammateTimezone(tomorrow9amTime, teammateTimezoneString, locale);
const teammateTimeDisplay = (
<FormattedMessage
id='create_post_button.option.schedule_message.options.teammate_user_hour'
defaultMessage='{time} {user}s time'
values={{
user: (
<span className='userDisplayName'>
{teammateDisplayName}
</span>
),
time: scheduledTimeInTeammateTimezone,
}}
/>
);
extraProps.trailingElements = teammateTimeDisplay;
}
const tomorrowClickHandler = useCallback((e: React.UIEvent) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
const optionTomorrow = (
@ -118,7 +80,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
}
className='core-menu-options'
autoFocus={true}
{...extraProps}
/>
);
@ -137,7 +98,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
/>
}
className='core-menu-options'
{...extraProps}
/>
);
@ -157,7 +117,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
}
className='core-menu-options'
autoFocus={now.weekday === 5 || now.weekday === 6}
{...extraProps}
/>
);
@ -185,17 +144,7 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
options = [optionTomorrow, optionMonday];
}
return (
<>
{options}
<RecentUsedCustomDate
handleOnSelect={handleOnSelect}
userCurrentTimezone={userCurrentTimezone}
tomorrow9amTime={tomorrow9amTime}
nextMonday={nextMonday}
/>
</>
);
return <>{options}</>;
}
export default memo(CoreMenuOptions);

View file

@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React from 'react';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import {WithTestMenuContext} from 'components/menu/menu_context_test';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import DmMenuOptions, {DmScheduleHeader} from './dm_menu_options';
jest.mock('components/advanced_text_editor/use_post_box_indicator');
const mockedUseTimePostBoxIndicator = jest.mocked(useTimePostBoxIndicator);
const defaultHookValue = {
userCurrentTimezone: 'America/New_York',
teammateTimezone: {
useAutomaticTimezone: true,
automaticTimezone: 'Europe/London',
manualTimezone: '',
},
recipientTimezoneString: 'Europe/London',
teammateDisplayName: 'Sarah',
teammateFirstName: 'Sarah',
teammate: {
position: 'San Francisco',
timezone: {
useAutomaticTimezone: 'true',
automaticTimezone: 'Europe/London',
manualTimezone: '',
},
},
currentUserTimesStamp: DateTime.now().toMillis(),
isDM: true,
isSelfDM: false,
isBot: false,
showRemoteUserHour: false,
isScheduledPostEnabled: true,
showDndWarning: false,
teammateId: 'user2',
};
describe('DmMenuOptions', () => {
const handleOnSelect = jest.fn();
beforeEach(() => {
handleOnSelect.mockReset();
mockedUseTimePostBoxIndicator.mockReturnValue(defaultHookValue as ReturnType<typeof useTimePostBoxIndicator>);
});
it('renders Their morning preset', () => {
renderWithContext(
<WithTestMenuContext>
<DmMenuOptions
handleOnSelect={handleOnSelect}
channelId='channel1'
/>
</WithTestMenuContext>,
);
expect(screen.getByText('Their morning')).toBeInTheDocument();
expect(screen.getByText(/yours/)).toBeInTheDocument();
});
it('calls handleOnSelect with their morning timestamp when clicked', () => {
renderWithContext(
<WithTestMenuContext>
<DmMenuOptions
handleOnSelect={handleOnSelect}
channelId='channel1'
/>
</WithTestMenuContext>,
);
fireEvent.click(screen.getByTestId('scheduling_time_their_morning'));
expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expect.any(Number));
});
});
describe('DmScheduleHeader', () => {
beforeEach(() => {
mockedUseTimePostBoxIndicator.mockReturnValue(defaultHookValue as ReturnType<typeof useTimePostBoxIndicator>);
});
it('renders schedule for recipient header with location', () => {
renderWithContext(
<WithTestMenuContext>
<DmScheduleHeader channelId='channel1'/>
</WithTestMenuContext>,
);
expect(screen.getByText(/Schedule for Sarah/)).toBeInTheDocument();
expect(screen.getByText(/San Francisco/)).toBeInTheDocument();
expect(screen.getByText(/now/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,151 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React, {memo, useCallback, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCurrentLocale} from 'selectors/i18n';
import {
getRecipientLocationLabel,
getTheirMorningTimestamp,
} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import * as Menu from 'components/menu';
import Timestamp from 'components/timestamp';
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
channelId: string;
}
function formatTimeInTimezone(timestamp: number, timezone: string, locale: string): string {
return DateTime.fromMillis(timestamp, {zone: 'utc'}).
setZone(timezone).
setLocale(locale).
toLocaleString(DateTime.TIME_SIMPLE);
}
function formatWeekdayInTimezone(timestamp: number, timezone: string, locale: string): string {
return DateTime.fromMillis(timestamp, {zone: 'utc'}).
setZone(timezone).
setLocale(locale).
toFormat('ccc');
}
function DmMenuOptions({handleOnSelect, channelId}: Props) {
const {
userCurrentTimezone,
recipientTimezoneString,
} = useTimePostBoxIndicator(channelId);
const locale = useSelector(getCurrentLocale);
const theirMorningTimestamp = useMemo(
() => getTheirMorningTimestamp(recipientTimezoneString),
[recipientTimezoneString],
);
const theirMorningSubtitle = useMemo(() => {
const theirDay = formatWeekdayInTimezone(theirMorningTimestamp, recipientTimezoneString, locale);
const theirTime = formatTimeInTimezone(theirMorningTimestamp, recipientTimezoneString, locale);
const senderTime = formatTimeInTimezone(theirMorningTimestamp, userCurrentTimezone, locale);
return (
<FormattedMessage
id='create_post_button.option.schedule_message.options.their_morning.subtitle'
defaultMessage='{theirDay} {theirTime} · {senderTime} yours'
values={{
theirDay,
theirTime,
senderTime,
}}
/>
);
}, [theirMorningTimestamp, recipientTimezoneString, userCurrentTimezone, locale]);
const handleTheirMorningClick = useCallback(
(e: React.UIEvent) => handleOnSelect(e, theirMorningTimestamp),
[handleOnSelect, theirMorningTimestamp],
);
return (
<Menu.Item
key='scheduling_time_their_morning'
data-testid='scheduling_time_their_morning'
onClick={handleTheirMorningClick}
labels={
<>
<span>
<FormattedMessage
id='create_post_button.option.schedule_message.options.their_morning'
defaultMessage='Their morning'
/>
</span>
<span className='secondary-label'>
{theirMorningSubtitle}
</span>
</>
}
className='core-menu-options dm-menu-options'
autoFocus={true}
/>
);
}
export function DmScheduleHeader({channelId}: {channelId: string}) {
const {
teammateTimezone,
teammateDisplayName,
recipientTimezoneString,
teammate,
currentUserTimesStamp,
} = useTimePostBoxIndicator(channelId);
const locationLabel = useMemo(
() => getRecipientLocationLabel(teammate, recipientTimezoneString),
[teammate, recipientTimezoneString],
);
return (
<Menu.Item
disabled={true}
labels={
<>
<span>
<FormattedMessage
id='create_post_button.option.schedule_message.options.dm_header'
defaultMessage='Schedule for {recipientName}'
values={{recipientName: teammateDisplayName}}
/>
</span>
<span className='secondary-label'>
<FormattedMessage
id='create_post_button.option.schedule_message.options.dm_header.subtitle'
defaultMessage='{location} · {time} now'
values={{
location: locationLabel,
time: (
<Timestamp
value={currentUserTimesStamp}
useDate={false}
userTimezone={teammateTimezone}
useTime={{
hour: 'numeric',
minute: 'numeric',
}}
/>
),
}}
/>
</span>
</>
}
className='dm-schedule-header'
/>
);
}
export default memo(DmMenuOptions);

View file

@ -4,18 +4,24 @@
import classNames from 'classnames';
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useDispatch, useSelector} from 'react-redux';
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {openModal} from 'actions/views/modals';
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import CoreMenuOptions from 'components/advanced_text_editor/send_button/send_post_options/core_menu_options';
import DmMenuOptions, {DmScheduleHeader} from 'components/advanced_text_editor/send_button/send_post_options/dm_menu_options';
import RecentUsedCustomDate from 'components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
import type {GlobalState} from 'types/store';
import ScheduledPostCustomTimeModal from '../scheduled_post_custom_time_modal/scheduled_post_custom_time_modal';
import './style.scss';
@ -29,6 +35,8 @@ type Props = {
export function SendPostOptions({disabled, onSelect, channelId}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
const {userCurrentTimezone, recipientTimezoneString} = useTimePostBoxIndicator(channelId);
const handleOnSelect = useCallback((e: React.FormEvent, scheduledAt: number) => {
e.preventDefault();
@ -61,6 +69,10 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
}));
}, [channelId, dispatch, handleSelectCustomTime]);
const customTimeLeadingElement = isDmRedesign ? (
<i className='icon-calendar-outline'/>
) : undefined;
return (
<Menu.Container
menuButtonTooltip={{
@ -92,19 +104,38 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
horizontal: 'right',
}}
>
<Menu.Item
disabled={true}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.header'
defaultMessage='Schedule message'
/>
}
/>
{isDmRedesign ? (
<DmScheduleHeader channelId={channelId}/>
) : (
<Menu.Item
disabled={true}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.header'
defaultMessage='Schedule message'
/>
}
/>
)}
<CoreMenuOptions
{isDmRedesign ? (
<DmMenuOptions
handleOnSelect={handleOnSelect}
channelId={channelId}
/>
) : (
<CoreMenuOptions
handleOnSelect={handleOnSelect}
channelId={channelId}
/>
)}
<RecentUsedCustomDate
handleOnSelect={handleOnSelect}
userCurrentTimezone={userCurrentTimezone}
channelId={channelId}
isDmRedesign={isDmRedesign}
recipientTimezoneString={recipientTimezoneString}
/>
<Menu.Separator/>
@ -112,10 +143,11 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
<Menu.Item
onClick={handleChooseCustomTime}
key={'choose_custom_time'}
leadingElement={customTimeLeadingElement}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.choose_custom_time'
defaultMessage='Choose a custom time'
defaultMessage='Choose a custom time'
/>
}
/>

View file

@ -56,7 +56,6 @@ describe('CoreMenuOptions Component', () => {
jest.useFakeTimers();
jest.setSystemTime(now.toJSDate());
// Hardcode `tomorrow9amTime` and `nextMonday`
tomorrow9amTime = DateTime.fromISO('2024-11-02T09:00:00', {zone: userCurrentTimezone}).toMillis();
nextMonday = DateTime.fromISO('2024-11-04T09:00:00', {zone: userCurrentTimezone}).toMillis();
});
@ -81,13 +80,18 @@ describe('CoreMenuOptions Component', () => {
};
}
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
function renderComponent(
state = initialState,
handleOnSelectOverride = handleOnSelect,
options: {isDmRedesign?: boolean; recipientTimezoneString?: string} = {},
) {
renderWithContext(
<RecentUsedCustomDate
handleOnSelect={handleOnSelectOverride}
userCurrentTimezone={userCurrentTimezone}
tomorrow9amTime={tomorrow9amTime}
nextMonday={nextMonday}
channelId='channelId'
isDmRedesign={options.isDmRedesign}
recipientTimezoneString={options.recipientTimezoneString}
/>,
state,
);
@ -274,4 +278,23 @@ describe('CoreMenuOptions Component', () => {
expect(screen.getByText(new RegExp(`${monthDay} at`))).toBeInTheDocument();
});
it('should render DM redesign labels when isDmRedesign is true', () => {
const recentTimestamp = DateTime.now().plus({days: 7}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: DateTime.now().toMillis(),
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state, handleOnSelect, {
isDmRedesign: true,
recipientTimezoneString: 'Europe/London',
});
expect(screen.getByText(/Your time · recently used/)).toBeInTheDocument();
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
});

View file

@ -11,6 +11,7 @@ import type {GlobalState} from '@mattermost/types/store';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getTheirMorningTimestamp} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
import * as Menu from 'components/menu';
import Timestamp, {RelativeRanges} from 'components/timestamp';
@ -19,8 +20,9 @@ import {scheduledPosts} from 'utils/constants';
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
userCurrentTimezone: string;
tomorrow9amTime: number;
nextMonday: number;
channelId: string;
isDmRedesign?: boolean;
recipientTimezoneString?: string;
}
const DATE_RANGES = [
@ -48,15 +50,13 @@ function shouldShowRecentlyUsedCustomTime(
nowMillis: number,
recentlyUsedCustomDateVal: RecentlyUsedCustomDate,
userCurrentTimezone: string,
tomorrow9amTime: number,
nextMonday: number,
excludedTimestamps: number[],
) {
return recentlyUsedCustomDateVal &&
typeof recentlyUsedCustomDateVal.update_at === 'number' &&
typeof recentlyUsedCustomDateVal.timestamp === 'number' &&
recentlyUsedCustomDateVal.timestamp > nowMillis && // is in the future
recentlyUsedCustomDateVal.timestamp !== tomorrow9amTime && // is not the existing option tomorrow 9a.m
recentlyUsedCustomDateVal.timestamp !== nextMonday && // is not the existing option tomorrow 9a.m
recentlyUsedCustomDateVal.timestamp > nowMillis &&
!excludedTimestamps.includes(recentlyUsedCustomDateVal.timestamp) &&
isTimestampWithinLast30Days(recentlyUsedCustomDateVal.update_at, userCurrentTimezone);
}
@ -73,7 +73,12 @@ function getDateOption(now: DateTime, timestamp: number | undefined, userCurrent
return isInCurrentWeek ? USE_DATE_WEEKDAY_LONG : USE_DATE_MONTH_DAY;
}
function RecentUsedCustomDate({handleOnSelect, userCurrentTimezone, nextMonday, tomorrow9amTime}: Props) {
function RecentUsedCustomDate({
handleOnSelect,
userCurrentTimezone,
isDmRedesign,
recipientTimezoneString,
}: Props) {
const now = DateTime.now().setZone(userCurrentTimezone);
const recentlyUsedCustomDate = useSelector((state: GlobalState) => getPreference(state, scheduledPosts.SCHEDULED_POSTS, scheduledPosts.RECENTLY_USED_CUSTOM_TIME));
const recentlyUsedCustomDateVal: RecentlyUsedCustomDate = useMemo(() => {
@ -86,16 +91,101 @@ function RecentUsedCustomDate({handleOnSelect, userCurrentTimezone, nextMonday,
}
return {};
}, [recentlyUsedCustomDate]);
const handleRecentlyUsedCustomTime = useCallback((e: React.UIEvent) => handleOnSelect(e, recentlyUsedCustomDateVal.timestamp!), [handleOnSelect, recentlyUsedCustomDateVal.timestamp]);
const excludedTimestamps = useMemo(() => {
if (isDmRedesign && recipientTimezoneString) {
return [getTheirMorningTimestamp(recipientTimezoneString)];
}
const tomorrow9amTime = DateTime.now().
setZone(userCurrentTimezone).
plus({days: 1}).
set({hour: 9, minute: 0, second: 0, millisecond: 0}).
toMillis();
const nextMonday = (() => {
const nowDt = DateTime.now().setZone(userCurrentTimezone);
const daysDifference = 1 - nowDt.weekday;
const adjustedDays = (daysDifference + 7) % 7;
const deltaDays = adjustedDays === 0 ? 7 : adjustedDays;
return nowDt.plus({days: deltaDays}).set({
hour: 9,
minute: 0,
second: 0,
millisecond: 0,
}).toMillis();
})();
return [tomorrow9amTime, nextMonday];
}, [isDmRedesign, recipientTimezoneString, userCurrentTimezone]);
const handleRecentlyUsedCustomTime = useCallback(
(e: React.UIEvent) => handleOnSelect(e, recentlyUsedCustomDateVal.timestamp!),
[handleOnSelect, recentlyUsedCustomDateVal.timestamp],
);
if (
!shouldShowRecentlyUsedCustomTime(now.toMillis(), recentlyUsedCustomDateVal, userCurrentTimezone, tomorrow9amTime, nextMonday)
!shouldShowRecentlyUsedCustomTime(now.toMillis(), recentlyUsedCustomDateVal, userCurrentTimezone, excludedTimestamps)
) {
return null;
}
const dateOption = getDateOption(now, recentlyUsedCustomDateVal.timestamp, userCurrentTimezone);
if (isDmRedesign) {
const timeOnly = (
<Timestamp
ranges={DATE_RANGES}
value={recentlyUsedCustomDateVal.timestamp}
timeZone={userCurrentTimezone}
useDate={false}
useTime={USE_TIME_HOUR_MINUTE_NUMERIC}
/>
);
const dayLabel = (
<Timestamp
ranges={DATE_RANGES}
value={recentlyUsedCustomDateVal.timestamp}
timeZone={userCurrentTimezone}
useDate={dateOption}
useTime={false}
/>
);
return (
<>
<Menu.Separator key='recent_custom_separator'/>
<Menu.Item
key='recently_used_custom_time'
data-testid='recently_used_custom_time'
onClick={handleRecentlyUsedCustomTime}
labels={
<>
<span>
<FormattedMessage
id='create_post_button.option.schedule_message.options.recently_used_dm.primary'
defaultMessage='{day} at {time}'
values={{
day: dayLabel,
time: timeOnly,
}}
/>
</span>
<span className='secondary-label'>
<FormattedMessage
id='create_post_button.option.schedule_message.options.recently_used_dm.subtitle'
defaultMessage='Your time · recently used'
/>
</span>
</>
}
className='core-menu-options dm-menu-options'
/>
</>
);
}
const timestamp = (
<Timestamp
ranges={DATE_RANGES}

View file

@ -7,11 +7,32 @@ ul#dropdown_send_post_options {
}
}
li.core-menu-options{
li.dm-schedule-header {
.label-elements {
font-size: 14px;
font-weight: 500;
}
.secondary-label {
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 400;
}
}
li.core-menu-options,
li.dm-menu-options {
display: flex;
flex-direction: column;
align-items: baseline;
.secondary-label {
margin-top: 2px;
color: rgba(var(--center-channel-color-rgb), 0.64);
font-size: 12px;
font-weight: 400;
}
.trailing-elements {
margin-top: 4px;
margin-left: 0;

View file

@ -16,6 +16,7 @@ import {
getUser,
makeGetDisplayName,
} from 'mattermost-redux/selectors/entities/users';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
import Constants, {UserStatuses} from 'utils/constants';
@ -103,17 +104,21 @@ function useTimePostBoxIndicator(channelId: string) {
const isSelfDM = isDM && teammateId === currentUserId;
const showRemoteUserHour = isDM && showIt && timestamp !== 0 && !isBot;
const recipientTimezoneString = getUserCurrentTimezone(teammateTimezone);
return {
showRemoteUserHour,
isDM,
currentUserTimesStamp: timestamp,
teammateTimezone,
recipientTimezoneString,
userCurrentTimezone,
isScheduledPostEnabled: isScheduledPostEnabledValue,
showDndWarning,
teammateId,
teammate,
teammateDisplayName,
teammateFirstName: teammate?.first_name || teammateDisplayName,
isSelfDM,
isBot,
};

View file

@ -35,6 +35,7 @@ type Props = {
className?: string;
errorText?: string | React.ReactNode;
timePickerInterval?: number;
timezone?: string;
};
export default function DateTimePickerModal({
@ -54,15 +55,23 @@ export default function DateTimePickerModal({
className,
errorText,
timePickerInterval,
timezone: timezoneProp,
}: Props) {
const userTimezone = useSelector(getCurrentTimezone);
const currentTime = getCurrentMomentForTimezone(userTimezone);
const activeTimezone = timezoneProp || userTimezone;
const currentTime = getCurrentMomentForTimezone(activeTimezone);
const initialRoundedTime = getRoundedTime(currentTime);
const [dateTime, setDateTime] = useState(initialTime || initialRoundedTime);
const [isInteracting, setIsInteracting] = useState(false);
useEffect(() => {
if (initialTime) {
setDateTime(initialTime);
}
}, [initialTime?.valueOf()]); // eslint-disable-line react-hooks/exhaustive-deps -- sync when parent updates time
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isInteracting) {
@ -120,7 +129,7 @@ export default function DateTimePickerModal({
<DateTimeInput
time={dateTime}
handleChange={handleChange}
timezone={userTimezone}
timezone={activeTimezone}
setIsInteracting={setIsInteracting}
relativeDate={relativeDate}
timePickerInterval={timePickerInterval}

View file

@ -4552,12 +4552,17 @@
"create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.",
"create_group_memberships_modal.title": "Re-add {username} to teams and channels",
"create_post_button.option.schedule_message": "Schedule message",
"create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time",
"create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time…",
"create_post_button.option.schedule_message.options.dm_header": "Schedule for {recipientName}",
"create_post_button.option.schedule_message.options.dm_header.subtitle": "{location} · {time} now",
"create_post_button.option.schedule_message.options.header": "Schedule message",
"create_post_button.option.schedule_message.options.monday": "Monday at {9amTime}",
"create_post_button.option.schedule_message.options.next_monday": "Next Monday at {9amTime}",
"create_post_button.option.schedule_message.options.recently_used_custom_time": "Recently used custom time",
"create_post_button.option.schedule_message.options.teammate_user_hour": "{time} {user}s time",
"create_post_button.option.schedule_message.options.recently_used_dm.primary": "{day} at {time}",
"create_post_button.option.schedule_message.options.recently_used_dm.subtitle": "Your time · recently used",
"create_post_button.option.schedule_message.options.their_morning": "Their morning",
"create_post_button.option.schedule_message.options.their_morning.subtitle": "{theirDay} {theirTime} · {senderTime} yours",
"create_post_button.option.schedule_message.options.tomorrow": "Tomorrow at {9amTime}",
"create_post_button.option.send_now": "Send Now",
"create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.",
@ -6260,7 +6265,12 @@
"saveChangesPanel.saved": "Settings saved",
"schedule_post.custom_time_modal.cancel_button_text": "Cancel",
"schedule_post.custom_time_modal.confirm_button_text": "Schedule",
"schedule_post.custom_time_modal.dm_subtitle": "to {recipientName}",
"schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}",
"schedule_post.custom_time_modal.perspective.mine": "My time",
"schedule_post.custom_time_modal.perspective.theirs": "{name}'s time",
"schedule_post.custom_time_modal.preview.recipient_receives": "{recipientName} receives at",
"schedule_post.custom_time_modal.preview.you_send": "You send at",
"schedule_post.custom_time_modal.title": "Schedule message",
"Schedule_post.empty_state.subtitle": "Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.",
"Schedule_post.empty_state.title": "No scheduled drafts at the moment",