diff --git a/src/CONST.ts b/src/CONST.ts index 82f6e2e7bb6a..d929a01e030a 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2305,8 +2305,8 @@ const CONST = { // eslint-disable-next-line max-len, no-misleading-character-class EMOJI: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu, - // eslint-disable-next-line max-len, no-misleading-character-class - EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/gu, + // eslint-disable-next-line max-len, no-misleading-character-class, no-empty-character-class + EMOJIS: /[\p{Extended_Pictographic}](\u200D[\p{Extended_Pictographic}]|[\u{1F3FB}-\u{1F3FF}]|[\u{E0020}-\u{E007F}]|\uFE0F|\u20E3)*|[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3/du, // eslint-disable-next-line max-len, no-misleading-character-class EMOJI_SKIN_TONES: /[\u{1f3fb}-\u{1f3ff}]/gu, diff --git a/src/components/Composer/index.native.tsx b/src/components/Composer/index.native.tsx index d5dc4c12afc0..9c380eb336c3 100644 --- a/src/components/Composer/index.native.tsx +++ b/src/components/Composer/index.native.tsx @@ -39,9 +39,9 @@ function Composer( ) { const textInput = useRef<AnimatedMarkdownTextInputRef | null>(null); const {isFocused, shouldResetFocusRef} = useResetComposerFocus(textInput); - const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const doesTextContainOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); - const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const markdownStyle = useMarkdownStyle(doesTextContainOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -73,7 +73,10 @@ function Composer( }, [shouldClear, onClear]); const maxHeightStyle = useMemo(() => StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), [StyleUtils, isComposerFullSize, maxLines]); - const composerStyle = useMemo(() => StyleSheet.flatten([style, textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}]), [style, textContainsOnlyEmojis, styles]); + const composerStyle = useMemo( + () => StyleSheet.flatten([style, doesTextContainOnlyEmojis ? styles.onlyEmojisTextLineHeight : styles.emojisWithTextLineHeight]), + [style, doesTextContainOnlyEmojis, styles], + ); return ( <RNMarkdownTextInput diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 3889c8597843..4e192e908458 100755 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -80,10 +80,10 @@ function Composer( }: ComposerProps, ref: ForwardedRef<TextInput | HTMLInputElement>, ) { - const textContainsOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); + const doesTextContainOnlyEmojis = useMemo(() => EmojiUtils.containsOnlyEmojis(value ?? ''), [value]); const theme = useTheme(); const styles = useThemeStyles(); - const markdownStyle = useMarkdownStyle(value, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); + const markdownStyle = useMarkdownStyle(doesTextContainOnlyEmojis, !isGroupPolicyReport ? excludeReportMentionStyle : excludeNoStyles); const StyleUtils = useStyleUtils(); const textRef = useRef<HTMLElement & RNText>(null); const textInput = useRef<AnimatedMarkdownTextInputRef | null>(null); @@ -345,10 +345,9 @@ function Composer( scrollStyleMemo, StyleUtils.getComposerMaxHeightStyle(maxLines, isComposerFullSize), isComposerFullSize ? ({height: '100%', maxHeight: 'none' as DimensionValue} as TextStyle) : undefined, - textContainsOnlyEmojis ? styles.onlyEmojisTextLineHeight : {}, ], - [style, styles.rtlTextRenderForSafari, styles.onlyEmojisTextLineHeight, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize, textContainsOnlyEmojis], + [style, styles.rtlTextRenderForSafari, scrollStyleMemo, StyleUtils, maxLines, isComposerFullSize], ); return ( diff --git a/src/components/InlineCodeBlock/WrappedText.tsx b/src/components/InlineCodeBlock/WrappedText.tsx index 3045c15c471b..e77f5dba4eda 100644 --- a/src/components/InlineCodeBlock/WrappedText.tsx +++ b/src/components/InlineCodeBlock/WrappedText.tsx @@ -37,7 +37,8 @@ function getTextMatrix(text: string): string[][] { * Validates if the text contains any emoji */ function containsEmoji(text: string): boolean { - return CONST.REGEX.EMOJIS.test(text); + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + return emojisRegex.test(text); } function WrappedText({children, wordStyles, textStyles}: WrappedTextProps) { diff --git a/src/components/SelectionList/Search/UserInfoCell.tsx b/src/components/SelectionList/Search/UserInfoCell.tsx index 2a5a7da51979..d89220935dc5 100644 --- a/src/components/SelectionList/Search/UserInfoCell.tsx +++ b/src/components/SelectionList/Search/UserInfoCell.tsx @@ -32,7 +32,7 @@ function UserInfoCell({participant, displayName}: UserInfoCellProps) { /> <Text numberOfLines={1} - style={[isLargeScreenWidth ? styles.themeTextColor : [styles.textMicro, styles.textBold], styles.flexShrink1]} + style={[isLargeScreenWidth ? styles.themeTextColor : styles.textMicroBold, styles.flexShrink1]} > {displayName} </Text> diff --git a/src/components/SelectionList/UserListItem.tsx b/src/components/SelectionList/UserListItem.tsx index 104990cf479c..119ebd9fd535 100644 --- a/src/components/SelectionList/UserListItem.tsx +++ b/src/components/SelectionList/UserListItem.tsx @@ -12,6 +12,7 @@ import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; import type {ListItem, UserListItemProps} from './types'; @@ -131,6 +132,7 @@ function UserListItem<TItem extends ListItem>({ {!!item.alternateText && ( <TextWithTooltip shouldShowTooltip={showTooltip} + emojiFontSize={variables.emojiSizeSmall} text={Str.removeSMSDomain(item.alternateText ?? '')} style={[styles.textLabelSupporting, styles.lh16, styles.pre]} /> diff --git a/src/components/TextInput/BaseTextInput/index.tsx b/src/components/TextInput/BaseTextInput/index.tsx index 685d54d86765..7764bdad0a60 100644 --- a/src/components/TextInput/BaseTextInput/index.tsx +++ b/src/components/TextInput/BaseTextInput/index.tsx @@ -386,6 +386,7 @@ function BaseTextInput( // Add disabled color theme when field is not editable. inputProps.disabled && styles.textInputDisabled, styles.pointerEventsAuto, + isMarkdownEnabled ? {lineHeight: variables.lineHeightMarkdownEnabledInput} : null, ]} multiline={isMultiline} maxLength={maxLength} diff --git a/src/components/TextWithTooltip/index.native.tsx b/src/components/TextWithTooltip/index.android.tsx similarity index 100% rename from src/components/TextWithTooltip/index.native.tsx rename to src/components/TextWithTooltip/index.android.tsx diff --git a/src/components/TextWithTooltip/index.ios.tsx b/src/components/TextWithTooltip/index.ios.tsx new file mode 100644 index 000000000000..f7c325b6d14e --- /dev/null +++ b/src/components/TextWithTooltip/index.ios.tsx @@ -0,0 +1,31 @@ +import React, {useMemo} from 'react'; +import Text from '@components/Text'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import CONST from '@src/CONST'; +import type TextWithTooltipProps from './types'; + +function TextWithTooltip({text, style, emojiFontSize, numberOfLines = 1}: TextWithTooltipProps) { + const processedTextArray = useMemo(() => { + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + const doesTextContainEmojis = !!(emojiFontSize && emojisRegex.test(text)); + + if (!doesTextContainEmojis) { + return []; + } + + return EmojiUtils.splitTextWithEmojis(text); + }, [emojiFontSize, text]); + + return ( + <Text + style={style} + numberOfLines={numberOfLines} + > + {processedTextArray.length !== 0 ? processedTextArray.map(({text: textItem, isEmoji}) => (isEmoji ? <Text style={{fontSize: emojiFontSize}}>{textItem}</Text> : textItem)) : text} + </Text> + ); +} + +TextWithTooltip.displayName = 'TextWithTooltip'; + +export default TextWithTooltip; diff --git a/src/components/TextWithTooltip/types.ts b/src/components/TextWithTooltip/types.ts index 4705e2b69a68..1df5af02b67a 100644 --- a/src/components/TextWithTooltip/types.ts +++ b/src/components/TextWithTooltip/types.ts @@ -12,6 +12,9 @@ type TextWithTooltipProps = { /** Custom number of lines for text wrapping */ numberOfLines?: number; + + /** Emoji font size */ + emojiFontSize?: number; }; export default TextWithTooltipProps; diff --git a/src/hooks/useMarkdownStyle.ts b/src/hooks/useMarkdownStyle.ts index c7e9bf2c0218..b3b2b33ae71a 100644 --- a/src/hooks/useMarkdownStyle.ts +++ b/src/hooks/useMarkdownStyle.ts @@ -1,16 +1,13 @@ import type {MarkdownStyle} from '@expensify/react-native-live-markdown'; import {useMemo} from 'react'; -import {containsOnlyEmojis} from '@libs/EmojiUtils'; import FontUtils from '@styles/utils/FontUtils'; import variables from '@styles/variables'; import useTheme from './useTheme'; const defaultEmptyArray: Array<keyof MarkdownStyle> = []; -function useMarkdownStyle(message: string | null = null, excludeStyles: Array<keyof MarkdownStyle> = defaultEmptyArray): MarkdownStyle { +function useMarkdownStyle(doesInputContainOnlyEmojis?: boolean, excludeStyles: Array<keyof MarkdownStyle> = defaultEmptyArray): MarkdownStyle { const theme = useTheme(); - const hasMessageOnlyEmojis = message != null && message.length > 0 && containsOnlyEmojis(message); - const emojiFontSize = hasMessageOnlyEmojis ? variables.fontSizeOnlyEmojis : variables.fontSizeNormal; // this map is used to reset the styles that are not needed - passing undefined value can break the native side const nonStylingDefaultValues: Record<string, string | number> = useMemo( @@ -37,7 +34,7 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array<ke fontSize: variables.fontSizeLarge, }, emoji: { - fontSize: emojiFontSize, + fontSize: doesInputContainOnlyEmojis ? variables.fontSizeEmojisOnlyComposer : variables.fontSizeEmojisWithinText, }, blockquote: { borderColor: theme.border, @@ -89,7 +86,7 @@ function useMarkdownStyle(message: string | null = null, excludeStyles: Array<ke } return styling; - }, [theme, emojiFontSize, excludeStyles, nonStylingDefaultValues]); + }, [theme, doesInputContainOnlyEmojis, excludeStyles, nonStylingDefaultValues]); return markdownStyle; } diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 4be2edc5c128..66a51099a5e9 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -148,7 +148,8 @@ function trimEmojiUnicode(emojiCode: string): string { */ function isFirstLetterEmoji(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + const match = trimmedMessage.match(emojisRegex); if (!match) { return false; @@ -162,7 +163,8 @@ function isFirstLetterEmoji(message: string): boolean { */ function containsOnlyEmojis(message: string): boolean { const trimmedMessage = Str.replaceAll(message.replace(/ /g, ''), '\n', ''); - const match = trimmedMessage.match(CONST.REGEX.EMOJIS); + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + const match = trimmedMessage.match(emojisRegex); if (!match) { return false; @@ -285,7 +287,8 @@ function extractEmojis(text: string): Emoji[] { } // Parse Emojis including skin tones - Eg: ['👩🏻', '👩🏻', '👩🏼', '👩🏻', '👩🏼', '👩'] - const parsedEmojis = text.match(CONST.REGEX.EMOJIS); + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + const parsedEmojis = text.match(emojisRegex); if (!parsedEmojis) { return []; @@ -586,7 +589,57 @@ function getSpacersIndexes(allEmojis: EmojiPickerList): number[] { return spacersIndexes; } -export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem}; +type TextWithEmoji = { + text: string; + isEmoji: boolean; +}; + +function splitTextWithEmojis(text = ''): TextWithEmoji[] { + if (!text) { + return []; + } + + // The regex needs to be cloned because `exec()` is a stateful operation and maintains the state inside + // the regex variable itself, so we must have a independent instance for each function's call. + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + + const splitText: TextWithEmoji[] = []; + let regexResult: RegExpExecArray | null; + let lastMatchIndexEnd = 0; + do { + regexResult = emojisRegex.exec(text); + + if (regexResult?.indices) { + const matchIndexStart = regexResult.indices[0][0]; + const matchIndexEnd = regexResult.indices[0][1]; + + if (matchIndexStart > lastMatchIndexEnd) { + splitText.push({ + text: text.slice(lastMatchIndexEnd, matchIndexStart), + isEmoji: false, + }); + } + + splitText.push({ + text: text.slice(matchIndexStart, matchIndexEnd), + isEmoji: true, + }); + + lastMatchIndexEnd = matchIndexEnd; + } + } while (regexResult !== null); + + if (lastMatchIndexEnd < text.length) { + splitText.push({ + text: text.slice(lastMatchIndexEnd, text.length), + isEmoji: false, + }); + } + + return splitText; +} + +export type {HeaderIndice, EmojiPickerList, EmojiSpacer, EmojiPickerListItem, TextWithEmoji}; export { findEmojiByName, @@ -611,4 +664,5 @@ export { hasAccountIDEmojiReacted, getRemovedSkinToneEmoji, getSpacersIndexes, + splitTextWithEmojis, }; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 64cae69e0b15..cb2304fc46a4 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -41,7 +41,9 @@ function isValidAddress(value: FormValue): boolean { return false; } - if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(CONST.REGEX.EMOJIS)) { + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + + if (!CONST.REGEX.ANY_VALUE.test(value) || value.match(emojisRegex)) { return false; } @@ -331,7 +333,8 @@ function isValidRoutingNumber(routingNumber: string): boolean { * Checks that the provided name doesn't contain any emojis */ function isValidCompanyName(name: string) { - return !name.match(CONST.REGEX.EMOJIS); + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + return !name.match(emojisRegex); } function isValidReportName(name: string) { diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx index 088ee9eb2b6e..7c0974f74a4b 100644 --- a/src/pages/home/report/ReportActionItemFragment.tsx +++ b/src/pages/home/report/ReportActionItemFragment.tsx @@ -2,7 +2,6 @@ import React, {memo} from 'react'; import type {StyleProp, TextStyle} from 'react-native'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +14,7 @@ import type {Message} from '@src/types/onyx/ReportAction'; import type ReportActionName from '@src/types/onyx/ReportActionName'; import AttachmentCommentFragment from './comment/AttachmentCommentFragment'; import TextCommentFragment from './comment/TextCommentFragment'; +import ReportActionItemMessageHeaderSender from './ReportActionItemMessageHeaderSender'; type ReportActionItemFragmentProps = { /** Users accountID */ @@ -159,18 +159,13 @@ function ReportActionItemFragment({ } return ( - <UserDetailsTooltip + <ReportActionItemMessageHeaderSender accountID={accountID} delegateAccountID={delegateAccountID} - icon={actorIcon} - > - <Text - numberOfLines={isSingleLine ? 1 : undefined} - style={[styles.chatItemMessageHeaderSender, isSingleLine ? styles.pre : styles.preWrap]} - > - {fragment?.text} - </Text> - </UserDetailsTooltip> + fragmentText={fragment.text} + actorIcon={actorIcon} + isSingleLine={isSingleLine} + /> ); } case 'LINK': diff --git a/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx b/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx new file mode 100644 index 000000000000..e31f8c55947f --- /dev/null +++ b/src/pages/home/report/ReportActionItemMessageHeaderSender.tsx @@ -0,0 +1,58 @@ +import React, {useMemo} from 'react'; +import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import CONST from '@src/CONST'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; + +type ReportActionItemMessageHeaderSenderProps = { + /** Text to display */ + fragmentText: string; + + /** Users accountID */ + accountID: number; + + /** Should this fragment be contained in a single line? */ + isSingleLine?: boolean; + + /** The accountID of the copilot who took this action on behalf of the user */ + delegateAccountID?: number; + + /** Actor icon */ + actorIcon?: OnyxCommon.Icon; +}; + +function ReportActionItemMessageHeaderSender({fragmentText, accountID, delegateAccountID, actorIcon, isSingleLine}: ReportActionItemMessageHeaderSenderProps) { + const styles = useThemeStyles(); + + const processedTextArray = useMemo(() => { + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); + const doesTextContainEmojis = emojisRegex.test(fragmentText); + + if (!doesTextContainEmojis) { + return []; + } + + return EmojiUtils.splitTextWithEmojis(fragmentText); + }, [fragmentText]); + + return ( + <UserDetailsTooltip + accountID={accountID} + delegateAccountID={delegateAccountID} + icon={actorIcon} + > + <Text + numberOfLines={isSingleLine ? 1 : undefined} + style={[styles.chatItemMessageHeaderSender, isSingleLine ? styles.pre : styles.preWrap]} + > + {processedTextArray.length !== 0 ? processedTextArray.map(({text, isEmoji}) => (isEmoji ? <Text style={styles.emojisWithinDisplayName}>{text}</Text> : text)) : fragmentText} + </Text> + </UserDetailsTooltip> + ); +} + +ReportActionItemMessageHeaderSender.displayName = 'ReportActionItemMessageHeaderSender'; + +export default ReportActionItemMessageHeaderSender; diff --git a/src/pages/home/report/comment/TextCommentFragment.tsx b/src/pages/home/report/comment/TextCommentFragment.tsx index 68827de96172..3474881e3fee 100644 --- a/src/pages/home/report/comment/TextCommentFragment.tsx +++ b/src/pages/home/report/comment/TextCommentFragment.tsx @@ -17,6 +17,7 @@ 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 */ @@ -44,19 +45,19 @@ type TextCommentFragmentProps = { function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, source, style, displayAsGroup, iouMessage = ''}: TextCommentFragmentProps) { const theme = useTheme(); const styles = useThemeStyles(); - const {html = '', text} = fragment ?? {}; + const {html = '', text = ''} = fragment ?? {}; const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); // If the only difference between fragment.text and fragment.html is <br /> 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 <br /> tag - const containsOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); - if (!shouldRenderAsText(html, text ?? '') && !(containsOnlyEmojis && styleAsDeleted)) { - const editedTag = fragment?.isEdited ? `<edited ${styleAsDeleted ? 'deleted' : ''} ${containsOnlyEmojis ? 'islarge' : ''}></edited>` : ''; + const doesTextContainOnlyEmojis = EmojiUtils.containsOnlyEmojis(text ?? ''); + if (!shouldRenderAsText(html, text ?? '') && !(doesTextContainOnlyEmojis && styleAsDeleted)) { + const editedTag = fragment?.isEdited ? `<edited ${styleAsDeleted ? 'deleted' : ''} ${doesTextContainOnlyEmojis ? 'islarge' : ''}></edited>` : ''; const htmlWithDeletedTag = styleAsDeleted ? `<del>${html}</del>` : html; - const htmlContent = containsOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '<emoji>', '<emoji islarge>') : htmlWithDeletedTag; + const htmlContent = doesTextContainOnlyEmojis ? Str.replaceAll(htmlWithDeletedTag, '<emoji>', '<emoji islarge>') : htmlWithDeletedTag; let htmlWithTag = editedTag ? `${htmlContent}${editedTag}` : htmlContent; if (styleAsMuted) { @@ -72,40 +73,55 @@ function TextCommentFragment({fragment, styleAsDeleted, styleAsMuted = false, so } const message = isEmpty(iouMessage) ? text : iouMessage; + const emojisRegex = new RegExp(CONST.REGEX.EMOJIS, CONST.REGEX.EMOJIS.flags.concat('g')); return ( - <Text style={[containsOnlyEmojis && styles.onlyEmojisText, styles.ltr, style]}> + <Text style={[doesTextContainOnlyEmojis && styles.onlyEmojisText, styles.ltr, style]}> <ZeroWidthView text={text} displayAsGroup={displayAsGroup} /> - <Text - style={[ - containsOnlyEmojis ? styles.onlyEmojisText : undefined, - styles.ltr, - style, - styleAsDeleted ? styles.offlineFeedback.deleted : undefined, - styleAsMuted ? styles.colorMuted : undefined, - !DeviceCapabilities.canUseTouchScreen() || !shouldUseNarrowLayout ? styles.userSelectText : styles.userSelectNone, - ]} - > - {convertToLTR(message ?? '')} - </Text> - {fragment?.isEdited && ( + {emojisRegex.test(message ?? '') && !doesTextContainOnlyEmojis ? ( + <TextWithEmojiFragment + message={message} + passedStyles={style} + styleAsDeleted={styleAsDeleted} + styleAsMuted={styleAsMuted} + isEdited={fragment?.isEdited} + hasEmojisOnly={doesTextContainOnlyEmojis} + /> + ) : ( <> <Text - style={[containsOnlyEmojis && styles.onlyEmojisTextLineHeight, styles.userSelectNone]} - dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + style={[ + styles.enhancedLineHeight, + doesTextContainOnlyEmojis ? styles.onlyEmojisText : undefined, + styles.ltr, + style, + styleAsDeleted ? styles.offlineFeedback.deleted : undefined, + styleAsMuted ? styles.colorMuted : undefined, + !DeviceCapabilities.canUseTouchScreen() || !shouldUseNarrowLayout ? styles.userSelectText : styles.userSelectNone, + ]} > - {' '} - </Text> - <Text - fontSize={variables.fontSizeSmall} - color={theme.textSupporting} - style={[styles.editedLabelStyles, styleAsDeleted && styles.offlineFeedback.deleted, style]} - > - {translate('reportActionCompose.edited')} + {convertToLTR(message ?? '')} </Text> + {!!fragment?.isEdited && ( + <> + <Text + style={[doesTextContainOnlyEmojis && styles.onlyEmojisTextLineHeight, styles.userSelectNone]} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + > + {' '} + </Text> + <Text + fontSize={variables.fontSizeSmall} + color={theme.textSupporting} + style={[styles.editedLabelStyles, styleAsDeleted && styles.offlineFeedback.deleted, style]} + > + {translate('reportActionCompose.edited')} + </Text> + </> + )} </> )} </Text> diff --git a/src/pages/home/report/comment/TextWithEmojiFragment.tsx b/src/pages/home/report/comment/TextWithEmojiFragment.tsx new file mode 100644 index 000000000000..cc0da8f81fb0 --- /dev/null +++ b/src/pages/home/report/comment/TextWithEmojiFragment.tsx @@ -0,0 +1,85 @@ +import React, {useMemo} from 'react'; +import type {StyleProp, TextStyle} from 'react-native'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as DeviceCapabilities from '@libs/DeviceCapabilities'; +import * as EmojiUtils from '@libs/EmojiUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; + +type TextWithEmojiFragmentProps = { + /** The message to be displayed */ + message: string; + + /** Additional styles to add after local styles. */ + passedStyles?: StyleProp<TextStyle>; + + /** Should this message fragment be styled as deleted? */ + styleAsDeleted?: boolean; + + /** Should this message fragment be styled as muted? */ + styleAsMuted?: boolean; + + /** Should "(edited)" suffix be rendered? */ + isEdited?: boolean; + + /** Does message contain only emojis? */ + hasEmojisOnly?: boolean; +}; + +function TextWithEmojiFragment({message, passedStyles, styleAsDeleted, styleAsMuted, isEdited, hasEmojisOnly}: TextWithEmojiFragmentProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const theme = useTheme(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const processedTextArray = useMemo(() => EmojiUtils.splitTextWithEmojis(message), [message]); + + return ( + <Text style={[hasEmojisOnly ? styles.onlyEmojisText : undefined, styles.ltr, passedStyles]}> + {processedTextArray.map(({text, isEmoji}) => + isEmoji ? ( + <Text style={[hasEmojisOnly ? styles.onlyEmojisText : styles.emojisWithinText]}>{text}</Text> + ) : ( + <Text + style={[ + styles.ltr, + passedStyles, + styleAsDeleted ? styles.offlineFeedback.deleted : undefined, + styleAsMuted ? styles.colorMuted : undefined, + !DeviceCapabilities.canUseTouchScreen() || !shouldUseNarrowLayout ? styles.userSelectText : styles.userSelectNone, + hasEmojisOnly ? styles.onlyEmojisText : styles.enhancedLineHeight, + ]} + > + {text} + </Text> + ), + )} + + {isEdited && ( + <> + <Text + style={[hasEmojisOnly && styles.onlyEmojisTextLineHeight, styles.userSelectNone]} + dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} + > + {' '} + </Text> + <Text + fontSize={variables.fontSizeSmall} + color={theme.textSupporting} + style={[styles.editedLabelStyles, styleAsDeleted && styles.offlineFeedback.deleted, passedStyles]} + > + {translate('reportActionCompose.edited')} + </Text> + </> + )} + </Text> + ); +} + +TextWithEmojiFragment.displayName = 'TextWithEmojiFragment'; + +export default TextWithEmojiFragment; diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 39e3dc9a8989..b208e63909a8 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -29,6 +29,8 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {splitTextWithEmojis} from '@libs/EmojiUtils'; +import type {TextWithEmoji} from '@libs/EmojiUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as SubscriptionUtils from '@libs/SubscriptionUtils'; import * as UserUtils from '@libs/UserUtils'; @@ -364,9 +366,16 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa const generalMenuItems = useMemo(() => getMenuItemsSection(generalMenuItemsData), [generalMenuItemsData, getMenuItemsSection]); const workspaceMenuItems = useMemo(() => getMenuItemsSection(workspaceMenuItemsData), [workspaceMenuItemsData, getMenuItemsSection]); - const currentUserDetails = currentUserPersonalDetails; - const avatarURL = currentUserDetails?.avatar ?? ''; - const accountID = currentUserDetails?.accountID ?? '-1'; + const avatarURL = currentUserPersonalDetails?.avatar ?? ''; + const accountID = currentUserPersonalDetails?.accountID ?? '-1'; + + const processedTextArray: TextWithEmoji[] = useMemo(() => { + const doesUsernameContainEmojis = CONST.REGEX.EMOJIS.test(currentUserPersonalDetails?.displayName ?? ''); + if (!doesUsernameContainEmojis) { + return []; + } + return splitTextWithEmojis(currentUserPersonalDetails?.displayName ?? ''); + }, [currentUserPersonalDetails?.displayName]); const headerContent = ( <View style={[styles.avatarSectionWrapperSettings, styles.justifyContentCenter, styles.ph5, styles.pb5]}> @@ -416,7 +425,7 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa </View> <View style={[styles.mb3, styles.w100]}> <AvatarWithImagePicker - isUsingDefaultAvatar={UserUtils.isDefaultAvatar(currentUserDetails?.avatar ?? '')} + isUsingDefaultAvatar={UserUtils.isDefaultAvatar(currentUserPersonalDetails?.avatar ?? '')} source={avatarURL} avatarID={accountID} onImageSelected={PersonalDetails.updateAvatar} @@ -429,18 +438,27 @@ function InitialSettingsPage({session, userWallet, bankAccountList, fundList, wa onErrorClose={PersonalDetails.clearAvatarErrors} onViewPhotoPress={() => Navigation.navigate(ROUTES.PROFILE_AVATAR.getRoute(String(accountID)))} previewSource={UserUtils.getFullSizeAvatar(avatarURL, accountID)} - originalFileName={currentUserDetails.originalFileName} + originalFileName={currentUserPersonalDetails.originalFileName} headerTitle={translate('profilePage.profileAvatar')} - fallbackIcon={currentUserDetails?.fallbackIcon} + fallbackIcon={currentUserPersonalDetails?.fallbackIcon} editIconStyle={styles.smallEditIconAccount} /> </View> - <Text - style={[styles.textHeadline, styles.pre, styles.textAlignCenter]} - numberOfLines={1} - > - {currentUserPersonalDetails.displayName ? currentUserPersonalDetails.displayName : formatPhoneNumber(session?.email ?? '')} - </Text> + {processedTextArray.length !== 0 ? ( + <Text + style={[styles.textHeadline, styles.pre, styles.textAlignCenter]} + numberOfLines={1} + > + {processedTextArray.map(({text, isEmoji}) => (isEmoji ? <Text style={styles.initialSettingsUsernameEmoji}>{text}</Text> : text))} + </Text> + ) : ( + <Text + style={[styles.textHeadline, styles.pre, styles.textAlignCenter]} + numberOfLines={1} + > + {currentUserPersonalDetails.displayName ? currentUserPersonalDetails.displayName : formatPhoneNumber(session?.email ?? '')} + </Text> + )} {!!currentUserPersonalDetails.displayName && ( <Text style={[styles.textLabelSupporting, styles.mt1, styles.w100, styles.textAlignCenter]} diff --git a/src/pages/settings/Profile/DisplayNamePage.tsx b/src/pages/settings/Profile/DisplayNamePage.tsx index 90f7ca3abbd6..35f5d77cb124 100644 --- a/src/pages/settings/Profile/DisplayNamePage.tsx +++ b/src/pages/settings/Profile/DisplayNamePage.tsx @@ -102,6 +102,7 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp role={CONST.ROLE.PRESENTATION} defaultValue={currentUserDetails.firstName ?? ''} spellCheck={false} + isMarkdownEnabled /> </View> <View> @@ -114,6 +115,7 @@ function DisplayNamePage({isLoadingApp = true, currentUserPersonalDetails}: Disp role={CONST.ROLE.PRESENTATION} defaultValue={currentUserDetails.lastName ?? ''} spellCheck={false} + isMarkdownEnabled /> </View> </FormProvider> diff --git a/src/styles/index.ts b/src/styles/index.ts index 05ec2649a030..eaba71481b6f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -409,7 +409,7 @@ const styles = (theme: ThemeColors) => color: theme.text, ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeSmall, - lineHeight: variables.lineHeightSmall, + lineHeight: variables.lineHeightNormal, }, textMicroSupporting: { @@ -1706,7 +1706,34 @@ const styles = (theme: ThemeColors) => }, onlyEmojisTextLineHeight: { - lineHeight: variables.fontSizeOnlyEmojisHeight, + lineHeight: variables.lineHeightEmojisOnlyComposer, + }, + + emojisWithTextLineHeight: { + lineHeight: variables.lineHeightEmojisWithTextComposer, + }, + + emojisWithinText: { + fontSize: variables.fontSizeEmojisWithinText, + lineHeight: variables.lineHeightComment, + }, + + emojisWithinDisplayName: { + fontSize: variables.fontSizeEmojisWithinText, + lineHeight: variables.lineHeightDisplayName, + }, + + emojisOnlyComposer: { + paddingTop: variables.emojiOnlyComposerPaddingTop, + paddingBottom: variables.emojiOnlyComposerPaddingBottom, + }, + + enhancedLineHeight: { + lineHeight: variables.lineHeightComment, + }, + + initialSettingsUsernameEmoji: { + fontSize: variables.fontSizeUsernameEmoji, }, createMenuPositionSidebar: (windowHeight: number) => @@ -2029,7 +2056,7 @@ const styles = (theme: ThemeColors) => color: theme.heading, ...FontUtils.fontFamily.platform.EXP_NEUE_BOLD, fontSize: variables.fontSizeNormal, - lineHeight: variables.lineHeightXLarge, + lineHeight: variables.lineHeightXXLarge, ...wordBreak.breakWord, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e0720ad1d836..4d6eac4f1a13 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -48,8 +48,6 @@ export default { defaultAvatarPreviewSize: 360, fabBottom: 25, breadcrumbsFontSize: getValueUsingPixelRatio(19, 32), - fontSizeOnlyEmojis: 30, - fontSizeOnlyEmojisHeight: 35, fontSizeSmall: getValueUsingPixelRatio(11, 17), fontSizeExtraSmall: 9, fontSizeLabel: getValueUsingPixelRatio(13, 19), @@ -87,8 +85,6 @@ export default { sidebarAvatarSize: 28, iconHeader: 48, iconSection: 68, - emojiSize: 20, - emojiLineHeight: 28, iouAmountTextSize: 40, extraSmallMobileResponsiveWidthBreakpoint: 320, extraSmallMobileResponsiveHeightBreakpoint: 667, @@ -114,6 +110,9 @@ export default { lineHeightSizeh1: getValueUsingPixelRatio(28, 32), lineHeightSizeh2: getValueUsingPixelRatio(24, 28), lineHeightSignInHeroXSmall: getValueUsingPixelRatio(32, 37), + lineHeightComment: 24, + lineHeightDisplayName: 25, + lineHeightMarkdownEnabledInput: 18, inputHeight: getValueUsingPixelRatio(52, 72), inputHeightSmall: 28, formErrorLineHeight: getValueUsingPixelRatio(18, 23), @@ -213,6 +212,21 @@ export default { welcomeVideoDelay: 1000, explanationModalDelay: 2000, + // Emoji related variables + emojiSize: 20, + emojiSizeSmall: 12, + emojiLineHeight: 28, + fontSizeOnlyEmojis: 30, + fontSizeOnlyEmojisHeight: 35, + fontSizeEmojisWithinText: 19, + fontSizeEmojisOnlyComposer: 27, + fontSizeUsernameEmoji: 25, + lineHeightEmojisOnlyComposer: 32, + lineHeightEmojisWithTextComposer: 22, + emojiOnlyMarginTop: 5, + emojiOnlyComposerPaddingBottom: 0, + emojiOnlyComposerPaddingTop: 7, + // The height of the empty list is 14px (2px for borders and 12px for vertical padding) // This is calculated based on the values specified in the 'getGoogleListViewStyle' function of the 'StyleUtils' utility googleEmptyListViewHeight: 14,