mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
Merge e2d7dcc5d4 into 508f1551e3
This commit is contained in:
commit
177959a3f3
22 changed files with 1118 additions and 199 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import classNames from 'classnames';
|
||||
import type {Moment} from 'moment-timezone';
|
||||
import React, {useMemo} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {SchedulePerspective} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import {formatTimezoneOffsetShort} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import Timestamp, {RelativeRanges} from 'components/timestamp';
|
||||
|
||||
import './schedule_dual_time_preview.scss';
|
||||
|
||||
type Props = {
|
||||
selectedDateTime: Moment;
|
||||
perspective: SchedulePerspective;
|
||||
recipientName: string;
|
||||
senderTimezone: string;
|
||||
recipientTimezone: string;
|
||||
showRecipientLine?: boolean;
|
||||
}
|
||||
|
||||
const DATE_RANGES = [
|
||||
RelativeRanges.TODAY_TITLE_CASE,
|
||||
RelativeRanges.TOMORROW_TITLE_CASE,
|
||||
];
|
||||
|
||||
function PreviewTime({value, timeZone}: {value: number; timeZone: string}) {
|
||||
return (
|
||||
<Timestamp
|
||||
ranges={DATE_RANGES}
|
||||
value={value}
|
||||
timeZone={timeZone}
|
||||
useTime={{
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduleDualTimePreview({
|
||||
selectedDateTime,
|
||||
perspective,
|
||||
recipientName,
|
||||
senderTimezone,
|
||||
recipientTimezone,
|
||||
showRecipientLine = true,
|
||||
}: Props) {
|
||||
const scheduledAt = selectedDateTime.valueOf();
|
||||
|
||||
const senderOffset = useMemo(
|
||||
() => formatTimezoneOffsetShort(senderTimezone, selectedDateTime),
|
||||
[senderTimezone, selectedDateTime],
|
||||
);
|
||||
|
||||
const recipientPrimary = perspective === 'theirs';
|
||||
const senderPrimary = perspective === 'mine';
|
||||
|
||||
return (
|
||||
<div
|
||||
className='ScheduleDualTimePreview'
|
||||
aria-live='polite'
|
||||
>
|
||||
{showRecipientLine && (
|
||||
<div className={classNames('ScheduleDualTimePreview__row', {primary: recipientPrimary, secondary: !recipientPrimary})}>
|
||||
<span className='ScheduleDualTimePreview__label'>
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.preview.recipient_receives'
|
||||
defaultMessage='{recipientName} receives at'
|
||||
values={{recipientName}}
|
||||
/>
|
||||
</span>
|
||||
<span className='ScheduleDualTimePreview__value'>
|
||||
<PreviewTime
|
||||
value={scheduledAt}
|
||||
timeZone={recipientTimezone}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames('ScheduleDualTimePreview__row', {primary: senderPrimary, secondary: !senderPrimary})}>
|
||||
<span className='ScheduleDualTimePreview__label'>
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.preview.you_send'
|
||||
defaultMessage='You send at'
|
||||
/>
|
||||
</span>
|
||||
<span className='ScheduleDualTimePreview__value'>
|
||||
<PreviewTime
|
||||
value={scheduledAt}
|
||||
timeZone={senderTimezone}
|
||||
/>
|
||||
{' '}
|
||||
<span className='ScheduleDualTimePreview__offset'>
|
||||
{'('}
|
||||
{senderOffset}
|
||||
{')'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import SchedulePerspectiveToggle from './schedule_perspective_toggle';
|
||||
|
||||
describe('SchedulePerspectiveToggle', () => {
|
||||
it('renders radiogroup with both options', () => {
|
||||
renderWithContext(
|
||||
<SchedulePerspectiveToggle
|
||||
perspective='theirs'
|
||||
recipientFirstName='Sarah'
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radiogroup')).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', {name: 'My time'})).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', {name: "Sarah's time"})).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('calls onChange when My time is selected', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<SchedulePerspectiveToggle
|
||||
perspective='theirs'
|
||||
recipientFirstName='Sarah'
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('radio', {name: 'My time'}));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mine');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
import type {SchedulePerspective} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
|
||||
import './schedule_perspective_toggle.scss';
|
||||
|
||||
type Props = {
|
||||
perspective: SchedulePerspective;
|
||||
recipientFirstName: string;
|
||||
onChange: (perspective: SchedulePerspective) => void;
|
||||
}
|
||||
|
||||
export default function SchedulePerspectiveToggle({perspective, recipientFirstName, onChange}: Props) {
|
||||
const handleMineClick = useCallback(() => onChange('mine'), [onChange]);
|
||||
const handleTheirsClick = useCallback(() => onChange('theirs'), [onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className='SchedulePerspectiveToggle'
|
||||
role='radiogroup'
|
||||
aria-label='Schedule time perspective'
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='SchedulePerspectiveToggle__option'
|
||||
role='radio'
|
||||
aria-checked={perspective === 'mine'}
|
||||
data-selected={perspective === 'mine'}
|
||||
onClick={handleMineClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.perspective.mine'
|
||||
defaultMessage='My time'
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='SchedulePerspectiveToggle__option'
|
||||
role='radio'
|
||||
aria-checked={perspective === 'theirs'}
|
||||
data-selected={perspective === 'theirs'}
|
||||
onClick={handleTheirsClick}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.perspective.theirs'
|
||||
defaultMessage="{name}'s time"
|
||||
values={{name: recipientFirstName}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,13 +12,26 @@ import {testingEnabled} from 'mattermost-redux/selectors/entities/general';
|
|||
import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import {
|
||||
getDefaultScheduleDateTime,
|
||||
isDmScheduleRedesign,
|
||||
reinterpretWallClock,
|
||||
type SchedulePerspective,
|
||||
} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import {
|
||||
DMUserTimezone,
|
||||
} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone';
|
||||
import ScheduleDualTimePreview from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_dual_time_preview';
|
||||
import SchedulePerspectiveToggle from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/schedule_perspective_toggle';
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
|
||||
|
||||
import {scheduledPosts} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import './scheduled_post_dm_custom_time_modal.scss';
|
||||
|
||||
const SCHEDULED_POST_CUSTOM_TIME_INTERVAL = 15; // minutes
|
||||
|
||||
type Props = {
|
||||
|
|
@ -32,19 +45,53 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
|
|||
const {formatMessage} = useIntl();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const userTimezone = useSelector(getCurrentTimezone);
|
||||
const now = moment().tz(userTimezone);
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const dispatch = useDispatch();
|
||||
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
|
||||
const {
|
||||
teammateDisplayName,
|
||||
teammateFirstName,
|
||||
recipientTimezoneString,
|
||||
} = useTimePostBoxIndicator(channelId);
|
||||
|
||||
const [perspective, setPerspective] = useState<SchedulePerspective>('theirs');
|
||||
|
||||
const activeTimezone = useMemo(() => {
|
||||
if (!isDmRedesign) {
|
||||
return userTimezone;
|
||||
}
|
||||
return perspective === 'theirs' ? recipientTimezoneString : userTimezone;
|
||||
}, [isDmRedesign, perspective, recipientTimezoneString, userTimezone]);
|
||||
|
||||
const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => {
|
||||
if (initialTime) {
|
||||
return initialTime;
|
||||
}
|
||||
|
||||
return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0});
|
||||
if (isDmRedesign) {
|
||||
return getDefaultScheduleDateTime('theirs', userTimezone, recipientTimezoneString);
|
||||
}
|
||||
|
||||
return moment().tz(userTimezone).add(1, 'days').set({
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
});
|
||||
});
|
||||
|
||||
const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]);
|
||||
|
||||
const handlePerspectiveChange = useCallback((newPerspective: SchedulePerspective) => {
|
||||
if (newPerspective === perspective) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTimezone = newPerspective === 'theirs' ? recipientTimezoneString : userTimezone;
|
||||
setSelectedDateTime((current) => reinterpretWallClock(current, newTimezone));
|
||||
setPerspective(newPerspective);
|
||||
}, [perspective, recipientTimezoneString, userTimezone]);
|
||||
|
||||
const handleOnConfirm = useCallback(async (dateTime: Moment) => {
|
||||
const selectedTime = dateTime.valueOf();
|
||||
const response = await onConfirm(selectedTime);
|
||||
|
|
@ -66,9 +113,13 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
|
|||
} else {
|
||||
onExited();
|
||||
}
|
||||
}, [onConfirm, onExited]);
|
||||
}, [currentUserId, dispatch, onConfirm, onExited, userTimezone]);
|
||||
|
||||
const bodySuffix = useMemo(() => {
|
||||
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
|
||||
|
||||
const timePickerInterval = useSelector(testingEnabled) ? 1 : SCHEDULED_POST_CUSTOM_TIME_INTERVAL;
|
||||
|
||||
const legacyBodySuffix = useMemo(() => {
|
||||
return (
|
||||
<DMUserTimezone
|
||||
channelId={channelId}
|
||||
|
|
@ -77,9 +128,68 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
|
|||
);
|
||||
}, [channelId, selectedDateTime]);
|
||||
|
||||
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
|
||||
if (isDmRedesign) {
|
||||
const bodyPrefix = (
|
||||
<SchedulePerspectiveToggle
|
||||
perspective={perspective}
|
||||
recipientFirstName={teammateFirstName}
|
||||
onChange={handlePerspectiveChange}
|
||||
/>
|
||||
);
|
||||
|
||||
const timePickerInterval = useSelector(testingEnabled) ? 1 : SCHEDULED_POST_CUSTOM_TIME_INTERVAL;
|
||||
const bodySuffix = (
|
||||
<ScheduleDualTimePreview
|
||||
selectedDateTime={selectedDateTime}
|
||||
perspective={perspective}
|
||||
recipientName={teammateDisplayName}
|
||||
senderTimezone={userTimezone}
|
||||
recipientTimezone={recipientTimezoneString}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<DateTimePickerModal
|
||||
className='scheduled_post_custom_time_modal scheduled_post_dm_custom_time_modal'
|
||||
initialTime={selectedDateTime}
|
||||
header={
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.title'
|
||||
defaultMessage='Schedule message'
|
||||
/>
|
||||
}
|
||||
subheading={
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.dm_subtitle'
|
||||
defaultMessage='to {recipientName}'
|
||||
values={{recipientName: teammateDisplayName}}
|
||||
/>
|
||||
}
|
||||
confirmButtonText={
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.confirm_button_text'
|
||||
defaultMessage='Schedule'
|
||||
/>
|
||||
}
|
||||
cancelButtonText={
|
||||
<FormattedMessage
|
||||
id='schedule_post.custom_time_modal.cancel_button_text'
|
||||
defaultMessage='Cancel'
|
||||
/>
|
||||
}
|
||||
ariaLabel={label}
|
||||
onExited={onExited}
|
||||
onConfirm={handleOnConfirm}
|
||||
onChange={setSelectedDateTime}
|
||||
bodyPrefix={bodyPrefix}
|
||||
bodySuffix={bodySuffix}
|
||||
relativeDate={true}
|
||||
onCancel={onExited}
|
||||
errorText={errorMessage}
|
||||
timePickerInterval={timePickerInterval}
|
||||
timezone={activeTimezone}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DateTimePickerModal
|
||||
|
|
@ -108,7 +218,7 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
|
|||
onExited={onExited}
|
||||
onConfirm={handleOnConfirm}
|
||||
onChange={setSelectedDateTime}
|
||||
bodySuffix={bodySuffix}
|
||||
bodySuffix={legacyBodySuffix}
|
||||
relativeDate={true}
|
||||
onCancel={onExited}
|
||||
errorText={errorMessage}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
.scheduled_post_dm_custom_time_modal {
|
||||
.modal-subheader {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import {DateTime} from 'luxon';
|
||||
import React from 'react';
|
||||
|
||||
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import {WithTestMenuContext} from 'components/menu/menu_context_test';
|
||||
|
||||
|
|
@ -11,8 +12,13 @@ import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
|
|||
|
||||
import CoreMenuOptions from './core_menu_options';
|
||||
|
||||
jest.mock('components/advanced_text_editor/send_button/schedule_message_dm_utils', () => ({
|
||||
isDmScheduleRedesign: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/advanced_text_editor/use_post_box_indicator');
|
||||
const mockedUseTimePostBoxIndicator = jest.mocked(useTimePostBoxIndicator);
|
||||
const mockedIsDmScheduleRedesign = jest.mocked(isDmScheduleRedesign);
|
||||
|
||||
const teammateDisplayName = 'John Doe';
|
||||
const userCurrentTimezone = 'America/New_York';
|
||||
|
|
@ -24,8 +30,13 @@ const teammateTimezone = {
|
|||
const defaultUseTimePostBoxIndicatorReturnValue = {
|
||||
userCurrentTimezone: 'America/New_York',
|
||||
teammateTimezone,
|
||||
recipientTimezoneString: 'Europe/London',
|
||||
teammateDisplayName,
|
||||
teammateFirstName: 'John',
|
||||
teammate: undefined,
|
||||
isDM: false,
|
||||
isSelfDM: false,
|
||||
isBot: false,
|
||||
showRemoteUserHour: false,
|
||||
currentUserTimesStamp: 0,
|
||||
isScheduledPostEnabled: false,
|
||||
|
|
@ -49,12 +60,8 @@ describe('CoreMenuOptions Component', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
handleOnSelect.mockReset();
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue({
|
||||
...defaultUseTimePostBoxIndicatorReturnValue,
|
||||
isDM: false,
|
||||
isSelfDM: false,
|
||||
isBot: false,
|
||||
});
|
||||
mockedIsDmScheduleRedesign.mockReturnValue(false);
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue(defaultUseTimePostBoxIndicatorReturnValue as unknown as ReturnType<typeof useTimePostBoxIndicator>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -62,7 +69,7 @@ describe('CoreMenuOptions Component', () => {
|
|||
});
|
||||
|
||||
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
|
||||
renderWithContext(
|
||||
return renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<CoreMenuOptions
|
||||
handleOnSelect={handleOnSelectOverride}
|
||||
|
|
@ -106,28 +113,14 @@ describe('CoreMenuOptions Component', () => {
|
|||
expect(screen.queryByText(/Tomorrow at/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should include trailing element when isDM true', () => {
|
||||
setMockDate(2); // Tuesday
|
||||
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue({
|
||||
...defaultUseTimePostBoxIndicatorReturnValue,
|
||||
isDM: true,
|
||||
isSelfDM: false,
|
||||
isBot: false,
|
||||
});
|
||||
it('should render nothing when DM schedule redesign is active', () => {
|
||||
setMockDate(2);
|
||||
mockedIsDmScheduleRedesign.mockReturnValue(true);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Check the trailing element is rendered in the component
|
||||
expect(screen.getAllByText(/John Doe/)[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT include trailing element when isDM false', () => {
|
||||
setMockDate(2); // Tuesday
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByText(/John Doe/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Tomorrow at/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Monday at/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleOnSelect with the right timestamp if tomorrow option is clicked', () => {
|
||||
|
|
@ -148,68 +141,4 @@ describe('CoreMenuOptions Component', () => {
|
|||
|
||||
expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expectedTimestamp);
|
||||
});
|
||||
|
||||
it('should NOT include trailing element when isDM and isBot are true', () => {
|
||||
setMockDate(2); // Tuesday
|
||||
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue({
|
||||
...defaultUseTimePostBoxIndicatorReturnValue,
|
||||
isDM: true,
|
||||
isSelfDM: false,
|
||||
isBot: true,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Check the trailing element is NOT rendered in the component as this is a bot
|
||||
expect(screen.queryByText(/John Doe/)).toBeNull();
|
||||
});
|
||||
|
||||
it('should NOT include trailing element when the DM is with oneself', () => {
|
||||
setMockDate(2); // Tuesday
|
||||
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue({
|
||||
...defaultUseTimePostBoxIndicatorReturnValue,
|
||||
isDM: true,
|
||||
isSelfDM: true,
|
||||
isBot: false,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Check the trailing element is NOT rendered in the component as this is a bot
|
||||
expect(screen.queryByText(/John Doe/)).toBeNull();
|
||||
});
|
||||
|
||||
it('should format teammate time according to user locale', () => {
|
||||
setMockDate(2); // Tuesday
|
||||
|
||||
const stateWithFrenchLocale = {
|
||||
...initialState,
|
||||
entities: {
|
||||
...initialState.entities,
|
||||
users: {
|
||||
...initialState.entities.users,
|
||||
profiles: {
|
||||
currentUserId: {
|
||||
locale: 'fr',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue({
|
||||
...defaultUseTimePostBoxIndicatorReturnValue,
|
||||
isDM: true,
|
||||
isSelfDM: false,
|
||||
isBot: false,
|
||||
});
|
||||
|
||||
renderComponent(stateWithFrenchLocale);
|
||||
|
||||
// Verify French format (no AM/PM)
|
||||
const timeTexts = screen.getAllByText(/\d{2}:\d{2}(?!\s*[AP]M)/);
|
||||
expect(timeTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,40 +6,18 @@ import React, {memo, useCallback} from 'react';
|
|||
import {FormattedMessage} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentLocale} from 'selectors/i18n';
|
||||
|
||||
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import * as Menu from 'components/menu';
|
||||
import type {Props as MenuItemProps} from 'components/menu/menu_item';
|
||||
import Timestamp from 'components/timestamp';
|
||||
|
||||
import RecentUsedCustomDate from './recent_used_custom_date';
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
type Props = {
|
||||
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a timestamp in the teammate's timezone using the current user's locale.
|
||||
* @param userCurrentTimestamp - Timestamp in milliseconds (UTC)
|
||||
* @param teammateTimezoneString - IANA timezone string (e.g., "America/New_York")
|
||||
* @param userLocale - User's locale code (e.g., "fr", "en", "de")
|
||||
* @returns Formatted time string respecting the user's locale
|
||||
* @example
|
||||
* // US locale: "8:00 AM"
|
||||
* // French locale: "08:00"
|
||||
* getScheduledTimeInTeammateTimezone(1635768000000, 'Europe/Paris', 'fr')
|
||||
*/
|
||||
function getScheduledTimeInTeammateTimezone(userCurrentTimestamp: number, teammateTimezoneString: string, userLocale: string): string {
|
||||
const scheduledTimeUTC = DateTime.fromMillis(userCurrentTimestamp, {zone: 'utc'});
|
||||
const teammateScheduledTime = scheduledTimeUTC.setZone(teammateTimezoneString);
|
||||
const formattedTime = teammateScheduledTime.
|
||||
setLocale(userLocale).
|
||||
toLocaleString(DateTime.TIME_SIMPLE);
|
||||
return formattedTime;
|
||||
}
|
||||
|
||||
function getNextWeekday(dateTime: DateTime, targetWeekday: number) {
|
||||
const daysDifference = targetWeekday - dateTime.weekday;
|
||||
const adjustedDays = (daysDifference + 7) % 7;
|
||||
|
|
@ -48,16 +26,23 @@ function getNextWeekday(dateTime: DateTime, targetWeekday: number) {
|
|||
}
|
||||
|
||||
function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
||||
const {
|
||||
userCurrentTimezone,
|
||||
teammateTimezone,
|
||||
teammateDisplayName,
|
||||
isDM,
|
||||
isSelfDM,
|
||||
isBot,
|
||||
} = useTimePostBoxIndicator(channelId);
|
||||
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
|
||||
|
||||
if (isDmRedesign) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LegacyCoreMenuOptions
|
||||
handleOnSelect={handleOnSelect}
|
||||
channelId={channelId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LegacyCoreMenuOptions({handleOnSelect, channelId}: Props) {
|
||||
const {userCurrentTimezone} = useTimePostBoxIndicator(channelId);
|
||||
|
||||
const locale = useSelector(getCurrentLocale);
|
||||
const now = DateTime.now().setZone(userCurrentTimezone);
|
||||
const tomorrow9amTime = DateTime.now().
|
||||
setZone(userCurrentTimezone).
|
||||
|
|
@ -79,29 +64,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
|||
/>
|
||||
);
|
||||
|
||||
const extraProps: Partial<MenuItemProps> = {};
|
||||
|
||||
if (isDM && !isBot && !isSelfDM) {
|
||||
const teammateTimezoneString = teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
|
||||
const scheduledTimeInTeammateTimezone = getScheduledTimeInTeammateTimezone(tomorrow9amTime, teammateTimezoneString, locale);
|
||||
const teammateTimeDisplay = (
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.teammate_user_hour'
|
||||
defaultMessage='{time} {user}’s time'
|
||||
values={{
|
||||
user: (
|
||||
<span className='userDisplayName'>
|
||||
{teammateDisplayName}
|
||||
</span>
|
||||
),
|
||||
time: scheduledTimeInTeammateTimezone,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
extraProps.trailingElements = teammateTimeDisplay;
|
||||
}
|
||||
|
||||
const tomorrowClickHandler = useCallback((e: React.UIEvent) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
|
||||
|
||||
const optionTomorrow = (
|
||||
|
|
@ -118,7 +80,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
|||
}
|
||||
className='core-menu-options'
|
||||
autoFocus={true}
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -137,7 +98,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
|||
/>
|
||||
}
|
||||
className='core-menu-options'
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -157,7 +117,6 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
|||
}
|
||||
className='core-menu-options'
|
||||
autoFocus={now.weekday === 5 || now.weekday === 6}
|
||||
{...extraProps}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -185,17 +144,7 @@ function CoreMenuOptions({handleOnSelect, channelId}: Props) {
|
|||
options = [optionTomorrow, optionMonday];
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{options}
|
||||
<RecentUsedCustomDate
|
||||
handleOnSelect={handleOnSelect}
|
||||
userCurrentTimezone={userCurrentTimezone}
|
||||
tomorrow9amTime={tomorrow9amTime}
|
||||
nextMonday={nextMonday}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <>{options}</>;
|
||||
}
|
||||
|
||||
export default memo(CoreMenuOptions);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DateTime} from 'luxon';
|
||||
import React from 'react';
|
||||
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import {WithTestMenuContext} from 'components/menu/menu_context_test';
|
||||
|
||||
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import DmMenuOptions, {DmScheduleHeader} from './dm_menu_options';
|
||||
|
||||
jest.mock('components/advanced_text_editor/use_post_box_indicator');
|
||||
const mockedUseTimePostBoxIndicator = jest.mocked(useTimePostBoxIndicator);
|
||||
|
||||
const defaultHookValue = {
|
||||
userCurrentTimezone: 'America/New_York',
|
||||
teammateTimezone: {
|
||||
useAutomaticTimezone: true,
|
||||
automaticTimezone: 'Europe/London',
|
||||
manualTimezone: '',
|
||||
},
|
||||
recipientTimezoneString: 'Europe/London',
|
||||
teammateDisplayName: 'Sarah',
|
||||
teammateFirstName: 'Sarah',
|
||||
teammate: {
|
||||
position: 'San Francisco',
|
||||
timezone: {
|
||||
useAutomaticTimezone: 'true',
|
||||
automaticTimezone: 'Europe/London',
|
||||
manualTimezone: '',
|
||||
},
|
||||
},
|
||||
currentUserTimesStamp: DateTime.now().toMillis(),
|
||||
isDM: true,
|
||||
isSelfDM: false,
|
||||
isBot: false,
|
||||
showRemoteUserHour: false,
|
||||
isScheduledPostEnabled: true,
|
||||
showDndWarning: false,
|
||||
teammateId: 'user2',
|
||||
};
|
||||
|
||||
describe('DmMenuOptions', () => {
|
||||
const handleOnSelect = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
handleOnSelect.mockReset();
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue(defaultHookValue as ReturnType<typeof useTimePostBoxIndicator>);
|
||||
});
|
||||
|
||||
it('renders Their morning preset', () => {
|
||||
renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<DmMenuOptions
|
||||
handleOnSelect={handleOnSelect}
|
||||
channelId='channel1'
|
||||
/>
|
||||
</WithTestMenuContext>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Their morning')).toBeInTheDocument();
|
||||
expect(screen.getByText(/yours/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls handleOnSelect with their morning timestamp when clicked', () => {
|
||||
renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<DmMenuOptions
|
||||
handleOnSelect={handleOnSelect}
|
||||
channelId='channel1'
|
||||
/>
|
||||
</WithTestMenuContext>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('scheduling_time_their_morning'));
|
||||
|
||||
expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expect.any(Number));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DmScheduleHeader', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseTimePostBoxIndicator.mockReturnValue(defaultHookValue as ReturnType<typeof useTimePostBoxIndicator>);
|
||||
});
|
||||
|
||||
it('renders schedule for recipient header with location', () => {
|
||||
renderWithContext(
|
||||
<WithTestMenuContext>
|
||||
<DmScheduleHeader channelId='channel1'/>
|
||||
</WithTestMenuContext>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Schedule for Sarah/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/San Francisco/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/now/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {DateTime} from 'luxon';
|
||||
import React, {memo, useCallback, useMemo} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getCurrentLocale} from 'selectors/i18n';
|
||||
|
||||
import {
|
||||
getRecipientLocationLabel,
|
||||
getTheirMorningTimestamp,
|
||||
} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import * as Menu from 'components/menu';
|
||||
import Timestamp from 'components/timestamp';
|
||||
|
||||
type Props = {
|
||||
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
function formatTimeInTimezone(timestamp: number, timezone: string, locale: string): string {
|
||||
return DateTime.fromMillis(timestamp, {zone: 'utc'}).
|
||||
setZone(timezone).
|
||||
setLocale(locale).
|
||||
toLocaleString(DateTime.TIME_SIMPLE);
|
||||
}
|
||||
|
||||
function formatWeekdayInTimezone(timestamp: number, timezone: string, locale: string): string {
|
||||
return DateTime.fromMillis(timestamp, {zone: 'utc'}).
|
||||
setZone(timezone).
|
||||
setLocale(locale).
|
||||
toFormat('ccc');
|
||||
}
|
||||
|
||||
function DmMenuOptions({handleOnSelect, channelId}: Props) {
|
||||
const {
|
||||
userCurrentTimezone,
|
||||
recipientTimezoneString,
|
||||
} = useTimePostBoxIndicator(channelId);
|
||||
|
||||
const locale = useSelector(getCurrentLocale);
|
||||
|
||||
const theirMorningTimestamp = useMemo(
|
||||
() => getTheirMorningTimestamp(recipientTimezoneString),
|
||||
[recipientTimezoneString],
|
||||
);
|
||||
|
||||
const theirMorningSubtitle = useMemo(() => {
|
||||
const theirDay = formatWeekdayInTimezone(theirMorningTimestamp, recipientTimezoneString, locale);
|
||||
const theirTime = formatTimeInTimezone(theirMorningTimestamp, recipientTimezoneString, locale);
|
||||
const senderTime = formatTimeInTimezone(theirMorningTimestamp, userCurrentTimezone, locale);
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.their_morning.subtitle'
|
||||
defaultMessage='{theirDay} {theirTime} · {senderTime} yours'
|
||||
values={{
|
||||
theirDay,
|
||||
theirTime,
|
||||
senderTime,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}, [theirMorningTimestamp, recipientTimezoneString, userCurrentTimezone, locale]);
|
||||
|
||||
const handleTheirMorningClick = useCallback(
|
||||
(e: React.UIEvent) => handleOnSelect(e, theirMorningTimestamp),
|
||||
[handleOnSelect, theirMorningTimestamp],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
key='scheduling_time_their_morning'
|
||||
data-testid='scheduling_time_their_morning'
|
||||
onClick={handleTheirMorningClick}
|
||||
labels={
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.their_morning'
|
||||
defaultMessage='Their morning'
|
||||
/>
|
||||
</span>
|
||||
<span className='secondary-label'>
|
||||
{theirMorningSubtitle}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
className='core-menu-options dm-menu-options'
|
||||
autoFocus={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function DmScheduleHeader({channelId}: {channelId: string}) {
|
||||
const {
|
||||
teammateTimezone,
|
||||
teammateDisplayName,
|
||||
recipientTimezoneString,
|
||||
teammate,
|
||||
currentUserTimesStamp,
|
||||
} = useTimePostBoxIndicator(channelId);
|
||||
|
||||
const locationLabel = useMemo(
|
||||
() => getRecipientLocationLabel(teammate, recipientTimezoneString),
|
||||
[teammate, recipientTimezoneString],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
disabled={true}
|
||||
labels={
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.dm_header'
|
||||
defaultMessage='Schedule for {recipientName}'
|
||||
values={{recipientName: teammateDisplayName}}
|
||||
/>
|
||||
</span>
|
||||
<span className='secondary-label'>
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.dm_header.subtitle'
|
||||
defaultMessage='{location} · {time} now'
|
||||
values={{
|
||||
location: locationLabel,
|
||||
time: (
|
||||
<Timestamp
|
||||
value={currentUserTimesStamp}
|
||||
useDate={false}
|
||||
userTimezone={teammateTimezone}
|
||||
useTime={{
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
className='dm-schedule-header'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(DmMenuOptions);
|
||||
|
|
@ -4,18 +4,24 @@
|
|||
import classNames from 'classnames';
|
||||
import React, {useCallback} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
|
||||
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
|
||||
|
||||
import {openModal} from 'actions/views/modals';
|
||||
|
||||
import {isDmScheduleRedesign} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import CoreMenuOptions from 'components/advanced_text_editor/send_button/send_post_options/core_menu_options';
|
||||
import DmMenuOptions, {DmScheduleHeader} from 'components/advanced_text_editor/send_button/send_post_options/dm_menu_options';
|
||||
import RecentUsedCustomDate from 'components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date';
|
||||
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
|
||||
import * as Menu from 'components/menu';
|
||||
|
||||
import {ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import ScheduledPostCustomTimeModal from '../scheduled_post_custom_time_modal/scheduled_post_custom_time_modal';
|
||||
|
||||
import './style.scss';
|
||||
|
|
@ -29,6 +35,8 @@ type Props = {
|
|||
export function SendPostOptions({disabled, onSelect, channelId}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const isDmRedesign = useSelector((state: GlobalState) => isDmScheduleRedesign(state, channelId));
|
||||
const {userCurrentTimezone, recipientTimezoneString} = useTimePostBoxIndicator(channelId);
|
||||
|
||||
const handleOnSelect = useCallback((e: React.FormEvent, scheduledAt: number) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -61,6 +69,10 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
|
|||
}));
|
||||
}, [channelId, dispatch, handleSelectCustomTime]);
|
||||
|
||||
const customTimeLeadingElement = isDmRedesign ? (
|
||||
<i className='icon-calendar-outline'/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Menu.Container
|
||||
menuButtonTooltip={{
|
||||
|
|
@ -92,19 +104,38 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
|
|||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<Menu.Item
|
||||
disabled={true}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.header'
|
||||
defaultMessage='Schedule message'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{isDmRedesign ? (
|
||||
<DmScheduleHeader channelId={channelId}/>
|
||||
) : (
|
||||
<Menu.Item
|
||||
disabled={true}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.header'
|
||||
defaultMessage='Schedule message'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CoreMenuOptions
|
||||
{isDmRedesign ? (
|
||||
<DmMenuOptions
|
||||
handleOnSelect={handleOnSelect}
|
||||
channelId={channelId}
|
||||
/>
|
||||
) : (
|
||||
<CoreMenuOptions
|
||||
handleOnSelect={handleOnSelect}
|
||||
channelId={channelId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecentUsedCustomDate
|
||||
handleOnSelect={handleOnSelect}
|
||||
userCurrentTimezone={userCurrentTimezone}
|
||||
channelId={channelId}
|
||||
isDmRedesign={isDmRedesign}
|
||||
recipientTimezoneString={recipientTimezoneString}
|
||||
/>
|
||||
|
||||
<Menu.Separator/>
|
||||
|
|
@ -112,10 +143,11 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
|
|||
<Menu.Item
|
||||
onClick={handleChooseCustomTime}
|
||||
key={'choose_custom_time'}
|
||||
leadingElement={customTimeLeadingElement}
|
||||
labels={
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.choose_custom_time'
|
||||
defaultMessage='Choose a custom time'
|
||||
defaultMessage='Choose a custom time…'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ describe('CoreMenuOptions Component', () => {
|
|||
jest.useFakeTimers();
|
||||
jest.setSystemTime(now.toJSDate());
|
||||
|
||||
// Hardcode `tomorrow9amTime` and `nextMonday`
|
||||
tomorrow9amTime = DateTime.fromISO('2024-11-02T09:00:00', {zone: userCurrentTimezone}).toMillis();
|
||||
nextMonday = DateTime.fromISO('2024-11-04T09:00:00', {zone: userCurrentTimezone}).toMillis();
|
||||
});
|
||||
|
|
@ -81,13 +80,18 @@ describe('CoreMenuOptions Component', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
|
||||
function renderComponent(
|
||||
state = initialState,
|
||||
handleOnSelectOverride = handleOnSelect,
|
||||
options: {isDmRedesign?: boolean; recipientTimezoneString?: string} = {},
|
||||
) {
|
||||
renderWithContext(
|
||||
<RecentUsedCustomDate
|
||||
handleOnSelect={handleOnSelectOverride}
|
||||
userCurrentTimezone={userCurrentTimezone}
|
||||
tomorrow9amTime={tomorrow9amTime}
|
||||
nextMonday={nextMonday}
|
||||
channelId='channelId'
|
||||
isDmRedesign={options.isDmRedesign}
|
||||
recipientTimezoneString={options.recipientTimezoneString}
|
||||
/>,
|
||||
state,
|
||||
);
|
||||
|
|
@ -274,4 +278,23 @@ describe('CoreMenuOptions Component', () => {
|
|||
|
||||
expect(screen.getByText(new RegExp(`${monthDay} at`))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render DM redesign labels when isDmRedesign is true', () => {
|
||||
const recentTimestamp = DateTime.now().plus({days: 7}).toMillis();
|
||||
|
||||
const recentlyUsedCustomDateVal = {
|
||||
update_at: DateTime.now().toMillis(),
|
||||
timestamp: recentTimestamp,
|
||||
};
|
||||
|
||||
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
|
||||
|
||||
renderComponent(state, handleOnSelect, {
|
||||
isDmRedesign: true,
|
||||
recipientTimezoneString: 'Europe/London',
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Your time · recently used/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {GlobalState} from '@mattermost/types/store';
|
|||
|
||||
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {getTheirMorningTimestamp} from 'components/advanced_text_editor/send_button/schedule_message_dm_utils';
|
||||
import * as Menu from 'components/menu';
|
||||
import Timestamp, {RelativeRanges} from 'components/timestamp';
|
||||
|
||||
|
|
@ -19,8 +20,9 @@ import {scheduledPosts} from 'utils/constants';
|
|||
type Props = {
|
||||
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
|
||||
userCurrentTimezone: string;
|
||||
tomorrow9amTime: number;
|
||||
nextMonday: number;
|
||||
channelId: string;
|
||||
isDmRedesign?: boolean;
|
||||
recipientTimezoneString?: string;
|
||||
}
|
||||
|
||||
const DATE_RANGES = [
|
||||
|
|
@ -48,15 +50,13 @@ function shouldShowRecentlyUsedCustomTime(
|
|||
nowMillis: number,
|
||||
recentlyUsedCustomDateVal: RecentlyUsedCustomDate,
|
||||
userCurrentTimezone: string,
|
||||
tomorrow9amTime: number,
|
||||
nextMonday: number,
|
||||
excludedTimestamps: number[],
|
||||
) {
|
||||
return recentlyUsedCustomDateVal &&
|
||||
typeof recentlyUsedCustomDateVal.update_at === 'number' &&
|
||||
typeof recentlyUsedCustomDateVal.timestamp === 'number' &&
|
||||
recentlyUsedCustomDateVal.timestamp > nowMillis && // is in the future
|
||||
recentlyUsedCustomDateVal.timestamp !== tomorrow9amTime && // is not the existing option tomorrow 9a.m
|
||||
recentlyUsedCustomDateVal.timestamp !== nextMonday && // is not the existing option tomorrow 9a.m
|
||||
recentlyUsedCustomDateVal.timestamp > nowMillis &&
|
||||
!excludedTimestamps.includes(recentlyUsedCustomDateVal.timestamp) &&
|
||||
isTimestampWithinLast30Days(recentlyUsedCustomDateVal.update_at, userCurrentTimezone);
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +73,12 @@ function getDateOption(now: DateTime, timestamp: number | undefined, userCurrent
|
|||
return isInCurrentWeek ? USE_DATE_WEEKDAY_LONG : USE_DATE_MONTH_DAY;
|
||||
}
|
||||
|
||||
function RecentUsedCustomDate({handleOnSelect, userCurrentTimezone, nextMonday, tomorrow9amTime}: Props) {
|
||||
function RecentUsedCustomDate({
|
||||
handleOnSelect,
|
||||
userCurrentTimezone,
|
||||
isDmRedesign,
|
||||
recipientTimezoneString,
|
||||
}: Props) {
|
||||
const now = DateTime.now().setZone(userCurrentTimezone);
|
||||
const recentlyUsedCustomDate = useSelector((state: GlobalState) => getPreference(state, scheduledPosts.SCHEDULED_POSTS, scheduledPosts.RECENTLY_USED_CUSTOM_TIME));
|
||||
const recentlyUsedCustomDateVal: RecentlyUsedCustomDate = useMemo(() => {
|
||||
|
|
@ -86,16 +91,101 @@ function RecentUsedCustomDate({handleOnSelect, userCurrentTimezone, nextMonday,
|
|||
}
|
||||
return {};
|
||||
}, [recentlyUsedCustomDate]);
|
||||
const handleRecentlyUsedCustomTime = useCallback((e: React.UIEvent) => handleOnSelect(e, recentlyUsedCustomDateVal.timestamp!), [handleOnSelect, recentlyUsedCustomDateVal.timestamp]);
|
||||
|
||||
const excludedTimestamps = useMemo(() => {
|
||||
if (isDmRedesign && recipientTimezoneString) {
|
||||
return [getTheirMorningTimestamp(recipientTimezoneString)];
|
||||
}
|
||||
|
||||
const tomorrow9amTime = DateTime.now().
|
||||
setZone(userCurrentTimezone).
|
||||
plus({days: 1}).
|
||||
set({hour: 9, minute: 0, second: 0, millisecond: 0}).
|
||||
toMillis();
|
||||
|
||||
const nextMonday = (() => {
|
||||
const nowDt = DateTime.now().setZone(userCurrentTimezone);
|
||||
const daysDifference = 1 - nowDt.weekday;
|
||||
const adjustedDays = (daysDifference + 7) % 7;
|
||||
const deltaDays = adjustedDays === 0 ? 7 : adjustedDays;
|
||||
return nowDt.plus({days: deltaDays}).set({
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
}).toMillis();
|
||||
})();
|
||||
|
||||
return [tomorrow9amTime, nextMonday];
|
||||
}, [isDmRedesign, recipientTimezoneString, userCurrentTimezone]);
|
||||
|
||||
const handleRecentlyUsedCustomTime = useCallback(
|
||||
(e: React.UIEvent) => handleOnSelect(e, recentlyUsedCustomDateVal.timestamp!),
|
||||
[handleOnSelect, recentlyUsedCustomDateVal.timestamp],
|
||||
);
|
||||
|
||||
if (
|
||||
!shouldShowRecentlyUsedCustomTime(now.toMillis(), recentlyUsedCustomDateVal, userCurrentTimezone, tomorrow9amTime, nextMonday)
|
||||
!shouldShowRecentlyUsedCustomTime(now.toMillis(), recentlyUsedCustomDateVal, userCurrentTimezone, excludedTimestamps)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dateOption = getDateOption(now, recentlyUsedCustomDateVal.timestamp, userCurrentTimezone);
|
||||
|
||||
if (isDmRedesign) {
|
||||
const timeOnly = (
|
||||
<Timestamp
|
||||
ranges={DATE_RANGES}
|
||||
value={recentlyUsedCustomDateVal.timestamp}
|
||||
timeZone={userCurrentTimezone}
|
||||
useDate={false}
|
||||
useTime={USE_TIME_HOUR_MINUTE_NUMERIC}
|
||||
/>
|
||||
);
|
||||
|
||||
const dayLabel = (
|
||||
<Timestamp
|
||||
ranges={DATE_RANGES}
|
||||
value={recentlyUsedCustomDateVal.timestamp}
|
||||
timeZone={userCurrentTimezone}
|
||||
useDate={dateOption}
|
||||
useTime={false}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Separator key='recent_custom_separator'/>
|
||||
<Menu.Item
|
||||
key='recently_used_custom_time'
|
||||
data-testid='recently_used_custom_time'
|
||||
onClick={handleRecentlyUsedCustomTime}
|
||||
labels={
|
||||
<>
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.recently_used_dm.primary'
|
||||
defaultMessage='{day} at {time}'
|
||||
values={{
|
||||
day: dayLabel,
|
||||
time: timeOnly,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className='secondary-label'>
|
||||
<FormattedMessage
|
||||
id='create_post_button.option.schedule_message.options.recently_used_dm.subtitle'
|
||||
defaultMessage='Your time · recently used'
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
className='core-menu-options dm-menu-options'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = (
|
||||
<Timestamp
|
||||
ranges={DATE_RANGES}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,32 @@ ul#dropdown_send_post_options {
|
|||
}
|
||||
}
|
||||
|
||||
li.core-menu-options{
|
||||
li.dm-schedule-header {
|
||||
.label-elements {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.secondary-label {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
li.core-menu-options,
|
||||
li.dm-menu-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: baseline;
|
||||
|
||||
.secondary-label {
|
||||
margin-top: 2px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.64);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.trailing-elements {
|
||||
margin-top: 4px;
|
||||
margin-left: 0;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
getUser,
|
||||
makeGetDisplayName,
|
||||
} from 'mattermost-redux/selectors/entities/users';
|
||||
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
|
||||
|
||||
import Constants, {UserStatuses} from 'utils/constants';
|
||||
|
||||
|
|
@ -103,17 +104,21 @@ function useTimePostBoxIndicator(channelId: string) {
|
|||
const isSelfDM = isDM && teammateId === currentUserId;
|
||||
|
||||
const showRemoteUserHour = isDM && showIt && timestamp !== 0 && !isBot;
|
||||
const recipientTimezoneString = getUserCurrentTimezone(teammateTimezone);
|
||||
|
||||
return {
|
||||
showRemoteUserHour,
|
||||
isDM,
|
||||
currentUserTimesStamp: timestamp,
|
||||
teammateTimezone,
|
||||
recipientTimezoneString,
|
||||
userCurrentTimezone,
|
||||
isScheduledPostEnabled: isScheduledPostEnabledValue,
|
||||
showDndWarning,
|
||||
teammateId,
|
||||
teammate,
|
||||
teammateDisplayName,
|
||||
teammateFirstName: teammate?.first_name || teammateDisplayName,
|
||||
isSelfDM,
|
||||
isBot,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ type Props = {
|
|||
className?: string;
|
||||
errorText?: string | React.ReactNode;
|
||||
timePickerInterval?: number;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export default function DateTimePickerModal({
|
||||
|
|
@ -54,15 +55,23 @@ export default function DateTimePickerModal({
|
|||
className,
|
||||
errorText,
|
||||
timePickerInterval,
|
||||
timezone: timezoneProp,
|
||||
}: Props) {
|
||||
const userTimezone = useSelector(getCurrentTimezone);
|
||||
const currentTime = getCurrentMomentForTimezone(userTimezone);
|
||||
const activeTimezone = timezoneProp || userTimezone;
|
||||
const currentTime = getCurrentMomentForTimezone(activeTimezone);
|
||||
const initialRoundedTime = getRoundedTime(currentTime);
|
||||
|
||||
const [dateTime, setDateTime] = useState(initialTime || initialRoundedTime);
|
||||
|
||||
const [isInteracting, setIsInteracting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTime) {
|
||||
setDateTime(initialTime);
|
||||
}
|
||||
}, [initialTime?.valueOf()]); // eslint-disable-line react-hooks/exhaustive-deps -- sync when parent updates time
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isInteracting) {
|
||||
|
|
@ -120,7 +129,7 @@ export default function DateTimePickerModal({
|
|||
<DateTimeInput
|
||||
time={dateTime}
|
||||
handleChange={handleChange}
|
||||
timezone={userTimezone}
|
||||
timezone={activeTimezone}
|
||||
setIsInteracting={setIsInteracting}
|
||||
relativeDate={relativeDate}
|
||||
timePickerInterval={timePickerInterval}
|
||||
|
|
|
|||
|
|
@ -4552,12 +4552,17 @@
|
|||
"create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.",
|
||||
"create_group_memberships_modal.title": "Re-add {username} to teams and channels",
|
||||
"create_post_button.option.schedule_message": "Schedule message",
|
||||
"create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time",
|
||||
"create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time…",
|
||||
"create_post_button.option.schedule_message.options.dm_header": "Schedule for {recipientName}",
|
||||
"create_post_button.option.schedule_message.options.dm_header.subtitle": "{location} · {time} now",
|
||||
"create_post_button.option.schedule_message.options.header": "Schedule message",
|
||||
"create_post_button.option.schedule_message.options.monday": "Monday at {9amTime}",
|
||||
"create_post_button.option.schedule_message.options.next_monday": "Next Monday at {9amTime}",
|
||||
"create_post_button.option.schedule_message.options.recently_used_custom_time": "Recently used custom time",
|
||||
"create_post_button.option.schedule_message.options.teammate_user_hour": "{time} {user}’s time",
|
||||
"create_post_button.option.schedule_message.options.recently_used_dm.primary": "{day} at {time}",
|
||||
"create_post_button.option.schedule_message.options.recently_used_dm.subtitle": "Your time · recently used",
|
||||
"create_post_button.option.schedule_message.options.their_morning": "Their morning",
|
||||
"create_post_button.option.schedule_message.options.their_morning.subtitle": "{theirDay} {theirTime} · {senderTime} yours",
|
||||
"create_post_button.option.schedule_message.options.tomorrow": "Tomorrow at {9amTime}",
|
||||
"create_post_button.option.send_now": "Send Now",
|
||||
"create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.",
|
||||
|
|
@ -6260,7 +6265,12 @@
|
|||
"saveChangesPanel.saved": "Settings saved",
|
||||
"schedule_post.custom_time_modal.cancel_button_text": "Cancel",
|
||||
"schedule_post.custom_time_modal.confirm_button_text": "Schedule",
|
||||
"schedule_post.custom_time_modal.dm_subtitle": "to {recipientName}",
|
||||
"schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}",
|
||||
"schedule_post.custom_time_modal.perspective.mine": "My time",
|
||||
"schedule_post.custom_time_modal.perspective.theirs": "{name}'s time",
|
||||
"schedule_post.custom_time_modal.preview.recipient_receives": "{recipientName} receives at",
|
||||
"schedule_post.custom_time_modal.preview.you_send": "You send at",
|
||||
"schedule_post.custom_time_modal.title": "Schedule message",
|
||||
"Schedule_post.empty_state.subtitle": "Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.",
|
||||
"Schedule_post.empty_state.title": "No scheduled drafts at the moment",
|
||||
|
|
|
|||
Loading…
Reference in a new issue