Skip to content

Commit ea912f9

Browse files
feat: handle command injection optionally (#1548)
The goal of the PR is to handle command injection and apply the command settings to the text using middleware that the SDK or apps can plug in. The existing commands middleware will be responsible for setting the `command` state to the `textComposer`, which can be used by the other middleware. SDKs can basically add something like this to support the commands UI on message input: ``` messageComposer.compositionMiddlewareExecutor.insert({ middleware: [createCommandInjectionMiddleware(messageComposer)], position: { after: 'stream-io/message-composer-middleware/attachments' }, }); messageComposer.draftCompositionMiddlewareExecutor.insert({ middleware: [createDraftCommandInjectionMiddleware(messageComposer)], position: { after: 'stream-io/message-composer-middleware/draft-attachments' }, }); messageComposer.textComposer.middlewareExecutor.insert({ middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware], position: { before: 'stream-io/text-composer/commands-middleware' }, }); messageComposer.textComposer.middlewareExecutor.insert({ middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware], position: { after: 'stream-io/text-composer/commands-middleware' }, }); ``` --------- Co-authored-by: MartinCupela <[email protected]>
1 parent 944d1c2 commit ea912f9

File tree

13 files changed

+724
-18
lines changed

13 files changed

+724
-18
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { MessageComposer } from '../../messageComposer';
2+
import type {
3+
MessageComposerMiddlewareState,
4+
MessageCompositionMiddleware,
5+
MessageDraftComposerMiddlewareValueState,
6+
MessageDraftCompositionMiddleware,
7+
} from '../messageComposer/types';
8+
import type { MiddlewareHandlerParams } from '../../../middleware';
9+
10+
export const createCommandInjectionMiddleware = (
11+
composer: MessageComposer,
12+
): MessageCompositionMiddleware => ({
13+
handlers: {
14+
compose: ({
15+
forward,
16+
next,
17+
state,
18+
}: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => {
19+
const command = composer.textComposer.command;
20+
if (!command) {
21+
return forward();
22+
}
23+
const { text } = state.localMessage;
24+
25+
const injection = `/${command?.name}`;
26+
const enrichedText = `${injection} ${text}`;
27+
28+
return next({
29+
...state,
30+
localMessage: {
31+
...state.localMessage,
32+
text: enrichedText,
33+
},
34+
message: {
35+
...state.message,
36+
text: enrichedText,
37+
},
38+
});
39+
},
40+
},
41+
id: 'stream-io/message-composer-middleware/command-string-injection',
42+
});
43+
44+
export const createDraftCommandInjectionMiddleware = (
45+
composer: MessageComposer,
46+
): MessageDraftCompositionMiddleware => ({
47+
handlers: {
48+
compose: ({
49+
forward,
50+
next,
51+
state,
52+
}: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => {
53+
const command = composer.textComposer.command;
54+
if (!command) {
55+
return forward();
56+
}
57+
const { text } = state.draft;
58+
59+
const injection = `/${command?.name}`;
60+
const enrichedText = `${injection} ${text}`;
61+
62+
return next({
63+
...state,
64+
draft: {
65+
...state.draft,
66+
text: enrichedText,
67+
},
68+
});
69+
},
70+
},
71+
id: 'stream-io/message-composer-middleware/draft-command-string-injection',
72+
});

src/messageComposer/middleware/messageComposer/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export * from './MessageComposerMiddlewareExecutor';
77
export * from './messageComposerState';
88
export * from './textComposer';
99
export * from './types';
10+
export * from './commandInjection';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { Middleware } from '../../../middleware';
2+
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';
3+
4+
export type PreCommandMiddleware = Middleware<
5+
TextComposerMiddlewareExecutorState,
6+
'onChange' | 'onSuggestionItemSelect'
7+
>;
8+
9+
export const createActiveCommandGuardMiddleware = (): PreCommandMiddleware => ({
10+
handlers: {
11+
onChange: ({ complete, forward, state }) => {
12+
if (state.command) {
13+
return complete(state);
14+
}
15+
return forward();
16+
},
17+
onSuggestionItemSelect: ({ forward }) => forward(),
18+
},
19+
id: 'stream-io/text-composer/active-command-guard',
20+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';
2+
import type { CommandSuggestion } from './types';
3+
import type { Middleware } from '../../../middleware';
4+
import { escapeRegExp } from './textMiddlewareUtils';
5+
6+
export type CommandStringExtractionMiddleware = Middleware<
7+
TextComposerMiddlewareExecutorState<CommandSuggestion>,
8+
'onChange' | 'onSuggestionItemSelect'
9+
>;
10+
11+
const stripCommandFromText = (text: string, commandName: string) =>
12+
text.replace(new RegExp(`^${escapeRegExp(`/${commandName}`)}\\s*`), '');
13+
14+
export const createCommandStringExtractionMiddleware =
15+
(): CommandStringExtractionMiddleware => ({
16+
handlers: {
17+
onChange: ({ complete, forward, state }) => {
18+
const { command } = state;
19+
20+
if (!command?.name) {
21+
return forward();
22+
}
23+
24+
const newText = stripCommandFromText(state.text, command.name);
25+
26+
return complete({
27+
...state,
28+
selection: {
29+
end: newText.length,
30+
start: newText.length,
31+
},
32+
text: newText,
33+
});
34+
},
35+
onSuggestionItemSelect: ({ next, forward, state }) => {
36+
const { command } = state;
37+
38+
if (!command) {
39+
return forward();
40+
}
41+
42+
const triggerWithCommand = `/${command?.name} `;
43+
44+
const newText = state.text.slice(triggerWithCommand.length);
45+
return next({
46+
...state,
47+
selection: {
48+
end: newText.length,
49+
start: newText.length,
50+
},
51+
text: newText,
52+
});
53+
},
54+
},
55+
id: 'stream-io/text-composer/command-string-extraction',
56+
});

src/messageComposer/middleware/textComposer/commands.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { BaseSearchSourceSync } from '../../../search';
55
import type { CommandResponse } from '../../../types';
66
import { mergeWith } from '../../../utils/mergeWith';
77
import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types';
8-
import { getTriggerCharWithToken, insertItemWithTrigger } from './textMiddlewareUtils';
8+
import {
9+
getCompleteCommandInString,
10+
getTriggerCharWithToken,
11+
insertItemWithTrigger,
12+
} from './textMiddlewareUtils';
913
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';
1014

1115
export class CommandSearchSource extends BaseSearchSourceSync<CommandSuggestion> {
@@ -115,10 +119,22 @@ export const createCommandsMiddleware = (
115119
handlers: {
116120
onChange: ({ state, next, complete, forward }) => {
117121
if (!state.selection) return forward();
122+
const finalText = state.text.slice(0, state.selection.end);
123+
const commandName = getCompleteCommandInString(finalText);
124+
if (commandName) {
125+
const command = searchSource?.query(commandName).items[0];
126+
if (command) {
127+
return next({
128+
...state,
129+
command,
130+
suggestions: undefined,
131+
});
132+
}
133+
}
118134

119135
const triggerWithToken = getTriggerCharWithToken({
120136
trigger: finalOptions.trigger,
121-
text: state.text.slice(0, state.selection.end),
137+
text: finalText,
122138
acceptTrailingSpaces: false,
123139
isCommand: true,
124140
});
@@ -144,27 +160,29 @@ export const createCommandsMiddleware = (
144160

145161
return complete({
146162
...state,
163+
command: null,
147164
suggestions: {
148165
query: triggerWithToken.slice(1),
149166
searchSource,
150167
trigger: finalOptions.trigger,
151168
},
152169
});
153170
},
154-
onSuggestionItemSelect: ({ state, complete, forward }) => {
171+
onSuggestionItemSelect: ({ state, next, forward }) => {
155172
const { selectedSuggestion } = state.change ?? {};
156173
if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger)
157174
return forward();
158175

159176
searchSource.resetStateAndActivate();
160-
return complete({
177+
return next({
161178
...state,
162179
...insertItemWithTrigger({
163180
insertText: `/${selectedSuggestion.name} `,
164181
selection: state.selection,
165182
text: state.text,
166183
trigger: finalOptions.trigger,
167184
}),
185+
command: selectedSuggestion,
168186
suggestions: undefined,
169187
});
170188
},

src/messageComposer/middleware/textComposer/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
export * from './activeCommandGuard';
12
export * from './commands';
3+
export * from './commandStringExtraction';
24
export * from './mentions';
35
export * from './validation';
46
export * from './TextComposerMiddlewareExecutor';

src/messageComposer/middleware/textComposer/textMiddlewareUtils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import type { TextSelection } from './types';
22

3+
/**
4+
* For commands, we want to match all patterns except:
5+
* 1. Text not starting with trigger
6+
* 2. Trigger in middle of text
7+
*/
38
export const getTriggerCharWithToken = ({
49
trigger,
510
text,
@@ -12,6 +17,7 @@ export const getTriggerCharWithToken = ({
1217
acceptTrailingSpaces?: boolean;
1318
}) => {
1419
const triggerNorWhitespace = `[^\\s${trigger}]*`;
20+
1521
const match = text.match(
1622
new RegExp(
1723
isCommand
@@ -26,6 +32,14 @@ export const getTriggerCharWithToken = ({
2632
return match && match[match.length - 1].trim();
2733
};
2834

35+
export const getCompleteCommandInString = (text: string) => {
36+
// starts with "/" followed by 1+ non-whitespace chars followed by 1+ white-space chars
37+
// the comand name is extracted into a separate group
38+
const match = text.match(/^\/(\S+)\s+.*/);
39+
const commandName = match && match[1];
40+
return commandName;
41+
};
42+
2943
export const insertItemWithTrigger = ({
3044
insertText,
3145
selection,

src/messageComposer/middleware/textComposer/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,5 +38,6 @@ export type TextComposerState<T extends Suggestion = Suggestion> = {
3838
mentionedUsers: UserResponse[];
3939
selection: TextSelection;
4040
text: string;
41+
command?: CommandResponse | null;
4142
suggestions?: Suggestions<T>;
4243
};

src/messageComposer/textComposer.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { TextSelection } from './middleware/textComposer/types';
66
import type { TextComposerState } from './middleware/textComposer/types';
77
import type { Suggestions } from './middleware/textComposer/types';
88
import type { MessageComposer } from './messageComposer';
9-
import type { DraftMessage, LocalMessage, UserResponse } from '../types';
9+
import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types';
1010

1111
export type TextComposerOptions = {
1212
composer: MessageComposer;
@@ -37,6 +37,7 @@ const initState = ({
3737
if (!message) {
3838
const text = composer.config.text.defaultValue ?? '';
3939
return {
40+
command: null,
4041
mentionedUsers: [],
4142
text,
4243
selection: { start: text.length, end: text.length },
@@ -118,6 +119,10 @@ export class TextComposer {
118119

119120
// --- START STATE API ---
120121

122+
get command() {
123+
return this.state.getLatestValue().command;
124+
}
125+
121126
get mentionedUsers() {
122127
return this.state.getLatestValue().mentionedUsers;
123128
}
@@ -146,6 +151,10 @@ export class TextComposer {
146151
this.state.partialNext({ mentionedUsers: users });
147152
}
148153

154+
clearCommand() {
155+
this.state.partialNext({ command: null });
156+
}
157+
149158
upsertMentionedUser = (user: UserResponse) => {
150159
const mentionedUsers = [...this.mentionedUsers];
151160
const existingUserIndex = mentionedUsers.findIndex((u) => u.id === user.id);
@@ -169,6 +178,11 @@ export class TextComposer {
169178
this.state.partialNext({ mentionedUsers });
170179
};
171180

181+
setCommand = (command: CommandResponse | null) => {
182+
if (command?.name === this.command?.name) return;
183+
this.state.partialNext({ command });
184+
};
185+
172186
setText = (text: string) => {
173187
if (!this.enabled || text === this.text) return;
174188
this.state.partialNext({ text });

0 commit comments

Comments
 (0)