Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/messageComposer/configuration/commands.configuration.ts
Original file line number Diff line number Diff line change
@@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I might be missing something here, but how would this generate a good reason for why the command is currently not sendable ?


if (!reason.length) {
return { command, ready: false, reason: 'missing_reason' };
}

return { command, ready: true };
};
export const DEFAULT_COMMANDS_CONFIG: CommandsConfig = {
sendValidators: [defaultCommandSendabilityValidator],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we keep these as an array ? Since the validation step is a middleware anyway, integrators can either add their own additional validation steps (or replace our current one)

};
export const applyCommandValidatorOverride = (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the command validation anyway goes through a middleware, this might not be needed I think. While convenient for integrators, I think convergence towards a middleware approach would be more appropriate (it would separate the need for overrides as well), unless I'm missing something important here

targetConfig: MessageComposerConfig,
sourceConfig?: DeepPartial<MessageComposerConfig>,
) => {
const overrideValidators = sourceConfig?.commands?.sendValidators as
| CommandSendValidator[]
| undefined;

if (typeof overrideValidators === 'undefined') {
return targetConfig;
}

return {
...targetConfig,
commands: {
...targetConfig.commands,
sendValidators: overrideValidators,
},
};
};
4 changes: 3 additions & 1 deletion src/messageComposer/configuration/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/messageComposer/configuration/index.ts
Original file line number Diff line number Diff line change
@@ -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';
27 changes: 27 additions & 0 deletions src/messageComposer/configuration/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
Expand Down Expand Up @@ -38,6 +40,29 @@ export type TextComposerConfig = {
maxLengthOnSend?: number;
};

export type CommandSendability = {
command: CommandResponse;
ready: boolean;
reason?: string & {};
metadata?: Record<string, unknown>;
};

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
/**
Expand Down Expand Up @@ -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 */
Expand Down
102 changes: 79 additions & 23 deletions src/messageComposer/messageComposer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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;

Expand Down Expand Up @@ -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<MessageComposerConfig>
> = (originalVal, channelConfigVal, key) =>
typeof originalVal === 'object'
Expand All @@ -223,14 +236,17 @@ export class MessageComposer extends WithSubscriptions {
: originalVal;

this.configState = new StateStore<MessageComposerConfig>(
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,
),
);

Expand Down Expand Up @@ -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 => {
Expand All @@ -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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would it be possible to extract this in a separate getter please ?

So something like:

get isCommandSendable() {
  const currentCommand = this.textComposer.command;
  return !currentCommand || this.isCommandSendable(currentCommand);
}


return (
commandIsSendable &&
!!(
(!this.attachmentManager.uploadsInProgressCount &&
(!this.textComposer.textIsEmpty ||
this.attachmentManager.successfulUploadsCount > 0)) ||
this.pollId ||
!!this.locationComposer.validLocation
)
);
}

Expand Down Expand Up @@ -426,7 +478,9 @@ export class MessageComposer extends WithSubscriptions {
}

updateConfig(config: DeepPartial<MessageComposerConfig>) {
this.configState.partialNext(mergeWith(this.config, config));
this.configState.partialNext(
applyCommandValidatorOverride(mergeWith(this.config, config), config),
);
}

refreshId = () => {
Expand Down Expand Up @@ -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;

Expand All @@ -637,6 +692,7 @@ export class MessageComposer extends WithSubscriptions {
) {
return;
}
if (this.editedMessage) return;

this.logDraftUpdateTimestamp();

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<CommandSearchSource, 'query'>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not just use CommandSearchSource here ? It should still comply

text?: string,
): CommandResponse | undefined => {
const rawCommand = getCommandByName(searchSource, getRawCommandName(text));
Expand All @@ -36,8 +29,10 @@ const getDisabledRawCommand = (

export const createCompositionValidationMiddleware = (
composer: MessageComposer,
commandSearchSource?: Pick<CommandSearchSource, 'query'>,
): MessageCompositionMiddleware => {
const commandSearchSource = new CommandSearchSource(composer.channel);
const effectiveCommandSearchSource =
commandSearchSource ?? new CommandSearchSource(composer.channel);

return {
id: 'stream-io/message-composer-middleware/data-validation',
Expand All @@ -52,14 +47,28 @@ export const createCompositionValidationMiddleware = (

const disabledRawCommand = getDisabledRawCommand(
composer,
commandSearchSource,
effectiveCommandSearchSource,
inputText,
);
if (disabledRawCommand) {
notifyCommandDisabled(composer, disabledRawCommand);
return await discard();
}

const currentCommand =
composer.textComposer.command ??
getCommandByName(effectiveCommandSearchSource, getRawCommandName(inputText));
if (currentCommand) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (currentCommand) {
if (currentCommand && notifyCommandNotReady({
composer,
sendability: composer.validateCommandSendability(currentCommand, inputText),
})) {

if (
notifyCommandNotReady({
composer,
sendability: composer.validateCommandSendability(currentCommand, inputText),
})
) {
return await discard();
}
}

const hasExceededMaxLength =
typeof maxLengthOnSend === 'number' && inputText.length > maxLengthOnSend;

Expand Down
Loading