// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import classNames from 'classnames'; import type { GridCellRenderer, SectionRenderedParams, } from 'react-virtualized'; import { AutoSizer, Grid } from 'react-virtualized'; import { chunk, clamp, debounce, findLast, flatMap, initial, last, zipObject, } from 'lodash'; import FocusTrap from 'focus-trap-react'; import { Emoji } from './Emoji'; import { dataByCategory, search } from './lib'; import type { LocalizerType } from '../../types/Util'; import { isSingleGrapheme } from '../../util/grapheme'; export type EmojiPickDataType = { skinTone?: number; shortName: string; }; export type OwnProps = { readonly i18n: LocalizerType; readonly onPickEmoji: (o: EmojiPickDataType) => unknown; readonly doSend?: () => unknown; readonly skinTone?: number; readonly onSetSkinTone?: (tone: number) => unknown; readonly recentEmojis?: Array; readonly onClickSettings?: () => unknown; readonly onClose?: () => unknown; }; export type Props = OwnProps & Pick, 'style'>; function focusOnRender(el: HTMLElement | null) { if (el) { el.focus(); } } const COL_COUNT = 8; const categories = [ 'recents', 'emoji', 'animal', 'food', 'activity', 'travel', 'object', 'symbol', 'flag', ]; export const EmojiPicker = React.memo( React.forwardRef( ( { i18n, doSend, onPickEmoji, skinTone = 0, onSetSkinTone, recentEmojis = [], style, onClickSettings, onClose, }: Props, ref ) => { const [firstRecent] = React.useState(recentEmojis); const [selectedCategory, setSelectedCategory] = React.useState( categories[0] ); const [searchMode, setSearchMode] = React.useState(false); const [searchText, setSearchText] = React.useState(''); const [scrollToRow, setScrollToRow] = React.useState(0); const [selectedTone, setSelectedTone] = React.useState(skinTone); const handleToggleSearch = React.useCallback( ( e: | React.MouseEvent | React.KeyboardEvent ) => { e.stopPropagation(); e.preventDefault(); setSearchText(''); setSelectedCategory(categories[0]); setSearchMode(m => !m); }, [setSearchText, setSearchMode] ); const debounceSearchChange = React.useMemo( () => debounce((query: string) => { setScrollToRow(0); setSearchText(query); }, 200), [setSearchText, setScrollToRow] ); const handleSearchChange = React.useCallback( (e: React.ChangeEvent) => { debounceSearchChange(e.currentTarget.value); }, [debounceSearchChange] ); const handlePickTone = React.useCallback( ( e: | React.MouseEvent | React.KeyboardEvent ) => { e.preventDefault(); e.stopPropagation(); const { tone = '0' } = e.currentTarget.dataset; const parsedTone = parseInt(tone, 10); setSelectedTone(parsedTone); if (onSetSkinTone) { onSetSkinTone(parsedTone); } }, [onSetSkinTone] ); const handlePickEmoji = React.useCallback( ( e: | React.MouseEvent | React.KeyboardEvent ) => { const { shortName } = e.currentTarget.dataset; if ('key' in e) { if (e.key === 'Enter') { if (doSend) { doSend(); e.stopPropagation(); e.preventDefault(); } else if (shortName) { onPickEmoji({ skinTone: selectedTone, shortName }); e.stopPropagation(); e.preventDefault(); } } } else if (shortName) { e.stopPropagation(); e.preventDefault(); onPickEmoji({ skinTone: selectedTone, shortName }); } }, [doSend, onPickEmoji, selectedTone] ); // Handle key presses, particularly Escape React.useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === 'Escape') { if (searchMode) { event.preventDefault(); event.stopPropagation(); setScrollToRow(0); setSearchText(''); setSearchMode(false); } else if (onClose) { event.preventDefault(); event.stopPropagation(); onClose(); } } else if (!searchMode && !event.ctrlKey && !event.metaKey) { if ( [ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Shift', 'Tab', ' ', // Space ].includes(event.key) ) { // Do nothing, these can be used to navigate around the picker. } else if (isSingleGrapheme(event.key)) { // A single grapheme means the user is typing text. Switch to search mode. setSelectedCategory(categories[0]); setSearchMode(true); // Continue propagation, typing the first letter for search. } else { // For anything else, assume it's a special key that isn't one of the ones // above (such as Delete or ContextMenu). onClose?.(); event.preventDefault(); event.stopPropagation(); } } }; document.addEventListener('keydown', handler); return () => { document.removeEventListener('keydown', handler); }; }, [onClose, searchMode, setSearchMode]); const [, ...renderableCategories] = categories; const emojiGrid = React.useMemo(() => { if (searchText) { return chunk( search(searchText).map(e => e.short_name), COL_COUNT ); } const chunks = flatMap(renderableCategories, cat => chunk( dataByCategory[cat].map(e => e.short_name), COL_COUNT ) ); return [...chunk(firstRecent, COL_COUNT), ...chunks]; }, [firstRecent, renderableCategories, searchText]); const rowCount = emojiGrid.length; const catRowEnds = React.useMemo(() => { const rowEnds: Array = [ Math.ceil(firstRecent.length / COL_COUNT) - 1, ]; renderableCategories.forEach(cat => { rowEnds.push( Math.ceil(dataByCategory[cat].length / COL_COUNT) + (last(rowEnds) as number) ); }); return rowEnds; }, [firstRecent.length, renderableCategories]); const catToRowOffsets = React.useMemo(() => { const offsets = initial(catRowEnds).map(i => i + 1); return zipObject(categories, [0, ...offsets]); }, [catRowEnds]); const catOffsetEntries = React.useMemo( () => Object.entries(catToRowOffsets), [catToRowOffsets] ); const handleSelectCategory = React.useCallback( ( e: | React.MouseEvent | React.KeyboardEvent ) => { e.stopPropagation(); e.preventDefault(); const { category } = e.currentTarget.dataset; if (category) { setSelectedCategory(category); setScrollToRow(catToRowOffsets[category]); } }, [catToRowOffsets, setSelectedCategory, setScrollToRow] ); const cellRenderer = React.useCallback( ({ key, style: cellStyle, rowIndex, columnIndex }) => { const shortName = emojiGrid[rowIndex][columnIndex]; return shortName ? (
) : null; }, [emojiGrid, handlePickEmoji, selectedTone] ); const getRowHeight = React.useCallback( ({ index }: { index: number }) => { if (searchText) { return 34; } if (catRowEnds.includes(index) && index !== last(catRowEnds)) { return 44; } return 34; }, [catRowEnds, searchText] ); const onSectionRendered = React.useMemo( () => debounce(({ rowStartIndex }: SectionRenderedParams) => { const [cat] = findLast(catOffsetEntries, ([, row]) => rowStartIndex >= row) || categories; setSelectedCategory(cat); }, 10), [catOffsetEntries] ); return (
{rowCount > 0 ? (
{({ width, height }) => ( )}
) : (
{i18n('EmojiPicker--empty')}
)}
{Boolean(onClickSettings) && ( ))}
) : null} {Boolean(onClickSettings) && (
)}
); } ) );