Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
971ed00
feat: handle command injection optionally
khushal87 May 28, 2025
318e03c
test: fix broken test
khushal87 May 28, 2025
04988f0
test: fix broken test
khushal87 May 28, 2025
780c52c
test: fix broken test
khushal87 May 28, 2025
84d861a
Merge branch 'master' of github.com:GetStream/stream-chat-js into han…
khushal87 May 29, 2025
c095a38
Merge branch 'master' of github.com:GetStream/stream-chat-js into han…
khushal87 May 29, 2025
0b3eccd
fix: add setCommand setter
khushal87 Jun 3, 2025
9c9644e
fix: add changes to improve tthe middlware
khushal87 Jun 4, 2025
274793c
fix: add searchSource
khushal87 Jun 4, 2025
b72490c
fix: handle whitespaces
khushal87 Jun 4, 2025
ad400a8
test: fix broken tests
khushal87 Jun 4, 2025
febad52
fix: refine the implementation
khushal87 Jun 5, 2025
d2d54ac
fix: composition command injection middleware
khushal87 Jun 5, 2025
3e7229e
refactor: change name for command middleware
khushal87 Jun 5, 2025
5983996
Merge branch 'master' of github.com:GetStream/stream-chat-js into han…
khushal87 Jun 9, 2025
19355ce
Merge branch 'master' of github.com:GetStream/stream-chat-js into han…
khushal87 Jun 16, 2025
ef19c9d
fix: use search source for the command data
khushal87 Jun 16, 2025
136eb09
fix: name of the middleware
khushal87 Jun 17, 2025
0a53752
fix: trigger with command
khushal87 Jun 17, 2025
cb2ada6
fix: command search
khushal87 Jun 17, 2025
114e367
fix: isTextMatched name
khushal87 Jun 17, 2025
fdb352b
Merge branch 'master' of github.com:GetStream/stream-chat-js into han…
khushal87 Jun 18, 2025
aacb8a9
feat: add active command guard middlware
khushal87 Jun 18, 2025
cf88672
test: change file name
khushal87 Jun 18, 2025
e14c318
fix: use complete instead of discard
khushal87 Jun 18, 2025
f17349e
fix: add improvements
khushal87 Jun 19, 2025
d525ae3
refactor: streamline the complete command identification and extracti…
MartinCupela Jun 19, 2025
19edce6
chore: resolve conflicts and add text composer change
khushal87 Jun 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { MessageComposer } from '../../messageComposer';
import type {
MessageComposerMiddlewareState,
MessageCompositionMiddleware,
MessageDraftComposerMiddlewareValueState,
MessageDraftCompositionMiddleware,
} from '../messageComposer/types';
import type { MiddlewareHandlerParams } from '../../../middleware';

export const createCommandInjectionMiddleware = (
composer: MessageComposer,
): MessageCompositionMiddleware => ({
handlers: {
compose: ({
forward,
next,
state,
}: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => {
const command = composer.textComposer.command;
if (!command) {
return forward();
}
const { text } = state.localMessage;

const injection = `/${command?.name}`;
const enrichedText = `${injection} ${text}`;

return next({
...state,
localMessage: {
...state.localMessage,
text: enrichedText,
},
message: {
...state.message,
text: enrichedText,
},
});
},
},
id: 'stream-io/message-composer-middleware/command-string-injection',
});

export const createDraftCommandInjectionMiddleware = (
composer: MessageComposer,
): MessageDraftCompositionMiddleware => ({
handlers: {
compose: ({
forward,
next,
state,
}: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => {
const command = composer.textComposer.command;
if (!command) {
return forward();
}
const { text } = state.draft;

const injection = `/${command?.name}`;
const enrichedText = `${injection} ${text}`;

return next({
...state,
draft: {
...state.draft,
text: enrichedText,
},
});
},
},
id: 'stream-io/message-composer-middleware/draft-command-string-injection',
});
1 change: 1 addition & 0 deletions src/messageComposer/middleware/messageComposer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './MessageComposerMiddlewareExecutor';
export * from './messageComposerState';
export * from './textComposer';
export * from './types';
export * from './commandInjection';
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { Middleware } from '../../../middleware';
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';
import { stripTriggerFromText } from './textMiddlewareUtils';
import type { CommandSuggestion } from './types';

export type ApplyCommandSettingsMiddleware = Middleware<
TextComposerMiddlewareExecutorState<CommandSuggestion>,
'onChange' | 'onSuggestionItemSelect'
>;

export const createCommandStringExtractionMiddleware =
(): ApplyCommandSettingsMiddleware => ({
handlers: {
onChange: ({ complete, forward, state }) => {
const { command } = state;

if (!command) {
return forward();
}

const triggerWithCommand = `/${command.name}`;

const newText = stripTriggerFromText(state.text, triggerWithCommand);

return complete({
...state,
selection: {
end: state.selection.end - triggerWithCommand.length,
start: state.selection.start - triggerWithCommand.length,
},
text: newText,
});
},
onSuggestionItemSelect: ({ next, forward, state }) => {
const { command } = state;

if (!command) {
return forward();
}

const triggerWithCommand = `/${command?.name} `;

const newText = state.text.slice(triggerWithCommand.length);
return next({
...state,
selection: {
end: state.selection.end - triggerWithCommand.length,
start: state.selection.start - triggerWithCommand.length,
},
text: newText,
});
},
},
id: 'stream-io/text-composer/apply-command-settings',
});
36 changes: 31 additions & 5 deletions src/messageComposer/middleware/textComposer/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { BaseSearchSourceSync } from '../../../search';
import type { CommandResponse } from '../../../types';
import { mergeWith } from '../../../utils/mergeWith';
import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types';
import { getTriggerCharWithToken, insertItemWithTrigger } from './textMiddlewareUtils';
import {
getFirstWordFromText,
getTriggerCharWithToken,
insertItemWithTrigger,
isTextMatched,
} from './textMiddlewareUtils';
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';

export class CommandSearchSource extends BaseSearchSourceSync<CommandSuggestion> {
Expand Down Expand Up @@ -115,10 +120,11 @@ export const createCommandsMiddleware = (
handlers: {
onChange: ({ state, next, complete, forward }) => {
if (!state.selection) return forward();
const finalText = state.text.slice(0, state.selection.end);

const triggerWithToken = getTriggerCharWithToken({
trigger: finalOptions.trigger,
text: state.text.slice(0, state.selection.end),
text: finalText,
acceptTrailingSpaces: false,
isCommand: true,
});
Expand All @@ -142,29 +148,49 @@ export const createCommandsMiddleware = (
return next(newState);
}

const query = triggerWithToken.slice(1);

const searchQuery = getFirstWordFromText(query);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it necessary to get first word from query if the query itself is just a trigger followed by chars that are not white space?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now, if you allow spaces, the user can paste anything let's say /giphy good morning, in that case,e the query will be giphy good morning but the search source cannot return anything as the pattern didn't match. That is why we get the first word from the query just for the command search.


const commands = searchSource?.query(searchQuery).items;

const matchedCommand = commands?.find((command) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@khushal87 does it make sense to perform query(searchQuery) and then again commands.find()? Isn't it doing the same with the difference that commands.find in this case would just be commands[0] (as find returns on the first matched and the first matched is the at 0 index before query returns all the matches)?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will simplify it. Makes sense

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

isTextMatched(query.toLowerCase(), command.name.toLowerCase()),
);

if (matchedCommand) {
return next({
...state,
command: matchedCommand,
suggestions: undefined,
});
}

return complete({
...state,
command: null,
suggestions: {
query: triggerWithToken.slice(1),
query: searchQuery,
searchSource,
trigger: finalOptions.trigger,
},
});
},
onSuggestionItemSelect: ({ state, complete, forward }) => {
onSuggestionItemSelect: ({ state, next, forward }) => {
const { selectedSuggestion } = state.change ?? {};
if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger)
return forward();

searchSource.resetStateAndActivate();
return complete({
return next({
...state,
...insertItemWithTrigger({
insertText: `/${selectedSuggestion.name} `,
selection: state.selection,
text: state.text,
trigger: finalOptions.trigger,
}),
command: selectedSuggestion,
suggestions: undefined,
});
},
Expand Down
1 change: 1 addition & 0 deletions src/messageComposer/middleware/textComposer/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './commands';
export * from './commandStringExtraction';
export * from './mentions';
export * from './validation';
export * from './TextComposerMiddlewareExecutor';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { TextSelection } from './types';

/**
* For commands, we want to match all patterns except:
* 1. Text not starting with trigger
* 2. Trigger in middle of text
*/
export const getTriggerCharWithToken = ({
trigger,
text,
Expand All @@ -11,19 +16,23 @@ export const getTriggerCharWithToken = ({
isCommand?: boolean;
acceptTrailingSpaces?: boolean;
}) => {
// No trigger in between text
const notTrigger = `[^${trigger}]*`;
const triggerNorWhitespace = `[^\\s${trigger}]*`;

const match = text.match(
new RegExp(
isCommand
? `^[${trigger}]${triggerNorWhitespace}$`
? `^[${trigger}]${notTrigger}$`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we allow white space?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is the function returns null for any text except the pattern /command which, we cannot rely on if we want to use it as an input for search later in the middleware. That is why I allowed it.

: acceptTrailingSpaces
? `(?!^|\\W)?[${trigger}]${triggerNorWhitespace}\\s?${triggerNorWhitespace}$`
: `(?!^|\\W)?[${trigger}]${triggerNorWhitespace}$`,
'g',
),
);

return match && match[match.length - 1].trim();
const result = match && match[match.length - 1];
return isCommand ? result : result?.trim();
};

export const insertItemWithTrigger = ({
Expand Down Expand Up @@ -134,3 +143,33 @@ export const getTokenizedSuggestionDisplayName = ({
: [displayName],
},
});

export const isTextMatched = (input: string, command: string): boolean => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this a duplicate logic already present in another util function in this file?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which utility are you talking about here?

try {
// Create a regex to match the command at the start of the input with optional whitespace
const regex = new RegExp(`^${escapeRegExp(command)}\\s+`, 'i');
return regex.test(input);
} catch (error) {
console.error('Error in validating with the regex:', error);
return false;
}
};

/**
* Extracts the first word from a given text.
* @param text - The input text from which to extract the first word.
* @returns The first word found in the text, or an empty string if no word is found.
*/
export const getFirstWordFromText = (text: string): string => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const match = text.match(/^\s*(\S+)/);
return match ? match[1] : '';
};

/**
* Strips the trigger from the text.
* @param text - The input text from which to strip the trigger.
* @param trigger - The trigger string to be removed from the start of the text.
* @returns The text with the trigger removed from the start.
*/
export const stripTriggerFromText = (text: string, trigger: string) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not strip trigger. In the single place where it is used it strips a trigger with command name. Trigger is for example "/" but not "/ban".
The function itself could also be renamed to stripTextFromText

Copy link
Member Author

@khushal87 khushal87 Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about stripTextFromStartOfTheText as it strips the text from the start of the text?

text.replace(new RegExp(`^${escapeRegExp(trigger)}\\s*`), '');
1 change: 1 addition & 0 deletions src/messageComposer/middleware/textComposer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export type TextComposerState<T extends Suggestion = Suggestion> = {
mentionedUsers: UserResponse[];
selection: TextSelection;
text: string;
command?: CommandResponse | null;
suggestions?: Suggestions<T>;
};
16 changes: 15 additions & 1 deletion src/messageComposer/textComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { TextSelection } from './middleware/textComposer/types';
import type { TextComposerState } from './middleware/textComposer/types';
import type { Suggestions } from './middleware/textComposer/types';
import type { MessageComposer } from './messageComposer';
import type { DraftMessage, LocalMessage, UserResponse } from '../types';
import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types';

export type TextComposerOptions = {
composer: MessageComposer;
Expand Down Expand Up @@ -37,6 +37,7 @@ const initState = ({
if (!message) {
const text = composer.config.text.defaultValue ?? '';
return {
command: null,
mentionedUsers: [],
text,
selection: { start: text.length, end: text.length },
Expand Down Expand Up @@ -118,6 +119,10 @@ export class TextComposer {

// --- START STATE API ---

get command() {
return this.state.getLatestValue().command;
}

get mentionedUsers() {
return this.state.getLatestValue().mentionedUsers;
}
Expand Down Expand Up @@ -146,6 +151,10 @@ export class TextComposer {
this.state.partialNext({ mentionedUsers: users });
}

clearCommand() {
this.state.partialNext({ command: null });
}

upsertMentionedUser = (user: UserResponse) => {
const mentionedUsers = [...this.mentionedUsers];
const existingUserIndex = mentionedUsers.findIndex((u) => u.id === user.id);
Expand All @@ -169,6 +178,11 @@ export class TextComposer {
this.state.partialNext({ mentionedUsers });
};

setCommand = (command: CommandResponse | null) => {
if (command === this.command) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may not work if the objects have not the same memory reference, but their content refer to the same command. Maybe something like command.name === this.command.name would be more appropriate or to have a function that evaluates that two command objects are equal.

Copy link
Member Author

@khushal87 khushal87 Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed using the 1st way

this.state.partialNext({ command });
};

setText = (text: string) => {
if (!this.enabled || text === this.text) return;
this.state.partialNext({ text });
Expand Down
Loading