apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
/* eslint-disable complexity */
import type { IMessage, ISubscription } from '@rocket.chat/core-typings';
import { useContentBoxSize, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import {
MessageComposerAction,
MessageComposerToolbarActions,
MessageComposer,
MessageComposerInput,
MessageComposerToolbar,
MessageComposerActionsDivider,
MessageComposerToolbarSubmit,
MessageComposerHint,
MessageComposerButton,
} from '@rocket.chat/ui-composer';
import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts';
import { useMutation } from '@tanstack/react-query';
import type { ReactElement, MouseEventHandler, FormEvent, ClipboardEventHandler, MouseEvent } from 'react';
import React, { memo, useRef, useReducer, useCallback } from 'react';
import { Trans } from 'react-i18next';
import { useSubscription } from 'use-subscription';
import { createComposerAPI } from '../../../../../app/ui-message/client/messageBox/createComposerAPI';
import type { FormattingButton } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
import { formattingButtons } from '../../../../../app/ui-message/client/messageBox/messageBoxFormatting';
import { getImageExtensionFromMime } from '../../../../../lib/getImageExtensionFromMime';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { useReactiveValue } from '../../../../hooks/useReactiveValue';
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
import { keyCodes } from '../../../../lib/utils/keyCodes';
import AudioMessageRecorder from '../../../composer/AudioMessageRecorder';
import VideoMessageRecorder from '../../../composer/VideoMessageRecorder';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopup } from '../../contexts/ComposerPopupContext';
import { useRoom } from '../../contexts/RoomContext';
import ComposerBoxPopup from '../ComposerBoxPopup';
import ComposerBoxPopupPreview from '../ComposerBoxPopupPreview';
import ComposerUserActionIndicator from '../ComposerUserActionIndicator';
import { useAutoGrow } from '../RoomComposer/hooks/useAutoGrow';
import { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview';
import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs';
import MessageBoxActionsToolbar from './MessageBoxActionsToolbar';
import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar';
import MessageBoxReplies from './MessageBoxReplies';
import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus';
import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder';
const reducer = (_: unknown, event: FormEvent<HTMLInputElement>): boolean => {
const target = event.target as HTMLInputElement;
return Boolean(target.value.trim());
};
const handleFormattingShortcut = (event: KeyboardEvent, formattingButtons: FormattingButton[], composer: ComposerAPI) => {
const isMacOS = navigator.platform.indexOf('Mac') !== -1;
const isCmdOrCtrlPressed = (isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey);
if (!isCmdOrCtrlPressed) {
return false;
}
const key = event.key.toLowerCase();
const formatter = formattingButtons.find((formatter) => 'command' in formatter && formatter.command === key);
if (!formatter || !('pattern' in formatter)) {
return false;
}
composer.wrapSelection(formatter.pattern);
return true;
};
const emptySubscribe = () => () => undefined;
const getEmptyFalse = () => false;
const a: any[] = [];
const getEmptyArray = () => a;
type MessageBoxProps = {
tmid?: IMessage['_id'];
readOnly: boolean;
onSend?: (params: { value: string; tshow?: boolean; previewUrls?: string[]; isSlashCommandAllowed?: boolean }) => Promise<void>;
onJoin?: () => Promise<void>;
onResize?: () => void;
onTyping?: () => void;
onEscape?: () => void;
onNavigateToPreviousMessage?: () => void;
onNavigateToNextMessage?: () => void;
onUploadFiles?: (files: readonly File[]) => void;
tshow?: IMessage['tshow'];
previewUrls?: string[];
subscription?: ISubscription;
showFormattingTips: boolean;
isEmbedded?: boolean;
};
const MessageBox = ({
tmid,
onSend,
onJoin,
onNavigateToNextMessage,
onNavigateToPreviousMessage,
onUploadFiles,
onEscape,
onTyping,
readOnly,
tshow,
previewUrls,
}: MessageBoxProps): ReactElement => {
const chat = useChat();
const room = useRoom();
const t = useTranslation();
const e2eEnabled = useSetting<boolean>('E2E_Enable');
const unencryptedMessagesAllowed = useSetting<boolean>('E2E_Allow_Unencrypted_Messages');
const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed;
const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room);
const [typing, setTyping] = useReducer(reducer, false);
const { isMobile } = useLayout();
const sendOnEnterBehavior = useUserPreference<'normal' | 'alternative' | 'desktop'>('sendOnEnter') || isMobile;
const sendOnEnter = sendOnEnterBehavior == null || sendOnEnterBehavior === 'normal' || (sendOnEnterBehavior === 'desktop' && !isMobile);
if (!chat) {
throw new Error('Chat context not found');
}
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageComposerRef = useRef<HTMLElement>(null);
const shadowRef = useRef<HTMLDivElement>(null);
const storageID = `messagebox_${room._id}${tmid ? `-${tmid}` : ''}`;
const callbackRef = useCallback(
(node: HTMLTextAreaElement) => {
if (node === null || chat.composer) {
return;
}
chat.setComposerAPI(createComposerAPI(node, storageID));
},
[chat, storageID],
);
const autofocusRef = useMessageBoxAutoFocus(!isMobile);
const useEmojis = useUserPreference<boolean>('useEmojis');
const handleOpenEmojiPicker: MouseEventHandler<HTMLElement> = useMutableCallback((e) => {
e.stopPropagation();
e.preventDefault();
if (!useEmojis) {
return;
}
const ref = messageComposerRef.current as HTMLElement;
chat.emojiPicker.open(ref, (emoji: string) => chat.composer?.insertText(` :${emoji}: `));
});
const handleSendMessage = useMutableCallback(() => {
const text = chat.composer?.text ?? '';
chat.composer?.clear();
clearPopup();
onSend?.({
value: text,
tshow,
previewUrls,
isSlashCommandAllowed,
});
});
const closeEditing = (event: KeyboardEvent | MouseEvent<HTMLElement>) => {
if (chat.currentEditing) {
event.preventDefault();
event.stopPropagation();
chat.currentEditing.reset().then((reset) => {
if (!reset) {
chat.currentEditing?.cancel();
}
});
}
};
const handler = useMutableCallback((event: KeyboardEvent) => {
const { which: keyCode } = event;
const input = event.target as HTMLTextAreaElement;
const isSubmitKey = keyCode === keyCodes.CARRIAGE_RETURN || keyCode === keyCodes.NEW_LINE;
if (isSubmitKey) {
const withModifier = event.shiftKey || event.ctrlKey || event.altKey || event.metaKey;
const isSending = (sendOnEnter && !withModifier) || (!sendOnEnter && withModifier);
event.preventDefault();
if (!isSending) {
chat.composer?.insertNewLine();
return false;
}
handleSendMessage();
return false;
}
if (chat.composer && handleFormattingShortcut(event, [...formattingButtons], chat.composer)) {
return;
}
if (event.shiftKey || event.ctrlKey || event.metaKey) {
return;
}
switch (event.key) {
case 'Escape': {
closeEditing(event);
if (!input.value.trim()) onEscape?.();
return;
}
case 'ArrowUp': {
if (input.selectionEnd === 0) {
event.preventDefault();
event.stopPropagation();
onNavigateToPreviousMessage?.();
if (event.altKey) {
input.setSelectionRange(0, 0);
}
}
return;
}
case 'ArrowDown': {
if (input.selectionEnd === input.value.length) {
event.preventDefault();
event.stopPropagation();
onNavigateToNextMessage?.();
if (event.altKey) {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
}
onTyping?.();
});
const isEditing = useSubscription({
getCurrentValue: chat.composer?.editing.get ?? getEmptyFalse,
subscribe: chat.composer?.editing.subscribe ?? emptySubscribe,
});
const isRecordingAudio = useSubscription({
getCurrentValue: chat.composer?.recording.get ?? getEmptyFalse,
subscribe: chat.composer?.recording.subscribe ?? emptySubscribe,
});
const isMicrophoneDenied = useSubscription({
getCurrentValue: chat.composer?.isMicrophoneDenied.get ?? getEmptyFalse,
subscribe: chat.composer?.isMicrophoneDenied.subscribe ?? emptySubscribe,
});
const isRecordingVideo = useSubscription({
getCurrentValue: chat.composer?.recordingVideo.get ?? getEmptyFalse,
subscribe: chat.composer?.recordingVideo.subscribe ?? emptySubscribe,
});
const formatters = useSubscription({
getCurrentValue: chat.composer?.formatters.get ?? getEmptyArray,
subscribe: chat.composer?.formatters.subscribe ?? emptySubscribe,
});
const isRecording = isRecordingAudio || isRecordingVideo;
const { textAreaStyle, shadowStyle } = useAutoGrow(textareaRef, shadowRef, isRecordingAudio);
const canSend = useReactiveValue(useCallback(() => roomCoordinator.verifyCanSendMessage(room._id), [room._id]));
const sizes = useContentBoxSize(textareaRef);
const format = useFormatDateAndTime();
const joinMutation = useMutation(async () => onJoin?.());
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useMutableCallback((event) => {
const { clipboardData } = event;
if (!clipboardData) {
return;
}
const items = Array.from(clipboardData.items);
if (items.some(({ kind, type }) => kind === 'string' && type === 'text/plain')) {
return;
}
const files = items
.filter((item) => item.kind === 'file' && item.type.indexOf('image/') !== -1)
.map((item) => {
const fileItem = item.getAsFile();
if (!fileItem) {
return;
}
const imageExtension = fileItem ? getImageExtensionFromMime(fileItem.type) : undefined;
const extension = imageExtension ? `.${imageExtension}` : '';
Object.defineProperty(fileItem, 'name', {
writable: true,
value: `Clipboard - ${format(new Date())}${extension}`,
});
return fileItem;
})
.filter((file): file is File => !!file);
if (files.length) {
event.preventDefault();
onUploadFiles?.(files);
}
});
const composerPopupConfig = useComposerPopup();
const {
popup,
focused,
items,
ariaActiveDescendant,
suspended,
select,
commandsRef,
callbackRef: c,
filter,
clearPopup,
} = useComposerBoxPopup<{ _id: string; sort?: number }>({
configurations: composerPopupConfig,
});
const keyDownHandlerCallbackRef = useCallback(
(node: HTMLTextAreaElement) => {
if (node === null) {
return;
}
node.addEventListener('keydown', (e: KeyboardEvent) => {
handler(e);
});
},
[handler],
);
const mergedRefs = useMessageComposerMergedRefs(c, textareaRef, callbackRef, autofocusRef, keyDownHandlerCallbackRef);
const shouldPopupPreview = useEnablePopupPreview(filter, popup);
return (
<>
{chat.composer?.quotedMessages && <MessageBoxReplies />}
{shouldPopupPreview && popup && (
<ComposerBoxPopup select={select} items={items} focused={focused} title={popup.title} renderItem={popup.renderItem} />
)}
{/*
SlashCommand Preview popup works in a weird way
There is only one trigger for all the commands: "/"
After that we need to the slashcommand list and check if the command exists and provide the preview
if not the query is `suspend` which means the slashcommand is not found or doesn't have a preview
*/}
{popup?.preview && (
<ComposerBoxPopupPreview
select={select}
items={items as any}
focused={focused as any}
renderItem={popup.renderItem}
ref={commandsRef}
rid={room._id}
tmid={tmid}
suspended={suspended}
/>
)}
{isEditing && (
<MessageComposerHint
icon='pencil'
helperText={
!isMobile ? (
<Trans i18nKey='Editing_message_hint'>
<strong>esc</strong> to cancel ยท <strong>enter</strong> to save
</Trans>
) : undefined
}
>
{t('Editing_message')}
</MessageComposerHint>
)}
{readOnly && !isEditing && <MessageComposerHint>{t('This_room_is_read_only')}</MessageComposerHint>}
{isRecordingVideo && <VideoMessageRecorder reference={messageComposerRef} rid={room._id} tmid={tmid} />}
<MessageComposer ref={messageComposerRef} variant={isEditing ? 'editing' : undefined}>
{isRecordingAudio && <AudioMessageRecorder rid={room._id} isMicrophoneDenied={isMicrophoneDenied} />}
<MessageComposerInput
ref={mergedRefs}
aria-label={composerPlaceholder}
name='msg'
disabled={isRecording || !canSend}
onChange={setTyping}
style={textAreaStyle}
placeholder={composerPlaceholder}
onPaste={handlePaste}
aria-activedescendant={ariaActiveDescendant}
/>
<div ref={shadowRef} style={shadowStyle} />
<MessageComposerToolbar>
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
<MessageComposerAction
icon='emoji'
disabled={!useEmojis || isRecording || !canSend}
onClick={handleOpenEmojiPicker}
title={t('Emoji')}
/>
<MessageComposerActionsDivider />
{chat.composer && formatters.length > 0 && (
<MessageBoxFormattingToolbar
composer={chat.composer}
variant={sizes.inlineSize < 480 ? 'small' : 'large'}
items={formatters}
disabled={isRecording || !canSend}
/>
)}
<MessageBoxActionsToolbar
canSend={canSend}
typing={typing}
isMicrophoneDenied={isMicrophoneDenied}
rid={room._id}
tmid={tmid}
isRecording={isRecording}
variant={sizes.inlineSize < 480 ? 'small' : 'large'}
/>
</MessageComposerToolbarActions>
<MessageComposerToolbarSubmit>
{!canSend && (
<MessageComposerButton primary onClick={onJoin} loading={joinMutation.isLoading}>
{t('Join')}
</MessageComposerButton>
)}
{canSend && (
<>
{isEditing && <MessageComposerButton onClick={closeEditing}>{t('Cancel')}</MessageComposerButton>}
<MessageComposerAction
aria-label={t('Send')}
icon='send'
disabled={!canSend || (!typing && !isEditing)}
onClick={handleSendMessage}
secondary={typing || isEditing}
info={typing || isEditing}
/>
</>
)}
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
<ComposerUserActionIndicator rid={room._id} tmid={tmid} />
</>
);
};
export default memo(MessageBox);