Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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: ({
complete,
forward,
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 complete({
...state,
localMessage: {
...state.localMessage,
text: enrichedText,
},
message: {
...state.message,
text: enrichedText,
},
});
},
},
id: 'stream-io/message-composer-middleware/command-injection',
});

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

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

return complete({
...state,
draft: {
...state.draft,
text: enrichedText,
},
});
},
},
id: 'stream-io/message-composer-middleware/draft-command-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 type { CommandSuggestion } from './types';

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

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

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

const trigger = `/${command.name}`;
const newText = state.text.replace(new RegExp(`^${trigger}(\\s|$)`), '');

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

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

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

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

export class CommandSearchSource extends BaseSearchSource<CommandSuggestion> {
Expand Down Expand Up @@ -103,6 +107,7 @@ export const createCommandsMiddleware = (
searchSource?: CommandSearchSource;
},
): CommandsMiddleware => {
const commands = channel?.getConfig()?.commands ?? [];
const finalOptions = mergeWith(DEFAULT_OPTIONS, options ?? {});
let searchSource = new CommandSearchSource(channel);
if (options?.searchSource) {
Expand Down Expand Up @@ -143,29 +148,49 @@ export const createCommandsMiddleware = (
return next(newState);
}

const inputText = triggerWithToken?.toLowerCase().slice(1);
const matchedCommand = commands.find((command) => {
if (!command.name || !inputText) return false;
return isTextMatched(inputText, command.name.toLowerCase());
});

if (matchedCommand) {
return next({
...state,
command: matchedCommand,
suggestions: {
query: triggerWithToken.slice(1),
searchSource,
trigger: finalOptions.trigger,
},
});
}

return complete({
...state,
command: null,
suggestions: {
query: triggerWithToken.slice(1),
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 './applyCommandSettings';
export * from './mentions';
export * from './validation';
export * from './TextComposerMiddlewareExecutor';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,13 @@ 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 {
const regex = new RegExp(`^${escapeRegExp(command)}`, 'i');
return regex.test(input);
} catch (error) {
console.error('Error in validating with the regex:', error);
return false;
}
};
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
Loading