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,78 @@
import { Field, FieldGroup, TextInput, FieldLabel, FieldRow, Box, FieldError } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useEffect, useId } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { isValidLink } from '../room/MessageList/lib/isValidLink';
export type AddLinkComposerActionModalProps = {
selectedText?: string;
onConfirm: (url: string, text: string) => void;
onClose: () => void;
};
const AddLinkComposerActionModal = ({ selectedText, onClose, onConfirm }: AddLinkComposerActionModalProps) => {
const { t } = useTranslation();
const textField = useId();
const urlField = useId();
const { handleSubmit, setFocus, control, formState } = useForm({
mode: 'onChange',
defaultValues: {
text: selectedText || '',
url: '',
},
});
useEffect(() => {
setFocus(selectedText ? 'url' : 'text');
}, [selectedText, setFocus]);
const onClickConfirm = ({ url, text }: { url: string; text: string }) => {
onConfirm(url, text);
};
const submit = handleSubmit(onClickConfirm);
return (
<GenericModal
variant='warning'
icon={null}
confirmText={t('Add')}
onCancel={onClose}
wrapperFunction={(props) => <Box is='form' onSubmit={(e) => void submit(e)} {...props} />}
confirmDisabled={!formState.isValid}
title={t('Add_link')}
>
<FieldGroup>
<Field>
<FieldLabel htmlFor={textField}>{t('Text')}</FieldLabel>
<FieldRow>
<Controller control={control} name='text' render={({ field }) => <TextInput autoComplete='off' id={textField} {...field} />} />
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={urlField}>{t('URL')}</FieldLabel>
<FieldRow>
<Controller
control={control}
name='url'
rules={{
validate: (value) => isValidLink(value) || t('Invalid_URL'),
required: {
value: true,
message: t(`URL_is_required`),
},
}}
render={({ field }) => <TextInput autoComplete='off' id={urlField} {...field} />}
/>
</FieldRow>
<FieldError>{formState.errors.url?.message}</FieldError>
</Field>
</FieldGroup>
</GenericModal>
);
};
export default AddLinkComposerActionModal;

View File

@@ -0,0 +1,133 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { Box, Icon, Throbber } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AudioRecorder } from '../../../lib/AudioRecorder';
import { useChat } from '../../room/contexts/ChatContext';
const audioRecorder = new AudioRecorder();
export type AudioMessageRecorderProps = {
rid: IRoom['_id'];
isMicrophoneDenied?: boolean;
};
const AudioMessageRecorder = ({ rid, isMicrophoneDenied }: AudioMessageRecorderProps) => {
const { t } = useTranslation();
const [state, setState] = useState<'loading' | 'recording'>('recording');
const [time, setTime] = useState('00:00');
const [recordingInterval, setRecordingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
const [recordingRoomId, setRecordingRoomId] = useState<IRoom['_id'] | null>(null);
const stopRecording = useStableCallback(async () => {
if (recordingInterval) {
clearInterval(recordingInterval);
}
setRecordingInterval(null);
setRecordingRoomId(null);
setTime('00:00');
chat?.action.stop('recording');
chat?.composer?.setRecordingMode(false);
const blob = await new Promise<Blob>((resolve) => audioRecorder.stop(resolve));
return blob;
});
const handleUnmount = useStableCallback(async () => {
if (state === 'recording') {
await stopRecording();
}
});
const handleRecord = useStableCallback(async () => {
chat?.composer?.setRecordingMode(true);
if (recordingRoomId && recordingRoomId !== rid) {
return;
}
try {
await audioRecorder.start();
chat?.action.performContinuously('recording');
const startTime = new Date();
setRecordingInterval(
setInterval(() => {
const now = new Date();
const distance = (now.getTime() - startTime.getTime()) / 1000;
const minutes = Math.floor(distance / 60);
const seconds = Math.floor(distance % 60);
setTime(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
}, 1000),
);
setRecordingRoomId(rid);
} catch (error) {
console.log(error);
chat?.composer?.setRecordingMode(false);
}
});
const handleCancelButtonClick = useStableCallback(async () => {
await stopRecording();
});
const chat = useChat();
const handleDoneButtonClick = useStableCallback(async () => {
setState('loading');
const blob = await stopRecording();
const fileName = `${t('Audio_record')}.mp3`;
const file = new File([blob], fileName, { type: 'audio/mpeg' });
await chat?.flows.uploadFiles({ files: [file] });
});
useEffect(() => {
handleRecord();
return () => {
handleUnmount();
};
}, [handleUnmount, handleRecord]);
if (isMicrophoneDenied) {
return null;
}
return (
<Box
display='flex'
position='absolute'
color='default'
paddingInline={4}
paddingBlock={12}
role='group'
aria-label={t('Audio_recorder')}
>
{state === 'recording' && (
<>
<MessageComposerAction icon='circle-cross' title={t('Cancel_recording')} onClick={handleCancelButtonClick} />
<Box display='flex' alignItems='center' marginInline={4} justifyContent='center'>
<Icon name='rec' color='red' />
<Box fontScale='p2' marginInlineStart={4} is='span' minWidth='x40'>
{time}
</Box>
</Box>
<MessageComposerAction icon='circle-check' title={t('Finish_recording')} onClick={handleDoneButtonClick} />
</>
)}
{state === 'loading' && <Throbber inheritColor size='x12' />}
</Box>
);
};
export default AudioMessageRecorder;

View File

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

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';

View File

@@ -0,0 +1,143 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, ButtonGroup, Button, Icon, PositionAnimated } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { AllHTMLAttributes, RefObject } from 'react';
import { useRef, useEffect, useState } from 'react';
import { UserAction, USER_ACTIVITIES } from '../../../lib/UserAction';
import { VideoRecorder, useVideoRecorderCameraStarted } from '../../../lib/videoRecorder';
import { useChat } from '../../room/contexts/ChatContext';
export type VideoMessageRecorderProps = {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
reference: RefObject<HTMLElement | null>;
} & Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>;
const videoContainerClass = css`
transform: scaleX(-1);
filter: FlipH;
@media (max-width: 500px) {
& > video {
width: 100%;
height: 100%;
}
}
`;
const getVideoRecordingExtension = () => {
const supported = VideoRecorder.getSupportedMimeTypes();
if (supported.match(/video\/webm/)) {
return 'webm';
}
return 'mp4';
};
const VideoMessageRecorder = ({ rid, tmid, reference }: VideoMessageRecorderProps) => {
const t = useTranslation();
const videoRef = useRef<HTMLVideoElement>(null);
const dispatchToastMessage = useToastMessageDispatch();
const [time, setTime] = useState<string | undefined>('00:00');
const [recordingState, setRecordingState] = useState<'idle' | 'loading' | 'recording'>('idle');
const [recordingInterval, setRecordingInterval] = useState<ReturnType<typeof setInterval> | null>(null);
const isRecording = recordingState === 'recording';
const cameraStarted = useVideoRecorderCameraStarted();
const sendButtonDisabled = !(cameraStarted && !isRecording);
const chat = useChat();
const stopVideoRecording = async (rid: IRoom['_id'], tmid?: IMessage['_id']) => {
if (recordingInterval) {
clearInterval(recordingInterval);
}
setRecordingInterval(null);
VideoRecorder.stopRecording();
UserAction.stop(rid, USER_ACTIVITIES.USER_RECORDING, { tmid });
setRecordingState('idle');
};
const handleRecord = async () => {
if (recordingState === 'recording') {
stopVideoRecording(rid, tmid);
} else {
VideoRecorder.record();
setRecordingState('recording');
UserAction.performContinuously(rid, USER_ACTIVITIES.USER_RECORDING, { tmid });
setTime('00:00');
const startTime = new Date();
setRecordingInterval(
setInterval(() => {
const now = new Date();
const distance = (now.getTime() - startTime.getTime()) / 1000;
const minutes = Math.floor(distance / 60);
const seconds = Math.floor(distance % 60);
setTime(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
}, 1000),
);
}
};
const handleSendRecord = async () => {
const cb = async (blob: Blob) => {
const fileName = `${t('Video_record')}.${getVideoRecordingExtension()}`;
const file = new File([blob], fileName, { type: VideoRecorder.getSupportedMimeTypes().split(';')[0] });
await chat?.flows.uploadFiles({ files: [file] });
};
VideoRecorder.stop(cb);
chat?.composer?.setRecordingVideo(false);
setTime(undefined);
stopVideoRecording(rid, tmid);
};
const handleCancel = useStableCallback(() => {
VideoRecorder.stop();
chat?.composer?.setRecordingVideo(false);
setTime(undefined);
stopVideoRecording(rid, tmid);
});
useEffect(() => {
if (!VideoRecorder.getSupportedMimeTypes()) {
return dispatchToastMessage({ type: 'error', message: t('Browser_does_not_support_recording_video') });
}
VideoRecorder.start(videoRef.current ?? undefined, (success) => (!success ? handleCancel() : undefined));
return () => {
VideoRecorder.stop();
};
}, [dispatchToastMessage, handleCancel, t]);
return (
<PositionAnimated visible='visible' anchor={reference} placement='top-end'>
<Box role='dialog' aria-label={t('Video_record')} backgroundColor='light' padding={4} borderRadius={4} elevation='2'>
<Box className={videoContainerClass} overflow='hidden' height={240} borderRadius={4}>
<video muted autoPlay playsInline ref={videoRef} width={320} height={240} />
</Box>
<Box marginBlockStart={4} display='flex' justifyContent='space-between'>
<Button aria-label={isRecording ? t('Stop_Recording') : t('Record')} small onClick={handleRecord}>
<Box is='span' display='flex' alignItems='center'>
<Icon size='x16' marginInlineEnd={time ? 4 : undefined} name={isRecording ? 'stop-unfilled' : 'rec'} />
{time && <span>{time}</span>}
</Box>
</Button>
<ButtonGroup>
<Button small onClick={handleCancel}>
{t('Cancel')}
</Button>
<Button primary small disabled={sendButtonDisabled} onClick={handleSendRecord}>
{t('Send')}
</Button>
</ButtonGroup>
</Box>
</Box>
</PositionAnimated>
);
};
export default VideoMessageRecorder;

View File

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