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,110 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MessageBox from './MessageBox';
import type { createComposerAPI } from './createComposerAPI';
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
import { useFileUpload } from '../../body/hooks/useFileUpload';
import { useChat } from '../../contexts/ChatContext';
import { useComposerPopupOptions } from '../../contexts/ComposerPopupContext';
import { useRoom, useRoomSubscription } from '../../contexts/RoomContext';
jest.mock('../../../../lib/rooms/roomCoordinator', () => ({
roomCoordinator: { getRoomDirectives: () => ({ canSendMessage: () => true }) },
}));
jest.mock('../../../../lib/getURL', () => ({ getURL: (url: string) => url }));
jest.mock('../../hooks/useE2EERoomState', () => ({ useE2EERoomState: jest.fn() }));
jest.mock('../../contexts/ChatContext', () => ({ useChat: jest.fn() }));
jest.mock('../../contexts/RoomContext', () => ({ useRoom: jest.fn(), useRoomSubscription: jest.fn() }));
jest.mock('../../contexts/ComposerPopupContext', () => ({ useComposerPopupOptions: jest.fn() }));
jest.mock('../../body/hooks/useFileUpload', () => ({ useFileUpload: jest.fn() }));
const createChatStub = () => {
const chat = {
composer: undefined as ReturnType<typeof createComposerAPI> | undefined,
setComposerAPI: (composer?: ReturnType<typeof createComposerAPI>) => {
chat.composer?.release();
chat.composer = composer;
},
currentEditingMessage: { getMID: () => undefined },
action: { start: jest.fn(), stop: jest.fn(), performContinuously: jest.fn() },
emojiPicker: { open: jest.fn(), close: jest.fn() },
flows: {},
};
return chat as unknown as ChatAPI & { composer?: ReturnType<typeof createComposerAPI> };
};
const renderMessageBox = (onSend: jest.Mock, endpointHandler: jest.Mock) => {
const chat = createChatStub();
(useChat as jest.Mock).mockReturnValue(chat);
const view = render(<MessageBox showFormattingTips={false} onSend={onSend} />, {
wrapper: mockAppRoot().withUserPreference('sendOnEnter', 'normal').withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(),
});
return { chat, ...view };
};
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'rid', t: 'c', name: 'general' });
(useRoomSubscription as jest.Mock).mockReturnValue({ rid: 'rid' });
(useComposerPopupOptions as jest.Mock).mockReturnValue([]);
(useFileUpload as jest.Mock).mockReturnValue({
hasUploads: false,
handleUploadFiles: jest.fn(),
isUploading: false,
isProcessingUploads: false,
});
});
afterEach(() => {
localStorage.clear();
jest.clearAllMocks();
});
describe('MessageBox drafts', () => {
it('should not discard a newer draft when a pending send resolves after the room changed', async () => {
const user = userEvent.setup();
const endpointHandler = jest.fn(() => null);
let resolveSend: (() => void) | undefined;
// Mirrors the real send flow: it clears the composer, then awaits the server response.
const onSend = jest.fn(() => {
chat.composer?.clear();
return new Promise<void>((resolve) => {
resolveSend = () => resolve();
});
});
const { chat, unmount } = renderMessageBox(onSend, endpointHandler);
const composer = screen.getByRole('textbox', { name: 'Message #general' });
// Send a message and keep its response pending.
await user.type(composer, 'message A');
// The composer listens for `which`, which userEvent does not set.
// eslint-disable-next-line testing-library/prefer-user-event
fireEvent.keyDown(composer, { key: 'Enter', which: 13, keyCode: 13 });
await waitFor(() => expect(onSend).toHaveBeenCalledWith(expect.objectContaining({ value: 'message A' })));
// Type a new draft while the send is still in flight.
await user.type(composer, 'draft B');
await waitFor(() => expect(localStorage.getItem('messagebox_rid')).toBe('draft B'));
// Leave the room: the draft is flushed to the server on unmount.
unmount();
await waitFor(() => expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'draft B' }));
// The delayed send finally resolves.
await act(async () => {
resolveSend?.();
});
expect(endpointHandler).toHaveBeenCalledTimes(1);
expect(endpointHandler).not.toHaveBeenCalledWith(expect.objectContaining({ draft: '' }));
});
});

View File

@@ -0,0 +1,435 @@
/* 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);

View File

@@ -0,0 +1,168 @@
import type { IRoom, IMessage } from '@rocket.chat/core-typings';
import { isTruthy } from '@rocket.chat/tools';
import { GenericMenu, type GenericMenuItemProps } from '@rocket.chat/ui-client';
import { MessageComposerAction, MessageComposerActionsDivider } from '@rocket.chat/ui-composer';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation, useLayoutHiddenActions } from '@rocket.chat/ui-contexts';
import type { MouseEvent } from 'react';
import { memo } from 'react';
import { useAudioMessageAction } from './hooks/useAudioMessageAction';
import { useCreateDiscussionAction } from './hooks/useCreateDiscussionAction';
import { useFileUploadAction } from './hooks/useFileUploadAction';
import { useShareLocationAction } from './hooks/useShareLocationAction';
import { useTimestampAction } from './hooks/useTimestampAction';
import { useVideoMessageAction } from './hooks/useVideoMessageAction';
import { useWebdavActions } from './hooks/useWebdavActions';
import { useMessageboxAppsActionButtons } from '../../../../../hooks/useMessageboxAppsActionButtons';
import { messageBox } from '../../../../../lib/messageBox';
import { useChat } from '../../../contexts/ChatContext';
import { useRoom } from '../../../contexts/RoomContext';
export type MessageBoxActionsToolbarProps = {
canSend: boolean;
isMicrophoneDenied: boolean;
variant: 'small' | 'large';
isRecording: boolean;
rid: IRoom['_id'];
tmid?: IMessage['_id'];
isEditing: boolean;
};
const isHidden = (hiddenActions: Array<string>, action: GenericMenuItemProps) => {
if (!action) {
return true;
}
return hiddenActions.includes(action.id);
};
const MessageBoxActionsToolbar = ({
canSend,
isRecording,
rid,
tmid,
variant = 'large',
isMicrophoneDenied,
isEditing = false,
}: MessageBoxActionsToolbarProps) => {
const t = useTranslation();
const chatContext = useChat();
if (!chatContext) {
throw new Error('useChat must be used within a ChatProvider');
}
const room = useRoom();
const disableBasicActions = !canSend || isRecording || isEditing;
const audioMessageAction = useAudioMessageAction(disableBasicActions || isMicrophoneDenied, isMicrophoneDenied);
const videoMessageAction = useVideoMessageAction(disableBasicActions);
const fileUploadAction = useFileUploadAction(disableBasicActions);
const webdavActions = useWebdavActions(disableBasicActions);
const createDiscussionAction = useCreateDiscussionAction(disableBasicActions, room);
const shareLocationAction = useShareLocationAction(disableBasicActions, room, tmid);
const timestampAction = useTimestampAction(!canSend || isRecording, chatContext.composer);
const apps = useMessageboxAppsActionButtons();
const { composerToolbox: hiddenActions } = useLayoutHiddenActions();
const allActions = {
...(!isHidden(hiddenActions, audioMessageAction) && { audioMessageAction }),
...(!isHidden(hiddenActions, videoMessageAction) && { videoMessageAction }),
...(!isHidden(hiddenActions, fileUploadAction) && { fileUploadAction }),
...(!isHidden(hiddenActions, createDiscussionAction) && { createDiscussionAction }),
...(!isHidden(hiddenActions, shareLocationAction) && { shareLocationAction }),
...(timestampAction && !isHidden(hiddenActions, timestampAction) && { timestampAction }),
...(!hiddenActions.includes('webdav-add') && webdavActions && { webdavActions }),
};
const featured = [];
const createNew = [];
const share = [];
const insert = [];
createNew.push(allActions.createDiscussionAction);
if (allActions.timestampAction) {
insert.push(allActions.timestampAction);
}
if (variant === 'small') {
featured.push(allActions.audioMessageAction, allActions.fileUploadAction);
createNew.push(allActions.videoMessageAction);
} else {
featured.push(allActions.audioMessageAction, allActions.videoMessageAction, allActions.fileUploadAction);
}
if (allActions.webdavActions) {
createNew.push(...allActions.webdavActions);
}
share.push(allActions.shareLocationAction);
const groups = {
...(apps.isSuccess &&
apps.data.length > 0 && {
Apps: apps.data,
}),
...messageBox.actions.get(),
};
const messageBoxActions = Object.entries(groups).map(([name, group]) => {
const items: GenericMenuItemProps[] = group
.filter((item) => !hiddenActions.includes(item.id))
.map((item) => ({
id: item.id,
icon: item.icon,
content: t(item.label),
onClick: (event?: MouseEvent<HTMLElement>) =>
item.action({
rid,
tmid,
event: event as unknown as Event,
chat: chatContext,
}),
gap: Boolean(!item.icon),
disabled: disableBasicActions,
}));
return {
title: t(name as TranslationKey),
items: items || [],
};
});
const createNewFiltered = createNew.filter(isTruthy);
const shareFiltered = share.filter(isTruthy);
const insertFiltered = insert.filter(isTruthy);
const renderAction = ({ id, icon, content, disabled, onClick }: GenericMenuItemProps) => {
if (!icon) {
return;
}
return <MessageComposerAction key={id} icon={icon} data-qa-id={id} title={content as string} disabled={disabled} onClick={onClick} />;
};
return (
<>
<MessageComposerActionsDivider />
{featured.map((action) => action && renderAction(action))}
<GenericMenu
disabled={isRecording || !canSend}
data-qa-id='menu-more-actions'
detached
icon='plus'
sections={[
{ title: t('Create_new'), items: createNewFiltered },
{ title: t('Share'), items: shareFiltered },
...(insertFiltered.length > 0 ? [{ title: t('Insert'), items: insertFiltered }] : []),
...messageBoxActions,
]}
title={t('More_actions')}
/>
</>
);
};
export default memo(MessageBoxActionsToolbar);

View File

@@ -0,0 +1,64 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo } from 'react';
import { AudioRecorder } from '../../../../../../lib/AudioRecorder';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
const audioRecorder = new AudioRecorder();
export const useAudioMessageAction = (disabled: boolean, isMicrophoneDenied: boolean): GenericMenuItemProps => {
const isFileUploadEnabled = useSetting('FileUpload_Enabled', true);
const isAudioRecorderEnabled = useSetting('Message_AudioRecorderEnabled', true);
const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList', '');
const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList', '');
const [isPermissionDenied] = useMediaPermissions('microphone');
const isAllowed = useMemo(
() =>
Boolean(
audioRecorder.isSupported() &&
!isMicrophoneDenied &&
isFileUploadEnabled &&
isAudioRecorderEnabled &&
!fileUploadMediaTypeBlackList?.match(/audio\/mp3|audio\/\*/i) &&
(!fileUploadMediaTypeWhiteList || fileUploadMediaTypeWhiteList.match(/audio\/mp3|audio\/\*/i)),
),
[fileUploadMediaTypeBlackList, fileUploadMediaTypeWhiteList, isAudioRecorderEnabled, isFileUploadEnabled, isMicrophoneDenied],
);
const getMediaActionTitle = useMediaActionTitle('audio', isPermissionDenied, isFileUploadEnabled, isAudioRecorderEnabled, isAllowed);
const chat = useChat();
const stopRecording = useStableCallback(() => {
chat?.action.stop('recording');
chat?.composer?.setRecordingMode(false);
});
const setMicrophoneDenied = useStableCallback((isDenied: boolean) => {
if (isDenied) {
stopRecording();
}
chat?.composer?.setIsMicrophoneDenied(isDenied);
});
useEffect(() => {
setMicrophoneDenied(isPermissionDenied);
}, [setMicrophoneDenied, isPermissionDenied]);
const handleRecordButtonClick = () => chat?.composer?.setRecordingMode(true);
return {
id: 'audio-message',
content: getMediaActionTitle,
icon: 'mic',
disabled: !isAllowed || Boolean(disabled),
onClick: handleRecordButtonClick,
};
};

View File

@@ -0,0 +1,34 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation, useSetting, usePermission, useSetModal } from '@rocket.chat/ui-contexts';
import CreateDiscussion from '../../../../../../components/CreateDiscussion';
export const useCreateDiscussionAction = (disabled: boolean, room?: IRoom): GenericMenuItemProps => {
const t = useTranslation();
const setModal = useSetModal();
if (!room) {
throw new Error('Invalid room');
}
const handleCreateDiscussion = () =>
setModal(
<CreateDiscussion onClose={() => setModal(null)} defaultParentRoom={room?.prid || room?._id} encryptedParentRoom={room?.encrypted} />,
);
const discussionEnabled = useSetting('Discussion_enabled', true);
const canStartDiscussion = usePermission('start-discussion', room._id);
const canSstartDiscussionOtherUser = usePermission('start-discussion-other-user', room._id);
const allowDiscussion = room && discussionEnabled && !isRoomFederated(room) && (canStartDiscussion || canSstartDiscussionOtherUser);
return {
id: 'create-discussion',
content: t('Discussion'),
icon: 'discussion',
disabled: !allowDiscussion || disabled,
onClick: handleCreateDiscussion,
};
};

View File

@@ -0,0 +1,52 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useFileInput } from '../../../../../../hooks/useFileInput';
import { useChat } from '../../../../contexts/ChatContext';
const fileInputProps = { type: 'file', multiple: true };
export const useFileUploadAction = (disabled: boolean): GenericMenuItemProps => {
const { t } = useTranslation();
const fileUploadEnabled = useSetting('FileUpload_Enabled', true);
const fileInputRef = useFileInput(fileInputProps);
const chat = useChat();
useEffect(() => {
const resetFileInput = () => {
if (!fileInputRef?.current) {
return;
}
fileInputRef.current.value = '';
};
const handleUploadChange = async () => {
const { getMimeType } = await import('../../../../../../../app/utils/lib/mimeTypes');
const filesToUpload = Array.from(fileInputRef?.current?.files ?? []).map((file) => {
Object.defineProperty(file, 'type', {
value: getMimeType(file.type, file.name),
});
return file;
});
chat?.flows.uploadFiles({ files: filesToUpload, resetFileInput });
};
fileInputRef.current?.addEventListener('change', handleUploadChange);
return () => fileInputRef?.current?.removeEventListener('change', handleUploadChange);
}, [chat, fileInputRef]);
const handleUpload = () => {
fileInputRef?.current?.click();
};
return {
id: 'file-upload',
content: t('Upload_file'),
icon: 'clip',
onClick: handleUpload,
disabled: !fileUploadEnabled || disabled,
};
};

View File

@@ -0,0 +1,33 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import { isRoomFederated } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting, useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import ShareLocationModal from '../../../../ShareLocation/ShareLocationModal';
export const useShareLocationAction = (disabled: boolean, room?: IRoom, tmid?: IMessage['tmid']): GenericMenuItemProps => {
if (!room) {
throw new Error('Invalid room');
}
const { t } = useTranslation();
const setModal = useSetModal();
const isMapViewEnabled = useSetting('MapView_Enabled') === true;
const isGeolocationCurrentPositionSupported = Boolean(navigator.geolocation?.getCurrentPosition);
const googleMapsApiKey = useSetting('MapView_GMapsAPIKey', '');
const canGetGeolocation = isMapViewEnabled && isGeolocationCurrentPositionSupported && googleMapsApiKey && googleMapsApiKey.length;
const handleShareLocation = () => setModal(<ShareLocationModal rid={room._id} tmid={tmid} onClose={() => setModal(null)} />);
const allowGeolocation = room && canGetGeolocation && !isRoomFederated(room);
return {
id: 'share-location',
content: t('Location'),
icon: 'map-pin',
onClick: handleShareLocation,
disabled: !allowGeolocation || disabled,
};
};

View File

@@ -0,0 +1,27 @@
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { TimestampPickerModal } from '../../../../../../components/message/toolbar/items/actions/Timestamp/TimestampPicker/TimestampPickerModal';
import type { ComposerAPI } from '../../../../../../lib/chats/ChatAPI';
export const useTimestampAction = (disabled: boolean, composer: ComposerAPI | undefined): GenericMenuItemProps | undefined => {
const setModal = useSetModal();
const { t } = useTranslation();
const handleClick = () => {
if (!composer) {
return;
}
setModal(<TimestampPickerModal onClose={() => setModal(null)} composer={composer} />);
};
return {
id: 'timestamp',
icon: 'clock',
content: t('Timestamp'),
onClick: handleClick,
disabled,
};
};

View File

@@ -0,0 +1,62 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetting } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo } from 'react';
import { VideoRecorder } from '../../../../../../lib/videoRecorder';
import { useChat } from '../../../../contexts/ChatContext';
import { useMediaActionTitle } from '../../hooks/useMediaActionTitle';
import { useMediaPermissions } from '../../hooks/useMediaPermissions';
export const useVideoMessageAction = (disabled: boolean): GenericMenuItemProps => {
const isFileUploadEnabled = useSetting('FileUpload_Enabled', true);
const isVideoRecorderEnabled = useSetting('Message_VideoRecorderEnabled', true);
const fileUploadMediaTypeBlackList = useSetting('FileUpload_MediaTypeBlackList', 'image/svg+xml');
const fileUploadMediaTypeWhiteList = useSetting('FileUpload_MediaTypeWhiteList', '');
const [isPermissionDenied, setIsPermissionDenied] = useMediaPermissions('camera');
const isAllowed = useMemo(
() =>
Boolean(
!isPermissionDenied &&
navigator.mediaDevices &&
window.MediaRecorder &&
isFileUploadEnabled &&
isVideoRecorderEnabled &&
!fileUploadMediaTypeBlackList?.match(/video\/webm|video\/\*/i) &&
(!fileUploadMediaTypeWhiteList || fileUploadMediaTypeWhiteList.match(/video\/webm|video\/\*/i)) &&
Boolean(VideoRecorder.getSupportedMimeTypes()),
),
[fileUploadMediaTypeBlackList, fileUploadMediaTypeWhiteList, isFileUploadEnabled, isPermissionDenied, isVideoRecorderEnabled],
);
const getMediaActionTitle = useMediaActionTitle('video', isPermissionDenied, isFileUploadEnabled, isVideoRecorderEnabled, isAllowed);
const chat = useChat();
const handleOpenVideoMessage = () => {
if (!chat?.composer?.recordingVideo.get()) {
chat?.composer?.setRecordingVideo(true);
}
};
const handleDenyVideo = useStableCallback((isDenied: boolean) => {
if (isDenied) {
chat?.composer?.setRecordingVideo(false);
}
setIsPermissionDenied(isDenied);
});
useEffect(() => {
handleDenyVideo(isPermissionDenied);
}, [handleDenyVideo, isPermissionDenied]);
return {
id: 'video-message',
content: getMediaActionTitle,
icon: 'video-message',
disabled: !isAllowed || Boolean(disabled),
onClick: handleOpenVideoMessage,
};
};

View File

@@ -0,0 +1,45 @@
import type { IWebdavAccountIntegration } from '@rocket.chat/core-typings';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useSetModal, useSetting } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useWebDAVAccountIntegrationsQuery } from '../../../../../../hooks/webdav/useWebDAVAccountIntegrationsQuery';
import { useChat } from '../../../../contexts/ChatContext';
import AddWebdavAccountModal from '../../../../webdav/AddWebdavAccountModal';
import WebdavFilePickerModal from '../../../../webdav/WebdavFilePickerModal';
export const useWebdavActions = (disabled: boolean): GenericMenuItemProps[] => {
const enabled = useSetting('Webdav_Integration_Enabled', false);
const { isSuccess, data } = useWebDAVAccountIntegrationsQuery({ enabled });
const chat = useChat();
const { t } = useTranslation();
const setModal = useSetModal();
const handleAddWebDav = () => setModal(<AddWebdavAccountModal onClose={() => setModal(null)} onConfirm={() => setModal(null)} />);
const handleUpload = async (file: File) => chat?.flows.uploadFiles({ files: [file] });
const handleOpenWebdav = (account: IWebdavAccountIntegration) =>
setModal(<WebdavFilePickerModal account={account} onUpload={handleUpload} onClose={() => setModal(null)} />);
return [
{
id: 'webdav-add',
content: t('Add_Server'),
icon: 'cloud-plus',
disabled: !isSuccess,
onClick: handleAddWebDav,
},
...(isSuccess
? data.map((account) => ({
id: account._id,
content: account.name,
icon: 'cloud-plus' as const,
disabled,
onClick: () => handleOpenWebdav(account),
}))
: []),
];
};

View File

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

View File

@@ -0,0 +1,184 @@
import type { IMessage, IRoom } from '@rocket.chat/core-typings';
import {
MessageComposerAction,
MessageComposerToolbarActions,
MessageComposer,
MessageComposerToolbar,
MessageComposerActionsDivider,
MessageComposerToolbarSubmit,
MessageComposerButton,
} from '@rocket.chat/ui-composer';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { MouseEvent, ReactNode, RefObject } from 'react';
import MessageBoxActionsToolbar from './MessageBoxActionsToolbar';
import MessageBoxFormattingToolbar from './MessageBoxFormattingToolbar';
import MessageBoxHint from './MessageBoxHint';
import MessageBoxReplies from './MessageBoxReplies';
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../lib/messageBoxFormatting';
import AudioMessageRecorder from '../../../composer/AudioMessageRecorder';
import VideoMessageRecorder from '../../../composer/VideoMessageRecorder';
import ComposerBoxPopup from '../ComposerBoxPopup';
import ComposerBoxPopupPreview from '../ComposerBoxPopupPreview';
import ComposerUserActionIndicator from '../ComposerUserActionIndicator';
import type { useComposerBoxPopup } from '../hooks/useComposerBoxPopup';
type MessageBoxBaseProps = {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
input: ReactNode;
hint?: ReactNode;
files?: ReactNode;
composer?: ComposerAPI;
messageComposerRef: RefObject<HTMLElement | null>;
popup: ReturnType<typeof useComposerBoxPopup>;
shouldPopupPreview?: boolean;
isEditing: boolean;
isRecording: boolean;
isRecordingAudio: boolean;
isRecordingVideo: boolean;
isMicrophoneDenied: boolean;
formatters: FormattingButton[];
canSend: boolean;
useEmojis?: boolean;
sendEnabled: boolean;
sendActive: boolean;
inlineSize: number;
e2eEnabled: boolean;
unencryptedMessagesAllowed: boolean;
isMobile: boolean;
joinPending: boolean;
onEmojiClick: (e: MouseEvent<HTMLElement>) => void;
onSend: () => void;
onJoin?: () => Promise<void>;
closeEditing: (event: MouseEvent<HTMLElement>) => void;
};
const MessageBoxBase = ({
rid,
tmid,
input,
hint,
files,
composer,
messageComposerRef,
popup,
shouldPopupPreview,
isEditing,
isRecording,
isRecordingAudio,
isRecordingVideo,
isMicrophoneDenied,
formatters,
canSend,
useEmojis,
sendEnabled,
sendActive,
inlineSize,
e2eEnabled,
unencryptedMessagesAllowed,
isMobile,
joinPending,
onEmojiClick,
onSend,
onJoin,
closeEditing,
}: MessageBoxBaseProps) => {
const t = useTranslation();
const variant = inlineSize < 480 ? 'small' : 'large';
return (
<>
{composer?.quotedMessages && <MessageBoxReplies />}
{shouldPopupPreview && popup.option && (
<ComposerBoxPopup
select={popup.select}
items={popup.items}
focused={popup.focused}
title={popup.option.title}
renderItem={popup.option.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.option?.preview && (
<ComposerBoxPopupPreview
select={popup.select}
items={popup.items as any}
focused={popup.focused as any}
title={popup.option.title}
renderItem={popup.option.renderItem}
ref={popup.commandsRef}
rid={rid}
tmid={tmid}
suspended={popup.suspended}
/>
)}
{hint}
<MessageBoxHint
isEditing={isEditing}
e2eEnabled={e2eEnabled}
unencryptedMessagesAllowed={unencryptedMessagesAllowed}
isMobile={isMobile}
/>
{isRecordingVideo && <VideoMessageRecorder reference={messageComposerRef} rid={rid} tmid={tmid} />}
<MessageComposer ref={messageComposerRef} variant={isEditing ? 'editing' : undefined}>
{isRecordingAudio && <AudioMessageRecorder rid={rid} isMicrophoneDenied={isMicrophoneDenied} />}
{input}
{files}
<MessageComposerToolbar>
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
<MessageComposerAction
icon='emoji'
disabled={!useEmojis || isRecording || !canSend}
onClick={onEmojiClick}
title={t('Emoji')}
/>
<MessageComposerActionsDivider />
{composer && formatters.length > 0 && (
<MessageBoxFormattingToolbar composer={composer} variant={variant} items={formatters} disabled={isRecording || !canSend} />
)}
<MessageBoxActionsToolbar
canSend={canSend}
isMicrophoneDenied={isMicrophoneDenied}
rid={rid}
tmid={tmid}
isRecording={isRecording}
variant={variant}
isEditing={isEditing}
/>
</MessageComposerToolbarActions>
<MessageComposerToolbarSubmit>
{!canSend && (
<MessageComposerButton primary onClick={onJoin} loading={joinPending}>
{t('Join')}
</MessageComposerButton>
)}
{canSend && (
<>
{isEditing && <MessageComposerButton onClick={closeEditing}>{t('Cancel')}</MessageComposerButton>}
<MessageComposerAction
aria-label={t('Send')}
icon='send'
disabled={!sendEnabled}
onClick={onSend}
secondary={sendActive}
info={sendActive}
/>
</>
)}
</MessageComposerToolbarSubmit>
</MessageComposerToolbar>
</MessageComposer>
<ComposerUserActionIndicator rid={rid} tmid={tmid} />
</>
);
};
export default MessageBoxBase;

View File

@@ -0,0 +1,42 @@
import { GenericMenu } from '@rocket.chat/ui-client';
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI';
import { isPromptButton, type FormattingButton } from '../../../../../lib/messageBoxFormatting';
export type FormattingToolbarDropdownProps = {
composer: ComposerAPI;
items: FormattingButton[];
disabled: boolean;
};
const FormattingToolbarDropdown = ({ composer, items, disabled }: FormattingToolbarDropdownProps) => {
const { t } = useTranslation();
const formattingItems: GenericMenuItemProps[] = items.map((formatter) => {
const handleFormattingAction = () => {
if ('link' in formatter) {
window.open(formatter.link, '_blank', 'rel=noreferrer noopener');
return;
}
if (isPromptButton(formatter)) {
return formatter.prompt(composer);
}
composer.wrapSelection(formatter.pattern);
};
return {
id: `formatter-${formatter.label}`,
content: t(formatter.label),
icon: 'icon' in formatter ? formatter.icon : 'link',
onClick: () => handleFormattingAction(),
};
});
const sections = [{ title: t('Message_Formatting_toolbox'), items: formattingItems }];
return <GenericMenu title={t('Message_Formatting_toolbox')} disabled={disabled} detached icon='meatballs' sections={sections} />;
};
export default FormattingToolbarDropdown;

View File

@@ -0,0 +1,75 @@
import { MessageComposerAction } from '@rocket.chat/ui-composer';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import FormattingToolbarDropdown from './FormattingToolbarDropdown';
import type { ComposerAPI } from '../../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../../lib/messageBoxFormatting';
import { isPromptButton } from '../../../../../lib/messageBoxFormatting';
export type MessageBoxFormattingToolbarProps = {
composer: ComposerAPI;
variant?: 'small' | 'large';
items: FormattingButton[];
disabled: boolean;
};
const MessageBoxFormattingToolbar = ({ items, variant = 'large', composer, disabled }: MessageBoxFormattingToolbarProps) => {
const { t } = useTranslation();
if (variant === 'small') {
const collapsedItems = [...items];
const featuredFormatter = collapsedItems.splice(0, 1)[0];
return (
<>
{'icon' in featuredFormatter && (
<MessageComposerAction
onClick={() =>
isPromptButton(featuredFormatter) ? featuredFormatter.prompt(composer) : composer.wrapSelection(featuredFormatter.pattern)
}
icon={featuredFormatter.icon}
title={t(featuredFormatter.label)}
disabled={disabled}
/>
)}
<FormattingToolbarDropdown composer={composer} items={collapsedItems} disabled={disabled} />
</>
);
}
return (
<>
{items.map((formatter) =>
'icon' in formatter ? (
<MessageComposerAction
disabled={disabled}
icon={formatter.icon}
key={formatter.label}
data-id={formatter.label}
title={t(formatter.label)}
onClick={(): void => {
if (isPromptButton(formatter)) {
formatter.prompt(composer);
return;
}
if ('link' in formatter) {
window.open(formatter.link, '_blank', 'rel=noreferrer noopener');
return;
}
composer.wrapSelection(formatter.pattern);
}}
/>
) : (
<span key={formatter.label} {...(disabled && { style: { pointerEvents: 'none' } })} title={formatter.label}>
<a href={formatter.link} target='_blank' rel='noopener noreferrer'>
{formatter.text()}
</a>
</span>
),
)}
</>
);
};
export default memo(MessageBoxFormattingToolbar);

View File

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

View File

@@ -0,0 +1,106 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import MessageBoxHint from './MessageBoxHint';
import { useRoom } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
jest.mock('../../hooks/useE2EERoomState', () => ({
useE2EERoomState: jest.fn(),
}));
jest.mock('../../contexts/RoomContext', () => ({
useRoom: jest.fn(),
}));
const renderOptions = {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Editing_message: 'Editing message',
Editing_message_hint: '<strong>esc</strong> to cancel · <strong>enter</strong> to save',
This_room_is_read_only: 'This room is read only',
E2EE_Composer_Unencrypted_Message: "You're sending an unencrypted message",
})
.build(),
};
describe('MessageBoxHint', () => {
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: false });
(useE2EERoomState as jest.Mock).mockReturnValue('WAITING_KEYS');
});
describe('Editing message', () => {
it('renders hint text when isEditing is true', () => {
render(<MessageBoxHint isEditing={true} isMobile={false} />, renderOptions);
expect(screen.getByText('Editing message')).toBeInTheDocument();
expect(screen.getByText('Editing message')).toBeInTheDocument();
});
it('renders helpText when isEditing is true and it is not mobile', () => {
render(<MessageBoxHint isEditing={true} isMobile={false} />, renderOptions);
expect(screen.getByText(/to save/)).toBeInTheDocument();
});
it('renders hint without helpText when isEditing is true and it is mobile', () => {
render(<MessageBoxHint isEditing={true} isMobile={true} />, renderOptions);
expect(screen.queryByText(/to save/)).not.toBeInTheDocument();
});
});
describe('Read only', () => {
beforeEach(() => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
});
it('renders hint text when Read only is true', () => {
render(<MessageBoxHint />, renderOptions);
expect(screen.getByText('This room is read only')).toBeInTheDocument();
});
});
describe('Unencrypted message', () => {
it('renders hint text when E2EE room with unencrypted messages is true', () => {
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText("You're sending an unencrypted message")).toBeInTheDocument();
});
it('renders "Read only" hint text when E2EE room with unencrypted messages is true', () => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText('This room is read only')).toBeInTheDocument();
});
it('renders "Editing message" hint text when isEditing is truem, E2EE is enabled and unencrypted messages is true', () => {
(useRoom as jest.Mock).mockReturnValue({ _id: 'roomId', ro: true });
render(<MessageBoxHint isEditing={true} e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.getByText('Editing message')).toBeInTheDocument();
});
it('does not renders hint text when E2ERoomState is READY', () => {
(useE2EERoomState as jest.Mock).mockReturnValue('READY');
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when E2ERoomState is DISABLED', () => {
(useE2EERoomState as jest.Mock).mockReturnValue('DISABLED');
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when unencrypted messages is true and E2EE is disabled', () => {
render(<MessageBoxHint e2eEnabled={false} unencryptedMessagesAllowed={true} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
it('does not renders hint text when unencrypted messages is false and E2EE is enabled', () => {
render(<MessageBoxHint e2eEnabled={true} unencryptedMessagesAllowed={false} />, renderOptions);
expect(screen.queryByText("You're sending an unencrypted message")).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,58 @@
import { MessageComposerHint } from '@rocket.chat/ui-composer';
import { memo } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useRoom } from '../../contexts/RoomContext';
import { useE2EERoomState } from '../../hooks/useE2EERoomState';
export type MessageBoxHintProps = {
isEditing?: boolean;
e2eEnabled?: boolean;
unencryptedMessagesAllowed?: boolean;
isMobile?: boolean;
};
const MessageBoxHint = ({ isEditing, e2eEnabled, unencryptedMessagesAllowed, isMobile }: MessageBoxHintProps) => {
const room = useRoom();
const isReadOnly = room?.ro || false;
const { t } = useTranslation();
const e2eRoomState = useE2EERoomState(room._id);
const isUnencryptedHintVisible =
e2eEnabled &&
unencryptedMessagesAllowed &&
e2eRoomState &&
e2eRoomState !== 'READY' &&
e2eRoomState !== 'DISABLED' &&
!isEditing &&
!isReadOnly;
if (!isEditing && !isUnencryptedHintVisible && !isReadOnly) {
return null;
}
const renderHintText = (): string => {
if (isEditing) {
return t('Editing_message');
}
if (isReadOnly) {
return t('This_room_is_read_only');
}
if (isUnencryptedHintVisible) {
return t('E2EE_Composer_Unencrypted_Message');
}
return '';
};
return (
<MessageComposerHint
icon={isEditing ? 'pencil' : undefined}
helperText={isEditing && !isMobile ? <Trans i18nKey='Editing_message_hint' /> : undefined}
>
{renderHintText()}
</MessageComposerHint>
);
};
export default memo(MessageBoxHint);

View File

@@ -0,0 +1,29 @@
import { Box } from '@rocket.chat/fuselage';
import { memo, useSyncExternalStore } from 'react';
import MessageBoxReply from './MessageBoxReply';
import { useChat } from '../../contexts/ChatContext';
const MessageBoxReplies = () => {
const chat = useChat();
if (!chat?.composer?.quotedMessages) {
throw new Error('Chat context not found');
}
const replies = useSyncExternalStore(chat.composer.quotedMessages.subscribe, chat.composer.quotedMessages.get);
if (!replies.length) {
return null;
}
return (
<Box marginBlockEnd={8} position='relative' overflowY='auto' maxHeight='x256'>
{replies.map((reply) => (
<MessageBoxReply key={reply._id} reply={reply} />
))}
</Box>
);
};
export default memo(MessageBoxReplies);

View File

@@ -0,0 +1,58 @@
import type { IMessage, MessageQuoteAttachment } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { IconButton, Box, Margins } from '@rocket.chat/fuselage';
import { useUserDisplayName } from '@rocket.chat/ui-client';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { QuoteAttachment } from '../../../../components/message/content/attachments/QuoteAttachment';
import AttachmentProvider from '../../../../providers/AttachmentProvider';
import { useChat } from '../../contexts/ChatContext';
export type MessageBoxReplyProps = { reply: IMessage };
const MessageBoxReply = ({ reply }: MessageBoxReplyProps) => {
const { t } = useTranslation();
const chat = useChat();
const displayName = useUserDisplayName(reply?.u);
const closeWrapperStyle = css`
position: absolute;
right: 0.5rem;
top: 0.75rem;
`;
return (
<Margins block={4}>
<Box display='flex' position='relative'>
<AttachmentProvider>
<QuoteAttachment
attachment={
{
text: reply.msg,
md: reply.md,
author_name: reply.alias || displayName,
author_icon: `/avatar/${reply.u.username}`,
ts: reply.ts,
attachments: reply?.attachments?.map((obj) => ({ ...obj, collapsed: true })),
collapsed: true,
} as MessageQuoteAttachment
}
/>
</AttachmentProvider>
<Box
className={closeWrapperStyle}
data-mid={reply._id}
onClick={(): void => {
chat?.composer?.dismissQuotedMessage(reply._id);
}}
>
<IconButton mini icon='cross' aria-label={t('Dismiss_quoted_message')} />
</Box>
</Box>
</Margins>
);
};
export default memo(MessageBoxReply);

View File

@@ -0,0 +1,27 @@
import MessageComposerGenericFile from './MessageComposerGenericFile';
import MessageComposerImageFileItem from './MessageComposerImageFile';
import type { Upload } from '../../../../lib/chats/Upload';
import { MAX_FILE_SIZE_PREVIEW } from '../../../../lib/constants';
import { isPreviewableImage } from '../../../../lib/utils/isPreviewableImage';
export type MessageComposerFileItemProps = {
upload: Upload;
onRemove: (id: string) => void;
onEdit: (id: Upload['id'], fileName: string, altText?: string) => void;
onCancel: (id: Upload['id']) => void;
disabled: boolean;
shouldPreview?: boolean;
previewUrl?: string;
};
const MessageComposerFileItem = (props: MessageComposerFileItemProps) => {
const shouldPreview = isPreviewableImage(props.upload.file.type) && !(props.upload.file.size > MAX_FILE_SIZE_PREVIEW);
if (shouldPreview) {
return <MessageComposerImageFileItem {...props} />;
}
return <MessageComposerGenericFile {...props} />;
};
export default MessageComposerFileItem;

View File

@@ -0,0 +1,43 @@
import { MessageComposerFileGroup } from '@rocket.chat/ui-composer';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import MessageComposerFileItem from './MessageComposerFileItem';
import type { Upload } from '../../../../lib/chats/Upload';
import { useFileUpload } from '../../body/hooks/useFileUpload';
const MessageComposerFiles = () => {
const { t } = useTranslation();
const { uploads, uploadsStore, isProcessingUploads, hasUploads } = useFileUpload();
const handleEdit = useCallback(
(id: Upload['id'], fileName: string, altText?: string) => {
uploadsStore?.editUploadFileName(id, fileName);
if (altText !== undefined) {
uploadsStore?.editUploadAltText(id, altText);
}
},
[uploadsStore],
);
if (!uploadsStore || !hasUploads) {
return null;
}
return (
<MessageComposerFileGroup aria-label={t('Uploads')}>
{uploads.map((upload) => (
<MessageComposerFileItem
key={upload.id}
upload={upload}
onRemove={uploadsStore.removeUpload}
onEdit={handleEdit}
onCancel={uploadsStore.cancel}
disabled={isProcessingUploads}
/>
))}
</MessageComposerFileGroup>
);
};
export default MessageComposerFiles;

View File

@@ -0,0 +1,100 @@
import { IconButton } from '@rocket.chat/fuselage';
import { useButtonPattern } from '@rocket.chat/fuselage-hooks';
import { MessageComposerFile, MessageComposerFileError, MessageComposerFileLoader } from '@rocket.chat/ui-composer';
import { useSetModal } from '@rocket.chat/ui-contexts';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { MessageComposerFileItemProps } from './MessageComposerFileItem';
import { getMimeType } from '../../../../../app/utils/lib/mimeTypes';
import { getFileExtension } from '../../../../../lib/utils/getFileExtension';
import { usePreventPropagation } from '../../../../hooks/usePreventPropagation';
import { formatBytes } from '../../../../lib/utils/formatBytes';
import { useChat } from '../../contexts/ChatContext';
import FileUploadModal from '../../modals/FileUploadModal';
const MessageComposerGenericFile = ({
upload,
onRemove,
onEdit,
onCancel,
shouldPreview,
previewUrl,
disabled,
...props
}: MessageComposerFileItemProps) => {
const { t } = useTranslation();
const chat = useChat();
const [isActive, setIsActive] = useState(false);
const setModal = useSetModal();
const fileSize = formatBytes(upload.file.size, 2);
const fileExtension = getMimeType(upload.file.type, upload.file.name);
const isLoading = !upload.url && !upload.error;
const handleOpenFilePreview = () => {
if (isLoading || upload.error) {
return;
}
setModal(
<FileUploadModal
onSubmit={(name, altText) => {
onEdit(upload.id, name, altText);
setModal(null);
chat?.composer?.focus();
}}
fileName={upload.file.name}
fileAltText={upload.altText}
file={upload.file}
onClose={() => setModal(null)}
/>,
);
};
const dismissAction = isLoading ? () => onCancel(upload.id) : () => onRemove(upload.id);
const handleDismiss = usePreventPropagation(dismissAction);
const buttonProps = useButtonPattern(handleDismiss);
const actionIcon =
isLoading && !isActive ? (
<MessageComposerFileLoader />
) : (
<IconButton {...buttonProps} aria-label={isLoading ? t('Cancel') : t('Remove')} mini icon='cross' />
);
if (upload.error) {
return (
<MessageComposerFileError
fileTitle={upload.file.name}
fileFormat={getFileExtension(upload.file.name)}
error={upload.error}
actionIcon={actionIcon}
onClick={handleOpenFilePreview}
/>
);
}
return (
<MessageComposerFile
aria-label={upload.file.name}
onClick={handleOpenFilePreview}
onPointerLeave={() => setIsActive(false)}
onPointerEnter={() => setIsActive(true)}
onFocus={() => setIsActive(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget) && setIsActive(false)}
fileTitle={upload.file.name}
fileSubtitle={`${fileSize} - ${fileExtension}`}
previewUrl={shouldPreview ? previewUrl : undefined}
alt={upload.altText}
fileFormat={getFileExtension(upload.file.name)}
showPreview={shouldPreview}
actionIcon={actionIcon}
aria-busy={isLoading}
disabled={disabled}
{...props}
/>
);
};
export default MessageComposerGenericFile;

View File

@@ -0,0 +1,11 @@
import type { MessageComposerFileItemProps } from './MessageComposerFileItem';
import MessageComposerGenericFile from './MessageComposerGenericFile';
import { useFileAsDataURL } from '../../hooks/useFileAsDataURL';
const MessageComposerImageFile = ({ upload, ...props }: MessageComposerFileItemProps) => {
const [, url] = useFileAsDataURL(upload.file);
return <MessageComposerGenericFile upload={upload} shouldPreview previewUrl={typeof url === 'string' ? url : undefined} {...props} />;
};
export default MessageComposerImageFile;

View File

@@ -0,0 +1,61 @@
import { createComposerAPI } from './createComposerAPI';
jest.mock('../../../../lib/chats/uploads', () => ({
createUploadsAPI: () => ({}),
}));
const setupComposer = (initialValue: string, cursor: { start: number; end: number }) => {
const input = document.createElement('textarea');
document.body.appendChild(input);
const composer = createComposerAPI(input, jest.fn(), '', Number.MAX_SAFE_INTEGER, { current: null }, { rid: 'GENERAL' });
input.value = initialValue;
input.setSelectionRange(cursor.start, cursor.end);
return { composer, input };
};
describe('ChatMessages Composer API - replaceText', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('should place the cursor right after the mention when inserting at the start of the message', () => {
const { composer, input } = setupComposer('@jhello', { start: 2, end: 2 });
composer.replaceText('@john ', { start: 0, end: 2 });
expect(input.value).toBe('@john hello');
expect(input.selectionStart).toBe('@john '.length);
expect(input.selectionEnd).toBe('@john '.length);
});
it('should place the cursor right after the mention when inserting in the middle of the message', () => {
const { composer, input } = setupComposer('hi @jthere', { start: 5, end: 5 });
composer.replaceText('@john ', { start: 3, end: 5 });
expect(input.value).toBe('hi @john there');
expect(input.selectionStart).toBe('hi @john '.length);
expect(input.selectionEnd).toBe('hi @john '.length);
});
it('should place the cursor right after the mention when inserting at the end of the message', () => {
const { composer, input } = setupComposer('hello @j', { start: 8, end: 8 });
composer.replaceText('@john ', { start: 6, end: 8 });
expect(input.value).toBe('hello @john ');
expect(input.selectionStart).toBe('hello @john '.length);
expect(input.selectionEnd).toBe('hello @john '.length);
});
it('should keep the cursor collapsed right after the inserted text', () => {
const { composer, input } = setupComposer('@jhello', { start: 2, end: 2 });
composer.replaceText('@john ', { start: 0, end: 2 });
expect(input.selectionStart).toBe(input.selectionEnd);
});
});

View File

@@ -0,0 +1,172 @@
import type { RefObject } from 'react';
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import { createComposerAPICore, triggerEvent, type SetText } from '../../../../lib/createComposerAPICore';
import { limitQuoteChain } from '../../../../lib/limitQuoteChain';
export const createComposerAPI = (
input: HTMLTextAreaElement,
persistDraft: (value: string) => void,
initialDraft: string,
quoteChainLimit: number,
composerRef: RefObject<HTMLElement | null>,
{ rid, tmid }: { rid: string; tmid?: string },
): ComposerAPI => {
const focus = (): void => {
input.focus();
};
const setText: SetText = (text, { selection, skipFocus } = {}) => {
!skipFocus && focus();
const { selectionStart, selectionEnd } = input;
const textAreaTxt = input.value;
if (typeof selection === 'function') {
selection = selection({ start: selectionStart, end: selectionEnd });
}
if (selection) {
if (!document.execCommand?.('insertText', false, text)) {
input.value = textAreaTxt.substring(0, selectionStart) + text + textAreaTxt.substring(selectionStart);
!skipFocus && focus();
}
input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length);
}
if (!selection) {
input.value = text;
}
triggerEvent(input, 'input');
triggerEvent(input, 'change');
!skipFocus && focus();
};
const core = createComposerAPICore({
input,
composerRef,
room: { rid, tmid },
initialValue: initialDraft,
save: () => persistDraft(input.value),
setText,
focus,
prepareQuotedMessage: (message) => limitQuoteChain(message, quoteChainLimit),
});
const { insertText } = core;
const wrapSelection = (pattern: string): { selectionStart: number; selectionEnd: number; value: string } => {
const { selectionEnd = input.value.length, selectionStart = 0 } = input;
const initText = input.value.slice(0, selectionStart);
const selectedText = input.value.slice(selectionStart, selectionEnd);
const finalText = input.value.slice(selectionEnd, input.value.length);
focus();
const startPattern = pattern.slice(0, pattern.indexOf('{{text}}'));
const startPatternFound = input.value.slice(selectionStart - startPattern.length, selectionStart) === startPattern;
if (startPatternFound) {
const endPattern = pattern.slice(pattern.indexOf('{{text}}') + '{{text}}'.length);
const endPatternFound = input.value.slice(selectionEnd, selectionEnd + endPattern.length) === endPattern;
if (endPatternFound) {
insertText(selectedText);
input.selectionStart = selectionStart - startPattern.length;
input.selectionEnd = selectionEnd + endPattern.length;
if (!document.execCommand?.('insertText', false, selectedText)) {
input.value = initText.slice(0, initText.length - startPattern.length) + selectedText + finalText.slice(endPattern.length);
}
input.selectionStart = selectionStart - startPattern.length;
input.selectionEnd = input.selectionStart + selectedText.length;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
return {
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
value: input.value,
};
}
}
if (!document.execCommand?.('insertText', false, pattern.replace('{{text}}', selectedText))) {
input.value = initText + pattern.replace('{{text}}', selectedText) + finalText;
}
input.selectionStart = selectionStart + pattern.indexOf('{{text}}');
input.selectionEnd = input.selectionStart + selectedText.length;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
return {
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
value: input.value,
};
};
// Gets the text that is connected to the cursor and replaces it with the given text
const replaceText = (text: string, selection: { readonly start: number; readonly end: number }): void => {
// Selects the text that is connected to the cursor
input.setSelectionRange(selection.start ?? 0, selection.end ?? text.length);
const textAreaTxt = input.value;
if (!document.execCommand?.('insertText', false, text)) {
input.value = textAreaTxt.substring(0, selection.start) + text + textAreaTxt.substring(selection.end);
}
const cursorPosition = (selection.start ?? 0) + text.length;
input.selectionStart = cursorPosition;
input.selectionEnd = cursorPosition;
triggerEvent(input, 'input');
triggerEvent(input, 'change');
focus();
};
const replyWith = async (text: string): Promise<void> => {
setText(text);
};
return {
...core,
setText,
wrapSelection,
replaceText,
replyWith,
substring: (start: number, end?: number) => {
return input.value.substring(start, end);
},
getCursorPosition: () => {
return input.selectionStart;
},
setCursorToEnd: () => {
input.selectionEnd = input.value.length;
input.selectionStart = input.selectionEnd;
focus();
},
setCursorToStart: () => {
input.selectionStart = 0;
input.selectionEnd = input.selectionStart;
focus();
},
get text(): string {
return input.value;
},
get selection(): { start: number; end: number } {
return {
start: input.selectionStart,
end: input.selectionEnd,
};
},
};
};

View File

@@ -0,0 +1,535 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useDraft } from './useDraft';
const storageKey = (rid: string, tmid?: string) => `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
const seedLocalDraft = (value: string, rid: string, tmid?: string) => {
localStorage.setItem(storageKey(rid, tmid), value);
};
const readLocalDraft = (rid: string, tmid?: string) => localStorage.getItem(storageKey(rid, tmid));
type RenderUseDraftProps = {
serverDraft?: string;
threadExists?: boolean;
};
const renderUseDraft = ({
rid = 'rid',
serverDraft,
tmid,
threadExists,
endpointHandler = jest.fn(() => null),
}: {
rid?: string;
tmid?: string;
endpointHandler?: jest.Mock;
} & RenderUseDraftProps = {}) => {
const { result, rerender } = renderHook<ReturnType<typeof useDraft>, RenderUseDraftProps>(
({ serverDraft, threadExists }) => useDraft(rid, serverDraft, tmid, threadExists),
{
initialProps: { serverDraft, threadExists },
wrapper: mockAppRoot().withEndpoint('POST', '/v1/rooms.saveDraft', endpointHandler).build(),
},
);
return { result, rerender, endpointHandler };
};
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});
describe('initialValue', () => {
it('should be an empty string when there is no server draft nor local draft', () => {
const { result } = renderUseDraft();
expect(result.current.initialValue).toBe('');
});
it('should restore the local draft when there is no server draft', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft();
expect(result.current.initialValue).toBe('local draft');
});
it('should restore the server draft when provided', () => {
const { result } = renderUseDraft({ serverDraft: 'server draft' });
expect(result.current.initialValue).toBe('server draft');
});
it('should prefer the server draft over the local draft', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft({ serverDraft: 'server draft' });
expect(result.current.initialValue).toBe('server draft');
});
it('should fall back to the local draft when the server draft is an empty string', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft({ serverDraft: '' });
expect(result.current.initialValue).toBe('local draft');
});
it('should restore the thread-scoped local draft when a tmid is provided', () => {
seedLocalDraft('room draft', 'rid');
seedLocalDraft('thread draft', 'rid', 'tmid');
const { result } = renderUseDraft({ tmid: 'tmid' });
expect(result.current.initialValue).toBe('thread draft');
});
it('should not restore the room draft in a thread composer', () => {
seedLocalDraft('room draft', 'rid');
const { result } = renderUseDraft({ tmid: 'tmid' });
expect(result.current.initialValue).toBe('');
});
it('should remain stable across rerenders even if the server draft changes', () => {
const { result, rerender } = renderUseDraft({ serverDraft: 'first' });
rerender({ serverDraft: 'second' });
expect(result.current.initialValue).toBe('first');
});
});
describe('persistLocal', () => {
it('should write the draft to local storage under the room-scoped key', () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
});
expect(readLocalDraft('rid')).toBe('typed message');
});
it('should write the draft to local storage under the thread-scoped key when a tmid is provided', () => {
const { result } = renderUseDraft({ tmid: 'tmid' });
act(() => {
result.current.persistLocal('typed in thread');
});
expect(readLocalDraft('rid', 'tmid')).toBe('typed in thread');
expect(readLocalDraft('rid')).toBe(null);
});
it('should overwrite a previously persisted draft', () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.persistLocal('second');
});
expect(readLocalDraft('rid')).toBe('second');
});
it('should remove the local entry instead of storing an empty string', () => {
seedLocalDraft('local draft', 'rid');
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('');
});
expect(readLocalDraft('rid')).toBe(null);
});
it('should not call the save draft endpoint', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
});
expect(endpointHandler).not.toHaveBeenCalled();
});
});
describe('flushDraft', () => {
it('should not call the save draft endpoint when no draft was persisted', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not flush a draft restored from local storage that was never re-persisted', () => {
seedLocalDraft('restored draft', 'rid');
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should save the persisted draft to the server', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
});
it('should remove the local copy once the draft is saved to the server', async () => {
const { result } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
});
it('should include the tmid when flushing a thread draft', async () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid' });
act(() => {
result.current.persistLocal('typed in thread');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in thread', tmid: 'tmid' });
});
it('should flush only the latest persisted draft', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.persistLocal('second');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'second' });
});
it('should flush an empty draft to clear an existing server draft', async () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
});
it('should not call the endpoint when flushing an empty draft with no existing server draft', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not re-save a restored server draft that was not edited', () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('server draft');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not flush the same draft twice', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
});
it('should flush again after a new draft is persisted', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('first');
result.current.flushDraft();
result.current.persistLocal('second');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(2));
expect(endpointHandler).toHaveBeenNthCalledWith(1, { rid: 'rid', draft: 'first' });
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'second' });
});
it('should not save the draft to the server when the thread does not exist', () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('typed in deleted thread');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should keep the draft in local storage when the thread does not exist', () => {
const { result } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('typed in deleted thread');
result.current.flushDraft();
});
expect(readLocalDraft('rid', 'tmid')).toBe('typed in deleted thread');
});
it('should keep discarding drafts persisted after a flush while the thread does not exist', () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
act(() => {
result.current.persistLocal('first');
result.current.flushDraft();
result.current.persistLocal('second');
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should not save the draft when the thread stops existing after mount', () => {
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: true });
act(() => {
result.current.persistLocal('typed before thread deletion');
});
rerender({ threadExists: false });
act(() => {
result.current.flushDraft();
});
expect(endpointHandler).not.toHaveBeenCalled();
});
it('should save the draft when the thread starts existing after mount', async () => {
const { result, rerender, endpointHandler } = renderUseDraft({ tmid: 'tmid', threadExists: false });
rerender({ threadExists: true });
act(() => {
result.current.persistLocal('typed in new thread');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed in new thread', tmid: 'tmid' });
});
it('should still save the draft when threadExists is false but there is no tmid', async () => {
const { result, endpointHandler } = renderUseDraft({ threadExists: false });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'typed message' });
});
it('should flush the given value instead of the persisted draft', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('edited message');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'edited message' });
});
it('should clear the server draft when flushed with an empty value', async () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '' });
});
it('should clear the server thread draft when flushed with an empty value', async () => {
const { result, endpointHandler } = renderUseDraft({ tmid: 'tmid', serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed in thread');
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: '', tmid: 'tmid' });
});
it('should remove the local copy once the draft is cleared on the server', async () => {
const { result } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
});
await waitFor(() => expect(readLocalDraft('rid')).toBe(null));
});
it('should not call the endpoint when flushing an empty value with no server draft', () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('');
result.current.flushDraft('');
});
expect(endpointHandler).not.toHaveBeenCalled();
expect(readLocalDraft('rid')).toBe(null);
});
it('should remove the local copy when the flushed value already matches the server draft', () => {
const { result, endpointHandler } = renderUseDraft({ serverDraft: 'server draft' });
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('server draft');
});
expect(endpointHandler).not.toHaveBeenCalled();
expect(readLocalDraft('rid')).toBe(null);
});
it('should keep a draft persisted while the previous save is still in flight', async () => {
let resolveSave: (() => void) | undefined;
const endpointHandler = jest.fn(
() =>
new Promise<null>((resolve) => {
resolveSave = () => resolve(null);
}),
);
const { result } = renderUseDraft({ serverDraft: 'server draft', endpointHandler });
act(() => {
result.current.flushDraft('');
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
act(() => {
result.current.persistLocal('typed while sending');
});
await act(async () => {
resolveSave?.();
});
expect(readLocalDraft('rid')).toBe('typed while sending');
});
it('should keep the local copy when the save fails', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const endpointHandler = jest.fn(() => Promise.reject(new Error('save failed')) as unknown as null);
const { result } = renderUseDraft({ endpointHandler });
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(warn).toHaveBeenCalledTimes(1);
expect(readLocalDraft('rid')).toBe('typed message');
});
it('should retry a draft whose save failed and drop the local copy once it lands', async () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
const endpointHandler = jest.fn().mockRejectedValueOnce(new Error('save failed')).mockResolvedValueOnce(null);
const { result } = renderUseDraft({ endpointHandler });
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(readLocalDraft('rid')).toBe('typed message');
await act(async () => {
result.current.persistLocal('typed message');
result.current.flushDraft();
});
expect(warn).toHaveBeenCalledTimes(1);
expect(endpointHandler).toHaveBeenCalledTimes(2);
expect(endpointHandler).toHaveBeenNthCalledWith(2, { rid: 'rid', draft: 'typed message' });
expect(readLocalDraft('rid')).toBe(null);
});
it('should not flush the persisted draft after it was superseded by an explicit flush', async () => {
const { result, endpointHandler } = renderUseDraft();
act(() => {
result.current.persistLocal('typed message');
result.current.flushDraft('sent message');
result.current.flushDraft();
});
await waitFor(() => expect(endpointHandler).toHaveBeenCalledTimes(1));
expect(endpointHandler).toHaveBeenCalledWith({ rid: 'rid', draft: 'sent message' });
});
});
describe('referential stability', () => {
it('should keep persistLocal and flushDraft identities across rerenders', () => {
const { result, rerender } = renderUseDraft();
const { persistLocal, flushDraft } = result.current;
rerender({});
expect(result.current.persistLocal).toBe(persistLocal);
expect(result.current.flushDraft).toBe(flushDraft);
});
});

View File

@@ -0,0 +1,80 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useEffect, useRef } from 'react';
export const useDraft = (rid: string, serverDraft?: string, tmid?: string, threadExists = true) => {
const storageKey = `messagebox_${rid}${tmid ? `-${tmid}` : ''}`;
const saveDraft = useEndpoint('POST', '/v1/rooms.saveDraft');
const setLocalDraft = useCallback(
(value?: string) => {
if (value) {
localStorage.setItem(storageKey, value);
} else {
localStorage.removeItem(storageKey);
}
},
[storageKey],
);
const initialValueRef = useRef(serverDraft || localStorage.getItem(storageKey) || '');
const draftRef = useRef<string | null>(null);
const threadExistsRef = useRef(threadExists);
const serverValueRef = useRef(serverDraft ?? '');
useEffect(() => {
threadExistsRef.current = threadExists;
}, [threadExists]);
const persistLocal = useCallback(
(value: string) => {
draftRef.current = value;
setLocalDraft(value);
},
[setLocalDraft],
);
const flushDraft = useCallback(
(value?: string) => {
const draft = value ?? draftRef.current;
if (draft === null) {
return;
}
draftRef.current = null;
if (tmid && !threadExistsRef.current && draft) {
return;
}
if (draft === serverValueRef.current) {
setLocalDraft();
return;
}
const previousServerValue = serverValueRef.current;
serverValueRef.current = draft;
void saveDraft({ rid, draft, ...(tmid && { tmid }) })
.then(() => {
if (draftRef.current === null) {
setLocalDraft();
}
})
.catch((error) => {
if (serverValueRef.current === draft) {
serverValueRef.current = previousServerValue;
}
console.warn(error);
});
},
[saveDraft, rid, tmid, setLocalDraft],
);
return {
initialValue: initialValueRef.current,
persistLocal,
flushDraft,
};
};

View File

@@ -0,0 +1,34 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const useMediaActionTitle = (
media: 'audio' | 'video',
isPermissionDenied: boolean,
isFileUploadEnabled: boolean,
isMediaEnabled: boolean,
isAllowed: boolean,
) => {
const { t } = useTranslation();
const getMediaActionTitle = useMemo(() => {
if (isPermissionDenied) {
return media === 'audio' ? t('Microphone_access_not_allowed') : t('Camera_access_not_allowed');
}
if (!isFileUploadEnabled) {
return t('File_Upload_Disabled');
}
if (!isMediaEnabled) {
return media === 'audio' ? t('Message_Audio_Recording_Disabled') : t('Message_Video_Recording_Disabled');
}
if (!isAllowed) {
return t('error-not-allowed');
}
return media === 'audio' ? t('Audio_message') : t('Video_message');
}, [media, isPermissionDenied, isFileUploadEnabled, isMediaEnabled, isAllowed, t]);
return getMediaActionTitle;
};

View File

@@ -0,0 +1,59 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useQuery, useQueryClient } from '@tanstack/react-query';
type MediaDevices = 'camera' | 'microphone';
const getDeviceKind = (name: MediaDevices): MediaDeviceKind => {
switch (name) {
case 'camera':
return 'videoinput';
case 'microphone':
return 'audioinput';
}
};
export const useMediaPermissions = (name: MediaDevices): [isPermissionDenied: boolean, setIsPermissionDenied: (state: boolean) => void] => {
const queryClient = useQueryClient();
const queryKey = ['media-permissions', name];
const setIsPermissionDenied = useStableCallback((isDenied: boolean) => {
queryClient.setQueryData(queryKey, isDenied);
});
const { data } = useQuery({
queryKey,
queryFn: async () => {
if (navigator.permissions) {
try {
const permissionStatus = await navigator.permissions.query({ name: name as PermissionName });
permissionStatus.onchange = (): void => {
queryClient.setQueryData(queryKey, permissionStatus.state === 'denied');
};
return permissionStatus.state === 'denied';
} catch (error) {
console.warn(error);
}
}
if (!navigator.mediaDevices?.enumerateDevices) {
return true;
}
try {
if (!(await navigator.mediaDevices.enumerateDevices()).some(({ kind }) => kind === getDeviceKind(name))) {
return true;
}
} catch (error) {
console.warn(error);
}
},
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
placeholderData: false,
});
return [Boolean(data), setIsPermissionDenied];
};

View File

@@ -0,0 +1,64 @@
import type { Ref } from 'react';
import { useCallback, useEffect, useRef } from 'react';
/**
* if the user is types outside the message box and its not actually typing in any input field
* then the message box should be focused
* @returns callbackRef to bind the logic to the message box
*/
export const useMessageBoxAutoFocus = (enabled: boolean): Ref<HTMLElement> => {
const ref = useRef<HTMLElement>(undefined);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const { current: node } = ref;
const { target } = e;
if (!target) {
return;
}
if (target === node) {
return;
}
if (!((e.keyCode > 45 && e.keyCode < 91) || e.keyCode === 8)) {
return;
}
if (/input|textarea|select/i.test((target as HTMLElement).tagName)) {
return;
}
if (e.ctrlKey === true || e.metaKey === true) {
return;
}
node?.focus();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, []);
return useCallback(
(node: HTMLElement | null) => {
if (!node) {
return;
}
ref.current = node;
if (!enabled) {
return;
}
if (ref.current) {
ref.current.focus();
}
},
[enabled, ref],
);
};

View File

@@ -0,0 +1,18 @@
import type { IRoom } from '@rocket.chat/core-typings';
import { isDirectMessageRoom } from '@rocket.chat/core-typings';
import { useRoomName } from '../../../../../hooks/useRoomName';
export const useMessageBoxPlaceholder = (placeholder: string, room?: IRoom) => {
if (!room) {
throw new Error('In order to get the placeholder a `room` must be provided');
}
const roomName = useRoomName(room);
if (isDirectMessageRoom(room)) {
return `${placeholder} @${roomName}`;
}
return `${placeholder} #${roomName}`;
};

View File

@@ -0,0 +1,39 @@
import type { ComposerAPI } from '../../../../lib/chats/ChatAPI';
import type { FormattingButton } from '../../../../lib/messageBoxFormatting';
export const emptySubscribe = () => () => undefined;
export const getEmptyFalse = () => false;
const a: any[] = [];
export const getEmptyArray = () => a;
export const isCmdOrCtrlPressed = (event: { metaKey: boolean; ctrlKey: boolean }) => {
const isMacOS = navigator.platform.indexOf('Mac') !== -1;
return (isMacOS && event.metaKey) || (!isMacOS && event.ctrlKey);
};
export const handleFormattingShortcut = (
event: KeyboardEvent,
formattingButtons: FormattingButton[],
composer: ComposerAPI,
preventDefault = false,
) => {
if (!isCmdOrCtrlPressed(event)) {
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;
}
if (preventDefault) {
event.preventDefault();
}
composer.wrapSelection(formatter.pattern);
return true;
};

View File

@@ -0,0 +1,191 @@
import { handleSelectionWrapping } from './wrapSelection';
import type { ChatAPI, ComposerAPI } from '../../../../lib/chats/ChatAPI';
const createTextarea = (value: string, selectionStart: number, selectionEnd: number): HTMLTextAreaElement => {
const textarea = document.createElement('textarea');
textarea.value = value;
textarea.selectionStart = selectionStart;
textarea.selectionEnd = selectionEnd;
return textarea;
};
const createMockComposer = (text: string): Pick<ComposerAPI, 'text' | 'wrapSelection'> => ({
text,
wrapSelection: jest.fn(() => ({ selectionStart: 0, selectionEnd: 0, value: '' })),
});
const createInputEvent = (textarea: HTMLTextAreaElement, data: string, isComposing = false): InputEvent => {
const event = new InputEvent('input', {
data,
inputType: 'insertText',
isComposing,
bubbles: true,
cancelable: true,
});
Object.defineProperty(event, 'target', { value: textarea, writable: false });
return event;
};
describe('handleSelectionWrapping', () => {
describe('dead key / IME composition scenarios', () => {
it('should NOT wrap when the selected text is the same character just typed (dead key double-press)', () => {
// Simulates: user presses ' twice on a Brazilian/international keyboard
// After composition ends, the browser has:
// - inserted the character ' into the textarea
// - selected it (selectionStart=0, selectionEnd=1)
// - fired an InputEvent with data="'"
const textarea = createTextarea("'", 0, 1);
const composer = createMockComposer("'");
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when dead key composition produces a character mid-text', () => {
// User is typing "hello'" — the dead key inserts ' and selects it
const textarea = createTextarea("hello'", 5, 6);
const composer = createMockComposer("hello'");
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when backtick dead key is pressed twice', () => {
const textarea = createTextarea('`', 0, 1);
const composer = createMockComposer('`');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '`');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should NOT wrap when tilde dead key is pressed twice', () => {
const textarea = createTextarea('˜', 0, 1);
const composer = createMockComposer('˜');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '˜');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
});
describe('legitimate wrapping scenarios', () => {
it('should wrap when user selects text and types a wrapping character', () => {
// User selects "hello" and types '
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith("'{{text}}'");
});
it('should wrap when user selects partial text and types a wrapping character', () => {
// User selects "world" in "hello world" and types *
const textarea = createTextarea('hello world', 6, 11);
const composer = createMockComposer('hello world');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '*');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith('*{{text}}*');
});
it('should wrap with backtick when text is selected', () => {
const textarea = createTextarea('some code here', 5, 9);
const composer = createMockComposer('some code here');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, '`');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(true);
expect(composer.wrapSelection).toHaveBeenCalledWith('`{{text}}`');
});
});
describe('no-op scenarios', () => {
it('should return false when there is no selection (cursor only)', () => {
const textarea = createTextarea('hello', 3, 3);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should return false when there is no composer', () => {
const textarea = createTextarea('hello', 0, 5);
const chat = {} as unknown as ChatAPI;
const event = createInputEvent(textarea, "'");
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
});
it('should return false for non-wrapping characters', () => {
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = createInputEvent(textarea, 'a');
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
it('should return false when event.data is null', () => {
const textarea = createTextarea('hello', 0, 5);
const composer = createMockComposer('hello');
const chat = { composer } as unknown as ChatAPI;
const event = new InputEvent('input', {
data: null,
inputType: 'deleteContentBackward',
bubbles: true,
});
Object.defineProperty(event, 'target', { value: textarea, writable: false });
const result = handleSelectionWrapping(event, chat);
expect(result).toBe(false);
expect(composer.wrapSelection).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,66 @@
import type { ChatAPI } from '../../../../lib/chats/ChatAPI';
const wrapSelectionPatterns: Record<string, string> = {
'`': '`{{text}}`',
'"': '"{{text}}"',
"'": "'{{text}}'",
'(': '({{text}})',
'<': '<{{text}}>',
'{': '{{{text}}}',
'[': '[{{text}}]',
'*': '*{{text}}*',
'_': '_{{text}}_',
'~': '~{{text}}~',
'˜': '~{{text}}~',
};
const once = (target: EventTarget, eventName: string, callback: (event: Event) => void) => {
const handleEvent = (e: Event) => {
callback(e);
target.removeEventListener(eventName, handleEvent);
};
target.addEventListener(eventName, handleEvent);
};
export const handleSelectionWrapping = (event: InputEvent, chat: ChatAPI): boolean => {
const { composer } = chat;
if (!composer) {
return false;
}
const input = event.target as HTMLTextAreaElement;
const { selectionStart, selectionEnd } = input;
const testSelection = input.value.slice(selectionStart, selectionEnd);
// if the selection is the same of the data, return false
if (testSelection === event.data) {
return false;
}
if (event.data === chat.composer?.text) {
return false;
}
if (selectionStart === selectionEnd) {
return false;
}
const key = event.data;
if (!key) {
return false;
}
const pattern = wrapSelectionPatterns[key];
if (!pattern) {
return false;
}
const selection = composer.wrapSelection(pattern);
// this is a workaround when we are using MAC
if (event.isComposing) {
once(input, 'input', (event) => {
input.value = selection.value;
input.setSelectionRange(selection.selectionStart, selection.selectionEnd);
event.preventDefault();
});
}
event.preventDefault();
return true;
};