Skip to content

Commit 6d605e6

Browse files
feat: Expandable Message Composer (RocketChat#36920)
Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com>
1 parent e488086 commit 6d605e6

9 files changed

Lines changed: 439 additions & 13 deletions

File tree

apps/meteor/client/views/room/composer/messageBox/MessageBox.tsx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable complexity */
22
import { isRoomFederated, isRoomNativeFederated, type IMessage, type ISubscription } from '@rocket.chat/core-typings';
33
import { useContentBoxSize, useEffectEvent } from '@rocket.chat/fuselage-hooks';
4-
import { useSafeRefCallback } from '@rocket.chat/ui-client';
4+
import { FeaturePreview, FeaturePreviewOff, FeaturePreviewOn, useSafeRefCallback } from '@rocket.chat/ui-client';
55
import {
66
MessageComposerAction,
77
MessageComposerToolbarActions,
@@ -11,6 +11,7 @@ import {
1111
MessageComposerActionsDivider,
1212
MessageComposerToolbarSubmit,
1313
MessageComposerButton,
14+
MessageComposerInputExpandable,
1415
} from '@rocket.chat/ui-composer';
1516
import { useTranslation, useUserPreference, useLayout, useSetting } from '@rocket.chat/ui-contexts';
1617
import { useMutation } from '@tanstack/react-query';
@@ -423,17 +424,35 @@ const MessageBox = ({
423424
{isRecordingVideo && <VideoMessageRecorder reference={messageComposerRef} rid={room._id} tmid={tmid} />}
424425
<MessageComposer ref={messageComposerRef} variant={isEditing ? 'editing' : undefined}>
425426
{isRecordingAudio && <AudioMessageRecorder rid={room._id} isMicrophoneDenied={isMicrophoneDenied} />}
426-
<MessageComposerInput
427-
ref={mergedRefs}
428-
aria-label={composerPlaceholder}
429-
name='msg'
430-
disabled={isRecording || !canSend}
431-
onChange={setTyping}
432-
style={textAreaStyle}
433-
placeholder={composerPlaceholder}
434-
onPaste={handlePaste}
435-
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
436-
/>
427+
<FeaturePreview feature='expandableMessageComposer'>
428+
<FeaturePreviewOn>
429+
<MessageComposerInputExpandable
430+
dimensions={sizes}
431+
ref={mergedRefs}
432+
aria-label={composerPlaceholder}
433+
name='msg'
434+
disabled={isRecording || !canSend}
435+
onChange={setTyping}
436+
style={textAreaStyle}
437+
placeholder={composerPlaceholder}
438+
onPaste={handlePaste}
439+
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
440+
/>
441+
</FeaturePreviewOn>
442+
<FeaturePreviewOff>
443+
<MessageComposerInput
444+
ref={mergedRefs}
445+
aria-label={composerPlaceholder}
446+
name='msg'
447+
disabled={isRecording || !canSend}
448+
onChange={setTyping}
449+
style={textAreaStyle}
450+
placeholder={composerPlaceholder}
451+
onPaste={handlePaste}
452+
aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined}
453+
/>
454+
</FeaturePreviewOff>
455+
</FeaturePreview>
437456
<MessageComposerToolbar>
438457
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
439458
<MessageComposerAction
19.4 KB
Loading

packages/i18n/src/locales/en.i18n.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2029,6 +2029,8 @@
20292029
"Execute_Synchronization_Now": "Execute Synchronization Now",
20302030
"Exit_Full_Screen": "Exit Full Screen",
20312031
"Expand": "Expand",
2032+
"Expandable_message_composer": "Expandable Composer",
2033+
"Expandable_message_composer_description": "Expand the text input area to easily write and review longer messages without having to scroll up and down.",
20322034
"Expand_group": "Expand {{group}}",
20332035
"Expand_all": "Expand all",
20342036
"Expand_view": "Expand view",

packages/ui-client/src/hooks/useFeaturePreviewList.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ export type FeaturesAvailable =
55
| 'enable-timestamp-message-parser'
66
| 'contextualbarResizable'
77
| 'newNavigation'
8-
| 'secondarySidebar';
8+
| 'secondarySidebar'
9+
| 'expandableMessageComposer';
910

1011
export type FeaturePreviewProps = {
1112
name: FeaturesAvailable;
@@ -73,6 +74,15 @@ export const defaultFeaturesPreview: FeaturePreviewProps[] = [
7374
value: true,
7475
},
7576
},
77+
{
78+
name: 'expandableMessageComposer',
79+
i18n: 'Expandable_message_composer',
80+
description: 'Expandable_message_composer_description',
81+
imageUrl: 'images/featurePreview/expandable-composer.png',
82+
group: 'Message',
83+
value: false,
84+
enabled: true,
85+
},
7686
];
7787

7888
export const enabledDefaultFeatures = defaultFeaturesPreview.filter((feature) => feature.enabled);

packages/ui-composer/src/MessageComposer/MessageComposer.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
MessageComposerToolbarSubmit,
1313
MessageComposerSkeleton,
1414
MessageComposerHint,
15+
MessageComposerInputExpandable,
1516
} from '.';
1617

1718
export default {
@@ -48,6 +49,21 @@ export const Default: StoryFn<typeof MessageComposer> = () => (
4849
</MessageComposer>
4950
);
5051

52+
export const Expandable: StoryFn<typeof MessageComposer> = () => (
53+
<MessageComposer>
54+
<MessageComposerInputExpandable
55+
dimensions={{
56+
inlineSize: 400,
57+
blockSize: 120,
58+
}}
59+
placeholder='Type a message...'
60+
/>
61+
<MessageComposerToolbar>
62+
<MessageToolbarActions />
63+
</MessageComposerToolbar>
64+
</MessageComposer>
65+
);
66+
5167
export const ToolbarActions: StoryFn<typeof MessageComposerToolbarActions> = () => <MessageToolbarActions />;
5268

5369
export const WithHints: StoryFn<typeof MessageComposer> = () => (
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
3+
import MessageComposerInputExpandable from './MessageComposerInputExpandable';
4+
5+
test('should show expand button when dimensions.blockSize > 100', () => {
6+
render(
7+
<MessageComposerInputExpandable
8+
dimensions={{
9+
inlineSize: 400,
10+
blockSize: 120,
11+
}}
12+
placeholder='Type a message...'
13+
/>,
14+
);
15+
16+
const expandButton = screen.getByRole('button');
17+
expect(expandButton).toBeInTheDocument();
18+
expect(expandButton).toHaveAttribute('title', 'Expand');
19+
});
20+
21+
test('should not show expand button when dimensions.blockSize <= 100', () => {
22+
render(
23+
<MessageComposerInputExpandable
24+
dimensions={{
25+
inlineSize: 400,
26+
blockSize: 80,
27+
}}
28+
placeholder='Type a message...'
29+
/>,
30+
);
31+
32+
const expandButton = screen.queryByRole('button');
33+
expect(expandButton).not.toBeInTheDocument();
34+
});
35+
36+
test('should expand input when expand button is clicked', () => {
37+
render(
38+
<MessageComposerInputExpandable
39+
dimensions={{
40+
inlineSize: 400,
41+
blockSize: 120,
42+
}}
43+
placeholder='Type a message...'
44+
/>,
45+
);
46+
47+
const expandButton = screen.getByRole('button');
48+
const textarea = screen.getByRole('textbox');
49+
50+
// Initially not expanded
51+
expect(textarea).not.toHaveStyle({ height: '500px' });
52+
53+
// Click to expand
54+
fireEvent.click(expandButton);
55+
56+
// Should be expanded now
57+
expect(textarea).toHaveStyle({ height: '500px' });
58+
expect(textarea).toHaveStyle({ maxHeight: '50vh' });
59+
expect(expandButton).toHaveAttribute('title', 'Collapse');
60+
});
61+
62+
test('should collapse input when collapse button is clicked', () => {
63+
render(
64+
<MessageComposerInputExpandable
65+
dimensions={{
66+
inlineSize: 400,
67+
blockSize: 120,
68+
}}
69+
placeholder='Type a message...'
70+
/>,
71+
);
72+
73+
const expandButton = screen.getByRole('button');
74+
const textarea = screen.getByRole('textbox');
75+
76+
// Expand first
77+
fireEvent.click(expandButton);
78+
expect(textarea).toHaveStyle({ height: '500px' });
79+
80+
// Click to collapse
81+
fireEvent.click(expandButton);
82+
83+
// Should be collapsed now
84+
expect(textarea).not.toHaveStyle({ height: '500px' });
85+
expect(textarea).not.toHaveStyle({ maxHeight: '50vh' });
86+
expect(expandButton).toHaveAttribute('title', 'Expand');
87+
});
88+
89+
test('should auto-collapse when input is cleared', () => {
90+
render(
91+
<MessageComposerInputExpandable
92+
dimensions={{
93+
inlineSize: 400,
94+
blockSize: 120,
95+
}}
96+
placeholder='Type a message...'
97+
/>,
98+
);
99+
100+
const expandButton = screen.getByRole('button');
101+
const textarea = screen.getByRole('textbox');
102+
103+
// Expand first
104+
fireEvent.click(expandButton);
105+
expect(textarea).toHaveStyle({ height: '500px' });
106+
107+
// Type some text
108+
fireEvent.change(textarea, { target: { value: 'Some text' } });
109+
expect(textarea).toHaveStyle({ height: '500px' });
110+
111+
// Clear the text
112+
fireEvent.change(textarea, { target: { value: '' } });
113+
114+
// Should auto-collapse
115+
expect(textarea).not.toHaveStyle({ height: '500px' });
116+
expect(textarea).not.toHaveStyle({ maxHeight: '50vh' });
117+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { css } from '@rocket.chat/css-in-js';
2+
import { Box, IconButton } from '@rocket.chat/fuselage';
3+
import { useState, type ComponentProps, ChangeEvent, forwardRef } from 'react';
4+
import { useTranslation } from 'react-i18next';
5+
6+
import MessageComposerInput from './MessageComposerInput';
7+
8+
export type ExpandComposerButtonProps = ComponentProps<typeof Box> & {
9+
dimensions: Readonly<{
10+
inlineSize: number;
11+
blockSize: number;
12+
}>;
13+
};
14+
15+
const MessageComposerInputExpandable = forwardRef<HTMLTextAreaElement, ExpandComposerButtonProps>(
16+
({ dimensions, onChange, ...props }, ref) => {
17+
const { t } = useTranslation();
18+
const [expanded, setExpanded] = useState(false);
19+
20+
const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
21+
if (event.target.value.length === 0) {
22+
setExpanded(false);
23+
}
24+
25+
onChange?.(event);
26+
};
27+
28+
return (
29+
<>
30+
{dimensions.blockSize > 100 && (
31+
<Box
32+
position='absolute'
33+
padding={8}
34+
className={css`
35+
top: 0;
36+
right: 0;
37+
`}
38+
>
39+
<IconButton
40+
small
41+
icon={expanded ? 'arrow-collapse' : 'arrow-expand'}
42+
title={expanded ? t('Collapse') : t('Expand')}
43+
onClick={() => setExpanded(!expanded)}
44+
/>
45+
</Box>
46+
)}
47+
<MessageComposerInput ref={ref} onChange={handleChange} {...(!!expanded && { height: 500, maxHeight: '50vh' })} {...props} />
48+
</>
49+
);
50+
},
51+
);
52+
53+
MessageComposerInputExpandable.displayName = 'MessageComposerInputExpandable';
54+
55+
export default MessageComposerInputExpandable;

0 commit comments

Comments
 (0)