From 4918a36eb174356da5c970a400f47d09ea2ad8fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 08:04:19 +0000 Subject: [PATCH 1/4] Redesign scheduled message UI for 1:1 DMs with recipient timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a DM recipient has a known timezone, the schedule dropdown shows a recipient header, Their morning preset, and updated recently-used labels. The custom-time modal adds a perspective toggle defaulting to recipient time, dual-time preview, and preserves wall-clock values on toggle flip. Channels, group messages, and DMs without recipient timezone fall back to the existing sender-centric UI unchanged. Co-authored-by: Sven Hüster --- .../channels/schedule_message_menu.ts | 6 +- .../channels/schedule_message_modal.ts | 8 + .../schedule_message_dm_utils.test.ts | 81 ++++++++++ .../send_button/schedule_message_dm_utils.ts | 112 +++++++++++++ .../schedule_dual_time_preview.scss | 45 ++++++ .../schedule_dual_time_preview.tsx | 104 ++++++++++++ .../schedule_perspective_toggle.scss | 34 ++++ .../schedule_perspective_toggle.test.tsx | 40 +++++ .../schedule_perspective_toggle.tsx | 56 +++++++ .../scheduled_post_custom_time_modal.tsx | 124 +++++++++++++- .../scheduled_post_dm_custom_time_modal.scss | 6 + .../core_menu_options.test.tsx | 97 ++--------- .../send_post_options/core_menu_options.tsx | 79 ++------- .../dm_menu_options.test.tsx | 99 ++++++++++++ .../send_post_options/dm_menu_options.tsx | 152 ++++++++++++++++++ .../send_button/send_post_options/index.tsx | 56 +++++-- .../recent_used_custom_date.test.tsx | 31 +++- .../recent_used_custom_date.tsx | 110 +++++++++++-- .../send_button/send_post_options/style.scss | 23 ++- .../use_post_box_indicator.tsx | 5 + .../date_time_picker_modal.tsx | 13 +- webapp/channels/src/i18n/en.json | 13 +- 22 files changed, 1103 insertions(+), 191 deletions(-) create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.test.ts create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/schedule_message_dm_utils.ts create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.scss create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.scss create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.test.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_dm_custom_time_modal.scss create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.test.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.tsx 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..ad9b815eef2 --- /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 {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; +import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users'; + +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..769e76ec52a --- /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 { + margin-top: 16px; + padding: 12px 16px; + background-color: rgba(var(--center-channel-color-rgb), 0.04); + border-radius: var(--radius-s); + + &__row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + font-size: 13px; + 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..6cda901582f --- /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,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Moment} from 'moment-timezone'; +import React, {useMemo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; + +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..cb27c8561b2 --- /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; + align-items: center; + gap: 3px; + margin-bottom: 16px; + padding: 3px; + background-color: rgba(var(--center-channel-color-rgb), 0.04); + border-radius: var(--radius-m); + border: var(--border-default); + width: fit-content; + + &__option { + display: flex; + cursor: pointer; + padding: 6px 12px; + background-color: transparent; + color: rgba(var(--center-channel-color-rgb), 0.75); + border-radius: 4px; + font-size: 13px; + line-height: 18px; + font-weight: 500; + border: none; + + &[data-selected='true'] { + background-color: var(--center-channel-bg); + color: var(--center-channel-color); + box-shadow: var(--elevation-1); + } + + &: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..da074a785bf 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 @@ -15,10 +15,23 @@ import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; 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 { + getDefaultScheduleDateTime, + isDmScheduleRedesign, + reinterpretWallClock, + type SchedulePerspective, +} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils'; 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'; @@ -49,6 +56,7 @@ describe('CoreMenuOptions Component', () => { beforeEach(() => { handleOnSelect.mockReset(); + mockedIsDmScheduleRedesign.mockReturnValue(false); mockedUseTimePostBoxIndicator.mockReturnValue({ ...defaultUseTimePostBoxIndicatorReturnValue, isDM: false, @@ -62,7 +70,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', () => { @@ -149,67 +143,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..54442f426ad 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,13 @@ 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)); + const {userCurrentTimezone} = useTimePostBoxIndicator(channelId); + + if (isDmRedesign) { + return null; + } - const locale = useSelector(getCurrentLocale); const now = DateTime.now().setZone(userCurrentTimezone); const tomorrow9amTime = DateTime.now(). setZone(userCurrentTimezone). @@ -79,29 +54,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 +70,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) { } className='core-menu-options' autoFocus={true} - {...extraProps} /> ); @@ -137,7 +88,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) { /> } className='core-menu-options' - {...extraProps} /> ); @@ -157,7 +107,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) { } className='core-menu-options' autoFocus={now.weekday === 5 || now.weekday === 6} - {...extraProps} /> ); @@ -185,17 +134,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..45524df522d --- /dev/null +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/dm_menu_options.tsx @@ -0,0 +1,152 @@ +// 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 useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator'; +import { + getRecipientLocationLabel, + getTheirMorningTimestamp, +} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils'; +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, + teammateTimezone, + 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({ Date: Fri, 22 May 2026 08:50:36 +0000 Subject: [PATCH 2/4] Fix ESLint errors blocking Web App CI check-lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder imports per import/order, wrap JSX literals in expression containers, remove unused variable, fix padded-blocks in tests, and extract LegacyCoreMenuOptions so useCallback hooks are not called after an early return in CoreMenuOptions. Co-authored-by: Sven Hüster --- .../send_button/schedule_message_dm_utils.ts | 2 +- .../schedule_dual_time_preview.tsx | 6 ++++-- .../scheduled_post_custom_time_modal.tsx | 12 ++++++------ .../send_post_options/core_menu_options.test.tsx | 4 +--- .../send_post_options/core_menu_options.tsx | 12 +++++++++++- .../send_post_options/dm_menu_options.tsx | 3 +-- .../advanced_text_editor/use_post_box_indicator.tsx | 2 +- 7 files changed, 25 insertions(+), 16 deletions(-) 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 index ad9b815eef2..8322cb0aece 100644 --- 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 @@ -9,8 +9,8 @@ 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 {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users'; +import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import type {GlobalState} from 'types/store'; 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 index 6cda901582f..748f87f4a65 100644 --- 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 @@ -1,10 +1,10 @@ // 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 classNames from 'classnames'; 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'; @@ -95,7 +95,9 @@ export default function ScheduleDualTimePreview({ /> {' '} - ({senderOffset}) + {'('} + {senderOffset} + {')'} 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 da074a785bf..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,18 +12,18 @@ 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 { - 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 { 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'; diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx index dd3ca477d75..69039d684da 100644 --- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx @@ -4,13 +4,12 @@ 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'; import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils'; -import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils'; - import CoreMenuOptions from './core_menu_options'; jest.mock('components/advanced_text_editor/send_button/schedule_message_dm_utils', () => ({ @@ -142,5 +141,4 @@ describe('CoreMenuOptions Component', () => { expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expectedTimestamp); }); - }); 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 54442f426ad..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 @@ -27,12 +27,22 @@ function getNextWeekday(dateTime: DateTime, targetWeekday: number) { function CoreMenuOptions({handleOnSelect, channelId}: Props) { const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId)); - const {userCurrentTimezone} = useTimePostBoxIndicator(channelId); if (isDmRedesign) { return null; } + return ( + + ); +} + +function LegacyCoreMenuOptions({handleOnSelect, channelId}: Props) { + const {userCurrentTimezone} = useTimePostBoxIndicator(channelId); + const now = DateTime.now().setZone(userCurrentTimezone); const tomorrow9amTime = DateTime.now(). setZone(userCurrentTimezone). 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 index 45524df522d..01c8b527f51 100644 --- 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 @@ -8,11 +8,11 @@ import {useSelector} from 'react-redux'; import {getCurrentLocale} from 'selectors/i18n'; -import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator'; 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'; @@ -38,7 +38,6 @@ function formatWeekdayInTimezone(timestamp: number, timezone: string, locale: st function DmMenuOptions({handleOnSelect, channelId}: Props) { const { userCurrentTimezone, - teammateTimezone, recipientTimezoneString, } = useTimePostBoxIndicator(channelId); diff --git a/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx b/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx index 94f1101b366..6ab699a1b73 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx @@ -10,13 +10,13 @@ import type {UserProfile} from '@mattermost/types/users'; import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels'; import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts'; import {getTimezoneForUserProfile, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; -import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import { getCurrentUserId, getStatusForUserId, getUser, makeGetDisplayName, } from 'mattermost-redux/selectors/entities/users'; +import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import Constants, {UserStatuses} from 'utils/constants'; From 7a86a3499922d44958376a93ba9dc3a53de39e9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 09:01:28 +0000 Subject: [PATCH 3/4] Fix stylelint property order in DM schedule message SCSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder CSS properties in four PR-touched stylesheets to satisfy order/properties-order and unblock Web App CI check-lint. Co-authored-by: Sven Hüster --- .../schedule_dual_time_preview.scss | 8 +++---- .../schedule_perspective_toggle.scss | 24 +++++++++---------- .../scheduled_post_dm_custom_time_modal.scss | 2 +- .../send_button/send_post_options/style.scss | 8 +++---- 4 files changed, 21 insertions(+), 21 deletions(-) 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 index 769e76ec52a..24dd77660c0 100644 --- 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 @@ -1,15 +1,15 @@ .ScheduleDualTimePreview { - margin-top: 16px; padding: 12px 16px; - background-color: rgba(var(--center-channel-color-rgb), 0.04); border-radius: var(--radius-s); + margin-top: 16px; + background-color: rgba(var(--center-channel-color-rgb), 0.04); &__row { display: flex; - justify-content: space-between; align-items: baseline; - gap: 12px; + justify-content: space-between; font-size: 13px; + gap: 12px; line-height: 20px; & + & { 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 index cb27c8561b2..a582f6afeef 100644 --- 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 @@ -1,30 +1,30 @@ .SchedulePerspectiveToggle { display: flex; - align-items: center; - gap: 3px; - margin-bottom: 16px; - padding: 3px; - background-color: rgba(var(--center-channel-color-rgb), 0.04); - border-radius: var(--radius-m); - border: var(--border-default); 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; - cursor: pointer; padding: 6px 12px; + border: none; + border-radius: 4px; background-color: transparent; color: rgba(var(--center-channel-color-rgb), 0.75); - border-radius: 4px; + cursor: pointer; font-size: 13px; - line-height: 18px; font-weight: 500; - border: none; + line-height: 18px; &[data-selected='true'] { background-color: var(--center-channel-bg); - color: var(--center-channel-color); box-shadow: var(--elevation-1); + color: var(--center-channel-color); } &:hover { diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_dm_custom_time_modal.scss b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_dm_custom_time_modal.scss index 0bcc90ce8e6..2f9080324e7 100644 --- a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_dm_custom_time_modal.scss +++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_dm_custom_time_modal.scss @@ -1,6 +1,6 @@ .scheduled_post_dm_custom_time_modal { .modal-subheader { - font-size: 13px; color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 13px; } } diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss index e8828c38a61..cffde029fdd 100644 --- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss @@ -9,14 +9,14 @@ ul#dropdown_send_post_options { li.dm-schedule-header { .label-elements { - font-weight: 500; font-size: 14px; + font-weight: 500; } .secondary-label { + color: rgba(var(--center-channel-color-rgb), 0.64); font-size: 12px; font-weight: 400; - color: rgba(var(--center-channel-color-rgb), 0.64); } } @@ -27,10 +27,10 @@ ul#dropdown_send_post_options { align-items: baseline; .secondary-label { + margin-top: 2px; + color: rgba(var(--center-channel-color-rgb), 0.64); font-size: 12px; font-weight: 400; - color: rgba(var(--center-channel-color-rgb), 0.64); - margin-top: 2px; } .trailing-elements { From e2d7dcc5d4c63449401b3cdfc6954a8d1a0dc38d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 May 2026 10:10:48 +0000 Subject: [PATCH 4/4] Fix i18n sync and TypeScript errors for DM schedule UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale teammate_user_hour from en.json via i18n-extract, drop invalid useDate prop on Timestamp preview, and update core_menu_options test mock for extended useTimePostBoxIndicator return shape. Co-authored-by: Sven Hüster --- .../schedule_dual_time_preview.tsx | 1 - .../send_post_options/core_menu_options.test.tsx | 12 ++++++------ webapp/channels/src/i18n/en.json | 1 - 3 files changed, 6 insertions(+), 8 deletions(-) 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 index 748f87f4a65..65eecad224c 100644 --- 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 @@ -32,7 +32,6 @@ function PreviewTime({value, timeZone}: {value: number; timeZone: string}) { ranges={DATE_RANGES} value={value} timeZone={timeZone} - useDate={DATE_RANGES} useTime={{ hour: 'numeric', minute: 'numeric', diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx index 69039d684da..2e3393b7ea5 100644 --- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx +++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx @@ -30,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, @@ -56,12 +61,7 @@ describe('CoreMenuOptions Component', () => { beforeEach(() => { handleOnSelect.mockReset(); mockedIsDmScheduleRedesign.mockReturnValue(false); - mockedUseTimePostBoxIndicator.mockReturnValue({ - ...defaultUseTimePostBoxIndicatorReturnValue, - isDM: false, - isSelfDM: false, - isBot: false, - }); + mockedUseTimePostBoxIndicator.mockReturnValue(defaultUseTimePostBoxIndicatorReturnValue as unknown as ReturnType); }); afterEach(() => { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 3c89d3ed31c..67858f44610 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4561,7 +4561,6 @@ "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.teammate_user_hour": "{time} {user}’s time", "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.",