Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enlarge emojis within text #37980

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions src/components/Composer/index.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<TextInput>,
) {
const textContainsOnlyEmojis = containsOnlyEmojis(value ?? '');
const textInput = useRef<AnimatedMarkdownTextInputRef | null>(null);
const markdownStyle = useMarkdownStyle(textContainsOnlyEmojis);
const {isFocused, shouldResetFocus} = useResetComposerFocus(textInput);
const theme = useTheme();
const markdownStyle = useMarkdownStyle();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();

Expand Down Expand Up @@ -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 (
<RNMarkdownTextInput
Expand All @@ -81,6 +84,7 @@ function Composer(
markdownStyle={markdownStyle}
autoFocus={autoFocus}
isFullComposerAvailable={isFullComposerAvailable}
value={value}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...props}
readOnly={isDisabled}
Expand Down
7 changes: 5 additions & 2 deletions src/components/Composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import useWindowDimensions from '@hooks/useWindowDimensions';
import * as Browser from '@libs/Browser';
import * as ComposerUtils from '@libs/ComposerUtils';
import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable';
import {containsOnlyEmojis} from '@libs/EmojiUtils';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
Expand Down Expand Up @@ -79,9 +80,10 @@ function Composer(
}: ComposerProps,
ref: ForwardedRef<TextInput | HTMLInputElement>,
) {
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<HTMLElement & RNText>(null);
Expand Down Expand Up @@ -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 (
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/useMarkdownStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -43,7 +46,7 @@ function useMarkdownStyle(): MarkdownStyle {
backgroundColor: theme.mentionBG,
},
}),
[theme],
[theme, containsEmojisOnly],
);

return markdownStyle;
Expand Down
142 changes: 120 additions & 22 deletions src/libs/EmojiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -592,4 +689,5 @@ export {
hasAccountIDEmojiReacted,
getRemovedSkinToneEmoji,
getSpacersIndexes,
splitTextWithEmojis,
};
27 changes: 21 additions & 6 deletions src/pages/home/report/ReportActionItemFragment.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -157,18 +159,31 @@ function ReportActionItemFragment({
);
}

const containEmoji = CONST.REGEX.EMOJIS.test(fragment.text);
let processedTextArray: string[] = [];
if (containEmoji) {
processedTextArray = splitTextWithEmojis(fragment.text);
}
return (
<UserDetailsTooltip
accountID={accountID}
delegateAccountID={delegateAccountID}
icon={actorIcon}
>
<Text
numberOfLines={isSingleLine ? 1 : undefined}
style={[styles.chatItemMessageHeaderSender, isSingleLine ? styles.pre : styles.preWrap]}
>
{fragment.text}
</Text>
{containEmoji ? (
<View style={styles.emojisAndTextWrapper}>
{processedTextArray.map((word: string) =>
CONST.REGEX.EMOJIS.test(word) ? <Text style={styles.emojisWithinText}>{word}</Text> : <Text style={styles.chatItemMessageHeaderSender}>{word}</Text>,
)}
</View>
) : (
<Text
numberOfLines={isSingleLine ? 1 : undefined}
style={[styles.chatItemMessageHeaderSender, isSingleLine ? styles.pre : styles.preWrap]}
>
{fragment.text}
</Text>
)}
</UserDetailsTooltip>
);
}
Expand Down
Loading
Loading