240 lines
7.0 KiB
TypeScript
240 lines
7.0 KiB
TypeScript
import React, {
|
|
useState, useEffect, memo, useRef, useMemo, useCallback,
|
|
} from '../../../lib/teact/teact';
|
|
import { withGlobal } from '../../../global';
|
|
|
|
import type { FC } from '../../../lib/teact/teact';
|
|
import type { GlobalState } from '../../../global/types';
|
|
import type {
|
|
EmojiModule,
|
|
EmojiRawData,
|
|
EmojiData,
|
|
} from '../../../util/emoji';
|
|
|
|
import { MENU_TRANSITION_DURATION, RECENT_SYMBOL_SET_ID } from '../../../config';
|
|
import { IS_TOUCH_ENV } from '../../../util/environment';
|
|
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
|
|
import { uncompressEmoji } from '../../../util/emoji';
|
|
import fastSmoothScroll from '../../../util/fastSmoothScroll';
|
|
import { pick } from '../../../util/iteratees';
|
|
import buildClassName from '../../../util/buildClassName';
|
|
import fastSmoothScrollHorizontal from '../../../util/fastSmoothScrollHorizontal';
|
|
import useAsyncRendering from '../../right/hooks/useAsyncRendering';
|
|
import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver';
|
|
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
|
|
import useLang from '../../../hooks/useLang';
|
|
import useAppLayout from '../../../hooks/useAppLayout';
|
|
|
|
import Button from '../../ui/Button';
|
|
import Loading from '../../ui/Loading';
|
|
import EmojiCategory from './EmojiCategory';
|
|
|
|
import './EmojiPicker.scss';
|
|
|
|
type OwnProps = {
|
|
className?: string;
|
|
onEmojiSelect: (emoji: string, name: string) => void;
|
|
};
|
|
|
|
type StateProps = Pick<GlobalState, 'recentEmojis'>;
|
|
|
|
type EmojiCategoryData = { id: string; name: string; emojis: string[] };
|
|
|
|
const ICONS_BY_CATEGORY: Record<string, string> = {
|
|
recent: 'icon-recent',
|
|
people: 'icon-smile',
|
|
nature: 'icon-animals',
|
|
foods: 'icon-eats',
|
|
activity: 'icon-sport',
|
|
places: 'icon-car',
|
|
objects: 'icon-lamp',
|
|
symbols: 'icon-language',
|
|
flags: 'icon-flag',
|
|
};
|
|
|
|
const OPEN_ANIMATION_DELAY = 200;
|
|
// Only a few categories are above this height.
|
|
const SMOOTH_SCROLL_DISTANCE = 800;
|
|
const FOCUS_MARGIN = 50;
|
|
const HEADER_BUTTON_WIDTH = 42; // px. Includes margins
|
|
const INTERSECTION_THROTTLE = 200;
|
|
|
|
const categoryIntersections: boolean[] = [];
|
|
|
|
let emojiDataPromise: Promise<EmojiModule>;
|
|
let emojiRawData: EmojiRawData;
|
|
let emojiData: EmojiData;
|
|
|
|
const EmojiPicker: FC<OwnProps & StateProps> = ({
|
|
className,
|
|
recentEmojis,
|
|
onEmojiSelect,
|
|
}) => {
|
|
// eslint-disable-next-line no-null/no-null
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
// eslint-disable-next-line no-null/no-null
|
|
const headerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [categories, setCategories] = useState<EmojiCategoryData[]>();
|
|
const [emojis, setEmojis] = useState<AllEmojis>();
|
|
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
|
|
const { isMobile } = useAppLayout();
|
|
|
|
const { observe: observeIntersection } = useIntersectionObserver({
|
|
rootRef: containerRef,
|
|
throttleMs: INTERSECTION_THROTTLE,
|
|
}, (entries) => {
|
|
entries.forEach((entry) => {
|
|
const { id } = entry.target as HTMLDivElement;
|
|
if (!id || !id.startsWith('emoji-category-')) {
|
|
return;
|
|
}
|
|
|
|
const index = Number(id.replace('emoji-category-', ''));
|
|
categoryIntersections[index] = entry.isIntersecting;
|
|
});
|
|
|
|
const intersectingWithIndexes = categoryIntersections
|
|
.map((isIntersecting, index) => ({ index, isIntersecting }))
|
|
.filter(({ isIntersecting }) => isIntersecting);
|
|
|
|
if (!intersectingWithIndexes.length) {
|
|
return;
|
|
}
|
|
|
|
setActiveCategoryIndex(intersectingWithIndexes[Math.floor(intersectingWithIndexes.length / 2)].index);
|
|
});
|
|
|
|
useHorizontalScroll(headerRef.current, !isMobile);
|
|
|
|
// Scroll header when active set updates
|
|
useEffect(() => {
|
|
if (!categories) {
|
|
return;
|
|
}
|
|
|
|
const header = headerRef.current;
|
|
if (!header) {
|
|
return;
|
|
}
|
|
|
|
const newLeft = activeCategoryIndex * HEADER_BUTTON_WIDTH - header.offsetWidth / 2 + HEADER_BUTTON_WIDTH / 2;
|
|
|
|
fastSmoothScrollHorizontal(header, newLeft);
|
|
}, [categories, activeCategoryIndex]);
|
|
|
|
const lang = useLang();
|
|
|
|
const allCategories = useMemo(() => {
|
|
if (!categories) {
|
|
return MEMO_EMPTY_ARRAY;
|
|
}
|
|
const themeCategories = [...categories];
|
|
if (recentEmojis?.length) {
|
|
themeCategories.unshift({
|
|
id: RECENT_SYMBOL_SET_ID,
|
|
name: lang('RecentStickers'),
|
|
emojis: recentEmojis,
|
|
});
|
|
}
|
|
|
|
return themeCategories;
|
|
}, [categories, lang, recentEmojis]);
|
|
|
|
// Initialize data on first render.
|
|
useEffect(() => {
|
|
setTimeout(() => {
|
|
const exec = () => {
|
|
setCategories(emojiData.categories);
|
|
|
|
setEmojis(emojiData.emojis as AllEmojis);
|
|
};
|
|
|
|
if (emojiData) {
|
|
exec();
|
|
} else {
|
|
ensureEmojiData()
|
|
.then(exec);
|
|
}
|
|
}, OPEN_ANIMATION_DELAY);
|
|
}, []);
|
|
|
|
const selectCategory = useCallback((index: number) => {
|
|
setActiveCategoryIndex(index);
|
|
const categoryEl = document.getElementById(`emoji-category-${index}`)!;
|
|
fastSmoothScroll(containerRef.current!, categoryEl, 'start', FOCUS_MARGIN, SMOOTH_SCROLL_DISTANCE);
|
|
}, []);
|
|
|
|
const handleEmojiSelect = useCallback((emoji: string, name: string) => {
|
|
onEmojiSelect(emoji, name);
|
|
}, [onEmojiSelect]);
|
|
|
|
const canRenderContents = useAsyncRendering([], MENU_TRANSITION_DURATION);
|
|
|
|
function renderCategoryButton(category: EmojiCategoryData, index: number) {
|
|
const icon = ICONS_BY_CATEGORY[category.id];
|
|
|
|
return icon && (
|
|
<Button
|
|
className={`symbol-set-button ${index === activeCategoryIndex ? 'activated' : ''}`}
|
|
round
|
|
faded
|
|
color="translucent"
|
|
// eslint-disable-next-line react/jsx-no-bind
|
|
onClick={() => selectCategory(index)}
|
|
ariaLabel={category.name}
|
|
>
|
|
<i className={icon} />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
const containerClassName = buildClassName('EmojiPicker', className);
|
|
|
|
if (!emojis || !canRenderContents) {
|
|
return (
|
|
<div className={containerClassName}>
|
|
<Loading />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={containerClassName}>
|
|
<div ref={headerRef} className="EmojiPicker-header" dir={lang.isRtl ? 'rtl' : ''}>
|
|
{allCategories.map(renderCategoryButton)}
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
className={buildClassName('EmojiPicker-main no-selection', IS_TOUCH_ENV ? 'no-scrollbar' : 'custom-scroll')}
|
|
>
|
|
{allCategories.map((category, i) => (
|
|
<EmojiCategory
|
|
category={category}
|
|
index={i}
|
|
allEmojis={emojis}
|
|
observeIntersection={observeIntersection}
|
|
shouldRender={activeCategoryIndex >= i - 1 && activeCategoryIndex <= i + 1}
|
|
onEmojiSelect={handleEmojiSelect}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
async function ensureEmojiData() {
|
|
if (!emojiDataPromise) {
|
|
emojiDataPromise = import('emoji-data-ios/emoji-data.json');
|
|
emojiRawData = (await emojiDataPromise).default;
|
|
|
|
emojiData = uncompressEmoji(emojiRawData);
|
|
}
|
|
|
|
return emojiDataPromise;
|
|
}
|
|
|
|
export default memo(withGlobal<OwnProps>(
|
|
(global): StateProps => pick(global, ['recentEmojis']),
|
|
)(EmojiPicker));
|