diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_menu.ts b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_menu.ts
index ad9e362bec8..2d5f6b34cd3 100644
--- a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_menu.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_menu.ts
@@ -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() {
diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts
index 3c45e41cb78..00c3239494c 100644
--- a/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts
+++ b/e2e-tests/playwright/lib/src/ui/components/channels/schedule_message_modal.ts
@@ -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() {
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.test.ts b/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.test.ts
new file mode 100644
index 00000000000..3a7c3b428ba
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.test.ts
@@ -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+$/);
+ });
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.ts b/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.ts
new file mode 100644
index 00000000000..8322cb0aece
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.ts
@@ -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,
+ });
+}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.scss b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.scss
new file mode 100644
index 00000000000..24dd77660c0
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.scss
@@ -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;
+ }
+}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.tsx
new file mode 100644
index 00000000000..65eecad224c
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.tsx
@@ -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 (
+
+ );
+}
+
+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 (
+
+ {showRecipientLine && (
+
+ )}
+
+
+
+
+
+
+ {' '}
+
+ {'('}
+ {senderOffset}
+ {')'}
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.scss b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.scss
new file mode 100644
index 00000000000..a582f6afeef
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.scss
@@ -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);
+ }
+ }
+}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.test.tsx
new file mode 100644
index 00000000000..2435aea53f9
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.test.tsx
@@ -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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('radio', {name: 'My time'}));
+
+ expect(onChange).toHaveBeenCalledWith('mine');
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.tsx
new file mode 100644
index 00000000000..0c21a4fa497
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.tsx
@@ -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 (
+
+
+
+
+ );
+}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
index e1d889e9f56..138659bbaa9 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
@@ -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();
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('theirs');
+
+ const activeTimezone = useMemo(() => {
+ if (!isDmRedesign) {
+ return userTimezone;
+ }
+ return perspective === 'theirs' ? recipientTimezoneString : userTimezone;
+ }, [isDmRedesign, perspective, recipientTimezoneString, userTimezone]);
+
const [selectedDateTime, setSelectedDateTime] = useState(() => {
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 (
+ );
- const timePickerInterval = useSelector(testingEnabled) ? 1 : SCHEDULED_POST_CUSTOM_TIME_INTERVAL;
+ const bodySuffix = (
+
+ );
+
+ return (
+
+ }
+ subheading={
+
+ }
+ confirmButtonText={
+
+ }
+ cancelButtonText={
+
+ }
+ ariaLabel={label}
+ onExited={onExited}
+ onConfirm={handleOnConfirm}
+ onChange={setSelectedDateTime}
+ bodyPrefix={bodyPrefix}
+ bodySuffix={bodySuffix}
+ relativeDate={true}
+ onCancel={onExited}
+ errorText={errorMessage}
+ timePickerInterval={timePickerInterval}
+ timezone={activeTimezone}
+ />
+ );
+ }
return (
({
+ 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);
});
afterEach(() => {
@@ -62,7 +69,7 @@ describe('CoreMenuOptions Component', () => {
});
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
- renderWithContext(
+ return renderWithContext(
{
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);
- });
});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
index d55e5e7409d..6d50e624939 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
@@ -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 (
+
+ );
+}
+
+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 = {};
-
- if (isDM && !isBot && !isSelfDM) {
- const teammateTimezoneString = teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
- const scheduledTimeInTeammateTimezone = getScheduledTimeInTeammateTimezone(tomorrow9amTime, teammateTimezoneString, locale);
- const teammateTimeDisplay = (
-
- {teammateDisplayName}
-
- ),
- 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}
-
- >
- );
+ return <>{options}>;
}
export default memo(CoreMenuOptions);
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.test.tsx
new file mode 100644
index 00000000000..886ec3321df
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.test.tsx
@@ -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);
+ });
+
+ it('renders Their morning preset', () => {
+ renderWithContext(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Their morning')).toBeInTheDocument();
+ expect(screen.getByText(/yours/)).toBeInTheDocument();
+ });
+
+ it('calls handleOnSelect with their morning timestamp when clicked', () => {
+ renderWithContext(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByTestId('scheduling_time_their_morning'));
+
+ expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expect.any(Number));
+ });
+});
+
+describe('DmScheduleHeader', () => {
+ beforeEach(() => {
+ mockedUseTimePostBoxIndicator.mockReturnValue(defaultHookValue as ReturnType);
+ });
+
+ it('renders schedule for recipient header with location', () => {
+ renderWithContext(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/Schedule for Sarah/)).toBeInTheDocument();
+ expect(screen.getByText(/San Francisco/)).toBeInTheDocument();
+ expect(screen.getByText(/now/)).toBeInTheDocument();
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.tsx
new file mode 100644
index 00000000000..01c8b527f51
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.tsx
@@ -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 (
+
+ );
+ }, [theirMorningTimestamp, recipientTimezoneString, userCurrentTimezone, locale]);
+
+ const handleTheirMorningClick = useCallback(
+ (e: React.UIEvent) => handleOnSelect(e, theirMorningTimestamp),
+ [handleOnSelect, theirMorningTimestamp],
+ );
+
+ return (
+
+
+
+
+
+ {theirMorningSubtitle}
+
+ >
+ }
+ 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 (
+
+
+
+
+
+
+ ),
+ }}
+ />
+
+ >
+ }
+ className='dm-schedule-header'
+ />
+ );
+}
+
+export default memo(DmMenuOptions);
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
index a99e57db7b2..438ad7d9cca 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
@@ -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 ? (
+
+ ) : undefined;
+
return (
-
- }
- />
+ {isDmRedesign ? (
+
+ ) : (
+
+ }
+ />
+ )}
-
+ ) : (
+
+ )}
+
+
@@ -112,10 +143,11 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
}
/>
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx
index 2427ed6d980..800c4829d08 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx
@@ -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(
,
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();
+ });
});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx
index c86c64f62e2..41c2ba8d705 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx
@@ -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 = (
+
+ );
+
+ const dayLabel = (
+
+ );
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ }
+ className='core-menu-options dm-menu-options'
+ />
+ >
+ );
+ }
+
const timestamp = (
{
+ 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({