Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,20 @@
import type { Middleware } from '../../../middleware';
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';

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

export const createActiveCommandGuardMiddleware = (): PreCommandMiddleware => ({
handlers: {
onChange: ({ complete, forward, state }) => {
if (state.command) {
return complete(state);
}
return forward();
},
onSuggestionItemSelect: ({ forward }) => forward(),
},
id: 'stream-io/text-composer/active-command-guard',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';
import type { CommandSuggestion } from './types';
import type { Middleware } from '../../../middleware';
import { escapeRegExp } from './textMiddlewareUtils';

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

const stripCommandFromText = (text: string, commandName: string) =>
text.replace(new RegExp(`^${escapeRegExp(`/${commandName}`)}\\s*`), '');

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

if (!command?.name) {
return forward();
}

const newText = stripCommandFromText(state.text, command.name);

return complete({
...state,
selection: {
end: newText.length,
start: newText.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: newText.length,
start: newText.length,
},
text: newText,
});
},
},
id: 'stream-io/text-composer/command-string-extraction',
});
26 changes: 22 additions & 4 deletions src/messageComposer/middleware/textComposer/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ 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 {
getCompleteCommandInString,
getTriggerCharWithToken,
insertItemWithTrigger,
} from './textMiddlewareUtils';
import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor';

export class CommandSearchSource extends BaseSearchSourceSync<CommandSuggestion> {
Expand Down Expand Up @@ -115,10 +119,22 @@ export const createCommandsMiddleware = (
handlers: {
onChange: ({ state, next, complete, forward }) => {
if (!state.selection) return forward();
const finalText = state.text.slice(0, state.selection.end);
const commandName = getCompleteCommandInString(finalText);
if (commandName) {
const command = searchSource?.query(commandName).items[0];
if (command) {
return next({
...state,
command,
suggestions: undefined,
});
}
}

const triggerWithToken = getTriggerCharWithToken({
trigger: finalOptions.trigger,
text: state.text.slice(0, state.selection.end),
text: finalText,
acceptTrailingSpaces: false,
isCommand: true,
});
Expand All @@ -144,27 +160,29 @@ export const createCommandsMiddleware = (

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
2 changes: 2 additions & 0 deletions src/messageComposer/middleware/textComposer/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './activeCommandGuard';
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 @@ -12,6 +17,7 @@ export const getTriggerCharWithToken = ({
acceptTrailingSpaces?: boolean;
}) => {
const triggerNorWhitespace = `[^\\s${trigger}]*`;

const match = text.match(
new RegExp(
isCommand
Expand All @@ -26,6 +32,14 @@ export const getTriggerCharWithToken = ({
return match && match[match.length - 1].trim();
};

export const getCompleteCommandInString = (text: string) => {
// starts with "/" followed by 1+ non-whitespace chars followed by 1+ white-space chars
// the comand name is extracted into a separate group
const match = text.match(/^\/(\S+)\s+.*/);
const commandName = match && match[1];
return commandName;
};

export const insertItemWithTrigger = ({
insertText,
selection,
Expand Down
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>;
};
26 changes: 23 additions & 3 deletions 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?.name === this.command?.name) return;
this.state.partialNext({ command });
};

setText = (text: string) => {
if (!this.enabled || text === this.text) return;
this.state.partialNext({ text });
Expand All @@ -181,7 +195,13 @@ export class TextComposer {
this.state.partialNext({ selection });
};

insertText = ({ text, selection }: { text: string; selection?: TextSelection }) => {
insertText = async ({
text,
selection,
}: {
text: string;
selection?: TextSelection;
}) => {
if (!this.enabled) return;

const finalSelection: TextSelection = selection ?? this.selection;
Expand All @@ -203,7 +223,7 @@ export class TextComposer {
? finalText.length
: currentText.slice(0, expectedCursorPosition).length;

this.state.partialNext({
await this.handleChange({
text: finalText,
selection: {
start: cursorPosition,
Expand Down
2 changes: 1 addition & 1 deletion src/pagination/BasePaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const DEFAULT_PAGINATION_OPTIONS: Required<PaginatorOptions> = {

export abstract class BasePaginator<T> {
state: StateStore<PaginatorState<T>>;
protected pageSize: number;
pageSize: number;
protected _executeQueryDebounced!: DebouncedExecQueryFunction;
protected _isCursorPagination = false;

Expand Down
22 changes: 20 additions & 2 deletions src/pagination/ReminderPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,26 @@ import type { StreamChat } from '../client';

export class ReminderPaginator extends BasePaginator<ReminderResponse> {
private client: StreamChat;
filters: ReminderFilters | undefined;
sort: ReminderSort | undefined;
protected _filters: ReminderFilters | undefined;
protected _sort: ReminderSort | undefined;

get filters(): ReminderFilters | undefined {
return this._filters;
}

get sort(): ReminderSort | undefined {
return this._sort;
}

set filters(filters: ReminderFilters | undefined) {
this._filters = filters;
this.resetState();
}

set sort(sort: ReminderSort | undefined) {
this._sort = sort;
this.resetState();
}

constructor(client: StreamChat, options?: PaginatorOptions) {
super(options);
Expand Down
Loading