diff --git a/src/messageComposer/middleware/messageComposer/commandInjection.ts b/src/messageComposer/middleware/messageComposer/commandInjection.ts new file mode 100644 index 000000000..63f85024e --- /dev/null +++ b/src/messageComposer/middleware/messageComposer/commandInjection.ts @@ -0,0 +1,72 @@ +import type { MessageComposer } from '../../messageComposer'; +import type { + MessageComposerMiddlewareState, + MessageCompositionMiddleware, + MessageDraftComposerMiddlewareValueState, + MessageDraftCompositionMiddleware, +} from '../messageComposer/types'; +import type { MiddlewareHandlerParams } from '../../../middleware'; + +export const createCommandInjectionMiddleware = ( + composer: MessageComposer, +): MessageCompositionMiddleware => ({ + handlers: { + compose: ({ + forward, + next, + state, + }: MiddlewareHandlerParams) => { + const command = composer.textComposer.command; + if (!command) { + return forward(); + } + const { text } = state.localMessage; + + const injection = `/${command?.name}`; + const enrichedText = `${injection} ${text}`; + + return next({ + ...state, + localMessage: { + ...state.localMessage, + text: enrichedText, + }, + message: { + ...state.message, + text: enrichedText, + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/command-string-injection', +}); + +export const createDraftCommandInjectionMiddleware = ( + composer: MessageComposer, +): MessageDraftCompositionMiddleware => ({ + handlers: { + compose: ({ + forward, + next, + state, + }: MiddlewareHandlerParams) => { + const command = composer.textComposer.command; + if (!command) { + return forward(); + } + const { text } = state.draft; + + const injection = `/${command?.name}`; + const enrichedText = `${injection} ${text}`; + + return next({ + ...state, + draft: { + ...state.draft, + text: enrichedText, + }, + }); + }, + }, + id: 'stream-io/message-composer-middleware/draft-command-string-injection', +}); diff --git a/src/messageComposer/middleware/messageComposer/index.ts b/src/messageComposer/middleware/messageComposer/index.ts index 7ecf84a11..ef3111674 100644 --- a/src/messageComposer/middleware/messageComposer/index.ts +++ b/src/messageComposer/middleware/messageComposer/index.ts @@ -7,3 +7,4 @@ export * from './MessageComposerMiddlewareExecutor'; export * from './messageComposerState'; export * from './textComposer'; export * from './types'; +export * from './commandInjection'; diff --git a/src/messageComposer/middleware/textComposer/activeCommandGuard.ts b/src/messageComposer/middleware/textComposer/activeCommandGuard.ts new file mode 100644 index 000000000..07ab631fd --- /dev/null +++ b/src/messageComposer/middleware/textComposer/activeCommandGuard.ts @@ -0,0 +1,20 @@ +import type { Middleware } from '../../../middleware'; +import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; + +export type PreCommandMiddleware = Middleware< + TextComposerMiddlewareExecutorState, + 'onChange' | 'onSuggestionItemSelect' +>; + +export const createActiveCommandGuardMiddleware = (): PreCommandMiddleware => ({ + handlers: { + onChange: ({ complete, forward, state }) => { + if (state.command) { + return complete(state); + } + return forward(); + }, + onSuggestionItemSelect: ({ forward }) => forward(), + }, + id: 'stream-io/text-composer/active-command-guard', +}); diff --git a/src/messageComposer/middleware/textComposer/commandStringExtraction.ts b/src/messageComposer/middleware/textComposer/commandStringExtraction.ts new file mode 100644 index 000000000..669b59092 --- /dev/null +++ b/src/messageComposer/middleware/textComposer/commandStringExtraction.ts @@ -0,0 +1,56 @@ +import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; +import type { CommandSuggestion } from './types'; +import type { Middleware } from '../../../middleware'; +import { escapeRegExp } from './textMiddlewareUtils'; + +export type CommandStringExtractionMiddleware = Middleware< + TextComposerMiddlewareExecutorState, + 'onChange' | 'onSuggestionItemSelect' +>; + +const stripCommandFromText = (text: string, commandName: string) => + text.replace(new RegExp(`^${escapeRegExp(`/${commandName}`)}\\s*`), ''); + +export const createCommandStringExtractionMiddleware = + (): CommandStringExtractionMiddleware => ({ + handlers: { + onChange: ({ complete, forward, state }) => { + const { command } = state; + + if (!command?.name) { + return forward(); + } + + const newText = stripCommandFromText(state.text, command.name); + + return complete({ + ...state, + selection: { + end: newText.length, + start: newText.length, + }, + text: newText, + }); + }, + onSuggestionItemSelect: ({ next, forward, state }) => { + const { command } = state; + + if (!command) { + return forward(); + } + + const triggerWithCommand = `/${command?.name} `; + + const newText = state.text.slice(triggerWithCommand.length); + return next({ + ...state, + selection: { + end: newText.length, + start: newText.length, + }, + text: newText, + }); + }, + }, + id: 'stream-io/text-composer/command-string-extraction', + }); diff --git a/src/messageComposer/middleware/textComposer/commands.ts b/src/messageComposer/middleware/textComposer/commands.ts index c3d41dc74..52f8b4262 100644 --- a/src/messageComposer/middleware/textComposer/commands.ts +++ b/src/messageComposer/middleware/textComposer/commands.ts @@ -5,7 +5,11 @@ import { BaseSearchSourceSync } from '../../../search'; import type { CommandResponse } from '../../../types'; import { mergeWith } from '../../../utils/mergeWith'; import type { CommandSuggestion, TextComposerMiddlewareOptions } from './types'; -import { getTriggerCharWithToken, insertItemWithTrigger } from './textMiddlewareUtils'; +import { + getCompleteCommandInString, + getTriggerCharWithToken, + insertItemWithTrigger, +} from './textMiddlewareUtils'; import type { TextComposerMiddlewareExecutorState } from './TextComposerMiddlewareExecutor'; export class CommandSearchSource extends BaseSearchSourceSync { @@ -115,10 +119,22 @@ export const createCommandsMiddleware = ( handlers: { onChange: ({ state, next, complete, forward }) => { if (!state.selection) return forward(); + const finalText = state.text.slice(0, state.selection.end); + const commandName = getCompleteCommandInString(finalText); + if (commandName) { + const command = searchSource?.query(commandName).items[0]; + if (command) { + return next({ + ...state, + command, + suggestions: undefined, + }); + } + } const triggerWithToken = getTriggerCharWithToken({ trigger: finalOptions.trigger, - text: state.text.slice(0, state.selection.end), + text: finalText, acceptTrailingSpaces: false, isCommand: true, }); @@ -144,6 +160,7 @@ export const createCommandsMiddleware = ( return complete({ ...state, + command: null, suggestions: { query: triggerWithToken.slice(1), searchSource, @@ -151,13 +168,13 @@ export const createCommandsMiddleware = ( }, }); }, - onSuggestionItemSelect: ({ state, complete, forward }) => { + onSuggestionItemSelect: ({ state, next, forward }) => { const { selectedSuggestion } = state.change ?? {}; if (!selectedSuggestion || state.suggestions?.trigger !== finalOptions.trigger) return forward(); searchSource.resetStateAndActivate(); - return complete({ + return next({ ...state, ...insertItemWithTrigger({ insertText: `/${selectedSuggestion.name} `, @@ -165,6 +182,7 @@ export const createCommandsMiddleware = ( text: state.text, trigger: finalOptions.trigger, }), + command: selectedSuggestion, suggestions: undefined, }); }, diff --git a/src/messageComposer/middleware/textComposer/index.ts b/src/messageComposer/middleware/textComposer/index.ts index 878d5c838..8410ca58d 100644 --- a/src/messageComposer/middleware/textComposer/index.ts +++ b/src/messageComposer/middleware/textComposer/index.ts @@ -1,4 +1,6 @@ +export * from './activeCommandGuard'; export * from './commands'; +export * from './commandStringExtraction'; export * from './mentions'; export * from './validation'; export * from './TextComposerMiddlewareExecutor'; diff --git a/src/messageComposer/middleware/textComposer/textMiddlewareUtils.ts b/src/messageComposer/middleware/textComposer/textMiddlewareUtils.ts index b16560c9f..783ed93d8 100644 --- a/src/messageComposer/middleware/textComposer/textMiddlewareUtils.ts +++ b/src/messageComposer/middleware/textComposer/textMiddlewareUtils.ts @@ -1,5 +1,10 @@ import type { TextSelection } from './types'; +/** + * For commands, we want to match all patterns except: + * 1. Text not starting with trigger + * 2. Trigger in middle of text + */ export const getTriggerCharWithToken = ({ trigger, text, @@ -12,6 +17,7 @@ export const getTriggerCharWithToken = ({ acceptTrailingSpaces?: boolean; }) => { const triggerNorWhitespace = `[^\\s${trigger}]*`; + const match = text.match( new RegExp( isCommand @@ -26,6 +32,14 @@ export const getTriggerCharWithToken = ({ return match && match[match.length - 1].trim(); }; +export const getCompleteCommandInString = (text: string) => { + // starts with "/" followed by 1+ non-whitespace chars followed by 1+ white-space chars + // the comand name is extracted into a separate group + const match = text.match(/^\/(\S+)\s+.*/); + const commandName = match && match[1]; + return commandName; +}; + export const insertItemWithTrigger = ({ insertText, selection, diff --git a/src/messageComposer/middleware/textComposer/types.ts b/src/messageComposer/middleware/textComposer/types.ts index 478817a8c..f7aaf70d2 100644 --- a/src/messageComposer/middleware/textComposer/types.ts +++ b/src/messageComposer/middleware/textComposer/types.ts @@ -38,5 +38,6 @@ export type TextComposerState = { mentionedUsers: UserResponse[]; selection: TextSelection; text: string; + command?: CommandResponse | null; suggestions?: Suggestions; }; diff --git a/src/messageComposer/textComposer.ts b/src/messageComposer/textComposer.ts index b65e2c42d..39f82a31d 100644 --- a/src/messageComposer/textComposer.ts +++ b/src/messageComposer/textComposer.ts @@ -6,7 +6,7 @@ import type { TextSelection } from './middleware/textComposer/types'; import type { TextComposerState } from './middleware/textComposer/types'; import type { Suggestions } from './middleware/textComposer/types'; import type { MessageComposer } from './messageComposer'; -import type { DraftMessage, LocalMessage, UserResponse } from '../types'; +import type { CommandResponse, DraftMessage, LocalMessage, UserResponse } from '../types'; export type TextComposerOptions = { composer: MessageComposer; @@ -37,6 +37,7 @@ const initState = ({ if (!message) { const text = composer.config.text.defaultValue ?? ''; return { + command: null, mentionedUsers: [], text, selection: { start: text.length, end: text.length }, @@ -118,6 +119,10 @@ export class TextComposer { // --- START STATE API --- + get command() { + return this.state.getLatestValue().command; + } + get mentionedUsers() { return this.state.getLatestValue().mentionedUsers; } @@ -146,6 +151,10 @@ export class TextComposer { this.state.partialNext({ mentionedUsers: users }); } + clearCommand() { + this.state.partialNext({ command: null }); + } + upsertMentionedUser = (user: UserResponse) => { const mentionedUsers = [...this.mentionedUsers]; const existingUserIndex = mentionedUsers.findIndex((u) => u.id === user.id); @@ -169,6 +178,11 @@ export class TextComposer { this.state.partialNext({ mentionedUsers }); }; + setCommand = (command: CommandResponse | null) => { + if (command?.name === this.command?.name) return; + this.state.partialNext({ command }); + }; + setText = (text: string) => { if (!this.enabled || text === this.text) return; this.state.partialNext({ text }); @@ -181,7 +195,13 @@ export class TextComposer { this.state.partialNext({ selection }); }; - insertText = ({ text, selection }: { text: string; selection?: TextSelection }) => { + insertText = async ({ + text, + selection, + }: { + text: string; + selection?: TextSelection; + }) => { if (!this.enabled) return; const finalSelection: TextSelection = selection ?? this.selection; @@ -203,7 +223,7 @@ export class TextComposer { ? finalText.length : currentText.slice(0, expectedCursorPosition).length; - this.state.partialNext({ + await this.handleChange({ text: finalText, selection: { start: cursorPosition, diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index afbc05adc..7f73f0f53 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -38,7 +38,7 @@ export const DEFAULT_PAGINATION_OPTIONS: Required = { export abstract class BasePaginator { state: StateStore>; - protected pageSize: number; + pageSize: number; protected _executeQueryDebounced!: DebouncedExecQueryFunction; protected _isCursorPagination = false; diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/ReminderPaginator.ts index 7365d9514..ff81b5dc9 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/ReminderPaginator.ts @@ -9,8 +9,26 @@ import type { StreamChat } from '../client'; export class ReminderPaginator extends BasePaginator { private client: StreamChat; - filters: ReminderFilters | undefined; - sort: ReminderSort | undefined; + protected _filters: ReminderFilters | undefined; + protected _sort: ReminderSort | undefined; + + get filters(): ReminderFilters | undefined { + return this._filters; + } + + get sort(): ReminderSort | undefined { + return this._sort; + } + + set filters(filters: ReminderFilters | undefined) { + this._filters = filters; + this.resetState(); + } + + set sort(sort: ReminderSort | undefined) { + this._sort = sort; + this.resetState(); + } constructor(client: StreamChat, options?: PaginatorOptions) { super(options); diff --git a/src/reminders/ReminderManager.ts b/src/reminders/ReminderManager.ts index 2dad68abf..8c4dac1b5 100644 --- a/src/reminders/ReminderManager.ts +++ b/src/reminders/ReminderManager.ts @@ -1,4 +1,5 @@ import { Reminder } from './Reminder'; +import { DEFAULT_STOP_REFRESH_BOUNDARY_MS } from './ReminderTimer'; import { StateStore } from '../store'; import { ReminderPaginator } from '../pagination'; import { WithSubscriptions } from '../utils/WithSubscriptions'; @@ -26,6 +27,7 @@ export const DEFAULT_REMINDER_MANAGER_CONFIG: ReminderManagerConfig = { 8 * oneHour, oneDay, ], + stopTimerRefreshBoundaryMs: DEFAULT_STOP_REFRESH_BOUNDARY_MS, }; const isReminderExistsError = (error: Error) => @@ -51,12 +53,12 @@ export type ReminderManagerState = { export type ReminderManagerConfig = { scheduledOffsetsMs: number[]; - stopTimerRefreshBoundaryMs?: number; + stopTimerRefreshBoundaryMs: number; }; export type ReminderManagerOptions = { client: StreamChat; - config?: ReminderManagerConfig; + config?: Partial; }; export class ReminderManager extends WithSubscriptions { @@ -71,6 +73,9 @@ export class ReminderManager extends WithSubscriptions { this.configState = new StateStore({ scheduledOffsetsMs: config?.scheduledOffsetsMs ?? DEFAULT_REMINDER_MANAGER_CONFIG.scheduledOffsetsMs, + stopTimerRefreshBoundaryMs: + config?.stopTimerRefreshBoundaryMs ?? + DEFAULT_REMINDER_MANAGER_CONFIG.stopTimerRefreshBoundaryMs, }); this.state = new StateStore({ reminders: new Map() }); this.paginator = new ReminderPaginator(client); @@ -78,6 +83,15 @@ export class ReminderManager extends WithSubscriptions { // Config API START // updateConfig(config: Partial) { + if ( + typeof config.stopTimerRefreshBoundaryMs === 'number' && + config.stopTimerRefreshBoundaryMs !== this.stopTimerRefreshBoundaryMs + ) { + this.reminders.forEach((reminder) => { + reminder.timer.stopRefreshBoundaryMs = + config?.stopTimerRefreshBoundaryMs as number; + }); + } this.configState.partialNext(config); } diff --git a/src/types.ts b/src/types.ts index ab1b4f4b0..8bcb8d06f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4036,7 +4036,7 @@ export type ReminderAPIResponse = APIResponse & { export type CreateReminderOptions = { messageId: string; - remind_at?: string; + remind_at?: string | null; user_id?: string; }; diff --git a/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts new file mode 100644 index 000000000..d241decd3 --- /dev/null +++ b/test/unit/MessageComposer/middleware/messageComposer/commandInjection.test.ts @@ -0,0 +1,342 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Channel } from '../../../../../src/channel'; +import { StreamChat } from '../../../../../src/client'; +import { MessageComposer } from '../../../../../src/messageComposer/messageComposer'; +import { createCommandInjectionMiddleware } from '../../../../../src/messageComposer/middleware/messageComposer/commandInjection'; +import { + CommandResponse, + createDraftCommandInjectionMiddleware, + MessageComposerMiddlewareState, + MessageDraftComposerMiddlewareValueState, + MiddlewareStatus, +} from '../../../../../src'; + +const setup = (initialState: MessageComposerMiddlewareState) => { + return { + state: initialState, + next: async (state: MessageComposerMiddlewareState) => ({ state }), + complete: async (state: MessageComposerMiddlewareState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +const setupDraft = (initialState: MessageDraftComposerMiddlewareValueState) => { + return { + state: initialState, + next: async (state: MessageDraftComposerMiddlewareValueState) => ({ state }), + complete: async (state: MessageDraftComposerMiddlewareValueState) => ({ + state, + status: 'complete' as MiddlewareStatus, + }), + discard: async () => ({ state: initialState, status: 'discard' as MiddlewareStatus }), + forward: async () => ({ state: initialState }), + }; +}; + +describe('stream-io/message-composer-middleware/command-injection', () => { + let channel: Channel; + let client: StreamChat; + let messageComposer: MessageComposer; + let textComposerMiddleware: ReturnType; + + beforeEach(() => { + client = { + userID: 'currentUser', + user: { id: 'currentUser' }, + } as any; + + channel = { + getClient: vi.fn().mockReturnValue(client), + state: { + members: {}, + watchers: {}, + }, + getConfig: vi.fn().mockReturnValue({ commands: [] }), + } as any; + + const textComposer = { + get command() { + return { + name: '', + description: '', + }; + }, + get text() { + return ''; + }, + get mentionedUsers() { + return []; + }, + setCommand: (command: CommandResponse | null) => {}, + }; + + const attachmentManager = { + get uploadsInProgressCount() { + return 0; + }, + get successfulUploads() { + return []; + }, + }; + + const linkPreviewsManager = { + state: { + getLatestValue: () => ({ + previews: new Map(), + }), + }, + }; + + const pollComposer = { + state: { + getLatestValue: () => ({ + data: { + options: [], + name: '', + max_votes_allowed: '', + id: '', + user_id: '', + voting_visibility: 'public', + allow_answers: false, + allow_user_suggested_options: false, + description: '', + enforce_unique_vote: true, + }, + errors: {}, + }), + }, + get canCreatePoll() { + return false; + }, + }; + + messageComposer = { + channel, + config: {}, + threadId: undefined, + client, + textComposer, + attachmentManager, + linkPreviewsManager, + pollComposer, + get lastChangeOriginIsLocal() { + return true; + }, + editedMessage: undefined, + get quotedMessage() { + return undefined; + }, + } as any; + + textComposerMiddleware = createCommandInjectionMiddleware(messageComposer); + }); + + it("should forward if there's no command state set", async () => { + vi.spyOn(messageComposer.textComposer, 'command', 'get').mockReturnValue(null); + const result = await textComposerMiddleware.handlers.compose( + setup({ + message: { + id: 'test-id', + parent_id: undefined, + type: 'regular', + }, + localMessage: { + attachments: [], + created_at: new Date(), + deleted_at: null, + error: undefined, + id: 'test-id', + mentioned_users: [], + parent_id: undefined, + pinned_at: null, + reaction_groups: null, + status: 'sending', + text: 'haha', + type: 'regular', + updated_at: new Date(), + }, + sendOptions: {}, + }), + ); + expect(result.status).toBeUndefined; + expect(result.state.message.text).toBeUndefined; + expect(result.state.localMessage.text).toBeUndefined; + }); + + it('should inject command into message text', async () => { + vi.spyOn(messageComposer.textComposer, 'command', 'get').mockReturnValue({ + name: 'giphy', + description: 'Send a giphy', + }); + + const result = await textComposerMiddleware.handlers.compose( + setup({ + message: { + id: 'test-id', + parent_id: undefined, + type: 'regular', + }, + localMessage: { + attachments: [], + created_at: new Date(), + deleted_at: null, + error: undefined, + id: 'test-id', + mentioned_users: [], + parent_id: undefined, + pinned_at: null, + reaction_groups: null, + status: 'sending', + text: 'haha', + type: 'regular', + updated_at: new Date(), + }, + sendOptions: {}, + }), + ); + + expect(result.status).toBeUndefined; + expect(result.state.message.text).toBe('/giphy haha'); + expect(result.state.localMessage.text).toBe('/giphy haha'); + }); +}); + +describe('stream-io/message-composer-middleware/draft-command-injection', () => { + let channel: Channel; + let client: StreamChat; + let messageComposer: MessageComposer; + let draftTextComposerMiddleware: ReturnType< + typeof createDraftCommandInjectionMiddleware + >; + + beforeEach(() => { + client = { + userID: 'currentUser', + user: { id: 'currentUser' }, + } as any; + + channel = { + getClient: vi.fn().mockReturnValue(client), + state: { + members: {}, + watchers: {}, + }, + getConfig: vi.fn().mockReturnValue({ commands: [] }), + } as any; + + const textComposer = { + get command() { + return { + name: '', + description: '', + }; + }, + get text() { + return ''; + }, + get mentionedUsers() { + return []; + }, + setCommand: (command: CommandResponse | null) => {}, + }; + + const attachmentManager = { + get uploadsInProgressCount() { + return 0; + }, + get successfulUploads() { + return []; + }, + }; + + const linkPreviewsManager = { + state: { + getLatestValue: () => ({ + previews: new Map(), + }), + }, + }; + + const pollComposer = { + state: { + getLatestValue: () => ({ + data: { + options: [], + name: '', + max_votes_allowed: '', + id: '', + user_id: '', + voting_visibility: 'public', + allow_answers: false, + allow_user_suggested_options: false, + description: '', + enforce_unique_vote: true, + }, + errors: {}, + }), + }, + get canCreatePoll() { + return false; + }, + }; + + messageComposer = { + channel, + config: {}, + threadId: undefined, + client, + textComposer, + attachmentManager, + linkPreviewsManager, + pollComposer, + get lastChangeOriginIsLocal() { + return true; + }, + editedMessage: undefined, + get quotedMessage() { + return undefined; + }, + } as any; + + draftTextComposerMiddleware = createDraftCommandInjectionMiddleware(messageComposer); + }); + + it('should forward if there is no command state set', async () => { + vi.spyOn(messageComposer.textComposer, 'command', 'get').mockReturnValue(null); + + const result = await draftTextComposerMiddleware.handlers.compose( + setupDraft({ + draft: { + id: 'test-id', + parent_id: undefined, + text: 'haha', + }, + }), + ); + + expect(result.state.draft.text).toBeUndefined; + }); + + it('should inject command into draft text', async () => { + vi.spyOn(messageComposer.textComposer, 'command', 'get').mockReturnValue({ + name: 'giphy', + description: 'Send a giphy', + }); + + const result = await draftTextComposerMiddleware.handlers.compose( + setupDraft({ + draft: { + id: 'test-id', + parent_id: undefined, + text: 'haha', + }, + }), + ); + + expect(result.state.draft.text).toBe('/giphy haha'); + }); +}); diff --git a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts index be9b2d958..f8feae799 100644 --- a/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.test.ts @@ -52,6 +52,12 @@ const setup = ({ const channel = client.channel('channelType', 'channelId'); channel.keystroke = vi.fn().mockResolvedValue({}); channel.getClient = vi.fn().mockReturnValue(client); + channel.getConfig = vi.fn().mockReturnValue({ + commands: [ + { name: 'ban', description: 'Ban a user' }, + { name: 'mute', description: 'Mute a user' }, + ], + }); const messageComposer = new MessageComposer({ client: client, @@ -143,14 +149,13 @@ describe('TextComposerMiddlewareExecutor', () => { eventName: 'onChange', initialValue: { ...initialValue, - text: '/ban', - selection: { start: 4, end: 4 }, + text: '/ban ', + selection: { start: 5, end: 5 }, }, }); - expect(result.state.suggestions).toBeDefined(); - expect(result.state.suggestions?.trigger).toBe('/'); - expect(result.state.suggestions?.query).toBe('ban'); + expect(result.state.command).toBeDefined(); + expect(result.state.command?.name).toBe('ban'); result = await textComposer.middlewareExecutor.execute({ eventName: 'onChange', @@ -206,6 +211,8 @@ describe('TextComposerMiddlewareExecutor', () => { expect(textComposer.text).toBe('/ban '); expect(textComposer.suggestions).toBeUndefined(); + expect(textComposer.command).toBeDefined(); + expect(textComposer.command?.name).toBe('ban'); }); it('should not be impacted by errors triggered by search source query', async () => { @@ -290,14 +297,14 @@ describe('TextComposerMiddlewareExecutor', () => { eventName: 'onChange', initialValue: { ...initialValue, - text: '/test', + text: '/ban ', selection: { start: 0, end: 5 }, }, }); - expect(result.state.suggestions).toBeDefined(); - expect(result.state.suggestions?.trigger).toBe('/'); - expect(result.state.suggestions?.query).toBe('test'); + expect(result.state.suggestions).not.toBeDefined(); + expect(result.state.command).toBeDefined(); + expect(result.state.command?.name).toBe('ban'); }); it('should handle new search trigger', async () => { @@ -433,10 +440,6 @@ describe('TextComposerMiddlewareExecutor', () => { }, }); - expect(result.state.suggestions).toBeDefined(); - expect(result.state.suggestions?.trigger).toBe('/'); - expect(result.state.suggestions?.query).toBe('ban'); - // Then test a mention after the command result = await textComposer.middlewareExecutor.execute({ eventName: 'onChange', diff --git a/test/unit/MessageComposer/middleware/textComposer/command.test.ts b/test/unit/MessageComposer/middleware/textComposer/command.test.ts new file mode 100644 index 000000000..deece7095 --- /dev/null +++ b/test/unit/MessageComposer/middleware/textComposer/command.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StreamChat } from '../../../../../src/client'; +import { TextComposerConfig } from '../../../../../src/messageComposer/configuration'; +import { + CompositionContext, + MessageComposer, +} from '../../../../../src/messageComposer/messageComposer'; +import type { DraftResponse, LocalMessage } from '../../../../../src/types'; +import { TextComposerMiddleware } from '../../../../../src'; +import { createActiveCommandGuardMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/activeCommandGuard'; +import { createCommandStringExtractionMiddleware } from '../../../../../src/messageComposer/middleware/textComposer/commandStringExtraction'; + +// Mock dependencies + +const setup = ({ + config, + composition, + compositionContext, +}: { + config?: Partial; + composition?: DraftResponse | LocalMessage; + compositionContext?: CompositionContext; +} = {}) => { + // Reset mocks + vi.clearAllMocks(); + + // Setup mocks + const client = new StreamChat('apiKey', 'apiSecret'); + client.queryUsers = vi.fn().mockResolvedValue({ users: [] }); + + const channel = client.channel('channelType', 'channelId'); + channel.keystroke = vi.fn().mockResolvedValue({}); + channel.getClient = vi.fn().mockReturnValue(client); + channel.getConfig = vi.fn().mockReturnValue({ + commands: [ + { name: 'ban', description: 'Ban a user' }, + { name: 'mute', description: 'Mute a user' }, + ], + }); + + const messageComposer = new MessageComposer({ + client: client, + composition, + compositionContext: compositionContext ?? channel, + config: { text: config }, + }); + messageComposer.textComposer.middlewareExecutor.insert({ + middleware: [createActiveCommandGuardMiddleware() as TextComposerMiddleware], + position: { before: 'stream-io/text-composer/commands-middleware' }, + }); + messageComposer.textComposer.middlewareExecutor.insert({ + middleware: [createCommandStringExtractionMiddleware() as TextComposerMiddleware], + position: { after: 'stream-io/text-composer/commands-middleware' }, + }); + return { client, channel, messageComposer }; +}; + +const initialValue = { + text: '', + selection: { start: 0, end: 0 }, + mentionedUsers: [], +}; + +describe('Apply Command Settings Middleware', () => { + it('should initialize with default middleware', () => { + const { + messageComposer: { textComposer }, + } = setup(); + const middleware = textComposer.middlewareExecutor.middleware; + expect(middleware.length).toBe(5); + expect(middleware[0].id).toBe('stream-io/text-composer/pre-validation-middleware'); + expect(middleware[1].id).toBe('stream-io/text-composer/mentions-middleware'); + expect(middleware[2].id).toBe('stream-io/text-composer/active-command-guard'); + expect(middleware[3].id).toBe('stream-io/text-composer/commands-middleware'); + expect(middleware[4].id).toBe('stream-io/text-composer/command-string-extraction'); + }); + + it.each([ + { + inputText: '/ban user1', + inputSelection: { start: 10, end: 10 }, + outputText: 'user1', + outputSelection: { start: 5, end: 5 }, + }, + { + inputText: '/mute user2', + inputSelection: { start: 11, end: 11 }, + outputText: 'user2', + outputSelection: { start: 5, end: 5 }, + }, + { + inputText: '/banuser1', + inputSelection: { start: 9, end: 9 }, + outputText: '/banuser1', + outputSelection: { start: 9, end: 9 }, + }, + { + inputText: '/mute user3', + inputSelection: { start: 15, end: 15 }, + outputText: 'user3', + outputSelection: { start: 5, end: 5 }, + }, + ])( + 'should extract command from the text on onChange', + async ({ inputText, inputSelection, outputText, outputSelection }) => { + const { + messageComposer: { textComposer }, + } = setup(); + + const result = await textComposer.middlewareExecutor.execute({ + eventName: 'onChange', + initialValue: { + ...initialValue, + text: inputText, + selection: inputSelection, + }, + }); + + expect(result.state.text).toBe(outputText); + expect(result.state.selection).toEqual(outputSelection); + }, + ); + + it('execute the active command guard middleware flow', async () => { + const { + messageComposer: { textComposer }, + } = setup(); + + const result = await textComposer.middlewareExecutor.execute({ + eventName: 'onChange', + initialValue: { + ...initialValue, + text: '/ban ', + selection: { + start: 5, + end: 5, + }, + }, + }); + + expect(result.state.text).toBe(''); + expect(result.state.command?.name).toBe('ban'); + + const newResult = await textComposer.middlewareExecutor.execute({ + eventName: 'onChange', + initialValue: { + ...result.state, + text: '/ban', + selection: { + start: 4, + end: 4, + }, + }, + }); + + expect(result.state.command?.name).toBe('ban'); + expect(newResult.state.text).toBe('/ban'); + expect(newResult.state.selection).toEqual({ start: 4, end: 4 }); + }); +}); diff --git a/test/unit/MessageComposer/textComposer.test.ts b/test/unit/MessageComposer/textComposer.test.ts index bf63b740d..ab744bb82 100644 --- a/test/unit/MessageComposer/textComposer.test.ts +++ b/test/unit/MessageComposer/textComposer.test.ts @@ -8,7 +8,6 @@ import { textIsEmpty } from '../../../src/messageComposer/textComposer'; import { DraftResponse, LocalMessage } from '../../../src/types'; import { logChatPromiseExecution } from '../../../src/utils'; import { TextComposerConfig } from '../../../src/messageComposer/configuration'; -import { TextComposerState } from '../../../src'; const textComposerMiddlewareExecuteOutput = { state: { @@ -104,6 +103,7 @@ describe('TextComposer', () => { it('should initialize with default config', () => { const { messageComposer } = setup(); expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ + command: null, mentionedUsers: [], text: '', selection: { start: 0, end: 0 }, @@ -114,6 +114,7 @@ describe('TextComposer', () => { const defaultValue = 'XXX'; const { messageComposer } = setup({ config: { defaultValue } }); expect(messageComposer.textComposer.state.getLatestValue()).toEqual({ + command: null, mentionedUsers: [], text: defaultValue, selection: { start: defaultValue.length, end: defaultValue.length }, @@ -225,6 +226,7 @@ describe('TextComposer', () => { updated_at: new Date(), }; const initialState = { + command: null, mentionedUsers: [], text: '', selection: { start: 0, end: 0 }, @@ -490,23 +492,26 @@ describe('TextComposer', () => { type: 'regular', text: 'Hello world', }; - it('should insert text at the specified selection', () => { + it('should insert text at the specified selection', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: ' beautiful', selection: { start: 5, end: 5 } }); + await textComposer.insertText({ + text: ' beautiful', + selection: { start: 5, end: 5 }, + }); expect(textComposer.text).toBe('Hello beautiful world'); }); - it('should insert text at the end if no selection provided', () => { + it('should insert text at the end if no selection provided', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: '!' }); + await textComposer.insertText({ text: '!' }); expect(textComposer.text).toBe('Hello world!'); }); - it('should respect maxLengthOnEdit', () => { + it('should respect maxLengthOnEdit', async () => { const message: LocalMessage = { id: 'test-message', type: 'regular', @@ -518,43 +523,43 @@ describe('TextComposer', () => { config: { maxLengthOnEdit: 8 }, composition: message, }); - textComposer.insertText({ text: ' beautiful world' }); + await textComposer.insertText({ text: ' beautiful world' }); expect(textComposer.text).toBe('Hello be'); }); - it('should handle empty text insertion', () => { + it('should handle empty text insertion', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: '', selection: { start: 5, end: 5 } }); + await textComposer.insertText({ text: '', selection: { start: 5, end: 5 } }); expect(textComposer.text).toBe('Hello world'); }); - it('should handle insertion at the start of text', () => { + it('should handle insertion at the start of text', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: 'Hi ', selection: { start: 0, end: 0 } }); + await textComposer.insertText({ text: 'Hi ', selection: { start: 0, end: 0 } }); expect(textComposer.text).toBe('Hi Hello world'); }); - it('should handle insertion at end of text', () => { + it('should handle insertion at end of text', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: '!', selection: { start: 11, end: 11 } }); + await textComposer.insertText({ text: '!', selection: { start: 11, end: 11 } }); expect(textComposer.text).toBe('Hello world!'); }); - it('should handle insertion with multi-character selection', () => { + it('should handle insertion with multi-character selection', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message }); - textComposer.insertText({ text: 'Hi', selection: { start: 0, end: 5 } }); + await textComposer.insertText({ text: 'Hi', selection: { start: 0, end: 5 } }); expect(textComposer.text).toBe('Hi world'); }); - it('should handle insertion with multi-character selection and maxLengthOnEdit restricting the size', () => { + it('should handle insertion with multi-character selection and maxLengthOnEdit restricting the size', async () => { const message: LocalMessage = { id: 'test-message', type: 'regular', @@ -567,17 +572,32 @@ describe('TextComposer', () => { composition: message, }); const insertedText = 'Hi world'; - textComposer.insertText({ text: insertedText, selection: { start: 7, end: 9 } }); + await textComposer.insertText({ + text: insertedText, + selection: { start: 7, end: 9 }, + }); expect(textComposer.text).toBe('Hello wHi '); }); - it('should not insert text if disabled', () => { + it('should not insert text if disabled', async () => { const { messageComposer: { textComposer }, } = setup({ composition: message, config: { enabled: false } }); - textComposer.insertText({ text: ' beautiful', selection: { start: 5, end: 5 } }); + await textComposer.insertText({ + text: ' beautiful', + selection: { start: 5, end: 5 }, + }); expect(textComposer.text).toBe(message.text); }); + + it('should reflect pasting of command trigger with partial command name', async () => { + const { + messageComposer: { textComposer }, + } = setup({ composition: message }); + await textComposer.insertText({ text: '/giph', selection: { start: 0, end: 11 } }); + expect(textComposer.text).toBe('/giph'); + expect(textComposer.suggestions).toBeDefined(); + }); }); describe('wrapSelection', () => { diff --git a/test/unit/reminders/ReminderManager.test.ts b/test/unit/reminders/ReminderManager.test.ts index e56781c48..bf69e8a79 100644 --- a/test/unit/reminders/ReminderManager.test.ts +++ b/test/unit/reminders/ReminderManager.test.ts @@ -5,12 +5,11 @@ import { Reminder, ReminderManager, ReminderResponse, - ReminderTimer, + ReminderState, StreamChat, } from '../../../src'; import { describe, expect, it, vi } from 'vitest'; import { PaginationQueryReturnValue } from '../../../src/pagination'; -import { sleep } from '../../../src/utils'; const baseData = { channel_cid: 'channel_cid', @@ -71,19 +70,40 @@ describe('ReminderManager', () => { const manager = new ReminderManager({ client, config }); // @ts-expect-error accessing private property expect(manager.client).toBe(client); - expect(manager.configState.getLatestValue()).toEqual(config); + expect(manager.configState.getLatestValue()).toEqual({ + ...DEFAULT_REMINDER_MANAGER_CONFIG, + ...config, + }); expect(manager.state.getLatestValue()).toEqual({ reminders: new Map() }); }); }); describe('config state API', () => { - it('updates config object', () => { + it('updates scheduledOffsetsMs', () => { const client = new StreamChat('api-key'); const manager = new ReminderManager({ client }); const config = { scheduledOffsetsMs: [1, 2, 3] }; manager.updateConfig(config); expect(manager.scheduledOffsetsMs).toEqual(config.scheduledOffsetsMs); }); + + it('updates stopTimerRefreshBoundaryMs for every timer', () => { + const client = new StreamChat('api-key'); + const manager = new ReminderManager({ client }); + const scheduleOffsetMs = 62 * 1000; + const reminderResponse = generateReminderResponse({ scheduleOffsetMs }); + manager.upsertToState({ data: reminderResponse }); + + expect(manager.stopTimerRefreshBoundaryMs).toBe(DEFAULT_STOP_REFRESH_BOUNDARY_MS); + const config = { stopTimerRefreshBoundaryMs: 1 }; + manager.updateConfig(config); + expect(manager.stopTimerRefreshBoundaryMs).toEqual( + config.stopTimerRefreshBoundaryMs, + ); + expect( + manager.reminders.get(reminderResponse.message_id)?.timer.stopRefreshBoundaryMs, + ).toBe(1); + }); }); describe('state API', () => { @@ -303,14 +323,18 @@ describe('ReminderManager', () => { client.dispatchEvent(generateReminderEvent(type, reminderResponse)); expect(manager.reminders.size).toBe(1); const remindAtDate = new Date('1970-01-01'); - expect( - manager.reminders.get(reminderResponse.message_id)?.state.getLatestValue(), - ).toEqual({ + const { timeLeftMs, ...state } = manager.reminders + .get(reminderResponse.message_id) + ?.state.getLatestValue() as ReminderState; + expect({ + ...state, + timeLeftMs: Math.round((timeLeftMs ?? 0) / 1000), + }).toEqual({ ...reminderResponse, created_at: new Date(reminderResponse.created_at), remind_at: remindAtDate, updated_at: new Date(reminderResponse.updated_at), - timeLeftMs: remindAtDate.getTime() - now.getTime(), + timeLeftMs: Math.round((remindAtDate.getTime() - now.getTime()) / 1000), }); }); it('removes reminder from state on reminder.deleted event', () => {