MM-34586 Custom status feature (#5220) (#5420)

(cherry picked from commit e442275c6f)

Co-authored-by: Chetanya Kandhari <chetanya.kandhari@brightscout.com>
This commit is contained in:
Mattermost Build 2021-06-04 15:02:35 +02:00 committed by GitHub
parent 0d83ac4c93
commit abc2f30ef3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 3896 additions and 140 deletions

View file

@ -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,
};

View file

@ -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<UserStatus[]>;
getStatus: (userId: string) => Promise<UserStatus>;
updateStatus: (status: UserStatus) => Promise<UserStatus>;
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;

View file

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/clear_button should match snapshot 1`] = `
<ForwardRef
onPress={[Function]}
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
},
Object {
"height": 40,
"width": 40,
},
]
}
>
<CompassIcon
name="close-circle"
size={20}
style={
Object {
"borderRadius": 1000,
"color": "rgba(61,60,64,0.52)",
}
}
/>
</ForwardRef>
`;

View file

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/custom_status_emoji should match snapshot 1`] = `
<Text
testID="custom_status_emoji.calendar"
>
<Text
style={
Array [
undefined,
Object {
"fontSize": 16,
},
]
}
>
📆
</Text>
</Text>
`;
exports[`components/custom_status/custom_status_emoji should match snapshot with props 1`] = `
<Text
testID="custom_status_emoji.calendar"
>
<Text
style={
Array [
undefined,
Object {
"fontSize": 34,
},
]
}
>
📆
</Text>
</Text>
`;

View file

@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/custom_status_text should match snapshot 1`] = `
<Text
style={
Array [
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
undefined,
]
}
>
In a meeting
</Text>
`;
exports[`components/custom_status/custom_status_text should match snapshot with empty text 1`] = `
<Text
style={
Array [
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
undefined,
]
}
>
</Text>
`;

View file

@ -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(
<ClearButton
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should call handlePress when press event is fired', () => {
const wrapper = shallow(
<ClearButton
{...baseProps}
/>,
);
wrapper.find(TouchableOpacity).simulate('press');
expect(baseProps.handlePress).toBeCalled();
});
});

View file

@ -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 (
<TouchableOpacity
onPress={preventDoubleTap(handlePress)}
style={[style.container, {height: containerSize, width: containerSize}]}
testID={testID}
>
<CompassIcon
name={iconName}
size={size}
style={style.button}
/>
</TouchableOpacity>
);
};
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),
},
};
});

View file

@ -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(
<CustomStatusEmoji/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should match snapshot with props', () => {
const wrapper = renderWithRedux(
<CustomStatusEmoji
emojiSize={34}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should not render when getCustomStatus returns null', () => {
(CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(() => null);
const wrapper = renderWithRedux(
<CustomStatusEmoji/>,
);
expect(wrapper.toJSON()).toBeNull();
});
});

View file

@ -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 (
<Text
style={style}
testID={`${testIdPrefix}custom_status_emoji.${customStatus.emoji}`}
>
<Emoji
size={emojiSize}
emojiName={customStatus.emoji}
/>
</Text>
);
};
CustomStatusEmoji.defaultProps = {
emojiSize: 16,
};
export default CustomStatusEmoji;

View file

@ -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(
<CustomStatusText
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should match snapshot with empty text', () => {
const wrapper = shallow(
<CustomStatusText
{...baseProps}
text={''}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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
style={[getStyleSheet(theme).label, textStyle]}
ellipsizeMode={ellipsizeMode}
numberOfLines={numberOfLines}
>
{text}
</Text>
);
export default CustomStatusText;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,
},
};
});

View file

@ -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 && (
<CustomStatusEmoji
userID={post.user_id}
style={style.customStatusEmoji}
/>
)}
{!isSystemPost &&
<HeaderTag
isAutoResponder={isAutoResponse}

View file

@ -9,6 +9,7 @@ import {getBool} from '@mm-redux/selectors/entities/preferences';
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
import {getUser, getCurrentUser} from '@mm-redux/selectors/entities/users';
import {getUserCurrentTimezone} from '@mm-redux/utils/timezone_utils';
import {isCustomStatusEnabled} from '@selectors/custom_status';
import {postUserDisplayName} from '@utils/post';
import {isGuest} from '@utils/users';
@ -40,6 +41,7 @@ function mapStateToProps() {
isGuest: isGuest(author),
isMilitaryTime: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'),
userTimezone: enableTimezone ? getUserCurrentTimezone(currentUser.timezone) : undefined,
isCustomStatusEnabled: isCustomStatusEnabled(state),
};
};
}

View file

@ -87,7 +87,6 @@ exports[`ChannelItem should match snapshot 1`] = `
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -98,6 +97,8 @@ exports[`ChannelItem should match snapshot 1`] = `
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
@ -208,7 +209,6 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -218,6 +218,7 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
},
Object {
"color": "#ffffff",
"opacity": 1,
},
]
}
@ -328,7 +329,6 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -338,6 +338,7 @@ exports[`ChannelItem should match snapshot for current user i.e currentUser (you
},
Object {
"color": "#ffffff",
"opacity": 1,
},
]
}
@ -448,7 +449,6 @@ exports[`ChannelItem should match snapshot for deactivated user and is currentCh
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -458,6 +458,7 @@ exports[`ChannelItem should match snapshot for deactivated user and is currentCh
},
Object {
"color": "#ffffff",
"opacity": 1,
},
]
}
@ -557,7 +558,6 @@ exports[`ChannelItem should match snapshot for deactivated user and is searchRes
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -568,6 +568,8 @@ exports[`ChannelItem should match snapshot for deactivated user and is searchRes
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
@ -667,7 +669,6 @@ exports[`ChannelItem should match snapshot for deactivated user and not searchRe
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -678,6 +679,8 @@ exports[`ChannelItem should match snapshot for deactivated user and not searchRe
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
@ -777,7 +780,6 @@ exports[`ChannelItem should match snapshot for isManualUnread 1`] = `
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -788,6 +790,8 @@ exports[`ChannelItem should match snapshot for isManualUnread 1`] = `
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
@ -804,6 +808,137 @@ exports[`ChannelItem should match snapshot for no displayName 1`] = `null`;
exports[`ChannelItem should match snapshot for showUnreadForMsgs 1`] = `null`;
exports[`ChannelItem should match snapshot with custom status emoji 1`] = `
<ForwardRef
onPress={[Function]}
underlayColor="rgba(69,120,191,0.5)"
>
<View
style={
Array [
Object {
"flex": 1,
"flexDirection": "row",
"height": 44,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item"
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"paddingLeft": 16,
},
undefined,
]
}
testID="main.sidebar.channels_list.list.channel_item.channel_id"
>
<ChannelIcon
channelId="channel_id"
hasDraft={false}
isActive={false}
isArchived={false}
isInfo={false}
isUnread={true}
membersCount={1}
size={24}
statusStyle={
Object {
"backgroundColor": "#145dbf",
"borderColor": "transparent",
}
}
testID="main.sidebar.channels_list.list.channel_item.channel_icon"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
type="O"
userId="currentUser"
/>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
"marginLeft": 13,
"maxWidth": "80%",
"paddingRight": 10,
},
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
testID="main.sidebar.channels_list.list.channel_item.display_name"
>
display_name
</Text>
<CustomStatusEmoji
emojiSize={16}
style={
Array [
Object {
"color": "rgba(255,255,255,0.6)",
"opacity": 0.6,
},
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
testID="display_name"
userID="currentUser"
/>
</View>
</View>
</ForwardRef>
`;
exports[`ChannelItem should match snapshot with draft 1`] = `
<ForwardRef
onPress={[Function]}
@ -891,7 +1026,6 @@ exports[`ChannelItem should match snapshot with draft 1`] = `
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -902,6 +1036,8 @@ exports[`ChannelItem should match snapshot with draft 1`] = `
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}
@ -1003,7 +1139,6 @@ exports[`ChannelItem should match snapshot with mentions and muted 1`] = `
Object {
"alignSelf": "center",
"color": "rgba(255,255,255,0.6)",
"flex": 1,
"fontFamily": "Open Sans",
"fontSize": 16,
"lineHeight": 24,
@ -1014,6 +1149,8 @@ exports[`ChannelItem should match snapshot with mentions and muted 1`] = `
Object {
"color": "#ffffff",
"fontWeight": "500",
"maxWidth": "70%",
"opacity": 1,
},
]
}

View file

@ -7,11 +7,13 @@ import {
TouchableHighlight,
Text,
View,
Platform,
} from 'react-native';
import {intlShape} from 'react-intl';
import Badge from '@components/badge';
import ChannelIcon from '@components/channel_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import {General} from '@mm-redux/constants';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@ -37,6 +39,7 @@ export default class ChannelItem extends PureComponent {
theme: PropTypes.object.isRequired,
unreadMsgs: PropTypes.number.isRequired,
isSearchResult: PropTypes.bool,
customStatusEnabled: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -175,6 +178,15 @@ export default class ChannelItem extends PureComponent {
const itemTestID = `${testID}.${channelId}`;
const displayNameTestID = `${testID}.display_name`;
const customStatus = this.props.teammateId && this.props.customStatusEnabled ?
(
<CustomStatusEmoji
userID={this.props.teammateId}
style={[style.emoji, extraTextStyle]}
testID={displayName}
/>
) : null;
return (
<TouchableHighlight
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
@ -198,6 +210,7 @@ export default class ChannelItem extends PureComponent {
>
{channelDisplayName}
</Text>
{customStatus}
{badge}
</View>
</View>
@ -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,

View file

@ -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(
<ChannelItem
{...baseProps}
teammateId={baseProps.currentUserId}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('Should call onPress', () => {
const onSelectChannel = jest.fn();

View file

@ -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),
};
};
}

View file

@ -8,10 +8,9 @@ exports[`DrawerItem should match snapshot 1`] = `
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"flexDirection": "row",
"height": 50,
"minHeight": 50,
}
}
>
@ -51,9 +50,10 @@ exports[`DrawerItem should match snapshot 1`] = `
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
}
}
>
@ -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`] = `
</View>
</ForwardRef>
`;
exports[`DrawerItem should match snapshot without separator and centered false 1`] = `
<ForwardRef
onPress={[MockFunction]}
testID="test-id"
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
}
>
<View
style={
Object {
"alignItems": "center",
"height": 50,
"justifyContent": "center",
"marginLeft": 5,
"width": 45,
}
}
>
<CompassIcon
name="icon-name"
style={
Array [
Object {
"color": "rgba(61,60,64,0.64)",
"fontSize": 24,
},
Object {
"color": "#fd5960",
},
]
}
/>
</View>
<View
style={
Object {
"flex": 1,
}
}
>
<View
style={
Object {
"flex": 1,
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="default message"
id="i18-id"
style={
Array [
Object {
"color": "rgba(61,60,64,0.5)",
"fontSize": 17,
"includeFontPadding": false,
"textAlignVertical": "center",
},
Object {
"color": "#fd5960",
},
Object {},
]
}
/>
</View>
</View>
</View>
</ForwardRef>
`;

View file

@ -15,3 +15,19 @@ exports[`SettingsSidebar should match snapshot 1`] = `
useNativeAnimations={true}
/>
`;
exports[`SettingsSidebar should match snapshot with custom status enabled 1`] = `
<DrawerLayoutAdapter
drawerPosition="right"
drawerWidth={710}
forwardRef={
Object {
"current": null,
}
}
onDrawerClose={[Function]}
onDrawerOpen={[Function]}
renderNavigationView={[Function]}
useNativeAnimations={true}
/>
`;

View file

@ -87,11 +87,11 @@ export default class DrawerItem extends PureComponent {
onPress={onPress}
>
<View style={style.container}>
{icon &&
<View style={style.iconContainer}>
{icon}
</View>
}
{icon && (
<View style={style.iconContainer}>
{icon}
</View>
)}
<View style={style.wrapper}>
<View style={style.labelContainer}>
{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,

View file

@ -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(
<DrawerItem {...props}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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(
<SettingsSidebar
{...baseProps}
isCustomStatusEnabled={true}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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 = (
<View
testID={`custom_status.emoji.${isStatusSet ? customStatus.emoji : 'default'}`}
>
{isStatusSet ? (
<Emoji
emojiName={customStatus.emoji}
size={20}
/>
) : (
<CompassIcon
name='emoticon-happy-outline'
size={24}
style={style.customStatusIcon}
/>
)}
</View>
);
const clearButton = isStatusSet ?
(
<ClearButton
handlePress={this.clearCustomStatus}
theme={theme}
testID='settings.sidebar.custom_status.action.clear'
/>
) : null;
const retryMessage = showRetryMessage ?
(
<FormattedText
id='custom_status.failure_message'
defaultMessage='Failed to update status. Try again'
style={style.retryMessage}
/>
) : null;
const text = isStatusSet ? customStatus.text : (
<FormattedText
id='mobile.routes.custom_status'
defaultMessage='Set a Status'
/>
);
const labelComponent = (
<>
<View
style={style.customStatusTextContainer}
>
<CustomStatusText
text={text}
theme={theme}
/>
</View>
{retryMessage}
{clearButton &&
<View
style={style.clearButton}
>
{clearButton}
</View>
}
</>
);
return (
<DrawerItem
testID='settings.sidebar.custom_status.action'
labelComponent={labelComponent}
leftComponent={customStatusEmoji}
separator={false}
onPress={this.goToCustomStatus}
theme={theme}
/>
);
};
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()}
</View>
<View style={style.separator}/>
<View style={style.block}>
@ -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,
},
};
});

View file

@ -61,7 +61,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
label: {
color: changeOpacity(theme.centerChannelColor, 0.5),
flex: 1,
fontSize: 17,
textAlignVertical: 'center',
includeFontPadding: false,

View file

@ -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';

View file

@ -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,
};

View file

@ -23,6 +23,11 @@ const Preferences: Dictionary<any> = {
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,

View file

@ -50,6 +50,7 @@ export type Config = {
EnableCustomBrand: string;
EnableCustomEmoji: string;
EnableCustomTermsOfService: string;
EnableCustomUserStatuses: string;
EnableDeveloper: string;
EnableDiagnostics: string;
EnableEmailBatching: string;

View file

@ -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';

View file

@ -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<string>;
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;
}

View file

@ -11,14 +11,19 @@ exports[`ChannelTitle should match snapshot 1`] = `
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
}
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "90%",
},
]
}
>
<Text
@ -63,14 +68,19 @@ exports[`ChannelTitle should match snapshot when is DM and has guests and the te
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
}
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "80%",
},
]
}
>
<Text
@ -100,6 +110,20 @@ exports[`ChannelTitle should match snapshot when is DM and has guests and the te
}
}
/>
<CustomStatusEmoji
emojiSize={16}
style={
Array [
Object {
"color": undefined,
"marginHorizontal": 1,
},
Object {
"marginHorizontal": 5,
},
]
}
/>
</View>
<View
style={
@ -140,14 +164,19 @@ exports[`ChannelTitle should match snapshot when is DM and has guests but the te
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
}
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "80%",
},
]
}
>
<Text
@ -177,6 +206,20 @@ exports[`ChannelTitle should match snapshot when is DM and has guests but the te
}
}
/>
<CustomStatusEmoji
emojiSize={16}
style={
Array [
Object {
"color": undefined,
"marginHorizontal": 1,
},
Object {
"marginHorizontal": 5,
},
]
}
/>
</View>
</ForwardRef>
`;
@ -192,14 +235,19 @@ exports[`ChannelTitle should match snapshot when isChannelShared is true 1`] = `
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
}
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "90%",
},
]
}
>
<Text
@ -263,14 +311,19 @@ exports[`ChannelTitle should match snapshot when isSelfDMChannel is true 1`] = `
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
}
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "90%",
},
]
}
>
<Text
@ -311,3 +364,74 @@ exports[`ChannelTitle should match snapshot when isSelfDMChannel is true 1`] = `
</View>
</ForwardRef>
`;
exports[`ChannelTitle should match snapshot with custom status emoji 1`] = `
<ForwardRef
style={
Object {
"flex": 1,
}
}
testID="channel.title.button"
>
<View
style={
Array [
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "flex-start",
"position": "relative",
"top": -1,
},
Object {
"width": "80%",
},
]
}
>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": undefined,
"flex": 0,
"flexShrink": 1,
"fontSize": 18,
"fontWeight": "bold",
"textAlign": "center",
}
}
testID="channel.nav_bar.title"
>
My Channel
</Text>
<CompassIcon
name="chevron-down"
size={24}
style={
Object {
"color": undefined,
"marginHorizontal": 1,
}
}
/>
<CustomStatusEmoji
emojiSize={16}
style={
Array [
Object {
"color": undefined,
"marginHorizontal": 1,
},
Object {
"marginHorizontal": 5,
},
]
}
/>
</View>
</ForwardRef>
`;

View file

@ -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 = (
<CompassIcon
@ -152,6 +156,20 @@ export default class ChannelTitle extends PureComponent {
name='bell-off-outline'
/>
);
wrapperWidth -= 10;
}
const customStatus = this.props.channelType === General.DM_CHANNEL && this.props.customStatusEnabled ?
(
<CustomStatusEmoji
userID={this.props.teammateId}
emojiSize={16}
style={[style.icon, style.emoji]}
/>
) : null;
if (customStatus) {
wrapperWidth -= 10;
}
let channelIcon;
@ -176,7 +194,7 @@ export default class ChannelTitle extends PureComponent {
style={style.container}
onPress={onPress}
>
<View style={style.wrapper}>
<View style={[style.wrapper, {width: `${wrapperWidth}%`}]}>
{this.archiveIcon(style)}
<Text
ellipsizeMode='tail'
@ -188,6 +206,7 @@ export default class ChannelTitle extends PureComponent {
</Text>
{channelIcon}
{icon}
{customStatus}
{mutedIcon}
</View>
{hasGuestsText}
@ -213,6 +232,9 @@ const getStyle = makeStyleSheetFromTheme((theme) => {
color: theme.sidebarHeaderTextColor,
marginHorizontal: 1,
},
emoji: {
marginHorizontal: 5,
},
text: {
color: theme.sidebarHeaderTextColor,
fontSize: 18,

View file

@ -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(
<ChannelTitle
{...baseProps}
channelType={General.DM_CHANNEL}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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),
};
}

View file

@ -33,6 +33,7 @@ exports[`channelInfo should match snapshot 1`] = `
hasGuests={false}
header=""
isArchived={false}
isCustomStatusEnabled={false}
isGroupConstrained={false}
isTeammateGuest={false}
memberCount={2}

View file

@ -1940,3 +1940,379 @@ exports[`channel_info_header should match snapshot when public channel and hasGu
</Text>
</View>
`;
exports[`channel_info_header should match snapshot with custom status enabled 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"borderBottomColor": undefined,
"borderBottomWidth": 1,
"marginBottom": 40,
"paddingVertical": 15,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"paddingVertical": 10,
},
Object {
"paddingHorizontal": 15,
},
]
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
testID="channel_info.header.channel_icon"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
type="D"
/>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
>
Channel name
</Text>
</View>
<View
style={
Array [
Object {
"paddingHorizontal": 15,
},
Object {
"alignItems": "center",
"flexDirection": "row",
"paddingVertical": 10,
"position": "relative",
},
]
}
testID="channel_info.header.custom_status"
>
<Connect(Emoji)
emojiName="calendar"
size={20}
testID="custom_status.emoji.calendar"
textStyle={
Object {
"color": "#3d3c40",
"marginRight": 8,
}
}
/>
<CustomStatusText
ellipsizeMode="tail"
numberOfLines={1}
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"width": "80%",
}
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
<View
style={
Object {
"marginTop": 15,
}
}
>
<ForwardRef
onLongPress={[Function]}
>
<View
style={
Object {
"paddingHorizontal": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Purpose"
id="channel_info.purpose"
style={
Object {
"backgroundColor": "transparent",
"color": "#3d3c40",
"fontSize": 13,
"marginBottom": 10,
}
}
/>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 13,
"lineHeight": 20,
}
}
testID="channel_info.header.purpose"
>
Purpose string
</Text>
</View>
</ForwardRef>
</View>
<View
style={
Object {
"marginTop": 15,
}
}
>
<ForwardRef
onLongPress={[Function]}
>
<View
style={
Object {
"paddingHorizontal": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Header"
id="channel_info.header"
style={
Object {
"backgroundColor": "transparent",
"color": "#3d3c40",
"fontSize": 13,
"marginBottom": 10,
}
}
/>
<Connect(Markdown)
baseTextStyle={
Object {
"color": "#3d3c40",
"fontSize": 13,
"lineHeight": 20,
}
}
blockStyles={
Object {
"adjacentParagraph": Object {
"marginTop": 6,
},
"horizontalRule": Object {
"backgroundColor": "#3d3c40",
"height": 0.5,
"marginVertical": 10,
},
"quoteBlockIcon": Object {
"color": undefined,
},
}
}
disableAtChannelMentionHighlight={true}
disableGallery={true}
onChannelLinkPress={[MockFunction]}
testID="channel_info.header.header"
textStyles={
Object {
"code": Object {
"alignSelf": "center",
"backgroundColor": undefined,
"fontFamily": "Menlo",
},
"codeBlock": Object {
"fontFamily": "Menlo",
},
"del": Object {
"textDecorationLine": "line-through",
},
"emph": Object {
"fontStyle": "italic",
},
"error": Object {
"color": "#fd5960",
},
"heading1": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading1Text": Object {
"paddingBottom": 8,
},
"heading2": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading2Text": Object {
"paddingBottom": 8,
},
"heading3": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading3Text": Object {
"paddingBottom": 8,
},
"heading4": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading4Text": Object {
"paddingBottom": 8,
},
"heading5": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading5Text": Object {
"paddingBottom": 8,
},
"heading6": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading6Text": Object {
"paddingBottom": 8,
},
"link": Object {
"color": "#2389d7",
},
"mention": Object {
"color": "#2389d7",
},
"mention_highlight": Object {
"backgroundColor": "#ffe577",
"color": "#166de0",
},
"strong": Object {
"fontWeight": "bold",
},
"table_header_row": Object {
"fontWeight": "700",
},
}
}
value="Header string"
/>
</View>
</ForwardRef>
</View>
<Text
style={
Array [
Object {
"backgroundColor": "transparent",
"color": undefined,
"flexDirection": "row",
"fontSize": 12,
"marginTop": 5,
},
Object {
"paddingHorizontal": 15,
},
]
}
>
<InjectIntl(FormattedText)
defaultMessage="Created by {creator} on "
id="mobile.routes.channelInfo.createdBy"
values={
Object {
"creator": "Creator",
}
}
/>
<FormattedDate
format="LL"
value={123}
/>
</Text>
</View>
`;

View file

@ -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}
/>
}
<View style={style.rowsContainer}>

View file

@ -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(),

View file

@ -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}
</Text>
</View>
{isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji &&
<View
style={[style.row, style.customStatusContainer]}
testID={`${testID}.custom_status`}
>
<Emoji
emojiName={customStatus.emoji}
size={20}
textStyle={style.iconContainer}
testID={`custom_status.emoji.${customStatus.emoji}`}
/>
<CustomStatusText
text={customStatus.text}
theme={theme}
textStyle={style.customStatusText}
ellipsizeMode='tail'
numberOfLines={1}
/>
</View>
}
{this.renderHasGuestText(style)}
{purpose.length > 0 &&
<View style={style.section}>
@ -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',

View file

@ -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(
<ChannelInfoHeader
{...baseProps}
isCustomStatusEnabled={true}
type={General.DM_CHANNEL}
customStatus={customStatus}
/>,
{context: {intl: intlMock}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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);

View file

@ -0,0 +1,235 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`screens/custom_status_modal should match snapshot 1`] = `
<CustomStatusModal
actions={
Object {
"removeRecentCustomStatus": [MockFunction],
"setCustomStatus": [MockFunction],
"unsetCustomStatus": [MockFunction],
}
}
customStatus={
Object {
"emoji": "calendar",
"text": "In a meeting",
}
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
recentCustomStatuses={
Array [
Object {
"emoji": "calendar",
"text": "In a meeting",
},
]
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
`;
exports[`screens/custom_status_modal should match snapshot when user has no custom status set 1`] = `
<CustomStatusModal
actions={
Object {
"removeRecentCustomStatus": [MockFunction],
"setCustomStatus": [MockFunction],
"unsetCustomStatus": [MockFunction],
}
}
customStatus={null}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
recentCustomStatuses={
Array [
Object {
"emoji": "calendar",
"text": "In a meeting",
},
]
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
`;
exports[`screens/custom_status_modal should match snapshot when user has no recent custom status 1`] = `
<CustomStatusModal
actions={
Object {
"removeRecentCustomStatus": [MockFunction],
"setCustomStatus": [MockFunction],
"unsetCustomStatus": [MockFunction],
}
}
customStatus={
Object {
"emoji": "calendar",
"text": "In a meeting",
}
}
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
recentCustomStatuses={Array []}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
`;

View file

@ -0,0 +1,239 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`screens/custom_status_suggestion should match snapshot 1`] = `
<ForwardRef
onPress={[Function]}
testID="custom_status_suggestion.In a meeting"
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"height": 46,
"left": 14,
"marginRight": 6,
"top": 12,
"width": 45,
}
}
>
<Connect(Emoji)
emojiName="calendar"
size={20}
/>
</Text>
<View
style={
Object {
"flex": 1,
}
}
>
<View
style={
Object {
"flex": 1,
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
"width": "70%",
}
}
>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
}
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
</View>
</View>
</ForwardRef>
`;
exports[`screens/custom_status_suggestion should match snapshot with separator and clear button 1`] = `
<ForwardRef
onPress={[Function]}
testID="custom_status_suggestion.In a meeting"
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"flexDirection": "row",
"minHeight": 50,
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"height": 46,
"left": 14,
"marginRight": 6,
"top": 12,
"width": 45,
}
}
>
<Connect(Emoji)
emojiName="calendar"
size={20}
/>
</Text>
<View
style={
Object {
"flex": 1,
}
}
>
<View
style={
Object {
"flex": 1,
"justifyContent": "center",
"paddingBottom": 14,
"paddingTop": 14,
"width": "70%",
}
}
>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
}
}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
<View
style={
Object {
"position": "absolute",
"right": 13,
"top": 4,
}
}
>
<ClearButton
containerSize={40}
handlePress={[Function]}
iconName="close"
size={18}
testID="custom_status_suggestion.clear.button"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
</View>
</View>
</ForwardRef>
`;

View file

@ -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(
<CustomStatusModal {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should match snapshot when user has no custom status set', () => {
const wrapper = shallowWithIntl(
<CustomStatusModal
{...baseProps}
customStatus={null}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should match snapshot when user has no recent custom status', () => {
const wrapper = shallowWithIntl(
<CustomStatusModal
{...baseProps}
recentCustomStatuses={[]}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -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<ActionResult>;
unsetCustomStatus: () => ActionFunc;
removeRecentCustomStatus: (customStatus: UserCustomStatus) => ActionFunc;
};
}
type State = {
emoji: string;
text: string;
}
class CustomStatusModal extends NavigationComponent<Props, State> {
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<string, StyleProp<ViewStyle>>) => {
const {recentCustomStatuses, theme} = this.props;
const recentStatuses = recentCustomStatuses.map((status: UserCustomStatus, index: number) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={this.handleSuggestionClick}
handleClear={this.handleRecentCustomStatusClear}
emoji={status.emoji}
text={status.text}
theme={theme}
separator={index !== recentCustomStatuses.length - 1}
/>
));
if (recentStatuses.length === 0) {
return null;
}
return (
<>
<View style={style.separator}/>
<View testID='custom_status.recents'>
<FormattedText
id='custom_status.suggestions.recent_title'
defaultMessage='RECENT'
style={style.title}
/>
<View style={style.block}>
{recentStatuses}
</View>
</View >
</>
);
};
renderCustomStatusSuggestions = (style: Record<string, StyleProp<ViewStyle>>) => {
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[]) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={this.handleSuggestionClick}
emoji={status.emoji}
text={status.text}
theme={theme}
separator={index !== arr.length - 1}
/>
));
if (customStatusSuggestions.length === 0) {
return null;
}
return (
<>
<View style={style.separator}/>
<View testID='custom_status.suggestions'>
<FormattedText
id='custom_status.suggestions.title'
defaultMessage='SUGGESTIONS'
style={style.title}
/>
<View style={style.block}>
{customStatusSuggestions}
</View>
</View>
</>
);
};
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 = (
<TouchableOpacity
testID={`custom_status.emoji.${isStatusSet ? (emoji || 'speech_balloon') : 'default'}`}
onPress={preventDoubleTap(this.openEmojiPicker)}
style={style.iconContainer}
>
{isStatusSet ? (
<Text style={style.emoji}>
<Emoji
emojiName={emoji || 'speech_balloon'}
size={20}
/>
</Text>
) : (
<CompassIcon
name='emoticon-happy-outline'
size={24}
style={style.icon}
/>
)}
</TouchableOpacity>
);
const customStatusInput = (
<View style={style.inputContainer}>
<TextInput
testID='custom_status.input'
autoCapitalize='none'
autoCorrect={false}
blurOnSubmit={false}
disableFullscreenUI={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
keyboardType='default'
maxLength={CustomStatus.CUSTOM_STATUS_TEXT_CHARACTER_LIMIT}
onChangeText={this.handleTextChange}
placeholder={this.props.intl.formatMessage({id: 'custom_status.set_status', defaultMessage: 'Set a Status'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
returnKeyType='go'
style={style.input}
secureTextEntry={false}
underlineColorAndroid='transparent'
value={text}
/>
{customStatusEmoji}
{isStatusSet ? (
<View
style={style.clearButton}
testID='custom_status.input.clear.button'
>
<ClearButton
handlePress={this.clearHandle}
theme={theme}
/>
</View>
) : null}
</View>
);
let keyboardOffset = DeviceTypes.IS_IPHONE_WITH_INSETS ? 110 : 60;
if (isLandscape) {
keyboardOffset = DeviceTypes.IS_IPHONE_WITH_INSETS ? 0 : 10;
}
return (
<SafeAreaView
testID='custom_status.screen'
style={style.container}
>
<KeyboardAvoidingView
behavior='padding'
style={style.container}
keyboardVerticalOffset={keyboardOffset}
enabled={Platform.OS === 'ios'}
>
<ScrollView
bounces={false}
>
<StatusBar/>
<View style={style.scrollView}>
{customStatusInput}
{this.renderRecentCustomStatuses(style)}
{this.renderCustomStatusSuggestions(style)}
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
}
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,
},
};
});

View file

@ -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(
<CustomStatusSuggestion
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should match snapshot with separator and clear button', () => {
const wrapper = shallow(
<CustomStatusSuggestion
{...baseProps}
separator={true}
handleClear={jest.fn()}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
it('should call handleSuggestionClick on clicking the suggestion', () => {
const wrapper = shallow(
<CustomStatusSuggestion
{...baseProps}
/>,
);
wrapper.find(TouchableOpacity).simulate('press');
expect(baseProps.handleSuggestionClick).toBeCalled();
});
});

View file

@ -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 ?
(
<View style={style.clearButtonContainer}>
<ClearButton
handlePress={handleSuggestionClear}
theme={theme}
iconName='close'
size={18}
testID='custom_status_suggestion.clear.button'
/>
</View>
) : null;
return (
<TouchableOpacity
testID={`custom_status_suggestion.${text}`}
onPress={handleClick}
>
<View style={style.container}>
<Text style={style.iconContainer}>
<Emoji
emojiName={emoji}
size={20}
/>
</Text>
<View style={style.wrapper}>
<View style={style.textContainer}>
<CustomStatusText
text={text}
theme={theme}
textStyle={{color: theme.centerChannelColor}}
/>
</View>
{clearButton}
{separator && <View style={style.divider}/>}
</View>
</View>
</TouchableOpacity>
);
};
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,
},
};
});

View file

@ -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<GenericAction>) {
return {
actions: bindActionCreators({
setCustomStatus,
unsetCustomStatus,
removeRecentCustomStatus,
}, dispatch),
};
}
export default connect(makeMapStateToProps, mapDispatchToProps)(CustomStatusModal);

View file

@ -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;

View file

@ -389,3 +389,480 @@ exports[`user_profile should match snapshot when user is from remote 1`] = `
</ScrollView>
</RNCSafeAreaView>
`;
exports[`user_profile should match snapshot with custom status 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
testID="user_profile.screen"
>
<Connect(StatusBar) />
<ScrollView
contentContainerStyle={
Object {
"paddingBottom": 48,
}
}
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
testID="user_profile.scroll_view"
>
<View
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 25,
}
}
>
<Connect(ProfilePicture)
iconSize={104}
size={153}
statusBorderWidth={6}
statusSize={36}
testID="user_profile.profile_picture"
userId="4hzdnk6mg7gepe7yze6m3domnc"
/>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"marginTop": 15,
}
}
testID="user_profile.username"
>
@fred
</Text>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<View
style={
Object {
"marginBottom": 25,
"marginHorizontal": 15,
}
}
>
<View
testID="user_profile.custom_status"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
>
Status
</Text>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"marginRight": 5,
}
}
testID="custom_status.emoji.calendar"
>
<Connect(Emoji)
emojiName="calendar"
size={20}
/>
</Text>
<View
style={
Object {
"justifyContent": "center",
"width": "80%",
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
>
In a meeting
</Text>
</View>
</View>
</View>
<View
testID="user_profile.display_block"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
testID="user_profile.display_block.nickname.label"
>
Nickname
</Text>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
testID="user_profile.display_block.nickname.value"
>
nick
</Text>
</View>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<userProfileRow
action={[Function]}
defaultMessage="Send Message"
icon="send"
iconColor="rgba(0, 0, 0, 0.7)"
iconSize={24}
testID="user_profile.send_message.action"
textColor="#000"
textId="mobile.routes.user_profile.send_message"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
togglable={false}
/>
</ScrollView>
</RNCSafeAreaView>
`;
exports[`user_profile should match snapshot with custom status and isMyUser true 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
testID="user_profile.screen"
>
<Connect(StatusBar) />
<ScrollView
contentContainerStyle={
Object {
"paddingBottom": 48,
}
}
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
testID="user_profile.scroll_view"
>
<View
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 25,
}
}
>
<Connect(ProfilePicture)
iconSize={104}
size={153}
statusBorderWidth={6}
statusSize={36}
testID="user_profile.profile_picture"
userId="4hzdnk6mg7gepe7yze6m3domnc"
/>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"marginTop": 15,
}
}
testID="user_profile.username"
>
@fred
</Text>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<View
style={
Object {
"marginBottom": 25,
"marginHorizontal": 15,
}
}
>
<View
testID="user_profile.custom_status"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
>
Status
</Text>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"marginRight": 5,
}
}
testID="custom_status.emoji.calendar"
>
<Connect(Emoji)
emojiName="calendar"
size={20}
/>
</Text>
<View
style={
Object {
"justifyContent": "center",
"width": "80%",
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
>
In a meeting
</Text>
</View>
<View
style={
Object {
"position": "absolute",
"right": 0,
"top": -8,
}
}
>
<ClearButton
containerSize={40}
handlePress={[MockFunction]}
iconName="close-circle"
size={20}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</View>
</View>
</View>
<View
testID="user_profile.display_block"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
testID="user_profile.display_block.nickname.label"
>
Nickname
</Text>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
testID="user_profile.display_block.nickname.value"
>
nick
</Text>
</View>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<userProfileRow
action={[Function]}
defaultMessage="Send Message"
icon="send"
iconColor="rgba(0, 0, 0, 0.7)"
iconSize={24}
testID="user_profile.send_message.action"
textColor="#000"
textId="mobile.routes.user_profile.send_message"
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
togglable={false}
/>
</ScrollView>
</RNCSafeAreaView>
`;

View file

@ -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);

View file

@ -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 (
<View
testID='user_profile.custom_status'
>
<Text style={style.header}>{label}</Text>
<View style={style.customStatus}>
<Text
style={style.iconContainer}
testID={`custom_status.emoji.${customStatus.emoji}`}
>
<Emoji
emojiName={customStatus.emoji}
size={20}
/>
</Text>
<View style={style.customStatusTextContainer}>
<Text style={style.text}>
{customStatus.text}
</Text>
</View>
{isMyUser && (
<View style={style.clearButton}>
<ClearButton
theme={theme}
handlePress={this.props.actions.unsetCustomStatus}
/>
</View>
)}
</View>
</View>
);
}
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) => {
},
};
});

View file

@ -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(
<UserProfile
@ -58,6 +73,27 @@ describe('user_profile', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with custom status', () => {
const wrapper = shallowWithIntl(
<UserProfile
{...customStatusProps}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot with custom status and isMyUser true', () => {
const wrapper = shallowWithIntl(
<UserProfile
{...customStatusProps}
isMyUser={true}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should contain bot tag', () => {
const botUser = {
email: 'test@test.com',

View file

@ -48,9 +48,12 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => {
};
});
function createTouchableComponent(children, action) {
function createTouchableComponent(children, action, testID) {
return (
<TouchableWithFeedback onPress={action}>
<TouchableWithFeedback
onPress={action}
testID={testID}
>
{children}
</TouchableWithFeedback>
);
@ -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;

View file

@ -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);
}

View file

@ -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",

View file

@ -90,6 +90,7 @@
"EnableUserCreation": true,
"EnableOpenServer": true,
"EnableUserDeactivation": false,
"EnableCustomUserStatuses": false,
"RestrictCreationToDomains": "",
"EnableCustomBrand": false,
"CustomBrandText": "",

View file

@ -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();

View file

@ -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',

View file

@ -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;

View file

@ -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,

View file

@ -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));

View file

@ -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();
}