diff --git a/src/messageComposer/configuration/commands.configuration.ts b/src/messageComposer/configuration/commands.configuration.ts new file mode 100644 index 000000000..50a221754 --- /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'; + +export 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 0bd43a450..9a8ff18c0 100644 --- a/src/messageComposer/configuration/configuration.ts +++ b/src/messageComposer/configuration/configuration.ts @@ -5,9 +5,10 @@ import type { LinkPreviewsManagerConfig, LocationComposerConfig, MessageComposerConfig, + TextComposerConfig, } from './types'; -import type { TextComposerConfig } from './types'; import { generateUUIDv4 } from '../../utils'; +import { DEFAULT_COMMANDS_CONFIG } from './commands.configuration'; export const DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: LinkPreviewsManagerConfig = { debounceURLEnrichmentMs: 1500, @@ -46,6 +47,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/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 0a59b953f..048cf3848 100644 --- a/src/messageComposer/configuration/types.ts +++ b/src/messageComposer/configuration/types.ts @@ -1,6 +1,8 @@ import type { LinkPreview } from '../linkPreviewsManager'; import type { FileUploadFilter } from '../attachmentManager'; +import type { MessageComposer } from '../messageComposer'; import type { FileLike, FileReference } from '../types'; +import type { CommandResponse, UserResponse } from '../../types'; export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial< Record @@ -38,6 +40,29 @@ export type TextComposerConfig = { maxLengthOnSend?: number; }; +export type CommandSendability = { + command: CommandResponse; + 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 = { + sendValidators: CommandSendValidator[]; +}; + export type AttachmentManagerConfig = { // todo: document removal of noFiles prop showing how to achieve the same with custom fileUploadFilter function /** @@ -86,6 +111,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..f6f79e0db 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, @@ -30,7 +30,7 @@ import type { } from '../types'; import { WithSubscriptions } from '../utils/WithSubscriptions'; import type { StreamChat } from '../client'; -import type { MessageComposerConfig } from './configuration/types'; +import type { CommandSendability, MessageComposerConfig } from './configuration/types'; import type { CommandSuggestionDisabledReason, TextComposerCommandActivationEffect, @@ -44,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; @@ -208,7 +212,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' @@ -223,14 +236,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, + mergeMessageComposerConfigCustomizer, + ), + config, ), ); @@ -360,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 => { @@ -378,21 +402,49 @@ export class MessageComposer extends WithSubscriptions { isCommandDisabled = (command: CommandResponse) => !!this.getCommandDisabledReason(command); - get pollId() { - return this.state.getLatestValue().pollId; - } + validateCommandSendability = ( + 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, + rawText: text, + }; - get showReplyInChannel() { - return this.state.getLatestValue().showReplyInChannel; - } + for (const validator of this.config.commands.sendValidators) { + const result = validator(validationContext); + if (result && !result.ready) { + return result; + } + } + + return { command, ready: true }; + }; + + isCommandSendable = (command: CommandResponse, text = this.textComposer.text) => + this.validateCommandSendability(command, text).ready; get hasSendableData() { - return !!( - (!this.attachmentManager.uploadsInProgressCount && - (!this.textComposer.textIsEmpty || - this.attachmentManager.successfulUploadsCount > 0)) || - this.pollId || - !!this.locationComposer.validLocation + const currentCommand = this.textComposer.command; + const commandIsSendable = !currentCommand || this.isCommandSendable(currentCommand); + + return ( + commandIsSendable && + !!( + (!this.attachmentManager.uploadsInProgressCount && + (!this.textComposer.textIsEmpty || + this.attachmentManager.successfulUploadsCount > 0)) || + this.pollId || + !!this.locationComposer.validLocation + ) ); } @@ -426,7 +478,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 = () => { @@ -624,6 +678,7 @@ export class MessageComposer extends WithSubscriptions { draft.channel_cid !== this.channel.cid ) return; + if (this.editedMessage) return; this.initState({ composition: draft }); }).unsubscribe; @@ -637,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 87f1e0343..5c83d1ea9 100644 --- a/src/messageComposer/middleware/messageComposer/compositionValidation.ts +++ b/src/messageComposer/middleware/messageComposer/compositionValidation.ts @@ -1,7 +1,12 @@ import { textIsEmpty } from '../../textComposer'; import type { CommandResponse } from '../../../types'; import { CommandSearchSource } from '../textComposer/commands'; -import { getRawCommandName, notifyCommandDisabled } from '../textComposer/commandUtils'; +import { + getCommandByName, + getRawCommandName, + notifyCommandDisabled, + notifyCommandNotReady, +} from '../textComposer/commandUtils'; import type { MessageComposerMiddlewareState, MessageCompositionMiddleware, @@ -11,21 +16,9 @@ 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, + searchSource: Pick, text?: string, ): CommandResponse | undefined => { const rawCommand = getCommandByName(searchSource, getRawCommandName(text)); @@ -36,8 +29,10 @@ const getDisabledRawCommand = ( export const createCompositionValidationMiddleware = ( composer: MessageComposer, + commandSearchSource?: Pick, ): MessageCompositionMiddleware => { - const commandSearchSource = new CommandSearchSource(composer.channel); + const effectiveCommandSearchSource = + commandSearchSource ?? new CommandSearchSource(composer.channel); return { id: 'stream-io/message-composer-middleware/data-validation', @@ -52,7 +47,7 @@ export const createCompositionValidationMiddleware = ( const disabledRawCommand = getDisabledRawCommand( composer, - commandSearchSource, + effectiveCommandSearchSource, inputText, ); if (disabledRawCommand) { @@ -60,6 +55,20 @@ export const createCompositionValidationMiddleware = ( return await discard(); } + const currentCommand = + composer.textComposer.command ?? + getCommandByName(effectiveCommandSearchSource, getRawCommandName(inputText)); + if (currentCommand) { + if ( + notifyCommandNotReady({ + composer, + sendability: composer.validateCommandSendability(currentCommand, inputText), + }) + ) { + return await discard(); + } + } + const hasExceededMaxLength = typeof maxLengthOnSend === 'number' && inputText.length > maxLengthOnSend; 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..a5e54221e 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'; @@ -77,6 +78,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 +96,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 +176,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 +344,7 @@ export const createMentionsMiddleware = ( searchSource = options.searchSource; searchSource.resetState(); } else { - searchSource = new MentionsSearchSource(channel); + searchSource = new MentionsSearchSource(channel, { trigger: finalOptions.trigger }); } searchSource.activate(); return { @@ -344,10 +352,28 @@ export const createMentionsMiddleware = ( handlers: { onChange: ({ state, next, complete, forward }) => { if (!state.selection) return forward(); + // 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( + (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 = @@ -361,18 +387,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, @@ -389,7 +416,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 928083197..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 @@ -573,6 +575,103 @@ describe('MessageComposer', () => { expect(messageComposer.hasSendableData).toBe(false); }); + it('should account for command sendability in hasSendableData', () => { + const validator = vi.fn(({ command, mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? undefined + : { command, ready: false as const, reason: 'missing_user' as const }, + ); + const { messageComposer } = setup({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'ban', description: 'Ban a user' }], + }); + + messageComposer.textComposer.state.partialNext({ + command: { description: 'Ban a user', name: 'ban' }, + mentionedUsers: [], + selection: { start: 4, end: 4 }, + text: '/ban', + }); + 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', + }); + 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 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({ + 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.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', + }); + 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(); @@ -786,6 +885,112 @@ describe('MessageComposer', () => { expect(messageComposer.getCommandDisabledReason({ name: 'giphy' })).toBeUndefined(); }); + it('should return command sendability for current raw commands', () => { + const validator = vi.fn(({ command, commandArgsText }) => + commandArgsText.length > 0 + ? undefined + : { + command, + metadata: { expected: 'args' }, + ready: false as const, + reason: 'missing_args', + }, + ); + const { messageComposer } = setup({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + commands: [{ name: 'custom', description: 'Custom command' }], + }); + const customCommand = { description: 'Custom command', name: 'custom' }; + + expect( + messageComposer.validateCommandSendability(customCommand, '/custom'), + ).toEqual({ + command: customCommand, + metadata: { expected: 'args' }, + ready: false, + reason: 'missing_args', + }); + }); + + it('should allow overriding default command validators via config', () => { + const validator = vi.fn(({ command, mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? { command, ready: true as const } + : { command, ready: false as const, reason: 'missing_user' as const }, + ); + const { messageComposer } = setup({ + config: { + commands: { + sendValidators: [validator], + }, + }, + }); + + vi.spyOn(messageComposer.channel, 'getConfig').mockReturnValue({ + 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.validateCommandSendability( + messageComposer.textComposer.command!, + '/ban @target-user', + ), + ).toEqual({ + command: messageComposer.textComposer.command!, + ready: true, + }); + expect(validator).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: 'ban' }), + commandArgsText: '@target-user', + rawText: '/ban @target-user', + }), + ); + }); + + 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 768172c3d..4837f6b46 100644 --- a/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts +++ b/test/unit/MessageComposer/middleware/messageComposer/compositionValidation.test.ts @@ -1,11 +1,13 @@ 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, createDraftCompositionValidationMiddleware, } from '../../../../../src/messageComposer/middleware/messageComposer/compositionValidation'; +import { CommandSearchSource } from '../../../../../src/messageComposer/middleware/textComposer/commands'; import { AttachmentLoadingState, LocalImageAttachment, @@ -14,10 +16,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,12 +43,19 @@ const setupMiddleware = ( new MessageComposer({ client, compositionContext: channel, + config: custom.config, composition: custom.editedMessage, }); + const commandSearchSource = new CommandSearchSource(messageComposer.channel); + return { + commandSearchSource, messageComposer, - validationMiddleware: createCompositionValidationMiddleware(messageComposer), + validationMiddleware: createCompositionValidationMiddleware( + messageComposer, + commandSearchSource, + ), }; }; @@ -233,6 +247,199 @@ describe('stream-io/message-composer-middleware/data-validation', () => { ); }); + it('should discard commands that are not ready to send', async () => { + const validator = vi.fn(({ command, mentionedUsersInText }) => + mentionedUsersInText.length > 0 + ? undefined + : { + command, + metadata: { source: 'test-validator' }, + ready: false as const, + reason: 'missing_user' as const, + }, + ); + const { messageComposer, validationMiddleware } = setupMiddleware({ + config: { + commands: { + sendValidators: [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 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({ + 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 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({ 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 },