From 14d377e713d3e2c22f05b008c20bff7f3a85653b Mon Sep 17 00:00:00 2001 From: Manoj Malik Date: Fri, 23 Jul 2021 01:32:19 +0530 Subject: [PATCH] MM-36917 Custom status expiry (#5434) * Started with the custom status feature Added custom status actions and API integration Added custom status selectors and types Added custom status emoji component Added EnableCustomStatuses flag in config Added Set a status option in the settings sidebar * Fixed emoji and added clear button Fixed the default emoji in the set a status in the settings sidebar Made a custom status label component to match the styling of rest of the drawer items Fixed the bug in the selectors Added localization id in en.json * Added icon for clear button Fixed the custom status label issue Added styling and icon for the clear button Fixed order of the localization ids * Made custom status modal with suggestions Fixed the long custom status overflowing in the sidebar with ellipses Added the functionality to open custom status modal from sidebar Made the constants for custom status Made the custom status suggestion functional component Made the custom status modal functional component Made the selector for the recent custom statuses Added some localization ids * Changed the custom status modal to class component Added recent custom statuses in the modal Fixed the styling of titles Fixed lint errors except one * Fix types and use i18n translations * Update types * Refactored some code and made clear button component Refactored the code of the custom status label to a different file and used it everywhere Made a separate component for clear button and added it in recents and sidebar Fixed the styling of the custom status text according to the new figma designs * Added emojipicker in custom status modal Fixed the Done button in custom status modal Added the functionality to clear status from inside the modal * Added custom status in user profile and post header and review fixes Added custom status in user profile page Added custom status emoji in post header Added functionality to preventDoubleTap everywhere Added localization ids Review fixes * Fixed several issues Increased touch area of clear button everywhere Fixed the event propagation issue in clearing suggestions Fixed the center alignment issue of title in custom status modal Added clear button in user profile page Review fixes * Fix types for custom status modal * Fix tsc * Fixed types and separated functions for Recent and suggestions in custom status modal * Update app/components/sidebars/settings/settings_sidebar_base.js * Updated localization id in settings sidebar * Changed the method in removeRecentCustomStatus API call Changed names of some variables * Fixed the input height issue in ios and modalContext issue in ios * Fixed various issues Fixed clear button border radius issue in ios Changed the method to post in removeRecentCustomStatus API call Made the custom status modal input single line and fixed the height Changed the height of iconContainer in custom status suggestion * Fixed the event propagation issue without use of PanResponder * Unit tests Added unit tests for custom status emoji component Added unit tests for custom status text component Added unit tests for clear button component Added unit tests for custom status suggestion component Added unit tests for custom status modal component Added tests and updated snapshtots in user profile screen * Added custom status in several new locations Added custom status emoji in LHS channel list Added custom status emoji in channel header Added custom status in channel info header Modified the styles of some components * Fixed width issue in LHS channel list and channel title (#8) Added one check in channel info header * E2E tests for custom status (#6) * Completed MM-T3890, MM-T3891, MM-T3892, MM-T3893 e2e tests Added EnableCustomUserStatuses in default config of detox Added several testIDs in custom status modal and clear button components Modified settings_sidebar ui component in detox support Made custom status screen ui screen in detox support Added testIDs in custom status suggestion and clear buttons Modified custom status ui screen component in detox support Added appropriate comments in the tests Modified custom status emoji component to accept styling and added testID Modified the custom status emoji styling in post header, user profile, LHS channel list, channel info header, channel title Made user profile screen in detox support Added testID prop in custom status emoji component Added testIDs in channel info header and user profile and user profile row components * Added unit tests for new components and updated tests for custom status emoji Updated snapshots for existing components Added new tests for channel title and LHS channel item Added new tests for channel info and channel info header Updated snapshots * Fixed styles and emoji opacity in custom status input in modal * Fixed the color of text and emoji in custom status suggestions * Fixed the color of emoji and font size in the custom status input in modal * Updated snapshots * Fix the MM-T3893 E2E test case * Update detox/e2e/test/smoke_test/custom_status.e2e.js * Apply suggestions from code review Co-authored-by: Joseph Baylon * Updated snapshots and moved the custom status e2e test to different location * Fix styles * Fix ios styles * Update snapshots * Updated styles and localization ids * Changed the API call for the remove recent custom status action (#12) * Fix styles for ios * Fixed styles in Android Fixed the styling of clear button everywhere Fixed the separator not showing issue in custom status suggestion Fixed the emoji positioning issue in custom status suggestion * Fixed tests and updated snapshots * Fix stylessss for ios * Updated snapshots * Added close button and title in emojipicker screen opened from custom status modal * Fixed the delay issue in clearing and setting the custom status in settings sidebar (#13) * Fixed the delay issue in clearing the custom status in settings sidebar Modified the unsetCustomStatus action to use request statuses Modified the settings sidebar to use the request status to show or hide custom status Made proper types and reducers for the unset custom status request * Added the Retry message for setting and clearing the custom status Made set custom status request action types and reducers Modified the setCustomStatus action to dispatch request status actions Made the retryMessage component Added the logic for when to show the retry message * Modified set custom status action to dispatch Update me action with the new custom status * Modified the settings sidebar to use componentDidUpdate for the delay issue in custom status clearing and setting * Modified componentDidUpdate and made a new function for handling request status change * Modified handleRequestStatusChange function to two separate functions * Code refactoring * Updated tests * Fixed the issue for custom status not updating in settings sidebar when changed from another client * Fixed the issue for custom status not updating in settings sidebar when changed from another client * Fixed the IOS UI issues - Fixed the font-weight of the channel header custom status - Fixed the extra space added on typing in input issue * Fix custom status e2e tests * Fixed the custom status input click area issue * Fix custom status input touchable area * added expiry time to custom status * removed spaces from en.json * Updated snapshots * Updated snapshots * added some ui changes to expiry stuff in custom status * Apply suggestions from code review Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com> * Review fixes (#15) * Review fixes Sorted the import order in all files Modified removeRecentCustomStatus action not to use bindClientFunction Added types for custom status client functions Added containerSize prop in clear button component Removed the check for custom status enabled from custom status emoji and added it in post_header, channel_title, channel_item Modified getCustomStatusSelector to return undefined if custom status does not exist Modified settings_sidebar, channel_info, channel_info_header, custom_status_modal, user_profile to handle undefined custom status Removed the text wrapper from the custom status emoji in channel_info_header Added check for identifying the navigation button pressed in custom status modal * Added server version check in isCustomStatusEnabled selector * Refactored some code and added types in style in custom status modal Added type in custom status suggestion functions in custom status modal Refactored some code * Fixed failing tests and updated snapshots * Expiry Time functionality added * Updated snapshot for clear button component * ran npm run fix * Added timezone functionality to custom status expiry * Merged custom status into test-1230 * Review fixes Removed logic for tracking requests for setting and unsetting custom status Added logic for clearing status without delay in settings sidebar Added event for set custom status failure and attached listener in settings sidebar Added feature in custom status text component to accept string or FormattedText as prop and ellipsizeMode and numberOfLines as well Removed labelSibling and failureText props from drawer item Passed clearButton and retryMessage in labelComponent from settings sidebar to drawer item Replaced Text with CustomStatusText in channel info header Added localization in the custom status suggestions Added exception handling in the getRecentCustomStatuses selector * Fixed drawer item unit tests and updated snapshots * Fixed UI issues for IOS in settings sidebar and main sidebar * Updated snapshots * Refactored duration code in custom status suggestion * Review fixes Removed check for user null or undefined from setCustomStatus action Refactored some code in custom status emoji and post header Memoized handleClear in custom status suggestion Made stylesheet in settings_sidebar and added all styles there Consolidated openCustomStatusModal function inside goToCustomStatusScreen * Refactored some code Refactored some code in set custom status action Updated name of a unit test in drawer item and updated snapshot * added ui fixes to date time picker component and displayExpiryTime component * Removed server version check for testing * bug fix with timezone and statusSame * Review UI fixes (#18) Changed the padding for custom status both in settings sidebar and custom status suggestions Changed the position of clear button in the settings sidebar * UI fix in user profile screen Fixed the call to apiGetChannelByName in custom status e2e test * Added server version check for custom status feature * Updated snapshots * modified testIDs, output rendering logic * Review fixes Converted mapStateToProps to makeMapStateToProps and moved makeGetCustomStatus call inside them in several components * Removed the server version check and fixed showing custom status in user profile bug * Updated datetime picker version and fixed timezone issue Fixed the memoization issue in getCurrentUserTimezone selector Refactored the getCurrentDateTimeForTimezone to getCurrentMomentForTimezone Added the timezoneOffsetInMinutes prop to the datetime picker * Fixed ios UI issues Fixed the positioning of Clear after in custom status modal in ios Fixed the positioning of spinner date time picker in ios * expiry time now showing in custom status,clear after modals * Fixed the custom status not showing in user profile of other users if own status is not set * Changed the emoji size in the channel title * Updated snapshots * Added custom status emoji in post header Fixed the lint errors and type check errors * Updated snapshots Fixed the prop type isMilitaryTime for user_profile screen * UI fixes and code refactoring (#19) * added date time picker, fixed spacing in some components * UI fixes, added isCustomStatusExpired selector * removed extra spaces from package-lock.json * Review Fixes, upgraded datetimepicker version * Refactored some code * Refactored some code * Added server version check for the custom status feature * Merge master into custom-status-expiry * Review fixes and corrected import order * Resolved tsc errors, corrected intl usage * Code refactoring and minor bug fixes Fixed the default value of isCustomStatusExpired in mapStateToProps from false to true in all components Changed the ids of various expiry dropdown texts Fixed the order of ids in en.json * Changed date time format in front of Custom option in Clear after modal (#20) * Corrected 'check' usage in front of 'Custom' in clear-after-modal * Renamed clear_after_suggestions to clear_after_menu_item. removed unused code * Added unit tests and e2e tests * Review fixes Removed custom status clear after modal as a connected component * Updated failing snapshot * Review UI fixes Changed the size of expiry time in channel header and fixed the width of custom status text along with the position of the emoji Fixed the divider between custom status input and Clear after in the custom status modal Changed the size of expiry time in custom status suggestions Fixed the position of the tick in the clear after modal * Updated snapshots * Review fixes: Code refactoring * Updated snapshots * Code refactoring and fixed several issues Updated and added unit tests for custom_status_expiry component Refactored some code in custom_status_modal and clear_after_modal Increased the size of the back icon in the Clear after modal Added the logic for showing Clear after option only when the status is set Added 30 minute intervals in the timepicker * Custom status expiry review fixes (#24) * Added server version check for custom status expiry and review fixes Made an isCustomStatusExpirySupported selector for checking server version Modified custom status modal not to show clear after row if expiry not supported Fixed the bug of status not setting if expiry not supported Added the logic for hiding the duratiion from suggestions if expiry not supported Changed the transitioning of opening Clear After modal to open in the same stack level Added the expiry supported check in settings sidebar and channel info screen and fixed the UI issues Code refactoring * Fixed the Back icon in the Clear after modal * Fixed the Clear after flow and e2e tests Fixed the Set status modal closing issue on closing Clear after modal Added the feature to show Today in the custom status expiry component Refactored and fixed the e2e tests for custom status expiry Refactored some code in the Clear After modal * Fixed unit tests and updated snapshots * Fixed the bug showing invalid date in the Clear after field in the Set status modal * Updated package-lock.json * Code refactoring * UI fixes in the user profile page Added isExpirySupported check in the user profile page * Fixed unit test and updated snapshot * Updated snapshot for user_profile * Review fixes: Code refactoring * Fixed failing snapshots * Fixed the MM-T4092 e2e test for ios (#25) * Review fixes: Code refactoring Deleted app/screens/custom_status_clear_after/index.ts file and changed import paths * Code refactoring in e2e tests and updated eslint no shadow rule * Disallowed the selection of past time in custom status date time picker Co-authored-by: Chetanya Kandhari Co-authored-by: Manoj <77336594+manojosh@users.noreply.github.com> Co-authored-by: Joseph Baylon Co-authored-by: Chetanya Kandhari Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com> --- .eslintrc.json | 4 +- .../custom_status_expiry.test.tsx.snap | 45 ++ .../custom_status_emoji.test.tsx | 2 + .../custom_status/custom_status_emoji.tsx | 6 +- .../custom_status_expiry.test.tsx | 61 ++ .../custom_status/custom_status_expiry.tsx | 124 +++++ .../settings_sidebar.test.js.snap | 16 + app/components/sidebars/settings/index.js | 4 +- .../settings/settings_sidebar.test.js | 19 + .../settings/settings_sidebar_base.js | 60 +- app/constants/custom_status.ts | 43 ++ app/mm-redux/selectors/entities/timezone.ts | 22 + app/mm-redux/types/users.ts | 12 + .../__snapshots__/channel_info.test.js.snap | 1 + .../channel_info_header.test.js.snap | 525 ++++++++++++++++-- app/screens/channel_info/channel_info.js | 6 + app/screens/channel_info/channel_info.test.js | 1 + .../channel_info/channel_info_header.js | 48 +- .../channel_info/channel_info_header.test.js | 25 + app/screens/channel_info/index.js | 10 +- .../custom_status_modal.test.tsx.snap | 4 + .../custom_status_suggestion.test.tsx.snap | 191 +++---- .../custom_status_modal.test.tsx | 2 + .../custom_status/custom_status_modal.tsx | 241 ++++++-- .../custom_status_suggestion.test.tsx | 16 +- .../custom_status_suggestion.tsx | 48 +- app/screens/custom_status/index.ts | 12 +- .../clear_after_menu_item.test.tsx.snap | 173 ++++++ .../clear_after_modal.test.tsx.snap | 64 +++ .../date_time_selector.test.tsx.snap | 116 ++++ .../clear_after_menu_item.test.tsx | 50 ++ .../clear_after_menu_item.tsx | 149 +++++ .../clear_after_modal.test.tsx | 25 + .../clear_after_modal.tsx | 188 +++++++ .../date_time_selector.test.tsx | 23 + .../date_time_selector.tsx | 125 +++++ app/screens/index.js | 3 + .../__snapshots__/user_profile.test.js.snap | 368 +++++++++++- app/screens/user_profile/index.js | 15 +- app/screens/user_profile/user_profile.js | 44 +- app/screens/user_profile/user_profile.test.js | 21 + app/selectors/custom_status.ts | 28 +- app/utils/timezone.js | 3 + assets/base/i18n/en.json | 13 + .../support/ui/component/date_time_picker.js | 40 ++ detox/e2e/support/ui/component/index.js | 2 + detox/e2e/support/ui/screen/custom_status.js | 11 + .../ui/screen/custom_status_clear_after.js | 86 +++ detox/e2e/support/ui/screen/index.js | 2 + .../custom_status_expiry.e2e.js | 215 +++++++ ios/Podfile | 2 +- package-lock.json | 17 + package.json | 1 + 53 files changed, 3042 insertions(+), 290 deletions(-) create mode 100644 app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap create mode 100644 app/components/custom_status/custom_status_expiry.test.tsx create mode 100644 app/components/custom_status/custom_status_expiry.tsx create mode 100644 app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap create mode 100644 app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap create mode 100644 app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap create mode 100644 app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx create mode 100644 app/screens/custom_status_clear_after/clear_after_menu_item.tsx create mode 100644 app/screens/custom_status_clear_after/clear_after_modal.test.tsx create mode 100644 app/screens/custom_status_clear_after/clear_after_modal.tsx create mode 100644 app/screens/custom_status_clear_after/date_time_selector.test.tsx create mode 100644 app/screens/custom_status_clear_after/date_time_selector.tsx create mode 100644 detox/e2e/support/ui/component/date_time_picker.js create mode 100644 detox/e2e/support/ui/screen/custom_status_clear_after.js create mode 100644 detox/e2e/test/custom_statuses/custom_status_expiry.e2e.js diff --git a/.eslintrc.json b/.eslintrc.json index f95474148..85621dc15 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -47,7 +47,9 @@ "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/explicit-module-boundary-types": "off" + "@typescript-eslint/explicit-module-boundary-types": "off", + "no-shadow": "off", + "@typescript-eslint/no-shadow": "error" }, "overrides": [ { diff --git a/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap new file mode 100644 index 000000000..4ec31afe8 --- /dev/null +++ b/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/custom_status_expiry should match snapshot 1`] = ` + + + Today + + +`; + +exports[`components/custom_status/custom_status_expiry should match snapshot with prefix and brackets 1`] = ` + + ( + + Until + + + + Today + + ) + +`; diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx index 3ed874891..3951137b4 100644 --- a/app/components/custom_status/custom_status_emoji.test.tsx +++ b/app/components/custom_status/custom_status_emoji.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import * as CustomStatusSelectors from '@selectors/custom_status'; import {renderWithRedux} from 'test/testing_library'; +import {CustomStatusDuration} from '@mm-redux/types/users'; jest.mock('@selectors/custom_status'); @@ -14,6 +15,7 @@ describe('components/custom_status/custom_status_emoji', () => { return { emoji: 'calendar', text: 'In a meeting', + duration: CustomStatusDuration.DONT_CLEAR, }; }; (CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(getCustomStatus); diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx index f5fcce0da..2b4c08c1f 100644 --- a/app/components/custom_status/custom_status_emoji.tsx +++ b/app/components/custom_status/custom_status_emoji.tsx @@ -7,7 +7,7 @@ import {useSelector} from 'react-redux'; import Emoji from '@components/emoji'; import {GlobalState} from '@mm-redux/types/store'; -import {makeGetCustomStatus} from '@selectors/custom_status'; +import {makeGetCustomStatus, isCustomStatusExpired} from '@selectors/custom_status'; interface ComponentProps { emojiSize?: number; @@ -21,7 +21,9 @@ const CustomStatusEmoji = ({emojiSize, userID, style, testID}: ComponentProps) = const customStatus = useSelector((state: GlobalState) => { return getCustomStatus(state, userID); }); - if (!customStatus?.emoji) { + const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, customStatus)); + + if (!customStatus?.emoji || customStatusExpired) { return null; } diff --git a/app/components/custom_status/custom_status_expiry.test.tsx b/app/components/custom_status/custom_status_expiry.test.tsx new file mode 100644 index 000000000..e1df7996b --- /dev/null +++ b/app/components/custom_status/custom_status_expiry.test.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import moment from 'moment-timezone'; + +import Preferences from '@mm-redux/constants/preferences'; +import * as PreferenceSelectors from '@mm-redux/selectors/entities/preferences'; +import {renderWithReduxIntl} from 'test/testing_library'; +import CustomStatusExpiry from './custom_status_expiry'; + +jest.mock('@mm-redux/selectors/entities/preferences'); + +describe('components/custom_status/custom_status_expiry', () => { + const date = moment().endOf('day'); + + const baseProps = { + theme: Preferences.THEMES.default, + time: date.toDate(), + }; + + (PreferenceSelectors.getBool as jest.Mock).mockReturnValue(false); + it('should match snapshot', () => { + const wrapper = renderWithReduxIntl( + , + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + expect(wrapper.getByText('Today')).toBeDefined(); + }); + + it('should match snapshot with prefix and brackets', () => { + const props = { + ...baseProps, + showPrefix: true, + withinBrackets: true, + }; + const wrapper = renderWithReduxIntl( + , + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + expect(wrapper.getByText('Until')).toBeDefined(); + expect(wrapper.getByText('Today')).toBeDefined(); + }); + + it("should render Tomorrow if given tomorrow's date", () => { + const props = { + ...baseProps, + time: moment().add(1, 'day').endOf('day').toDate(), + }; + const wrapper = renderWithReduxIntl( + , + ); + expect(wrapper.getByText('Tomorrow')).toBeDefined(); + }); +}); diff --git a/app/components/custom_status/custom_status_expiry.tsx b/app/components/custom_status/custom_status_expiry.tsx new file mode 100644 index 000000000..1d5465772 --- /dev/null +++ b/app/components/custom_status/custom_status_expiry.tsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React from 'react'; +import {Text, TextStyle} from 'react-native'; +import {useSelector} from 'react-redux'; + +import FormattedDate from '@components/formatted_date'; +import FormattedText from '@components/formatted_text'; +import FormattedTime from '@components/formatted_time'; +import Preferences from '@mm-redux/constants/preferences'; +import {getBool} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getCurrentMomentForTimezone} from '@utils/timezone'; + +import {Theme} from '@mm-redux/types/preferences'; +import {GlobalState} from '@mm-redux/types/store'; + +type Props = { + theme: Theme; + time: Date; + textStyles?: TextStyle; + testID?: string; + showPrefix?: boolean; + withinBrackets?: boolean; + showToday?: boolean; + showTimeCompulsory?: boolean; +} + +const CustomStatusExpiry = ({time, theme, textStyles = {}, showPrefix, withinBrackets, testID = '', showTimeCompulsory, showToday}: Props) => { + const timezone = useSelector(getCurrentUserTimezone); + const styles = createStyleSheet(theme); + const militaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time')); + const currentMomentTime = getCurrentMomentForTimezone(timezone); + + const expiryMomentTime = timezone ? moment(time).tz(timezone) : moment(time); + + const plusSixDaysEndTime = currentMomentTime.clone().add(6, 'days').endOf('day'); + const tomorrowEndTime = currentMomentTime.clone().add(1, 'day').endOf('day'); + const todayEndTime = currentMomentTime.clone().endOf('day'); + const isCurrentYear = currentMomentTime.get('y') === expiryMomentTime.get('y'); + + let dateComponent; + if ((showToday && expiryMomentTime.isBefore(todayEndTime)) || expiryMomentTime.isSame(todayEndTime)) { + dateComponent = ( + + ); + } else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) { + dateComponent = ( + + ); + } else if (expiryMomentTime.isAfter(tomorrowEndTime)) { + let format = 'dddd'; + if (expiryMomentTime.isAfter(plusSixDaysEndTime) && isCurrentYear) { + format = 'MMM DD'; + } else if (!isCurrentYear) { + format = 'MMM DD, YYYY'; + } + + dateComponent = ( + + ); + } + + const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime)); + + return ( + + {withinBrackets && '('} + {showPrefix && ( + + )} + {showPrefix && ' '} + {dateComponent} + {useTime && dateComponent && ( + <> + {' '} + + {' '} + + )} + {useTime && ( + + )} + {withinBrackets && ')'} + + ); +}; + +export default CustomStatusExpiry; + +const createStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + text: { + fontSize: 15, + color: theme.centerChannelColor, + }, + }; +}); diff --git a/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap b/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap index 4684891a9..b1c058148 100644 --- a/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap +++ b/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap @@ -31,3 +31,19 @@ exports[`SettingsSidebar should match snapshot with custom status enabled 1`] = useNativeAnimations={true} /> `; + +exports[`SettingsSidebar should match snapshot with custom status expiry 1`] = ` + +`; diff --git a/app/components/sidebars/settings/index.js b/app/components/sidebars/settings/index.js index 4195fb1e0..413520e16 100644 --- a/app/components/sidebars/settings/index.js +++ b/app/components/sidebars/settings/index.js @@ -8,7 +8,7 @@ import {unsetCustomStatus} from '@actions/views/custom_status'; import {setStatus} from '@mm-redux/actions/users'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUser, getStatusForUserId} from '@mm-redux/selectors/entities/users'; -import {isCustomStatusEnabled, makeGetCustomStatus} from '@selectors/custom_status'; +import {isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported, makeGetCustomStatus} from '@selectors/custom_status'; import {logout} from 'app/actions/views/user'; @@ -29,6 +29,8 @@ function makeMapStateToProps() { theme: getTheme(state), isCustomStatusEnabled: customStatusEnabled, customStatus, + isCustomStatusExpired: customStatusEnabled ? isCustomStatusExpired(state, customStatus) : true, + isCustomStatusExpirySupported: customStatusEnabled ? isCustomStatusExpirySupported(state) : false, }; }; } diff --git a/app/components/sidebars/settings/settings_sidebar.test.js b/app/components/sidebars/settings/settings_sidebar.test.js index cd7d1f53b..34caa7837 100644 --- a/app/components/sidebars/settings/settings_sidebar.test.js +++ b/app/components/sidebars/settings/settings_sidebar.test.js @@ -7,11 +7,13 @@ import {shallowWithIntl} from 'test/intl-test-helper'; import Preferences from '@mm-redux/constants/preferences'; import SettingsSidebar from './settings_sidebar.ios'; +import {CustomStatusDuration} from '@mm-redux/types/users'; describe('SettingsSidebar', () => { const customStatus = { emoji: 'calendar', text: 'In a meeting', + duration: CustomStatusDuration.DONT_CLEAR, }; const baseProps = { @@ -26,6 +28,7 @@ describe('SettingsSidebar', () => { status: 'offline', theme: Preferences.THEMES.default, isCustomStatusEnabled: false, + isCustomStatusExpired: false, customStatus, }; @@ -47,4 +50,20 @@ describe('SettingsSidebar', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + + it('should match snapshot with custom status expiry', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); }); diff --git a/app/components/sidebars/settings/settings_sidebar_base.js b/app/components/sidebars/settings/settings_sidebar_base.js index b75e4ae98..d7c26719f 100644 --- a/app/components/sidebars/settings/settings_sidebar_base.js +++ b/app/components/sidebars/settings/settings_sidebar_base.js @@ -10,6 +10,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter'; import {showModal, showModalOverCurrentContext, dismissModal} from '@actions/navigation'; import CompassIcon from '@components/compass_icon'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; import CustomStatusText from '@components/custom_status/custom_status_text'; import ClearButton from '@components/custom_status/clear_button'; import Emoji from '@components/emoji'; @@ -37,6 +38,8 @@ export default class SettingsSidebarBase extends PureComponent { theme: PropTypes.object.isRequired, isCustomStatusEnabled: PropTypes.bool.isRequired, customStatus: PropTypes.object, + isCustomStatusExpired: PropTypes.bool.isRequired, + isCustomStatusExpirySupported: PropTypes.bool.isRequired, }; static defaultProps = { @@ -77,7 +80,7 @@ export default class SettingsSidebarBase extends PureComponent { handleCustomStatusChange = (prevCustomStatus, customStatus) => { const isStatusSet = Boolean(customStatus?.emoji); if (isStatusSet) { - const isStatusChanged = prevCustomStatus?.emoji !== customStatus.emoji || prevCustomStatus?.text !== customStatus.text; + const isStatusChanged = prevCustomStatus?.emoji !== customStatus?.emoji || prevCustomStatus?.text !== customStatus?.text || prevCustomStatus?.expires_at !== customStatus?.expires_at; if (isStatusChanged) { this.setState({ showStatus: true, @@ -240,7 +243,7 @@ export default class SettingsSidebarBase extends PureComponent { } renderCustomStatus = () => { - const {isCustomStatusEnabled, customStatus, theme} = this.props; + const {isCustomStatusEnabled, customStatus, theme, isCustomStatusExpired, isCustomStatusExpirySupported} = this.props; const {showStatus, showRetryMessage} = this.state; if (!isCustomStatusEnabled) { @@ -248,7 +251,7 @@ export default class SettingsSidebarBase extends PureComponent { } const style = getStyleSheet(theme); - const isStatusSet = customStatus?.emoji && showStatus; + const isStatusSet = !isCustomStatusExpired && customStatus?.emoji && showStatus; const customStatusEmoji = ( ); - const clearButton = isStatusSet ? - ( - - ) : null; - - const retryMessage = showRetryMessage ? - ( - - ) : null; - const text = isStatusSet ? customStatus.text : ( + {Boolean(isStatusSet && isCustomStatusExpirySupported && customStatus?.duration) && ( + + )} - {retryMessage} - {clearButton && + {showRetryMessage && ( + + )} + {isStatusSet && ( - {clearButton} + - } + )} ); @@ -428,6 +433,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { customStatusIcon: { color: changeOpacity(theme.centerChannelColor, 0.64), }, + customStatusExpiryText: { + paddingTop: 3, + fontSize: 15, + color: changeOpacity(theme.centerChannelColor, 0.35), + }, clearButton: { position: 'absolute', top: 4, diff --git a/app/constants/custom_status.ts b/app/constants/custom_status.ts index 29dea9894..855ead848 100644 --- a/app/constants/custom_status.ts +++ b/app/constants/custom_status.ts @@ -1,5 +1,48 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {CustomStatusDuration} from '@mm-redux/types/users'; +import {t} from '@utils/i18n'; + +const { + DONT_CLEAR, + THIRTY_MINUTES, + ONE_HOUR, + FOUR_HOURS, + TODAY, + THIS_WEEK, + DATE_AND_TIME, +} = CustomStatusDuration; + +export const durationValues = { + [DONT_CLEAR]: { + id: t('custom_status.expiry_dropdown.dont_clear'), + defaultMessage: "Don't clear", + }, + [THIRTY_MINUTES]: { + id: t('custom_status.expiry_dropdown.thirty_minutes'), + defaultMessage: '30 minutes', + }, + [ONE_HOUR]: { + id: t('custom_status.expiry_dropdown.one_hour'), + defaultMessage: '1 hour', + }, + [FOUR_HOURS]: { + id: t('custom_status.expiry_dropdown.four_hours'), + defaultMessage: '4 hours', + }, + [TODAY]: { + id: t('custom_status.expiry_dropdown.today'), + defaultMessage: 'Today', + }, + [THIS_WEEK]: { + id: t('custom_status.expiry_dropdown.this_week'), + defaultMessage: 'This week', + }, + [DATE_AND_TIME]: { + id: t('custom_status.expiry_dropdown.date_and_time'), + defaultMessage: 'Date and Time', + }, +}; export const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100; export const SET_CUSTOM_STATUS_FAILURE = 'set_custom_status_failure'; diff --git a/app/mm-redux/selectors/entities/timezone.ts b/app/mm-redux/selectors/entities/timezone.ts index e0c4431a0..7b439d40b 100644 --- a/app/mm-redux/selectors/entities/timezone.ts +++ b/app/mm-redux/selectors/entities/timezone.ts @@ -1,11 +1,19 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {createSelector} from 'reselect'; + +import {getCurrentUser} from '@mm-redux/selectors/entities/common'; + import {GlobalState} from '@mm-redux/types/store'; +import {UserProfile} from '@mm-redux/types/users'; export function getUserTimezone(state: GlobalState, id: string) { const profile = state.entities.users.profiles[id]; + return getTimezoneForUserProfile(profile); +} +function getTimezoneForUserProfile(profile: UserProfile) { if (profile && profile.timezone) { return { ...profile.timezone, @@ -24,3 +32,17 @@ export function isTimezoneEnabled(state: GlobalState) { const {config} = state.entities.general; return config.ExperimentalTimezone === 'true'; } + +export const getCurrentUserTimezone = createSelector( + getCurrentUser, + isTimezoneEnabled, + (user, enabledTimezone) => { + let timezone; + if (enabledTimezone) { + const userTimezone = getTimezoneForUserProfile(user); + timezone = userTimezone.useAutomaticTimezone ? userTimezone.automaticTimezone : userTimezone.manualTimezone; + } + + return timezone; + }, +); diff --git a/app/mm-redux/types/users.ts b/app/mm-redux/types/users.ts index 6fe83c19b..f6c671a5d 100644 --- a/app/mm-redux/types/users.ts +++ b/app/mm-redux/types/users.ts @@ -80,7 +80,19 @@ export type UserStatus = { active_channel?: string; } +export enum CustomStatusDuration { + DONT_CLEAR = '', + THIRTY_MINUTES = 'thirty_minutes', + ONE_HOUR = 'one_hour', + FOUR_HOURS = 'four_hours', + TODAY = 'today', + THIS_WEEK = 'this_week', + DATE_AND_TIME = 'date_and_time', +} + export type UserCustomStatus = { emoji: string; text: string; + expires_at?: string; + duration: CustomStatusDuration; } diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap index 3c014e90d..09ecf8c1b 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -34,6 +34,7 @@ exports[`channelInfo should match snapshot 1`] = ` header="" isArchived={false} isCustomStatusEnabled={false} + isCustomStatusExpired={false} isGroupConstrained={false} isTeammateGuest={false} memberCount={2} diff --git a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap index 22110b1e7..49325807b 100644 --- a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap @@ -2034,7 +2034,6 @@ exports[`channel_info_header should match snapshot with custom status enabled 1` "alignItems": "center", "flexDirection": "row", "paddingVertical": 10, - "position": "relative", }, ] } @@ -2045,54 +2044,494 @@ exports[`channel_info_header should match snapshot with custom status enabled 1` size={20} testID="custom_status.emoji.calendar" textStyle={ - Object { - "color": "#3d3c40", - "marginRight": 8, - } + Array [ + Object { + "color": "#3d3c40", + "marginRight": 8, + }, + Object { + "bottom": 0, + }, + ] } /> - + + theme={ + Object { + "awayIndicator": "#ffbc42", + "buttonBg": "#166de0", + "buttonColor": "#ffffff", + "centerChannelBg": "#ffffff", + "centerChannelColor": "#3d3c40", + "codeTheme": "github", + "dndIndicator": "#f74343", + "errorTextColor": "#fd5960", + "linkColor": "#2389d7", + "mentionBg": "#ffffff", + "mentionBj": "#ffffff", + "mentionColor": "#145dbf", + "mentionHighlightBg": "#ffe577", + "mentionHighlightLink": "#166de0", + "newMessageSeparator": "#ff8800", + "onlineIndicator": "#06d6a0", + "sidebarBg": "#145dbf", + "sidebarHeaderBg": "#1153ab", + "sidebarHeaderTextColor": "#ffffff", + "sidebarText": "#ffffff", + "sidebarTextActiveBorder": "#579eff", + "sidebarTextActiveColor": "#ffffff", + "sidebarTextHoverBg": "#4578bf", + "sidebarUnreadText": "#ffffff", + "type": "Mattermost", + } + } + /> + + + + + + + + Purpose string + + + + + + + + + + + + + + + + + +`; + +exports[`channel_info_header should match snapshot with custom status expiry 1`] = ` + + + + + Channel name + + + + + + + + } diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js index eb62c9447..0d319b16c 100644 --- a/app/screens/channel_info/channel_info.test.js +++ b/app/screens/channel_info/channel_info.test.js @@ -49,6 +49,7 @@ describe('channelInfo', () => { isDirectMessage: false, isLandscape: false, isCustomStatusEnabled: false, + isCustomStatusExpired: false, actions: { getChannelStats: jest.fn(), getCustomEmojisInText: jest.fn(), diff --git a/app/screens/channel_info/channel_info_header.js b/app/screens/channel_info/channel_info_header.js index af346f74a..f7ddf04ad 100644 --- a/app/screens/channel_info/channel_info_header.js +++ b/app/screens/channel_info/channel_info_header.js @@ -14,6 +14,7 @@ import Clipboard from '@react-native-community/clipboard'; import {popToRoot} from '@actions/navigation'; import ChannelIcon from '@components/channel_icon'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; import CustomStatusText from '@components/custom_status/custom_status_text'; import Emoji from '@components/emoji'; import FormattedDate from '@components/formatted_date'; @@ -47,6 +48,8 @@ export default class ChannelInfoHeader extends React.PureComponent { timezone: PropTypes.string, customStatus: PropTypes.object, isCustomStatusEnabled: PropTypes.bool.isRequired, + isCustomStatusExpired: PropTypes.bool.isRequired, + isCustomStatusExpirySupported: PropTypes.bool.isRequired, }; static contextTypes = { @@ -147,6 +150,8 @@ export default class ChannelInfoHeader extends React.PureComponent { timezone, customStatus, isCustomStatusEnabled, + isCustomStatusExpired, + isCustomStatusExpirySupported, } = this.props; const style = getStyleSheet(theme); @@ -157,6 +162,9 @@ export default class ChannelInfoHeader extends React.PureComponent { android: style.detail, }); + const showCustomStatus = isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji && !isCustomStatusExpired; + const showCustomStatusExpiry = Boolean(customStatus?.duration && isCustomStatusExpirySupported); + return ( @@ -180,7 +188,7 @@ export default class ChannelInfoHeader extends React.PureComponent { {displayName} - {isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji && + {showCustomStatus && ( - + + + {showCustomStatusExpiry && ( + + )} + - } + )} {this.renderHasGuestText(style)} {purpose.length > 0 && @@ -299,16 +317,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { color: theme.centerChannelColor, }, customStatusContainer: { - position: 'relative', flexDirection: 'row', alignItems: 'center', paddingVertical: 10, }, + customStatus: { + width: '90%', + }, + customStatusExpiry: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 13, + }, customStatusText: { flex: 1, fontSize: 15, color: theme.centerChannelColor, - width: '80%', + height: '100%', }, channelNameContainer: { flexDirection: 'row', diff --git a/app/screens/channel_info/channel_info_header.test.js b/app/screens/channel_info/channel_info_header.test.js index c84c520c8..d20d94ce9 100644 --- a/app/screens/channel_info/channel_info_header.test.js +++ b/app/screens/channel_info/channel_info_header.test.js @@ -5,6 +5,7 @@ import {shallow} from 'enzyme'; import Preferences from '@mm-redux/constants/preferences'; import {General} from '@mm-redux/constants'; +import {CustomStatusDuration} from '@mm-redux/types/users'; import ChannelInfoHeader from './channel_info_header.js'; @@ -44,6 +45,8 @@ describe('channel_info_header', () => { isGroupConstrained: false, testID: 'channel_info.header', isCustomStatusEnabled: false, + isCustomStatusExpired: false, + isCustomStatusExpirySupported: false, }; test('should match snapshot', async () => { @@ -120,6 +123,7 @@ describe('channel_info_header', () => { const customStatus = { emoji: 'calendar', text: 'In a meeting', + duration: CustomStatusDuration.DONT_CLEAR, }; const wrapper = shallow( @@ -133,4 +137,25 @@ describe('channel_info_header', () => { ); expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should match snapshot with custom status expiry', () => { + const customStatus = { + emoji: 'calendar', + text: 'In a meeting', + duration: CustomStatusDuration.DATE_AND_TIME, + expires_at: '2200-04-13T18:09:12.451Z', + }; + + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); }); diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index 9de3a5187..cb29bd562 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -13,7 +13,7 @@ import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/ent import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; import {displayUsername} from '@mm-redux/utils/user_utils'; -import {makeGetCustomStatus, isCustomStatusEnabled} from '@selectors/custom_status'; +import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status'; import {isGuest} from '@utils/users'; import ChannelInfo from './channel_info'; @@ -34,6 +34,8 @@ function makeMapStateToProps() { let isTeammateGuest = false; let customStatusEnabled = false; let customStatus; + let customStatusExpired = true; + let customStatusExpirySupported = false; const isDirectMessage = currentChannel.type === General.DM_CHANNEL; if (isDirectMessage) { @@ -44,7 +46,9 @@ function makeMapStateToProps() { currentChannelGuestCount = 1; } customStatusEnabled = isCustomStatusEnabled(state); - customStatus = customStatusEnabled ? getCustomStatus(state, teammateId) : undefined; + customStatus = customStatusEnabled && getCustomStatus(state, teammateId); + customStatusExpired = customStatusEnabled ? isCustomStatusExpired(state, customStatus) : true; + customStatusExpirySupported = customStatusEnabled ? isCustomStatusExpirySupported(state) : false; } if (currentChannel.type === General.GM_CHANNEL) { @@ -63,6 +67,8 @@ function makeMapStateToProps() { theme: getTheme(state), customStatus, isCustomStatusEnabled: customStatusEnabled, + isCustomStatusExpired: customStatusExpired, + isCustomStatusExpirySupported: customStatusExpirySupported, }; }; } diff --git a/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap b/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap index afc623737..a9c5ce781 100644 --- a/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap +++ b/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap @@ -11,6 +11,7 @@ exports[`screens/custom_status_modal should match snapshot 1`] = ` } customStatus={ Object { + "duration": "", "emoji": "calendar", "text": "In a meeting", } @@ -45,6 +46,7 @@ exports[`screens/custom_status_modal should match snapshot 1`] = ` recentCustomStatuses={ Array [ Object { + "duration": "", "emoji": "calendar", "text": "In a meeting", }, @@ -122,6 +124,7 @@ exports[`screens/custom_status_modal should match snapshot when user has no cust recentCustomStatuses={ Array [ Object { + "duration": "", "emoji": "calendar", "text": "In a meeting", }, @@ -170,6 +173,7 @@ exports[`screens/custom_status_modal should match snapshot when user has no rece } customStatus={ Object { + "duration": "", "emoji": "calendar", "text": "In a meeting", } diff --git a/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap b/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap index 66ef1e248..0ddc9e51e 100644 --- a/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap +++ b/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap @@ -49,43 +49,45 @@ exports[`screens/custom_status_suggestion should match snapshot 1`] = ` } } > - + + /> + @@ -141,95 +143,52 @@ exports[`screens/custom_status_suggestion should match snapshot with separator a } } > - + - - - + /> + diff --git a/app/screens/custom_status/custom_status_modal.test.tsx b/app/screens/custom_status/custom_status_modal.test.tsx index 5c590b18e..84bdb139f 100644 --- a/app/screens/custom_status/custom_status_modal.test.tsx +++ b/app/screens/custom_status/custom_status_modal.test.tsx @@ -7,11 +7,13 @@ import Preferences from '@mm-redux/constants/preferences'; import CustomStatusModal from '@screens/custom_status/custom_status_modal'; import {shallowWithIntl} from 'test/intl-test-helper'; +import {CustomStatusDuration} from '@mm-redux/types/users'; describe('screens/custom_status_modal', () => { const customStatus = { emoji: 'calendar', text: 'In a meeting', + duration: CustomStatusDuration.DONT_CLEAR, }; const baseProps = { diff --git a/app/screens/custom_status/custom_status_modal.tsx b/app/screens/custom_status/custom_status_modal.tsx index 68a9f4559..f876c1fb3 100644 --- a/app/screens/custom_status/custom_status_modal.tsx +++ b/app/screens/custom_status/custom_status_modal.tsx @@ -1,46 +1,65 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import moment, {Moment} from 'moment-timezone'; import React from 'react'; import {intlShape, injectIntl} from 'react-intl'; import {View, Text, TouchableOpacity, TextInput, Keyboard, KeyboardAvoidingView, Platform, ScrollView, StyleProp, ViewStyle} from 'react-native'; import {Navigation, NavigationComponent, NavigationComponentProps, OptionsTopBarButton, Options, NavigationButtonPressedEvent} from 'react-native-navigation'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {dismissModal, showModal, mergeNavigationOptions} from '@actions/navigation'; +import {dismissModal, showModal, mergeNavigationOptions, goToScreen} from '@actions/navigation'; import Emoji from '@components/emoji'; import CompassIcon from '@components/compass_icon'; import ClearButton from '@components/custom_status/clear_button'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; import FormattedText from '@components/formatted_text'; import StatusBar from '@components/status_bar'; import {CustomStatus, DeviceTypes} from '@constants'; +import {durationValues} from '@constants/custom_status'; import {ActionFunc, ActionResult} from '@mm-redux/types/actions'; import {Theme} from '@mm-redux/types/preferences'; -import {UserCustomStatus} from '@mm-redux/types/users'; +import {CustomStatusDuration, UserCustomStatus} from '@mm-redux/types/users'; import EventEmitter from '@mm-redux/utils/event_emitter'; import CustomStatusSuggestion from '@screens/custom_status/custom_status_suggestion'; +import {getRoundedTime} from '@screens/custom_status_clear_after/date_time_selector'; import {t} from '@utils/i18n'; import {preventDoubleTap} from '@utils/tap'; +import {getCurrentMomentForTimezone} from '@utils/timezone'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; type DefaultUserCustomStatus = { emoji: string; message: string; messageDefault: string; + durationDefault: CustomStatusDuration; }; +const { + DONT_CLEAR, + THIRTY_MINUTES, + ONE_HOUR, + FOUR_HOURS, + TODAY, + THIS_WEEK, + DATE_AND_TIME, +} = CustomStatusDuration; + const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [ - {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting'}, - {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch'}, - {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick'}, - {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home'}, - {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation'}, + {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: ONE_HOUR}, + {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: THIRTY_MINUTES}, + {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: TODAY}, + {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: TODAY}, + {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: THIS_WEEK}, ]; +const defaultDuration: CustomStatusDuration = TODAY; + interface Props extends NavigationComponentProps { intl: typeof intlShape; theme: Theme; customStatus: UserCustomStatus; + userTimezone: string; recentCustomStatuses: UserCustomStatus[]; isLandscape: boolean; actions: { @@ -48,11 +67,15 @@ interface Props extends NavigationComponentProps { unsetCustomStatus: () => ActionFunc; removeRecentCustomStatus: (customStatus: UserCustomStatus) => ActionFunc; }; + isExpirySupported: boolean; + isCustomStatusExpired: boolean; } type State = { emoji: string; text: string; + duration: CustomStatusDuration; + expires_at: Moment; } class CustomStatusModal extends NavigationComponent { @@ -75,21 +98,31 @@ class CustomStatusModal extends NavigationComponent { constructor(props: Props) { super(props); + const {customStatus, userTimezone, isCustomStatusExpired, intl, theme, componentId} = props; - this.rightButton.text = props.intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}); - this.rightButton.color = props.theme.sidebarHeaderTextColor; + this.rightButton.text = intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}); + this.rightButton.color = theme.sidebarHeaderTextColor; const options: Options = { topBar: { rightButtons: [this.rightButton], }, }; + mergeNavigationOptions(componentId, options); - mergeNavigationOptions(props.componentId, options); + const currentTime = getCurrentMomentForTimezone(userTimezone); + + let initialCustomExpiryTime: Moment = getRoundedTime(currentTime); + const isCurrentCustomStatusSet = !isCustomStatusExpired && (customStatus?.text || customStatus?.emoji); + if (isCurrentCustomStatusSet && customStatus?.duration === DATE_AND_TIME && customStatus?.expires_at) { + initialCustomExpiryTime = moment(customStatus?.expires_at); + } this.state = { - emoji: props.customStatus?.emoji, - text: props.customStatus?.text || '', + emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '', + text: isCurrentCustomStatusSet ? customStatus?.text : '', + duration: isCurrentCustomStatusSet ? (customStatus?.duration ?? DONT_CLEAR) : defaultDuration, + expires_at: initialCustomExpiryTime, }; } @@ -106,16 +139,25 @@ class CustomStatusModal extends NavigationComponent { } handleSetStatus = async () => { - const {emoji, text} = this.state; + const {emoji, text, duration} = this.state; const isStatusSet = emoji || text; - const {customStatus} = this.props; + const {customStatus, isExpirySupported} = this.props; if (isStatusSet) { - const isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text; + let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration; + if (isStatusSame && duration === DATE_AND_TIME) { + isStatusSame = customStatus?.expires_at === this.calculateExpiryTime(duration); + } if (!isStatusSame) { - const status = { + const status: UserCustomStatus = { emoji: emoji || 'speech_balloon', text: text.trim(), + duration: DONT_CLEAR, }; + + if (isExpirySupported) { + status.duration = duration; + status.expires_at = this.calculateExpiryTime(duration); + } const {error} = await this.props.actions.setCustomStatus(status); if (error) { EventEmitter.emit(CustomStatus.SET_CUSTOM_STATUS_FAILURE); @@ -128,30 +170,65 @@ class CustomStatusModal extends NavigationComponent { dismissModal(); }; + calculateExpiryTime = (duration: CustomStatusDuration): string => { + const {userTimezone} = this.props; + const currentTime = getCurrentMomentForTimezone(userTimezone); + const {expires_at} = this.state; + switch (duration) { + case THIRTY_MINUTES: + return currentTime.add(30, 'minutes').seconds(0).milliseconds(0).toISOString(); + case ONE_HOUR: + return currentTime.add(1, 'hour').seconds(0).milliseconds(0).toISOString(); + case FOUR_HOURS: + return currentTime.add(4, 'hours').seconds(0).milliseconds(0).toISOString(); + case TODAY: + return currentTime.endOf('day').toISOString(); + case THIS_WEEK: + return currentTime.endOf('week').toISOString(); + case DATE_AND_TIME: + return expires_at.toISOString(); + case DONT_CLEAR: + default: + return ''; + } + }; + handleTextChange = (value: string) => this.setState({text: value}); handleRecentCustomStatusClear = (status: UserCustomStatus) => this.props.actions.removeRecentCustomStatus(status); clearHandle = () => { - this.setState({emoji: '', text: ''}); + this.setState({emoji: '', text: '', duration: defaultDuration}); }; - handleSuggestionClick = (status: UserCustomStatus) => { - const {emoji, text} = status; - this.setState({emoji, text}); + handleCustomStatusSuggestionClick = (status: UserCustomStatus) => { + const {emoji, text, duration} = status; + this.setState({emoji, text, duration}); + }; + + handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => { + const {emoji, text, duration} = status; + this.setState({emoji, text, duration: duration || DONT_CLEAR}); + if (duration === DATE_AND_TIME) { + this.openClearAfterModal(); + } }; renderRecentCustomStatuses = (style: Record>) => { - const {recentCustomStatuses, theme} = this.props; + const {recentCustomStatuses, theme, isExpirySupported} = this.props; + const recentStatuses = recentCustomStatuses.map((status: UserCustomStatus, index: number) => ( )); @@ -177,22 +254,25 @@ class CustomStatusModal extends NavigationComponent { }; renderCustomStatusSuggestions = (style: Record>) => { - const {recentCustomStatuses, theme, intl} = this.props; + const {recentCustomStatuses, theme, intl, isExpirySupported} = this.props; const recentCustomStatusTexts = recentCustomStatuses.map((status: UserCustomStatus) => status.text); const customStatusSuggestions = defaultCustomStatusSuggestions. map((status) => ({ emoji: status.emoji, text: intl.formatMessage({id: status.message, defaultMessage: status.messageDefault}), + duration: status.durationDefault, })). filter((status: UserCustomStatus) => !recentCustomStatusTexts.includes(status.text)). map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => ( )); @@ -236,11 +316,31 @@ class CustomStatusModal extends NavigationComponent { this.setState({emoji}); } - render() { - const {emoji, text} = this.state; - const {theme, isLandscape} = this.props; + handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) => + this.setState({ + duration, + expires_at: duration === DATE_AND_TIME && expires_at ? moment(expires_at) : this.state.expires_at, + }); - const isStatusSet = emoji || text; + openClearAfterModal = async () => { + const {intl, theme} = this.props; + const screen = 'ClearAfter'; + const title = intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'}); + const passProps = { + handleClearAfterClick: this.handleClearAfterClick, + initialDuration: this.state.duration, + intl, + theme, + }; + + goToScreen(screen, title, passProps); + }; + + render() { + const {emoji, text, duration, expires_at} = this.state; + const {theme, isLandscape, intl, isExpirySupported} = this.props; + + const isStatusSet = Boolean(emoji || text); const style = getStyleSheet(theme); const customStatusEmoji = ( { ); + const clearAfterTime = duration && duration === DATE_AND_TIME ? ( + + + + ) : ( + + ); + + const clearAfter = isExpirySupported && ( + + + {intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'})} + {clearAfterTime} + + + + ); + const customStatusInput = ( { underlineColorAndroid='transparent' value={text} /> + {isStatusSet && ( + + )} {customStatusEmoji} {isStatusSet ? ( { > - {customStatusInput} + + {customStatusInput} + {isStatusSet && clearAfter} + {this.renderRecentCustomStatuses(style)} {this.renderCustomStatusSuggestions(style)} + @@ -338,20 +481,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), }, scrollView: { flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), paddingTop: 32, }, inputContainer: { - position: 'relative', - flexDirection: 'row', - alignItems: 'center', - borderTopWidth: 1, - borderBottomWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + justifyContent: 'center', + height: 48, backgroundColor: theme.centerChannelBg, }, input: { @@ -361,7 +499,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { fontSize: 17, paddingHorizontal: 52, textAlignVertical: 'center', - height: 48, + height: '100%', }, icon: { color: changeOpacity(theme.centerChannelColor, 0.64), @@ -372,7 +510,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { iconContainer: { position: 'absolute', left: 14, - top: 12, + top: 10, }, separator: { marginTop: 32, @@ -394,5 +532,30 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { top: 3, right: 14, }, + customStatusExpiry: { + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + marginRight: 16, + marginLeft: 52, + }, + expiryTimeLabel: { + fontSize: 17, + paddingLeft: 16, + textAlignVertical: 'center', + color: theme.centerChannelColor, + }, + expiryTime: { + position: 'absolute', + right: 42, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + rightIcon: { + position: 'absolute', + right: 18, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, }; }); diff --git a/app/screens/custom_status/custom_status_suggestion.test.tsx b/app/screens/custom_status/custom_status_suggestion.test.tsx index 774b73039..a1b8b1226 100644 --- a/app/screens/custom_status/custom_status_suggestion.test.tsx +++ b/app/screens/custom_status/custom_status_suggestion.test.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {shallow} from 'enzyme'; import React from 'react'; import {TouchableOpacity} from 'react-native'; import Preferences from '@mm-redux/constants/preferences'; import CustomStatusSuggestion from '@screens/custom_status/custom_status_suggestion'; +import {CustomStatusDuration} from '@mm-redux/types/users'; +import {shallowWithIntl} from 'test/intl-test-helper'; describe('screens/custom_status_suggestion', () => { const baseProps = { @@ -15,20 +16,21 @@ describe('screens/custom_status_suggestion', () => { text: 'In a meeting', theme: Preferences.THEMES.default, separator: false, + duration: CustomStatusDuration.DONT_CLEAR, }; it('should match snapshot', () => { - const wrapper = shallow( + const wrapper = shallowWithIntl( , ); - expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.dive().getElement()).toMatchSnapshot(); }); it('should match snapshot with separator and clear button', () => { - const wrapper = shallow( + const wrapper = shallowWithIntl( { />, ); - expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.dive().getElement()).toMatchSnapshot(); }); it('should call handleSuggestionClick on clicking the suggestion', () => { - const wrapper = shallow( + const wrapper = shallowWithIntl( , ); - wrapper.find(TouchableOpacity).simulate('press'); + wrapper.dive().find(TouchableOpacity).simulate('press'); expect(baseProps.handleSuggestionClick).toBeCalled(); }); }); diff --git a/app/screens/custom_status/custom_status_suggestion.tsx b/app/screens/custom_status/custom_status_suggestion.tsx index 35d8cffc7..17edcb301 100644 --- a/app/screens/custom_status/custom_status_suggestion.tsx +++ b/app/screens/custom_status/custom_status_suggestion.tsx @@ -1,41 +1,46 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {injectIntl, intlShape} from 'react-intl'; import React, {useCallback} from 'react'; import {View, TouchableOpacity, Text} from 'react-native'; import Emoji from '@components/emoji'; import ClearButton from '@components/custom_status/clear_button'; import CustomStatusText from '@components/custom_status/custom_status_text'; +import {durationValues} from '@constants/custom_status'; import {Theme} from '@mm-redux/types/preferences'; -import {UserCustomStatus} from '@mm-redux/types/users'; +import {CustomStatusDuration, UserCustomStatus} from '@mm-redux/types/users'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; type Props = { + intl: typeof intlShape; handleSuggestionClick: (status: UserCustomStatus) => void; emoji: string; text: string; handleClear?: (status: UserCustomStatus) => void; theme: Theme; separator: boolean; + duration: CustomStatusDuration; + isExpirySupported: boolean; + expires_at?: string; }; -const CustomStatusSuggestion = (props: Props) => { - const {handleSuggestionClick, emoji, text, theme, separator, handleClear} = props; +const CustomStatusSuggestion = ({handleSuggestionClick, emoji, text, theme, separator, handleClear, duration, expires_at, intl, isExpirySupported}: Props) => { const style = getStyleSheet(theme); const handleClick = useCallback(preventDoubleTap(() => { - handleSuggestionClick({emoji, text}); + handleSuggestionClick({emoji, text, duration}); }), []); const handleSuggestionClear = useCallback(() => { if (handleClear) { - handleClear({emoji, text}); + handleClear({emoji, text, duration, expires_at}); } }, []); - const clearButton = handleClear ? + const clearButton = handleClear && expires_at ? ( { - + + + + {Boolean(duration && isExpirySupported) && ( + + + + )} {clearButton} {separator && } @@ -76,7 +92,7 @@ const CustomStatusSuggestion = (props: Props) => { ); }; -export default CustomStatusSuggestion; +export default injectIntl(CustomStatusSuggestion); const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -111,6 +127,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { divider: { backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), height: 1, + marginRight: 16, + }, + customStatusDuration: { + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + }, + customStatusText: { + color: theme.centerChannelColor, }, }; }); diff --git a/app/screens/custom_status/index.ts b/app/screens/custom_status/index.ts index e5c0615a8..c7accb8de 100644 --- a/app/screens/custom_status/index.ts +++ b/app/screens/custom_status/index.ts @@ -6,24 +6,32 @@ import {bindActionCreators, Dispatch} from 'redux'; import {setCustomStatus, unsetCustomStatus, removeRecentCustomStatus} from '@actions/views/custom_status'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone'; import {GenericAction} from '@mm-redux/types/actions'; import {GlobalState} from '@mm-redux/types/store'; +import {UserCustomStatus} from '@mm-redux/types/users'; import CustomStatusModal from '@screens/custom_status/custom_status_modal'; -import {getRecentCustomStatuses, makeGetCustomStatus} from '@selectors/custom_status'; +import {getRecentCustomStatuses, isCustomStatusExpired, isCustomStatusExpirySupported, makeGetCustomStatus} from '@selectors/custom_status'; import {isLandscape} from '@selectors/device'; function makeMapStateToProps() { const getCustomStatus = makeGetCustomStatus(); return (state: GlobalState) => { - const customStatus = getCustomStatus(state); + const customStatus: UserCustomStatus | undefined = getCustomStatus(state); const recentCustomStatuses = getRecentCustomStatuses(state); const theme = getTheme(state); + const userTimezone = getCurrentUserTimezone(state); + const isExpirySupported = isCustomStatusExpirySupported(state); + const customStatusExpired = isCustomStatusExpired(state, customStatus); return { + userTimezone, customStatus, recentCustomStatuses, theme, isLandscape: isLandscape(state), + isExpirySupported, + isCustomStatusExpired: customStatusExpired, }; }; } diff --git a/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap new file mode 100644 index 000000000..7d48130b6 --- /dev/null +++ b/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap @@ -0,0 +1,173 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screens/clear_after_menu_item should match snapshot 1`] = ` + + + + + + + + + +`; + +exports[`screens/clear_after_menu_item should match snapshot with separator and selected check 1`] = ` + + + + + + + + + + + + + +`; diff --git a/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap new file mode 100644 index 000000000..ddbba91d0 --- /dev/null +++ b/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap @@ -0,0 +1,64 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screens/clear_after_modal should match snapshot 1`] = ` + +`; diff --git a/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap new file mode 100644 index 000000000..bdb867bfe --- /dev/null +++ b/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screens/date_time_selector should match snapshot 1`] = ` + + + + + + Select Date + + + + + + + Select Time + + + + + +`; diff --git a/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx b/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx new file mode 100644 index 000000000..12248d650 --- /dev/null +++ b/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {TouchableOpacity} from 'react-native'; + +import Preferences from '@mm-redux/constants/preferences'; +import {CustomStatusDuration} from '@mm-redux/types/users'; +import {shallowWithIntl} from 'test/intl-test-helper'; +import ClearAfterMenuItem from './clear_after_menu_item'; + +describe('screens/clear_after_menu_item', () => { + const baseProps = { + theme: Preferences.THEMES.default, + duration: CustomStatusDuration.DONT_CLEAR, + separator: false, + isSelected: false, + handleItemClick: jest.fn(), + }; + + it('should match snapshot', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.dive().getElement()).toMatchSnapshot(); + }); + + it('should match snapshot with separator and selected check', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.dive().getElement()).toMatchSnapshot(); + }); + + it('should call handleItemClick on clicking the suggestion', () => { + const wrapper = shallowWithIntl( + , + ); + + wrapper.dive().find(TouchableOpacity).simulate('press'); + expect(baseProps.handleItemClick).toBeCalled(); + }); +}); diff --git a/app/screens/custom_status_clear_after/clear_after_menu_item.tsx b/app/screens/custom_status_clear_after/clear_after_menu_item.tsx new file mode 100644 index 000000000..b4b4982ea --- /dev/null +++ b/app/screens/custom_status_clear_after/clear_after_menu_item.tsx @@ -0,0 +1,149 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment, {Moment} from 'moment'; +import React, {useCallback, useState} from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import {View, TouchableOpacity} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import CustomStatusExpiry from '@components/custom_status/custom_status_expiry'; +import CustomStatusText from '@components/custom_status/custom_status_text'; +import {durationValues} from '@constants/custom_status'; +import {Theme} from '@mm-redux/types/preferences'; +import {CustomStatusDuration} from '@mm-redux/types/users'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import DateTimePicker from './date_time_selector'; + +type Props = { + handleItemClick: (duration: CustomStatusDuration, expiresAt: string) => void; + duration: CustomStatusDuration; + theme: Theme; + separator: boolean; + isSelected: boolean; + intl: typeof intlShape; + showExpiryTime?: boolean; + showDateTimePicker?: boolean; +}; + +const { + DONT_CLEAR, + THIRTY_MINUTES, + ONE_HOUR, + FOUR_HOURS, + TODAY, + THIS_WEEK, + DATE_AND_TIME, +} = CustomStatusDuration; + +const ClearAfterMenuItem = ({handleItemClick, duration, theme, separator, isSelected, intl, showExpiryTime = false, showDateTimePicker = false}: Props) => { + const style = getStyleSheet(theme); + + const [expiry, setExpiry] = useState(''); + + const expiryMenuItems: { [key in CustomStatusDuration]: string } = { + [DONT_CLEAR]: intl.formatMessage(durationValues[DONT_CLEAR]), + [THIRTY_MINUTES]: intl.formatMessage(durationValues[THIRTY_MINUTES]), + [ONE_HOUR]: intl.formatMessage(durationValues[ONE_HOUR]), + [FOUR_HOURS]: intl.formatMessage(durationValues[FOUR_HOURS]), + [TODAY]: intl.formatMessage(durationValues[TODAY]), + [THIS_WEEK]: intl.formatMessage(durationValues[THIS_WEEK]), + [DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}), + }; + + const handleClick = useCallback( + preventDoubleTap(() => { + handleItemClick(duration, ''); + }), [handleItemClick, duration]); + + const handleCustomExpiresAtChange = useCallback((expiresAt: Moment) => { + setExpiry(expiresAt.toISOString()); + handleItemClick(duration, expiresAt.toISOString()); + }, [handleItemClick, duration]); + + return ( + + + + + + {isSelected && ( + + + + )} + {showExpiryTime && expiry !== '' && ( + + + + )} + + + {separator && } + + {showDateTimePicker && ( + + )} + + ); +}; + +export default injectIntl(ClearAfterMenuItem); + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + display: 'flex', + flexDirection: 'row', + padding: 10, + }, + textContainer: { + marginLeft: 5, + marginBottom: 2, + alignItems: 'center', + width: '70%', + flex: 1, + flexDirection: 'row', + position: 'relative', + }, + rightPosition: { + position: 'absolute', + right: 14, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + marginHorizontal: 16, + }, + button: { + borderRadius: 1000, + color: theme.buttonBg, + }, + customStatusExpiry: { + color: theme.linkColor, + }, + }; +}); diff --git a/app/screens/custom_status_clear_after/clear_after_modal.test.tsx b/app/screens/custom_status_clear_after/clear_after_modal.test.tsx new file mode 100644 index 000000000..c78149ee7 --- /dev/null +++ b/app/screens/custom_status_clear_after/clear_after_modal.test.tsx @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; +import {CustomStatusDuration} from '@mm-redux/types/users'; +import ClearAfterModal from '@screens/custom_status_clear_after/clear_after_modal'; +import {shallowWithIntl} from 'test/intl-test-helper'; + +describe('screens/clear_after_modal', () => { + const baseProps = { + theme: Preferences.THEMES.default, + initialDuration: CustomStatusDuration.DONT_CLEAR, + handleClearAfterClick: jest.fn(), + }; + + it('should match snapshot', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/custom_status_clear_after/clear_after_modal.tsx b/app/screens/custom_status_clear_after/clear_after_modal.tsx new file mode 100644 index 000000000..f4c373e2f --- /dev/null +++ b/app/screens/custom_status_clear_after/clear_after_modal.tsx @@ -0,0 +1,188 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SafeAreaView, View, StatusBar} from 'react-native'; +import React from 'react'; +import {intlShape, injectIntl} from 'react-intl'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scrollview'; +import { + Navigation, + NavigationButtonPressedEvent, + NavigationComponent, + NavigationComponentProps, + Options, + OptionsTopBarButton, +} from 'react-native-navigation'; + +import {Theme} from '@mm-redux/types/preferences'; +import {CustomStatusDuration} from '@mm-redux/types/users'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; + +import {mergeNavigationOptions, popTopScreen} from 'app/actions/navigation'; +import ClearAfterMenuItem from './clear_after_menu_item'; +interface Props extends NavigationComponentProps { + intl: typeof intlShape; + theme: Theme; + handleClearAfterClick: (duration: CustomStatusDuration, expiresAt: string) => void; + initialDuration: CustomStatusDuration; +} + +type State = { + duration: CustomStatusDuration; + expiresAt: string; + showExpiryTime: boolean; +} + +const {DATE_AND_TIME} = CustomStatusDuration; +class ClearAfterModal extends NavigationComponent { + rightButton: OptionsTopBarButton = { + id: 'update-custom-status-clear-after', + testID: 'clear_after.done.button', + enabled: true, + showAsAction: 'always', + }; + + static options(): Options { + return { + topBar: { + title: { + alignment: 'center', + }, + }, + }; + } + + constructor(props: Props) { + super(props); + this.rightButton.text = props.intl.formatMessage({ + id: 'mobile.custom_status.modal_confirm', + defaultMessage: 'Done', + }); + + this.rightButton.color = props.theme.sidebarHeaderTextColor; + const options: Options = { + topBar: { + rightButtons: [this.rightButton], + }, + }; + + mergeNavigationOptions(props.componentId, options); + + this.state = { + duration: props.initialDuration, + expiresAt: '', + showExpiryTime: false, + }; + } + + componentDidMount() { + Navigation.events().bindComponent(this); + } + + navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) { + switch (buttonId) { + case 'update-custom-status-clear-after': + this.onDone(); + break; + } + } + + onDone = () => { + this.props.handleClearAfterClick(this.state.duration, this.state.expiresAt); + popTopScreen(); + }; + + handleItemClick = (duration: CustomStatusDuration, expiresAt: string) => + this.setState({ + duration, + expiresAt, + showExpiryTime: duration === DATE_AND_TIME && expiresAt !== '', + }); + + renderClearAfterMenu = () => { + const {theme} = this.props; + const style = getStyleSheet(theme); + const {duration} = this.state; + + const clearAfterMenu = Object.values(CustomStatusDuration).map( + (item, index, arr) => { + if (index === arr.length - 1) { + return null; + } + + return ( + + ); + }, + ); + + if (clearAfterMenu.length === 0) { + return null; + } + + return ( + + {clearAfterMenu} + + ); + }; + + render() { + const {theme} = this.props; + const style = getStyleSheet(theme); + const {duration, expiresAt, showExpiryTime} = this.state; + return ( + + + + + {this.renderClearAfterMenu()} + + + + + + + ); + } +} + +export default injectIntl(ClearAfterModal); + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + scrollView: { + flex: 1, + paddingTop: 32, + paddingBottom: 32, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + }; +}); diff --git a/app/screens/custom_status_clear_after/date_time_selector.test.tsx b/app/screens/custom_status_clear_after/date_time_selector.test.tsx new file mode 100644 index 000000000..63af3ddb3 --- /dev/null +++ b/app/screens/custom_status_clear_after/date_time_selector.test.tsx @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; +import {renderWithRedux} from 'test/testing_library'; +import DateTimeSelector from './date_time_selector'; + +describe('screens/date_time_selector', () => { + const baseProps = { + theme: Preferences.THEMES.default, + handleChange: jest.fn(), + }; + + it('should match snapshot', () => { + const wrapper = renderWithRedux( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/custom_status_clear_after/date_time_selector.tsx b/app/screens/custom_status_clear_after/date_time_selector.tsx new file mode 100644 index 000000000..99e6a52b1 --- /dev/null +++ b/app/screens/custom_status_clear_after/date_time_selector.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment, {Moment} from 'moment-timezone'; +import React, {useState} from 'react'; +import {View, Button, Platform} from 'react-native'; +import {useSelector} from 'react-redux'; + +import Preferences from '@mm-redux/constants/preferences'; +import {getBool} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone'; +import {Theme} from '@mm-redux/types/preferences'; +import {GlobalState} from '@mm-redux/types/store'; +import DateTimePicker from '@react-native-community/datetimepicker'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {getCurrentMomentForTimezone, getUtcOffsetForTimeZone} from '@utils/timezone'; + +type Props = { + theme: Theme; + handleChange: (currentDate: Moment) => void; +} + +type AndroidMode = 'date' | 'time'; + +const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30; +export function getRoundedTime(value: Moment) { + const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES; + const start = moment(value); + const diff = start.minute() % roundedTo; + if (diff === 0) { + return value; + } + const remainder = roundedTo - diff; + return start.add(remainder, 'm').seconds(0).milliseconds(0); +} + +const DateTimeSelector = (props: Props) => { + const {theme} = props; + const styles = getStyleSheet(theme); + const militaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time')); + const timezone = useSelector(getCurrentUserTimezone); + const currentTime = getCurrentMomentForTimezone(timezone); + const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined; + + const minimumDate = getRoundedTime(currentTime); + + const [date, setDate] = useState(minimumDate); + const [mode, setMode] = useState('date'); + const [show, setShow] = useState(false); + + const onChange = (_: React.ChangeEvent, selectedDate: Date) => { + const currentDate = selectedDate || date; + setShow(Platform.OS === 'ios'); + if (moment(currentDate).isAfter(minimumDate)) { + setDate(moment(currentDate)); + props.handleChange(moment(currentDate)); + } + }; + + const showMode = (currentMode: AndroidMode) => { + setShow(true); + setMode(currentMode); + }; + + const showDatepicker = () => { + showMode('date'); + props.handleChange(moment(date)); + }; + + const showTimepicker = () => { + showMode('time'); + props.handleChange(moment(date)); + }; + + return ( + + +