MM-60874 - show usertz in scheduled message options (#29116)

* MM-60874 - show usertz in schedule message time options

* get the teammate time working with moment

* replace moment with luxon

* add unit tests to core_menu_options

* fix linter

* address pr feedback

* MM-60123 - store most recently custom time (#29120)

* MM-60123 - store most recently used custom time

* add the most recent used custom date option

* improve the code and add some validations

* add unit tests to cover the recently used time scenario

* clean up code in unit tests

* fix linter report

* fix translation files

* address PR feedback

* do not show recent custom time if matches any of the existing options

* prevent show date if is in the past and update unit tests

* format custom date based on week and add unit tests for new scenarios

* remove unused mock, clean up code, extract code to function

* extract recent used custom date logic to be an independent component

* address PR comments for improvements

* turn function into selector
This commit is contained in:
Pablo Vélez 2024-11-07 16:02:22 +01:00 committed by GitHub
parent c90e562528
commit 37d97e8024
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 823 additions and 85 deletions

View File

@ -1,30 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {DateTime} from 'luxon'; import React from 'react';
import React, {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import RemoteUserHour from 'components/advanced_text_editor/remote_user_hour'; import RemoteUserHour from 'components/advanced_text_editor/remote_user_hour';
import ScheduledPostIndicator from 'components/advanced_text_editor/scheduled_post_indicator/scheduled_post_indicator'; import ScheduledPostIndicator from 'components/advanced_text_editor/scheduled_post_indicator/scheduled_post_indicator';
import Constants, {UserStatuses} from 'utils/constants'; import useTimePostBoxIndicator from '../use_post_box_indicator';
import type {GlobalState} from 'types/store';
import './style.scss'; import './style.scss';
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
type Props = { type Props = {
channelId: string; channelId: string;
teammateDisplayName: string; teammateDisplayName: string;
@ -33,37 +18,12 @@ type Props = {
} }
export default function PostBoxIndicator({channelId, teammateDisplayName, location, postId}: Props) { export default function PostBoxIndicator({channelId, teammateDisplayName, location, postId}: Props) {
const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || ''); const {
const isTeammateDND = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false)); showRemoteUserHour,
const isDM = useSelector((state: GlobalState) => Boolean(getDirectChannel(state, channelId)?.teammate_id)); isScheduledPostEnabled,
const showDndWarning = isTeammateDND && isDM; currentUserTimesStamp,
teammateTimezone,
const [timestamp, setTimestamp] = useState(0); } = useTimePostBoxIndicator(channelId);
const [showIt, setShowIt] = useState(false);
const teammateTimezone = useSelector((state: GlobalState) => {
const teammate = teammateId ? getUser(state, teammateId) : undefined;
return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE;
}, (a, b) => a.automaticTimezone === b.automaticTimezone &&
a.manualTimezone === b.manualTimezone &&
a.useAutomaticTimezone === b.useAutomaticTimezone);
useEffect(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
const interval = setInterval(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
}, 1000 * 60);
return () => clearInterval(interval);
}, [teammateTimezone.useAutomaticTimezone, teammateTimezone.automaticTimezone, teammateTimezone.manualTimezone]);
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
const showRemoteUserHour = showDndWarning && showIt && timestamp !== 0;
return ( return (
<div className='postBoxIndicator'> <div className='postBoxIndicator'>
@ -71,7 +31,7 @@ export default function PostBoxIndicator({channelId, teammateDisplayName, locati
showRemoteUserHour && showRemoteUserHour &&
<RemoteUserHour <RemoteUserHour
displayName={teammateDisplayName} displayName={teammateDisplayName}
timestamp={timestamp} timestamp={currentUserTimesStamp}
teammateTimezone={teammateTimezone} teammateTimezone={teammateTimezone}
/> />
} }

View File

@ -5,15 +5,19 @@ import moment from 'moment';
import type {Moment} from 'moment-timezone'; import type {Moment} from 'moment-timezone';
import React, {useCallback, useMemo, useState} from 'react'; import React, {useCallback, useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux'; import {useDispatch, useSelector} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import { import {
DMUserTimezone, DMUserTimezone,
} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone'; } from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone';
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal'; import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
import {scheduledPosts} from 'utils/constants';
type Props = { type Props = {
channelId: string; channelId: string;
onExited: () => void; onExited: () => void;
@ -25,19 +29,35 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
const userTimezone = useSelector(getCurrentTimezone); const userTimezone = useSelector(getCurrentTimezone);
const now = moment().tz(userTimezone);
const currentUserId = useSelector(getCurrentUserId);
const dispatch = useDispatch();
const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => { const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => {
if (initialTime) { if (initialTime) {
return initialTime; return initialTime;
} }
const now = moment().tz(userTimezone);
return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0}); return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0});
}); });
const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]); const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]);
const handleOnConfirm = useCallback(async (dateTime: Moment) => { const handleOnConfirm = useCallback(async (dateTime: Moment) => {
const response = await onConfirm(dateTime.valueOf()); const selectedTime = dateTime.valueOf();
const response = await onConfirm(selectedTime);
dispatch(
savePreferences(
currentUserId,
[{
user_id: currentUserId,
category: scheduledPosts.SCHEDULED_POSTS,
name: scheduledPosts.RECENTLY_USED_CUSTOM_TIME,
value: JSON.stringify({update_at: moment().tz(userTimezone).valueOf(), timestamp: selectedTime}),
}],
),
);
if (response.error) { if (response.error) {
setErrorMessage(response.error); setErrorMessage(response.error);
} else { } else {

View File

@ -0,0 +1,155 @@
// 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 {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
import CoreMenuOptions from './core_menu_options';
jest.mock('components/menu', () => ({
__esModule: true,
Item: jest.fn(({labels, trailingElements, children, ...props}) => (
<div {...props}>
{labels}
{children}
{trailingElements}
</div>
)),
Separator: jest.fn(() => <div className='menu-separator'/>),
}));
jest.mock('components/advanced_text_editor/use_post_box_indicator');
const mockedUseTimePostBoxIndicator = jest.mocked(useTimePostBoxIndicator);
const teammateDisplayName = 'John Doe';
const userCurrentTimezone = 'America/New_York';
const teammateTimezone = {
useAutomaticTimezone: true,
automaticTimezone: 'Europe/London',
manualTimezone: '',
};
const defaultUseTimePostBoxIndicatorReturnValue = {
userCurrentTimezone: 'America/New_York',
teammateTimezone,
teammateDisplayName,
isDM: false,
showRemoteUserHour: false,
currentUserTimesStamp: 0,
isScheduledPostEnabled: false,
showDndWarning: false,
teammateId: '',
};
const initialState = {
entities: {
preferences: {
myPreferences: {},
},
users: {
currentUserId: 'currentUserId',
},
},
};
describe('CoreMenuOptions Component', () => {
const handleOnSelect = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
handleOnSelect.mockReset();
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: false,
});
});
afterEach(() => {
jest.useRealTimers();
});
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
renderWithContext(
<CoreMenuOptions
handleOnSelect={handleOnSelectOverride}
channelId='channelId'
/>,
state,
);
}
function setMockDate(weekday: number) {
const mockDate = DateTime.fromObject({weekday}, {zone: userCurrentTimezone}).toJSDate();
jest.useFakeTimers();
jest.setSystemTime(mockDate);
}
it('should render tomorrow option on Sunday', () => {
setMockDate(7); // Sunday
renderComponent();
expect(screen.getByText(/Tomorrow at/)).toBeInTheDocument();
expect(screen.queryByText(/Monday at/)).not.toBeInTheDocument();
});
it('should render tomorrow and next Monday options on Monday', () => {
setMockDate(1); // Monday
renderComponent();
expect(screen.getByText(/Tomorrow at/)).toBeInTheDocument();
expect(screen.getByText(/Next Monday at/)).toBeInTheDocument();
});
it('should render Monday option on Friday', () => {
setMockDate(5); // Friday
renderComponent();
expect(screen.getByText(/Monday at/)).toBeInTheDocument();
expect(screen.queryByText(/Tomorrow at/)).not.toBeInTheDocument();
});
it('should include trailing element when isDM true', () => {
setMockDate(2); // Tuesday
mockedUseTimePostBoxIndicator.mockReturnValue({
...defaultUseTimePostBoxIndicatorReturnValue,
isDM: 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();
});
it('should call handleOnSelect with the right timestamp if tomorrow option is clicked', () => {
setMockDate(3); // Wednesday
renderComponent();
const tomorrowOption = screen.getByText(/Tomorrow at/);
fireEvent.click(tomorrowOption);
const expectedTimestamp = DateTime.now().
setZone(userCurrentTimezone).
plus({days: 1}).
set({hour: 9, minute: 0, second: 0, millisecond: 0}).
toMillis();
expect(handleOnSelect).toHaveBeenCalledWith(expect.anything(), expectedTimestamp);
});
});

View File

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import moment from 'moment'; import {DateTime} from 'luxon';
import React, {memo, useCallback, useEffect} from 'react'; import React, {memo, useCallback, useEffect} from 'react';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux'; import {useSelector} from 'react-redux';
@ -10,20 +10,44 @@ import {
TrackPropertyUser, TrackPropertyUserAgent, TrackPropertyUser, TrackPropertyUserAgent,
TrackScheduledPostsFeature, TrackScheduledPostsFeature,
} from 'mattermost-redux/constants/telemetry'; } from 'mattermost-redux/constants/telemetry';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {trackFeatureEvent} from 'actions/telemetry_actions'; import {trackFeatureEvent} from 'actions/telemetry_actions';
import useTimePostBoxIndicator from 'components/advanced_text_editor/use_post_box_indicator';
import * as Menu from 'components/menu'; import * as Menu from 'components/menu';
import type {Props as MenuItemProps} from 'components/menu/menu_item';
import Timestamp from 'components/timestamp'; import Timestamp from 'components/timestamp';
import RecentUsedCustomDate from './recent_used_custom_date';
type Props = { type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void; handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
channelId: string;
} }
function CoreMenuOptions({handleOnSelect}: Props) { function getScheduledTimeInTeammateTimezone(userCurrentTimestamp: number, teammateTimezoneString: string): string {
const userTimezone = useSelector(getCurrentTimezone); const scheduledTimeUTC = DateTime.fromMillis(userCurrentTimestamp, {zone: 'utc'});
const teammateScheduledTime = scheduledTimeUTC.setZone(teammateTimezoneString);
const formattedTime = teammateScheduledTime.toFormat('h:mm a');
return formattedTime;
}
function getNextWeekday(dateTime: DateTime, targetWeekday: number) {
const daysDifference = targetWeekday - dateTime.weekday;
const adjustedDays = (daysDifference + 7) % 7;
const deltaDays = adjustedDays === 0 ? 7 : adjustedDays;
return dateTime.plus({days: deltaDays});
}
function CoreMenuOptions({handleOnSelect, channelId}: Props) {
const {
userCurrentTimezone,
teammateTimezone,
teammateDisplayName,
isDM,
} = useTimePostBoxIndicator(channelId);
const currentUserId = useSelector(getCurrentUserId); const currentUserId = useSelector(getCurrentUserId);
useEffect(() => { useEffect(() => {
@ -40,12 +64,19 @@ function CoreMenuOptions({handleOnSelect}: Props) {
); );
}, [currentUserId]); }, [currentUserId]);
const today = moment().tz(userTimezone); const now = DateTime.now().setZone(userCurrentTimezone);
const tomorrow9amTime = moment(). const tomorrow9amTime = DateTime.now().
tz(userTimezone). setZone(userCurrentTimezone).
add(1, 'days'). plus({days: 1}).
set({hour: 9, minute: 0, second: 0, millisecond: 0}). set({hour: 9, minute: 0, second: 0, millisecond: 0}).
valueOf(); toMillis();
const nextMonday = getNextWeekday(now, 1).set({
hour: 9,
minute: 0,
second: 0,
millisecond: 0,
}).toMillis();
const timeComponent = ( const timeComponent = (
<Timestamp <Timestamp
@ -54,6 +85,29 @@ function CoreMenuOptions({handleOnSelect}: Props) {
/> />
); );
const extraProps: Partial<MenuItemProps> = {};
if (isDM) {
const teammateTimezoneString = teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
const scheduledTimeInTeammateTimezone = getScheduledTimeInTeammateTimezone(tomorrow9amTime, teammateTimezoneString);
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) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]); const tomorrowClickHandler = useCallback((e) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
const optionTomorrow = ( const optionTomorrow = (
@ -67,15 +121,11 @@ function CoreMenuOptions({handleOnSelect}: Props) {
values={{'9amTime': timeComponent}} values={{'9amTime': timeComponent}}
/> />
} }
className='core-menu-options'
{...extraProps}
/> />
); );
const nextMonday = moment().
tz(userTimezone).
day(8). // next monday; 1 = Monday, 8 = next Monday
set({hour: 9, minute: 0, second: 0, millisecond: 0}). // 9 AM
valueOf();
const nextMondayClickHandler = useCallback((e) => handleOnSelect(e, nextMonday), [handleOnSelect, nextMonday]); const nextMondayClickHandler = useCallback((e) => handleOnSelect(e, nextMonday), [handleOnSelect, nextMonday]);
const optionNextMonday = ( const optionNextMonday = (
@ -89,6 +139,8 @@ function CoreMenuOptions({handleOnSelect}: Props) {
values={{'9amTime': timeComponent}} values={{'9amTime': timeComponent}}
/> />
} }
className='core-menu-options'
{...extraProps}
/> />
); );
@ -105,14 +157,16 @@ function CoreMenuOptions({handleOnSelect}: Props) {
}} }}
/> />
} }
className='core-menu-options'
{...extraProps}
/> />
); );
let options: React.ReactElement[] = []; let options: React.ReactElement[] = [];
switch (today.day()) { switch (now.weekday) {
// Sunday // Sunday
case 0: case 7:
options = [optionTomorrow]; options = [optionTomorrow];
break; break;
@ -133,9 +187,15 @@ function CoreMenuOptions({handleOnSelect}: Props) {
} }
return ( return (
<React.Fragment> <>
{options} {options}
</React.Fragment> <RecentUsedCustomDate
handleOnSelect={handleOnSelect}
userCurrentTimezone={userCurrentTimezone}
tomorrow9amTime={tomorrow9amTime}
nextMonday={nextMonday}
/>
</>
); );
} }

View File

@ -103,7 +103,10 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
} }
/> />
<CoreMenuOptions handleOnSelect={handleOnSelect}/> <CoreMenuOptions
handleOnSelect={handleOnSelect}
channelId={channelId}
/>
<Menu.Separator/> <Menu.Separator/>

View File

@ -0,0 +1,276 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React from 'react';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
import {scheduledPosts} from 'utils/constants';
import RecentUsedCustomDate from './recent_used_custom_date';
jest.mock('components/advanced_text_editor/use_post_box_indicator', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('components/menu', () => ({
__esModule: true,
Item: jest.fn(({labels, trailingElements, children, ...props}) => (
<div {...props}>
{labels}
{children}
{trailingElements}
</div>
)),
Separator: jest.fn(() => <div className='menu-separator'/>),
}));
const initialState = {
entities: {
preferences: {
myPreferences: {},
},
users: {
currentUserId: 'currentUserId',
},
},
};
const recentUsedCustomDateString = 'Recently used custom time';
describe('CoreMenuOptions Component', () => {
const userCurrentTimezone = 'America/New_York';
const handleOnSelect = jest.fn();
let now: DateTime;
let tomorrow9amTime: number;
let nextMonday: number;
beforeEach(() => {
jest.clearAllMocks();
handleOnSelect.mockReset();
now = DateTime.fromISO('2024-11-01T10:00:00', {zone: userCurrentTimezone});
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();
});
afterEach(() => {
jest.useRealTimers();
});
function createStateWithRecentlyUsedCustomDate(value: string) {
return {
...initialState,
entities: {
...initialState.entities,
preferences: {
...initialState.entities.preferences,
myPreferences: {
...initialState.entities.preferences.myPreferences,
[getPreferenceKey(scheduledPosts.SCHEDULED_POSTS, scheduledPosts.RECENTLY_USED_CUSTOM_TIME)]: {value},
},
},
},
};
}
function renderComponent(state = initialState, handleOnSelectOverride = handleOnSelect) {
renderWithContext(
<RecentUsedCustomDate
handleOnSelect={handleOnSelectOverride}
userCurrentTimezone={userCurrentTimezone}
tomorrow9amTime={tomorrow9amTime}
nextMonday={nextMonday}
/>,
state,
);
}
it('should render recently used custom time option when valid', () => {
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);
expect(screen.getByText(recentUsedCustomDateString)).toBeInTheDocument();
});
it('should not render recently used custom time when preference value is invalid JSON', () => {
const invalidJson = '{ invalid JSON }';
const state = createStateWithRecentlyUsedCustomDate(invalidJson);
renderComponent(state);
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
it('should call handleOnSelect with the correct timestamp when "Recently used custom time" is clicked', () => {
const recentTimestamp = DateTime.now().plus({days: 5}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: DateTime.now().toMillis(),
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
const handleOnSelectMock = jest.fn();
renderComponent(state, handleOnSelectMock);
const recentCustomOption = screen.getByText(recentUsedCustomDateString);
fireEvent.click(recentCustomOption);
expect(handleOnSelectMock).toHaveBeenCalledWith(expect.anything(), recentTimestamp);
});
it('should not render recently used custom time when update_at is older than 30 days', () => {
const outdatedUpdateAt = DateTime.now().minus({days: 35}).toMillis();
const recentTimestamp = DateTime.now().plus({days: 5}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: outdatedUpdateAt,
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
it('should not render recently used custom time when timestamp is in the past', () => {
const now = DateTime.now().setZone(userCurrentTimezone);
const nowMillis = now.toMillis();
jest.useFakeTimers();
jest.setSystemTime(now.toJSDate());
const pastTimestamp = now.minus({days: 1}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: nowMillis,
timestamp: pastTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
it('should not render recently used custom time when timestamp equals tomorrow9amTime', () => {
const nowMillis = now.toMillis();
const recentlyUsedCustomDateVal = {
update_at: nowMillis,
timestamp: tomorrow9amTime, // Use the value from beforeEach
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
it('should not render recently used custom time when timestamp equals nextMonday', () => {
const nowMillis = now.toMillis();
const recentlyUsedCustomDateVal = {
update_at: nowMillis,
timestamp: nextMonday,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.queryByText(recentUsedCustomDateString)).not.toBeInTheDocument();
});
it('should render "Today at HH:MM AM/PM" when recently used custom date is TODAY', () => {
const now = DateTime.fromISO('2024-11-01T10:00:00', {zone: userCurrentTimezone});
jest.useFakeTimers();
jest.setSystemTime(now.toJSDate());
const recentTimestamp = now.plus({minutes: 5}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: now.toMillis(),
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.getByText(recentUsedCustomDateString)).toBeInTheDocument();
expect(screen.getByText(/Today at/)).toBeInTheDocument();
});
it('should render "Weekday at HH:MM AM/PM" if recent used custom date is in the SAME week', () => {
const now = DateTime.fromISO('2024-11-01T10:00:00', {zone: userCurrentTimezone});
jest.useFakeTimers();
jest.setSystemTime(now.toJSDate());
const recentTimestamp = now.plus({days: 2}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: now.toMillis(),
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.getByText(recentUsedCustomDateString)).toBeInTheDocument();
const scheduledDate = DateTime.fromMillis(recentTimestamp).setZone(userCurrentTimezone);
const weekdayName = scheduledDate.toFormat('EEEE');
expect(screen.getByText(new RegExp(`${weekdayName} at`))).toBeInTheDocument();
});
it('should render "Month Day at HH:MM AM/PM" if recent used custom date is NOT in the same week', () => {
const now = DateTime.fromISO('2024-11-01T10:00:00', {zone: userCurrentTimezone});
jest.useFakeTimers();
jest.setSystemTime(now.toJSDate());
const recentTimestamp = now.plus({days: 14}).toMillis();
const recentlyUsedCustomDateVal = {
update_at: now.toMillis(),
timestamp: recentTimestamp,
};
const state = createStateWithRecentlyUsedCustomDate(JSON.stringify(recentlyUsedCustomDateVal));
renderComponent(state);
expect(screen.getByText(recentUsedCustomDateString)).toBeInTheDocument();
const scheduledDate = DateTime.fromMillis(recentTimestamp).setZone(userCurrentTimezone);
const monthDay = scheduledDate.toFormat('MMMM d');
expect(screen.getByText(new RegExp(`${monthDay} at`))).toBeInTheDocument();
});
});

View File

@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Zone} from 'luxon';
import {DateTime} from 'luxon';
import React, {memo, useCallback, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import * as Menu from 'components/menu';
import Timestamp, {RelativeRanges} from 'components/timestamp';
import {scheduledPosts} from 'utils/constants';
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
userCurrentTimezone: string;
tomorrow9amTime: number;
nextMonday: number;
}
const DATE_RANGES = [
RelativeRanges.TODAY_TITLE_CASE,
RelativeRanges.TOMORROW_TITLE_CASE,
];
interface RecentlyUsedCustomDate {
update_at?: number;
timestamp?: number;
}
function isTimestampWithinLast30Days(timestamp: number, timeZone = 'UTC') {
if (!timestamp || isNaN(timestamp)) {
return false;
}
const usedDate = DateTime.fromMillis(timestamp).setZone(timeZone);
const now = DateTime.now().setZone(timeZone);
const thirtyDaysAgo = now.minus({days: 30});
return usedDate >= thirtyDaysAgo && usedDate <= now;
}
function shouldShowRecentlyUsedCustomTime(
nowMillis: number,
recentlyUsedCustomDateVal: RecentlyUsedCustomDate,
userCurrentTimezone: string,
tomorrow9amTime: number,
nextMonday: 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
isTimestampWithinLast30Days(recentlyUsedCustomDateVal.update_at, userCurrentTimezone);
}
const USE_DATE_WEEKDAY_LONG = {weekday: 'long'} as const;
const USE_TIME_HOUR_MINUTE_NUMERIC = {hour: 'numeric', minute: 'numeric'} as const;
const USE_DATE_MONTH_DAY = {month: 'long', day: 'numeric'} as const;
function getDateOption(now: DateTime, timestamp: number | undefined, userCurrentTimezone: string | Zone | undefined) {
if (!now || !timestamp || !userCurrentTimezone) {
return USE_DATE_WEEKDAY_LONG;
}
const scheduledDate = DateTime.fromMillis(timestamp).setZone(userCurrentTimezone);
const isInCurrentWeek = scheduledDate.weekNumber === now.weekNumber && scheduledDate.weekYear === now.weekYear;
return isInCurrentWeek ? USE_DATE_WEEKDAY_LONG : USE_DATE_MONTH_DAY;
}
function RecentUsedCustomDate({handleOnSelect, userCurrentTimezone, nextMonday, tomorrow9amTime}: 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(() => {
if (recentlyUsedCustomDate) {
try {
return JSON.parse(recentlyUsedCustomDate) as RecentlyUsedCustomDate;
} catch (e) {
return {};
}
}
return {};
}, [recentlyUsedCustomDate]);
const handleRecentlyUsedCustomTime = useCallback((e) => handleOnSelect(e, recentlyUsedCustomDateVal.timestamp!), [handleOnSelect, recentlyUsedCustomDateVal.timestamp]);
if (
!shouldShowRecentlyUsedCustomTime(now.toMillis(), recentlyUsedCustomDateVal, userCurrentTimezone, tomorrow9amTime, nextMonday)
) {
return null;
}
const dateOption = getDateOption(now, recentlyUsedCustomDateVal.timestamp, userCurrentTimezone);
const timestamp = (
<Timestamp
ranges={DATE_RANGES}
value={recentlyUsedCustomDateVal.timestamp}
timeZone={userCurrentTimezone}
useDate={dateOption}
useTime={USE_TIME_HOUR_MINUTE_NUMERIC}
/>
);
const trailingElement = (
<FormattedMessage
id='create_post_button.option.schedule_message.options.recently_used_custom_time'
defaultMessage='Recently used custom time'
/>
);
return (
<>
<Menu.Separator key='recent_custom_separator'/>
<Menu.Item
key='recently_used_custom_time'
onClick={handleRecentlyUsedCustomTime}
labels={timestamp}
className='core-menu-options'
trailingElements={trailingElement}
/>
</>
);
}
export default memo(RecentUsedCustomDate);

View File

@ -6,4 +6,16 @@ ul#dropdown_send_post_options {
font-weight: bold; font-weight: bold;
} }
} }
li.core-menu-options{
display: flex;
flex-direction: column;
align-items: baseline;
.trailing-elements {
margin-top: 4px;
margin-left: 0;
opacity: 0.8;
}
}
} }

View File

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import {useState, useEffect, useMemo} from 'react';
import {useSelector} from 'react-redux';
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 {getStatusForUserId, getUser, makeGetDisplayName} from 'mattermost-redux/selectors/entities/users';
import Constants, {UserStatuses} from 'utils/constants';
import type {GlobalState} from 'types/store';
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: 'UTC',
manualTimezone: '',
};
const MINUTE = 1000 * 60;
function useTimePostBoxIndicator(channelId: string) {
const getDisplayName = useMemo(makeGetDisplayName, []);
const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || '');
const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : ''));
const isDM = useSelector(
(state: GlobalState) => Boolean(getDirectChannel(state, channelId)?.teammate_id),
);
// Check if the teammate is in DND status
const isTeammateDND = useSelector((state: GlobalState) =>
(teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false),
);
// Determine if the DND warning should be shown
const showDndWarning = isTeammateDND && isDM;
const [timestamp, setTimestamp] = useState(0);
const [showIt, setShowIt] = useState(false);
// get teammate timezone information
const teammateTimezone = useSelector(
(state: GlobalState) => {
if (!teammateId) {
return DEFAULT_TIMEZONE;
}
const teammate = getUser(state, teammateId);
return getTimezoneForUserProfile(teammate);
},
(a, b) =>
a.automaticTimezone === b.automaticTimezone &&
a.manualTimezone === b.manualTimezone &&
a.useAutomaticTimezone === b.useAutomaticTimezone,
);
// current user timezone
const userCurrentTimezone = useSelector((state: GlobalState) => getCurrentTimezone(state));
// UseEffect to update the timestamp and the visibility for the time indicator
useEffect(() => {
function updateTime() {
const timezone =
teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
const teammateUserDate = DateTime.local().setZone(timezone);
setTimestamp(teammateUserDate.toMillis());
const currentHour = teammateUserDate.hour;
const showIndicator =
currentHour >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY ||
currentHour < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY;
setShowIt(showIndicator);
}
updateTime();
const interval = setInterval(updateTime, MINUTE);
return () => clearInterval(interval);
}, [
teammateTimezone.useAutomaticTimezone,
teammateTimezone.automaticTimezone,
teammateTimezone.manualTimezone,
]);
const isScheduledPostEnabledValue = useSelector(isScheduledPostsEnabled);
const showRemoteUserHour = isDM && showIt && timestamp !== 0;
return {
showRemoteUserHour,
isDM,
currentUserTimesStamp: timestamp,
teammateTimezone,
userCurrentTimezone,
isScheduledPostEnabled: isScheduledPostEnabledValue,
showDndWarning,
teammateId,
teammateDisplayName,
};
}
export default useTimePostBoxIndicator;

View File

@ -3455,6 +3455,8 @@
"create_post_button.option.schedule_message.options.header": "Schedule message", "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.monday": "Monday at {9amTime}",
"create_post_button.option.schedule_message.options.next_monday": "Next 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.tomorrow": "Tomorrow at {9amTime}", "create_post_button.option.schedule_message.options.tomorrow": "Tomorrow at {9amTime}",
"create_post_button.option.send_now": "Send Now", "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.", "create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.",

View File

@ -10,7 +10,10 @@ import {getTimezoneLabel, getUserCurrentTimezone} from 'mattermost-redux/utils/t
import {getCurrentUser} from './common'; import {getCurrentUser} from './common';
export function getTimezoneForUserProfile(profile: UserProfile) { export const getTimezoneForUserProfile = createSelector(
'getTimezoneForUserProfile',
(profile: UserProfile) => profile,
(profile) => {
if (profile && profile.timezone) { if (profile && profile.timezone) {
return { return {
...profile.timezone, ...profile.timezone,
@ -23,7 +26,8 @@ export function getTimezoneForUserProfile(profile: UserProfile) {
automaticTimezone: '', automaticTimezone: '',
manualTimezone: '', manualTimezone: '',
}; };
} },
);
export const getCurrentTimezoneFull = createSelector( export const getCurrentTimezoneFull = createSelector(
'getCurrentTimezoneFull', 'getCurrentTimezoneFull',

View File

@ -2223,5 +2223,10 @@ export const PageLoadContext = {
export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts'; export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts';
export const scheduledPosts = {
RECENTLY_USED_CUSTOM_TIME: 'recently_used_custom_time',
SCHEDULED_POSTS: 'scheduled_posts',
};
export default Constants; export default Constants;