2022-02-07 23:00:04 +00:00
|
|
|
// Copyright 2019-2022 Signal Messenger, LLC
|
2020-10-30 20:34:04 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2019-05-24 23:58:27 +00:00
|
|
|
import * as React from 'react';
|
|
|
|
import classNames from 'classnames';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type {
|
2019-05-24 23:58:27 +00:00
|
|
|
GridCellRenderer,
|
|
|
|
SectionRenderedParams,
|
|
|
|
} from 'react-virtualized';
|
2021-10-26 19:15:33 +00:00
|
|
|
import { AutoSizer, Grid } from 'react-virtualized';
|
2019-05-24 23:58:27 +00:00
|
|
|
import {
|
|
|
|
chunk,
|
2022-02-07 23:00:04 +00:00
|
|
|
clamp,
|
2019-05-24 23:58:27 +00:00
|
|
|
debounce,
|
|
|
|
findLast,
|
|
|
|
flatMap,
|
|
|
|
initial,
|
|
|
|
last,
|
|
|
|
zipObject,
|
|
|
|
} from 'lodash';
|
2021-10-04 17:14:00 +00:00
|
|
|
import FocusTrap from 'focus-trap-react';
|
|
|
|
|
2019-05-24 23:58:27 +00:00
|
|
|
import { Emoji } from './Emoji';
|
|
|
|
import { dataByCategory, search } from './lib';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { LocalizerType } from '../../types/Util';
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-10-21 16:53:32 +00:00
|
|
|
export type EmojiPickDataType = {
|
|
|
|
skinTone?: number;
|
|
|
|
shortName: string;
|
|
|
|
};
|
2019-06-27 20:35:21 +00:00
|
|
|
|
2019-05-24 23:58:27 +00:00
|
|
|
export type OwnProps = {
|
|
|
|
readonly i18n: LocalizerType;
|
2019-06-27 20:35:21 +00:00
|
|
|
readonly onPickEmoji: (o: EmojiPickDataType) => unknown;
|
2019-12-17 20:25:57 +00:00
|
|
|
readonly doSend?: () => unknown;
|
2020-05-11 23:14:02 +00:00
|
|
|
readonly skinTone?: number;
|
2021-09-09 20:53:26 +00:00
|
|
|
readonly onSetSkinTone: (tone: number) => unknown;
|
2019-12-17 20:25:57 +00:00
|
|
|
readonly recentEmojis?: Array<string>;
|
2021-09-09 16:29:01 +00:00
|
|
|
readonly onClickSettings?: () => unknown;
|
2020-05-05 19:49:34 +00:00
|
|
|
readonly onClose?: () => unknown;
|
2019-05-24 23:58:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
|
|
|
|
|
2019-11-07 21:36:16 +00:00
|
|
|
function focusOnRender(el: HTMLElement | null) {
|
2019-05-24 23:58:27 +00:00
|
|
|
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<HTMLDivElement, Props>(
|
|
|
|
(
|
|
|
|
{
|
|
|
|
i18n,
|
2019-06-27 20:35:21 +00:00
|
|
|
doSend,
|
2019-05-24 23:58:27 +00:00
|
|
|
onPickEmoji,
|
|
|
|
skinTone = 0,
|
|
|
|
onSetSkinTone,
|
2019-12-17 20:25:57 +00:00
|
|
|
recentEmojis = [],
|
2019-05-24 23:58:27 +00:00
|
|
|
style,
|
2021-09-09 16:29:01 +00:00
|
|
|
onClickSettings,
|
2019-05-24 23:58:27 +00:00
|
|
|
onClose,
|
|
|
|
}: Props,
|
|
|
|
ref
|
|
|
|
) => {
|
2020-09-01 00:09:28 +00:00
|
|
|
const [firstRecent] = React.useState(recentEmojis);
|
2019-05-24 23:58:27 +00:00
|
|
|
const [selectedCategory, setSelectedCategory] = React.useState(
|
|
|
|
categories[0]
|
|
|
|
);
|
|
|
|
const [searchMode, setSearchMode] = React.useState(false);
|
|
|
|
const [searchText, setSearchText] = React.useState('');
|
|
|
|
const [scrollToRow, setScrollToRow] = React.useState(0);
|
2020-10-02 20:05:09 +00:00
|
|
|
const [selectedTone, setSelectedTone] = React.useState(skinTone);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-05-05 19:49:34 +00:00
|
|
|
const handleToggleSearch = React.useCallback(
|
|
|
|
(e: React.MouseEvent) => {
|
|
|
|
e.stopPropagation();
|
|
|
|
setSearchText('');
|
|
|
|
setSelectedCategory(categories[0]);
|
|
|
|
setSearchMode(m => !m);
|
|
|
|
},
|
|
|
|
[setSearchText, setSearchMode]
|
|
|
|
);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
|
|
|
const debounceSearchChange = React.useMemo(
|
|
|
|
() =>
|
2020-02-07 19:37:04 +00:00
|
|
|
debounce((query: string) => {
|
2019-05-24 23:58:27 +00:00
|
|
|
setScrollToRow(0);
|
2022-02-07 23:00:04 +00:00
|
|
|
setSearchText(query);
|
2019-05-24 23:58:27 +00:00
|
|
|
}, 200),
|
|
|
|
[setSearchText, setScrollToRow]
|
|
|
|
);
|
|
|
|
|
|
|
|
const handleSearchChange = React.useCallback(
|
|
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
|
debounceSearchChange(e.currentTarget.value);
|
|
|
|
},
|
|
|
|
[debounceSearchChange]
|
|
|
|
);
|
|
|
|
|
|
|
|
const handlePickTone = React.useCallback(
|
|
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
2020-10-12 14:53:39 +00:00
|
|
|
e.preventDefault();
|
|
|
|
e.stopPropagation();
|
|
|
|
|
2019-05-24 23:58:27 +00:00
|
|
|
const { tone = '0' } = e.currentTarget.dataset;
|
|
|
|
const parsedTone = parseInt(tone, 10);
|
|
|
|
setSelectedTone(parsedTone);
|
2020-05-11 23:14:02 +00:00
|
|
|
if (onSetSkinTone) {
|
|
|
|
onSetSkinTone(parsedTone);
|
|
|
|
}
|
2019-05-24 23:58:27 +00:00
|
|
|
},
|
2020-05-11 23:14:02 +00:00
|
|
|
[onSetSkinTone]
|
2019-05-24 23:58:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
const handlePickEmoji = React.useCallback(
|
2019-05-31 21:58:53 +00:00
|
|
|
(
|
|
|
|
e:
|
|
|
|
| React.MouseEvent<HTMLButtonElement>
|
|
|
|
| React.KeyboardEvent<HTMLButtonElement>
|
|
|
|
) => {
|
|
|
|
if ('key' in e) {
|
2020-05-05 19:49:34 +00:00
|
|
|
if (e.key === 'Enter' && doSend) {
|
|
|
|
e.stopPropagation();
|
2019-05-31 21:58:53 +00:00
|
|
|
e.preventDefault();
|
2020-05-05 19:49:34 +00:00
|
|
|
doSend();
|
2019-05-31 21:58:53 +00:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const { shortName } = e.currentTarget.dataset;
|
|
|
|
if (shortName) {
|
2020-05-05 19:49:34 +00:00
|
|
|
e.stopPropagation();
|
|
|
|
e.preventDefault();
|
2019-05-31 21:58:53 +00:00
|
|
|
onPickEmoji({ skinTone: selectedTone, shortName });
|
|
|
|
}
|
2019-05-24 23:58:27 +00:00
|
|
|
}
|
|
|
|
},
|
2019-06-27 20:35:21 +00:00
|
|
|
[doSend, onPickEmoji, selectedTone]
|
2019-05-24 23:58:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
// Handle escape key
|
2020-01-08 17:44:54 +00:00
|
|
|
React.useEffect(() => {
|
|
|
|
const handler = (event: KeyboardEvent) => {
|
|
|
|
if (searchMode && event.key === 'Escape') {
|
2022-02-07 23:00:04 +00:00
|
|
|
setScrollToRow(0);
|
2020-01-08 17:44:54 +00:00
|
|
|
setSearchText('');
|
|
|
|
setSearchMode(false);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
} else if (
|
|
|
|
!searchMode &&
|
2021-08-03 15:05:22 +00:00
|
|
|
!event.ctrlKey &&
|
2020-01-08 17:44:54 +00:00
|
|
|
![
|
|
|
|
'ArrowUp',
|
|
|
|
'ArrowDown',
|
|
|
|
'ArrowLeft',
|
|
|
|
'ArrowRight',
|
|
|
|
'Shift',
|
|
|
|
'Tab',
|
|
|
|
' ', // Space
|
|
|
|
].includes(event.key)
|
|
|
|
) {
|
2020-05-05 19:49:34 +00:00
|
|
|
if (onClose) {
|
|
|
|
onClose();
|
|
|
|
}
|
2020-01-08 17:44:54 +00:00
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
}
|
|
|
|
};
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
document.addEventListener('keydown', handler);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
document.removeEventListener('keydown', handler);
|
|
|
|
};
|
|
|
|
}, [onClose, searchMode]);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-23 21:17:06 +00:00
|
|
|
const [, ...renderableCategories] = categories;
|
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
const emojiGrid = React.useMemo(() => {
|
|
|
|
if (searchText) {
|
|
|
|
return chunk(
|
|
|
|
search(searchText).map(e => e.short_name),
|
|
|
|
COL_COUNT
|
|
|
|
);
|
|
|
|
}
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-23 21:17:06 +00:00
|
|
|
const chunks = flatMap(renderableCategories, cat =>
|
2020-01-08 17:44:54 +00:00
|
|
|
chunk(
|
|
|
|
dataByCategory[cat].map(e => e.short_name),
|
|
|
|
COL_COUNT
|
|
|
|
)
|
|
|
|
);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
return [...chunk(firstRecent, COL_COUNT), ...chunks];
|
2020-09-01 00:09:28 +00:00
|
|
|
}, [firstRecent, renderableCategories, searchText]);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2022-02-07 23:00:04 +00:00
|
|
|
const rowCount = emojiGrid.length;
|
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
const catRowEnds = React.useMemo(() => {
|
|
|
|
const rowEnds: Array<number> = [
|
|
|
|
Math.ceil(firstRecent.length / COL_COUNT) - 1,
|
|
|
|
];
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-23 21:17:06 +00:00
|
|
|
renderableCategories.forEach(cat => {
|
2020-01-08 17:44:54 +00:00
|
|
|
rowEnds.push(
|
|
|
|
Math.ceil(dataByCategory[cat].length / COL_COUNT) +
|
|
|
|
(last(rowEnds) as number)
|
|
|
|
);
|
|
|
|
});
|
2019-05-24 23:58:27 +00:00
|
|
|
|
2020-01-08 17:44:54 +00:00
|
|
|
return rowEnds;
|
2020-09-01 00:09:28 +00:00
|
|
|
}, [firstRecent.length, renderableCategories]);
|
2020-01-08 17:44:54 +00:00
|
|
|
|
|
|
|
const catToRowOffsets = React.useMemo(() => {
|
|
|
|
const offsets = initial(catRowEnds).map(i => i + 1);
|
|
|
|
|
|
|
|
return zipObject(categories, [0, ...offsets]);
|
2020-09-01 00:09:28 +00:00
|
|
|
}, [catRowEnds]);
|
2019-05-24 23:58:27 +00:00
|
|
|
|
|
|
|
const catOffsetEntries = React.useMemo(
|
|
|
|
() => Object.entries(catToRowOffsets),
|
|
|
|
[catToRowOffsets]
|
|
|
|
);
|
|
|
|
|
|
|
|
const handleSelectCategory = React.useCallback(
|
2020-05-05 19:49:34 +00:00
|
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
|
|
e.stopPropagation();
|
|
|
|
const { category } = e.currentTarget.dataset;
|
2019-05-24 23:58:27 +00:00
|
|
|
if (category) {
|
|
|
|
setSelectedCategory(category);
|
|
|
|
setScrollToRow(catToRowOffsets[category]);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
[catToRowOffsets, setSelectedCategory, setScrollToRow]
|
|
|
|
);
|
|
|
|
|
|
|
|
const cellRenderer = React.useCallback<GridCellRenderer>(
|
|
|
|
({ key, style: cellStyle, rowIndex, columnIndex }) => {
|
|
|
|
const shortName = emojiGrid[rowIndex][columnIndex];
|
|
|
|
|
|
|
|
return shortName ? (
|
|
|
|
<div
|
|
|
|
key={key}
|
|
|
|
className="module-emoji-picker__body__emoji-cell"
|
|
|
|
style={cellStyle}
|
|
|
|
>
|
|
|
|
<button
|
2020-09-01 00:09:28 +00:00
|
|
|
type="button"
|
2019-05-24 23:58:27 +00:00
|
|
|
className="module-emoji-picker__button"
|
|
|
|
onClick={handlePickEmoji}
|
2019-05-31 21:58:53 +00:00
|
|
|
onKeyDown={handlePickEmoji}
|
2019-05-24 23:58:27 +00:00
|
|
|
data-short-name={shortName}
|
|
|
|
title={shortName}
|
|
|
|
>
|
|
|
|
<Emoji shortName={shortName} skinTone={selectedTone} />
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
) : null;
|
|
|
|
},
|
2020-09-01 00:09:28 +00:00
|
|
|
[emojiGrid, handlePickEmoji, selectedTone]
|
2019-05-24 23:58:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
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),
|
2020-09-01 00:09:28 +00:00
|
|
|
[catOffsetEntries]
|
2019-05-24 23:58:27 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
return (
|
2021-10-06 20:56:37 +00:00
|
|
|
<FocusTrap
|
|
|
|
focusTrapOptions={{
|
|
|
|
allowOutsideClick: true,
|
|
|
|
}}
|
|
|
|
>
|
2021-10-04 17:14:00 +00:00
|
|
|
<div className="module-emoji-picker" ref={ref} style={style}>
|
|
|
|
<header className="module-emoji-picker__header">
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
onClick={handleToggleSearch}
|
|
|
|
title={i18n('EmojiPicker--search-placeholder')}
|
|
|
|
className={classNames(
|
|
|
|
'module-emoji-picker__button',
|
|
|
|
'module-emoji-picker__button--icon',
|
|
|
|
searchMode
|
|
|
|
? 'module-emoji-picker__button--icon--close'
|
|
|
|
: 'module-emoji-picker__button--icon--search'
|
|
|
|
)}
|
|
|
|
aria-label={i18n('EmojiPicker--search-placeholder')}
|
|
|
|
/>
|
|
|
|
{searchMode ? (
|
|
|
|
<div className="module-emoji-picker__header__search-field">
|
|
|
|
<input
|
|
|
|
ref={focusOnRender}
|
|
|
|
className="module-emoji-picker__header__search-field__input"
|
|
|
|
placeholder={i18n('EmojiPicker--search-placeholder')}
|
|
|
|
onChange={handleSearchChange}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
) : (
|
|
|
|
categories.map(cat =>
|
|
|
|
cat === 'recents' && firstRecent.length === 0 ? null : (
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
key={cat}
|
|
|
|
data-category={cat}
|
|
|
|
title={cat}
|
|
|
|
onClick={handleSelectCategory}
|
|
|
|
className={classNames(
|
|
|
|
'module-emoji-picker__button',
|
|
|
|
'module-emoji-picker__button--icon',
|
|
|
|
`module-emoji-picker__button--icon--${cat}`,
|
|
|
|
selectedCategory === cat
|
|
|
|
? 'module-emoji-picker__button--selected'
|
|
|
|
: null
|
|
|
|
)}
|
|
|
|
aria-label={i18n(`EmojiPicker__button--${cat}`)}
|
|
|
|
/>
|
|
|
|
)
|
|
|
|
)
|
2019-05-24 23:58:27 +00:00
|
|
|
)}
|
2021-10-04 17:14:00 +00:00
|
|
|
</header>
|
2022-02-07 23:00:04 +00:00
|
|
|
{rowCount > 0 ? (
|
2021-10-04 17:14:00 +00:00
|
|
|
<div>
|
|
|
|
<AutoSizer>
|
|
|
|
{({ width, height }) => (
|
|
|
|
<Grid
|
|
|
|
key={searchText}
|
|
|
|
className="module-emoji-picker__body"
|
|
|
|
width={width}
|
|
|
|
height={height}
|
|
|
|
columnCount={COL_COUNT}
|
|
|
|
columnWidth={38}
|
|
|
|
rowHeight={getRowHeight}
|
2022-02-07 23:00:04 +00:00
|
|
|
rowCount={rowCount}
|
2021-10-04 17:14:00 +00:00
|
|
|
cellRenderer={cellRenderer}
|
2022-02-07 23:00:04 +00:00
|
|
|
// In some cases, `scrollToRow` can be too high for a short period
|
|
|
|
// during state changes. This ensures that the value is never too
|
|
|
|
// large.
|
|
|
|
scrollToRow={clamp(scrollToRow, 0, rowCount - 1)}
|
2021-10-04 17:14:00 +00:00
|
|
|
scrollToAlignment="start"
|
|
|
|
onSectionRendered={onSectionRendered}
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
</AutoSizer>
|
2019-05-24 23:58:27 +00:00
|
|
|
</div>
|
|
|
|
) : (
|
2021-10-04 17:14:00 +00:00
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'module-emoji-picker__body',
|
|
|
|
'module-emoji-picker__body--empty'
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
{i18n('EmojiPicker--empty')}
|
|
|
|
<Emoji
|
|
|
|
shortName="slightly_frowning_face"
|
|
|
|
size={16}
|
|
|
|
style={{ marginLeft: '4px' }}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
<footer className="module-emoji-picker__footer">
|
|
|
|
{Boolean(onClickSettings) && (
|
|
|
|
<button
|
|
|
|
aria-label={i18n('CustomizingPreferredReactions__title')}
|
|
|
|
className="module-emoji-picker__button module-emoji-picker__button--footer module-emoji-picker__button--settings"
|
|
|
|
onClick={onClickSettings}
|
|
|
|
title={i18n('CustomizingPreferredReactions__title')}
|
|
|
|
type="button"
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
<div className="module-emoji-picker__footer__skin-tones">
|
|
|
|
{[0, 1, 2, 3, 4, 5].map(tone => (
|
2020-01-08 17:44:54 +00:00
|
|
|
<button
|
2020-09-01 00:09:28 +00:00
|
|
|
type="button"
|
2021-10-04 17:14:00 +00:00
|
|
|
key={tone}
|
|
|
|
data-tone={tone}
|
|
|
|
onClick={handlePickTone}
|
|
|
|
title={i18n('EmojiPicker--skin-tone', [`${tone}`])}
|
2020-01-08 17:44:54 +00:00
|
|
|
className={classNames(
|
|
|
|
'module-emoji-picker__button',
|
2021-10-04 17:14:00 +00:00
|
|
|
'module-emoji-picker__button--footer',
|
|
|
|
selectedTone === tone
|
2020-01-08 17:44:54 +00:00
|
|
|
? 'module-emoji-picker__button--selected'
|
|
|
|
: null
|
|
|
|
)}
|
2021-10-04 17:14:00 +00:00
|
|
|
>
|
|
|
|
<Emoji shortName="hand" skinTone={tone} size={20} />
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
{Boolean(onClickSettings) && (
|
|
|
|
<div className="module-emoji-picker__footer__settings-spacer" />
|
2019-05-24 23:58:27 +00:00
|
|
|
)}
|
2021-10-04 17:14:00 +00:00
|
|
|
</footer>
|
|
|
|
</div>
|
|
|
|
</FocusTrap>
|
2019-05-24 23:58:27 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
)
|
|
|
|
);
|