Skip to content

Commit fedaca3

Browse files
committed
feat: introduce selective command enablement based on message editing or quoting in message composer
1 parent 88c0ccc commit fedaca3

25 files changed

Lines changed: 400 additions & 19 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
"emoji-mart": "^5.4.0",
105105
"react": "^19.0.0 || ^18.0.0 || ^17.0.0",
106106
"react-dom": "^19.0.0 || ^18.0.0 || ^17.0.0",
107-
"stream-chat": "^9.41.0"
107+
"stream-chat": "^9.43.0"
108108
},
109109
"peerDependenciesMeta": {
110110
"@breezystack/lamejs": {
@@ -170,7 +170,7 @@
170170
"react-dom": "^19.0.0",
171171
"sass": "^1.97.2",
172172
"semantic-release": "^25.0.2",
173-
"stream-chat": "^9.41.1",
173+
"stream-chat": "^9.43.0",
174174
"typescript": "^5.4.5",
175175
"typescript-eslint": "^8.17.0",
176176
"vite": "^7.3.1",

src/components/Dialog/styling/ContextMenu.scss

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@
199199

200200
&:disabled {
201201
background-color: transparent;
202+
color: var(--str-chat__text-disabled);
203+
cursor: default;
204+
205+
.str-chat__context-menu__button__details,
206+
.str-chat__icon {
207+
color: inherit;
208+
}
202209
}
203210

204211
.str-chat__icon {
@@ -211,7 +218,6 @@
211218
@include utils.ellipsis-text;
212219
flex: auto;
213220
text-align: start;
214-
color: var(--str-chat__text-primary);
215221
white-space: nowrap;
216222
min-width: 0;
217223
}

src/components/MessageComposer/AttachmentSelector/AttachmentSelector.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import React, {
77
useRef,
88
useState,
99
} from 'react';
10-
import { useAttachmentManagerState, useMessageComposerController } from '../hooks';
10+
import {
11+
useAttachmentManagerState,
12+
useMessageComposerCommands,
13+
useMessageComposerController,
14+
} from '../hooks';
1115
import { CHANNEL_CONTAINER_ID } from '../../Channel/constants';
1216
import {
1317
ContextMenu,
@@ -146,10 +150,14 @@ export const DefaultAttachmentSelectorComponents = {
146150
Command({ submenuHeader, submenuItems }: AttachmentSelectorActionProps) {
147151
const { t } = useTranslationContext();
148152
const { openSubmenu } = useContextMenuContext();
153+
const commands = useMessageComposerCommands();
154+
const hasEnabledCommands = commands.some(({ enabled }) => enabled);
149155
const hasSubmenu = !!submenuItems;
156+
150157
return (
151158
<ContextMenuButton
152-
className='str-chat__attachment-selector-actions-menu__button str-chat__attachment-selector-actions-menu__create-poll-button'
159+
className='str-chat__attachment-selector-actions-menu__button str-chat__attachment-selector-actions-menu__commands-button'
160+
disabled={!hasEnabledCommands}
153161
hasSubMenu={hasSubmenu}
154162
Icon={IconCommand}
155163
onClick={(event) => {

src/components/MessageComposer/AttachmentSelector/CommandsMenu.tsx

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { type ComponentProps, type ComponentType, useMemo } from 'react';
22
import type { CommandResponse } from 'stream-chat';
33
import { useMessageComposerContext, useTranslationContext } from '../../../context';
4-
import { useMessageComposerController } from '../hooks';
4+
import { useMessageComposerCommands, useMessageComposerController } from '../hooks';
55
import {
66
ContextMenuBackButton,
77
ContextMenuButton,
@@ -56,25 +56,24 @@ export const CommandsMenu = () => {
5656
const { closeMenu } = useContextMenuContext();
5757
const messageComposer = useMessageComposerController();
5858
const { textareaRef } = useMessageComposerContext();
59-
const channelConfig = messageComposer.channel.getConfig();
60-
const commands = useMemo<(CommandResponse & { name: string })[]>(
59+
const commands = useMessageComposerCommands();
60+
const sortedCommands = useMemo(
6161
() =>
62-
(channelConfig?.commands ?? [])
63-
.filter(
64-
(command): command is CommandResponse & { name: string } => !!command.name,
65-
)
66-
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')),
67-
[channelConfig],
62+
[...commands].sort((a, b) =>
63+
(a.command.name ?? '').localeCompare(b.command.name ?? ''),
64+
),
65+
[commands],
6866
);
6967

7068
return (
7169
<>
72-
{commands.map((command) => (
70+
{sortedCommands.map(({ command, enabled }) => (
7371
<CommandContextMenuItem
7472
command={command}
73+
enabled={enabled}
7574
key={command.name}
7675
onClick={() => {
77-
if (!command.name) return;
76+
if (!command.name || !enabled) return;
7877
messageComposer.textComposer.setCommand(command);
7978
closeMenu();
8079
// Defer the focus to the next frame so it wins over FocusScope's restore-to-attachment-selector-button behavior.
@@ -122,20 +121,26 @@ export const useCommandTranslation = (command: CommandResponse) => {
122121
export const CommandContextMenuItem = ({
123122
className,
124123
command,
124+
enabled = true,
125125
...props
126126
}: ComponentProps<'button'> & {
127127
command: CommandResponse & { name: string };
128+
enabled?: boolean;
128129
}) => {
129130
const { args, description } = useCommandTranslation(command);
130131

131132
// todo: retrieve the command trigger char from textComposer - needed adjustment in LLC
132-
const details = useMemo(() => `/${command.name} ${args}`, [args, command.name]);
133+
const details = useMemo(
134+
() => (args ? `/${command.name} ${args}` : `/${command.name}`),
135+
[args, command.name],
136+
);
133137

134138
return (
135139
<ContextMenuButton
136140
{...props}
137141
className={clsx('str-chat__context-menu__button--command', className)}
138142
details={details}
143+
disabled={!enabled}
139144
Icon={icons[command.name]}
140145
key={command.name}
141146
label={command.name}

src/components/MessageComposer/__tests__/AttachmentSelector.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
waitFor,
99
} from '@testing-library/react';
1010
import { fromPartial } from '@total-typescript/shoehorn';
11+
import type { CommandResponse } from 'stream-chat';
1112
import { MessageComposer } from '../MessageComposer';
1213
import { Chat } from '../../Chat';
1314
import {
@@ -38,6 +39,8 @@ const UPLOAD_FILE_BUTTON_CLASS =
3839
'str-chat__attachment-selector-actions-menu__upload-file-button';
3940
const CREATE_POLL_BUTTON_CLASS =
4041
'str-chat__attachment-selector-actions-menu__create-poll-button';
42+
const COMMANDS_BUTTON_CLASS =
43+
'str-chat__attachment-selector-actions-menu__commands-button';
4144
const SHARE_LOCATION_BUTTON_CLASS =
4245
'str-chat__attachment-selector-actions-menu__add-location-button';
4346
const SIMPLE_ATTACHMENT_SELECTOR_TEST_ID = 'invoke-attachment-selector-button';
@@ -177,6 +180,53 @@ describe('AttachmentSelector', () => {
177180
expect(menu).toHaveTextContent('Location');
178181
});
179182

183+
it('keeps Commands visible and disables it when all commands are unavailable', async () => {
184+
const disabledCommand = fromPartial<CommandResponse>({
185+
args: 'ban-command-args',
186+
description: 'ban-command-description',
187+
name: 'ban',
188+
set: 'moderation_set',
189+
});
190+
const {
191+
channels: [customChannel],
192+
client: customClient,
193+
} = await initClientWithChannels({
194+
channelsData: [
195+
{
196+
channel: {
197+
...defaultChannelData,
198+
cid: 'type:id',
199+
config: {
200+
commands: [disabledCommand],
201+
polls: false,
202+
shared_locations: false,
203+
uploads: false,
204+
},
205+
id: 'id',
206+
type: 'type',
207+
},
208+
},
209+
],
210+
});
211+
212+
customChannel.messageComposer.initState({
213+
composition: generateMessage({ text: 'editing' }),
214+
});
215+
216+
await renderComponent({
217+
channelStateContext: { channelCapabilities: {} },
218+
customChannel,
219+
customClient,
220+
});
221+
222+
await invokeMenu();
223+
224+
const menu = screen.getByTestId(ATTACHMENT_SELECTOR__ACTIONS_MENU_TEST_ID);
225+
const commandsButton = menu.querySelector(`.${COMMANDS_BUTTON_CLASS}`);
226+
227+
expect(commandsButton).toBeDisabled();
228+
});
229+
180230
it('renders with poll only if only polls are enabled', async () => {
181231
const {
182232
channels: [customChannel],

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { QuotedMessagePreview } from '../QuotedMessagePreview';
3939
import type {
4040
Attachment,
4141
Channel as ChannelType,
42+
CommandResponse,
4243
CooldownTimerState,
4344
LinkPreviewsManagerState,
4445
LocalAttachment,
@@ -1377,6 +1378,28 @@ describe(`MessageInputFlat`, () => {
13771378
});
13781379
quotedMessagePreviewIsNotDisplayed(mainListMessage);
13791380
});
1381+
1382+
it('clears active command when quoting makes it unavailable', async () => {
1383+
const { channel } = await renderComponent();
1384+
const command = fromPartial<CommandResponse>({
1385+
args: 'ban-command-args',
1386+
description: 'ban-command-description',
1387+
name: 'ban',
1388+
set: 'moderation_set',
1389+
});
1390+
1391+
await act(() => {
1392+
channel.messageComposer.textComposer.setCommand(command);
1393+
});
1394+
1395+
expect(screen.getByText('ban')).toBeInTheDocument();
1396+
1397+
await initQuotedMessagePreview(mainListMessage);
1398+
1399+
await waitFor(() => {
1400+
expect(screen.queryByText('ban')).not.toBeInTheDocument();
1401+
});
1402+
});
13801403
});
13811404

13821405
describe('send button', () => {
@@ -1554,5 +1577,26 @@ describe(`MessageInputFlat`, () => {
15541577
expect(textarea).toHaveValue('');
15551578
});
15561579
});
1580+
1581+
it('should clear active command when entering edit mode', async () => {
1582+
const { channel } = await renderComponent();
1583+
const command = fromPartial<CommandResponse>({
1584+
args: 'giphy-command-args',
1585+
description: 'giphy-command-description',
1586+
name: 'giphy',
1587+
});
1588+
1589+
await act(() => {
1590+
channel.messageComposer.textComposer.setCommand(command);
1591+
});
1592+
1593+
expect(screen.getByText('giphy')).toBeInTheDocument();
1594+
1595+
await enterEditMode();
1596+
1597+
await waitFor(() => {
1598+
expect(screen.queryByText('giphy')).not.toBeInTheDocument();
1599+
});
1600+
});
15571601
});
15581602
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { fromPartial } from '@total-typescript/shoehorn';
3+
import type { CommandResponse, MessageComposerState } from 'stream-chat';
4+
import { StateStore } from 'stream-chat';
5+
6+
import { useMessageComposerCommands } from '../useMessageComposerCommands';
7+
8+
const mockedUseMessageComposerController = vi.hoisted(() => vi.fn());
9+
10+
let commands: CommandResponse[];
11+
let messageComposer: {
12+
channel: { getConfig: ReturnType<typeof vi.fn> };
13+
getCommandDisabledReason: ReturnType<typeof vi.fn>;
14+
isCommandDisabled: ReturnType<typeof vi.fn>;
15+
state: StateStore<MessageComposerState>;
16+
};
17+
let state: StateStore<MessageComposerState>;
18+
19+
vi.mock('../useMessageComposerController', () => ({
20+
useMessageComposerController: mockedUseMessageComposerController,
21+
}));
22+
23+
describe('useMessageComposerCommands', () => {
24+
beforeEach(() => {
25+
state = new StateStore<MessageComposerState>(
26+
fromPartial<MessageComposerState>({
27+
editedMessage: null,
28+
quotedMessage: null,
29+
}) as MessageComposerState,
30+
);
31+
commands = [
32+
fromPartial<CommandResponse>({
33+
args: 'giphy-command-args',
34+
description: 'giphy-command-description',
35+
name: 'giphy',
36+
}),
37+
fromPartial<CommandResponse>({
38+
args: 'ban-command-args',
39+
description: 'ban-command-description',
40+
name: 'ban',
41+
set: 'moderation_set',
42+
}),
43+
fromPartial<CommandResponse>({
44+
description: 'missing-name',
45+
}),
46+
];
47+
messageComposer = {
48+
channel: {
49+
getConfig: vi.fn(() => ({
50+
commands,
51+
})),
52+
},
53+
getCommandDisabledReason: vi.fn((command: CommandResponse) => {
54+
const latestState = state.getLatestValue();
55+
56+
if (latestState.editedMessage) {
57+
return 'editing';
58+
}
59+
60+
if (
61+
latestState.quotedMessage &&
62+
(command.set === 'moderation_set' || command.name === 'moderation_set')
63+
) {
64+
return 'quoted_message';
65+
}
66+
67+
return undefined;
68+
}),
69+
isCommandDisabled: vi.fn(
70+
(command: CommandResponse) => !!messageComposer.getCommandDisabledReason(command),
71+
),
72+
state,
73+
};
74+
mockedUseMessageComposerController.mockReturnValue(messageComposer);
75+
});
76+
77+
afterEach(() => {
78+
vi.clearAllMocks();
79+
});
80+
81+
it('returns named commands with enabled state', () => {
82+
const { result } = renderHook(() => useMessageComposerCommands());
83+
84+
expect(result.current).toEqual([
85+
{ command: expect.objectContaining({ name: 'giphy' }), enabled: true },
86+
{ command: expect.objectContaining({ name: 'ban' }), enabled: true },
87+
]);
88+
});
89+
90+
it('updates when entering edit mode and disables all commands', () => {
91+
const { result } = renderHook(() => useMessageComposerCommands());
92+
93+
act(() => {
94+
state.partialNext({
95+
editedMessage: fromPartial({ id: 'edited-message-id' }),
96+
});
97+
});
98+
99+
expect(result.current).toEqual([
100+
{ command: expect.objectContaining({ name: 'giphy' }), enabled: false },
101+
{ command: expect.objectContaining({ name: 'ban' }), enabled: false },
102+
]);
103+
});
104+
105+
it('marks quoted-message-disabled commands as disabled while keeping allowed ones enabled', () => {
106+
const { result } = renderHook(() => useMessageComposerCommands());
107+
108+
act(() => {
109+
state.partialNext({
110+
quotedMessage: fromPartial({ id: 'quoted-message-id' }),
111+
});
112+
});
113+
114+
expect(result.current).toEqual([
115+
{ command: expect.objectContaining({ name: 'giphy' }), enabled: true },
116+
{ command: expect.objectContaining({ name: 'ban' }), enabled: false },
117+
]);
118+
});
119+
});

0 commit comments

Comments
 (0)