diff --git a/webapp/channels/src/components/advanced_text_editor/post_box_indicator/post_box_indicator.tsx b/webapp/channels/src/components/advanced_text_editor/post_box_indicator/post_box_indicator.tsx
index ec81a97b64..c83489554e 100644
--- a/webapp/channels/src/components/advanced_text_editor/post_box_indicator/post_box_indicator.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/post_box_indicator/post_box_indicator.tsx
@@ -1,30 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {DateTime} from 'luxon';
-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 React from 'react';
import RemoteUserHour from 'components/advanced_text_editor/remote_user_hour';
import ScheduledPostIndicator from 'components/advanced_text_editor/scheduled_post_indicator/scheduled_post_indicator';
-import Constants, {UserStatuses} from 'utils/constants';
-
-import type {GlobalState} from 'types/store';
+import useTimePostBoxIndicator from '../use_post_box_indicator';
import './style.scss';
-const DEFAULT_TIMEZONE = {
- useAutomaticTimezone: true,
- automaticTimezone: '',
- manualTimezone: '',
-};
-
type Props = {
channelId: string;
teammateDisplayName: string;
@@ -33,37 +18,12 @@ type Props = {
}
export default function PostBoxIndicator({channelId, teammateDisplayName, location, postId}: Props) {
- const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || '');
- const isTeammateDND = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false));
- const isDM = useSelector((state: GlobalState) => Boolean(getDirectChannel(state, channelId)?.teammate_id));
- const showDndWarning = isTeammateDND && isDM;
-
- const [timestamp, setTimestamp] = useState(0);
- 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;
+ const {
+ showRemoteUserHour,
+ isScheduledPostEnabled,
+ currentUserTimesStamp,
+ teammateTimezone,
+ } = useTimePostBoxIndicator(channelId);
return (
@@ -71,7 +31,7 @@ export default function PostBoxIndicator({channelId, teammateDisplayName, locati
showRemoteUserHour &&
}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
index f136ed9318..ef5d96f3f9 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal.tsx
@@ -5,15 +5,19 @@ import moment from 'moment';
import type {Moment} from 'moment-timezone';
import React, {useCallback, useMemo, useState} from 'react';
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 {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {
DMUserTimezone,
} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone';
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
+import {scheduledPosts} from 'utils/constants';
+
type Props = {
channelId: string;
onExited: () => void;
@@ -25,19 +29,35 @@ export default function ScheduledPostCustomTimeModal({channelId, onExited, onCon
const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState
();
const userTimezone = useSelector(getCurrentTimezone);
+ const now = moment().tz(userTimezone);
+ const currentUserId = useSelector(getCurrentUserId);
+ const dispatch = useDispatch();
const [selectedDateTime, setSelectedDateTime] = useState(() => {
if (initialTime) {
return initialTime;
}
- const now = moment().tz(userTimezone);
return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0});
});
const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]);
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) {
setErrorMessage(response.error);
} else {
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx
new file mode 100644
index 0000000000..465b3885b8
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx
@@ -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}) => (
+
+ {labels}
+ {children}
+ {trailingElements}
+
+ )),
+ Separator: jest.fn(() => ),
+}));
+
+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(
+ ,
+ 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);
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
index 5a6b5a9840..4bd2a99d78 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import moment from 'moment';
+import {DateTime} from 'luxon';
import React, {memo, useCallback, useEffect} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
@@ -10,20 +10,44 @@ import {
TrackPropertyUser, TrackPropertyUserAgent,
TrackScheduledPostsFeature,
} from 'mattermost-redux/constants/telemetry';
-import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {trackFeatureEvent} from 'actions/telemetry_actions';
+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';
+
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
+ channelId: string;
}
-function CoreMenuOptions({handleOnSelect}: Props) {
- const userTimezone = useSelector(getCurrentTimezone);
+function getScheduledTimeInTeammateTimezone(userCurrentTimestamp: number, teammateTimezoneString: string): string {
+ 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);
useEffect(() => {
@@ -40,12 +64,19 @@ function CoreMenuOptions({handleOnSelect}: Props) {
);
}, [currentUserId]);
- const today = moment().tz(userTimezone);
- const tomorrow9amTime = moment().
- tz(userTimezone).
- add(1, 'days').
+ const now = DateTime.now().setZone(userCurrentTimezone);
+ const tomorrow9amTime = DateTime.now().
+ setZone(userCurrentTimezone).
+ plus({days: 1}).
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 extraProps: Partial = {};
+
+ if (isDM) {
+ const teammateTimezoneString = teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone || 'UTC';
+ const scheduledTimeInTeammateTimezone = getScheduledTimeInTeammateTimezone(tomorrow9amTime, teammateTimezoneString);
+ const teammateTimeDisplay = (
+
+ {teammateDisplayName}
+
+ ),
+ time: scheduledTimeInTeammateTimezone,
+ }}
+ />
+ );
+
+ extraProps.trailingElements = teammateTimeDisplay;
+ }
+
const tomorrowClickHandler = useCallback((e) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
const optionTomorrow = (
@@ -67,15 +121,11 @@ function CoreMenuOptions({handleOnSelect}: Props) {
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 optionNextMonday = (
@@ -89,6 +139,8 @@ function CoreMenuOptions({handleOnSelect}: Props) {
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[] = [];
- switch (today.day()) {
+ switch (now.weekday) {
// Sunday
- case 0:
+ case 7:
options = [optionTomorrow];
break;
@@ -133,9 +187,15 @@ function CoreMenuOptions({handleOnSelect}: Props) {
}
return (
-
+ <>
{options}
-
+
+ >
);
}
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
index 81cc6844b8..ae4d431702 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/index.tsx
@@ -103,7 +103,10 @@ export function SendPostOptions({disabled, onSelect, channelId}: Props) {
}
/>
-
+
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx
new file mode 100644
index 0000000000..e84b670772
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx
@@ -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}) => (
+
+ {labels}
+ {children}
+ {trailingElements}
+
+ )),
+ Separator: jest.fn(() => ),
+}));
+
+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(
+ ,
+ 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();
+ });
+});
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx
new file mode 100644
index 0000000000..5851ae9656
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx
@@ -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 = (
+
+ );
+
+ const trailingElement = (
+
+ );
+
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export default memo(RecentUsedCustomDate);
diff --git a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss
index efe35b2190..445f60f1ed 100644
--- a/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss
+++ b/webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/style.scss
@@ -6,4 +6,16 @@ ul#dropdown_send_post_options {
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;
+ }
+ }
}
diff --git a/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx b/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx
new file mode 100644
index 0000000000..7fa0139ba5
--- /dev/null
+++ b/webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx
@@ -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;
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json
index 706be1a116..209d540c0c 100644
--- a/webapp/channels/src/i18n/en.json
+++ b/webapp/channels/src/i18n/en.json
@@ -3455,6 +3455,8 @@
"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.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.",
diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/timezone.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/timezone.ts
index 462e94d59a..1e66891896 100644
--- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/timezone.ts
+++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/timezone.ts
@@ -10,20 +10,24 @@ import {getTimezoneLabel, getUserCurrentTimezone} from 'mattermost-redux/utils/t
import {getCurrentUser} from './common';
-export function getTimezoneForUserProfile(profile: UserProfile) {
- if (profile && profile.timezone) {
- return {
- ...profile.timezone,
- useAutomaticTimezone: profile.timezone.useAutomaticTimezone === 'true',
- };
- }
+export const getTimezoneForUserProfile = createSelector(
+ 'getTimezoneForUserProfile',
+ (profile: UserProfile) => profile,
+ (profile) => {
+ if (profile && profile.timezone) {
+ return {
+ ...profile.timezone,
+ useAutomaticTimezone: profile.timezone.useAutomaticTimezone === 'true',
+ };
+ }
- return {
- useAutomaticTimezone: true,
- automaticTimezone: '',
- manualTimezone: '',
- };
-}
+ return {
+ useAutomaticTimezone: true,
+ automaticTimezone: '',
+ manualTimezone: '',
+ };
+ },
+);
export const getCurrentTimezoneFull = createSelector(
'getCurrentTimezoneFull',
diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx
index a3dbd8fe73..a4a81c191c 100644
--- a/webapp/channels/src/utils/constants.tsx
+++ b/webapp/channels/src/utils/constants.tsx
@@ -2223,5 +2223,10 @@ export const PageLoadContext = {
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;