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
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AudioMessageRecorder';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
249
apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx
Normal file
249
apps/meteor/client/views/composer/EmojiPicker/EmojiPicker.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ToneSelector';
|
||||
1
apps/meteor/client/views/composer/EmojiPicker/index.ts
Normal file
1
apps/meteor/client/views/composer/EmojiPicker/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './EmojiPicker';
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './VideoMessageRecorder';
|
||||
Reference in New Issue
Block a user