Files
upstream-mirror 30810eecd7
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
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

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;