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 ( + + +