[MM-12353] Add feature to render jumbo emoji and unwrapped Emoji component with Text when rendering message containing emoji/s only (#2356)

* add feature to render jumbo emoji/s

* fix bottom clipping of emoji on Android

* updated per feedback
This commit is contained in:
Saturnino Abril 2018-11-23 00:56:27 +08:00 committed by Harrison Healey
parent 6df0f8b915
commit d8abeabd70
8 changed files with 618 additions and 52 deletions

View file

@ -0,0 +1,33 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MarkdownEmoji should match snapshot 1`] = `
<Unknown
context={Array []}
first={true}
last={true}
literal={null}
nodeKey="5"
>
<Unknown
context={
Array [
"paragraph",
]
}
emojiName="smile"
literal=":smile:"
nodeKey="4"
>
<Unknown
context={
Array [
"paragraph",
"emoji",
]
}
literal=":smile:"
nodeKey="3"
/>
</Unknown>
</Unknown>
`;

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import MarkdownEmoji from './markdown_emoji';
function mapStateToProps(state) {
return {
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(MarkdownEmoji);

View file

@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Node, Parser} from 'commonmark';
import Renderer from 'commonmark-react-renderer';
import React, {PureComponent} from 'react';
import {Platform, Text, View} from 'react-native';
import PropTypes from 'prop-types';
import Emoji from 'app/components/emoji';
import FormattedText from 'app/components/formatted_text';
import CustomPropTypes from 'app/constants/custom_prop_types';
import {blendColors, concatStyles, makeStyleSheetFromTheme} from 'app/utils/theme';
export default class MarkdownEmoji extends PureComponent {
static propTypes = {
baseTextStyle: CustomPropTypes.Style,
isEdited: PropTypes.bool,
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired,
value: PropTypes.string.isRequired,
};
constructor(props) {
super(props);
this.parser = this.createParser();
this.renderer = this.createRenderer();
}
createParser = () => {
return new Parser();
}
createRenderer = () => {
return new Renderer({
renderers: {
editedIndicator: this.renderEditedIndicator,
emoji: this.renderEmoji,
paragraph: this.renderParagraph,
text: this.renderText,
},
});
};
computeTextStyle = (baseStyle) => {
if (!this.props.shouldRenderJumboEmoji) {
return baseStyle;
}
const style = getStyleSheet(this.props.theme);
return concatStyles(baseStyle, style.jumboEmoji);
};
renderEmoji = ({context, emojiName, literal}) => {
return (
<Emoji
emojiName={emojiName}
literal={literal}
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
/>
);
};
renderParagraph = ({children}) => {
const style = getStyleSheet(this.props.theme);
return (
<View style={style.block}>{children}</View>
);
};
renderText = ({context, literal}) => {
const style = this.computeTextStyle(this.props.baseTextStyle, context);
return <Text style={style}>{literal}</Text>;
};
renderEditedIndicator = ({context}) => {
let spacer = '';
if (context[0] === 'paragraph') {
spacer = ' ';
}
const style = getStyleSheet(this.props.theme);
const styles = [
this.props.baseTextStyle,
style.editedIndicatorText,
];
return (
<Text style={styles}>
{spacer}
<FormattedText
id='post_message_view.edited'
defaultMessage='(edited)'
/>
</Text>
);
};
render() {
const ast = this.parser.parse(this.props.value);
if (this.props.isEdited) {
const editIndicatorNode = new Node('edited_indicator');
if (ast.lastChild && ['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.lastChild.appendChild(editIndicatorNode);
} else {
const node = new Node('paragraph');
node.appendChild(editIndicatorNode);
ast.appendChild(node);
}
}
return this.renderer.render(ast);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
// Android has trouble giving text transparency depending on how it's nested,
// so we calculate the resulting colour manually
const editedOpacity = Platform.select({
ios: 0.3,
android: 1.0,
});
const editedColor = Platform.select({
ios: theme.centerChannelColor,
android: blendColors(theme.centerChannelBg, theme.centerChannelColor, 0.3),
});
return {
block: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
},
editedIndicatorText: {
color: editedColor,
opacity: editedOpacity,
},
jumboEmoji: {
fontSize: 40,
lineHeight: 50,
},
};
});

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from 'mattermost-redux/constants/preferences';
import MarkdownEmoji from './markdown_emoji';
describe('MarkdownEmoji', () => {
const baseProps = {
baseTextStyle: {color: '#3d3c40', fontSize: 15, lineHeight: 20},
isEdited: false,
shouldRenderJumboEmoji: true,
theme: Preferences.THEMES.default,
value: ':smile:',
};
test('should match snapshot', () => {
const wrapper = shallow(
<MarkdownEmoji {...baseProps}/>
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -10,6 +10,8 @@ import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId, getCurrentUserRoles} from 'mattermost-redux/selectors/entities/users';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
import {memoizeResult} from 'mattermost-redux/utils/helpers';
import {
isEdited,
@ -19,68 +21,79 @@ import {
} from 'mattermost-redux/utils/post_utils';
import {isAdmin as checkIsAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {hasEmojisOnly} from 'app/utils/emoji_utils';
import PostBody from './post_body';
const POST_TIMEOUT = 20000;
function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.postId) || {};
const channel = getChannel(state, post.channel_id) || {};
function makeMapStateToProps() {
const memoizeHasEmojisOnly = memoizeResult((message, customEmojis) => hasEmojisOnly(message, customEmojis));
let isFailed = post.failed;
let isPending = post.id === post.pending_post_id;
if (isPending && Date.now() - post.create_at > POST_TIMEOUT) {
// Something has prevented the post from being set to failed, so it's safe to assume
// that it has actually failed by this point
isFailed = true;
isPending = false;
}
return (state, ownProps) => {
const post = getPost(state, ownProps.postId) || {};
const channel = getChannel(state, post.channel_id) || {};
const isUserCanManageMembers = canManageChannelMembers(state);
const isEphemeralPost = isPostEphemeral(post);
let isFailed = post.failed;
let isPending = post.id === post.pending_post_id;
if (isPending && Date.now() - post.create_at > POST_TIMEOUT) {
// Something has prevented the post from being set to failed, so it's safe to assume
// that it has actually failed by this point
isFailed = true;
isPending = false;
}
const config = getConfig(state);
const license = getLicense(state);
const currentUserId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const isAdmin = checkIsAdmin(roles);
const isSystemAdmin = checkIsSystemAdmin(roles);
let canDelete = false;
const isUserCanManageMembers = canManageChannelMembers(state);
const isEphemeralPost = isPostEphemeral(post);
if (post && !ownProps.channelIsArchived) {
canDelete = canDeletePost(state, config, license, currentTeamId, currentChannelId, currentUserId, post, isAdmin, isSystemAdmin);
}
const config = getConfig(state);
const license = getLicense(state);
const currentUserId = getCurrentUserId(state);
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : '';
const isAdmin = checkIsAdmin(roles);
const isSystemAdmin = checkIsSystemAdmin(roles);
let canDelete = false;
let isPostAddChannelMember = false;
if (
channel &&
(channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
isUserCanManageMembers &&
isEphemeralPost &&
post.props &&
post.props.add_channel_member
) {
isPostAddChannelMember = true;
}
if (post && !ownProps.channelIsArchived) {
canDelete = canDeletePost(state, config, license, currentTeamId, currentChannelId, currentUserId, post, isAdmin, isSystemAdmin);
}
return {
postProps: post.props || {},
postType: post.type || '',
fileIds: post.file_ids,
hasBeenDeleted: post.state === Posts.POST_DELETED,
hasBeenEdited: isEdited(post),
hasReactions: post.has_reactions,
isFailed,
isPending,
isPostAddChannelMember,
isPostEphemeral: isEphemeralPost,
isSystemMessage: isSystemMessage(post),
message: post.message,
theme: getTheme(state),
canDelete,
let isPostAddChannelMember = false;
if (
channel &&
(channel.type === General.PRIVATE_CHANNEL || channel.type === General.OPEN_CHANNEL) &&
isUserCanManageMembers &&
isEphemeralPost &&
post.props &&
post.props.add_channel_member
) {
isPostAddChannelMember = true;
}
const customEmojis = getCustomEmojisByName(state);
const {isEmojiOnly, shouldRenderJumboEmoji} = memoizeHasEmojisOnly(post.message, customEmojis);
return {
postProps: post.props || {},
postType: post.type || '',
fileIds: post.file_ids,
hasBeenDeleted: post.state === Posts.POST_DELETED,
hasBeenEdited: isEdited(post),
hasReactions: post.has_reactions,
isFailed,
isPending,
isPostAddChannelMember,
isPostEphemeral: isEphemeralPost,
isSystemMessage: isSystemMessage(post),
message: post.message,
isEmojiOnly,
shouldRenderJumboEmoji,
theme: getTheme(state),
canDelete,
};
};
}
export default connect(mapStateToProps, null, null, {withRef: true})(PostBody);
export default connect(makeMapStateToProps, null, null, {withRef: true})(PostBody);

View file

@ -16,6 +16,7 @@ import {Posts} from 'mattermost-redux/constants';
import CombinedSystemMessage from 'app/components/combined_system_message';
import FormattedText from 'app/components/formatted_text';
import Markdown from 'app/components/markdown';
import MarkdownEmoji from 'app/components/markdown/markdown_emoji';
import ShowMoreButton from 'app/components/show_more_button';
import {emptyFunction} from 'app/utils/general';
@ -58,6 +59,8 @@ export default class PostBody extends PureComponent {
replyBarStyle: PropTypes.array,
showAddReaction: PropTypes.bool,
showLongPost: PropTypes.bool.isRequired,
isEmojiOnly: PropTypes.bool.isRequired,
shouldRenderJumboEmoji: PropTypes.bool.isRequired,
theme: PropTypes.object,
};
@ -275,6 +278,7 @@ export default class PostBody extends PureComponent {
hasBeenDeleted,
hasBeenEdited,
highlight,
isEmojiOnly,
isFailed,
isPending,
isPostAddChannelMember,
@ -290,6 +294,7 @@ export default class PostBody extends PureComponent {
postProps,
postType,
replyBarStyle,
shouldRenderJumboEmoji,
theme,
} = this.props;
const {isLongPost, maxHeight} = this.state;
@ -331,6 +336,17 @@ export default class PostBody extends PureComponent {
</View>
</View>
);
} else if (isEmojiOnly) {
messageComponent = (
<View style={style.row}>
<MarkdownEmoji
baseTextStyle={messageStyle}
isEdited={hasBeenEdited}
shouldRenderJumboEmoji={shouldRenderJumboEmoji}
value={message}
/>
</View>
);
} else if (message.length) {
messageComponent = (
<View style={style.row}>

93
app/utils/emoji_utils.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,220 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {hasEmojisOnly} from './emoji_utils';
describe('hasEmojisOnly with named emojis', () => {
const testCases = [{
name: 'Named emoji',
message: ':smile:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Valid custom emoji',
message: ':valid_custom:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Invalid custom emoji',
message: ':invalid_custom:',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Named emojis',
message: ':smile: :heart:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Named emojis with white spaces',
message: ' :smile: :heart: ',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Named emojis with potential custom emojis',
message: ':smile: :heart: :valid_custom: :one:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Named emojis of 4 plus invalid :exceed: named emoji',
message: ':smile: :heart: :valid_custom: :one: :exceed:',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Named emojis greater than max of 4',
message: ':smile: :heart: :valid_custom: :one: :heart:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: 'smile',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: 'smile:',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: ':smile',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: ':smile::',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: '::smile:',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Mixed valid and invalid named emojis',
message: ' :smile: invalid :heart: ',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}];
const customEmojis = new Map([['valid_custom', 0]]);
for (const testCase of testCases) {
it(`${testCase.name} - ${testCase.message}`, () => {
expect(hasEmojisOnly(testCase.message, customEmojis)).toEqual(testCase.expected);
});
}
});
describe('hasEmojisOnly with unicode emojis', () => {
const testCases = [{
name: 'Unicode emoji',
message: '👍',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Unicode emoji',
message: '🙌',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Unicode emojis',
message: '🙌 👏',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Unicode emojis without whitespace in between',
message: '🙌👏',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Unicode emojis with white spaces',
message: ' 😣 😖 ',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: '4 unicode emojis',
message: '😣 😖 🙌 👏',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Unicode emojis greater than max of 4',
message: '😣 😖 🙌 👏 💩',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: false},
}, {
name: 'Unicode emoji',
message: '\ud83d\udd5f',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Not valid unicode emoji',
message: '\ud83d\udd5fnotvalid',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Mixed valid and invalid unicode emojis',
message: '😣 invalid 😖',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}];
const customEmojis = new Map();
for (const testCase of testCases) {
it(`${testCase.name} - ${testCase.message}`, () => {
expect(hasEmojisOnly(testCase.message, customEmojis)).toEqual(testCase.expected);
});
}
});
describe('hasEmojisOnly with emoticons', () => {
const testCases = [{
name: 'Emoticon',
message: ':)',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Emoticon',
message: ':+1:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Emoticons',
message: ':) :-o',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Emoticons with white spaces',
message: ' :) :-o ',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: '4 emoticons',
message: ':) :-o :+1: :|',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'Emoticons greater than max of 4',
message: ':) :-o :+1: :| :p',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid emoticon',
message: ':|:p',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Not valid named emoji',
message: ':) :-o :+1::|',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'Mixed valid and invalid named emojis',
message: ':) :-o invalid :|',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}];
const customEmojis = new Map();
for (const testCase of testCases) {
it(`${testCase.name} - ${testCase.message}`, () => {
expect(hasEmojisOnly(testCase.message, customEmojis)).toEqual(testCase.expected);
});
}
});
describe('hasEmojisOnly with empty and mixed emojis', () => {
const testCases = [{
name: 'not with empty message',
message: '',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'not with empty message',
message: ' ',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'not with no emoji pattern',
message: 'smile',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'with invalid custom emoji',
message: ':smile: :) :invalid_custom:',
expected: {isEmojiOnly: false, shouldRenderJumboEmoji: false},
}, {
name: 'with named emoji and emoticon',
message: ':smile: :) :valid_custom:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'with unicode emoji and emoticon',
message: '👍 :)',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'with named and unicode emojis',
message: ':smile: 👍',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'with named & unicode emojis and emoticon',
message: ':smile: 👍 :)',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'with 4 named & unicode emojis and emoticon',
message: ':smile: 👍 :) :heart:',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: true},
}, {
name: 'with named & unicode emojis and emoticon greater than max of 4',
message: ':smile: 👍 :) :heart: 👏',
expected: {isEmojiOnly: true, shouldRenderJumboEmoji: false},
}];
const customEmojis = new Map([['valid_custom', 0]]);
for (const testCase of testCases) {
it(`${testCase.name} - ${testCase.message}`, () => {
expect(hasEmojisOnly(testCase.message, customEmojis)).toEqual(testCase.expected);
});
}
});