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 2s
436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
/* eslint-disable complexity */
|
|
import { isRoomFederated, isRoomNativeFederated, type IMessage, type ISubscription } from '@rocket.chat/core-typings';
|
|
import { useContentBoxSize, useStableCallback, useMediaQuery, useSafeRefCallback } from '@rocket.chat/fuselage-hooks';
|
|
import { MessageComposerInputExpandable } from '@rocket.chat/ui-composer';
|
|
import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import type { MouseEvent, ClipboardEvent, ChangeEvent } from 'react';
|
|
import { memo, useRef, useReducer, useCallback, useSyncExternalStore } from 'react';
|
|
|
|
import MessageBoxBase from './MessageBoxBase';
|
|
import MessageComposerFiles from './MessageComposerFiles';
|
|
import { createComposerAPI } from './createComposerAPI';
|
|
import { useDraft } from './hooks/useDraft';
|
|
import { useMessageBoxAutoFocus } from './hooks/useMessageBoxAutoFocus';
|
|
import { useMessageBoxPlaceholder } from './hooks/useMessageBoxPlaceholder';
|
|
import { emptySubscribe, getEmptyFalse, getEmptyArray, handleFormattingShortcut } from './messageBoxHelpers';
|
|
import { handleSelectionWrapping } from './wrapSelection';
|
|
import { getImageExtensionFromMime } from '../../../../../lib/getImageExtensionFromMime';
|
|
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
|
import { useIsFederationEnabled } from '../../../../hooks/useIsFederationEnabled';
|
|
import { emoji } from '../../../../lib/emoji';
|
|
import { formattingButtons } from '../../../../lib/messageBoxFormatting';
|
|
import { roomCoordinator } from '../../../../lib/rooms/roomCoordinator';
|
|
import { keyCodes } from '../../../../lib/utils/keyCodes';
|
|
import { Subscriptions } from '../../../../stores';
|
|
import { useFileUpload } from '../../body/hooks/useFileUpload';
|
|
import { useChat } from '../../contexts/ChatContext';
|
|
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
|
|
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
|
|
import { useAutoGrow } from '../RoomComposer/hooks/useAutoGrow';
|
|
import { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
|
|
import { useEnablePopupPreview } from '../hooks/useEnablePopupPreview';
|
|
import { useMessageComposerMergedRefs } from '../hooks/useMessageComposerMergedRefs';
|
|
|
|
const reducer = (_: unknown, event: ChangeEvent<HTMLInputElement>): boolean => {
|
|
const { target } = event;
|
|
|
|
return Boolean(target.value.trim());
|
|
};
|
|
|
|
export type MessageBoxProps = {
|
|
tmid?: IMessage['_id'];
|
|
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;
|
|
tshow?: IMessage['tshow'];
|
|
previewUrls?: string[];
|
|
subscription?: ISubscription;
|
|
showFormattingTips: boolean;
|
|
isEmbedded?: boolean;
|
|
threadExists?: boolean;
|
|
};
|
|
|
|
const MessageBox = ({
|
|
tmid,
|
|
onSend,
|
|
onJoin,
|
|
onNavigateToNextMessage,
|
|
onNavigateToPreviousMessage,
|
|
onEscape,
|
|
onTyping,
|
|
tshow,
|
|
previewUrls,
|
|
threadExists,
|
|
}: MessageBoxProps) => {
|
|
const chat = useChat();
|
|
const room = useRoom();
|
|
const t = useTranslation();
|
|
const e2eEnabled = useSetting('E2E_Enable', false);
|
|
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages', false);
|
|
const isSlashCommandAllowed = !e2eEnabled || !room.encrypted || unencryptedMessagesAllowed;
|
|
const composerPlaceholder = useMessageBoxPlaceholder(t('Message'), room);
|
|
const quoteChainLimit = useSetting('Message_QuoteChainLimit', 2);
|
|
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 subscription = useRoomSubscription();
|
|
const { initialValue, persistLocal, flushDraft } = useDraft(
|
|
room._id,
|
|
tmid ? subscription?.threadDrafts?.[tmid] : subscription?.draft,
|
|
tmid,
|
|
threadExists,
|
|
);
|
|
|
|
const callbackRef = useCallback(
|
|
(node: HTMLTextAreaElement) => {
|
|
if (node === null && chat.composer) {
|
|
flushDraft();
|
|
return chat.setComposerAPI();
|
|
}
|
|
|
|
if (chat.composer) {
|
|
return;
|
|
}
|
|
chat.setComposerAPI(
|
|
createComposerAPI(node, persistLocal, initialValue, quoteChainLimit, messageComposerRef, { rid: room._id, tmid }),
|
|
);
|
|
},
|
|
[chat, flushDraft, initialValue, persistLocal, quoteChainLimit, room._id, tmid],
|
|
);
|
|
|
|
const isTouchDevice = useMediaQuery('(pointer: coarse)');
|
|
const autofocusRef = useMessageBoxAutoFocus(!isTouchDevice);
|
|
|
|
const useEmojis = useUserPreference<boolean>('useEmojis');
|
|
|
|
const handleOpenEmojiPicker = useStableCallback((e: MouseEvent<HTMLElement>) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
if (!useEmojis) {
|
|
return;
|
|
}
|
|
|
|
const ref = messageComposerRef.current as HTMLElement;
|
|
chat.emojiPicker.open(ref, (emojiName: string) => {
|
|
const emojiEntry = emoji.list[`:${emojiName}:`];
|
|
const text = emojiEntry && 'unicode' in emojiEntry && emojiEntry.unicode ? ` ${emojiEntry.unicode} ` : ` :${emojiName}: `;
|
|
chat.composer?.insertText(text);
|
|
});
|
|
});
|
|
|
|
const { hasUploads, handleUploadFiles, isUploading, isProcessingUploads } = useFileUpload();
|
|
|
|
const handleSendMessage = useStableCallback(async () => {
|
|
if (isUploading || isProcessingUploads) {
|
|
return;
|
|
}
|
|
|
|
const { composer } = chat;
|
|
const text = composer?.text ?? '';
|
|
popup.clear();
|
|
|
|
void onSend?.({
|
|
value: text,
|
|
tshow,
|
|
previewUrls,
|
|
isSlashCommandAllowed,
|
|
}).then(() => {
|
|
if (!composer) {
|
|
return;
|
|
}
|
|
|
|
flushDraft(composer.text);
|
|
});
|
|
});
|
|
|
|
const closeEditing = async (event: KeyboardEvent | MouseEvent<HTMLElement>) => {
|
|
const mid = chat.currentEditingMessage.getMID();
|
|
if (mid) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// NOTE: if the message was reset (i.e. content changed), we keep the editing mode on
|
|
const reset = await chat.currentEditingMessage.reset();
|
|
|
|
if (!reset) {
|
|
await chat.currentEditingMessage.cancel();
|
|
await chat.currentEditingMessage.stop();
|
|
}
|
|
|
|
popup.clear();
|
|
}
|
|
};
|
|
|
|
const keyboardEventHandler = useStableCallback((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 = useSyncExternalStore(chat.composer?.editing.subscribe ?? emptySubscribe, chat.composer?.editing.get ?? getEmptyFalse);
|
|
|
|
const isRecordingAudio = useSyncExternalStore(
|
|
chat.composer?.recording.subscribe ?? emptySubscribe,
|
|
chat.composer?.recording.get ?? getEmptyFalse,
|
|
);
|
|
|
|
const isMicrophoneDenied = useSyncExternalStore(
|
|
chat.composer?.isMicrophoneDenied.subscribe ?? emptySubscribe,
|
|
chat.composer?.isMicrophoneDenied.get ?? getEmptyFalse,
|
|
);
|
|
|
|
const isRecordingVideo = useSyncExternalStore(
|
|
chat.composer?.recordingVideo.subscribe ?? emptySubscribe,
|
|
chat.composer?.recordingVideo.get ?? getEmptyFalse,
|
|
);
|
|
|
|
const formatters = useSyncExternalStore(
|
|
chat.composer?.formatters.subscribe ?? emptySubscribe,
|
|
chat.composer?.formatters.get ?? getEmptyArray,
|
|
);
|
|
|
|
const isRecording = isRecordingAudio || isRecordingVideo;
|
|
|
|
const { autoGrowRef, textAreaStyle } = useAutoGrow(textareaRef, isRecordingAudio);
|
|
|
|
const federationMatrixEnabled = useIsFederationEnabled();
|
|
|
|
// canSendMessage directives read from the Subscriptions store, so subscribe to it to re-run on changes
|
|
// (e.g. user joins/leaves the room). room and federationMatrixEnabled are already React-reactive.
|
|
const subscribeSubscriptions = useCallback((onStoreChange: () => void) => Subscriptions.use.subscribe(onStoreChange), []);
|
|
const canSend = useSyncExternalStore(subscribeSubscriptions, () => {
|
|
if (!room.t) {
|
|
return false;
|
|
}
|
|
|
|
if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) {
|
|
return false;
|
|
}
|
|
|
|
if (isRoomFederated(room)) {
|
|
// we are dropping the non native federation for now
|
|
if (!isRoomNativeFederated(room)) {
|
|
return false;
|
|
}
|
|
|
|
return federationMatrixEnabled;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const sizes = useContentBoxSize(textareaRef);
|
|
|
|
const format = useFormatDateAndTime();
|
|
|
|
const joinMutation = useMutation({
|
|
mutationFn: async () => onJoin?.(),
|
|
});
|
|
|
|
const handlePaste = useStableCallback((event: ClipboardEvent<HTMLTextAreaElement>) => {
|
|
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();
|
|
handleUploadFiles?.(files);
|
|
}
|
|
});
|
|
|
|
const popupOptions = useComposerPopupOptions();
|
|
const popup = useComposerBoxPopup(popupOptions);
|
|
|
|
const keyDownHandlerCallbackRef = useSafeRefCallback(
|
|
useCallback(
|
|
(node: HTMLTextAreaElement) => {
|
|
const eventHandler = (e: KeyboardEvent) => keyboardEventHandler(e);
|
|
node.addEventListener('keydown', eventHandler);
|
|
|
|
return () => {
|
|
node.removeEventListener('keydown', eventHandler);
|
|
};
|
|
},
|
|
[keyboardEventHandler],
|
|
),
|
|
);
|
|
|
|
const beforeInputHandlerCallbackRef = useSafeRefCallback(
|
|
useCallback(
|
|
(node: HTMLTextAreaElement) => {
|
|
const eventHandler = (e: Event) => handleSelectionWrapping(e as InputEvent, chat);
|
|
node.addEventListener('beforeinput', eventHandler);
|
|
|
|
return () => {
|
|
node.removeEventListener('beforeinput', eventHandler);
|
|
};
|
|
},
|
|
[chat],
|
|
),
|
|
);
|
|
|
|
const mergedRefs = useMessageComposerMergedRefs(
|
|
popup.callbackRef,
|
|
textareaRef,
|
|
autoGrowRef,
|
|
callbackRef,
|
|
autofocusRef,
|
|
keyDownHandlerCallbackRef,
|
|
beforeInputHandlerCallbackRef,
|
|
);
|
|
|
|
const shouldPopupPreview = useEnablePopupPreview(popup.filter, popup.option);
|
|
|
|
return (
|
|
<MessageBoxBase
|
|
rid={room._id}
|
|
tmid={tmid}
|
|
composer={chat.composer}
|
|
messageComposerRef={messageComposerRef}
|
|
popup={popup}
|
|
shouldPopupPreview={shouldPopupPreview}
|
|
isEditing={isEditing}
|
|
isRecording={isRecording}
|
|
isRecordingAudio={isRecordingAudio}
|
|
isRecordingVideo={isRecordingVideo}
|
|
isMicrophoneDenied={isMicrophoneDenied}
|
|
formatters={formatters}
|
|
canSend={canSend}
|
|
useEmojis={useEmojis}
|
|
sendEnabled={canSend && !isUploading && !isProcessingUploads && (typing || isEditing || hasUploads)}
|
|
sendActive={typing || isEditing || hasUploads}
|
|
inlineSize={sizes.inlineSize}
|
|
e2eEnabled={e2eEnabled}
|
|
unencryptedMessagesAllowed={unencryptedMessagesAllowed}
|
|
isMobile={isMobile}
|
|
joinPending={joinMutation.isPending}
|
|
onEmojiClick={handleOpenEmojiPicker}
|
|
onSend={handleSendMessage}
|
|
onJoin={onJoin}
|
|
closeEditing={closeEditing}
|
|
input={
|
|
<MessageComposerInputExpandable
|
|
dimensions={sizes}
|
|
ref={mergedRefs}
|
|
aria-label={composerPlaceholder}
|
|
name='msg'
|
|
disabled={isRecording || !canSend || isProcessingUploads}
|
|
onChange={setTyping}
|
|
style={textAreaStyle}
|
|
placeholder={composerPlaceholder}
|
|
onPaste={handlePaste}
|
|
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
|
|
/>
|
|
}
|
|
files={<MessageComposerFiles />}
|
|
/>
|
|
);
|
|
};
|
|
|
|
export default memo(MessageBox);
|