From ace39303a909797cc99fd811563ce2d9c9d302ea Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 13 May 2026 13:12:36 +0200 Subject: [PATCH 1/6] feat(messageComposer): add generic command sendability validation Add configurable command sendability validators so the composer can block submit for incomplete built-in and custom commands. Reuse the same validation context for send-button gating and final composition validation to keep command readiness consistent. Co-authored-by: Cursor --- .../configuration/configuration.ts | 6 ++ src/messageComposer/configuration/types.ts | 27 +++++- src/messageComposer/messageComposer.ts | 86 +++++++++++++++++-- .../messageComposer/compositionValidation.ts | 48 ++++++----- .../MessageComposer/messageComposer.test.ts | 78 +++++++++++++++++ .../compositionValidation.test.ts | 50 ++++++++++- 6 files changed, 264 insertions(+), 31 deletions(-) diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 0bd43a450..98e0bcd7a 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -2,6 +2,7 @@ import { find } from 'linkifyjs'; import { API_MAX_FILES_ALLOWED_PER_MESSAGE } from '../../constants'; import type { AttachmentManagerConfig, + CommandsConfig, LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, @@ -39,6 +40,10 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { publishTypingEvents: true, }; +export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = { + validators: [], +}; + export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { enabled: true, getDeviceId: () => generateUUIDv4(), @@ -46,6 +51,7 @@ export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { export const DEFAULT_COMPOSER_CONFIG: MessageComposerConfig = { attachments: DEFAULT_ATTACHMENT_MANAGER_CONFIG, + commands: DEFAULT_COMMANDS_CONFIG, drafts: { enabled: false }, linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG, location: DEFAULT_LOCATION_COMPOSER_CONFIG, diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 0a59b953f..33a76041c 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -1,6 +1,7 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; -import type { FileLike, FileReference } from '../types'; +import type { MessageComposer } from '../messageComposer'; +import type { CommandResponse, FileLike, FileReference, UserResponse } from '../../types'; export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< Record @@ -38,6 +39,28 @@ export type TextComposerConfig = { maxLengthOnSend?: number; }; +export type CommandSendability = { + ready: boolean; + reason?: string & {}; + metadata?: Record; +}; + +export type CommandSendValidationContext = { + command: CommandResponse; + composer: MessageComposer; + commandArgsText: string; + mentionedUsersInText: UserResponse[]; + rawText: string; +}; + +export type CommandSendValidator = ( + context: CommandSendValidationContext, +) => CommandSendability | undefined; + +export type CommandsConfig = { + validators: CommandSendValidator[]; +}; + export type AttachmentManagerConfig = { // todo: document removal of noFiles prop showing how to achieve the same with custom fileUploadFilter function /** @@ -86,6 +109,8 @@ export type LocationComposerConfig = { export type MessageComposerConfig = { /** If true, enables creating drafts on the server */ drafts: DraftsConfiguration; + /** Configuration for command sendability validation */ + commands: CommandsConfig; /** Configuration for the attachment manager */ attachments: AttachmentManagerConfig; /** Configuration for the link previews manager */ diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 61e3ea767..6a2e279e2 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -17,6 +17,10 @@ import { formatMessage, generateUUIDv4, isLocalMessage, unformatMessage } from ' import { mergeWith } from '../utils/mergeWith'; import { Channel } from '../channel'; import { Thread } from '../thread'; +import { + getRawCommandName, + stripCommandFromText, +} from './middleware/textComposer/commandUtils'; import type { ChannelAPIResponse, CommandResponse, @@ -27,10 +31,15 @@ import type { LocalMessageBase, MessageResponse, MessageResponseBase, + UserResponse, } from '../types'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; -import type { MessageComposerConfig } from './configuration/types'; +import type { + CommandSendValidationContext, + CommandSendability, + MessageComposerConfig, +} from './configuration/types'; import type { CommandSuggestionDisabledReason, TextComposerCommandActivationEffect, @@ -378,6 +387,63 @@ export class MessageComposer extends WithSubscriptions { isCommandDisabled = (command: CommandResponse) => !!this.getCommandDisabledReason(command); + getKnownCommand = (commandName?: string): CommandResponse | undefined => { + if (!commandName) return; + + const normalizedCommandName = commandName.toLowerCase(); + return (this.channel.getConfig()?.commands ?? []).find( + (command) => command.name?.toLowerCase() === normalizedCommandName, + ); + }; + + getCurrentCommand = (text = this.textComposer.text): CommandResponse | undefined => + this.textComposer.command ?? this.getKnownCommand(getRawCommandName(text)); + + getMentionedUsersInText = ( + text = this.textComposer.text, + mentionedUsers = this.textComposer.mentionedUsers, + ): UserResponse[] => + Array.from( + new Set( + mentionedUsers.filter( + ({ id, name }) => + text.includes(`@${id}`) || (!!name && text.includes(`@${name}`)), + ), + ), + ); + + getCommandSendValidationContext = ( + command: CommandResponse, + text = this.textComposer.text, + ): CommandSendValidationContext => ({ + command, + commandArgsText: command.name + ? stripCommandFromText(text, command.name).trim() + : text.trim(), + composer: this, + mentionedUsersInText: this.getMentionedUsersInText(text), + rawText: text, + }); + + getCommandSendability = ( + command: CommandResponse, + text = this.textComposer.text, + ): CommandSendability => { + const validationContext = this.getCommandSendValidationContext(command, text); + + for (const validator of this.config.commands.validators) { + const result = validator(validationContext); + if (result && !result.ready) { + return result; + } + } + + return { ready: true }; + }; + + isCommandSendable = (command: CommandResponse, text = this.textComposer.text) => + this.getCommandSendability(command, text).ready; + get pollId() { return this.state.getLatestValue().pollId; } @@ -387,12 +453,18 @@ export class MessageComposer extends WithSubscriptions { } get hasSendableData() { - return !!( - (!this.attachmentManager.uploadsInProgressCount && - (!this.textComposer.textIsEmpty || - this.attachmentManager.successfulUploadsCount > 0)) || - this.pollId || - !!this.locationComposer.validLocation + const currentCommand = this.getCurrentCommand(); + const commandIsSendable = !currentCommand || this.isCommandSendable(currentCommand); + + return ( + commandIsSendable && + !!( + (!this.attachmentManager.uploadsInProgressCount && + (!this.textComposer.textIsEmpty || + this.attachmentManager.successfulUploadsCount > 0)) || + this.pollId || + !!this.locationComposer.validLocation + ) ); } diff --git a/src/messageComposer/middleware/messageComposer/compositionValidation.ts b/src/messageComposer/middleware/messageComposer/compositionValidation.ts index 87f1e0343..93ccb86c8 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -1,6 +1,5 @@ import { textIsEmpty } from '../../textComposer'; import type { CommandResponse } from '../../../types'; -import { CommandSearchSource } from '../textComposer/commands'; import { getRawCommandName, notifyCommandDisabled } from '../textComposer/commandUtils'; import type { MessageComposerMiddlewareState, @@ -11,24 +10,11 @@ import type { import type { MessageComposer } from '../../messageComposer'; import type { MiddlewareHandlerParams } from '../../../middleware'; -const getCommandByName = ( - searchSource: CommandSearchSource, - commandName?: string, -): CommandResponse | undefined => { - if (!commandName) return; - - const normalizedCommandName = commandName.toLowerCase(); - return searchSource - .query(normalizedCommandName) - .items.find((command) => command.name?.toLowerCase() === normalizedCommandName); -}; - const getDisabledRawCommand = ( composer: MessageComposer, - searchSource: CommandSearchSource, text?: string, ): CommandResponse | undefined => { - const rawCommand = getCommandByName(searchSource, getRawCommandName(text)); + const rawCommand = composer.getKnownCommand(getRawCommandName(text)); if (rawCommand && composer.isCommandDisabled(rawCommand)) { return rawCommand; } @@ -37,8 +23,6 @@ const getDisabledRawCommand = ( export const createCompositionValidationMiddleware = ( composer: MessageComposer, ): MessageCompositionMiddleware => { - const commandSearchSource = new CommandSearchSource(composer.channel); - return { id: 'stream-io/message-composer-middleware/data-validation', handlers: { @@ -50,16 +34,36 @@ export const createCompositionValidationMiddleware = ( const { maxLengthOnSend } = composer.config.text ?? {}; const inputText = state.message.text ?? ''; - const disabledRawCommand = getDisabledRawCommand( - composer, - commandSearchSource, - inputText, - ); + const disabledRawCommand = getDisabledRawCommand(composer, inputText); if (disabledRawCommand) { notifyCommandDisabled(composer, disabledRawCommand); return await discard(); } + const currentCommand = composer.getCurrentCommand(inputText); + if (currentCommand) { + const sendability = composer.getCommandSendability(currentCommand, inputText); + + if (!sendability.ready) { + composer.client.notifications.addWarning({ + message: 'Command not ready to be sent', + origin: { + emitter: 'MessageComposer', + context: { command: currentCommand, composer }, + }, + options: { + type: 'validation:command:not-ready', + metadata: { + command: currentCommand.name, + ...(sendability.reason ? { reason: sendability.reason } : {}), + ...(sendability.metadata ?? {}), + }, + }, + }); + return await discard(); + } + } + const hasExceededMaxLength = typeof maxLengthOnSend === 'number' && inputText.length > maxLengthOnSend; diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 928083197..121eb5bc9 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -573,6 +573,47 @@ describe('MessageComposer', () => { expect(messageComposer.hasSendableData).toBe(false); }); + it('should account for command sendability in hasSendableData', () => { + const validator = vi.fn(({ mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? undefined + : { ready: false as const, reason: 'missing_user' as const }, + ); + const { messageComposer } = setup({ + config: { + commands: { + validators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + + messageComposer.textComposer.state.partialNext({ + mentionedUsers: [], + selection: { start: 4, end: 4 }, + text: '/ban', + }); + expect(messageComposer.hasSendableData).toBe(false); + + messageComposer.textComposer.state.partialNext({ + mentionedUsers: [{ id: 'target-user', name: 'Target User' }], + selection: { start: 16, end: 16 }, + text: '/ban @target-user', + }); + expect(messageComposer.hasSendableData).toBe(true); + expect(validator).toHaveBeenLastCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: 'ban' }), + commandArgsText: '@target-user', + mentionedUsersInText: [{ id: 'target-user', name: 'Target User' }], + rawText: '/ban @target-user', + }), + ); + }); + it('should return the correct compositionIsEmpty', () => { const { messageComposer } = setup(); @@ -786,6 +827,43 @@ describe('MessageComposer', () => { expect(messageComposer.getCommandDisabledReason({ name: 'giphy' })).toBeUndefined(); }); + it('should return command sendability for current raw commands', () => { + const validator = vi.fn(({ commandArgsText }) => + commandArgsText.length > 0 + ? undefined + : { + metadata: { expected: 'args' }, + ready: false as const, + reason: 'missing_args', + }, + ); + const { messageComposer } = setup({ + config: { + commands: { + validators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'custom', description: 'Custom command' }], + }); + + expect(messageComposer.getCurrentCommand('/custom')).toEqual( + expect.objectContaining({ name: 'custom' }), + ); + expect( + messageComposer.getCommandSendability( + messageComposer.getCurrentCommand('/custom')!, + '/custom', + ), + ).toEqual({ + metadata: { expected: 'args' }, + ready: false, + reason: 'missing_args', + }); + }); + it('should register subscriptions', () => { const { messageComposer } = setup(); const unsubscribeFunctions = messageComposer[ diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index 768172c3d..e529ce361 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Channel } from '../../../../../src/channel'; import { StreamChat } from '../../../../../src/client'; +import type { MessageComposerConfig } from '../../../../../src/messageComposer'; import { MessageComposer } from '../../../../../src/messageComposer/messageComposer'; import { createCompositionValidationMiddleware, @@ -14,10 +15,15 @@ import { MiddlewareStatus } from '../../../../../src/middleware'; import { MessageComposerMiddlewareState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { MessageDraftComposerMiddlewareValueState } from '../../../../../src/messageComposer/middleware/messageComposer/types'; import { LocalMessage, MessageResponse } from '../../../../../src'; +import type { DeepPartial } from '../../../../../src/types.utility'; import { generateChannel } from '../../../test-utils/generateChannel'; const setupMiddleware = ( - custom: { composer?: MessageComposer; editedMessage?: MessageResponse } = {}, + custom: { + composer?: MessageComposer; + config?: DeepPartial; + editedMessage?: MessageResponse; + } = {}, ) => { const user = { id: 'user' }; const client = new StreamChat('apiKey'); @@ -36,6 +42,7 @@ const setupMiddleware = ( new MessageComposer({ client, compositionContext: channel, + config: custom.config, composition: custom.editedMessage, }); @@ -233,6 +240,47 @@ describe('stream-io/message-composer-middleware/data-validation', () => { ); }); + it('should discard commands that are not ready to send', async () => { + const validator = vi.fn(({ mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? undefined + : { + metadata: { source: 'test-validator' }, + ready: false as const, + reason: 'missing_user' as const, + }, + ); + const { messageComposer, validationMiddleware } = setupMiddleware({ + config: { + commands: { + validators: [validator], + }, + }, + }); + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); + + const result = await validationMiddleware.handlers.compose( + setupMiddlewareInputs(setupCompositionState('/ban')), + ); + + expect(result.status).toBe('discard'); + expect(addWarningSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + metadata: expect.objectContaining({ + command: 'ban', + reason: 'missing_user', + source: 'test-validator', + }), + type: 'validation:command:not-ready', + }), + }), + ); + }); + it('should allow raw known commands if command is not disabled', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ From a72e8d5fdfe66f3ffeb9ac542600a57ec93e2984 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 13 May 2026 13:53:01 +0200 Subject: [PATCH 2/6] fix(messageComposer): ship default ban sendability rules Provide a default ban validator in composer config. Allow command validator arrays to be replaced via config overrides. Co-authored-by: Cursor --- .../configuration/configuration.ts | 35 ++++++++++- src/messageComposer/configuration/types.ts | 3 +- src/messageComposer/messageComposer.ts | 43 ++++++++++--- .../MessageComposer/messageComposer.test.ts | 60 +++++++++++++++++++ .../compositionValidation.test.ts | 50 ++++++++++++++++ 5 files changed, 181 insertions(+), 10 deletions(-) diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index 98e0bcd7a..d37c7d6b4 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -2,12 +2,14 @@ import { find } from 'linkifyjs'; import { API_MAX_FILES_ALLOWED_PER_MESSAGE } from '../../constants'; import type { AttachmentManagerConfig, + CommandSendValidator, CommandsConfig, LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, } from './types'; import type { TextComposerConfig } from './types'; +import type { UserResponse } from '../../types'; import { generateUUIDv4 } from '../../utils'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { @@ -40,8 +42,39 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { publishTypingEvents: true, }; +const stripMentionTokens = (text: string, mentionedUsersInText: UserResponse[]) => + mentionedUsersInText.reduce((value, user) => { + let next = value.replace(`@${user.id}`, ''); + + if (user.name) { + next = next.replace(`@${user.name}`, ''); + } + + return next.trim(); + }, text.trim()); + +export const defaultCommandSendabilityValidator: CommandSendValidator = ({ + command, + commandArgsText, + mentionedUsersInText, +}) => { + if (command.name !== 'ban') return; + + if (mentionedUsersInText.length === 0) { + return { ready: false, reason: 'missing_mention' }; + } + + const reason = stripMentionTokens(commandArgsText, mentionedUsersInText); + + if (!reason.length) { + return { ready: false, reason: 'missing_reason' }; + } + + return { ready: true }; +}; + export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = { - validators: [], + validators: [defaultCommandSendabilityValidator], }; export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 33a76041c..36d171074 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -1,7 +1,8 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; import type { MessageComposer } from '../messageComposer'; -import type { CommandResponse, FileLike, FileReference, UserResponse } from '../../types'; +import type { FileLike, FileReference } from '../types'; +import type { CommandResponse, UserResponse } from '../../types'; export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< Record diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 6a2e279e2..7dd843723 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -36,6 +36,7 @@ import type { import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; import type { + CommandSendValidator, CommandSendValidationContext, CommandSendability, MessageComposerConfig, @@ -174,6 +175,27 @@ const initState = ( }; }; +const applyCommandValidatorOverride = ( + targetConfig: MessageComposerConfig, + sourceConfig?: DeepPartial, +) => { + const overrideValidators = sourceConfig?.commands?.validators as + | CommandSendValidator[] + | undefined; + + if (typeof overrideValidators === 'undefined') { + return targetConfig; + } + + return { + ...targetConfig, + commands: { + ...targetConfig.commands, + validators: overrideValidators, + }, + }; +}; + export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; @@ -232,14 +254,17 @@ export class MessageComposer extends WithSubscriptions { : originalVal; this.configState = new StateStore( - mergeWith( - mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}), - { - location: { - enabled: this.channel.getConfig()?.shared_locations, + applyCommandValidatorOverride( + mergeWith( + mergeWith(DEFAULT_COMPOSER_CONFIG, config ?? {}), + { + location: { + enabled: this.channel.getConfig()?.shared_locations, + }, }, - }, - mergeChannelConfigCustomizer, + mergeChannelConfigCustomizer, + ), + config, ), ); @@ -498,7 +523,9 @@ export class MessageComposer extends WithSubscriptions { } updateConfig(config: DeepPartial) { - this.configState.partialNext(mergeWith(this.config, config)); + this.configState.partialNext( + applyCommandValidatorOverride(mergeWith(this.config, config), config), + ); } refreshId = () => { diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 121eb5bc9..0aeb844e7 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -614,6 +614,28 @@ describe('MessageComposer', () => { ); }); + it('should apply the default ban command validator', () => { + const { messageComposer } = setup(); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + + messageComposer.textComposer.state.partialNext({ + mentionedUsers: [{ id: 'target-user', name: 'Target User' }], + selection: { start: 16, end: 16 }, + text: '/ban @target-user', + }); + expect(messageComposer.hasSendableData).toBe(false); + + messageComposer.textComposer.state.partialNext({ + mentionedUsers: [{ id: 'target-user', name: 'Target User' }], + selection: { start: 23, end: 23 }, + text: '/ban @target-user rude', + }); + expect(messageComposer.hasSendableData).toBe(true); + }); + it('should return the correct compositionIsEmpty', () => { const { messageComposer } = setup(); @@ -864,6 +886,44 @@ describe('MessageComposer', () => { }); }); + it('should allow overriding default command validators via config', () => { + const validator = vi.fn(({ mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? { ready: true as const } + : { ready: false as const, reason: 'missing_user' as const }, + ); + const { messageComposer } = setup({ + config: { + commands: { + validators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + messageComposer.textComposer.state.partialNext({ + mentionedUsers: [{ id: 'target-user', name: 'Target User' }], + selection: { start: 16, end: 16 }, + text: '/ban @target-user', + }); + + expect( + messageComposer.getCommandSendability( + messageComposer.getCurrentCommand('/ban @target-user')!, + '/ban @target-user', + ), + ).toEqual({ ready: true }); + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: 'ban' }), + commandArgsText: '@target-user', + rawText: '/ban @target-user', + }), + ); + }); + it('should register subscriptions', () => { const { messageComposer } = setup(); const unsubscribeFunctions = messageComposer[ diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index e529ce361..97944a1f0 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -281,6 +281,56 @@ describe('stream-io/message-composer-middleware/data-validation', () => { ); }); + it('should discard ban commands without a reason by default', async () => { + const { messageComposer, validationMiddleware } = setupMiddleware(); + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue('/ban @user1'); + vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ + { id: 'user1', name: 'User One' }, + ]); + const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); + + const result = await validationMiddleware.handlers.compose( + setupMiddlewareInputs(setupCompositionState('/ban @user1')), + ); + + expect(result.status).toBe('discard'); + expect(addWarningSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + metadata: expect.objectContaining({ + command: 'ban', + reason: 'missing_reason', + }), + type: 'validation:command:not-ready', + }), + }), + ); + }); + + it('should allow ban commands with mention and reason by default', async () => { + const { messageComposer, validationMiddleware } = setupMiddleware(); + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + '/ban @user1 rude behavior', + ); + vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([ + { id: 'user1', name: 'User One' }, + ]); + const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); + + const result = await validationMiddleware.handlers.compose( + setupMiddlewareInputs(setupCompositionState('/ban @user1 rude behavior')), + ); + + expect(result.status).toBeUndefined(); + expect(addWarningSpy).not.toHaveBeenCalled(); + }); + it('should allow raw known commands if command is not disabled', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ From 5a0e0ade09c6d501bcf7cdac6463e1aff3939fe7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Wed, 13 May 2026 14:25:11 +0200 Subject: [PATCH 3/6] refactor: rename mergeChannelConfigCustomizer to mergeMessageComposerConfigCustomizer --- src/messageComposer/messageComposer.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index 7dd843723..fcd66b625 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -239,7 +239,16 @@ export class MessageComposer extends WithSubscriptions { ); } - const mergeChannelConfigCustomizer: MergeWithCustomizer< + /** + * Customizes config merges for the composer constructor. + * + * It catches two scalar override cases that should not use the default deep merge: + * - client-disabled `enabled` flags stay disabled even if the channel config tries to re-enable them + * - scalar channel-config values replace client defaults for matching config keys + * + * All other values fall back to the normal `mergeWith` behavior. + */ + const mergeMessageComposerConfigCustomizer: MergeWithCustomizer< DeepPartial > = (originalVal, channelConfigVal, key) => typeof originalVal === 'object' @@ -262,7 +271,7 @@ export class MessageComposer extends WithSubscriptions { enabled: this.channel.getConfig()?.shared_locations, }, }, - mergeChannelConfigCustomizer, + mergeMessageComposerConfigCustomizer, ), config, ), From 2284b22a4759310322faa3181eddd2959c0b006c Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 14 May 2026 10:47:37 +0200 Subject: [PATCH 4/6] feat: control command sendability --- .../configuration/commands.configuration.ts | 55 ++++++++ .../configuration/configuration.ts | 41 +----- src/messageComposer/configuration/index.ts | 4 + src/messageComposer/configuration/types.ts | 3 +- src/messageComposer/messageComposer.ts | 118 +++++------------- .../messageComposer/compositionValidation.ts | 49 ++++---- .../messageComposer/textComposer.ts | 18 +-- .../middleware/textComposer/commandUtils.ts | 69 +++++++++- .../middleware/textComposer/commands.ts | 8 +- .../middleware/textComposer/index.ts | 1 + .../middleware/textComposer/mentions.ts | 21 +++- .../MessageComposer/messageComposer.test.ts | 105 +++++++++++++--- .../compositionValidation.test.ts | 115 ++++++++++++++++- 13 files changed, 414 insertions(+), 193 deletions(-) create mode 100644 src/messageComposer/configuration/commands.configuration.ts diff --git a/src/messageComposer/configuration/commands.configuration.ts b/src/messageComposer/configuration/commands.configuration.ts new file mode 100644 index 000000000..f3b877c1c --- /dev/null +++ b/src/messageComposer/configuration/commands.configuration.ts @@ -0,0 +1,55 @@ +import type { + CommandsConfig, + CommandSendValidator, + MessageComposerConfig, +} from './types'; +import type { DeepPartial } from '../../types.utility'; +import { stripMentionTokens } from '../middleware'; + +const MENTION_ONLY_COMMANDS = new Set(['mute', 'unmute', 'unban']); +export const defaultCommandSendabilityValidator: CommandSendValidator = ({ + command, + commandArgsText, + mentionedUsersInText, +}) => { + if (command.name !== 'ban' && !MENTION_ONLY_COMMANDS.has(command.name ?? '')) return; + + if (mentionedUsersInText.length === 0) { + return { command, ready: false, reason: 'missing_mention' }; + } + + if (command.name !== 'ban') { + return { command, ready: true }; + } + + const reason = stripMentionTokens(commandArgsText, mentionedUsersInText); + + if (!reason.length) { + return { command, ready: false, reason: 'missing_reason' }; + } + + return { command, ready: true }; +}; +export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = { + sendValidators: [defaultCommandSendabilityValidator], +}; +export const applyCommandValidatorOverride = ( + targetConfig: MessageComposerConfig, + sourceConfig?: DeepPartial, +) => { + const overrideValidators = sourceConfig?.commands?.sendValidators as + | CommandSendValidator[] + | undefined; + + if (typeof overrideValidators === 'undefined') { + return targetConfig; + } + + return { + ...targetConfig, + commands: { + ...targetConfig.commands, + sendValidators: overrideValidators, + }, + }; +}; diff --git a/src/messageComposer/configuration/configuration.ts b/src/messageComposer/configuration/configuration.ts index d37c7d6b4..9a8ff18c0 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -2,15 +2,13 @@ import { find } from 'linkifyjs'; import { API_MAX_FILES_ALLOWED_PER_MESSAGE } from '../../constants'; import type { AttachmentManagerConfig, - CommandSendValidator, - CommandsConfig, LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, + TextComposerConfig, } from './types'; -import type { TextComposerConfig } from './types'; -import type { UserResponse } from '../../types'; import { generateUUIDv4 } from '../../utils'; +import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { debounceURLEnrichmentMs: 1500, @@ -42,41 +40,6 @@ export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = { publishTypingEvents: true, }; -const stripMentionTokens = (text: string, mentionedUsersInText: UserResponse[]) => - mentionedUsersInText.reduce((value, user) => { - let next = value.replace(`@${user.id}`, ''); - - if (user.name) { - next = next.replace(`@${user.name}`, ''); - } - - return next.trim(); - }, text.trim()); - -export const defaultCommandSendabilityValidator: CommandSendValidator = ({ - command, - commandArgsText, - mentionedUsersInText, -}) => { - if (command.name !== 'ban') return; - - if (mentionedUsersInText.length === 0) { - return { ready: false, reason: 'missing_mention' }; - } - - const reason = stripMentionTokens(commandArgsText, mentionedUsersInText); - - if (!reason.length) { - return { ready: false, reason: 'missing_reason' }; - } - - return { ready: true }; -}; - -export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = { - validators: [defaultCommandSendabilityValidator], -}; - export const DEFAULT_LOCATION_COMPOSER_CONFIG: LocationComposerConfig = { enabled: true, getDeviceId: () => generateUUIDv4(), diff --git a/src/messageComposer/configuration/index.ts b/src/messageComposer/configuration/index.ts index 28e190c8b..62da8735a 100644 --- a/src/messageComposer/configuration/index.ts +++ b/src/messageComposer/configuration/index.ts @@ -1,2 +1,6 @@ export * from './configuration'; export * from './types'; +export { applyCommandValidatorOverride } from './commands.configuration'; +export { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; +export { defaultCommandSendabilityValidator } from './commands.configuration'; +export { MENTION_ONLY_COMMANDS } from './commands.configuration'; diff --git a/src/messageComposer/configuration/types.ts b/src/messageComposer/configuration/types.ts index 36d171074..048cf3848 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -41,6 +41,7 @@ export type TextComposerConfig = { }; export type CommandSendability = { + command: CommandResponse; ready: boolean; reason?: string & {}; metadata?: Record; @@ -59,7 +60,7 @@ export type CommandSendValidator = ( ) => CommandSendability | undefined; export type CommandsConfig = { - validators: CommandSendValidator[]; + sendValidators: CommandSendValidator[]; }; export type AttachmentManagerConfig = { diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index fcd66b625..dfd859b2d 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -5,7 +5,7 @@ import { LocationComposer } from './LocationComposer'; import { MessageComposerEffectHandlers } from './MessageComposerEffectHandlers'; import { PollComposer } from './pollComposer'; import { TextComposer } from './textComposer'; -import { DEFAULT_COMPOSER_CONFIG } from './configuration'; +import { applyCommandValidatorOverride, DEFAULT_COMPOSER_CONFIG } from './configuration'; import type { MessageComposerMiddlewareValue } from './middleware'; import { MessageComposerMiddlewareExecutor, @@ -17,10 +17,6 @@ import { formatMessage, generateUUIDv4, isLocalMessage, unformatMessage } from ' import { mergeWith } from '../utils/mergeWith'; import { Channel } from '../channel'; import { Thread } from '../thread'; -import { - getRawCommandName, - stripCommandFromText, -} from './middleware/textComposer/commandUtils'; import type { ChannelAPIResponse, CommandResponse, @@ -31,16 +27,10 @@ import type { LocalMessageBase, MessageResponse, MessageResponseBase, - UserResponse, } from '../types'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; -import type { - CommandSendValidator, - CommandSendValidationContext, - CommandSendability, - MessageComposerConfig, -} from './configuration/types'; +import type { CommandSendability, MessageComposerConfig } from './configuration/types'; import type { CommandSuggestionDisabledReason, TextComposerCommandActivationEffect, @@ -54,6 +44,10 @@ import type { PollComposerSnapshot } from './pollComposer'; import type { TextComposerSnapshot } from './textComposer'; import type { DeepPartial } from '../types.utility'; import type { MergeWithCustomizer } from '../utils/mergeWith/mergeWithCore'; +import { + getMentionedUsersInText, + stripCommandFromText, +} from './middleware/textComposer/commandUtils'; type UnregisterSubscriptions = Unsubscribe; @@ -175,27 +169,6 @@ const initState = ( }; }; -const applyCommandValidatorOverride = ( - targetConfig: MessageComposerConfig, - sourceConfig?: DeepPartial, -) => { - const overrideValidators = sourceConfig?.commands?.validators as - | CommandSendValidator[] - | undefined; - - if (typeof overrideValidators === 'undefined') { - return targetConfig; - } - - return { - ...targetConfig, - commands: { - ...targetConfig.commands, - validators: overrideValidators, - }, - }; -}; - export class MessageComposer extends WithSubscriptions { readonly channel: Channel; readonly state: StateStore; @@ -403,6 +376,14 @@ export class MessageComposer extends WithSubscriptions { return this.state.getLatestValue().quotedMessage; } + get pollId() { + return this.state.getLatestValue().pollId; + } + + get showReplyInChannel() { + return this.state.getLatestValue().showReplyInChannel; + } + getCommandDisabledReason = ( command: CommandResponse, ): CommandSuggestionDisabledReason | undefined => { @@ -421,73 +402,38 @@ export class MessageComposer extends WithSubscriptions { isCommandDisabled = (command: CommandResponse) => !!this.getCommandDisabledReason(command); - getKnownCommand = (commandName?: string): CommandResponse | undefined => { - if (!commandName) return; - - const normalizedCommandName = commandName.toLowerCase(); - return (this.channel.getConfig()?.commands ?? []).find( - (command) => command.name?.toLowerCase() === normalizedCommandName, - ); - }; - - getCurrentCommand = (text = this.textComposer.text): CommandResponse | undefined => - this.textComposer.command ?? this.getKnownCommand(getRawCommandName(text)); - - getMentionedUsersInText = ( - text = this.textComposer.text, - mentionedUsers = this.textComposer.mentionedUsers, - ): UserResponse[] => - Array.from( - new Set( - mentionedUsers.filter( - ({ id, name }) => - text.includes(`@${id}`) || (!!name && text.includes(`@${name}`)), - ), - ), - ); - - getCommandSendValidationContext = ( - command: CommandResponse, - text = this.textComposer.text, - ): CommandSendValidationContext => ({ - command, - commandArgsText: command.name - ? stripCommandFromText(text, command.name).trim() - : text.trim(), - composer: this, - mentionedUsersInText: this.getMentionedUsersInText(text), - rawText: text, - }); - - getCommandSendability = ( + validateCommandSendability = ( command: CommandResponse, text = this.textComposer.text, ): CommandSendability => { - const validationContext = this.getCommandSendValidationContext(command, text); + const validationContext = { + command, + commandArgsText: command.name + ? stripCommandFromText(text, command.name).trim() + : text.trim(), + composer: this, + mentionedUsersInText: getMentionedUsersInText( + text, + this.textComposer.mentionedUsers, + ), + rawText: text, + }; - for (const validator of this.config.commands.validators) { + for (const validator of this.config.commands.sendValidators) { const result = validator(validationContext); if (result && !result.ready) { return result; } } - return { ready: true }; + return { command, ready: true }; }; isCommandSendable = (command: CommandResponse, text = this.textComposer.text) => - this.getCommandSendability(command, text).ready; - - get pollId() { - return this.state.getLatestValue().pollId; - } - - get showReplyInChannel() { - return this.state.getLatestValue().showReplyInChannel; - } + this.validateCommandSendability(command, text).ready; get hasSendableData() { - const currentCommand = this.getCurrentCommand(); + const currentCommand = this.textComposer.command; const commandIsSendable = !currentCommand || this.isCommandSendable(currentCommand); return ( @@ -732,6 +678,7 @@ export class MessageComposer extends WithSubscriptions { draft.channel_cid !== this.channel.cid ) return; + if (this.editedMessage) return; this.initState({ composition: draft }); }).unsubscribe; @@ -745,6 +692,7 @@ export class MessageComposer extends WithSubscriptions { ) { return; } + if (this.editedMessage) return; this.logDraftUpdateTimestamp(); diff --git a/src/messageComposer/middleware/messageComposer/compositionValidation.ts b/src/messageComposer/middleware/messageComposer/compositionValidation.ts index 93ccb86c8..5c83d1ea9 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -1,6 +1,12 @@ import { textIsEmpty } from '../../textComposer'; import type { CommandResponse } from '../../../types'; -import { getRawCommandName, notifyCommandDisabled } from '../textComposer/commandUtils'; +import { CommandSearchSource } from '../textComposer/commands'; +import { + getCommandByName, + getRawCommandName, + notifyCommandDisabled, + notifyCommandNotReady, +} from '../textComposer/commandUtils'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, @@ -12,9 +18,10 @@ import type { MiddlewareHandlerParams } from '../../../middleware'; const getDisabledRawCommand = ( composer: MessageComposer, + searchSource: Pick, text?: string, ): CommandResponse | undefined => { - const rawCommand = composer.getKnownCommand(getRawCommandName(text)); + const rawCommand = getCommandByName(searchSource, getRawCommandName(text)); if (rawCommand && composer.isCommandDisabled(rawCommand)) { return rawCommand; } @@ -22,7 +29,11 @@ const getDisabledRawCommand = ( export const createCompositionValidationMiddleware = ( composer: MessageComposer, + commandSearchSource?: Pick, ): MessageCompositionMiddleware => { + const effectiveCommandSearchSource = + commandSearchSource ?? new CommandSearchSource(composer.channel); + return { id: 'stream-io/message-composer-middleware/data-validation', handlers: { @@ -34,32 +45,26 @@ export const createCompositionValidationMiddleware = ( const { maxLengthOnSend } = composer.config.text ?? {}; const inputText = state.message.text ?? ''; - const disabledRawCommand = getDisabledRawCommand(composer, inputText); + const disabledRawCommand = getDisabledRawCommand( + composer, + effectiveCommandSearchSource, + inputText, + ); if (disabledRawCommand) { notifyCommandDisabled(composer, disabledRawCommand); return await discard(); } - const currentCommand = composer.getCurrentCommand(inputText); + const currentCommand = + composer.textComposer.command ?? + getCommandByName(effectiveCommandSearchSource, getRawCommandName(inputText)); if (currentCommand) { - const sendability = composer.getCommandSendability(currentCommand, inputText); - - if (!sendability.ready) { - composer.client.notifications.addWarning({ - message: 'Command not ready to be sent', - origin: { - emitter: 'MessageComposer', - context: { command: currentCommand, composer }, - }, - options: { - type: 'validation:command:not-ready', - metadata: { - command: currentCommand.name, - ...(sendability.reason ? { reason: sendability.reason } : {}), - ...(sendability.metadata ?? {}), - }, - }, - }); + if ( + notifyCommandNotReady({ + composer, + sendability: composer.validateCommandSendability(currentCommand, inputText), + }) + ) { return await discard(); } } diff --git a/src/messageComposer/middleware/messageComposer/textComposer.ts b/src/messageComposer/middleware/messageComposer/textComposer.ts index 5a702515b..2caf5c39d 100644 --- a/src/messageComposer/middleware/messageComposer/textComposer.ts +++ b/src/messageComposer/middleware/messageComposer/textComposer.ts @@ -4,6 +4,7 @@ import type { MessageDraftComposerMiddlewareValueState, MessageDraftCompositionMiddleware, } from './types'; +import { getMentionedUsersInText } from '../textComposer/commandUtils'; import type { MessageComposer } from '../../messageComposer'; import type { MiddlewareHandlerParams } from '../../../middleware'; @@ -22,13 +23,7 @@ export const createTextComposerCompositionMiddleware = ( // Instead of checking if a user is still mentioned every time the text changes, // just filter out non-mentioned users before submit, which is cheaper // and allows users to easily undo any accidental deletion - const mentioned_users = Array.from( - new Set( - mentionedUsers.filter( - ({ id, name }) => text.includes(`@${id}`) || text.includes(`@${name}`), - ), - ), - ); + const mentioned_users = getMentionedUsersInText(text, mentionedUsers); // prevent introducing text and mentioned_users array into the payload sent to the server if (!text && mentioned_users.length === 0) return forward(); @@ -67,14 +62,7 @@ export const createDraftTextComposerCompositionMiddleware = ( // just filter out non-mentioned users before submit, which is cheaper // and allows users to easily undo any accidental deletion const mentioned_users = mentionedUsers.length - ? Array.from( - new Set( - mentionedUsers.filter( - ({ id, name }) => - inputText.includes(`@${id}`) || inputText.includes(`@${name}`), - ), - ), - ) + ? getMentionedUsersInText(inputText, mentionedUsers) : undefined; const text = diff --git a/src/messageComposer/middleware/textComposer/commandUtils.ts b/src/messageComposer/middleware/textComposer/commandUtils.ts index d29789550..d5109687e 100644 --- a/src/messageComposer/middleware/textComposer/commandUtils.ts +++ b/src/messageComposer/middleware/textComposer/commandUtils.ts @@ -1,5 +1,7 @@ import type { MessageComposer } from '../../messageComposer'; -import type { CommandResponse } from '../../../types'; +import type { CommandResponse, UserResponse } from '../../../types'; +import type { CommandSendability } from '../../configuration'; +import type { CommandSearchSource } from './commands'; export function escapeCommandRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); @@ -19,6 +21,43 @@ export const getCompleteCommandInString = (text: string) => { export const stripCommandFromText = (text: string, commandName: string) => text.replace(new RegExp(`^${escapeCommandRegExp(`/${commandName}`)}\\s*`), ''); +export const stripMentionTokens = ( + text: string, + mentionedUsersInText: UserResponse[], + trigger = '@', +) => + mentionedUsersInText.reduce((value, user) => { + let next = value.replace(`${trigger}${user.id}`, ''); + + if (user.name) { + next = next.replace(`${trigger}${user.name}`, ''); + } + + return next.trim(); + }, text.trim()); + +export const getMentionedUsersInText = (text: string, mentionedUsers: UserResponse[]) => + Array.from( + new Set( + mentionedUsers.filter( + ({ id, name }) => + text.includes(`@${id}`) || (!!name && text.includes(`@${name}`)), + ), + ), + ); + +export const getCommandByName = ( + searchSource: Pick, + commandName?: string, +): CommandResponse | undefined => { + if (!commandName) return; + + const normalizedCommandName = commandName.toLowerCase(); + return searchSource + .query(normalizedCommandName) + .items.find((command) => command.name?.toLowerCase() === normalizedCommandName); +}; + export const notifyCommandDisabled = ( composer: MessageComposer, command: CommandResponse, @@ -46,3 +85,31 @@ export const notifyCommandDisabled = ( return true; }; + +export const notifyCommandNotReady = ({ + composer, + sendability, +}: { + composer: MessageComposer; + sendability: CommandSendability; +}) => { + if (sendability.ready) return; + + composer.client.notifications.addWarning({ + message: 'Command not ready to be sent', + origin: { + emitter: 'MessageComposer', + context: { command: sendability.command, composer }, + }, + options: { + type: 'validation:command:not-ready', + metadata: { + command: sendability.command.name, + ...(sendability.reason ? { reason: sendability.reason } : {}), + ...(sendability.metadata ?? {}), + }, + }, + }); + + return true; +}; diff --git a/src/messageComposer/middleware/textComposer/commands.ts b/src/messageComposer/middleware/textComposer/commands.ts index 4138bb549..82000eebd 100644 --- a/src/messageComposer/middleware/textComposer/commands.ts +++ b/src/messageComposer/middleware/textComposer/commands.ts @@ -6,7 +6,11 @@ import type { CommandResponse } from '../../../types'; import { mergeWith } from '../../../utils/mergeWith'; import type { MessageComposer } from '../../messageComposer'; import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types'; -import { getCompleteCommandInString, notifyCommandDisabled } from './commandUtils'; +import { + getCommandByName, + getCompleteCommandInString, + notifyCommandDisabled, +} from './commandUtils'; import { getTriggerCharWithToken, insertItemWithTrigger } from './textMiddlewareUtils'; import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; @@ -124,7 +128,7 @@ export const createCommandsMiddleware = ( const finalText = state.text.slice(0, state.selection.end); const commandName = getCompleteCommandInString(finalText); if (commandName) { - const command = searchSource?.query(commandName).items[0]; + const command = getCommandByName(searchSource, commandName); const composer = options?.composer; if (command && !composer?.isCommandDisabled(command)) { return next({ diff --git a/src/messageComposer/middleware/textComposer/index.ts b/src/messageComposer/middleware/textComposer/index.ts index 856e495bb..b7a1648d0 100644 --- a/src/messageComposer/middleware/textComposer/index.ts +++ b/src/messageComposer/middleware/textComposer/index.ts @@ -2,6 +2,7 @@ export * from './activeCommandGuard'; export * from './commands'; export * from './commandEffects'; export * from './commandStringExtraction'; +export * from './commandUtils'; export * from './mentions'; export * from './validation'; export * from './TextComposerMiddlewareExecutor'; diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 200701231..1d5e7e833 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -77,6 +77,7 @@ export const calculateLevenshtein = (query: string, name: string) => { export type MentionsSearchSourceOptions = SearchSourceOptions & { mentionAllAppUsers?: boolean; textComposerText?: string; + trigger?: string; // todo: document that if you want transliteration, you need to provide the function, e.g. import {default: transliterate} from '@sindresorhus/transliterate'; // this is now replacing a parameter useMentionsTransliteration transliterate?: (text: string) => string; @@ -94,12 +95,17 @@ export class MentionsSearchSource extends BaseSearchSource { config: MentionsSearchSourceOptions; constructor(channel: Channel, options?: MentionsSearchSourceOptions) { - const { mentionAllAppUsers, textComposerText, transliterate, ...restOptions } = - options || {}; + const { + mentionAllAppUsers, + textComposerText, + transliterate, + trigger, + ...restOptions + } = options || {}; super(restOptions); this.client = channel.getClient(); this.channel = channel; - this.config = { mentionAllAppUsers, textComposerText }; + this.config = { mentionAllAppUsers, textComposerText, trigger }; if (transliterate) { this.transliterate = transliterate; @@ -169,7 +175,8 @@ export class MentionsSearchSource extends BaseSearchSource { ).toLowerCase(); const maxDistance = 3; - const lastDigits = textComposerText.slice(-(maxDistance + 1)).includes('@'); + const trigger = this.config.trigger ?? '@'; + const lastDigits = textComposerText.slice(-(maxDistance + 1)).includes(trigger); if (updatedName) { const levenshtein = calculateLevenshtein(updatedQuery, updatedName); @@ -336,7 +343,7 @@ export const createMentionsMiddleware = ( searchSource = options.searchSource; searchSource.resetState(); } else { - searchSource = new MentionsSearchSource(channel); + searchSource = new MentionsSearchSource(channel, { trigger: finalOptions.trigger }); } searchSource.activate(); return { @@ -389,7 +396,9 @@ export const createMentionsMiddleware = ( return complete({ ...state, ...insertItemWithTrigger({ - insertText: `@${selectedSuggestion.name || selectedSuggestion.id} `, + insertText: `${searchSource.config.trigger ?? finalOptions.trigger}${ + selectedSuggestion.name || selectedSuggestion.id + } `, selection: state.selection, text: state.text, trigger: finalOptions.trigger, diff --git a/test/unit/MessageComposer/messageComposer.test.ts b/test/unit/MessageComposer/messageComposer.test.ts index 0aeb844e7..8a6a95437 100644 --- a/test/unit/MessageComposer/messageComposer.test.ts +++ b/test/unit/MessageComposer/messageComposer.test.ts @@ -12,9 +12,11 @@ import { Thread, } from '../../../src'; import { DeepPartial } from '../../../src/types.utility'; +import { CommandSearchSource } from '../../../src/messageComposer/middleware/textComposer/commands'; import { MessageComposer } from '../../../src/messageComposer/messageComposer'; import { DraftResponse, MessageResponse } from '../../../src/types'; import { MockOfflineDB } from '../offline-support/MockOfflineDB'; +import { getCommandByName } from '../../../src/messageComposer/middleware/textComposer/commandUtils'; const generateUuidV4Output = 'test-uuid'; // Mock dependencies @@ -574,15 +576,15 @@ describe('MessageComposer', () => { }); it('should account for command sendability in hasSendableData', () => { - const validator = vi.fn(({ mentionedUsersInText }) => + const validator = vi.fn(({ command, mentionedUsersInText }) => mentionedUsersInText.length > 0 ? undefined - : { ready: false as const, reason: 'missing_user' as const }, + : { command, ready: false as const, reason: 'missing_user' as const }, ); const { messageComposer } = setup({ config: { commands: { - validators: [validator], + sendValidators: [validator], }, }, }); @@ -592,6 +594,7 @@ describe('MessageComposer', () => { }); messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, mentionedUsers: [], selection: { start: 4, end: 4 }, text: '/ban', @@ -599,6 +602,7 @@ describe('MessageComposer', () => { expect(messageComposer.hasSendableData).toBe(false); messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, mentionedUsers: [{ id: 'target-user', name: 'Target User' }], selection: { start: 16, end: 16 }, text: '/ban @target-user', @@ -622,6 +626,7 @@ describe('MessageComposer', () => { }); messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, mentionedUsers: [{ id: 'target-user', name: 'Target User' }], selection: { start: 16, end: 16 }, text: '/ban @target-user', @@ -629,6 +634,7 @@ describe('MessageComposer', () => { expect(messageComposer.hasSendableData).toBe(false); messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, mentionedUsers: [{ id: 'target-user', name: 'Target User' }], selection: { start: 23, end: 23 }, text: '/ban @target-user rude', @@ -636,6 +642,36 @@ describe('MessageComposer', () => { expect(messageComposer.hasSendableData).toBe(true); }); + it('should require mentions for default moderation target commands', () => { + const { messageComposer } = setup(); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [ + { name: 'mute', description: 'Mute a user' }, + { name: 'unmute', description: 'Unmute a user' }, + { name: 'unban', description: 'Unban a user' }, + ], + }); + + for (const commandName of ['mute', 'unmute', 'unban'] as const) { + messageComposer.textComposer.state.partialNext({ + command: { description: `${commandName} a user`, name: commandName }, + mentionedUsers: [], + selection: { start: commandName.length + 1, end: commandName.length + 1 }, + text: `/${commandName}`, + }); + expect(messageComposer.hasSendableData).toBe(false); + + messageComposer.textComposer.state.partialNext({ + command: { description: `${commandName} a user`, name: commandName }, + mentionedUsers: [{ id: 'target-user', name: 'Target User' }], + selection: { start: commandName.length + 14, end: commandName.length + 14 }, + text: `/${commandName} @target-user`, + }); + expect(messageComposer.hasSendableData).toBe(true); + } + }); + it('should return the correct compositionIsEmpty', () => { const { messageComposer } = setup(); @@ -850,10 +886,11 @@ describe('MessageComposer', () => { }); it('should return command sendability for current raw commands', () => { - const validator = vi.fn(({ commandArgsText }) => + const validator = vi.fn(({ command, commandArgsText }) => commandArgsText.length > 0 ? undefined : { + command, metadata: { expected: 'args' }, ready: false as const, reason: 'missing_args', @@ -862,7 +899,7 @@ describe('MessageComposer', () => { const { messageComposer } = setup({ config: { commands: { - validators: [validator], + sendValidators: [validator], }, }, }); @@ -870,16 +907,12 @@ describe('MessageComposer', () => { vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ commands: [{ name: 'custom', description: 'Custom command' }], }); + const customCommand = { description: 'Custom command', name: 'custom' }; - expect(messageComposer.getCurrentCommand('/custom')).toEqual( - expect.objectContaining({ name: 'custom' }), - ); expect( - messageComposer.getCommandSendability( - messageComposer.getCurrentCommand('/custom')!, - '/custom', - ), + messageComposer.validateCommandSendability(customCommand, '/custom'), ).toEqual({ + command: customCommand, metadata: { expected: 'args' }, ready: false, reason: 'missing_args', @@ -887,15 +920,15 @@ describe('MessageComposer', () => { }); it('should allow overriding default command validators via config', () => { - const validator = vi.fn(({ mentionedUsersInText }) => + const validator = vi.fn(({ command, mentionedUsersInText }) => mentionedUsersInText.length > 0 - ? { ready: true as const } - : { ready: false as const, reason: 'missing_user' as const }, + ? { command, ready: true as const } + : { command, ready: false as const, reason: 'missing_user' as const }, ); const { messageComposer } = setup({ config: { commands: { - validators: [validator], + sendValidators: [validator], }, }, }); @@ -904,17 +937,21 @@ describe('MessageComposer', () => { commands: [{ name: 'ban', description: 'Ban a user' }], }); messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, mentionedUsers: [{ id: 'target-user', name: 'Target User' }], selection: { start: 16, end: 16 }, text: '/ban @target-user', }); expect( - messageComposer.getCommandSendability( - messageComposer.getCurrentCommand('/ban @target-user')!, + messageComposer.validateCommandSendability( + messageComposer.textComposer.command!, '/ban @target-user', ), - ).toEqual({ ready: true }); + ).toEqual({ + command: messageComposer.textComposer.command!, + ready: true, + }); expect(validator).toHaveBeenCalledWith( expect.objectContaining({ command: expect.objectContaining({ name: 'ban' }), @@ -924,6 +961,36 @@ describe('MessageComposer', () => { ); }); + it('should resolve raw commands from the registered command source', () => { + const validator = vi.fn(({ command, commandArgsText }) => + commandArgsText.length > 0 + ? undefined + : { command, ready: false as const, reason: 'missing_args' as const }, + ); + const { messageComposer } = setup({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + const customSearchSource = new CommandSearchSource(messageComposer.channel); + vi.spyOn(customSearchSource, 'query').mockReturnValue({ + items: [{ description: 'Custom command', id: 'custom', name: 'custom' }], + next: null, + }); + const customCommand = getCommandByName(customSearchSource, 'custom'); + + expect(customCommand).toEqual(expect.objectContaining({ name: 'custom' })); + expect( + messageComposer.validateCommandSendability(customCommand!, '/custom'), + ).toEqual({ + command: customCommand!, + ready: false, + reason: 'missing_args', + }); + }); + it('should register subscriptions', () => { const { messageComposer } = setup(); const unsubscribeFunctions = messageComposer[ diff --git a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts index 97944a1f0..4837f6b46 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -7,6 +7,7 @@ import { createCompositionValidationMiddleware, createDraftCompositionValidationMiddleware, } from '../../../../../src/messageComposer/middleware/messageComposer/compositionValidation'; +import { CommandSearchSource } from '../../../../../src/messageComposer/middleware/textComposer/commands'; import { AttachmentLoadingState, LocalImageAttachment, @@ -46,9 +47,15 @@ const setupMiddleware = ( composition: custom.editedMessage, }); + const commandSearchSource = new CommandSearchSource(messageComposer.channel); + return { + commandSearchSource, messageComposer, - validationMiddleware: createCompositionValidationMiddleware(messageComposer), + validationMiddleware: createCompositionValidationMiddleware( + messageComposer, + commandSearchSource, + ), }; }; @@ -241,10 +248,11 @@ describe('stream-io/message-composer-middleware/data-validation', () => { }); it('should discard commands that are not ready to send', async () => { - const validator = vi.fn(({ mentionedUsersInText }) => + const validator = vi.fn(({ command, mentionedUsersInText }) => mentionedUsersInText.length > 0 ? undefined : { + command, metadata: { source: 'test-validator' }, ready: false as const, reason: 'missing_user' as const, @@ -253,7 +261,7 @@ describe('stream-io/message-composer-middleware/data-validation', () => { const { messageComposer, validationMiddleware } = setupMiddleware({ config: { commands: { - validators: [validator], + sendValidators: [validator], }, }, }); @@ -281,6 +289,76 @@ describe('stream-io/message-composer-middleware/data-validation', () => { ); }); + it('should resolve raw commands through the provided command search source', async () => { + const validator = vi.fn(({ command }) => + command.name === 'custom' + ? { + command, + ready: false as const, + reason: 'missing_args' as const, + } + : undefined, + ); + const { messageComposer, commandSearchSource } = setupMiddleware({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + vi.spyOn(commandSearchSource, 'query').mockReturnValue({ + items: [{ description: 'Custom command', id: 'custom', name: 'custom' }], + next: null, + }); + + const result = await createCompositionValidationMiddleware( + messageComposer, + commandSearchSource, + ).handlers.compose(setupMiddlewareInputs(setupCompositionState('/custom'))); + + expect(result.status).toBe('discard'); + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: 'custom' }), + rawText: '/custom', + }), + ); + }); + + it('should initialize a default command search source when none is provided', async () => { + const validator = vi.fn(({ command }) => + command.name === 'custom' + ? { + command, + ready: false as const, + reason: 'missing_args' as const, + } + : undefined, + ); + const { messageComposer } = setupMiddleware({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'custom', description: 'Custom command' }], + }); + + const result = await createCompositionValidationMiddleware( + messageComposer, + ).handlers.compose(setupMiddlewareInputs(setupCompositionState('/custom'))); + + expect(result.status).toBe('discard'); + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: 'custom' }), + rawText: '/custom', + }), + ); + }); + it('should discard ban commands without a reason by default', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ @@ -331,6 +409,37 @@ describe('stream-io/message-composer-middleware/data-validation', () => { expect(addWarningSpy).not.toHaveBeenCalled(); }); + it('should discard mute, unmute and unban commands without a mention by default', async () => { + for (const commandName of ['mute', 'unmute', 'unban'] as const) { + const { messageComposer, validationMiddleware } = setupMiddleware(); + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: commandName, description: `${commandName} a user` }], + }); + vi.spyOn(messageComposer.textComposer, 'text', 'get').mockReturnValue( + `/${commandName}`, + ); + vi.spyOn(messageComposer.textComposer, 'mentionedUsers', 'get').mockReturnValue([]); + const addWarningSpy = vi.spyOn(messageComposer.client.notifications, 'addWarning'); + + const result = await validationMiddleware.handlers.compose( + setupMiddlewareInputs(setupCompositionState(`/${commandName}`)), + ); + + expect(result.status).toBe('discard'); + expect(addWarningSpy).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + metadata: expect.objectContaining({ + command: commandName, + reason: 'missing_mention', + }), + type: 'validation:command:not-ready', + }), + }), + ); + } + }); + it('should allow raw known commands if command is not disabled', async () => { const { messageComposer, validationMiddleware } = setupMiddleware(); vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ From a9c05c90e50a8e5343165ad2f781db84a8be2481 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 14 May 2026 11:27:30 +0200 Subject: [PATCH 5/6] feat(MessageComposer): keep user mentions up-to-date on text change --- src/messageComposer/messageComposer.ts | 8 +++--- .../middleware/textComposer/mentions.ts | 21 ++++++++++++---- .../TextComposerMiddlewareExecutor.test.ts | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/messageComposer/messageComposer.ts b/src/messageComposer/messageComposer.ts index dfd859b2d..f6f79e0db 100644 --- a/src/messageComposer/messageComposer.ts +++ b/src/messageComposer/messageComposer.ts @@ -406,16 +406,16 @@ export class MessageComposer extends WithSubscriptions { command: CommandResponse, text = this.textComposer.text, ): CommandSendability => { + const currentMentionedUsers = this.textComposer.mentionedUsers; + const mentionedUsersInText = getMentionedUsersInText(text, currentMentionedUsers); + const validationContext = { command, commandArgsText: command.name ? stripCommandFromText(text, command.name).trim() : text.trim(), composer: this, - mentionedUsersInText: getMentionedUsersInText( - text, - this.textComposer.mentionedUsers, - ), + mentionedUsersInText, rawText: text, }; diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 1d5e7e833..48aa25599 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -3,6 +3,7 @@ import { getTriggerCharWithToken, insertItemWithTrigger, } from './textMiddlewareUtils'; +import { getMentionedUsersInText } from './commandUtils'; import { BaseSearchSource, type SearchSourceOptions } from '../../../search'; import { mergeWith } from '../../../utils/mergeWith'; import type { TextComposerMiddlewareOptions, UserSuggestion } from './types'; @@ -351,10 +352,19 @@ export const createMentionsMiddleware = ( handlers: { onChange: ({ state, next, complete, forward }) => { if (!state.selection) return forward(); + const currentMentions = getMentionedUsersInText(state.text, state.mentionedUsers); + const mentionedUsersChanged = + currentMentions.length !== state.mentionedUsers.length || + currentMentions.some( + (user, index) => user.id !== state.mentionedUsers[index]?.id, + ); + const stateWithMentions = mentionedUsersChanged + ? { ...state, mentionedUsers: currentMentions } + : state; const triggerWithToken = getTriggerCharWithToken({ trigger: finalOptions.trigger, - text: state.text.slice(0, state.selection.end), + text: stateWithMentions.text.slice(0, stateWithMentions.selection.end), }); const newSearchTriggered = @@ -368,18 +378,19 @@ export const createMentionsMiddleware = ( !triggerWithToken || triggerWithToken.length < finalOptions.minChars; if (triggerWasRemoved) { - const hasStaleSuggestions = state.suggestions?.trigger === finalOptions.trigger; - const newState = { ...state }; + const hasStaleSuggestions = + stateWithMentions.suggestions?.trigger === finalOptions.trigger; + const newState = { ...stateWithMentions }; if (hasStaleSuggestions) { delete newState.suggestions; } return next(newState); } - searchSource.config.textComposerText = state.text; + searchSource.config.textComposerText = stateWithMentions.text; return complete({ - ...state, + ...stateWithMentions, suggestions: { query: triggerWithToken.slice(1), searchSource, diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index 473791007..8da18fc5a 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -193,6 +193,31 @@ describe('TextComposerMiddlewareExecutor', () => { expect(textComposer.mentionedUsers).toContainEqual(selectedSuggestion); }); + it('should prune stale mentions on change', async () => { + const { + messageComposer: { textComposer }, + } = setup(); + await textComposer.handleChange({ + text: '@jo', + selection: { start: 3, end: 3 }, + }); + + const selectedSuggestion = { + id: 'user1', + name: 'John Doe', + } as TextComposerSuggestion; + + await textComposer.handleSelect(selectedSuggestion); + expect(textComposer.mentionedUsers).toContainEqual(selectedSuggestion); + + await textComposer.handleChange({ + text: '', + selection: { start: 0, end: 0 }, + }); + + expect(textComposer.mentionedUsers).toEqual([]); + }); + it('should handle suggestion selection with commands', async () => { const { messageComposer: { textComposer }, From 0f693a7172aabbc542473198e0f73a14506241a7 Mon Sep 17 00:00:00 2001 From: martincupela Date: Thu, 14 May 2026 11:42:43 +0200 Subject: [PATCH 6/6] fix(textComposer): preserve mentions when command activation is skipped --- .../configuration/commands.configuration.ts | 2 +- .../middleware/textComposer/mentions.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/messageComposer/configuration/commands.configuration.ts b/src/messageComposer/configuration/commands.configuration.ts index f3b877c1c..50a221754 100644 --- a/src/messageComposer/configuration/commands.configuration.ts +++ b/src/messageComposer/configuration/commands.configuration.ts @@ -6,7 +6,7 @@ import type { import type { DeepPartial } from '../../types.utility'; import { stripMentionTokens } from '../middleware'; -const MENTION_ONLY_COMMANDS = new Set(['mute', 'unmute', 'unban']); +export const MENTION_ONLY_COMMANDS = new Set(['mute', 'unmute', 'unban']); export const defaultCommandSendabilityValidator: CommandSendValidator = ({ command, commandArgsText, diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 48aa25599..a5e54221e 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -352,7 +352,16 @@ export const createMentionsMiddleware = ( handlers: { onChange: ({ state, next, complete, forward }) => { if (!state.selection) return forward(); - const currentMentions = getMentionedUsersInText(state.text, state.mentionedUsers); + // Only prune stale mentions during normal text editing. Entering command mode + // clears text/mentions through the `command.activate` effect, which first + // snapshots the previous TextComposer state so it can be restored on + // `clearCommand()`. Custom middleware is allowed to remove that effect, + // though, and in that opt-out case we must not silently drop mentions + // here just because the user typed a raw command like `/ban`. + const currentMentions = + state.command || state.text.trimStart().startsWith('/') + ? state.mentionedUsers + : getMentionedUsersInText(state.text, state.mentionedUsers); const mentionedUsersChanged = currentMentions.length !== state.mentionedUsers.length || currentMentions.some(