From 37d97e8024dee06d07990a88ddf5bfb144770fac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20V=C3=A9lez?= Date: Thu, 7 Nov 2024 16:02:22 +0100 Subject: [PATCH] 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 --- .../post_box_indicator/post_box_indicator.tsx | 58 +--- .../scheduled_post_custom_time_modal.tsx | 26 +- .../core_menu_options.test.tsx | 155 ++++++++++ .../send_post_options/core_menu_options.tsx | 98 +++++-- .../send_button/send_post_options/index.tsx | 5 +- .../recent_used_custom_date.test.tsx | 276 ++++++++++++++++++ .../recent_used_custom_date.tsx | 130 +++++++++ .../send_button/send_post_options/style.scss | 12 + .../use_post_box_indicator.tsx | 111 +++++++ webapp/channels/src/i18n/en.json | 2 + .../src/selectors/entities/timezone.ts | 30 +- webapp/channels/src/utils/constants.tsx | 5 + 12 files changed, 823 insertions(+), 85 deletions(-) create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/core_menu_options.test.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.test.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/send_button/send_post_options/recent_used_custom_date.tsx create mode 100644 webapp/channels/src/components/advanced_text_editor/use_post_box_indicator.tsx 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;