diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts index aade4e557a64..3c2849b41e0a 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -1,3 +1,5 @@ +import getOperatingSystem from '@libs/getOperatingSystem'; +import CONST from '@src/CONST'; import emojis from './common'; import enEmojis from './en'; import esEmojis from './es'; @@ -31,5 +33,21 @@ const localeEmojis = { es: esEmojis, } as const; -export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis}; -export {skinTones, categoryFrequentlyUsed, default} from './common'; +// On windows, flag emojis are not supported +const emojisForOperatingSystem = + getOperatingSystem() === CONST.OS.WINDOWS + ? emojis.slice( + 0, + emojis.findIndex((emoji) => { + if (!('header' in emoji)) { + return; + } + + return emoji.header && emoji.code === 'flags'; + }), + ) + : emojis; + +export default emojisForOperatingSystem; +export {emojiNameTable, emojiCodeTableWithSkinTones, localeEmojis, emojisForOperatingSystem}; +export {skinTones, categoryFrequentlyUsed} from './common'; diff --git a/src/CONST.ts b/src/CONST.ts index 2e07e88b4314..aca59bd831e6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -946,6 +946,11 @@ const CONST = { IOS_CAMERAROLL_ACCESS_ERROR: 'Access to photo library was denied', ADD_PAYMENT_MENU_POSITION_Y: 226, ADD_PAYMENT_MENU_POSITION_X: 356, + EMOJI_PICKER_ITEM_TYPES: { + HEADER: 'header', + EMOJI: 'emoji', + SPACER: 'spacer', + }, EMOJI_PICKER_SIZE: { WIDTH: 320, HEIGHT: 416, diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js new file mode 100644 index 000000000000..806ab6587917 --- /dev/null +++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.js @@ -0,0 +1,161 @@ +import {FlashList} from '@shopify/flash-list'; +import PropTypes from 'prop-types'; +import React, {useMemo} from 'react'; +import {StyleSheet, Text, View} from 'react-native'; +import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; +import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; +import refPropTypes from '@components/refPropTypes'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import stylePropTypes from '@styles/stylePropTypes'; +import CONST from '@src/CONST'; + +const emojiPropTypes = { + /** The code of the item */ + code: PropTypes.string.isRequired, + + /** Whether the item is a header or not */ + header: PropTypes.bool, + + /** Whether the item is a spacer or not */ + spacer: PropTypes.bool, + + /** Types of an emoji - e.g. different skin types */ + types: PropTypes.arrayOf(PropTypes.string), +}; + +const propTypes = { + /** Indicates if the emoji list is filtered or not */ + isFiltered: PropTypes.bool.isRequired, + + /** Array of header emojis */ + headerEmojis: PropTypes.arrayOf(PropTypes.shape(emojiPropTypes)).isRequired, + + /** Function to scroll to a specific header in the emoji list */ + scrollToHeader: PropTypes.func.isRequired, + + /** Style to be applied to the list wrapper */ + listWrapperStyle: stylePropTypes, + + /** Reference to the emoji list */ + forwardedRef: refPropTypes, + + /** The data for the emoji list */ + data: PropTypes.arrayOf(PropTypes.shape(emojiPropTypes)).isRequired, + + /** Function to render each item in the list */ + renderItem: PropTypes.func.isRequired, + + /** Extra data to be passed to the list for re-rendering */ + // eslint-disable-next-line react/forbid-prop-types + extraData: PropTypes.any, + + /** Array of indices for the sticky headers */ + stickyHeaderIndices: PropTypes.arrayOf(PropTypes.number), + + /** Whether the list should always bounce vertically */ + alwaysBounceVertical: PropTypes.bool, +}; + +const defaultProps = { + listWrapperStyle: [], + forwardedRef: () => {}, + extraData: [], + stickyHeaderIndices: [], + alwaysBounceVertical: false, +}; + +/** + * Improves FlashList's recycling when there are different types of items + * @param {Object} item + * @returns {String} + */ +const getItemType = (item) => { + // item is undefined only when list is empty + if (!item) { + return; + } + + if (item.name) { + return CONST.EMOJI_PICKER_ITEM_TYPES.EMOJI; + } + if (item.header) { + return CONST.EMOJI_PICKER_ITEM_TYPES.HEADER; + } + + return CONST.EMOJI_PICKER_ITEM_TYPES.SPACER; +}; + +/** + * Return a unique key for each emoji item + * + * @param {Object} item + * @param {Number} index + * @returns {String} + */ +const keyExtractor = (item, index) => `emoji_picker_${item.code}_${index}`; + +/** + * Renders the list empty component + * @returns {React.Component} + */ +function ListEmptyComponent() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return {translate('common.noResultsFound')}; +} + +function BaseEmojiPickerMenu({headerEmojis, scrollToHeader, isFiltered, listWrapperStyle, forwardedRef, data, renderItem, stickyHeaderIndices, extraData, alwaysBounceVertical}) { + const styles = useThemeStyles(); + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + + const flattenListWrapperStyle = useMemo(() => StyleSheet.flatten(listWrapperStyle), [listWrapperStyle]); + + return ( + <> + {!isFiltered && ( + + )} + + + + + + ); +} + +BaseEmojiPickerMenu.propTypes = propTypes; +BaseEmojiPickerMenu.defaultProps = defaultProps; +BaseEmojiPickerMenu.displayName = 'BaseEmojiPickerMenu'; + +const BaseEmojiPickerMenuWithRef = React.forwardRef((props, ref) => ( + +)); + +BaseEmojiPickerMenuWithRef.displayName = 'BaseEmojiPickerMenuWithRef'; + +export default BaseEmojiPickerMenuWithRef; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/emojiPickerMenuPropTypes.js b/src/components/EmojiPicker/EmojiPickerMenu/emojiPickerMenuPropTypes.js new file mode 100644 index 000000000000..ae345f6fcf56 --- /dev/null +++ b/src/components/EmojiPicker/EmojiPickerMenu/emojiPickerMenuPropTypes.js @@ -0,0 +1,8 @@ +import PropTypes from 'prop-types'; + +const emojiPickerMenuPropTypes = { + /** Function to add the selected emoji to the main compose text input */ + onEmojiSelected: PropTypes.func.isRequired, +}; + +export default emojiPickerMenuPropTypes; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js index 36594dabcd30..0791e5113a1d 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js @@ -1,121 +1,73 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {FlatList, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import {scrollTo} from 'react-native-reanimated'; import _ from 'underscore'; -import emojiAssets from '@assets/emojis'; -import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; -import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as Browser from '@libs/Browser'; import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus'; -import compose from '@libs/compose'; -import * as EmojiUtils from '@libs/EmojiUtils'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; import * as ReportUtils from '@libs/ReportUtils'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; +import BaseEmojiPickerMenu from './BaseEmojiPickerMenu'; +import emojiPickerMenuPropTypes from './emojiPickerMenuPropTypes'; +import useEmojiPickerMenu from './useEmojiPickerMenu'; const propTypes = { - /** Function to add the selected emoji to the main compose text input */ - onEmojiSelected: PropTypes.func.isRequired, - /** The ref to the search input (may be null on small screen widths) */ forwardedRef: PropTypes.func, - - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** Stores user's frequently used emojis */ - // eslint-disable-next-line react/forbid-prop-types - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object), - - ...withLocalizePropTypes, + ...emojiPickerMenuPropTypes, }; const defaultProps = { forwardedRef: () => {}, - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - frequentlyUsedEmojis: [], }; const throttleTime = Browser.isMobile() ? 200 : 50; -function EmojiPickerMenu(props) { - const {forwardedRef, frequentlyUsedEmojis, preferredSkinTone, onEmojiSelected, preferredLocale, translate} = props; - +function EmojiPickerMenu({forwardedRef, onEmojiSelected}) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - - const {isSmallScreenWidth, windowHeight} = useWindowDimensions(); + const {isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const {singleExecution} = useSingleExecution(); + const { + allEmojis, + headerEmojis, + headerRowIndices, + filteredEmojis, + headerIndices, + setFilteredEmojis, + setHeaderIndices, + isListFiltered, + suggestEmojis, + preferredSkinTone, + listStyle, + emojiListRef, + } = useEmojiPickerMenu(); // Ref for the emoji search input const searchInputRef = useRef(null); - // Ref for emoji FlatList - const emojiListRef = useRef(null); - // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will // prevent auto focus when open picker for mobile device const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus(); - const firstNonHeaderIndex = useRef(0); - - /** - * Calculate the filtered + header emojis and header row indices - * @returns {Object} - */ - function getEmojisAndHeaderRowIndices() { - // If we're on Windows, don't display the flag emojis (the last category), - // since Windows doesn't support them - const filteredEmojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojiAssets); - - // Get the header emojis along with the code, index and icon. - // index is the actual header index starting at the first emoji and counting each one - const headerEmojis = EmojiUtils.getHeaderEmojis(filteredEmojis); - - // This is the indices of each header's Row - // The positions are static, and are calculated as index/numColumns (8 in our case) - // This is because each row of 8 emojis counts as one index to the flatlist - const headerRowIndices = _.map(headerEmojis, (headerEmoji) => Math.floor(headerEmoji.index / CONST.EMOJI_NUM_PER_ROW)); - - return {filteredEmojis, headerEmojis, headerRowIndices}; - } - - const emojis = useRef([]); - if (emojis.current.length === 0) { - emojis.current = getEmojisAndHeaderRowIndices().filteredEmojis; - } - const headerRowIndices = useRef([]); - if (headerRowIndices.current.length === 0) { - headerRowIndices.current = getEmojisAndHeaderRowIndices().headerRowIndices; - } - const [headerEmojis, setHeaderEmojis] = useState(() => getEmojisAndHeaderRowIndices().headerEmojis); - - const [filteredEmojis, setFilteredEmojis] = useState(emojis.current); - const [headerIndices, setHeaderIndices] = useState(headerRowIndices.current); const [highlightedIndex, setHighlightedIndex] = useState(-1); const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false); const [selection, setSelection] = useState({start: 0, end: 0}); const [isFocused, setIsFocused] = useState(false); const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false); const [highlightFirstEmoji, setHighlightFirstEmoji] = useState(false); - - useEffect(() => { - const emojisAndHeaderRowIndices = getEmojisAndHeaderRowIndices(); - emojis.current = emojisAndHeaderRowIndices.filteredEmojis; - headerRowIndices.current = emojisAndHeaderRowIndices.headerRowIndices; - setHeaderEmojis(emojisAndHeaderRowIndices.headerEmojis); - setFilteredEmojis(emojis.current); - setHeaderIndices(headerRowIndices.current); - }, [frequentlyUsedEmojis]); + const firstNonHeaderIndex = useMemo(() => _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header), [filteredEmojis]); /** * On text input selection change @@ -126,14 +78,6 @@ function EmojiPickerMenu(props) { setSelection(event.nativeEvent.selection); }, []); - /** - * Find and store index of the first emoji item - * @param {Array} filteredEmojisArr - */ - function updateFirstNonHeaderIndex(filteredEmojisArr) { - firstNonHeaderIndex.current = _.findIndex(filteredEmojisArr, (item) => !item.spacer && !item.header); - } - const mouseMoveHandler = useCallback(() => { if (!arePointerEventsDisabled) { return; @@ -141,18 +85,6 @@ function EmojiPickerMenu(props) { setArePointerEventsDisabled(false); }, [arePointerEventsDisabled]); - /** - * This function will be used with FlatList getItemLayout property for optimization purpose that allows skipping - * the measurement of dynamic content if we know the size (height or width) of items ahead of time. - * Generate and return an object with properties length(height of each individual row), - * offset(distance of the current row from the top of the FlatList), index(current row index) - * - * @param {*} data FlatList item - * @param {Number} index row index - * @returns {Object} - */ - const getItemLayout = useCallback((data, index) => ({length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}), []); - /** * Focuses the search Input and has the text selected */ @@ -164,26 +96,23 @@ function EmojiPickerMenu(props) { } const filterEmojis = _.throttle((searchTerm) => { - const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); + const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); + if (emojiListRef.current) { - emojiListRef.current.scrollToOffset({offset: 0, animated: false}); + scrollTo(emojiListRef, 0, 0, false); } if (normalizedSearchTerm === '') { // There are no headers when searching, so we need to re-make them sticky when there is no search term - setFilteredEmojis(emojis.current); - setHeaderIndices(headerRowIndices.current); + setFilteredEmojis(allEmojis); + setHeaderIndices(headerRowIndices); setHighlightedIndex(-1); - updateFirstNonHeaderIndex(emojis.current); setHighlightFirstEmoji(false); return; } - const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, emojis.current.length); - // Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky setFilteredEmojis(newFilteredEmojiList); setHeaderIndices([]); setHighlightedIndex(0); - updateFirstNonHeaderIndex(newFilteredEmojiList); setHighlightFirstEmoji(true); }, throttleTime); @@ -223,7 +152,7 @@ function EmojiPickerMenu(props) { // If nothing is highlighted and an arrow key is pressed // select the first emoji, apply keyboard movement styles, and disable pointer events if (highlightedIndex === -1) { - setHighlightedIndex(firstNonHeaderIndex.current); + setHighlightedIndex(firstNonHeaderIndex); setArePointerEventsDisabled(true); setIsUsingKeyboardMovement(true); return; @@ -253,7 +182,7 @@ function EmojiPickerMenu(props) { case 'ArrowLeft': move( -1, - () => highlightedIndex - 1 < firstNonHeaderIndex.current, + () => highlightedIndex - 1 < firstNonHeaderIndex, () => { // Reaching start of the list, arrow left set the focus to searchInput. focusInputWithTextSelect(); @@ -267,7 +196,7 @@ function EmojiPickerMenu(props) { case 'ArrowUp': move( -CONST.EMOJI_NUM_PER_ROW, - () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex.current, + () => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex, () => { // Reaching start of the list, arrow up set the focus to searchInput. focusInputWithTextSelect(); @@ -286,7 +215,7 @@ function EmojiPickerMenu(props) { setIsUsingKeyboardMovement(true); } }, - [filteredEmojis, highlightedIndex, selection.end, selection.start], + [filteredEmojis, firstNonHeaderIndex, highlightedIndex, selection.end, selection.start], ); const keyDownHandler = useCallback( @@ -376,44 +305,18 @@ function EmojiPickerMenu(props) { }; }, [forwardedRef, shouldFocusInputOnScreenFocus, cleanupEventHandlers, setupEventHandlers]); - useEffect(() => { - // Find and store index of the first emoji item on mount - updateFirstNonHeaderIndex(emojis.current); - }, []); - - const scrollToHeader = useCallback((headerIndex) => { - if (!emojiListRef.current) { - return; - } - - const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT; - emojiListRef.current.flashScrollIndicators(); - emojiListRef.current.scrollToOffset({offset: calculatedOffset, animated: true}); - }, []); - - /** - * @param {Number} skinTone - */ - const updatePreferredSkinTone = useCallback( - (skinTone) => { - if (Number(preferredSkinTone) === Number(skinTone)) { + const scrollToHeader = useCallback( + (headerIndex) => { + if (!emojiListRef.current) { return; } - User.updatePreferredSkinTone(skinTone); + const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT; + scrollTo(emojiListRef, 0, calculatedOffset, true); }, - [preferredSkinTone], + [emojiListRef], ); - /** - * Return a unique key for each emoji item - * - * @param {Object} item - * @param {Number} index - * @returns {String} - */ - const keyExtractor = useCallback((item, index) => `emoji_picker_${item.code}_${index}`, []); - /** * Given an emoji item object, render a component based on its type. * Items with the code "SPACER" return nothing and are used to fill rows up to 8 @@ -424,15 +327,15 @@ function EmojiPickerMenu(props) { * @returns {*} */ const renderItem = useCallback( - ({item, index}) => { - const {code, header, types} = item; + ({item, index, target}) => { + const {code, types} = item; if (item.spacer) { return null; } - if (header) { + if (item.header) { return ( - + {translate(`emojiPicker.headers.${code}`)} ); @@ -445,7 +348,7 @@ function EmojiPickerMenu(props) { return ( onEmojiSelected(emoji, item)} + onPress={singleExecution((emoji) => onEmojiSelected(emoji, item))} onHoverIn={() => { setHighlightFirstEmoji(false); if (!isUsingKeyboardMovement) { @@ -465,13 +368,20 @@ function EmojiPickerMenu(props) { /> ); }, - [preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, styles, translate, onEmojiSelected], + [ + preferredSkinTone, + highlightedIndex, + isUsingKeyboardMovement, + highlightFirstEmoji, + singleExecution, + styles.emojiHeaderContainer, + styles.mh4, + styles.textLabelSupporting, + translate, + onEmojiSelected, + ], ); - const isFiltered = emojis.current.length !== filteredEmojis.length; - const listStyle = StyleUtils.getEmojiPickerListHeight(isFiltered, windowHeight); - const height = !listStyle.maxHeight || listStyle.height < listStyle.maxHeight ? listStyle.height : listStyle.maxHeight; - const overflowLimit = Math.floor(height / CONST.EMOJI_PICKER_ITEM_HEIGHT) * 8; return ( 0} /> - {!isFiltered && ( - - )} - ); } +EmojiPickerMenu.displayName = 'EmojiPickerMenu'; EmojiPickerMenu.propTypes = propTypes; EmojiPickerMenu.defaultProps = defaultProps; @@ -549,14 +445,4 @@ const EmojiPickerMenuWithRef = React.forwardRef((props, ref) => ( EmojiPickerMenuWithRef.displayName = 'EmojiPickerMenuWithRef'; -export default compose( - withLocalize, - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - frequentlyUsedEmojis: { - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - }, - }), -)(EmojiPickerMenuWithRef); +export default EmojiPickerMenuWithRef; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js index 6ad93538c83b..1463ce736699 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js @@ -1,68 +1,42 @@ -import PropTypes from 'prop-types'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback} from 'react'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import Animated, {runOnUI, scrollTo, useAnimatedRef} from 'react-native-reanimated'; +import {runOnUI, scrollTo} from 'react-native-reanimated'; import _ from 'underscore'; -import emojis from '@assets/emojis'; -import CategoryShortcutBar from '@components/EmojiPicker/CategoryShortcutBar'; import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem'; -import EmojiSkinToneList from '@components/EmojiPicker/EmojiSkinToneList'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; -import * as EmojiUtils from '@libs/EmojiUtils'; -import * as User from '@userActions/User'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; +import BaseEmojiPickerMenu from './BaseEmojiPickerMenu'; +import emojiPickerMenuPropTypes from './emojiPickerMenuPropTypes'; +import useEmojiPickerMenu from './useEmojiPickerMenu'; -const propTypes = { - /** Function to add the selected emoji to the main compose text input */ - onEmojiSelected: PropTypes.func.isRequired, +const propTypes = emojiPickerMenuPropTypes; - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - /** Stores user's frequently used emojis */ - // eslint-disable-next-line react/forbid-prop-types - frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.object), - - /** Props related to translation */ - ...withLocalizePropTypes, -}; - -const defaultProps = { - preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - frequentlyUsedEmojis: [], -}; - -function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, translate, frequentlyUsedEmojis}) { +function EmojiPickerMenu({onEmojiSelected}) { const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const emojiList = useAnimatedRef(); - // eslint-disable-next-line react-hooks/exhaustive-deps - const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]); - const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]); - const headerRowIndices = useMemo(() => _.map(headerEmojis, (headerEmoji) => Math.floor(headerEmoji.index / CONST.EMOJI_NUM_PER_ROW)), [headerEmojis]); - const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); - const [headerIndices, setHeaderIndices] = useState(headerRowIndices); - const {windowWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const {translate} = useLocalize(); + const { + allEmojis, + headerEmojis, + headerRowIndices, + filteredEmojis, + headerIndices, + setFilteredEmojis, + setHeaderIndices, + isListFiltered, + suggestEmojis, + preferredSkinTone, + listStyle, + emojiListRef, + } = useEmojiPickerMenu(); const {singleExecution} = useSingleExecution(); - - useEffect(() => { - setFilteredEmojis(allEmojis); - }, [allEmojis]); - - useEffect(() => { - setHeaderIndices(headerRowIndices); - }, [headerRowIndices]); - - const getItemLayout = (data, index) => ({length: CONST.EMOJI_PICKER_ITEM_HEIGHT, offset: CONST.EMOJI_PICKER_ITEM_HEIGHT * index, index}); + const StyleUtils = useStyleUtils(); /** * Filter the entire list of emojis to only emojis that have the search term in their keywords @@ -70,10 +44,10 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t * @param {String} searchTerm */ const filterEmojis = _.debounce((searchTerm) => { - const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); + const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm); - if (emojiList.current) { - emojiList.current.scrollToOffset({offset: 0, animated: false}); + if (emojiListRef.current) { + emojiListRef.current.scrollToOffset({offset: 0, animated: false}); } if (normalizedSearchTerm === '') { @@ -82,42 +56,20 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t return; } - const newFilteredEmojiList = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); setFilteredEmojis(newFilteredEmojiList); - setHeaderIndices(undefined); + setHeaderIndices([]); }, 300); - /** - * @param {Number} skinTone - */ - const updatePreferredSkinTone = (skinTone) => { - if (preferredSkinTone === skinTone) { - return; - } - - User.updatePreferredSkinTone(skinTone); - }; - const scrollToHeader = (headerIndex) => { const calculatedOffset = Math.floor(headerIndex / CONST.EMOJI_NUM_PER_ROW) * CONST.EMOJI_PICKER_HEADER_HEIGHT; - emojiList.current.flashScrollIndicators(); runOnUI(() => { 'worklet'; - scrollTo(emojiList, 0, calculatedOffset, true); + scrollTo(emojiListRef, 0, calculatedOffset, true); })(); }; - /** - * Return a unique key for each emoji item - * - * @param {Object} item - * @param {Number} index - * @returns {String} - */ - const keyExtractor = (item, index) => `${index}${item.code}`; - /** * Given an emoji item object, render a component based on its type. * Items with the code "SPACER" return nothing and are used to fill rows up to 8 @@ -126,34 +78,35 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t * @param {Object} item * @returns {*} */ - const renderItem = ({item}) => { - const {code, types} = item; - if (item.spacer) { - return null; - } + const renderItem = useCallback( + ({item, target}) => { + const {code, types} = item; + if (item.spacer) { + return null; + } + + if (item.header) { + return ( + + {translate(`emojiPicker.headers.${code}`)} + + ); + } + + const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code; - if (item.header) { return ( - - {translate(`emojiPicker.headers.${code}`)} - + onEmojiSelected(emoji, item))} + emoji={emojiCode} + /> ); - } - - const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code; - - return ( - onEmojiSelected(emoji, item))} - emoji={emojiCode} - /> - ); - }; - - const isFiltered = allEmojis.length !== filteredEmojis.length; + }, + [styles, windowWidth, preferredSkinTone, singleExecution, onEmojiSelected, translate], + ); return ( - + 0} /> - {!isFiltered && ( - - )} - {translate('common.noResultsFound')}} alwaysBounceVertical={filteredEmojis.length !== 0} /> - ); } EmojiPickerMenu.displayName = 'EmojiPickerMenu'; EmojiPickerMenu.propTypes = propTypes; -EmojiPickerMenu.defaultProps = defaultProps; const EmojiPickerMenuWithRef = React.forwardRef((props, ref) => ( ( EmojiPickerMenuWithRef.displayName = 'EmojiPickerMenuWithRef'; -export default compose( - withLocalize, - withOnyx({ - preferredSkinTone: { - key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, - }, - frequentlyUsedEmojis: { - key: ONYXKEYS.FREQUENTLY_USED_EMOJIS, - }, - }), -)(EmojiPickerMenuWithRef); +export default EmojiPickerMenuWithRef; diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js new file mode 100644 index 000000000000..2d895193ec68 --- /dev/null +++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.js @@ -0,0 +1,67 @@ +import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useAnimatedRef} from 'react-native-reanimated'; +import _ from 'underscore'; +import emojis from '@assets/emojis'; +import {useFrequentlyUsedEmojis} from '@components/OnyxProvider'; +import useLocalize from '@hooks/useLocalize'; +import usePreferredEmojiSkinTone from '@hooks/usePreferredEmojiSkinTone'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as EmojiUtils from '@libs/EmojiUtils'; + +const useEmojiPickerMenu = () => { + const emojiListRef = useAnimatedRef(); + const frequentlyUsedEmojis = useFrequentlyUsedEmojis(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]); + const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]); + const headerRowIndices = useMemo(() => _.map(headerEmojis, (headerEmoji) => headerEmoji.index), [headerEmojis]); + const [filteredEmojis, setFilteredEmojis] = useState(allEmojis); + const [headerIndices, setHeaderIndices] = useState(headerRowIndices); + const isListFiltered = allEmojis.length !== filteredEmojis.length; + const {preferredLocale} = useLocalize(); + const [preferredSkinTone] = usePreferredEmojiSkinTone(); + const {windowHeight} = useWindowDimensions(); + const StyleUtils = useStyleUtils(); + const listStyle = StyleUtils.getEmojiPickerListHeight(isListFiltered, windowHeight); + + useEffect(() => { + setFilteredEmojis(allEmojis); + }, [allEmojis]); + + useEffect(() => { + setHeaderIndices(headerRowIndices); + }, [headerRowIndices]); + + /** + * Suggest emojis based on the search term + * @param {String} searchTerm + * @returns {[String, Array]} + */ + const suggestEmojis = useCallback( + (searchTerm) => { + const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', ''); + const emojisSuggestions = EmojiUtils.suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length); + + return [normalizedSearchTerm, emojisSuggestions]; + }, + [allEmojis, preferredLocale], + ); + + return { + allEmojis, + headerEmojis, + headerRowIndices, + filteredEmojis, + headerIndices, + setFilteredEmojis, + setHeaderIndices, + isListFiltered, + suggestEmojis, + preferredSkinTone, + listStyle, + emojiListRef, + }; +}; + +export default useEmojiPickerMenu; diff --git a/src/components/EmojiPicker/EmojiSkinToneList.js b/src/components/EmojiPicker/EmojiSkinToneList.js index 2b574b0b533f..da2559535895 100644 --- a/src/components/EmojiPicker/EmojiSkinToneList.js +++ b/src/components/EmojiPicker/EmojiSkinToneList.js @@ -1,60 +1,51 @@ -import PropTypes from 'prop-types'; import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import _ from 'underscore'; import * as Emojis from '@assets/emojis'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; +import usePreferredEmojiSkinTone from '@hooks/usePreferredEmojiSkinTone'; import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; import EmojiPickerMenuItem from './EmojiPickerMenuItem'; import getSkinToneEmojiFromIndex from './getSkinToneEmojiFromIndex'; -const propTypes = { - /** Stores user's preferred skin tone */ - preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - - /** Function to sync the selected skin tone with parent, onyx and nvp */ - updatePreferredSkinTone: PropTypes.func.isRequired, - - /** Props related to translation */ - ...withLocalizePropTypes, -}; - -function EmojiSkinToneList(props) { +function EmojiSkinToneList() { const styles = useThemeStyles(); const [highlightedIndex, setHighlightedIndex] = useState(null); const [isSkinToneListVisible, setIsSkinToneListVisible] = useState(false); + const {translate} = useLocalize(); + const [preferredSkinTone, setPreferredSkinTone] = usePreferredEmojiSkinTone(); const toggleIsSkinToneListVisible = useCallback(() => { setIsSkinToneListVisible((prev) => !prev); }, []); /** - * Pass the skinTone to props and hide the picker + * Set the preferred skin tone in Onyx and close the skin tone picker * @param {object} skinToneEmoji */ function updateSelectedSkinTone(skinToneEmoji) { toggleIsSkinToneListVisible(); setHighlightedIndex(skinToneEmoji.skinTone); - props.updatePreferredSkinTone(skinToneEmoji.skinTone); + setPreferredSkinTone(skinToneEmoji.skinTone); } - const currentSkinTone = getSkinToneEmojiFromIndex(props.preferredSkinTone); + const currentSkinTone = getSkinToneEmojiFromIndex(preferredSkinTone); return ( {!isSkinToneListVisible && ( {currentSkinTone.code} - {props.translate('emojiPicker.skinTonePickerLabel')} + {translate('emojiPicker.skinTonePickerLabel')} )} {isSkinToneListVisible && ( @@ -75,7 +66,6 @@ function EmojiSkinToneList(props) { ); } -EmojiSkinToneList.propTypes = propTypes; EmojiSkinToneList.displayName = 'EmojiSkinToneList'; -export default withLocalize(EmojiSkinToneList); +export default EmojiSkinToneList; diff --git a/src/components/OnyxProvider.tsx b/src/components/OnyxProvider.tsx index 1009a74ef1f7..124f3558df90 100644 --- a/src/components/OnyxProvider.tsx +++ b/src/components/OnyxProvider.tsx @@ -13,6 +13,8 @@ const [withBlockedFromConcierge, BlockedFromConciergeProvider] = createOnyxConte const [withBetas, BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETAS); const [withReportCommentDrafts, ReportCommentDraftsProvider] = createOnyxContext(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [withPreferredTheme, PreferredThemeProvider, PreferredThemeContext] = createOnyxContext(ONYXKEYS.PREFERRED_THEME); +const [withFrequentlyUsedEmojis, FrequentlyUsedEmojisProvider, , useFrequentlyUsedEmojis] = createOnyxContext(ONYXKEYS.FREQUENTLY_USED_EMOJIS); +const [withPreferredEmojiSkinTone, PreferredEmojiSkinToneProvider, PreferredEmojiSkinToneContext] = createOnyxContext(ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE); type OnyxProviderProps = { /** Rendered child component */ @@ -31,6 +33,8 @@ function OnyxProvider(props: OnyxProviderProps) { BetasProvider, ReportCommentDraftsProvider, PreferredThemeProvider, + FrequentlyUsedEmojisProvider, + PreferredEmojiSkinToneProvider, ]} > {props.children} @@ -55,4 +59,8 @@ export { withReportCommentDrafts, withPreferredTheme, PreferredThemeContext, + withFrequentlyUsedEmojis, + useFrequentlyUsedEmojis, + withPreferredEmojiSkinTone, + PreferredEmojiSkinToneContext, }; diff --git a/src/hooks/usePreferredEmojiSkinTone.ts b/src/hooks/usePreferredEmojiSkinTone.ts new file mode 100644 index 000000000000..6eeecdb16617 --- /dev/null +++ b/src/hooks/usePreferredEmojiSkinTone.ts @@ -0,0 +1,20 @@ +import {useCallback, useContext} from 'react'; +import {PreferredEmojiSkinToneContext} from '@components/OnyxProvider'; +import * as User from '@userActions/User'; + +export default function usePreferredEmojiSkinTone() { + const preferredSkinTone = useContext(PreferredEmojiSkinToneContext); + + const updatePreferredSkinTone = useCallback( + (skinTone: number) => { + if (Number(preferredSkinTone) === Number(skinTone)) { + return; + } + + User.updatePreferredSkinTone(skinTone); + }, + [preferredSkinTone], + ); + + return [preferredSkinTone, updatePreferredSkinTone]; +} diff --git a/src/styles/index.ts b/src/styles/index.ts index 71f77689042d..b0178a3cdb46 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1885,7 +1885,6 @@ const styles = (theme: ThemeColors) => display: 'flex', height: CONST.EMOJI_PICKER_HEADER_HEIGHT, justifyContent: 'center', - width: '100%', }, emojiSkinToneTitle: { @@ -1906,12 +1905,13 @@ const styles = (theme: ThemeColors) => }, emojiItem: { - width: '12.5%', + width: '100%', textAlign: 'center', borderRadius: 8, paddingTop: 2, paddingBottom: 2, height: CONST.EMOJI_PICKER_ITEM_HEIGHT, + flexShrink: 1, ...userSelect.userSelectNone, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 7a5df1657c99..a7bc368983b5 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -18,7 +18,6 @@ import createTooltipStyleUtils from './generators/TooltipStyleUtils'; import getContextMenuItemStyles from './getContextMenuItemStyles'; import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; -import spacing from './spacing'; import { AllStyles, AvatarSize, @@ -847,15 +846,14 @@ function displayIfTrue(condition: boolean): ViewStyle { /** * Gets the correct height for emoji picker list based on screen dimensions */ -function getEmojiPickerListHeight(hasAdditionalSpace: boolean, windowHeight: number): ViewStyle { +function getEmojiPickerListHeight(isRenderingShortcutRow: boolean, windowHeight: number): ViewStyle { const style = { - ...spacing.ph4, - height: hasAdditionalSpace ? CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT + CONST.CATEGORY_SHORTCUT_BAR_HEIGHT : CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT, + height: isRenderingShortcutRow ? CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT + CONST.CATEGORY_SHORTCUT_BAR_HEIGHT : CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT, }; if (windowHeight) { // dimensions of content above the emoji picker list - const dimensions = hasAdditionalSpace ? CONST.EMOJI_PICKER_TEXT_INPUT_SIZES : CONST.EMOJI_PICKER_TEXT_INPUT_SIZES + CONST.CATEGORY_SHORTCUT_BAR_HEIGHT; + const dimensions = isRenderingShortcutRow ? CONST.EMOJI_PICKER_TEXT_INPUT_SIZES + CONST.CATEGORY_SHORTCUT_BAR_HEIGHT : CONST.EMOJI_PICKER_TEXT_INPUT_SIZES; return { ...style, maxHeight: windowHeight - dimensions,