// Copyright 2019-2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import classNames from 'classnames'; import { AutoSizer, Grid, GridCellRenderer, SectionRenderedParams, } from 'react-virtualized'; import { chunk, debounce, findLast, flatMap, initial, last, zipObject, } from 'lodash'; import { Emoji } from './Emoji'; import { dataByCategory, search } from './lib'; import { LocalizerType } from '../../types/Util'; 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 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, 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) => { e.stopPropagation(); setSearchText(''); setSelectedCategory(categories[0]); setSearchMode(m => !m); }, [setSearchText, setSearchMode] ); const debounceSearchChange = React.useMemo( () => debounce((query: string) => { setSearchText(query); setScrollToRow(0); }, 200), [setSearchText, setScrollToRow] ); const handleSearchChange = React.useCallback( (e: React.ChangeEvent) => { debounceSearchChange(e.currentTarget.value); }, [debounceSearchChange] ); const handlePickTone = React.useCallback( (e: React.MouseEvent) => { 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 ) => { if ('key' in e) { if (e.key === 'Enter' && doSend) { e.stopPropagation(); e.preventDefault(); doSend(); } } else { const { shortName } = e.currentTarget.dataset; if (shortName) { e.stopPropagation(); e.preventDefault(); onPickEmoji({ skinTone: selectedTone, shortName }); } } }, [doSend, onPickEmoji, selectedTone] ); // Handle escape key React.useEffect(() => { const handler = (event: KeyboardEvent) => { if (searchMode && event.key === 'Escape') { setSearchText(''); setSearchMode(false); setScrollToRow(0); event.preventDefault(); event.stopPropagation(); } else if ( !searchMode && ![ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Shift', 'Tab', ' ', // Space ].includes(event.key) ) { if (onClose) { onClose(); } event.preventDefault(); event.stopPropagation(); } }; document.addEventListener('keydown', handler); return () => { document.removeEventListener('keydown', handler); }; }, [onClose, searchMode]); 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 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) => { e.stopPropagation(); 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 (
{emojiGrid.length > 0 ? (
{({ width, height }) => ( )}
) : (
{i18n('EmojiPicker--empty')}
)}
); } ) );