Skip to content

Commit 6257e17

Browse files
authored
feat: clear command mode in message composer using keyboard (#3175)
1 parent 8fa096a commit 6257e17

2 files changed

Lines changed: 131 additions & 27 deletions

File tree

‎src/components/MessageComposer/__tests__/MessageInput.test.tsx‎

Lines changed: 119 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import React from 'react';
2-
import { SearchController } from 'stream-chat';
2+
import type {
3+
Channel as ChannelType,
4+
CommandResponse,
5+
CooldownTimerState,
6+
LinkPreview,
7+
LinkPreviewsManagerState,
8+
LocalAttachment,
9+
LocalMessage,
10+
SendFileAPIResponse,
11+
StreamChat,
12+
UserResponse,
13+
} from 'stream-chat';
14+
import { LinkPreviewStatus, SearchController } from 'stream-chat';
315
import {
416
act,
517
cleanup,
@@ -13,16 +25,18 @@ import { axe } from '../../../../axe-helper';
1325
import { nanoid } from 'nanoid';
1426
import { fromPartial } from '@total-typescript/shoehorn';
1527

28+
import type { MessageComposerProps } from '../MessageComposer';
1629
import { MessageComposer } from '../MessageComposer';
1730
import { Channel } from '../../Channel/Channel';
31+
import type { MessageActionsProps } from '../../MessageActions';
1832
import { MessageActions } from '../../MessageActions';
1933

20-
import { DialogManagerProvider, MessageProvider, WithComponents } from '../../../context';
2134
import type {
2235
ChatContextValue,
2336
ComponentContextValue,
2437
MessageContextValue,
2538
} from '../../../context';
39+
import { DialogManagerProvider, MessageProvider, WithComponents } from '../../../context';
2640
import { ChatProvider } from '../../../context/ChatContext';
2741
import {
2842
dispatchMessageDeletedEvent,
@@ -36,21 +50,7 @@ import {
3650
initClientWithChannels,
3751
} from '../../../mock-builders';
3852
import { QuotedMessagePreview } from '../QuotedMessagePreview';
39-
import type {
40-
Attachment,
41-
Channel as ChannelType,
42-
CommandResponse,
43-
CooldownTimerState,
44-
LinkPreviewsManagerState,
45-
LocalAttachment,
46-
LocalMessage,
47-
SendFileAPIResponse,
48-
StreamChat,
49-
UserResponse,
50-
} from 'stream-chat';
5153
import type { ChannelProps } from '../../Channel';
52-
import type { MessageComposerProps } from '../MessageComposer';
53-
import type { MessageActionsProps } from '../../MessageActions';
5454
import type { GenerateChannelOptions } from '../../../mock-builders/generator/channel';
5555

5656
vi.mock('../../ChatView', async (importOriginal) => {
@@ -102,24 +102,25 @@ const mockedChannelData = generateChannel(
102102
},
103103
members: [generateMember({ user }), generateMember({ user: mentionUser })],
104104
messages: [mainListMessage],
105-
threads: [threadMessage],
105+
threads: [threadMessage] as unknown as GenerateChannelOptions['threads'],
106106
}),
107107
);
108108

109-
const defaultChatContext = {
109+
const defaultChatContext = fromPartial<ChatContextValue>({
110110
channelsQueryState: { queryInProgress: 'uninitialized' },
111111
getAppSettings: vi.fn(),
112112
latestMessageDatesByChannels: {},
113113
mutes: [],
114114
searchController: new SearchController(),
115-
};
115+
});
116116

117117
const cooldown = 30;
118118
const filename = 'some.txt';
119119
const fileUploadUrl = 'http://www.getstream.io'; // real url, because ImageAttachmentPreview will try to load the image
120120

121121
const getImage = () => new File(['content'], filename, { type: 'image/png' });
122-
const getFile = (name = filename) => new File(['content'], name, { type: 'text/plain' });
122+
const getFile = (name = filename): File =>
123+
new File(['content'], name, { type: 'text/plain' });
123124

124125
// Polyfill DOMRect for jsdom
125126
if (typeof globalThis.DOMRect === 'undefined') {
@@ -174,7 +175,7 @@ vi.mock('../../Channel/utils', async (importOriginal) => ({
174175
makeAddNotifications: () => mockAddNotification,
175176
}));
176177

177-
const defaultMessageContextValue = {
178+
const defaultMessageContextValue = fromPartial<MessageContextValue>({
178179
getMessageActions: () => ['delete', 'edit', 'quote'],
179180
handleDelete: () => {},
180181
handleFlag: () => {},
@@ -183,7 +184,7 @@ const defaultMessageContextValue = {
183184
handlePin: () => {},
184185
isMyMessage: () => true,
185186
message: mainListMessage,
186-
};
187+
});
187188

188189
function dropFile(file: File, formElement: Element) {
189190
fireEvent.drop(formElement, {
@@ -350,16 +351,26 @@ const setupUploadRejected = async (error: unknown) => {
350351
};
351352

352353
/** `channel.sendImage` / `channel.sendFile` pass upload options (e.g. `onUploadProgress`) after the file. */
353-
const expectChannelUploadCall = (spy, expectedFile) => {
354-
expect(spy).toHaveBeenCalled();
354+
type UploadSpy = {
355+
mock: {
356+
calls: [unknown, ...unknown[]][];
357+
};
358+
};
359+
360+
const expectChannelUploadCall = (spy: UploadSpy, expectedFile: File) => {
361+
expect(spy.mock.calls.length).toBeGreaterThan(0);
355362
const callArgs = spy.mock.calls[0];
356363
expect(callArgs[0]).toBe(expectedFile);
357364
expect(callArgs[callArgs.length - 1]).toEqual(
358365
expect.objectContaining({ onUploadProgress: expect.any(Function) }),
359366
);
360367
};
361368

362-
const renderWithActiveCooldown = async ({ messageInputProps = {} } = {}) => {
369+
const renderWithActiveCooldown = async ({
370+
messageInputProps = {},
371+
}: {
372+
messageInputProps?: Partial<MessageComposerProps>;
373+
} = {}) => {
363374
const {
364375
channels: [channel],
365376
client,
@@ -530,9 +541,9 @@ describe(`MessageInputFlat`, () => {
530541
// Manually trigger link preview state so the previews section renders
531542
await act(() => {
532543
const scrapedData = generateScrapedDataAttachment(
533-
fromPartial<Attachment>({
544+
fromPartial<LinkPreview>({
534545
og_scrape_url: 'http://getstream.io',
535-
status: 'loaded',
546+
status: LinkPreviewStatus.LOADED,
536547
title: 'http://getstream.io',
537548
}),
538549
);
@@ -1321,6 +1332,87 @@ describe(`MessageInputFlat`, () => {
13211332
expect(results).toHaveNoViolations();
13221333
});
13231334

1335+
describe('Command dismissal', () => {
1336+
it('clears the active command when Escape is pressed in the textarea', async () => {
1337+
const { channel } = await renderComponent();
1338+
const input = await screen.findByPlaceholderText(inputPlaceholder);
1339+
const command = fromPartial<CommandResponse>({
1340+
args: 'giphy-command-args',
1341+
description: 'giphy-command-description',
1342+
name: 'giphy',
1343+
});
1344+
1345+
await act(() => {
1346+
channel.messageComposer.textComposer.setCommand(command);
1347+
});
1348+
1349+
expect(screen.getByText('giphy')).toBeInTheDocument();
1350+
1351+
await act(() => {
1352+
fireEvent.keyDown(input, { key: 'Escape' });
1353+
});
1354+
1355+
await waitFor(() => {
1356+
expect(screen.queryByText('giphy')).not.toBeInTheDocument();
1357+
});
1358+
});
1359+
1360+
it('clears the active command when Backspace is pressed in an empty textarea', async () => {
1361+
const { channel } = await renderComponent();
1362+
const input = await screen.findByPlaceholderText(inputPlaceholder);
1363+
const command = fromPartial<CommandResponse>({
1364+
args: 'giphy-command-args',
1365+
description: 'giphy-command-description',
1366+
name: 'giphy',
1367+
});
1368+
1369+
await act(() => {
1370+
channel.messageComposer.textComposer.setCommand(command);
1371+
});
1372+
1373+
expect(screen.getByText('giphy')).toBeInTheDocument();
1374+
expect(input).toHaveValue('');
1375+
1376+
await act(() => {
1377+
fireEvent.keyDown(input, { key: 'Backspace' });
1378+
});
1379+
1380+
await waitFor(() => {
1381+
expect(screen.queryByText('giphy')).not.toBeInTheDocument();
1382+
});
1383+
});
1384+
1385+
it('clears the active command when the textarea DOM is empty even if composer text lags behind', async () => {
1386+
const { channel } = await renderComponent();
1387+
const input = (await screen.findByPlaceholderText(
1388+
inputPlaceholder,
1389+
)) as HTMLTextAreaElement;
1390+
const command = fromPartial<CommandResponse>({
1391+
args: 'giphy-command-args',
1392+
description: 'giphy-command-description',
1393+
name: 'giphy',
1394+
});
1395+
1396+
await act(() => {
1397+
channel.messageComposer.textComposer.setCommand(command);
1398+
channel.messageComposer.textComposer.state.next({
1399+
...channel.messageComposer.textComposer.state.getLatestValue(),
1400+
text: 'a',
1401+
});
1402+
});
1403+
1404+
input.value = '';
1405+
1406+
await act(() => {
1407+
fireEvent.keyDown(input, { key: 'Backspace' });
1408+
});
1409+
1410+
await waitFor(() => {
1411+
expect(screen.queryByText('giphy')).not.toBeInTheDocument();
1412+
});
1413+
});
1414+
});
1415+
13241416
describe('QuotedMessagePreview', () => {
13251417
it('is displayed on quote action click', async () => {
13261418
await renderComponent();

‎src/components/TextareaComposer/TextareaComposer.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ const attachmentManagerStateSelector = (state: AttachmentManagerState) => ({
5757
const defaultShouldSubmit = (event: React.KeyboardEvent<HTMLTextAreaElement>) =>
5858
event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing;
5959

60+
export const shouldBackspaceExitCommandMode = (text: string) => text.length === 0;
61+
6062
export type TextareaComposerProps = Omit<
6163
TextareaHTMLAttributes<HTMLTextAreaElement>,
6264
'style' | 'defaultValue' | 'disabled' | 'value'
@@ -174,6 +176,9 @@ export const TextareaComposer = ({
174176
return;
175177
}
176178

179+
// use the textarea value directly as the composer state is a step behind
180+
const textareaValue = textareaRef.current?.value ?? event.currentTarget.value;
181+
177182
if (
178183
textComposer.suggestions &&
179184
textComposer.suggestions.searchSource.items?.length
@@ -209,6 +214,13 @@ export const TextareaComposer = ({
209214
return nextIndex;
210215
});
211216
}
217+
} else if (
218+
textComposer.command &&
219+
(event.key === 'Escape' ||
220+
(event.key === 'Backspace' && shouldBackspaceExitCommandMode(textareaValue)))
221+
) {
222+
event.preventDefault();
223+
textComposer.clearCommand();
212224
} else if (
213225
shouldSubmit(event) &&
214226
textareaRef.current &&

0 commit comments

Comments
 (0)