From abc2f30ef3a09c1f9bbd4ca5067a2aaa926f9232 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Fri, 4 Jun 2021 15:02:35 +0200 Subject: [PATCH] MM-34586 Custom status feature (#5220) (#5420) (cherry picked from commit e442275c6f424db91a62a4de85e762abebc37b6b) Co-authored-by: Chetanya Kandhari --- app/actions/views/custom_status.ts | 66 +++ app/client/rest/users.ts | 26 +- .../__snapshots__/clear_button.test.tsx.snap | 31 ++ .../custom_status_emoji.test.tsx.snap | 39 ++ .../custom_status_text.test.tsx.snap | 37 ++ .../custom_status/clear_button.test.tsx | 36 ++ app/components/custom_status/clear_button.tsx | 59 +++ .../custom_status_emoji.test.tsx | 45 ++ .../custom_status/custom_status_emoji.tsx | 46 ++ .../custom_status/custom_status_text.test.tsx | 35 ++ .../custom_status/custom_status_text.tsx | 40 ++ .../post_list/post/header/header.tsx | 16 +- app/components/post_list/post/header/index.ts | 2 + .../__snapshots__/channel_item.test.js.snap | 155 +++++- .../channel_item/channel_item.js | 21 +- .../channel_item/channel_item.test.js | 13 + .../main/channels_list/channel_item/index.js | 2 + .../__snapshots__/drawer_item.test.js.snap | 90 +++- .../settings_sidebar.test.js.snap | 16 + .../sidebars/settings/drawer_item.js | 19 +- .../sidebars/settings/drawer_item.test.js | 13 + app/components/sidebars/settings/index.js | 28 +- .../settings/settings_sidebar.android.js | 5 + .../sidebars/settings/settings_sidebar.ios.js | 5 + .../settings/settings_sidebar.test.js | 19 + .../settings/settings_sidebar_base.js | 168 +++++- .../settings/status_label/status_label.js | 1 - app/constants/custom_status.ts | 5 + app/constants/index.js | 2 + app/mm-redux/constants/preferences.ts | 5 + app/mm-redux/types/config.ts | 1 + app/mm-redux/types/module.d.ts | 1 + app/mm-redux/types/users.ts | 8 +- .../__snapshots__/channel_title.test.js.snap | 204 ++++++-- .../channel_title/channel_title.js | 26 +- .../channel_title/channel_title.test.js | 12 + .../channel_nav_bar/channel_title/index.js | 6 +- .../__snapshots__/channel_info.test.js.snap | 1 + .../channel_info_header.test.js.snap | 376 ++++++++++++++ app/screens/channel_info/channel_info.js | 6 + app/screens/channel_info/channel_info.test.js | 2 + .../channel_info/channel_info_header.js | 43 ++ .../channel_info/channel_info_header.test.js | 19 + app/screens/channel_info/index.js | 76 +-- .../custom_status_modal.test.tsx.snap | 235 +++++++++ .../custom_status_suggestion.test.tsx.snap | 239 +++++++++ .../custom_status_modal.test.tsx | 57 +++ .../custom_status/custom_status_modal.tsx | 398 +++++++++++++++ .../custom_status_suggestion.test.tsx | 52 ++ .../custom_status_suggestion.tsx | 116 +++++ app/screens/custom_status/index.ts | 41 ++ app/screens/index.js | 3 + .../__snapshots__/user_profile.test.js.snap | 477 ++++++++++++++++++ app/screens/user_profile/index.js | 46 +- app/screens/user_profile/user_profile.js | 66 ++- app/screens/user_profile/user_profile.test.js | 36 ++ app/screens/user_profile/user_profile_row.js | 11 +- app/selectors/custom_status.ts | 43 ++ assets/base/i18n/en.json | 13 + .../support/server_api/default_config.json | 1 + .../support/ui/component/settings_sidebar.js | 9 + detox/e2e/support/ui/screen/channel_info.js | 1 + detox/e2e/support/ui/screen/custom_status.js | 64 +++ detox/e2e/support/ui/screen/index.js | 2 + detox/e2e/support/ui/screen/user_profile.js | 4 + .../test/custom_statuses/custom_status.e2e.js | 296 +++++++++++ 66 files changed, 3896 insertions(+), 140 deletions(-) create mode 100644 app/actions/views/custom_status.ts create mode 100644 app/components/custom_status/__snapshots__/clear_button.test.tsx.snap create mode 100644 app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap create mode 100644 app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap create mode 100644 app/components/custom_status/clear_button.test.tsx create mode 100644 app/components/custom_status/clear_button.tsx create mode 100644 app/components/custom_status/custom_status_emoji.test.tsx create mode 100644 app/components/custom_status/custom_status_emoji.tsx create mode 100644 app/components/custom_status/custom_status_text.test.tsx create mode 100644 app/components/custom_status/custom_status_text.tsx create mode 100644 app/constants/custom_status.ts create mode 100644 app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap create mode 100644 app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap create mode 100644 app/screens/custom_status/custom_status_modal.test.tsx create mode 100644 app/screens/custom_status/custom_status_modal.tsx create mode 100644 app/screens/custom_status/custom_status_suggestion.test.tsx create mode 100644 app/screens/custom_status/custom_status_suggestion.tsx create mode 100644 app/screens/custom_status/index.ts create mode 100644 app/selectors/custom_status.ts create mode 100644 detox/e2e/support/ui/screen/custom_status.js create mode 100644 detox/e2e/test/custom_statuses/custom_status.e2e.js diff --git a/app/actions/views/custom_status.ts b/app/actions/views/custom_status.ts new file mode 100644 index 000000000..c0a179e11 --- /dev/null +++ b/app/actions/views/custom_status.ts @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@client/rest'; +import {logError} from '@mm-redux/actions/errors'; +import {UserTypes} from '@mm-redux/action_types'; +import {getCurrentUser} from '@mm-redux/selectors/entities/common'; +import {ActionFunc, DispatchFunc, batchActions, GetStateFunc} from '@mm-redux/types/actions'; +import {UserCustomStatus} from '@mm-redux/types/users'; + +export function setCustomStatus(customStatus: UserCustomStatus): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const user = getCurrentUser(getState()); + if (!user.props) { + user.props = {}; + } + + const oldCustomStatus = user.props.customStatus; + user.props.customStatus = JSON.stringify(customStatus); + dispatch({type: UserTypes.RECEIVED_ME, data: user}); + + try { + await Client4.updateCustomStatus(customStatus); + } catch (error) { + user.props.customStatus = oldCustomStatus; + dispatch(batchActions([ + {type: UserTypes.RECEIVED_ME, data: user}, + logError(error), + ])); + return {error}; + } + + return {data: true}; + }; +} + +export function unsetCustomStatus(): ActionFunc { + return async (dispatch: DispatchFunc) => { + try { + await Client4.unsetCustomStatus(); + } catch (error) { + dispatch(logError(error)); + return {error}; + } + return {data: true}; + }; +} + +export function removeRecentCustomStatus(customStatus: UserCustomStatus): ActionFunc { + return async (dispatch: DispatchFunc) => { + try { + await Client4.removeRecentCustomStatus(customStatus); + } catch (error) { + dispatch(logError(error)); + return {error}; + } + + return {data: true}; + }; +} + +export default { + setCustomStatus, + unsetCustomStatus, + removeRecentCustomStatus, +}; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index bcc59ce5d..c5ded531a 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -3,7 +3,7 @@ import {analytics} from '@init/analytics'; import {General} from '@mm-redux/constants'; -import {UserProfile, UserStatus} from '@mm-redux/types/users'; +import {UserCustomStatus, UserProfile, UserStatus} from '@mm-redux/types/users'; import {buildQueryString, isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; @@ -43,6 +43,9 @@ export interface ClientUsersMix { getStatusesByIds: (userIds: string[]) => Promise; getStatus: (userId: string) => Promise; updateStatus: (status: UserStatus) => Promise; + updateCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; + unsetCustomStatus: () => Promise<{status: string}>; + removeRecentCustomStatus: (customStatus: UserCustomStatus) => Promise<{status: string}>; } const ClientUsers = (superclass: any) => class extends superclass { @@ -393,6 +396,27 @@ const ClientUsers = (superclass: any) => class extends superclass { {method: 'put', body: JSON.stringify(status)}, ); }; + + updateCustomStatus = (customStatus: UserCustomStatus) => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom`, + {method: 'put', body: JSON.stringify(customStatus)}, + ); + }; + + unsetCustomStatus = () => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom`, + {method: 'delete'}, + ); + }; + + removeRecentCustomStatus = (customStatus: UserCustomStatus) => { + return this.doFetch( + `${this.getUserRoute('me')}/status/custom/recent/delete`, + {method: 'post', body: JSON.stringify(customStatus)}, + ); + }; }; export default ClientUsers; diff --git a/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap b/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap new file mode 100644 index 000000000..2d11d6d4f --- /dev/null +++ b/app/components/custom_status/__snapshots__/clear_button.test.tsx.snap @@ -0,0 +1,31 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/clear_button should match snapshot 1`] = ` + + + +`; diff --git a/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap new file mode 100644 index 000000000..6a20881b3 --- /dev/null +++ b/app/components/custom_status/__snapshots__/custom_status_emoji.test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/custom_status_emoji should match snapshot 1`] = ` + + + 📆 + + +`; + +exports[`components/custom_status/custom_status_emoji should match snapshot with props 1`] = ` + + + 📆 + + +`; diff --git a/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap new file mode 100644 index 000000000..ac27e1a6e --- /dev/null +++ b/app/components/custom_status/__snapshots__/custom_status_text.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/custom_status/custom_status_text should match snapshot 1`] = ` + + In a meeting + +`; + +exports[`components/custom_status/custom_status_text should match snapshot with empty text 1`] = ` + + + +`; diff --git a/app/components/custom_status/clear_button.test.tsx b/app/components/custom_status/clear_button.test.tsx new file mode 100644 index 000000000..e8888307c --- /dev/null +++ b/app/components/custom_status/clear_button.test.tsx @@ -0,0 +1,36 @@ +// 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 ClearButton from '@components/custom_status/clear_button'; +import Preferences from '@mm-redux/constants/preferences'; + +describe('components/custom_status/clear_button', () => { + const baseProps = { + theme: Preferences.THEMES.default, + handlePress: jest.fn(), + }; + + it('should match snapshot', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should call handlePress when press event is fired', () => { + const wrapper = shallow( + , + ); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(baseProps.handlePress).toBeCalled(); + }); +}); diff --git a/app/components/custom_status/clear_button.tsx b/app/components/custom_status/clear_button.tsx new file mode 100644 index 000000000..354cb5bec --- /dev/null +++ b/app/components/custom_status/clear_button.tsx @@ -0,0 +1,59 @@ +// 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 CompassIcon from '@components/compass_icon'; +import {Theme} from '@mm-redux/types/preferences'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +interface Props { + handlePress: () => void; + size?: number; + containerSize?: number; + theme: Theme; + testID?: string; + iconName: string, +} + +const ClearButton = ({handlePress, iconName, size, containerSize, theme, testID}: Props) => { + const style = getStyleSheet(theme); + + return ( + + + + ); +}; + +ClearButton.defaultProps = { + size: 20, + containerSize: 40, + iconName: 'close-circle', +}; + +export default ClearButton; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + button: { + borderRadius: 1000, + color: changeOpacity(theme.centerChannelColor, 0.52), + }, + }; +}); diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx new file mode 100644 index 000000000..3ed874891 --- /dev/null +++ b/app/components/custom_status/custom_status_emoji.test.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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'; + +jest.mock('@selectors/custom_status'); + +describe('components/custom_status/custom_status_emoji', () => { + const getCustomStatus = () => { + return { + emoji: 'calendar', + text: 'In a meeting', + }; + }; + (CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(getCustomStatus); + it('should match snapshot', () => { + const wrapper = renderWithRedux( + , + ); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot with props', () => { + const wrapper = renderWithRedux( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should not render when getCustomStatus returns null', () => { + (CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(() => null); + const wrapper = renderWithRedux( + , + ); + + expect(wrapper.toJSON()).toBeNull(); + }); +}); diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx new file mode 100644 index 000000000..29a891086 --- /dev/null +++ b/app/components/custom_status/custom_status_emoji.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TextStyle} from 'react-native'; +import {useSelector} from 'react-redux'; + +import Emoji from '@components/emoji'; +import {GlobalState} from '@mm-redux/types/store'; +import {makeGetCustomStatus} from '@selectors/custom_status'; + +interface ComponentProps { + emojiSize?: number; + userID?: string; + style?: TextStyle; + testID?: string; +} + +const CustomStatusEmoji = ({emojiSize, userID, style, testID}: ComponentProps) => { + const getCustomStatus = makeGetCustomStatus(); + const customStatus = useSelector((state: GlobalState) => { + return getCustomStatus(state, userID); + }); + if (!customStatus?.emoji) { + return null; + } + + const testIdPrefix = testID ? `${testID}.` : ''; + return ( + + + + ); +}; + +CustomStatusEmoji.defaultProps = { + emojiSize: 16, +}; + +export default CustomStatusEmoji; diff --git a/app/components/custom_status/custom_status_text.test.tsx b/app/components/custom_status/custom_status_text.test.tsx new file mode 100644 index 000000000..ecbd5a6bf --- /dev/null +++ b/app/components/custom_status/custom_status_text.test.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {shallow} from 'enzyme'; +import React from 'react'; + +import CustomStatusText from '@components/custom_status/custom_status_text'; +import Preferences from '@mm-redux/constants/preferences'; + +describe('components/custom_status/custom_status_text', () => { + const baseProps = { + text: 'In a meeting', + theme: Preferences.THEMES.default, + }; + + it('should match snapshot', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should match snapshot with empty text', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/components/custom_status/custom_status_text.tsx b/app/components/custom_status/custom_status_text.tsx new file mode 100644 index 000000000..eb868a5a6 --- /dev/null +++ b/app/components/custom_status/custom_status_text.tsx @@ -0,0 +1,40 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TextStyle} from 'react-native'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import FormattedText from '@components/formatted_text'; +import type {Theme} from '@mm-redux/types/preferences'; + +interface ComponentProps { + text: string | typeof FormattedText; + theme: Theme; + textStyle?: TextStyle; + ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip'; + numberOfLines?: number; +} + +const CustomStatusText = ({text, theme, textStyle, ellipsizeMode, numberOfLines}: ComponentProps) => ( + + {text} + +); + +export default CustomStatusText; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + label: { + color: changeOpacity(theme.centerChannelColor, 0.5), + fontSize: 17, + textAlignVertical: 'center', + includeFontPadding: false, + }, + }; +}); diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 346310e4f..22c90e202 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {View} from 'react-native'; +import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import FormattedTime from '@components/formatted_time'; import {CHANNEL, THREAD} from '@constants/screen'; import {Posts} from '@mm-redux/constants'; @@ -30,6 +31,7 @@ type HeaderProps = { shouldRenderReplyButton?: boolean; theme: Theme; userTimezone?: string | null; + isCustomStatusEnabled: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -52,12 +54,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { opacity: 0.5, flex: 1, }, + customStatusEmoji: { + color: theme.centerChannelColor, + marginRight: 4, + marginTop: 1, + }, }; }); const Header = ({ commentCount, displayName, location, isBot, isGuest, - isMilitaryTime, post, rootPostAuthor, shouldRenderReplyButton, theme, userTimezone, + isMilitaryTime, post, rootPostAuthor, shouldRenderReplyButton, theme, userTimezone, isCustomStatusEnabled, }: HeaderProps) => { const style = getStyleSheet(theme); const pendingPostStyle = isPostPendingOrFailed(post) ? style.pendingPost : undefined; @@ -66,6 +73,7 @@ const Header = ({ const isSystemPost = isSystemMessage(post); const isReplyPost = Boolean(post.root_id && (!isPostEphemeral(post) || post.state === Posts.POST_DELETED)); const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0)); + const showCustomStatusEmoji = Boolean(isCustomStatusEnabled && displayName && !(isSystemPost || isBot || isAutoResponse || isWebHook)); return ( <> @@ -80,6 +88,12 @@ const Header = ({ theme={theme} userId={post.user_id} /> + {showCustomStatusEmoji && ( + + )} {!isSystemPost && + + + + + display_name + + + + + +`; + exports[`ChannelItem should match snapshot with draft 1`] = ` + ) : null; + return ( {channelDisplayName} + {customStatus} {badge} @@ -234,16 +247,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { paddingRight: 10, marginLeft: 13, maxWidth: '80%', - flex: 1, alignSelf: 'center', fontFamily: 'Open Sans', }, textActive: { color: theme.sidebarTextActiveColor, + opacity: 1, }, textUnread: { + opacity: 1, color: theme.sidebarUnreadText, fontWeight: '500', + maxWidth: '70%', + }, + emoji: { + color: changeOpacity(theme.sidebarText, 0.6), + opacity: Platform.OS === 'ios' ? 0.6 : 1, }, badge: { backgroundColor: theme.mentionBg, diff --git a/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js b/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js index 6be9af206..1d51c7ab1 100644 --- a/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js +++ b/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js @@ -39,6 +39,7 @@ describe('ChannelItem', () => { unreadMsgs: 1, isSearchResult: false, isBot: false, + customStatusEnabled: true, }; test('should match snapshot', () => { @@ -207,6 +208,18 @@ describe('ChannelItem', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + test('should match snapshot with custom status emoji', () => { + const wrapper = shallowWithIntl( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + test('Should call onPress', () => { const onSelectChannel = jest.fn(); diff --git a/app/components/sidebars/main/channels_list/channel_item/index.js b/app/components/sidebars/main/channels_list/channel_item/index.js index a470ab9f4..f455084f7 100644 --- a/app/components/sidebars/main/channels_list/channel_item/index.js +++ b/app/components/sidebars/main/channels_list/channel_item/index.js @@ -15,6 +15,7 @@ import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entit import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils'; import {displayUsername} from '@mm-redux/utils/user_utils'; +import {isCustomStatusEnabled} from '@selectors/custom_status'; import {getDraftForChannel} from '@selectors/views'; import {isGuest as isGuestUser} from '@utils/users'; @@ -85,6 +86,7 @@ function makeMapStateToProps() { teammateId, theme: getTheme(state), unreadMsgs, + customStatusEnabled: isCustomStatusEnabled(state), }; }; } diff --git a/app/components/sidebars/settings/__snapshots__/drawer_item.test.js.snap b/app/components/sidebars/settings/__snapshots__/drawer_item.test.js.snap index 259b59c94..4770ddb6a 100644 --- a/app/components/sidebars/settings/__snapshots__/drawer_item.test.js.snap +++ b/app/components/sidebars/settings/__snapshots__/drawer_item.test.js.snap @@ -8,10 +8,9 @@ exports[`DrawerItem should match snapshot 1`] = ` @@ -51,9 +50,10 @@ exports[`DrawerItem should match snapshot 1`] = ` @@ -64,7 +64,6 @@ exports[`DrawerItem should match snapshot 1`] = ` Array [ Object { "color": "rgba(61,60,64,0.5)", - "flex": 1, "fontSize": 17, "includeFontPadding": false, "textAlignVertical": "center", @@ -92,3 +91,84 @@ exports[`DrawerItem should match snapshot 1`] = ` `; + +exports[`DrawerItem should match snapshot without separator and centered false 1`] = ` + + + + + + + + + + + + +`; 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 2d9fdfc42..4684891a9 100644 --- a/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap +++ b/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap @@ -15,3 +15,19 @@ exports[`SettingsSidebar should match snapshot 1`] = ` useNativeAnimations={true} /> `; + +exports[`SettingsSidebar should match snapshot with custom status enabled 1`] = ` + +`; diff --git a/app/components/sidebars/settings/drawer_item.js b/app/components/sidebars/settings/drawer_item.js index 05f15e5cb..ca1ab2b5c 100644 --- a/app/components/sidebars/settings/drawer_item.js +++ b/app/components/sidebars/settings/drawer_item.js @@ -87,11 +87,11 @@ export default class DrawerItem extends PureComponent { onPress={onPress} > - {icon && - - {icon} - - } + {icon && ( + + {icon} + + )} {label} @@ -107,10 +107,9 @@ export default class DrawerItem extends PureComponent { const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { container: { - alignItems: 'center', backgroundColor: theme.centerChannelBg, flexDirection: 'row', - height: 50, + minHeight: 50, }, iconContainer: { width: 45, @@ -127,9 +126,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { flex: 1, }, labelContainer: { - alignItems: 'center', flex: 1, - flexDirection: 'row', + justifyContent: 'center', + paddingTop: 14, + paddingBottom: 14, }, centerLabel: { textAlign: 'center', @@ -137,7 +137,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }, label: { color: changeOpacity(theme.centerChannelColor, 0.5), - flex: 1, fontSize: 17, textAlignVertical: 'center', includeFontPadding: false, diff --git a/app/components/sidebars/settings/drawer_item.test.js b/app/components/sidebars/settings/drawer_item.test.js index db33ee794..6288a5ec1 100644 --- a/app/components/sidebars/settings/drawer_item.test.js +++ b/app/components/sidebars/settings/drawer_item.test.js @@ -28,4 +28,17 @@ describe('DrawerItem', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should match snapshot without separator and centered false', () => { + const props = { + ...baseProps, + centered: false, + separator: false, + }; + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); }); diff --git a/app/components/sidebars/settings/index.js b/app/components/sidebars/settings/index.js index 9ce1d0b1a..4195fb1e0 100644 --- a/app/components/sidebars/settings/index.js +++ b/app/components/sidebars/settings/index.js @@ -4,23 +4,32 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; +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 {logout} from 'app/actions/views/user'; import SettingsSidebar from './settings_sidebar'; -function mapStateToProps(state) { - const currentUser = getCurrentUser(state) || {}; - const status = getStatusForUserId(state, currentUser.id); +function makeMapStateToProps() { + const getCustomStatus = makeGetCustomStatus(); + return (state) => { + const currentUser = getCurrentUser(state) || {}; + const status = getStatusForUserId(state, currentUser.id); - return { - currentUser, - locale: currentUser?.locale, - status, - theme: getTheme(state), + const customStatusEnabled = isCustomStatusEnabled(state); + const customStatus = customStatusEnabled ? getCustomStatus(state) : undefined; + return { + currentUser, + locale: currentUser?.locale, + status, + theme: getTheme(state), + isCustomStatusEnabled: customStatusEnabled, + customStatus, + }; }; } @@ -29,8 +38,9 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ logout, setStatus, + unsetCustomStatus, }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(SettingsSidebar); +export default connect(makeMapStateToProps, mapDispatchToProps, null, {forwardRef: true})(SettingsSidebar); diff --git a/app/components/sidebars/settings/settings_sidebar.android.js b/app/components/sidebars/settings/settings_sidebar.android.js index ae51e329a..dbc95f4aa 100644 --- a/app/components/sidebars/settings/settings_sidebar.android.js +++ b/app/components/sidebars/settings/settings_sidebar.android.js @@ -47,6 +47,11 @@ export default class SettingsDrawerAndroid extends SettingsSidebarBase { this.goToSettingsScreeen(intl); }); + goToCustomStatus = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToCustomStatusScreen(intl); + }) + renderNavigationView = () => { const {theme} = this.props; const style = getStyleSheet(theme); diff --git a/app/components/sidebars/settings/settings_sidebar.ios.js b/app/components/sidebars/settings/settings_sidebar.ios.js index 77c1976d1..37f2532ee 100644 --- a/app/components/sidebars/settings/settings_sidebar.ios.js +++ b/app/components/sidebars/settings/settings_sidebar.ios.js @@ -111,6 +111,11 @@ export default class SettingsDrawer extends SettingsSidebarBase { this.goToSettingsScreeen(intl); }); + goToCustomStatus = preventDoubleTap(() => { + const {intl} = this.context; + this.goToCustomStatusScreen(intl); + }) + renderNavigationView = () => { const {theme} = this.props; const style = getStyleSheet(theme); diff --git a/app/components/sidebars/settings/settings_sidebar.test.js b/app/components/sidebars/settings/settings_sidebar.test.js index 4ed6cf465..cd7d1f53b 100644 --- a/app/components/sidebars/settings/settings_sidebar.test.js +++ b/app/components/sidebars/settings/settings_sidebar.test.js @@ -9,16 +9,24 @@ import Preferences from '@mm-redux/constants/preferences'; import SettingsSidebar from './settings_sidebar.ios'; describe('SettingsSidebar', () => { + const customStatus = { + emoji: 'calendar', + text: 'In a meeting', + }; + const baseProps = { actions: { logout: jest.fn(), setStatus: jest.fn(), + unsetCustomStatus: jest.fn(), }, currentUser: { id: 'user-id', }, status: 'offline', theme: Preferences.THEMES.default, + isCustomStatusEnabled: false, + customStatus, }; test('should match snapshot', () => { @@ -28,4 +36,15 @@ describe('SettingsSidebar', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + + it('should match snapshot with custom status enabled', () => { + 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 ce99d76d6..b75e4ae98 100644 --- a/app/components/sidebars/settings/settings_sidebar_base.js +++ b/app/components/sidebars/settings/settings_sidebar_base.js @@ -10,11 +10,16 @@ import EventEmitter from '@mm-redux/utils/event_emitter'; import {showModal, showModalOverCurrentContext, dismissModal} from '@actions/navigation'; import CompassIcon from '@components/compass_icon'; +import CustomStatusText from '@components/custom_status/custom_status_text'; +import ClearButton from '@components/custom_status/clear_button'; +import Emoji from '@components/emoji'; +import FormattedText from '@components/formatted_text'; import UserStatus from '@components/user_status'; -import {NavigationTypes} from '@constants'; +import {NavigationTypes, CustomStatus} from '@constants'; +import {t} from '@utils/i18n'; import {confirmOutOfOfficeDisabled} from '@utils/status'; import {preventDoubleTap} from '@utils/tap'; -import {t} from '@utils/i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import DrawerItem from './drawer_item'; import UserInfo from './user_info'; @@ -25,10 +30,13 @@ export default class SettingsSidebarBase extends PureComponent { actions: PropTypes.shape({ logout: PropTypes.func.isRequired, setStatus: PropTypes.func.isRequired, + unsetCustomStatus: PropTypes.func.isRequired, }).isRequired, currentUser: PropTypes.object.isRequired, status: PropTypes.string, theme: PropTypes.object.isRequired, + isCustomStatusEnabled: PropTypes.bool.isRequired, + customStatus: PropTypes.object, }; static defaultProps = { @@ -36,14 +44,47 @@ export default class SettingsSidebarBase extends PureComponent { status: 'offline', }; + constructor(props) { + super(props); + this.state = { + showStatus: props.isCustomStatusEnabled, + showRetryMessage: false, + }; + } + componentDidMount() { this.mounted = true; EventEmitter.on(NavigationTypes.CLOSE_SETTINGS_SIDEBAR, this.closeSettingsSidebar); + EventEmitter.on(CustomStatus.SET_CUSTOM_STATUS_FAILURE, this.handleSetCustomStatusFailure); + } + + componentDidUpdate(prevProps) { + this.handleCustomStatusChange(prevProps.customStatus, this.props.customStatus); } componentWillUnmount() { this.mounted = false; EventEmitter.off(NavigationTypes.CLOSE_SETTINGS_SIDEBAR, this.closeSettingsSidebar); + EventEmitter.off(CustomStatus.SET_CUSTOM_STATUS_FAILURE, this.handleSetCustomStatusFailure); + } + + handleSetCustomStatusFailure = () => { + this.setState({ + showRetryMessage: true, + }); + } + + handleCustomStatusChange = (prevCustomStatus, customStatus) => { + const isStatusSet = Boolean(customStatus?.emoji); + if (isStatusSet) { + const isStatusChanged = prevCustomStatus?.emoji !== customStatus.emoji || prevCustomStatus?.text !== customStatus.text; + if (isStatusChanged) { + this.setState({ + showStatus: true, + showRetryMessage: false, + }); + } + } } confirmResetBase = (status, intl) => { @@ -123,6 +164,11 @@ export default class SettingsSidebarBase extends PureComponent { ); }; + goToCustomStatusScreen = (intl) => { + this.closeSettingsSidebar(); + showModal('CustomStatus', intl.formatMessage({id: 'mobile.routes.custom_status', defaultMessage: 'Set a Status'})); + } + logout = preventDoubleTap(() => { const {logout} = this.props.actions; this.closeSettingsSidebar(); @@ -185,6 +231,102 @@ export default class SettingsSidebarBase extends PureComponent { ); }; + clearCustomStatus = async () => { + this.setState({showStatus: false, showRetryMessage: false}); + const {error} = await this.props.actions.unsetCustomStatus(); + if (error) { + this.setState({showStatus: true, showRetryMessage: true}); + } + } + + renderCustomStatus = () => { + const {isCustomStatusEnabled, customStatus, theme} = this.props; + const {showStatus, showRetryMessage} = this.state; + + if (!isCustomStatusEnabled) { + return null; + } + + const style = getStyleSheet(theme); + const isStatusSet = customStatus?.emoji && showStatus; + + const customStatusEmoji = ( + + {isStatusSet ? ( + + ) : ( + + )} + + ); + + const clearButton = isStatusSet ? + ( + + ) : null; + + const retryMessage = showRetryMessage ? + ( + + ) : null; + + const text = isStatusSet ? customStatus.text : ( + + ); + + const labelComponent = ( + <> + + + + {retryMessage} + {clearButton && + + {clearButton} + + } + + ); + + return ( + + ); + }; + renderOptions = (style) => { const {currentUser, theme} = this.props; @@ -207,10 +349,11 @@ export default class SettingsSidebarBase extends PureComponent { testID='settings.sidebar.status.action' labelComponent={this.renderUserStatusLabel(currentUser.id)} leftComponent={this.renderUserStatusIcon(currentUser.id)} - separator={false} + separator={this.props.isCustomStatusEnabled} onPress={this.handleSetStatus} theme={theme} /> + {this.renderCustomStatus()} @@ -276,3 +419,22 @@ export default class SettingsSidebarBase extends PureComponent { return; // eslint-disable-line no-useless-return } } + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + customStatusTextContainer: { + width: '70%', + }, + customStatusIcon: { + color: changeOpacity(theme.centerChannelColor, 0.64), + }, + clearButton: { + position: 'absolute', + top: 4, + right: 14, + }, + retryMessage: { + color: theme.errorTextColor, + }, + }; +}); diff --git a/app/components/sidebars/settings/status_label/status_label.js b/app/components/sidebars/settings/status_label/status_label.js index 8980a294b..afe3b462d 100644 --- a/app/components/sidebars/settings/status_label/status_label.js +++ b/app/components/sidebars/settings/status_label/status_label.js @@ -61,7 +61,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { label: { color: changeOpacity(theme.centerChannelColor, 0.5), - flex: 1, fontSize: 17, textAlignVertical: 'center', includeFontPadding: false, diff --git a/app/constants/custom_status.ts b/app/constants/custom_status.ts new file mode 100644 index 000000000..29dea9894 --- /dev/null +++ b/app/constants/custom_status.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100; +export const SET_CUSTOM_STATUS_FAILURE = 'set_custom_status_failure'; diff --git a/app/constants/index.js b/app/constants/index.js index 07b4215e2..729dccaf2 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -3,6 +3,7 @@ import AttachmentTypes from './attachment'; import CustomPropTypes from './custom_prop_types'; +import * as CustomStatus from './custom_status'; import DeepLinkTypes from './deep_linking'; import DeviceTypes from './device'; import ListTypes from './list'; @@ -21,4 +22,5 @@ export { Types, ViewTypes, WebsocketEvents, + CustomStatus, }; diff --git a/app/mm-redux/constants/preferences.ts b/app/mm-redux/constants/preferences.ts index 6224050c5..947405651 100644 --- a/app/mm-redux/constants/preferences.ts +++ b/app/mm-redux/constants/preferences.ts @@ -23,6 +23,11 @@ const Preferences: Dictionary = { INTERVAL_HOUR: 60 * 60, INTERVAL_IMMEDIATE: 30, + CATEGORY_CUSTOM_STATUS: 'custom_status', + NAME_CUSTOM_STATUS_TUTORIAL_STATE: 'custom_status_tutorial_state', + NAME_RECENT_CUSTOM_STATUSES: 'recent_custom_statuses', + CUSTOM_STATUS_MODAL_VIEWED: 'custom_status_modal_viewed', + // "immediate" is a 30 second interval INTERVAL_NEVER: 0, INTERVAL_NOT_SET: -1, diff --git a/app/mm-redux/types/config.ts b/app/mm-redux/types/config.ts index b7c9ef2ef..25948c3cd 100644 --- a/app/mm-redux/types/config.ts +++ b/app/mm-redux/types/config.ts @@ -50,6 +50,7 @@ export type Config = { EnableCustomBrand: string; EnableCustomEmoji: string; EnableCustomTermsOfService: string; + EnableCustomUserStatuses: string; EnableDeveloper: string; EnableDiagnostics: string; EnableEmailBatching: string; diff --git a/app/mm-redux/types/module.d.ts b/app/mm-redux/types/module.d.ts index 3aba157d1..c9a3a5ed3 100644 --- a/app/mm-redux/types/module.d.ts +++ b/app/mm-redux/types/module.d.ts @@ -6,3 +6,4 @@ declare module 'redux-action-buffer'; declare module 'react-intl'; declare module 'react-native-local-auth'; declare module 'react-native-cookies'; +declare module 'react-native-keyboard-aware-scrollview'; diff --git a/app/mm-redux/types/users.ts b/app/mm-redux/types/users.ts index 59d9ecff5..6fe83c19b 100644 --- a/app/mm-redux/types/users.ts +++ b/app/mm-redux/types/users.ts @@ -3,7 +3,7 @@ import {Channel} from './channels'; import {Team} from './teams'; import {PostType} from './posts'; -import {$ID, IDMappedObjects, RelationOneToMany, RelationOneToOne} from './utilities'; +import {$ID, IDMappedObjects, RelationOneToMany, RelationOneToOne, Dictionary} from './utilities'; export type UserNotifyProps = { auto_responder_active?: 'true' | 'false'; auto_responder_message?: string; @@ -37,6 +37,7 @@ export type UserProfile = { roles: string; remote_id?: string; locale: string; + props: Dictionary; notify_props: UserNotifyProps; terms_of_service_id: string; terms_of_service_create_at: number; @@ -78,3 +79,8 @@ export type UserStatus = { last_activity_at: number; active_channel?: string; } + +export type UserCustomStatus = { + emoji: string; + text: string; +} diff --git a/app/screens/channel/channel_nav_bar/channel_title/__snapshots__/channel_title.test.js.snap b/app/screens/channel/channel_nav_bar/channel_title/__snapshots__/channel_title.test.js.snap index f41367905..09d7c61e2 100644 --- a/app/screens/channel/channel_nav_bar/channel_title/__snapshots__/channel_title.test.js.snap +++ b/app/screens/channel/channel_nav_bar/channel_title/__snapshots__/channel_title.test.js.snap @@ -11,14 +11,19 @@ exports[`ChannelTitle should match snapshot 1`] = ` > + + `; @@ -192,14 +235,19 @@ exports[`ChannelTitle should match snapshot when isChannelShared is true 1`] = ` > `; + +exports[`ChannelTitle should match snapshot with custom status emoji 1`] = ` + + + + My Channel + + + + + +`; diff --git a/app/screens/channel/channel_nav_bar/channel_title/channel_title.js b/app/screens/channel/channel_nav_bar/channel_title/channel_title.js index 3a510dc95..5be9a108a 100644 --- a/app/screens/channel/channel_nav_bar/channel_title/channel_title.js +++ b/app/screens/channel/channel_nav_bar/channel_title/channel_title.js @@ -14,9 +14,10 @@ import {General} from '@mm-redux/constants'; import ChannelIcon from '@components/channel_icon'; import CompassIcon from '@components/compass_icon'; +import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import FormattedText from '@components/formatted_text'; -import {makeStyleSheetFromTheme} from '@utils/theme'; import {t} from '@utils/i18n'; +import {makeStyleSheetFromTheme} from '@utils/theme'; export default class ChannelTitle extends PureComponent { static propTypes = { @@ -32,6 +33,8 @@ export default class ChannelTitle extends PureComponent { isSelfDMChannel: PropTypes.bool.isRequired, onPress: PropTypes.func, theme: PropTypes.object, + teammateId: PropTypes.string, + customStatusEnabled: PropTypes.bool.isRequired, }; static defaultProps = { @@ -144,6 +147,7 @@ export default class ChannelTitle extends PureComponent { } let mutedIcon; + let wrapperWidth = 90; if (isChannelMuted) { mutedIcon = ( ); + wrapperWidth -= 10; + } + + const customStatus = this.props.channelType === General.DM_CHANNEL && this.props.customStatusEnabled ? + ( + + ) : null; + + if (customStatus) { + wrapperWidth -= 10; } let channelIcon; @@ -176,7 +194,7 @@ export default class ChannelTitle extends PureComponent { style={style.container} onPress={onPress} > - + {this.archiveIcon(style)} {channelIcon} {icon} + {customStatus} {mutedIcon} {hasGuestsText} @@ -213,6 +232,9 @@ const getStyle = makeStyleSheetFromTheme((theme) => { color: theme.sidebarHeaderTextColor, marginHorizontal: 1, }, + emoji: { + marginHorizontal: 5, + }, text: { color: theme.sidebarHeaderTextColor, fontSize: 18, diff --git a/app/screens/channel/channel_nav_bar/channel_title/channel_title.test.js b/app/screens/channel/channel_nav_bar/channel_title/channel_title.test.js index 9647feff3..3844e7ac6 100644 --- a/app/screens/channel/channel_nav_bar/channel_title/channel_title.test.js +++ b/app/screens/channel/channel_nav_bar/channel_title/channel_title.test.js @@ -15,6 +15,7 @@ describe('ChannelTitle', () => { hasGuests: false, canHaveSubtitle: false, isSelfDMChannel: false, + customStatusEnabled: true, }; test('should match snapshot', () => { @@ -83,4 +84,15 @@ describe('ChannelTitle', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should match snapshot with custom status emoji', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); }); diff --git a/app/screens/channel/channel_nav_bar/channel_title/index.js b/app/screens/channel/channel_nav_bar/channel_title/index.js index cc3633bc8..ea43f1c0b 100644 --- a/app/screens/channel/channel_nav_bar/channel_title/index.js +++ b/app/screens/channel/channel_nav_bar/channel_title/index.js @@ -8,6 +8,7 @@ import {getCurrentChannel, getMyCurrentChannelMembership, getCurrentChannelStats import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils'; +import {isCustomStatusEnabled} from '@selectors/custom_status'; import {isGuest} from 'app/utils/users'; @@ -21,8 +22,9 @@ function mapStateToProps(state) { let isTeammateGuest = false; let isSelfDMChannel = false; + let teammateId; if (currentChannel && currentChannel.type === General.DM_CHANNEL) { - const teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name); + teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name); const teammate = getUser(state, teammateId); isTeammateGuest = isGuest(teammate); isSelfDMChannel = currentUserId === currentChannel.teammate_id; @@ -39,6 +41,8 @@ function mapStateToProps(state) { isGuest: isTeammateGuest, isSelfDMChannel, theme: getTheme(state), + teammateId, + customStatusEnabled: isCustomStatusEnabled(state), }; } 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 598d7ac53..3c014e90d 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -33,6 +33,7 @@ exports[`channelInfo should match snapshot 1`] = ` hasGuests={false} header="" isArchived={false} + isCustomStatusEnabled={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 230900eb1..22110b1e7 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 @@ -1940,3 +1940,379 @@ exports[`channel_info_header should match snapshot when public channel and hasGu `; + +exports[`channel_info_header should match snapshot with custom status enabled 1`] = ` + + + + + Channel name + + + + + + + + + + + + Purpose string + + + + + + + + + + + + + + + + + +`; diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 2ab8bed02..24f668a89 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -48,6 +48,8 @@ export default class ChannelInfo extends PureComponent { isDirectMessage: PropTypes.bool.isRequired, teammateId: PropTypes.string, theme: PropTypes.object.isRequired, + customStatus: PropTypes.object, + isCustomStatusEnabled: PropTypes.bool.isRequired, }; static defaultProps = { @@ -168,6 +170,8 @@ export default class ChannelInfo extends PureComponent { teammateId, theme, isTeammateGuest, + customStatus, + isCustomStatusEnabled, } = this.props; const style = getStyleSheet(theme); @@ -201,6 +205,8 @@ export default class ChannelInfo extends PureComponent { hasGuests={currentChannelGuestCount > 0} isGroupConstrained={currentChannel.group_constrained} testID='channel_info.header' + customStatus={customStatus} + isCustomStatusEnabled={isCustomStatusEnabled} /> } diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js index 914b0c2c8..eb62c9447 100644 --- a/app/screens/channel_info/channel_info.test.js +++ b/app/screens/channel_info/channel_info.test.js @@ -47,6 +47,8 @@ describe('channelInfo', () => { theme: Preferences.THEMES.default, isTeammateGuest: false, isDirectMessage: false, + isLandscape: false, + isCustomStatusEnabled: 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 8895977cb..af346f74a 100644 --- a/app/screens/channel_info/channel_info_header.js +++ b/app/screens/channel_info/channel_info_header.js @@ -14,6 +14,8 @@ import Clipboard from '@react-native-community/clipboard'; import {popToRoot} from '@actions/navigation'; import ChannelIcon from '@components/channel_icon'; +import CustomStatusText from '@components/custom_status/custom_status_text'; +import Emoji from '@components/emoji'; import FormattedDate from '@components/formatted_date'; import FormattedText from '@components/formatted_text'; import Markdown from '@components/markdown'; @@ -22,6 +24,7 @@ import BottomSheet from '@utils/bottom_sheet'; import {t} from '@utils/i18n'; import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + import mattermostManaged from 'app/mattermost_managed'; export default class ChannelInfoHeader extends React.PureComponent { @@ -42,6 +45,8 @@ export default class ChannelInfoHeader extends React.PureComponent { isGroupConstrained: PropTypes.bool, testID: PropTypes.string, timezone: PropTypes.string, + customStatus: PropTypes.object, + isCustomStatusEnabled: PropTypes.bool.isRequired, }; static contextTypes = { @@ -140,6 +145,8 @@ export default class ChannelInfoHeader extends React.PureComponent { isGroupConstrained, testID, timezone, + customStatus, + isCustomStatusEnabled, } = this.props; const style = getStyleSheet(theme); @@ -173,6 +180,26 @@ export default class ChannelInfoHeader extends React.PureComponent { {displayName} + {isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji && + + + + + } {this.renderHasGuestText(style)} {purpose.length > 0 && @@ -267,6 +294,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { color: theme.centerChannelColor, marginLeft: 13, }, + iconContainer: { + marginRight: 8, + color: theme.centerChannelColor, + }, + customStatusContainer: { + position: 'relative', + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + }, + customStatusText: { + flex: 1, + fontSize: 15, + color: theme.centerChannelColor, + width: '80%', + }, channelNameContainer: { flexDirection: 'row', alignItems: 'center', diff --git a/app/screens/channel_info/channel_info_header.test.js b/app/screens/channel_info/channel_info_header.test.js index ac6bf7ec0..c84c520c8 100644 --- a/app/screens/channel_info/channel_info_header.test.js +++ b/app/screens/channel_info/channel_info_header.test.js @@ -43,6 +43,7 @@ describe('channel_info_header', () => { hasGuests: false, isGroupConstrained: false, testID: 'channel_info.header', + isCustomStatusEnabled: false, }; test('should match snapshot', async () => { @@ -114,4 +115,22 @@ describe('channel_info_header', () => { ); expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should match snapshot with custom status enabled', async () => { + const customStatus = { + emoji: 'calendar', + text: 'In a meeting', + }; + + 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 8c2492e6d..d76a639f2 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -13,47 +13,57 @@ 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 {isGuest} from '@utils/users'; import ChannelInfo from './channel_info'; -function mapStateToProps(state) { - const currentChannel = getCurrentChannel(state) || {}; - const currentChannelCreator = getUser(state, currentChannel.creator_id); - const teammateNameDisplay = getTeammateNameDisplaySetting(state); - const currentChannelCreatorName = displayUsername(currentChannelCreator, teammateNameDisplay); - const currentChannelStats = getCurrentChannelStats(state); - let currentChannelMemberCount = currentChannelStats && currentChannelStats.member_count; - let currentChannelGuestCount = (currentChannelStats && currentChannelStats.guest_count) || 0; - const currentUserId = getCurrentUserId(state); +function makeMapStateToProps() { + const getCustomStatus = makeGetCustomStatus(); + return (state) => { + const currentChannel = getCurrentChannel(state) || {}; + const currentChannelCreator = getUser(state, currentChannel.creator_id); + const teammateNameDisplay = getTeammateNameDisplaySetting(state); + const currentChannelCreatorName = displayUsername(currentChannelCreator, teammateNameDisplay); + const currentChannelStats = getCurrentChannelStats(state); + let currentChannelMemberCount = currentChannelStats && currentChannelStats.member_count; + let currentChannelGuestCount = (currentChannelStats && currentChannelStats.guest_count) || 0; + const currentUserId = getCurrentUserId(state); - let teammateId; - let isTeammateGuest = false; - const isDirectMessage = currentChannel.type === General.DM_CHANNEL; + let teammateId; + let isTeammateGuest = false; + let customStatusEnabled; + let customStatus; + const isDirectMessage = currentChannel.type === General.DM_CHANNEL; - if (isDirectMessage) { - teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name); - const teammate = getUser(state, teammateId); - if (isGuest(teammate)) { - isTeammateGuest = true; - currentChannelGuestCount = 1; + if (isDirectMessage) { + teammateId = getUserIdFromChannelName(currentUserId, currentChannel.name); + const teammate = getUser(state, teammateId); + if (isGuest(teammate)) { + isTeammateGuest = true; + currentChannelGuestCount = 1; + } + customStatusEnabled = isCustomStatusEnabled(state); + customStatus = customStatusEnabled ? getCustomStatus(state, teammateId) : undefined; } - } - if (currentChannel.type === General.GM_CHANNEL) { - currentChannelMemberCount = currentChannel.display_name.split(',').length; - } + if (currentChannel.type === General.GM_CHANNEL) { + currentChannelMemberCount = currentChannel.display_name.split(',').length; + } - return { - currentChannel, - currentChannelCreatorName, - currentChannelGuestCount, - currentChannelMemberCount, - currentUserId, - isTeammateGuest, - isDirectMessage, - teammateId, - theme: getTheme(state), + return { + currentChannel, + currentChannelCreatorName, + currentChannelGuestCount, + currentChannelMemberCount, + currentUserId, + isTeammateGuest, + isDirectMessage, + teammateId, + theme: getTheme(state), + customStatus, + isCustomStatusEnabled: customStatusEnabled, + }; }; } @@ -67,4 +77,4 @@ function mapDispatchToProps(dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(ChannelInfo); +export default connect(makeMapStateToProps, mapDispatchToProps)(ChannelInfo); 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 new file mode 100644 index 000000000..afc623737 --- /dev/null +++ b/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap @@ -0,0 +1,235 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screens/custom_status_modal should match snapshot 1`] = ` + +`; + +exports[`screens/custom_status_modal should match snapshot when user has no custom status set 1`] = ` + +`; + +exports[`screens/custom_status_modal should match snapshot when user has no recent custom status 1`] = ` + +`; 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 new file mode 100644 index 000000000..66ef1e248 --- /dev/null +++ b/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap @@ -0,0 +1,239 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`screens/custom_status_suggestion should match snapshot 1`] = ` + + + + + + + + + + + + +`; + +exports[`screens/custom_status_suggestion should match snapshot with separator and clear button 1`] = ` + + + + + + + + + + + + + + + + +`; diff --git a/app/screens/custom_status/custom_status_modal.test.tsx b/app/screens/custom_status/custom_status_modal.test.tsx new file mode 100644 index 000000000..5c590b18e --- /dev/null +++ b/app/screens/custom_status/custom_status_modal.test.tsx @@ -0,0 +1,57 @@ +// 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 CustomStatusModal from '@screens/custom_status/custom_status_modal'; + +import {shallowWithIntl} from 'test/intl-test-helper'; + +describe('screens/custom_status_modal', () => { + const customStatus = { + emoji: 'calendar', + text: 'In a meeting', + }; + + const baseProps = { + actions: { + setCustomStatus: jest.fn(), + unsetCustomStatus: jest.fn(), + removeRecentCustomStatus: jest.fn(), + }, + theme: Preferences.THEMES.default, + customStatus, + recentCustomStatuses: [customStatus], + }; + + it('should match snapshot', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should match snapshot when user has no custom status set', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should match snapshot when user has no recent custom status', () => { + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/screens/custom_status/custom_status_modal.tsx b/app/screens/custom_status/custom_status_modal.tsx new file mode 100644 index 000000000..68a9f4559 --- /dev/null +++ b/app/screens/custom_status/custom_status_modal.tsx @@ -0,0 +1,398 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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 Emoji from '@components/emoji'; +import CompassIcon from '@components/compass_icon'; +import ClearButton from '@components/custom_status/clear_button'; +import FormattedText from '@components/formatted_text'; +import StatusBar from '@components/status_bar'; +import {CustomStatus, DeviceTypes} from '@constants'; +import {ActionFunc, ActionResult} from '@mm-redux/types/actions'; +import {Theme} from '@mm-redux/types/preferences'; +import {UserCustomStatus} from '@mm-redux/types/users'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import CustomStatusSuggestion from '@screens/custom_status/custom_status_suggestion'; +import {t} from '@utils/i18n'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; + +type DefaultUserCustomStatus = { + emoji: string; + message: string; + messageDefault: string; +}; + +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'}, +]; + +interface Props extends NavigationComponentProps { + intl: typeof intlShape; + theme: Theme; + customStatus: UserCustomStatus; + recentCustomStatuses: UserCustomStatus[]; + isLandscape: boolean; + actions: { + setCustomStatus: (customStatus: UserCustomStatus) => Promise; + unsetCustomStatus: () => ActionFunc; + removeRecentCustomStatus: (customStatus: UserCustomStatus) => ActionFunc; + }; +} + +type State = { + emoji: string; + text: string; +} + +class CustomStatusModal extends NavigationComponent { + rightButton: OptionsTopBarButton = { + id: 'update-custom-status', + testID: 'custom_status.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 = { + emoji: props.customStatus?.emoji, + text: props.customStatus?.text || '', + }; + } + + componentDidMount() { + Navigation.events().bindComponent(this); + } + + navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) { + switch (buttonId) { + case 'update-custom-status': + this.handleSetStatus(); + break; + } + } + + handleSetStatus = async () => { + const {emoji, text} = this.state; + const isStatusSet = emoji || text; + const {customStatus} = this.props; + if (isStatusSet) { + const isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text; + if (!isStatusSame) { + const status = { + emoji: emoji || 'speech_balloon', + text: text.trim(), + }; + const {error} = await this.props.actions.setCustomStatus(status); + if (error) { + EventEmitter.emit(CustomStatus.SET_CUSTOM_STATUS_FAILURE); + } + } + } else if (customStatus?.emoji) { + this.props.actions.unsetCustomStatus(); + } + Keyboard.dismiss(); + dismissModal(); + }; + + handleTextChange = (value: string) => this.setState({text: value}); + + handleRecentCustomStatusClear = (status: UserCustomStatus) => this.props.actions.removeRecentCustomStatus(status); + + clearHandle = () => { + this.setState({emoji: '', text: ''}); + }; + + handleSuggestionClick = (status: UserCustomStatus) => { + const {emoji, text} = status; + this.setState({emoji, text}); + }; + + renderRecentCustomStatuses = (style: Record>) => { + const {recentCustomStatuses, theme} = this.props; + const recentStatuses = recentCustomStatuses.map((status: UserCustomStatus, index: number) => ( + + )); + + if (recentStatuses.length === 0) { + return null; + } + + return ( + <> + + + + + {recentStatuses} + + + + ); + }; + + renderCustomStatusSuggestions = (style: Record>) => { + const {recentCustomStatuses, theme, intl} = 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}), + })). + filter((status: UserCustomStatus) => !recentCustomStatusTexts.includes(status.text)). + map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => ( + + )); + + if (customStatusSuggestions.length === 0) { + return null; + } + + return ( + <> + + + + + {customStatusSuggestions} + + + + ); + }; + + openEmojiPicker = () => { + const {theme, intl} = this.props; + CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => { + const screen = 'AddReaction'; + const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}); + const passProps = { + closeButton: source, + onEmojiPress: this.handleEmojiClick, + }; + + showModal(screen, title, passProps); + }); + }; + + handleEmojiClick = (emoji: string) => { + dismissModal(); + this.setState({emoji}); + } + + render() { + const {emoji, text} = this.state; + const {theme, isLandscape} = this.props; + + const isStatusSet = emoji || text; + const style = getStyleSheet(theme); + const customStatusEmoji = ( + + {isStatusSet ? ( + + + + ) : ( + + )} + + ); + + const customStatusInput = ( + + + {customStatusEmoji} + {isStatusSet ? ( + + + + ) : null} + + ); + + let keyboardOffset = DeviceTypes.IS_IPHONE_WITH_INSETS ? 110 : 60; + if (isLandscape) { + keyboardOffset = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 10; + } + + return ( + + + + + + {customStatusInput} + {this.renderRecentCustomStatuses(style)} + {this.renderCustomStatusSuggestions(style)} + + + + + ); + } +} + +export default injectIntl(CustomStatusModal); + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + 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), + backgroundColor: theme.centerChannelBg, + }, + input: { + alignSelf: 'stretch', + color: theme.centerChannelColor, + width: '100%', + fontSize: 17, + paddingHorizontal: 52, + textAlignVertical: 'center', + height: 48, + }, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.64), + }, + emoji: { + color: theme.centerChannelColor, + }, + iconContainer: { + position: 'absolute', + left: 14, + top: 12, + }, + separator: { + marginTop: 32, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + title: { + fontSize: 17, + marginBottom: 12, + color: changeOpacity(theme.centerChannelColor, 0.5), + marginLeft: 16, + }, + clearButton: { + position: 'absolute', + top: 3, + right: 14, + }, + }; +}); diff --git a/app/screens/custom_status/custom_status_suggestion.test.tsx b/app/screens/custom_status/custom_status_suggestion.test.tsx new file mode 100644 index 000000000..774b73039 --- /dev/null +++ b/app/screens/custom_status/custom_status_suggestion.test.tsx @@ -0,0 +1,52 @@ +// 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'; + +describe('screens/custom_status_suggestion', () => { + const baseProps = { + handleSuggestionClick: jest.fn(), + emoji: 'calendar', + text: 'In a meeting', + theme: Preferences.THEMES.default, + separator: false, + }; + + it('should match snapshot', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should match snapshot with separator and clear button', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + it('should call handleSuggestionClick on clicking the suggestion', () => { + const wrapper = shallow( + , + ); + + wrapper.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 new file mode 100644 index 000000000..35d8cffc7 --- /dev/null +++ b/app/screens/custom_status/custom_status_suggestion.tsx @@ -0,0 +1,116 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +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 {Theme} from '@mm-redux/types/preferences'; +import {UserCustomStatus} from '@mm-redux/types/users'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + handleSuggestionClick: (status: UserCustomStatus) => void; + emoji: string; + text: string; + handleClear?: (status: UserCustomStatus) => void; + theme: Theme; + separator: boolean; +}; + +const CustomStatusSuggestion = (props: Props) => { + const {handleSuggestionClick, emoji, text, theme, separator, handleClear} = props; + const style = getStyleSheet(theme); + + const handleClick = useCallback(preventDoubleTap(() => { + handleSuggestionClick({emoji, text}); + }), []); + + const handleSuggestionClear = useCallback(() => { + if (handleClear) { + handleClear({emoji, text}); + } + }, []); + + const clearButton = handleClear ? + ( + + + + ) : null; + + return ( + + + + + + + + + + {clearButton} + {separator && } + + + + ); +}; + +export default CustomStatusSuggestion; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + backgroundColor: theme.centerChannelBg, + flexDirection: 'row', + minHeight: 50, + }, + iconContainer: { + width: 45, + height: 46, + left: 14, + top: 12, + marginRight: 6, + color: theme.centerChannelColor, + }, + wrapper: { + flex: 1, + }, + textContainer: { + paddingTop: 14, + paddingBottom: 14, + justifyContent: 'center', + width: '70%', + flex: 1, + }, + clearButtonContainer: { + position: 'absolute', + top: 4, + right: 13, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + }, + }; +}); diff --git a/app/screens/custom_status/index.ts b/app/screens/custom_status/index.ts new file mode 100644 index 000000000..e5c0615a8 --- /dev/null +++ b/app/screens/custom_status/index.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {setCustomStatus, unsetCustomStatus, removeRecentCustomStatus} from '@actions/views/custom_status'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {GenericAction} from '@mm-redux/types/actions'; +import {GlobalState} from '@mm-redux/types/store'; +import CustomStatusModal from '@screens/custom_status/custom_status_modal'; +import {getRecentCustomStatuses, makeGetCustomStatus} from '@selectors/custom_status'; +import {isLandscape} from '@selectors/device'; + +function makeMapStateToProps() { + const getCustomStatus = makeGetCustomStatus(); + return (state: GlobalState) => { + const customStatus = getCustomStatus(state); + const recentCustomStatuses = getRecentCustomStatuses(state); + const theme = getTheme(state); + + return { + customStatus, + recentCustomStatuses, + theme, + isLandscape: isLandscape(state), + }; + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + setCustomStatus, + unsetCustomStatus, + removeRecentCustomStatus, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(CustomStatusModal); diff --git a/app/screens/index.js b/app/screens/index.js index 7215e3f63..cc42da356 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -70,6 +70,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case 'CreateChannel': screen = require('@screens/create_channel').default; break; + case 'CustomStatus': + screen = require('@screens/custom_status').default; + break; case 'DisplaySettings': screen = require('@screens/settings/display_settings').default; break; diff --git a/app/screens/user_profile/__snapshots__/user_profile.test.js.snap b/app/screens/user_profile/__snapshots__/user_profile.test.js.snap index 7292c535e..0dfa5e004 100644 --- a/app/screens/user_profile/__snapshots__/user_profile.test.js.snap +++ b/app/screens/user_profile/__snapshots__/user_profile.test.js.snap @@ -389,3 +389,480 @@ exports[`user_profile should match snapshot when user is from remote 1`] = ` `; + +exports[`user_profile should match snapshot with custom status 1`] = ` + + + + + + + @fred + + + + + + + Status + + + + + + + + In a meeting + + + + + + + Nickname + + + nick + + + + + + + +`; + +exports[`user_profile should match snapshot with custom status and isMyUser true 1`] = ` + + + + + + + @fred + + + + + + + Status + + + + + + + + In a meeting + + + + + + + + + + Nickname + + + nick + + + + + + + +`; diff --git a/app/screens/user_profile/index.js b/app/screens/user_profile/index.js index a8d24cc51..4d944df93 100644 --- a/app/screens/user_profile/index.js +++ b/app/screens/user_profile/index.js @@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {setChannelDisplayName} from '@actions/views/channel'; +import {unsetCustomStatus} from '@actions/views/custom_status'; import {makeDirectChannel} from '@actions/views/more_dms'; import {getConfig} from '@mm-redux/selectors/entities/general'; import {getTeammateNameDisplaySetting, getTheme, getBool} from '@mm-redux/selectors/entities/preferences'; @@ -14,28 +15,34 @@ import {loadBot} from '@mm-redux/actions/bots'; import {getRemoteClusterInfo} from '@mm-redux/actions/remote_cluster'; import {getBotAccounts} from '@mm-redux/selectors/entities/bots'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {makeGetCustomStatus, isCustomStatusEnabled} from '@selectors/custom_status'; import UserProfile from './user_profile'; -function mapStateToProps(state, ownProps) { - const config = getConfig(state); - const {createChannel: createChannelRequest} = state.requests.channels; - const isMilitaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'); - const enableTimezone = isTimezoneEnabled(state); - const user = state.entities.users.profiles[ownProps.userId]; +function makeMapStateToProps() { + const getCustomStatus = makeGetCustomStatus(); + return (state, ownProps) => { + const config = getConfig(state); + const {createChannel: createChannelRequest} = state.requests.channels; + const isMilitaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'); + const enableTimezone = isTimezoneEnabled(state); + const user = state.entities.users.profiles[ownProps.userId]; - return { - config, - createChannelRequest, - currentDisplayName: state.views.channel.displayName, - user, - bot: getBotAccounts(state)[ownProps.userId], - teammateNameDisplay: getTeammateNameDisplaySetting(state), - enableTimezone, - isMilitaryTime, - theme: getTheme(state), - isMyUser: getCurrentUserId(state) === ownProps.userId, - remoteClusterInfo: state.entities.remoteCluster.info[user?.remote_id], + const customStatus = isCustomStatusEnabled(state) ? getCustomStatus(state, user?.id) : undefined; + return { + config, + createChannelRequest, + currentDisplayName: state.views.channel.displayName, + user, + bot: getBotAccounts(state)[ownProps.userId], + teammateNameDisplay: getTeammateNameDisplaySetting(state), + enableTimezone, + isMilitaryTime, + theme: getTheme(state), + isMyUser: getCurrentUserId(state) === ownProps.userId, + remoteClusterInfo: state.entities.remoteCluster.info[user?.remote_id], + customStatus, + }; }; } @@ -46,8 +53,9 @@ function mapDispatchToProps(dispatch) { setChannelDisplayName, loadBot, getRemoteClusterInfo, + unsetCustomStatus, }, dispatch), }; } -export default connect(mapStateToProps, mapDispatchToProps)(UserProfile); +export default connect(makeMapStateToProps, mapDispatchToProps)(UserProfile); diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js index 08c330495..4f31f7920 100644 --- a/app/screens/user_profile/user_profile.js +++ b/app/screens/user_profile/user_profile.js @@ -20,6 +20,8 @@ import { dismissAllModalsAndPopToRoot, } from '@actions/navigation'; import Config from '@assets/config'; +import Emoji from '@components/emoji'; +import ClearButton from '@components/custom_status/clear_button'; import ChannelIcon from '@components/channel_icon'; import FormattedTime from '@components/formatted_time'; import ProfilePicture from '@components/profile_picture'; @@ -44,6 +46,7 @@ export default class UserProfile extends PureComponent { setChannelDisplayName: PropTypes.func.isRequired, loadBot: PropTypes.func.isRequired, getRemoteClusterInfo: PropTypes.func.isRequired, + unsetCustomStatus: PropTypes.func.isRequired, }).isRequired, componentId: PropTypes.string, config: PropTypes.object.isRequired, @@ -56,6 +59,7 @@ export default class UserProfile extends PureComponent { enableTimezone: PropTypes.bool.isRequired, isMyUser: PropTypes.bool.isRequired, remoteClusterInfo: PropTypes.object, + customStatus: PropTypes.object, }; static contextTypes = { @@ -226,6 +230,50 @@ export default class UserProfile extends PureComponent { ); } + buildCustomStatusBlock = () => { + const {formatMessage} = this.context.intl; + const {customStatus, theme, isMyUser} = this.props; + const style = createStyleSheet(theme); + const isStatusSet = customStatus?.emoji; + + if (!isStatusSet) { + return null; + } + + const label = formatMessage({id: 'user.settings.general.status', defaultMessage: 'Status'}); + return ( + + {label} + + + + + + + {customStatus.text} + + + {isMyUser && ( + + + + )} + + + ); + } + buildTimezoneBlock = () => { const {theme, user, isMilitaryTime} = this.props; const style = createStyleSheet(theme); @@ -376,6 +424,7 @@ export default class UserProfile extends PureComponent { {this.props.config.ShowFullName === 'true' && this.buildDisplayBlock('first_name')} {this.props.config.ShowFullName === 'true' && this.buildDisplayBlock('last_name')} {this.props.config.ShowEmailAddress === 'true' && this.buildDisplayBlock('email')} + {this.props.config.EnableCustomUserStatuses === 'true' && this.buildCustomStatusBlock()} {this.buildDisplayBlock('nickname')} {this.buildOrganizationBlock()} {this.buildDisplayBlock('position')} @@ -444,6 +493,22 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => { container: { flex: 1, }, + iconContainer: { + marginRight: 5, + color: theme.centerChannelColor, + }, + customStatus: { + flexDirection: 'row', + }, + customStatusTextContainer: { + justifyContent: 'center', + width: '80%', + }, + clearButton: { + position: 'absolute', + top: -8, + right: 0, + }, content: { marginBottom: 25, marginHorizontal: 15, @@ -498,4 +563,3 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => { }, }; }); - diff --git a/app/screens/user_profile/user_profile.test.js b/app/screens/user_profile/user_profile.test.js index d47c6324f..ac6d4ead3 100644 --- a/app/screens/user_profile/user_profile.test.js +++ b/app/screens/user_profile/user_profile.test.js @@ -23,6 +23,7 @@ describe('user_profile', () => { makeDirectChannel: jest.fn(), loadBot: jest.fn(), getRemoteClusterInfo: jest.fn(), + unsetCustomStatus: jest.fn(), }; const baseProps = { actions, @@ -48,6 +49,20 @@ describe('user_profile', () => { is_bot: false, }; + const customStatus = { + emoji: 'calendar', + text: 'In a meeting', + }; + + const customStatusProps = { + ...baseProps, + customStatus, + config: { + EnableCustomUserStatuses: 'true', + }, + user, + }; + test('should match snapshot', () => { const wrapper = shallowWithIntl( { expect(wrapper.getElement()).toMatchSnapshot(); }); + test('should match snapshot with custom status', () => { + const wrapper = shallowWithIntl( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot with custom status and isMyUser true', () => { + const wrapper = shallowWithIntl( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + test('should contain bot tag', () => { const botUser = { email: 'test@test.com', diff --git a/app/screens/user_profile/user_profile_row.js b/app/screens/user_profile/user_profile_row.js index 85f4fdda9..cf3dd2a77 100644 --- a/app/screens/user_profile/user_profile_row.js +++ b/app/screens/user_profile/user_profile_row.js @@ -48,9 +48,12 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => { }; }); -function createTouchableComponent(children, action) { +function createTouchableComponent(children, action, testID) { return ( - + {children} ); @@ -100,7 +103,7 @@ function userProfileRow(props) { return RowComponent; } - return createTouchableComponent(RowComponent, action); + return createTouchableComponent(RowComponent, action, testID); } userProfileRow.propTypes = { @@ -118,6 +121,7 @@ userProfileRow.propTypes = { togglable: PropTypes.bool, textColor: PropTypes.string, theme: PropTypes.object.isRequired, + testID: PropTypes.string, }; userProfileRow.defaultProps = { @@ -125,6 +129,7 @@ userProfileRow.defaultProps = { iconSize: 15, textColor: '#000', togglable: false, + testID: 'user_profile.row', }; export default userProfileRow; diff --git a/app/selectors/custom_status.ts b/app/selectors/custom_status.ts new file mode 100644 index 000000000..af7011a64 --- /dev/null +++ b/app/selectors/custom_status.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createSelector} from 'reselect'; + +import {Preferences} from '@mm-redux/constants'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {get} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUser, getUser} from '@mm-redux/selectors/entities/users'; +import {UserCustomStatus} from '@mm-redux/types/users'; +import {GlobalState} from '@mm-redux/types/store'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; + +export function makeGetCustomStatus(): (state: GlobalState, userID?: string) => UserCustomStatus { + return createSelector( + (state: GlobalState, userID?: string) => (userID ? getUser(state, userID) : getCurrentUser(state)), + (user) => { + const userProps = user?.props || {}; + if (userProps.customStatus) { + return JSON.parse(userProps.customStatus) || undefined; + } + + return undefined; + }, + ); +} + +export const getRecentCustomStatuses = createSelector( + (state: GlobalState) => get(state, Preferences.CATEGORY_CUSTOM_STATUS, Preferences.NAME_RECENT_CUSTOM_STATUSES), + (value) => { + try { + return value ? JSON.parse(value) : []; + } catch (error) { + return []; + } + }, +); + +export function isCustomStatusEnabled(state: GlobalState) { + const config = getConfig(state); + const serverVersion = state.entities.general.serverVersion; + return config && config.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(serverVersion, 5, 36); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2bf9017a7..b25851aeb 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -125,6 +125,15 @@ "create_comment.addComment": "Add a comment...", "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", "create_post.write": "Write to {channelDisplayName}", + "custom_status.failure_message": "Failed to update status. Try again", + "custom_status.set_status": "Set a status", + "custom_status.suggestions.in_a_meeting": "In a meeting", + "custom_status.suggestions.on_a_vacation": "On a vacation", + "custom_status.suggestions.out_for_lunch": "Out for lunch", + "custom_status.suggestions.out_sick": "Out sick", + "custom_status.suggestions.recent_title": "RECENT", + "custom_status.suggestions.title": "SUGGESTIONS", + "custom_status.suggestions.working_from_home": "Working from home", "date_separator.today": "Today", "date_separator.yesterday": "Yesterday", "edit_post.editPost": "Edit the post...", @@ -252,6 +261,8 @@ "mobile.create_channel.public": "New Public Channel", "mobile.create_post.read_only": "This channel is read-only", "mobile.custom_list.no_results": "No Results", + "mobile.custom_status.choose_emoji": "Choose an emoji", + "mobile.custom_status.modal_confirm": "Done", "mobile.display_settings.sidebar": "Sidebar", "mobile.display_settings.theme": "Theme", "mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n", @@ -470,6 +481,7 @@ "mobile.routes.channelInfo.unarchive_channel": "Unarchive Channel", "mobile.routes.code": "{language} Code", "mobile.routes.code.noLanguage": "Code", + "mobile.routes.custom_status": "Set a Status", "mobile.routes.edit_profile": "Edit Profile", "mobile.routes.login": "Login", "mobile.routes.loginOptions": "Login Chooser", @@ -665,6 +677,7 @@ "user.settings.general.lastName": "Last Name", "user.settings.general.nickname": "Nickname", "user.settings.general.position": "Position", + "user.settings.general.status": "Status", "user.settings.general.username": "Username", "user.settings.modal.display": "Display", "user.settings.modal.notifications": "Notifications", diff --git a/detox/e2e/support/server_api/default_config.json b/detox/e2e/support/server_api/default_config.json index 906801a33..8c2e3f70a 100644 --- a/detox/e2e/support/server_api/default_config.json +++ b/detox/e2e/support/server_api/default_config.json @@ -90,6 +90,7 @@ "EnableUserCreation": true, "EnableOpenServer": true, "EnableUserDeactivation": false, + "EnableCustomUserStatuses": false, "RestrictCreationToDomains": "", "EnableCustomBrand": false, "CustomBrandText": "", diff --git a/detox/e2e/support/ui/component/settings_sidebar.js b/detox/e2e/support/ui/component/settings_sidebar.js index f618c982a..87d1f4e3b 100644 --- a/detox/e2e/support/ui/component/settings_sidebar.js +++ b/detox/e2e/support/ui/component/settings_sidebar.js @@ -15,6 +15,8 @@ class SettingsSidebar { editProfileAction: 'settings.sidebar.edit_profile.action', settingsAction: 'settings.sidebar.settings.action', logoutAction: 'settings.sidebar.logout.action', + customStatusAction: 'settings.sidebar.custom_status.action', + customStatusClearButton: 'settings.sidebar.custom_status.action.clear', } settingsSidebar = element(by.id(this.testID.settingsSidebar)); @@ -25,6 +27,8 @@ class SettingsSidebar { editProfileAction = element(by.id(this.testID.editProfileAction)); settingsAction = element(by.id(this.testID.settingsAction)); logoutAction = element(by.id(this.testID.logoutAction)); + customStatusAction = element(by.id(this.testID.customStatusAction)); + customStatusClearButton = element(by.id(this.testID.customStatusClearButton)); getUserStatus(userStatus) { const userStatusIconTestID = `${this.testID.userStatusIconPrefix}${userStatus}`; @@ -79,6 +83,11 @@ class SettingsSidebar { await this.logoutAction.tap(); await expect(this.settingsSidebar).not.toBeVisible(); } + + tapCustomStatusAction = async () => { + await this.customStatusAction.tap(); + await expect(this.settingsSidebar).not.toBeVisible(); + } } const settingsSidebar = new SettingsSidebar(); diff --git a/detox/e2e/support/ui/screen/channel_info.js b/detox/e2e/support/ui/screen/channel_info.js index 052630086..47943d501 100644 --- a/detox/e2e/support/ui/screen/channel_info.js +++ b/detox/e2e/support/ui/screen/channel_info.js @@ -14,6 +14,7 @@ class ChannelInfoScreen { channelDisplayName: 'channel_info.header.display_name', channelHeader: 'channel_info.header.header', channelPurpose: 'channel_info.header.purpose', + headerCustomStatus: 'channel_info.header.custom_status', favoritePreferenceAction: 'channel_info.favorite.action', favoriteSwitchFalse: 'channel_info.favorite.action.switch.false', favoriteSwitchTrue: 'channel_info.favorite.action.switch.true', diff --git a/detox/e2e/support/ui/screen/custom_status.js b/detox/e2e/support/ui/screen/custom_status.js new file mode 100644 index 000000000..061e2879d --- /dev/null +++ b/detox/e2e/support/ui/screen/custom_status.js @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SettingsSidebar} from '@support/ui/component'; + +class CustomStatusScreen { + testID = { + customStatusScreen: 'custom_status.screen', + input: 'custom_status.input', + selectedEmojiPrefix: 'custom_status.emoji.', + inputClearButton: 'custom_status.input.clear.button', + doneButton: 'custom_status.done.button', + suggestionPrefix: 'custom_status_suggestion.', + suggestionClearButton: 'custom_status_suggestion.clear.button', + } + + customStatusScreen = element(by.id(this.testID.customStatusScreen)); + input = element(by.id(this.testID.input)); + inputClearButton = element(by.id(this.testID.inputClearButton)) + doneButton = element(by.id(this.testID.doneButton)) + + getCustomStatusSelectedEmoji = (emoji) => { + const emojiTestID = `${this.testID.selectedEmojiPrefix}${emoji}`; + return element(by.id(emojiTestID)); + } + + getCustomStatusSuggestion = (text) => { + const suggestionID = `${this.testID.suggestionPrefix}${text}`; + return element(by.id(suggestionID)); + } + + getSuggestionClearButton = (text) => { + const suggestionID = `${this.testID.suggestionPrefix}${text}`; + return element(by.id(this.testID.suggestionClearButton).withAncestor(by.id(suggestionID))); + } + + toBeVisible = async () => { + await expect(this.customStatusScreen).toBeVisible(); + + return this.customStatusScreen; + } + + open = async () => { + // # Open custom status screen + await SettingsSidebar.tapCustomStatusAction(); + + return this.toBeVisible(); + } + + tapSuggestion = async ({emoji, text}) => { + await this.getCustomStatusSuggestion(text).tap(); + + await expect(this.input).toHaveText(text); + await expect(this.getCustomStatusSelectedEmoji(emoji)).toBeVisible(); + } + + close = async () => { + await this.doneButton.tap(); + return expect(this.customStatusScreen).not.toBeVisible(); + } +} + +const customStatusScreen = new CustomStatusScreen(); +export default customStatusScreen; diff --git a/detox/e2e/support/ui/screen/index.js b/detox/e2e/support/ui/screen/index.js index 3086d2324..946ec4bbe 100644 --- a/detox/e2e/support/ui/screen/index.js +++ b/detox/e2e/support/ui/screen/index.js @@ -11,6 +11,7 @@ import ChannelNotificationPreferenceScreen from './channel_notification_preferen import ChannelScreen from './channel'; import ClockDisplaySettingsScreen from './clock_display_settings'; import CreateChannelScreen from './create_channel'; +import CustomStatusScreen from './custom_status'; import DisplaySettingsScreen from './display_settings'; import EditChannelScreen from './edit_channel'; import EditPostScreen from './edit_post'; @@ -47,6 +48,7 @@ export { ChannelScreen, ClockDisplaySettingsScreen, CreateChannelScreen, + CustomStatusScreen, DisplaySettingsScreen, EditChannelScreen, EditPostScreen, diff --git a/detox/e2e/support/ui/screen/user_profile.js b/detox/e2e/support/ui/screen/user_profile.js index fe61071e1..20b251314 100644 --- a/detox/e2e/support/ui/screen/user_profile.js +++ b/detox/e2e/support/ui/screen/user_profile.js @@ -10,6 +10,8 @@ class UserProfileScreen { testID = { userProfilePicturePrefix: 'user_profile.profile_picture.', userProfileScreen: 'user_profile.screen', + customStatus: 'user_profile.custom_status', + profilePicture: 'user_profile.profile_picture', userProfileScrollView: 'user_profile.scroll_view', closeSettingsButton: 'close.settings.button', editButton: 'user_profile.edit.button', @@ -35,6 +37,8 @@ class UserProfileScreen { userProfileScreen = element(by.id(this.testID.userProfileScreen)); userProfileScrollView = element(by.id(this.testID.userProfileScrollView)); + customStatus = element(by.id(this.testID.customStatus)); + profilePicture = element(by.id(this.testID.profilePicture)) closeSettingsButton = element(by.id(this.testID.closeSettingsButton)); editButton = element(by.id(this.testID.editButton)); userProfileBotTag = element(by.id(this.testID.userProfileBotTag)); diff --git a/detox/e2e/test/custom_statuses/custom_status.e2e.js b/detox/e2e/test/custom_statuses/custom_status.e2e.js new file mode 100644 index 000000000..270ef3c1b --- /dev/null +++ b/detox/e2e/test/custom_statuses/custom_status.e2e.js @@ -0,0 +1,296 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {SettingsSidebar} from '@support/ui/component'; +import { + AddReactionScreen, + ChannelInfoScreen, + ChannelScreen, + CustomStatusScreen, + MoreDirectMessagesScreen, + ThreadScreen, + UserProfileScreen, +} from '@support/ui/screen'; +import { + Setup, + System, + Post, + Channel, +} from '@support/server_api'; + +describe('Custom status', () => { + const { + closeMainSidebar, + closeSettingsSidebar, + openMainSidebar, + openSettingsSidebar, + } = ChannelScreen; + const { + getCustomStatusSelectedEmoji, + getCustomStatusSuggestion, + getSuggestionClearButton, + tapSuggestion, + } = CustomStatusScreen; + const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation']; + const defaultStatus = { + emoji: 'calendar', + text: 'In a meeting', + }; + const customStatus = { + emoji: '😀', + emojiName: 'grinning', + text: 'Watering plants', + }; + let testChannel; + let testUser; + + beforeAll(async () => { + await System.apiUpdateConfig({TeamSettings: {EnableCustomUserStatuses: true}}); + }); + + beforeEach(async () => { + const {team, user} = await Setup.apiInit(); + const {channel} = await Channel.apiGetChannelByName(team.id, 'town-square'); + testChannel = channel; + testUser = user; + + // # Open channel screen + await ChannelScreen.open(user); + }); + + afterEach(async () => { + await ChannelScreen.logout(); + }); + + it('MM-T3890 Setting a custom status', async () => { + // # Open custom status screen + await openSettingsSidebar(); + await CustomStatusScreen.open(); + + // * Check if all the default suggestions are visible + const isSuggestionPresentPromiseArray = []; + defaultCustomStatuses.map(async (text) => { + isSuggestionPresentPromiseArray.push(expect(getCustomStatusSuggestion(text)).toBeVisible()); + }); + await Promise.all(isSuggestionPresentPromiseArray); + + // * Tap a suggestion and check if it is selected + await tapSuggestion(defaultStatus); + + // * Tap again and check if it is selected again + await tapSuggestion(defaultStatus); + + // # Tap on Done button and check if the modal closes + await CustomStatusScreen.close(); + + // * Check if the selected emoji and text are visible in the sidebar + await openSettingsSidebar(); + await expect(element(by.text(defaultStatus.text))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`))).toBeVisible(); + + // # Click on the Set a custom status option and check if the modal opens + await CustomStatusScreen.open(); + await CustomStatusScreen.close(); + + // # Close settings sidebar + await closeSettingsSidebar(); + }); + + it('MM-T3891 Setting your own custom status', async () => { + // # Open custom status modal and close it after setting the status + await openCustomStatusModalAndSetStatus(customStatus); + await openSettingsSidebar(); + + // * Check if the selected emoji and text are visible in the sideba + await expect(element(by.text(customStatus.text).withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`))).toBeVisible(); + + // # Tap on the clear custom status button in the sidebar + await SettingsSidebar.customStatusClearButton.tap(); + + // * Check if the custom status is cleared and open the modal + await expect(element(by.text('Set a Status').withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await CustomStatusScreen.open(); + + // * Check if the previously set status is present in the Recents section + await expect(element(by.text(customStatus.text).withAncestor(by.id('custom_status.recents')))).toBeVisible(); + + // # Tap on the status in the recents section and close the modal by clicking Done button + await element(by.text(customStatus.text).withAncestor(by.id('custom_status.recents'))).tap(); + await CustomStatusScreen.close(); + + // * Check if the status is set and open the modal + await openSettingsSidebar(); + await expect(element(by.text(customStatus.text).withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`))).toBeVisible(); + await CustomStatusScreen.open(); + + // # Tap on the input clear button in the custom status modal + await CustomStatusScreen.inputClearButton.tap(); + + // * Check if the custom status input and emoji have cleared or not and close the modal + await expect(CustomStatusScreen.input).toHaveText(''); + await expect(getCustomStatusSelectedEmoji('default')).toBeVisible(); + await CustomStatusScreen.close(); + + // * Check if the custom status is cleared + await openSettingsSidebar(); + await expect(element(by.text('Set a Status').withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await expect(element(by.id('custom_status.emoji.default'))).toBeVisible(); + + // # Close settings sidebar + await closeSettingsSidebar(); + }); + + it('MM-T3892 Recent statuses', async () => { + // # Open custom status modal and close it after setting the status + await openCustomStatusModalAndSetStatus(customStatus); + await openSettingsSidebar(); + + // * Check if the status is set in the sidebar + await expect(element(by.text(customStatus.text).withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`))).toBeVisible(); + + // # Tap on the clear custom status button in the sidebar + await SettingsSidebar.customStatusClearButton.tap(); + + // * Check if the custom status is cleared and open the modal + await expect(element(by.text('Set a Status').withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await CustomStatusScreen.open(); + + // * Check if the previously set status is present in the Recents section + await expect(element(by.text(customStatus.text).withAncestor(by.id('custom_status.recents')))).toBeVisible(); + + // # Tap on the clear button corresponding to the suggestion containing the previously set status + await getSuggestionClearButton(customStatus.text).tap(); + + // * Check if the suggestion is removed or not + await expect(getCustomStatusSuggestion(customStatus.text)).not.toExist(); + + // # Choose a status from the suggestions and set the status + await tapSuggestion(defaultStatus); + await CustomStatusScreen.close(); + + // * Check if the status is set in the sidebar + await openSettingsSidebar(); + await expect(element(by.text(defaultStatus.text))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`))).toBeVisible(); + + // # Tap on the clear custom status button in the sidebar + await SettingsSidebar.customStatusClearButton.tap(); + + // * Check if the custom status is cleared and open the modal + await expect(element(by.text('Set a Status').withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await CustomStatusScreen.open(); + + // * The last set status should be present in Recents list and not in the Suggestions list + await expect(element(by.text(defaultStatus.text).withAncestor(by.id('custom_status.recents')))).toBeVisible(); + await expect(element(by.text(defaultStatus.text).withAncestor(by.id('custom_status.suggestions')))).not.toExist(); + + // # Tap on the clear button of the suggestion containing the last set status + await getSuggestionClearButton(defaultStatus.text).tap(); + + // * The status should be moved from the Recents list to the Suggestions list + await expect(element(by.text(defaultStatus.text).withAncestor(by.id('custom_status.recents')))).not.toExist(); + await expect(element(by.text(defaultStatus.text).withAncestor(by.id('custom_status.suggestions')))).toBeVisible(); + await CustomStatusScreen.close(); + + // # Close settings sidebar + await closeSettingsSidebar(); + }); + + it('MM-T3893 Verifying where the custom status appears', async () => { + const message = 'Hello'; + + // # Open custom status modal and close it after setting the status + await openCustomStatusModalAndSetStatus(customStatus); + await openSettingsSidebar(); + + // * Check if the status is set in the sidebar and close the sidebar + await expect(element(by.text(customStatus.text).withAncestor(by.id(SettingsSidebar.testID.customStatusAction)))).toBeVisible(); + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`))).toBeVisible(); + await ChannelScreen.closeSettingsSidebar(); + + // # Post a message and check if custom status emoji is present in the post header + await ChannelScreen.postMessage(message); + await expect(element(by.id(`custom_status_emoji.${customStatus.emojiName}`).withAncestor(by.id('post_header')))).toBeVisible(); + + // # Open the reply thread for the last post + const {post} = await Post.apiGetLastPostInChannel(testChannel.id); + await ChannelScreen.openReplyThreadFor(post.id, message); + + // * Check if the custom status emoji is present in the post header and close thread + await expect(element(by.id(`custom_status_emoji.${customStatus.emojiName}`).withAncestor(by.id('post_header')))).toBeVisible(); + await ThreadScreen.back(); + + // # Open user profile screen + await openSettingsSidebar(); + await UserProfileScreen.open(); + await UserProfileScreen.toBeVisible(); + + // * Check if custom status is present in the user profile screen and close it + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`)).atIndex(0)).toExist(); + await expect(element(by.text(customStatus.text).withAncestor(by.id(UserProfileScreen.testID.customStatus)))).toBeVisible(); + await UserProfileScreen.close(); + + // # Open the main sidebar and click on more direct messages button + await ChannelScreen.openMainSidebar(); + await MoreDirectMessagesScreen.open(); + + // # Type the logged in user's username and tap it to open the DM + await MoreDirectMessagesScreen.searchInput.typeText(testUser.username); + await MoreDirectMessagesScreen.getUserAtIndex(0).tap(); + + // * Check if the custom status emoji is present in the channel title + await expect(element(by.id(`custom_status_emoji.${customStatus.emojiName}`).withAncestor(by.id('channel.title.button')))).toBeVisible(); + + // # Open the channel info screen + await ChannelInfoScreen.open(); + + // * Check if the custom status is present in the channel info screen and then close the screen + await expect(element(by.id(`custom_status.emoji.${customStatus.emojiName}`)).atIndex(0)).toExist(); + await expect(element(by.text(customStatus.text).withAncestor(by.id(ChannelInfoScreen.testID.headerCustomStatus)))).toBeVisible(); + await ChannelInfoScreen.close(); + + // # Open main sidebar and check if custom status emoji is present next to the username in the Direct messages section + await openMainSidebar(); + expect(element(by.id(`${testUser.username}.custom_status_emoji.${customStatus.emojiName}`))).toBeVisible(); + + // # Close main sidebar + await closeMainSidebar(); + }); +}); + +async function openCustomStatusModalAndSetStatus(customStatus) { + const {addReactionScreen} = AddReactionScreen; + const {getCustomStatusSelectedEmoji} = CustomStatusScreen; + + // # Open settings sidebar + await ChannelScreen.openSettingsSidebar(); + + // # Click on the Set a custom status option and check if the modal opens + await CustomStatusScreen.open(); + + // # Type the custom status text in the custom status input + await CustomStatusScreen.input.typeText(customStatus.text); + + // * Check if the speech balloon emoji appears when some text is typed in the input + await expect(getCustomStatusSelectedEmoji('speech_balloon')).toBeVisible(); + + // # First tap to dismiss the keyboard and then tap again to open the emoji picker + await getCustomStatusSelectedEmoji('speech_balloon').multiTap(2); + + // * Check if the Add reaction screen appears + await expect(addReactionScreen).toBeVisible(); + + // * Check if the emoji to be selected is present in the emojipicker and select it + await expect(element(by.text(customStatus.emoji))).toBeVisible(); + await element(by.text(customStatus.emoji)).tap(); + + // * Check if the Add reaction screen disappears + await expect(addReactionScreen).not.toBeVisible(); + + // * Check if the selected emoji is visible and close the modal by clicking on Done button + await expect(getCustomStatusSelectedEmoji(customStatus.emojiName)).toBeVisible(); + await CustomStatusScreen.close(); +}