Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
250 lines
7.4 KiB
TypeScript
250 lines
7.4 KiB
TypeScript
import { TextInput, Icon, Button, Divider } from '@rocket.chat/fuselage';
|
|
import { useMediaQuery, useMergedRefs, useOutsideClick } from '@rocket.chat/fuselage-hooks';
|
|
import {
|
|
EmojiPickerCategoryHeader,
|
|
EmojiPickerContainer,
|
|
EmojiPickerPreviewArea,
|
|
EmojiPickerHeader,
|
|
EmojiPickerListArea,
|
|
EmojiPickerPreview,
|
|
} from '@rocket.chat/ui-client';
|
|
import { useTranslation, usePermission, useRoute } from '@rocket.chat/ui-contexts';
|
|
import type { ChangeEvent, KeyboardEvent, MouseEvent, RefObject } from 'react';
|
|
import { useLayoutEffect, useState, useEffect, useRef } from 'react';
|
|
import type { ListRange, VirtuosoHandle } from 'react-virtuoso';
|
|
|
|
import CategoriesResult from './CategoriesResult';
|
|
import EmojiPickerCategoryItem from './EmojiPickerCategoryItem';
|
|
import EmojiPickerDropdown from './EmojiPickerDropDown';
|
|
import SearchingResult from './SearchingResult';
|
|
import ToneSelector from './ToneSelector';
|
|
import ToneSelectorWrapper from './ToneSelector/ToneSelectorWrapper';
|
|
import { usePreviewEmoji, useEmojiPickerData } from '../../../contexts/EmojiPickerContext';
|
|
import { emoji, getCategoriesList, getEmojisBySearchTerm } from '../../../lib/emoji';
|
|
import type { EmojiItem } from '../../../lib/emoji';
|
|
import { useIsVisible } from '../../room/hooks/useIsVisible';
|
|
|
|
export type EmojiPickerProps = {
|
|
reference: Element;
|
|
onClose: () => void;
|
|
onPickEmoji: (emoji: string) => void;
|
|
};
|
|
|
|
const EmojiPicker = ({ reference, onClose, onPickEmoji }: EmojiPickerProps) => {
|
|
const t = useTranslation();
|
|
|
|
const ref = useRef<Element | null>(reference);
|
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
|
const emojiContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [isVisibleRef, isInputVisible] = useIsVisible();
|
|
const textInputRef = useRef<HTMLInputElement>(undefined);
|
|
|
|
const mergedTextInputRef = useMergedRefs(isVisibleRef, textInputRef);
|
|
|
|
const emojiCategories = getCategoriesList();
|
|
|
|
const canManageEmoji = usePermission('manage-emoji');
|
|
const customEmojiRoute = useRoute('emoji-custom');
|
|
|
|
const [searching, setSearching] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [searchResults, setSearchResults] = useState<EmojiItem[]>([]);
|
|
|
|
const { emojiToPreview, handleRemovePreview } = usePreviewEmoji();
|
|
const {
|
|
recentEmojis,
|
|
setCurrentCategory,
|
|
addRecentEmoji,
|
|
setRecentEmojis,
|
|
actualTone,
|
|
currentCategory,
|
|
categoriesIndexes,
|
|
emojiListByCategory,
|
|
customItemsLimit,
|
|
setActualTone,
|
|
setCustomItemsLimit,
|
|
} = useEmojiPickerData();
|
|
|
|
useEffect(() => () => handleRemovePreview(), [handleRemovePreview]);
|
|
|
|
const scrollCategories = useMediaQuery('(width < 340px)');
|
|
|
|
useOutsideClick([emojiContainerRef], onClose);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!reference) {
|
|
return;
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
const anchorRect = reference.getBoundingClientRect();
|
|
if (anchorRect.width === 0 && anchorRect.height === 0) {
|
|
// The element is hidden, skip it
|
|
ref.current = null;
|
|
return;
|
|
}
|
|
|
|
ref.current = reference;
|
|
});
|
|
|
|
resizeObserver.observe(reference);
|
|
|
|
return () => {
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [reference]);
|
|
|
|
useEffect(() => {
|
|
if (textInputRef.current && isInputVisible) {
|
|
textInputRef.current.focus();
|
|
}
|
|
}, [isInputVisible]);
|
|
|
|
const handleSelectEmoji = (event: MouseEvent<HTMLElement>) => {
|
|
event.stopPropagation();
|
|
|
|
const _emoji = event.currentTarget.dataset?.emoji;
|
|
|
|
if (!_emoji) {
|
|
return;
|
|
}
|
|
|
|
let tone = '';
|
|
|
|
// Custom emoji should overwrite native emoji with the same name
|
|
// Custom emoji with name `:point_right:` should overwrite every tone
|
|
// Custom emoji with name `:point_right_tone1:` should overwrite only tone1, and not the original or other tones
|
|
if (emoji.list[`:${_emoji}:`].emojiPackage !== 'emojiCustom') {
|
|
for (const emojiPackage in emoji.packages) {
|
|
if (emoji.packages.hasOwnProperty(emojiPackage)) {
|
|
if (actualTone > 0 && emoji.packages[emojiPackage].toneList.hasOwnProperty(_emoji)) {
|
|
tone = `_tone${actualTone}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setSearchTerm('');
|
|
|
|
onPickEmoji(_emoji + tone);
|
|
addRecentEmoji(_emoji + tone);
|
|
onClose();
|
|
};
|
|
|
|
useEffect(() => {
|
|
setCurrentCategory('recent');
|
|
}, [setCurrentCategory]);
|
|
|
|
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
|
|
setSearchTerm(e.target.value);
|
|
setCurrentCategory('');
|
|
setSearching(e.target.value !== '');
|
|
|
|
const emojisResult = getEmojisBySearchTerm(e.target.value, actualTone, recentEmojis, setRecentEmojis);
|
|
|
|
if (emojisResult.filter((emoji) => emoji.image).length === 0) {
|
|
setCurrentCategory('no-results');
|
|
}
|
|
setSearchResults(emojisResult as EmojiItem[]);
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Escape') {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
const handleLoadMore = () => {
|
|
setCustomItemsLimit(customItemsLimit + 90);
|
|
};
|
|
|
|
const handleScroll = (range: ListRange) => {
|
|
const { startIndex } = range;
|
|
|
|
const category = categoriesIndexes.find(
|
|
(category, index) => category.index <= startIndex + 1 && categoriesIndexes[index + 1]?.index >= startIndex,
|
|
);
|
|
|
|
if (!category) {
|
|
return;
|
|
}
|
|
|
|
setCurrentCategory(category.key);
|
|
};
|
|
|
|
const handleGoToCategory = (category: string) => {
|
|
setSearching(false);
|
|
const { index } = categoriesIndexes.find((item) => item.key === category) || {};
|
|
|
|
if (index === undefined) {
|
|
return;
|
|
}
|
|
|
|
virtuosoRef.current?.scrollToIndex({ index: index > 0 ? index + 1 : 0 });
|
|
};
|
|
|
|
const handleGoToAddCustom = () => {
|
|
customEmojiRoute.push();
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<EmojiPickerDropdown reference={ref as RefObject<HTMLElement | null>} ref={emojiContainerRef}>
|
|
<EmojiPickerContainer role='dialog' aria-label={t('Emoji_picker')} onKeyDown={handleKeyDown}>
|
|
<EmojiPickerHeader>
|
|
<TextInput
|
|
// FIXME: remove autoFocus prop when rewriting the emojiPicker dropdown
|
|
autoFocus
|
|
ref={mergedTextInputRef}
|
|
value={searchTerm}
|
|
onChange={handleSearch}
|
|
endAddon={<Icon name='magnifier' size='x20' />}
|
|
placeholder={t('Search')}
|
|
aria-label={t('Search')}
|
|
/>
|
|
</EmojiPickerHeader>
|
|
<EmojiPickerCategoryHeader role='tablist' {...(scrollCategories && { style: { overflowX: 'scroll' } })}>
|
|
{emojiCategories.map((category) => (
|
|
<EmojiPickerCategoryItem
|
|
key={category.key}
|
|
category={category}
|
|
active={category.key === currentCategory}
|
|
handleGoToCategory={() => handleGoToCategory(category.key)}
|
|
/>
|
|
))}
|
|
</EmojiPickerCategoryHeader>
|
|
<Divider marginBlock={12} />
|
|
<EmojiPickerListArea role='tabpanel'>
|
|
{searching && <SearchingResult searchResults={searchResults} handleSelectEmoji={handleSelectEmoji} />}
|
|
{!searching && (
|
|
<CategoriesResult
|
|
ref={virtuosoRef}
|
|
items={emojiListByCategory}
|
|
customItemsLimit={customItemsLimit}
|
|
handleLoadMore={handleLoadMore}
|
|
handleSelectEmoji={handleSelectEmoji}
|
|
handleScroll={handleScroll}
|
|
/>
|
|
)}
|
|
</EmojiPickerListArea>
|
|
<EmojiPickerPreviewArea>
|
|
<div>
|
|
{emojiToPreview && <EmojiPickerPreview emoji={emojiToPreview.emoji} name={emojiToPreview.name} />}
|
|
{canManageEmoji && emojiToPreview === null && (
|
|
<Button small onClick={handleGoToAddCustom}>
|
|
{t('Add_emoji')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<ToneSelectorWrapper caption={t('Skin_tone')}>
|
|
<ToneSelector tone={actualTone} setTone={setActualTone} />
|
|
</ToneSelectorWrapper>
|
|
</EmojiPickerPreviewArea>
|
|
</EmojiPickerContainer>
|
|
</EmojiPickerDropdown>
|
|
);
|
|
};
|
|
|
|
export default EmojiPicker;
|