diff --git a/package-lock.json b/package-lock.json index e50cff0dbf27..91dabc7e9c40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.35", + "@expensify/react-native-live-markdown": "0.1.44", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", @@ -3097,9 +3097,9 @@ } }, "node_modules/@expensify/react-native-live-markdown": { - "version": "0.1.35", - "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.35.tgz", - "integrity": "sha512-W0FFIiU/sT+AwIrIOUHiNAHYjODAkEdYsf75tfBbkA6v2byHPxUlbzaJrZEQc0HgbvtAfTf9iQQqGWjNqe4pog==", + "version": "0.1.44", + "resolved": "https://registry.npmjs.org/@expensify/react-native-live-markdown/-/react-native-live-markdown-0.1.44.tgz", + "integrity": "sha512-zTfWTXZk6VQpX2TMeDW6GL3R7VBUE6I1rGfUZjsfgMPQ/IMMy5UQkvnhfUm5LlFIqKLFMqY4uxyx2QaLE3t05g==", "engines": { "node": ">= 18.0.0" }, diff --git a/package.json b/package.json index d600f2dfc1bf..cdda43b4da78 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "dependencies": { "@dotlottie/react-player": "^1.6.3", - "@expensify/react-native-live-markdown": "0.1.35", + "@expensify/react-native-live-markdown": "0.1.44", "@expo/metro-runtime": "~3.1.1", "@formatjs/intl-datetimeformat": "^6.10.0", "@formatjs/intl-listformat": "^7.2.2", diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index 6691c068eb3a..605bdbe117ef 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -10,6 +10,7 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ComposerUtils from '@libs/ComposerUtils'; +import {containsOnlyEmojis} from '@libs/EmojiUtils'; import type {ComposerProps} from './types'; function Composer( @@ -27,14 +28,16 @@ function Composer( // user can read new chats without the keyboard in the way of the view. // On Android the selection prop is required on the TextInput but this prop has issues on IOS selection, + value, ...props }: ComposerProps, ref: ForwardedRef, ) { + const textContainsOnlyEmojis = containsOnlyEmojis(value ?? ''); const textInput = useRef(null); + const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis); const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -65,7 +68,7 @@ function Composer( }, [shouldClear, onClear]); const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); - const composerStyle = useMemo(() => StyleSheet.flatten(style), [style]); + const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? {lineHeight: 32} : null]), [style, textContainsOnlyEmojis]); return ( , ) { + const textContainsOnlyEmojis = containsOnlyEmojis(value ?? ''); const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(); + const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis); const StyleUtils = useStyleUtils(); const {windowWidth} = useWindowDimensions(); const textRef = useRef(null); @@ -360,9 +362,10 @@ function Composer( Browser.isMobileSafari() || Browser.isSafari() ? styles.rtlTextRenderForSafari : {}, scrollStyleMemo, isComposerFullSize ? ({height: '100%', maxHeight: 'none' as DimensionValue} as TextStyle) : undefined, + textContainsOnlyEmojis ? {paddingBottom: 0} : null, ], - [numberOfLines, scrollStyleMemo, styles.rtlTextRenderForSafari, style, StyleUtils, isComposerFullSize], + [style, StyleUtils, numberOfLines, isComposerFullSize, styles.rtlTextRenderForSafari, scrollStyleMemo, textContainsOnlyEmojis], ); return ( diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index e753218e8406..10a3b9cc3a99 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -4,7 +4,7 @@ import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import useTheme from './useTheme'; -function useMarkdownStyle(): MarkdownStyle { +function useMarkdownStyle(containsEmojisOnly?: boolean): MarkdownStyle { const theme = useTheme(); const markdownStyle = useMemo( @@ -29,6 +29,9 @@ function useMarkdownStyle(): MarkdownStyle { color: theme.text, backgroundColor: 'transparent', }, + emoji: { + fontSize: containsEmojisOnly ? 27 : 19, + }, pre: { fontFamily: FontUtils.fontFamily.platform.MONOSPACE, color: theme.text, @@ -43,7 +46,7 @@ function useMarkdownStyle(): MarkdownStyle { backgroundColor: theme.mentionBG, }, }), - [theme], + [theme, containsEmojisOnly], ); return markdownStyle; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 05f6fbd17503..79cf9861a86e 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -125,31 +125,14 @@ function isFirstLetterEmoji(message: string): boolean { * Validates that this message contains only emojis */ function containsOnlyEmojis(message: string): boolean { - const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); - - if (!match) { + if (!message) { return false; } + const splittedMessage = message.split(' '); + // @ts-expect-error -- comments contain only BMP characters and emojis so codePointAt will return number + const messageWithoutEmojis = splittedMessage.filter((char: string) => char && char.codePointAt(0) <= 0xffff); - const codes = []; - match.map((emoji) => - getEmojiUnicode(emoji) - .split(' ') - .map((code) => { - if (!(CONST.INVISIBLE_CODEPOINTS as readonly string[]).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 = [...trimmedMessage] - .map((char) => getEmojiUnicode(char)) - .filter((string) => string.length > 0 && !(CONST.INVISIBLE_CODEPOINTS as readonly string[]).includes(string)); - return codes.length === messageCodes.length; + return messageWithoutEmojis.length === 0; } /** @@ -568,6 +551,120 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] { return spacersIndexes; } +/** + * Split string into plain text and emojis array with attention to: + * Surrogate pairs (combined emojis including flags) + * Modifiers (different skin tones and emoji variations) + * @param text + */ + +// Surrogate pairs (combined emojis) +const highSurrogateStart = 0xd800; +const highSurrogateEnd = 0xdbff; +const lowSurrogateStart = 0xdc00; +const zeroWidthJoiner = 0x200d; + +// Regional indicator symbols (flags) +const regionalIndicatorStart = 0x1f1e6; // 1st letter of a two-letter country code +const regionalIndicatorEnd = 0x1f1ff; // Last letter of a two-letter country code + +// Fitzpatrick scale modifiers (skin tone) +const fitzpatrickScaleStart = 0x1f3fb; // Type 1 (represents light skin tone) +const fitzpatrickScaleEnd = 0x1f3ff; // Type 6 (represents dark skin tone) + +// Variation selectors (specific variations in the presentation of other characters) +const variationModifierStart = 0xfe00; // Request text presentation of emoji +const variationModifierEnd = 0xfe0f; // Indicate that the character should be displayed as an emoji + +const codePointFromSurrogatePair = (pair: string) => { + const highOffset = pair.charCodeAt(0) - highSurrogateStart; + const lowOffset = pair.charCodeAt(1) - lowSurrogateStart; + // eslint-disable-next-line no-bitwise + return (highOffset << 10) + lowOffset + 0x10000; +}; + +const isZeroWidthJoiner = (text: string) => text?.charCodeAt(0) === zeroWidthJoiner; +const isWithinInclusiveRange = (value: number, lower: number, upper: number) => value >= lower && value <= upper; +const isFirstOfSurrogatePair = (text: string) => isWithinInclusiveRange(text?.[0].charCodeAt(0), highSurrogateStart, highSurrogateEnd); +const isRegionalIndicator = (text: string) => isWithinInclusiveRange(codePointFromSurrogatePair(text), regionalIndicatorStart, regionalIndicatorEnd); +const isFitzpatrickModifier = (text: string) => isWithinInclusiveRange(codePointFromSurrogatePair(text), fitzpatrickScaleStart, fitzpatrickScaleEnd); +const isVariationSelector = (text: string) => isWithinInclusiveRange(text?.charCodeAt(0), variationModifierStart, variationModifierEnd); + +// Define how many code units make up the character +const nextUnits = (i: number, text: string) => { + const current = text[i]; + // If a value at index is not part of a surrogate pair, or it is at the end take value at i + if (!isFirstOfSurrogatePair(current) || i === text.length - 1) { + return 1; + } + + const currentPair = current + text[i + 1]; + const nextPair = text.substring(i + 2, i + 5); + + if (isRegionalIndicator(currentPair) && isRegionalIndicator(nextPair)) { + return 4; // Flags (combination of 2 regional indicators) + } + + if (isFitzpatrickModifier(nextPair)) { + return 4; // Skin tones + } + return 2; // Variations and non-BMP characters +}; + +const splitTextWithEmojis = (text: string): string[] => { + if (!text) { + return []; + } + + let tmpString = ''; + let i = 0; + let increment = 0; + const tmpResult: string[] = []; + const processedArray: string[] = []; + while (i < text.length) { + increment += nextUnits(i + increment, text); + if (isVariationSelector(text[i + increment])) { + increment++; + } + if (isZeroWidthJoiner(text[i + increment])) { + increment++; + // eslint-disable-next-line no-continue -- without continue we would separate surrogate pair + continue; + } + tmpResult.push(text.substring(i, i + increment)); + i += increment; + increment = 0; + } + + for (let j = 0; j <= tmpResult.length; j++) { + if (!tmpResult[j]?.codePointAt(0)) { + // eslint-disable-next-line no-continue -- prevent error for empty chars + continue; + } + if (tmpResult[j] === ' ') { + tmpString += tmpResult[j]; + processedArray.push(tmpString); + tmpString = ''; + // eslint-disable-next-line no-continue -- skip rest of the checks in current iteration + continue; + } + // @ts-expect-error -- comments contain only BMP characters and emojis so codePointAt will return number + if (tmpResult[j].codePointAt(0) <= 0xffff) { + // is BMP character + tmpString += tmpResult[j]; + if (j === tmpResult.length - 1) { + processedArray.push(tmpString); + } + } else { + processedArray.push(tmpString); + processedArray.push(tmpResult[j]); + tmpString = ''; + } + } + // remove empty characters from array + return processedArray.filter((item) => item); +}; + export { findEmojiByName, findEmojiByCode, @@ -592,4 +689,5 @@ export { hasAccountIDEmojiReacted, getRemovedSkinToneEmoji, getSpacersIndexes, + splitTextWithEmojis, }; diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx index 04391bb19cd5..5c2ee146f198 100644 --- a/src/pages/home/report/ReportActionItemFragment.tsx +++ b/src/pages/home/report/ReportActionItemFragment.tsx @@ -1,5 +1,6 @@ import React, {memo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; +import {View} from 'react-native'; import type {AvatarProps} from '@components/Avatar'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -8,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import convertToLTR from '@libs/convertToLTR'; +import {splitTextWithEmojis} from '@libs/EmojiUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -157,18 +159,31 @@ function ReportActionItemFragment({ ); } + const containEmoji = CONST.REGEX.EMOJIS.test(fragment.text); + let processedTextArray: string[] = []; + if (containEmoji) { + processedTextArray = splitTextWithEmojis(fragment.text); + } return ( - - {fragment.text} - + {containEmoji ? ( + + {processedTextArray.map((word: string) => + CONST.REGEX.EMOJIS.test(word) ? {word} : {word}, + )} + + ) : ( + + {fragment.text} + + )} ); } diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 7ff413f554b8..98e592998807 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -8,15 +8,15 @@ import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import convertToLTR from '@libs/convertToLTR'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; -import * as EmojiUtils from '@libs/EmojiUtils'; +import {containsOnlyEmojis} from '@libs/EmojiUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {OriginalMessageSource} from '@src/types/onyx/OriginalMessage'; import type {Message} from '@src/types/onyx/ReportAction'; import RenderCommentHTML from './RenderCommentHTML'; import shouldRenderAsText from './shouldRenderAsText'; +import TextWithEmojiFragment from './TextWithEmojiFragment'; type TextCommentFragmentProps = { /** The reportAction's source */ @@ -51,12 +51,12 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so // If the only difference between fragment.text and fragment.html is
tags and emoji tag // on native, we render it as text, not as html // on other device, only render it as text if the only difference is
tag - const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text); - if (!shouldRenderAsText(html, text) && !(containsOnlyEmojis && styleAsDeleted)) { + const textContainsOnlyEmojis = containsOnlyEmojis(text); + if (!shouldRenderAsText(html, text) && !(textContainsOnlyEmojis && styleAsDeleted)) { const editedTag = fragment.isEdited ? `` : ''; const htmlWithDeletedTag = styleAsDeleted ? `${html}` : html; - const htmlContent = containsOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '', '') : htmlWithDeletedTag; + const htmlContent = textContainsOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '', '') : htmlWithDeletedTag; let htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent; if (styleAsMuted) { @@ -74,38 +74,53 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so const message = isEmpty(iouMessage) ? text : iouMessage; return ( - + - - {convertToLTR(message)} - - {fragment.isEdited && ( + {CONST.REGEX.EMOJIS.test(message) ? ( + + ) : ( <> - {' '} - - - {translate('reportActionCompose.edited')} + {message} + + {fragment.isEdited && ( + <> + + {' '} + + + {translate('reportActionCompose.edited')} + + + )} )} diff --git a/src/pages/home/report/comment/TextWithEmojiFragment.tsx b/src/pages/home/report/comment/TextWithEmojiFragment.tsx new file mode 100644 index 000000000000..c0ba268cac72 --- /dev/null +++ b/src/pages/home/report/comment/TextWithEmojiFragment.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import {splitTextWithEmojis} from '@libs/EmojiUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type ComponentProps = { + text: string; + passedStyles: StyleProp; + styleAsDeleted?: boolean; + styleAsMuted?: boolean; + isSmallScreenWidth?: boolean; + isEdited?: boolean; + emojisOnly?: boolean; +}; +function TextWithEmojiFragment({text, passedStyles, styleAsDeleted, styleAsMuted, isSmallScreenWidth, isEdited, emojisOnly}: ComponentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const theme = useTheme(); + const processedTextArray = splitTextWithEmojis(text); + + return ( + + {processedTextArray.map((word: string) => + CONST.REGEX.EMOJIS.test(word) ? ( + {word} + ) : ( + + {word} + + ), + )} + + {isEdited && ( + <> + + {' '} + + + {translate('reportActionCompose.edited')} + + + )} + + ); +} + +TextWithEmojiFragment.displayName = 'TextWithEmojiFragment'; + +export default TextWithEmojiFragment; diff --git a/src/styles/index.ts b/src/styles/index.ts index a736bc537fa6..f4a41cc366ce 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1571,15 +1571,28 @@ const styles = (theme: ThemeColors) => right: 0, } satisfies ViewStyle), + emojisAndTextWrapper: { + flexDirection: 'row', + alignItems: 'center', + }, + onlyEmojisText: { fontSize: variables.fontSizeOnlyEmojis, lineHeight: variables.fontSizeOnlyEmojisHeight, }, + emojisWithinText: { + fontSize: variables.fontSizeEmojisWithinText, + }, + onlyEmojisTextLineHeight: { lineHeight: variables.fontSizeOnlyEmojisHeight, }, + enhancedLineHeight: { + lineHeight: 23, + }, + createMenuPositionSidebar: (windowHeight: number) => ({ horizontal: 18, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ac04a436f72e..0cb099824476 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -49,6 +49,7 @@ export default { fabBottom: 25, breadcrumbsFontSize: getValueUsingPixelRatio(19, 32), fontSizeOnlyEmojis: 30, + fontSizeEmojisWithinText: 19, fontSizeOnlyEmojisHeight: 35, fontSizeSmall: getValueUsingPixelRatio(11, 17), fontSizeExtraSmall: 9,