diff --git a/src/CONST.js b/src/CONST.js index 30f9e24ae3e7..bbd8f98ab690 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -806,6 +806,7 @@ const CONST = { // eslint-disable-next-line max-len, no-misleading-character-class EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, + EMOJI_SURROGATE: /(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/, TAX_ID: /^\d{9}$/, NON_NUMERIC: /\D/g, EMOJI_NAME: /:[\w+-]+:/g, diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index b97a1fa070c3..6944b1990dee 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -17,6 +17,7 @@ import SelectCircle from './SelectCircle'; import colors from '../styles/colors'; import variables from '../styles/variables'; import MultipleAvatars from './MultipleAvatars'; +import TextEmoji from './TextEmoji'; const propTypes = { ...menuItemPropTypes, @@ -134,7 +135,7 @@ const MenuItem = (props) => { style={titleTextStyle} numberOfLines={1} > - {props.title} + {props.title} )} {Boolean(props.description) && !props.shouldShowDescriptionOnTop && ( diff --git a/src/components/TextEmoji.js b/src/components/TextEmoji.js new file mode 100644 index 000000000000..e6af964b0433 --- /dev/null +++ b/src/components/TextEmoji.js @@ -0,0 +1,52 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {View} from 'react-native'; +import _ from 'underscore'; +import Text from './Text'; +import * as EmojiUtils from '../libs/EmojiUtils'; +import * as StyleUtils from '../styles/StyleUtils'; +import stylePropTypes from '../styles/stylePropTypes'; + +const propTypes = { + /** The message text to render */ + children: PropTypes.string.isRequired, + + /** The message text additional style */ + style: stylePropTypes, + + /** The emoji text additional style */ + emojiContainerStyle: stylePropTypes, + + /** The plain text additional style */ + plainTextContainerStyle: stylePropTypes, +}; + +const defaultProps = { + style: [], +}; + +const TextEmoji = (props) => { + const words = EmojiUtils.getAllEmojiFromText(props.children); + const propsStyle = StyleUtils.parseStyleAsArray(props.style); + + return _.map(words, ({text, isEmoji}, index) => (isEmoji + ? ( + + + {text} + + + ) : ( + + + {text} + + + ))); +}; + +TextEmoji.displayName = 'TextEmoji'; +TextEmoji.defaultProps = defaultProps; +TextEmoji.propTypes = propTypes; + +export default TextEmoji; diff --git a/src/libs/EmojiUtils.js b/src/libs/EmojiUtils.js index bee46547a3e8..95fce2cb82b1 100644 --- a/src/libs/EmojiUtils.js +++ b/src/libs/EmojiUtils.js @@ -1,48 +1,47 @@ import _ from 'underscore'; import lodashOrderBy from 'lodash/orderBy'; import moment from 'moment'; -import Str from 'expensify-common/lib/str'; import CONST from '../CONST'; import * as User from './actions/User'; import emojisTrie from './EmojiTrie'; -/** - * Get the unicode code of an emoji in base 16. - * @param {String} input - * @returns {String} - */ -const getEmojiUnicode = _.memoize((input) => { - if (input.length === 0) { - return ''; - } +// /** +// * Get the unicode code of an emoji in base 16. +// * @param {String} input +// * @returns {String} +// */ +// const getEmojiUnicode = _.memoize((input) => { +// if (input.length === 0) { +// return ''; +// } - if (input.length === 1) { - return _.map(input.charCodeAt(0).toString().split(' '), val => parseInt(val, 10).toString(16)).join(' '); - } +// if (input.length === 1) { +// return _.map(input.charCodeAt(0).toString().split(' '), val => parseInt(val, 10).toString(16)).join(' '); +// } - const pairs = []; - - // Some Emojis in UTF-16 are stored as pair of 2 Unicode characters (eg Flags) - // The first char is generally between the range U+D800 to U+DBFF called High surrogate - // & the second char between the range U+DC00 to U+DFFF called low surrogate - // More info in the following links: - // 1. https://docs.microsoft.com/en-us/windows/win32/intl/surrogates-and-supplementary-characters - // 2. https://thekevinscott.com/emojis-in-javascript/ - for (let i = 0; i < input.length; i++) { - if (input.charCodeAt(i) >= 0xd800 && input.charCodeAt(i) <= 0xdbff) { // high surrogate - if (input.charCodeAt(i + 1) >= 0xdc00 && input.charCodeAt(i + 1) <= 0xdfff) { // low surrogate - pairs.push( - ((input.charCodeAt(i) - 0xd800) * 0x400) - + (input.charCodeAt(i + 1) - 0xdc00) + 0x10000, - ); - } - } else if (input.charCodeAt(i) < 0xd800 || input.charCodeAt(i) > 0xdfff) { - // modifiers and joiners - pairs.push(input.charCodeAt(i)); - } - } - return _.map(pairs, val => parseInt(val, 10).toString(16)).join(' '); -}); +// const pairs = []; + +// // Some Emojis in UTF-16 are stored as pair of 2 Unicode characters (eg Flags) +// // The first char is generally between the range U+D800 to U+DBFF called High surrogate +// // & the second char between the range U+DC00 to U+DFFF called low surrogate +// // More info in the following links: +// // 1. https://docs.microsoft.com/en-us/windows/win32/intl/surrogates-and-supplementary-characters +// // 2. https://thekevinscott.com/emojis-in-javascript/ +// for (let i = 0; i < input.length; i++) { +// if (input.charCodeAt(i) >= 0xd800 && input.charCodeAt(i) <= 0xdbff) { // high surrogate +// if (input.charCodeAt(i + 1) >= 0xdc00 && input.charCodeAt(i + 1) <= 0xdfff) { // low surrogate +// pairs.push( +// ((input.charCodeAt(i) - 0xd800) * 0x400) +// + (input.charCodeAt(i + 1) - 0xdc00) + 0x10000, +// ); +// } +// } else if (input.charCodeAt(i) < 0xd800 || input.charCodeAt(i) > 0xdfff) { +// // modifiers and joiners +// pairs.push(input.charCodeAt(i)); +// } +// } +// return _.map(pairs, val => parseInt(val, 10).toString(16)).join(' '); +// }); /** * Function to remove Skin Tone and utf16 surrogates from Emoji @@ -59,27 +58,27 @@ function trimEmojiUnicode(emojiCode) { * @param {String} message * @returns {Boolean} */ -function containsOnlyEmojis(message) { - const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); +// function containsOnlyEmojis(message) { +// const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); +// const match = trimmedMessage.match(CONST.REGEX.EMOJIS); - if (!match) { - return false; - } +// if (!match) { +// return false; +// } - const codes = []; - _.map(match, emoji => _.map(getEmojiUnicode(emoji).split(' '), (code) => { - if (!CONST.INVISIBLE_CODEPOINTS.includes(code)) { - codes.push(code); - } - return code; - })); +// const codes = []; +// _.map(match, emoji => _.map(getEmojiUnicode(emoji).split(' '), (code) => { +// if (!CONST.INVISIBLE_CODEPOINTS.includes(code)) { +// codes.push(code); +// } +// return code; +// })); - // Emojis are stored as multiple characters, so we're using spread operator - // to iterate over the actual emojis, not just characters that compose them - const messageCodes = _.filter(_.map([...trimmedMessage], char => getEmojiUnicode(char)), string => string.length > 0 && !CONST.INVISIBLE_CODEPOINTS.includes(string)); - return codes.length === messageCodes.length; -} +// // Emojis are stored as multiple characters, so we're using spread operator +// // to iterate over the actual emojis, not just characters that compose them +// const messageCodes = _.filter(_.map([...trimmedMessage], char => getEmojiUnicode(char)), string => string.length > 0 && !CONST.INVISIBLE_CODEPOINTS.includes(string)); +// return codes.length === messageCodes.length; +// } /** * Get the header indices based on the max emojis per row @@ -245,6 +244,141 @@ function suggestEmojis(text, limit = 5) { return []; } +// /** +// * Validates that this message contains emojis +// * +// * @param {String} message +// * @returns {Boolean} +// */ +// function hasEmojis(message) { +// if (!message) { +// return false; +// } +// const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); + +// // return CONST.REGEX.EMOJIS.test(trimmedMessage); +// return Boolean(trimmedMessage.match(CONST.REGEX.EMOJIS)); +// } + +/** + * Get all the emojis in the message + * @param {String} text + * @returns {Array} + */ +function getAllEmojiFromText(text) { + // return an empty array when no text is passed + if (!text) { + return []; + } + + // Unicode Character 'ZERO WIDTH JOINER' (U+200D) is usually used to join surrogate pair together without breaking the emoji + const zeroWidthJoiner = '\u200D'; // https://codepoints.net/U+200D?lang=en + const splittedMessage = text.split(''); + const result = []; + + let wordHolder = ''; // word counter + let emojiHolder = ''; // emoji counter + + const setResult = (word, isEmoji = false) => { + // for some weird reason javascript sees the empty string `"` as a word with `length = 1` + // this is caused after splitting the text empty spaces are added to both the start and the end of all emojis and text + // given the empty space is close to a text then its length is counted as 0 + // while if it's before or after an emoji then it's counted as 1, so we remove the word where word.length equals 1 + // NOTE: this does not affect a single character element example typing `[i | J]` cause after splitting its empty word.length is calculated as 0 + if (!isEmoji && word.length === 1) { + return; + } + + result.push({text: word, isEmoji}); + }; + + _.forEach(splittedMessage, (word, index) => { + if (CONST.REGEX.EMOJI_SURROGATE.test(word) || word === zeroWidthJoiner) { + setResult(wordHolder); + wordHolder = ''; + emojiHolder += word; + } else { + setResult(emojiHolder, true); + emojiHolder = ''; + wordHolder += word; + } + + if (index === splittedMessage.length - 1) { + setResult(emojiHolder, true); + setResult(wordHolder); + } + }); + + // remove none text characters like '' only return where text is a word or white space ' ' + return _.filter(result, res => res.text); +} + +// function getAllEmojiFromText(text) { +// if (!text) { +// return []; +// } + +// const splitText = []; +// let reResult; +// let lastMatchIndexEnd = 0; +// do { +// // Look for an emoji chunk in the string +// reResult = CONST.REGEX.EMOJIS.exec(text); + +// // If we reached the end of the string and it wasn't included in a previous match +// // the chunk between the end of the last match and the end of the string is plain text +// if (reResult === null && lastMatchIndexEnd !== text.length - 1) { +// splitText.push({ +// text: text.slice(lastMatchIndexEnd, text.length), +// isEmoji: false, +// }); +// // eslint-disable-next-line no-continue +// continue; +// } + +// const matchIndexStart = reResult.indices[0][0]; +// const matchIndexEnd = reResult.indices[0][1]; + +// // The chunk between the end of the last match and the start of the new one is plain-text +// splitText.push({ +// text: text.slice(lastMatchIndexEnd, matchIndexStart), +// isEmoji: false, +// }); + +// // Everything captured by the regex itself is emoji + whitespace +// splitText.push({ +// text: text.slice(matchIndexStart, matchIndexEnd), +// isEmoji: true, +// }); + +// lastMatchIndexEnd = matchIndexEnd; +// } while (reResult !== null); + +// return _.filter(splitText, res => res.text); +// } + +/** + * Validates that this message contains has emojis + * + * @param {String} message + * @returns {Boolean} + */ +function hasEmojis(message) { + const splitText = getAllEmojiFromText(message); + return _.find(splitText, chunk => chunk.isEmoji) !== undefined; +} + +/** + * Validates that this message contains only emojis + * + * @param {String} message + * @returns {Boolean} + */ +function containsOnlyEmojis(message) { + const splitText = getAllEmojiFromText(message); + return _.every(splitText, chunk => chunk.isEmoji); +} + export { getHeaderIndices, mergeEmojisWithFrequentlyUsedEmojis, @@ -253,4 +387,6 @@ export { replaceEmojis, suggestEmojis, trimEmojiUnicode, + getAllEmojiFromText, + hasEmojis, }; diff --git a/src/pages/DetailsPage.js b/src/pages/DetailsPage.js index dcc6d74270b0..e9be0b429ebc 100755 --- a/src/pages/DetailsPage.js +++ b/src/pages/DetailsPage.js @@ -25,6 +25,7 @@ import PressableWithoutFocus from '../components/PressableWithoutFocus'; import * as Report from '../libs/actions/Report'; import OfflineWithFeedback from '../components/OfflineWithFeedback'; import AutoUpdateTime from '../components/AutoUpdateTime'; +import TextEmoji from '../components/TextEmoji'; const matchType = PropTypes.shape({ params: PropTypes.shape({ @@ -148,7 +149,16 @@ class DetailsPage extends React.PureComponent { {details.displayName && ( - {isSMSLogin ? this.props.toLocalPhone(details.displayName) : details.displayName} + {isSMSLogin + ? this.props.toLocalPhone(details.displayName) + : ( + + {details.displayName} + + )} )} {details.login ? ( diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index c4f593517df5..c493183dcacf 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -15,6 +15,7 @@ import withLocalize, {withLocalizePropTypes} from '../../../components/withLocal import * as DeviceCapabilities from '../../../libs/DeviceCapabilities'; import compose from '../../../libs/compose'; import * as StyleUtils from '../../../styles/StyleUtils'; +import TextEmoji from '../../../components/TextEmoji'; const propTypes = { /** The message fragment needing to be displayed */ @@ -121,9 +122,21 @@ const ReportActionItemFragment = (props) => { - {StyleUtils.convertToLTR(Str.htmlDecode(text))} + + {StyleUtils.convertToLTR(Str.htmlDecode(text))} + {props.fragment.isEdited && ( { numberOfLines={props.isSingleLine ? 1 : undefined} style={[styles.chatItemMessageHeaderSender]} > - {Str.htmlDecode(props.fragment.text)} + + {props.fragment.text} + ); diff --git a/src/pages/settings/AboutPage/AboutPage.js b/src/pages/settings/AboutPage/AboutPage.js index 7c7840fdbbdb..6e8a1d8fd621 100644 --- a/src/pages/settings/AboutPage/AboutPage.js +++ b/src/pages/settings/AboutPage/AboutPage.js @@ -113,7 +113,7 @@ const AboutPage = (props) => { {props.translate( diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index f10bca4c6037..0954a1f37646 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -34,6 +34,7 @@ import ConfirmModal from '../../components/ConfirmModal'; import * as ReportUtils from '../../libs/ReportUtils'; import * as Link from '../../libs/actions/Link'; import OfflineWithFeedback from '../../components/OfflineWithFeedback'; +import TextEmoji from '../../components/TextEmoji'; const propTypes = { /* Onyx Props */ @@ -270,7 +271,14 @@ class InitialSettingsPage extends React.Component { {this.props.currentUserPersonalDetails.displayName - ? this.props.currentUserPersonalDetails.displayName + ? ( + + {this.props.currentUserPersonalDetails.displayName} + + ) : Str.removeSMSDomain(this.props.session.email)} diff --git a/src/styles/styles.js b/src/styles/styles/baseStyles.js similarity index 97% rename from src/styles/styles.js rename to src/styles/styles/baseStyles.js index 50104821d8fa..082a0dce5539 100644 --- a/src/styles/styles.js +++ b/src/styles/styles/baseStyles.js @@ -1,25 +1,25 @@ -import fontFamily from './fontFamily'; -import addOutlineWidth from './addOutlineWidth'; -import themeColors from './themes/default'; -import fontWeightBold from './fontWeight/bold'; -import variables from './variables'; -import spacing from './utilities/spacing'; -import sizing from './utilities/sizing'; -import flex from './utilities/flex'; -import display from './utilities/display'; -import overflow from './utilities/overflow'; -import whiteSpace from './utilities/whiteSpace'; -import wordBreak from './utilities/wordBreak'; -import positioning from './utilities/positioning'; -import codeStyles from './codeStyles'; -import visibility from './utilities/visibility'; -import writingDirection from './utilities/writingDirection'; -import optionAlternateTextPlatformStyles from './optionAlternateTextPlatformStyles'; -import emojiHeaderContainerPlatformStyles from './emojiHeaderContainerPlatformStyles'; -import pointerEventsNone from './pointerEventsNone'; -import pointerEventsAuto from './pointerEventsAuto'; -import overflowXHidden from './overflowXHidden'; -import CONST from '../CONST'; +import fontFamily from '../fontFamily'; +import addOutlineWidth from '../addOutlineWidth'; +import themeColors from '../themes/default'; +import fontWeightBold from '../fontWeight/bold'; +import variables from '../variables'; +import spacing from '../utilities/spacing'; +import sizing from '../utilities/sizing'; +import flex from '../utilities/flex'; +import display from '../utilities/display'; +import overflow from '../utilities/overflow'; +import whiteSpace from '../utilities/whiteSpace'; +import wordBreak from '../utilities/wordBreak'; +import positioning from '../utilities/positioning'; +import codeStyles from '../codeStyles'; +import visibility from '../utilities/visibility'; +import writingDirection from '../utilities/writingDirection'; +import optionAlternateTextPlatformStyles from '../optionAlternateTextPlatformStyles'; +import emojiHeaderContainerPlatformStyles from '../emojiHeaderContainerPlatformStyles'; +import pointerEventsNone from '../pointerEventsNone'; +import pointerEventsAuto from '../pointerEventsAuto'; +import overflowXHidden from '../overflowXHidden'; +import CONST from '../../CONST'; const picker = { backgroundColor: themeColors.transparent, @@ -305,6 +305,8 @@ const styles = { textDecorationLine: 'none', }, + displayNameText: {}, + textWhite: { color: themeColors.textLight, }, @@ -999,6 +1001,13 @@ const styles = { width: '100%', }, + sidebarFooterItem: { + flexShrink: 0, + color: themeColors.textSupporting, + fontSize: variables.fontSizeSmall, + paddingTop: 2, + }, + sidebarAvatar: { backgroundColor: themeColors.icon, borderRadius: 20, @@ -1101,6 +1110,27 @@ const styles = { lineHeight: variables.fontSizeOnlyEmojisHeight, }, + inboxMessageText: {}, + + emojiMessageText: { + position: 'relative', + fontSize: variables.fontSizeEmoji, + lineHeight: variables.fontSizeEmojiHeight, + top: 2, + }, + + inboxEmojiMessageText: {}, + + messageTextWithoutEmoji: { + height: '100%', + }, + + profileEmojiText: { + fontSize: variables.fontSizeEmojiProfile, + lineHeight: variables.fontSizeEmojiProfileHeight, + top: 4, + }, + createMenuPositionSidebar: { left: 18, bottom: 100, diff --git a/src/styles/styles/index.android.js b/src/styles/styles/index.android.js new file mode 100644 index 000000000000..a1b5e10240a0 --- /dev/null +++ b/src/styles/styles/index.android.js @@ -0,0 +1,39 @@ +import _ from 'lodash'; +import variables from '../variables'; +import baseStyles from './baseStyles'; + +const styles = { + ...baseStyles.styles, + displayNameText: { + marginTop: 4, + }, + + emojiMessageText: { + top: 3, + position: 'relative', + fontSize: variables.fontSizeEmoji, + lineHeight: variables.fontSizeEmojiHeight, + }, + + inboxEmojiMessageText: { + marginTop: 6, + }, + + inboxMessageText: { + marginTop: 1, + }, + + onlyEmojisText: { + marginTop: 3, + fontSize: variables.fontSizeOnlyEmojis, + lineHeight: variables.fontSizeOnlyEmojisHeight, + }, + + profileEmojiText: { + fontSize: variables.fontSizeEmojiProfile, + lineHeight: variables.fontSizeEmojiProfileHeight, + top: 4, + }, +}; + +export default _.assign(baseStyles, styles); diff --git a/src/styles/styles/index.ios.js b/src/styles/styles/index.ios.js new file mode 100644 index 000000000000..1c88a7b5ae85 --- /dev/null +++ b/src/styles/styles/index.ios.js @@ -0,0 +1,47 @@ +import _ from 'lodash'; +import variables from '../variables'; +import baseStyles from './baseStyles'; +import themeColors from '../themes/default'; + +const styles = { + ...baseStyles.styles, + displayNameText: { + marginTop: 6, + }, + + emojiMessageText: { + position: 'relative', + fontSize: variables.fontSizeEmoji, + lineHeight: variables.fontSizeEmojiHeight, + }, + + inboxEmojiMessageText: { + marginTop: 1, + }, + + onlyEmojisText: { + marginTop: 1, + fontSize: variables.fontSizeOnlyEmojis, + lineHeight: variables.fontSizeOnlyEmojisHeight, + }, + + inboxMessageText: { + marginTop: 0, + }, + + profileEmojiText: { + top: 0, + fontSize: variables.fontSizeEmojiProfile, + lineHeight: variables.fontSizeEmojiProfileHeight, + }, + + chatItemMessageHeaderTimestamp: { + flexShrink: 0, + paddingTop: 0, + color: themeColors.textSupporting, + fontSize: variables.fontSizeSmall, + bottom: 2.5, + }, +}; + +export default _.assign(baseStyles, styles); diff --git a/src/styles/styles/index.js b/src/styles/styles/index.js new file mode 100644 index 000000000000..8cdaab8a7d11 --- /dev/null +++ b/src/styles/styles/index.js @@ -0,0 +1,3 @@ +import styles from './baseStyles'; + +export default styles; diff --git a/src/styles/variables.js b/src/styles/variables/baseVariables.js similarity index 97% rename from src/styles/variables.js rename to src/styles/variables/baseVariables.js index 00ac9cf6ae5e..4282b316b7e1 100644 --- a/src/styles/variables.js +++ b/src/styles/variables/baseVariables.js @@ -39,6 +39,10 @@ export default { defaultAvatarPreviewSize: 360, fontSizeOnlyEmojis: 30, fontSizeOnlyEmojisHeight: 35, + fontSizeEmojiHeight: 24, + fontSizeEmojiProfileHeight: 25, + fontSizeEmojiProfile: 21, + fontSizeEmoji: 19, fontSizeSmall: getValueUsingPixelRatio(11, 17), fontSizeExtraSmall: 9, fontSizeLabel: getValueUsingPixelRatio(13, 19), diff --git a/src/styles/variables/index.js b/src/styles/variables/index.js new file mode 100644 index 000000000000..49a16647bba5 --- /dev/null +++ b/src/styles/variables/index.js @@ -0,0 +1,3 @@ +import variables from './baseVariables'; + +export default variables; diff --git a/src/styles/variables/index.web.js b/src/styles/variables/index.web.js new file mode 100644 index 000000000000..5fa61a0b73d1 --- /dev/null +++ b/src/styles/variables/index.web.js @@ -0,0 +1,7 @@ +import variables from './baseVariables'; + +export default { + ...variables, + fontSizeEmojiHeight: 26, + fontSizeEmojiProfileHeight: 29, +};