diff --git a/app/components/message_attachments/action_button/action_button.js b/app/components/message_attachments/action_button/action_button.js index 4209dc68a..86374fd04 100644 --- a/app/components/message_attachments/action_button/action_button.js +++ b/app/components/message_attachments/action_button/action_button.js @@ -2,12 +2,12 @@ // See LICENSE.txt for license information. import React, {PureComponent} from 'react'; -import {Text} from 'react-native'; import PropTypes from 'prop-types'; import Button from 'react-native-button'; import {preventDoubleTap} from 'app/utils/tap'; import {makeStyleSheetFromTheme} from 'app/utils/theme'; +import ActionButtonText from './action_button_text'; export default class ActionButton extends PureComponent { static propTypes = { @@ -34,7 +34,10 @@ export default class ActionButton extends PureComponent { containerStyle={style.button} onPress={this.handleActionPress} > - {name} + ); } @@ -56,6 +59,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { color: theme.buttonColor, fontSize: 12, fontWeight: '600', + lineHeight: 13, }, }; }); diff --git a/app/components/message_attachments/action_button/action_button_text.js b/app/components/message_attachments/action_button/action_button_text.js new file mode 100644 index 000000000..6c07409a5 --- /dev/null +++ b/app/components/message_attachments/action_button/action_button_text.js @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {Text, View, StyleSheet} from 'react-native'; +import Emoji from 'app/components/emoji'; +import {getEmoticonName} from 'app/utils/emoji_utils'; + +// reEmoji matches an emoji (eg. :taco:) at the start of a string. +const reEmoji = /^:([a-z0-9_\-+]+):\B/i; + +// reEmoticon matches an emoticon (eg. :D) at the start of a string. +const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:['’]-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\s|[*_~?])/i; + +// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right +// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <) +const reMain = /^[\s\S]+?(?=[:;x<]|$)/i; + +export default function ActionButtonText({message, style}) { + const components = []; + + let text = message; + while (text) { + let match; + + // See if the text starts with an emoji + if ((match = text.match(reEmoji))) { + components.push( + + ); + text = text.substring(match[0].length); + continue; + } + + // Or an emoticon + if ((match = text.match(reEmoticon))) { + const emoticonName = getEmoticonName(match[0]); + if (emoticonName) { + components.push( + + ); + text = text.substring(match[0].length); + continue; + } + } + + // This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that + // reMain always captures at least one character, so text will always be getting shorter + match = text.match(reMain); + + components.push( + + {match[0]} + + ); + text = text.substring(match[0].length); + } + + return ( + + {components} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'flex-start', + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); + +ActionButtonText.propTypes = { + message: PropTypes.string.isRequired, + style: PropTypes.object.isRequired, +}; \ No newline at end of file diff --git a/app/components/message_attachments/action_button/action_button_text.test.js b/app/components/message_attachments/action_button/action_button_text.test.js new file mode 100644 index 000000000..1e55197c7 --- /dev/null +++ b/app/components/message_attachments/action_button/action_button_text.test.js @@ -0,0 +1,143 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; +import ActionButtonText from './action_button_text'; + +describe('ActionButtonText emojis', () => { + const emojis = [ + { + name: 'smile', + literal: ':smile:', + }, + { + name: 'custom_emoji', + literal: ':custom_emoji:', + }, + { + name: 'heart', + literal: ':heart:', + }, + { + name: 'one', + literal: ':one:', + }, + { + name: 'slightly_smiling_face', + literal: ':)', + }, + { + name: 'wink', + literal: ';)', + }, + { + name: 'open_mouth', + literal: ':o', + }, + { + name: 'scream', + literal: ':-o', + }, + { + name: 'smirk', + literal: ':]', + }, + { + name: 'smile', + literal: ':D', + }, + { + name: 'stuck_out_tongue_closed_eyes', + literal: 'x-d', + }, + { + name: 'stuck_out_tongue', + literal: ':p', + }, + { + name: 'rage', + literal: ':@', + }, + { + name: 'slightly_frowning_face', + literal: ':(', + }, + { + name: 'cry', + literal: ':`(', + }, + { + name: 'confused', + literal: ':/', + }, + { + name: 'confounded', + literal: ':s', + }, + { + name: 'neutral_face', + literal: ':|', + }, + { + name: 'flushed', + literal: ':$', + }, + { + name: 'mask', + literal: ':-x', + }, + { + name: 'heart', + literal: '<3', + }, + { + name: 'broken_heart', + literal: ' { + test('only emoji ' + name, () => { + const baseProps = {message: literal, style: {fontSize: 12}}; + + const wrapper = shallow(); + + expect(wrapper.getElement().props.children.length).toBe(1); + + const child = wrapper.getElement().props.children[0]; + + expect(child.type.displayName).toBe('Connect(Emoji)'); + expect(child.props.emojiName).toBe(name); + expect(child.props.literal).toBe(literal); + expect(child.props.textStyle.fontSize).toBe(12); + }); + + test('emoji ' + name + ' with additional text', () => { + const baseProps = {message: 'emoji test with literal equals to ' + literal, style: {fontSize: 12}}; + + const wrapper = shallow(); + + expect(wrapper.getElement().props.children.length).toBe(2); + + const textChild = wrapper.getElement().props.children[0]; + const emoticonChild = wrapper.getElement().props.children[1]; + + expect(textChild.type.displayName).toBe('Text'); + expect(textChild.props.children).toBe('emoji test with literal equals to '); + expect(textChild.props.style.fontSize).toBe(12); + expect(emoticonChild.props.emojiName).toBe(name); + expect(emoticonChild.props.literal).toBe(literal); + expect(emoticonChild.type.displayName).toBe('Connect(Emoji)'); + expect(emoticonChild.props.textStyle.fontSize).toBe(12); + }); + }); +}); \ No newline at end of file diff --git a/app/utils/emoji_utils.js b/app/utils/emoji_utils.js index c95f41cb3..0dfc88f81 100644 --- a/app/utils/emoji_utils.js +++ b/app/utils/emoji_utils.js @@ -20,7 +20,7 @@ const RE_EMOTICON = { stuck_out_tongue: /(^|\s)(:-?p)(?=$|\s)/gi, // :p rage: /(^|\s)(:-?[[@])(?=$|\s)/g, // :@ slightly_frowning_face: /(^|\s)(:-?\()(?=$|\s)/g, // :( - cry: /(^|\s)(:['’]-?\(|:'\(|:'\()(?=$|\s)/g, // :`( + cry: /(^|\s)(:[`'’]-?\(|:'\(|:'\()(?=$|\s)/g, // :`( confused: /(^|\s)(:-?\/)(?=$|\s)/g, // :/ confounded: /(^|\s)(:-?s)(?=$|\s)/gi, // :s neutral_face: /(^|\s)(:-?\|)(?=$|\s)/g, // :| @@ -46,6 +46,10 @@ function isEmoticon(text) { return false; } +export function getEmoticonName(value) { + return Object.keys(RE_EMOTICON).find((key) => value.match(RE_EMOTICON[key]) !== null); +} + export function hasEmojisOnly(message, customEmojis) { if (!message || message.length === 0 || (/^\s{4}/).test(message)) { return {isEmojiOnly: false, shouldRenderJumboEmoji: false};