snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { VirtualizedScrollbars } from '@rocket.chat/ui-client';
import type { MouseEvent } from 'react';
import { forwardRef, memo, useRef } from 'react';
import type { ListRange, VirtuosoHandle } from 'react-virtuoso';
import { Virtuoso } from 'react-virtuoso';
import EmojiCategoryRow from './EmojiCategoryRow';
import type { EmojiPickerItem } from '../../../lib/emoji';
export type CategoriesResultProps = {
items: EmojiPickerItem[];
customItemsLimit: number;
handleLoadMore: () => void;
handleSelectEmoji: (event: MouseEvent<HTMLElement>) => void;
handleScroll: (range: ListRange) => void;
};
const CategoriesResult = forwardRef<VirtuosoHandle, CategoriesResultProps>(function CategoriesResult(
{ items, customItemsLimit, handleLoadMore, handleSelectEmoji, handleScroll },
ref,
) {
const wrapper = useRef<HTMLDivElement>(null);
return (
<Box
ref={wrapper}
className={css`
&.pointer-none .rcx-emoji-picker__element {
pointer-events: none;
}
`}
height='full'
>
<VirtualizedScrollbars>
<Virtuoso
ref={ref}
totalCount={items.length}
data={items}
rangeChanged={handleScroll}
isScrolling={(isScrolling: boolean) => {
if (!wrapper.current) {
return;
}
if (isScrolling) {
wrapper.current.classList.add('pointer-none');
} else {
wrapper.current.classList.remove('pointer-none');
}
}}
itemContent={(_, item) => (
<EmojiCategoryRow
item={item}
customItemsLimit={customItemsLimit}
handleLoadMore={handleLoadMore}
handleSelectEmoji={handleSelectEmoji}
/>
)}
/>
</VirtualizedScrollbars>
</Box>
);
});
export default memo(CategoriesResult);

View File

@@ -0,0 +1,61 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import { EmojiPickerLoadMore, EmojiPickerNotFound, EmojiPickerCategoryWrapper } from '@rocket.chat/ui-client';
import { memo, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
import EmojiElement from './EmojiElement';
import { isRowDivider, isLoadMore, CUSTOM_CATEGORY, CUSTOM_CATEGORY_CLASSNAME } from '../../../lib/emoji';
import type { EmojiPickerItem } from '../../../lib/emoji';
export type EmojiCategoryRowProps = {
customItemsLimit: number;
handleLoadMore: () => void;
handleSelectEmoji: (e: MouseEvent<HTMLElement>) => void;
item: EmojiPickerItem;
};
const EmojiCategoryRow = ({ item, handleLoadMore, handleSelectEmoji }: EmojiCategoryRowProps) => {
const { t } = useTranslation();
const categoryRowStyle = css`
button {
margin-right: 0.25rem;
margin-bottom: 0.25rem;
&:nth-child(9n) {
margin-right: 0;
}
}
`;
if (isRowDivider(item)) {
return (
<>
<Box is='h4' fontScale='c1' paddingBlock={8} id={`emoji-list-category-${item.category}`}>
{t(item.i18n)}
</Box>
</>
);
}
if (isLoadMore(item)) {
return <EmojiPickerLoadMore onClick={handleLoadMore}>{t('Load_more')}</EmojiPickerLoadMore>;
}
return (
<EmojiPickerCategoryWrapper className={categoryRowStyle}>
{item.length === 0 && <EmojiPickerNotFound>{t('No_emojis_found')}</EmojiPickerNotFound>}
{item.map(({ emoji, image, category }) => (
<EmojiElement
key={emoji + category}
emoji={emoji}
image={image}
className={category === CUSTOM_CATEGORY ? CUSTOM_CATEGORY_CLASSNAME : undefined}
onClick={handleSelectEmoji}
/>
))}
</EmojiPickerCategoryWrapper>
);
};
export default memo(EmojiCategoryRow);

View File

@@ -0,0 +1,46 @@
import { css } from '@rocket.chat/css-in-js';
import { IconButton } from '@rocket.chat/fuselage';
import DOMPurify from 'dompurify';
import type { MouseEvent, AllHTMLAttributes } from 'react';
import { memo } from 'react';
import { usePreviewEmoji } from '../../../contexts/EmojiPickerContext';
import type { EmojiItem } from '../../../lib/emoji';
export type EmojiElementProps = EmojiItem & { small?: boolean; onClick: (e: MouseEvent<HTMLElement>) => void } & Omit<
AllHTMLAttributes<HTMLButtonElement>,
'is'
>;
const smallEmojiClass = css`
.emoji {
font-size: 1.125rem;
}
`;
const EmojiElement = ({ emoji, image, onClick, small = false, ...props }: EmojiElementProps) => {
const { handlePreview, handleRemovePreview } = usePreviewEmoji();
if (!image) {
return null;
}
const emojiElement = <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(image) }} />;
return (
<IconButton
{...(small && { className: smallEmojiClass })}
small={small}
medium={!small}
onMouseEnter={() => handlePreview(image, emoji)}
onMouseLeave={handleRemovePreview}
onClick={onClick}
data-emoji={emoji}
aria-label={emoji}
icon={emojiElement}
{...props}
/>
);
};
export default memo(EmojiElement);

View File

@@ -0,0 +1,249 @@
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;

View File

@@ -0,0 +1,68 @@
import { IconButton } from '@rocket.chat/fuselage';
import type { AllHTMLAttributes } from 'react';
import { useTranslation } from 'react-i18next';
import type { EmojiCategory } from '../../../lib/emoji';
export type EmojiPickerCategoryItemProps = {
category: EmojiCategory;
active: boolean;
handleGoToCategory: () => void;
} & Omit<AllHTMLAttributes<HTMLButtonElement>, 'is'>;
const mapCategoryIcon = (category: string) => {
switch (category) {
case 'people':
return 'emoji';
case 'nature':
return 'leaf';
case 'food':
return 'burger';
case 'activity':
return 'ball';
case 'travel':
return 'airplane';
case 'objects':
return 'lamp-bulb';
case 'symbols':
return 'percentage';
case 'flags':
return 'flag';
case 'rocket':
return 'rocket';
default:
return 'clock';
}
};
const EmojiPickerCategoryItem = ({ category, active, handleGoToCategory, ...props }: EmojiPickerCategoryItemProps) => {
const { t } = useTranslation();
const icon = mapCategoryIcon(category.key);
return (
<IconButton
role='tab'
pressed={active}
aria-selected={active}
title={t(category.i18n)}
className={category.key}
small
aria-label={t(category.i18n)}
onClick={handleGoToCategory}
icon={icon}
{...props}
/>
);
};
export default EmojiPickerCategoryItem;

View File

@@ -0,0 +1,81 @@
import { Box, Tile } from '@rocket.chat/fuselage';
import { useMergedRefs, usePosition } from '@rocket.chat/fuselage-hooks';
import type { ReactNode, Ref, RefObject } from 'react';
import { useMemo, useRef, forwardRef } from 'react';
const getDropdownContainer = (descendant: HTMLElement | null) => {
for (let element = descendant ?? document.body; element !== document.body; element = element.parentElement ?? document.body) {
if (
getComputedStyle(element).transform !== 'none' ||
getComputedStyle(element).position === 'fixed' ||
getComputedStyle(element).willChange === 'transform'
) {
return element;
}
}
return document.body;
};
const useDropdownPosition = (reference: RefObject<HTMLElement | null>, target: RefObject<HTMLElement | null>) => {
const innerContainer = getDropdownContainer(reference.current);
const viewHeight = document.body.getBoundingClientRect().height;
const refTop = reference.current?.getBoundingClientRect().top ?? 0;
const targetHeight = target.current?.getBoundingClientRect().height || 0;
const placement = useMemo(() => {
if (refTop >= viewHeight / 2) {
return 'top-start';
}
return 'bottom-end';
}, [targetHeight, refTop]);
const maxHeight = useMemo(() => (placement === 'bottom-end' ? '482px' : `${refTop - 12}px`), [placement, refTop]);
const { style } = usePosition(reference as RefObject<HTMLElement>, target as RefObject<HTMLElement>, {
placement,
container: innerContainer,
});
return useMemo(() => {
return { ...style, maxHeight };
}, [style]);
};
export type EmojiPickerDesktopDropdownProps = {
children: ReactNode;
reference: RefObject<HTMLElement | null>;
};
/**
* @reference is the trigger element target
* @ref is the dropdown element target
* */
const EmojiPickerDesktopDropdown = forwardRef(function ToolboxDropdownDesktop(
{ reference, children }: EmojiPickerDesktopDropdownProps,
ref: Ref<HTMLElement>,
) {
const targetRef = useRef<HTMLElement>(null);
const mergedRef = useMergedRefs(ref, targetRef);
const style = useDropdownPosition(reference, targetRef);
return (
<Tile
style={style}
ref={mergedRef}
elevation='2'
paddingInline='0'
paddingBlock='0'
display='flex'
flexDirection='column'
overflow='auto'
>
<Box flexShrink={1} paddingBlock={12}>
{children}
</Box>
</Tile>
);
});
export default EmojiPickerDesktopDropdown;

View File

@@ -0,0 +1,28 @@
import { Dropdown as DropdownMobile } from '@rocket.chat/fuselage';
import { useLayout } from '@rocket.chat/ui-contexts';
import type { ForwardedRef, ReactNode, RefObject } from 'react';
import { forwardRef } from 'react';
import EmojiPickerDesktopDropdown from './EmojiPickerDesktopDropdown';
type EmojiPickerDropdownProps<R> = {
children: ReactNode;
reference: RefObject<R | null>;
};
const EmojiPickerDropdown = forwardRef(function EmojiPickerDropdown<TReferenceElement extends HTMLElement>(
{ children, reference }: EmojiPickerDropdownProps<TReferenceElement>,
ref: ForwardedRef<HTMLElement>,
) {
const { isMobile } = useLayout();
const Dropdown = isMobile ? DropdownMobile : EmojiPickerDesktopDropdown;
return (
<Dropdown ref={ref} reference={reference as RefObject<TReferenceElement>}>
{children}
</Dropdown>
);
});
export default EmojiPickerDropdown;

View File

@@ -0,0 +1,47 @@
import { EmojiPickerNotFound, VirtualizedScrollbars } from '@rocket.chat/ui-client';
import type { MouseEvent } from 'react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { VirtuosoGridHandle } from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import EmojiElement from './EmojiElement';
import SearchingResultWrapper from './SearchingResultWrapper';
import type { EmojiItem } from '../../../lib/emoji';
/**
* the `SearchingResults` is missing the previous loadMore function that was implemented before on the latest version of EmojiPicker using the Blaze Template. It can't be implemented because of the issue with react-virtuoso and the custom scrollbars, since its using virtual list its not gonna be an issue rendering bigger results for search
*
*/
export type SearchingResultProps = {
searchResults: EmojiItem[];
handleSelectEmoji: (event: MouseEvent<HTMLElement>) => void;
};
const SearchingResult = ({ searchResults, handleSelectEmoji }: SearchingResultProps) => {
const { t } = useTranslation();
const ref = useRef<VirtuosoGridHandle>(null);
if (searchResults.length === 0) {
return <EmojiPickerNotFound>{t('No_emojis_found')}</EmojiPickerNotFound>;
}
return (
<VirtualizedScrollbars>
<VirtuosoGrid
ref={ref}
totalCount={searchResults.length}
components={{
List: SearchingResultWrapper,
}}
itemContent={(index) => {
const { emoji, image } = searchResults[index] || {};
return <EmojiElement emoji={emoji} image={image} onClick={handleSelectEmoji} />;
}}
/>
</VirtualizedScrollbars>
);
};
export default SearchingResult;

View File

@@ -0,0 +1,20 @@
import { css } from '@rocket.chat/css-in-js';
import { Box } from '@rocket.chat/fuselage';
import type { Ref } from 'react';
import { forwardRef } from 'react';
const SearchingResultWrapper = forwardRef(function SearchingResultWrapper(props, ref: Ref<HTMLDivElement>) {
const searchResultWrapperStyle = css`
button {
margin-right: 0.25rem;
margin-bottom: 0.25rem;
&:nth-child(9n) {
margin-right: 0;
}
}
`;
return <Box ref={ref} {...props} className={searchResultWrapperStyle} display='flex' flexWrap='wrap' />;
});
export default SearchingResultWrapper;

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
const TONE_EMOJIS: Record<number, string> = {
1: '✋🏻',
2: '✋🏼',
3: '✋🏽',
4: '✋🏾',
5: '✋🏿',
};
const ToneItem = ({ tone }: { tone: number }) => (
<Box>
<span className='emoji'>{TONE_EMOJIS[tone] ?? '✋'}</span>
</Box>
);
export default ToneItem;

View File

@@ -0,0 +1,71 @@
import type { OptionType } from '@rocket.chat/fuselage';
import { IconButton, useCursor, PositionAnimated, Options } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
import { useMemo, useRef, useCallback, useEffect } from 'react';
import ToneItem from './ToneItem';
const ToneSelector = ({ tone, setTone }: { tone: number; setTone: (tone: number) => void }) => {
const options = useMemo(() => {
const renderOption = (tone: number) => <ToneItem tone={tone} />;
const statuses: Array<[value: number, label: ReactNode, selected: boolean]> = [
[0, renderOption(0), tone === 0],
[1, renderOption(1), tone === 1],
[2, renderOption(2), tone === 2],
[3, renderOption(3), tone === 3],
[4, renderOption(4), tone === 4],
[5, renderOption(5), tone === 5],
];
return statuses;
}, [tone]);
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] = useCursor(-1, options, ([selected], [, hide]) => {
setTone(selected);
reset();
hide();
});
const ref = useRef<HTMLButtonElement>(null);
const onClick = useCallback(() => {
if (!ref?.current) {
return;
}
ref.current.focus();
show();
}, [show]);
const handleSelection = useCallback(
([selected]: OptionType) => {
setTone(selected as number);
reset();
hide();
},
[hide, reset, setTone],
);
useEffect(() => {
setTone(tone);
}, [tone, setTone]);
return (
<>
<IconButton
medium
ref={ref}
onClick={onClick}
onBlur={hide}
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
marginInlineStart={4}
icon={<ToneItem tone={tone} />}
/>
<PositionAnimated width='auto' visible={visible} anchor={ref} placement='left-middle'>
<Options onSelect={handleSelection} options={options} cursor={cursor} />
</PositionAnimated>
</>
);
};
export default ToneSelector;

View File

@@ -0,0 +1,17 @@
import { Box } from '@rocket.chat/fuselage';
import type { ComponentPropsWithoutRef } from 'react';
export type ToneSelectorWrapperProps = {
caption: string;
} & Omit<ComponentPropsWithoutRef<typeof Box>, 'caption'>;
const ToneSelectorWrapper = ({ caption, children, ...props }: ToneSelectorWrapperProps) => {
return (
<Box {...props} display='flex' alignItems='center'>
<Box fontScale='c2'>{caption}</Box>
{children}
</Box>
);
};
export default ToneSelectorWrapper;

View File

@@ -0,0 +1 @@
export { default } from './ToneSelector';

View File

@@ -0,0 +1 @@
export { default } from './EmojiPicker';