snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,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: '' }));
|
||||
});
|
||||
});
|
||||
435
apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
Normal file
435
apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx
Normal 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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MessageBoxActionsToolbar';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './MessageBoxFormattingToolbar';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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],
|
||||
);
|
||||
};
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user