MessageComposer Middleware

Message Composer Middleware

MessageComposer middleware purpose is to allow for full control over the data processing in different composition scenarios.

Some managers (TextComposer, PollComposer, MessageComposer) use middleware to process state changes and compose data. These managers have one or more middleware executors that execute handler chains.

The middleware execution is performed via MiddlewareExecutor. It allows:

  • Registering middleware with specific handlers
  • Controlling handler execution order
  • Deciding when to terminate the middleware chaing execution

Middleware Execution

Middleware is an object with an id and a handlers object containing specific event handlers:

type Middleware<TValue, THandlers extends string> = {
  id: string;
  handlers: {
    [K in THandlers]: MiddlewareHandler<TValue>;
  };
};

The MiddlewareExecutor registers the middleware:

// Define middleware with handlers for different events
const textValidationMiddleware = {
  id: "textValidation",
  handlers: {
    // Handler for text changes
    onChange: async ({ state, next, discard }) => {
      if (state.text.length > 100) {
        return discard();
      }
      return next(state);
    },

    // Handler for suggestion selection
    onSuggestionItemSelect: async ({ state, next, complete }) => {
      // Process suggestion...
      return next(state);
    },
  },
};

// Register middleware with executor
middlewareExecutor.use(textValidationMiddleware);

The executor executes a chain of handlers by name. For example, when onChange is called, it runs all handlers registered for the onChange event. The state type varies based on specific MiddlewareExecutor. And each handler is given the control functions (next, discard, etc.).

// Executor runs the onChange handler chain
middlewareExecutor.execute({eventName: 'onChange', initialValue: { text: 'Hello', selection: {start: 1, end:} } });

Each handler in the chain receives the current state and control functions to manage the execution flow:

  • next(state): Continue the execution with the next handler in the chain and with the given state
  • complete(state): Stop the execution with and commit the given state
  • discard(): Stop execution and discard changes
  • forward(): Skip processing and continue with the next handler

Middleware Management

Middleware can be registered in several ways:

// Add middleware
middlewareExecutor.use({
  id: "customMiddleware",
  handlers: {
    onChange: async ({ state, next, complete }) => {
      // Process state
      if (state.text === "done") {
        return complete(state);
      }
      return next(state);
    },
  },
});

// Insert middleware at specific position
middlewareExecutor.insert({
  middleware: [customMiddleware],
  position: { after: "existingMiddlewareId" },
});

// Replace existing middleware
middlewareExecutor.replace([newMiddleware]);

// Set middleware order
middlewareExecutor.setOrder([
  "middleware-1-id",
  "middleware-2-id",
  "middleware-3-id",
]);

Custom Middleware Execution

To create a custom execution chain with custom state (all the handlers work with the same executor state) we can extend MiddlewareExecutor class to build a custom executor. There we can customize how the chain is executed or how the middleware is added, sorted or removed.

Custom handler execution can be also done via factory functions. This is actually approach used to generate pre-build middleware. Factory functions keep a common context for all the handlers.

Disabling Middleware

If the goal is to disable the middleware (and thus for example the corresponding trigger), we can simply override the registerred middleware (and disable mentions for example):

textComposer.middlewareExecutor.use([
  createCommandsMiddleware(textComposer.channel),
]);

The same applies to re-arranging the middleware.

Overriding a Middleware Handler

If we wanted to override what happens when a target is selected, we would be overriding the selection handler for all the types of suggestions:

import { createCommandsMiddleware, createMentionsMiddleware } from 'stream-chat';
import type {CommandsMiddleware, CommandSearchSource, TextComposerMiddlewareOptions, MentionsMiddleware, MentionsSearchSource} from 'stream-chat';

const customCreateCommandsMiddleware =  (
  channel: Channel,
  options?: Partial<TextComposerMiddlewareOptions> & {
    searchSource?: CommandSearchSource;
  },
): CommandsMiddleware => {
    const default = createCommandsMiddleware(channel);
    return {
        ...default,
        handlers: {
            ...default.handlers,
            onSuggestionItemSelect: () => {
                // Custom selection logic
            },
        }
    }
}

const customCreateMentionsMiddleware =  (
  channel: Channel,
  options?: Partial<TextComposerMiddlewareOptions> & {
    searchSource?: MentionsSearchSource;
  },
): MentionsMiddleware => {
    const default = createMentionsMiddleware(channel);
    return {
        ...default,
        handlers: {
            ...default.handlers,
            onSuggestionItemSelect: () => {
                // Custom selection logic
            },
        }
    }
}

Message Composer Middleware Overview

Each manager defines its own handler events and state types. In general there are two types of executors:

  1. Executors for state change - handlers mutate the original middleware state value into a new value
  2. Composition executors - handlers generate a new value

TextComposer The executor for state change recognized the following handlers:

  • onChange: Handles text changes
  • onSuggestionItemSelect: Handles suggestion selection

PollComposer The executor for state change recognizes the following handlers:

  • handleFieldChange: Processes poll form field updates
  • handleFieldBlur: Handles poll form field blur events

The executor for composition regocnized the following handlers:

  • compose: Composes poll data before the poll is created server-side

MessageComposer

The executor for message composition recognizes the following handlers

  • compose: Composes final message data

The executor for draft composition recognizes the following handlers

  • compose: Composes final message draft data

Text Composition Middleware

TextComposer state changes based on text change or suggestion selection are processed via middleware execution. The responsible executor is available as TextComposer.middlewareExecutor.

The executor registers the default middleware with the following middleware factories:

  1. createTextComposerPreValidationMiddleware
  2. createMentionsMiddleware
  3. createCommandsMiddleware

The mentions and commands middleware determines when to trigger suggestions offering and how to retrieve the suggestions items. This is done via MentionsSearchSource for mentions middleware and CommandSearchSource for commands middleware.

Search Source Basic Configuration

The mentions middleware and commands middleware handles data pagination and query loading state management out of the box. Both implement the BaseSearchSource API and allow for customization of debounce interval and page size.

const commandSearchSource = new CommandSearchSource(channel, {
  debounceMs,
  pageSize,
});
const commandSearchSource = new MentionsSearchSource(channel, {
  debounceMs,
  pageSize,
});

Custom Pagination Logic

We can custommize how the searched items are retrieved by overriding the MentionsSearchSource or CommandSearchSource method query. If the pagination supports cursor, the next value (cursor) should also be returned. We expect it to be a string:

import {MentionsSearchSource} from 'stream-chat;

class CustomMentionsSearchSource extends MentionsSearchSource {
  // ...
  query = async (searchQuery: string) => {
    let result;
    // custom logic...

    return {
      items: result.items,
      next: result.cursor,
    };
  }
}

MentionsSearchSource Customization

Besides the basic configuration MentionsSearchSource allows to configure the following:

  • mentionAllAppUsers - forces the MentionsSearchSource instance to always trigger users query instead of just channel members query
  • textComposerText - allows to keep the whole text composer text, not only the search query extracted from the trigger
  • transliterate - allows to transliterate the mention names
import { default: transliterate } from '@stream-io/transliterate';

const commandSearchSource = new MentionsSearchSource(channel, {
  debounceMs,
  mentionAllAppUsers,
  pageSize,
  textComposerText,
  transliterate,
});

Further we can customize how the data is retrieved, filtered, transformed and maintained.

Query Parameters Customization

The generation of search parameters can be customized by overriding the generation logic based on the search query value:

import { MentionsSearchSource } from "stream-chat";
import type {
  UserFilters,
  UserSort,
  UserOptions,
  MemberFilters,
  MemberSort,
  UserOptions,
} from "stream-chat";

class CustomMentionsSearchSource extends MentionsSearchSource {
  // ...
  prepareQueryUsersParams = (
    searchQuery: string,
  ): { filters: UserFilters; sort: UserSort; options: UserOptions } => {
    //... generate filters, sort, options
    return { filers, sort, options };
  };
  prepareQueryUsersParams = (
    searchQuery: string,
  ): { filters: MemberFilters; sort: MemberSort; options: UserOptions } => {
    //... generate filters, sort, options
    return { filers, sort, options };
  };
}

Or we can overwrite the parameters if they do not depend on the search query value (normally does not apply to filters):

mentionsSearchSource.userSort = { name: -1 };
mentionsSearchSource.memberSort = { name: -1 };
mentionsSearchSource.searchOptions = { include_deactivated_users: true };

Custom Results Filtering

Apply custom filtering logic to the query results before they are committed to the search source state.

import {MentionsSearchSource} from 'stream-chat;
import type {UserSuggestion} from 'stream-chat;

const mentionsFilter (item: UserSuggestion) => {
  //... custom filtering logic
}

class CustomMentionsSearchSource extends MentionsSearchSource {
  // ...
  filterQueryResults = (items: UserSuggestion[]) => {
    return items.filter(mentionsFilter)
  }
}

State Retention Between Searches With a new search query the search state is reset to the initial form except for items, which are kept from the last search result until query results are committed to the state. This is meant to prevent UI flickering between empty state and the first page results on every text change. This behavior can be, hovewever, changed by overriding getStateBeforeFirstQuery method:

import {MentionsSearchSource} from 'stream-chat;

class CustomMentionsSearchSource extends MentionsSearchSource {
  // ...
  getStateBeforeFirstQuery = (newSearchString: string) => {
    return {
      ...super.getStateBeforeFirstQuery(newSearchString),
      items: [],
    };
  }
}

TextComposer Middleware Customization

There are various level of detail in which text composition can be customized. We will demonstrate these use cases from the most general to the most specific.

Change Trigger or Minimum Trigger Characters

textComposer.middlewareExecutor.use([
  createMentionsMiddleware(textComposer.channel, {
    minChars: 3,
    trigger: "__",
  }),
  createCommandsMiddleware(textComposer.channel, { minChars: 3, trigger: "§" }),
]);

Provide Custom Search Source

If there is a need to redefine how the suggestion items are retrieved, we can provide a custom search source:

const searchSource = new CustomSearchSource();

textComposer.middlewareExecutor.use([
  createCommandsMiddleware(textComposer.channel, { searchSource }),
]);

Custom Suggestion and Trigger

To add a new suggestion type and associate it with a trigger, we need to create a brand new middleware and insert it at a convenient position in the execution flow.

import { ChannelSearchSource } from 'stream-chat';
import type {Channel, MessageComposer, TextComposerMiddlewareExecutorState} from 'stream-chat';

type ChannelWithId = Channel & {id: string};

type ChannelMentionsMiddleware = Middleware<
  TextComposerMiddlewareExecutorState<ChannelWithId>,
  'onChange' | 'onSuggestionItemSelect'
>;
// Custom middleware for emoji suggestions
const createTextComposerChannelMiddleware: TextComposerMiddleware = (composer: MessageComposer) =>  {
  const trigger = '#';
  const channelSearchSource = new ChannelSearchSource(composer.client)
  return {
    id: 'text-composer/channels-middleware',
    handlers: {
      onChange: async ({ state, forward, next }) => {
        const { text } = state;
        if (!text) return forward();
        const newState = {};

        // identifying the trigger, removing existin suggestions ...

        return next(newState);
      },
    onSuggestionItemSelect: async ({ state, forward, next }) => {
      const { selectedSuggestion: channel } = state.change;

      return next({
        ...state,
        text: insertTextAtSelection(state.text, channel, state.selection),
        suggestions: undefined
      });
    }
  });
};

// Register the middleware
textComposer.middlewareExecutor.insert({
  middleware: [emojiMiddleware],
  position: { before: 'stream-io/text-composer/mentions-middleware' }
});

Poll Composition Middleware

PollComposer state changes and composition are processed via middleware execution. The responsible executors are available as PollComposer.compositionMiddlewareExecutor and PollComposer.stateMiddlewareExecutor.

The executors register the default middleware with the following middleware factories:

  1. createPollComposerStateMiddleware
  2. createPollCompositionValidationMiddleware

The state middleware reflects the poll creation form fields validation and updates, while the composition middleware validates the final poll composition before creation.

PollComposer Middleware Customization Overview

You can customize the middleware behavior at various levels:

Basic Configuration

const stateMiddleware = createPollComposerStateMiddleware({
  processors: customProcessors,
  validators: customValidators,
});

Custom Validation Rules

const customValidators = {
  handleFieldChange: {
    name: ({ value }) => {
      if (value.length < 3)
        return { name: "Name must be at least 3 characters" };
      return { name: undefined };
    },
  },
};

Custom State Processing

const customProcessors = {
  handleFieldChange: {
    options: ({ value, data }) => {
      // Custom option processing logic
      return {
        options: value.map((option) => ({
          ...option,
          text: option.text.toUpperCase(),
        })),
      };
    },
  },
};

Continue reading for a detailed explanation on PollComposer middleware.

The PollComposer State Change Middleware

The State Change Middleware Value

The PollComposerStateChangeMiddlewareValue represents the state passed through the middleware execution chain. It contains:

  • previousState: The state before the current change
  • nextState: The next PollComposer state to be committed after successful middleware execution
  • targetFields: The fields being updated in the current change

The PollComposerState type includes:

type PollComposerState = {
  data: {
    allow_answers: boolean;
    allow_user_suggested_options: boolean;
    description: string;
    enforce_unique_vote: boolean;
    id: string;
    max_votes_allowed: string;
    name: string;
    options: Array<{ id: string; text: string }>;
    user_id?: string;
    voting_visibility: VotingVisibility;
  };
  errors: Record<string, string>;
};

The Configuration of createPollComposerStateMiddleware Factory

The state middleware handles field changes and blur events, providing validation and state processing capabilities. It supports customization through processors and validators.

The following keys from PollComposerState['data'] can be used for custom processors and validators:

type PollComposerDataKeys = {
  allow_answers: boolean;
  allow_user_suggested_options: boolean;
  description: string;
  enforce_unique_vote: boolean;
  id: string;
  max_votes_allowed: string;
  name: string;
  options: Array<{ id: string; text: string }>;
  user_id?: string;
  voting_visibility: VotingVisibility;
};
const stateMiddleware = createPollComposerStateMiddleware({
  processors: {
    handleFieldChange: {
      // Custom processors for field changes
      // Available keys: allow_answers, allow_user_suggested_options, description,
      // enforce_unique_vote, id, max_votes_allowed, name, options, user_id, voting_visibility
    },
    handleFieldBlur: {
      // Custom processors for field blur events
      // Available keys: allow_answers, allow_user_suggested_options, description,
      // enforce_unique_vote, id, max_votes_allowed, name, options, user_id, voting_visibility
    },
  },
  validators: {
    handleFieldChange: {
      // Custom validators for field changes
      // Available keys: allow_answers, allow_user_suggested_options, description,
      // enforce_unique_vote, id, max_votes_allowed, name, options, user_id, voting_visibility
    },
    handleFieldBlur: {
      // Custom validators for field blur events
      // Available keys: allow_answers, allow_user_suggested_options, description,
      // enforce_unique_vote, id, max_votes_allowed, name, options, user_id, voting_visibility
    },
  },
});

Default Validators

The middleware comes with built-in validators for common poll fields applied as follows:

handleFieldChange

  • pollStateChangeValidators
  • defaultPollFieldChangeEventValidators
  • `customValidators.handleFieldChange

handleFieldBlur

  • pollStateChangeValidators
  • defaultPollFieldBlurEventValidators
  • customValidators.handleFieldBlur

Default Processors

The middleware includes processors for mutating the new state:

handleFieldChange

  • pollCompositionStateProcessors
  • customProcessors.handleFieldChange

handleFieldBlur

  • there are no default state processors
  • customProcessors.handleFieldBlur

Composition Middleware

The composition middleware validates the final poll state before creation (existing errors, at least on option, etc.):

const compositionMiddleware =
  createPollCompositionValidationMiddleware(composer);

Message Composition Middleware

| handleSubmit | It is possible to customize the message composition via MessageComposer.compositionMiddlewareExecutor and draft composition via MessageComposer.draftCompositionMiddlewareExecutor. Also, the sending of the message is customizable via Channel prop doSendMessageRequest. |

The custom message data can be injected via custom middleware handlers or by setting the data directly:

MessageComposer uses middleware executors to handle message composition and draft composition. The executors are available as:

  • MessageComposer.compositionMiddlewareExecutor - for message composition
  • MessageComposer.draftCompositionMiddlewareExecutor - for draft composition

Default Middleware

MessageComposerMiddlewareState Structure

The MessageComposerMiddlewareState is the core state object that middleware operates on. It has three main fields:

export type MessageComposerMiddlewareState = {
  message: Message | UpdatedMessage;
  localMessage: LocalMessage;
  sendOptions: SendMessageOptions;
};

Fields Explanation

  1. localMessage: MessageResponse

    • Used for local channel state updates
    • Contains the message data that will be shown in the UI immediately
    • Can include temporary data or UI-specific properties
    • Example: Adding temporary IDs, local timestamps, or UI-specific flags
  2. message: MessageResponse

    • Contains the data that will be sent to the backend
    • Should match the expected backend message structure - Message type
    • Used for actual message creation/update
    • Example: Final message content, attachments, metadata
  3. sentOptions: SendMessageOptions | UpdateMessageOptions

    • Contains options for sending/updating the message
    • Must match either SendMessageOptions or UpdateMessageOptions type

Composition Middleware Executor

The composition middleware executor registers the following middleware in order:

  1. stream-io/message-composer-middleware/text-composition (createTextComposerCompositionMiddleware)

    • Adds text to the composition
    • Adjusts mentioned users array based on the current text
  2. stream-io/message-composer-middleware/attachments (createAttachmentsCompositionMiddleware)

    • Add the AttachmentManager.attachments into the attachments array of both localMessage and message
    • Discards the composition process if there are any unfinished uploads in progress
  3. stream-io/message-composer-middleware/link-previews (createLinkPreviewsCompositionMiddleware)

    • Includes loaded link previews in the attachments array
    • Determines whether link enrichment should be skipped server-side
  4. stream-io/message-composer-middleware/own-state (createMessageComposerStateCompositionMiddleware)

    • Enriches localMessage and message with relevant MessageComposer state values (poll id, quoted message id)
  5. stream-io/message-composer-middleware/custom-data (createCustomDataCompositionMiddleware)

    • Enriches the composition with CustomDataManager.customMessageData
  6. stream-io/message-composer-middleware/data-validation (createCustomDataCompositionMiddleware`)

    • Enforces text config parameter maxLengthOnSend
    • Discards composition if it can be considered empty or the data has not been changed locally (meaning the update took place based on message.updated and draft.updated WS events)
  7. stream-io/message-composer-middleware/data-cleanup (createCompositionDataCleanupMiddleware)

    • Converts the message payload from Message to UpdatedMessage
    • converst the sendOptions from SendMessageOptions to UpdateMessageOptions

Draft Composition Middleware Executor

The draft composition middleware executor registers:

  1. stream-io/message-composer-middleware/draft-text-composition (createDraftTextComposerCompositionMiddleware)

    • Adds text to the composition
    • Adjusts mentioned users array based on the current tex
  2. stream-io/message-composer-middleware/draft-attachments (createDraftAttachmentsCompositionMiddleware)

    • Add the AttachmentManager.attachments into the attachments array of both localMessage and message
    • Does not discard the composition process if there are any uploads in progress - just uncludes the successful uploads
  3. stream-io/message-composer-middleware/draft-link-previews (createDraftLinkPreviewsCompositionMiddleware)

    • Includes loaded link previews in the attachments array
  4. stream-io/message-composer-middleware/draft-own-state (createDraftMessageComposerStateCompositionMiddleware)

    • Enriches localMessage and message with relevant MessageComposer state values (poll id, quoted message id)
  5. stream-io/message-composer-middleware/draft-custom-data (createDraftCustomDataCompositionMiddleware)

    • Enriches the composition with CustomDataManager.customMessageData
  6. stream-io/message-composer-middleware/draft-data-validation (createDraftCompositionDataCleanupMiddleware)

    • Verifies whether draft can be created and discards the process if the draft cannot be created (is empty)

Message Composition Customization

Custom Message Data Transformation

We can add custom transformation logic by adding a new composition the middleware ( contains compose handler):

import type {
  MessageCompositionMiddleware,
  MessageComposerMiddlewareValueState,
  MiddlewareHandlerParams,
} from "stream-chat";

const customCompositionMiddleware = (
  composer: MessageComposer,
): MessageCompositionMiddleware => ({
  id: "message-composer/message-composer/custom-message-data",
  handlers: {
    compose: async ({
      state,
      next,
      forward,
    }: MiddlewareHandlerParams<MessageComposerMiddlewareValueState>) => {
      if (!composer.textComposer) return forward();

      const { mentionedUsers, text } = composer.textComposer;

      // Create new state objects
      const newLocalMessage = {
        ...state.localMessage,
        custom_ui_flag: true,
      };

      const newMessage = {
        ...state.message,
        custom_field: "value",
        mentioned_users: mentionedUsers.map((u) => u.id),
        text: text.toUpperCase(),
      };

      // Pass updated state to next middleware
      return next({
        ...state,
        localMessage: newLocalMessage,
        message: newMessage,
      });
    },
  },
});

Draft Composition Customization

Similarly, we can customize draft composition:

import type {
  MessageDraftCompositionMiddleware,
  MessageDraftComposerMiddlewareValueState,
  MiddlewareHandlerParams,
} from "stream-chat";

const customDraftMiddleware = (
  composer: MessageComposer,
): MessageDraftCompositionMiddleware => ({
  id: "message-composer/message-composer/custom-draft",
  handlers: {
    compose: async ({
      state,
      next,
      forward,
    }: MiddlewareHandlerParams<MessageDraftComposerMiddlewareValueState>) => {
      if (!composer.textComposer) return forward();

      const { text } = composer.textComposer;

      // Create new draft state
      const newDraft = {
        ...state.draft,
        custom_field: "draft_value",
        text: text.toLowerCase(),
      };

      // Pass updated state to next middleware
      return next({
        ...state,
        draft: newDraft,
      });
    },
  },
});

Middleware Management

Registering Custom Middleware

When adding custom middleware, you can insert it at specific positions using the middleware IDs:

// Add middleware after text composition
messageComposer.compositionMiddlewareExecutor.insert({
  middleware: [customCompositionMiddleware],
  position: { after: "stream-io/message-composer-middleware/text-composition" },
});

// Add middleware before draft composition
messageComposer.draftCompositionMiddlewareExecutor.insert({
  middleware: [customDraftMiddleware],
  position: {
    before: "stream-io/message-composer-middleware/draft-text-composition",
  },
});

// Replace existing middleware
messageComposer.compositionMiddlewareExecutor.replace([newMiddleware]);

// Set middleware order
messageComposer.compositionMiddlewareExecutor.setOrder([
  "stream-io/message-composer-middleware/text-composition",
  "custom-composition",
  "stream-io/message-composer-middleware/attachments",
  "stream-io/message-composer-middleware/link-previews",
  "stream-io/message-composer-middleware/custom-data",
  "stream-io/message-composer-middleware/data-validation",
  "stream-io/message-composer-middleware/data-cleanup",
]);

Disabling Middleware

To disable middleware functionality, you can replace it with a no-op version:

import type { MessageCompositionMiddleware } from "stream-chat";

// Create a no-op middleware
const disabledMiddleware: MessageCompositionMiddleware = {
  id: "message-composer/message-composer/text-composition",
  handlers: {
    compose: async ({ state, forward }) => {
      // Skip processing and forward to next middleware with unchanged state
      return forward();
    },
  },
};

// Replace the original middleware with the disabled version
messageComposer.compositionMiddlewareExecutor.replace([disabledMiddleware]);

Emoji Suggestions Middleware

Triggering emoji suggestion while typing is handled by TextComposer middleware. The middleware is not enabled by default. To enable it we have to import it from stream-chat-react/emojis plugin and register as follows:

import { createTextComposerEmojiMiddleware } from "stream-chat-react/emojis";
import type { TextComposerMiddleware } from "stream-chat";

import { init, SearchIndex } from "emoji-mart";
import data from "@emoji-mart/data";

init({ data });

const Component = () => {
  useEffect(() => {
    if (!chatClient) return;

    chatClient.setMessageComposerSetupFunction(({ composer }) => {
      composer.textComposer.middlewareExecutor.insert({
        middleware: [
          createTextComposerEmojiMiddleware(
            SearchIndex,
          ) as TextComposerMiddleware,
        ],
        position: { before: "stream-io/text-composer/mentions-middleware" },
        unique: true,
      });
    });
  }, [chatClient]);
};

We can customize the middleware in the following ways:

Change Trigger or Minimum Trigger Characters

textComposer.middlewareExecutor.use([
  createTextComposerEmojiMiddleware(SearchIndex, {
    minChars: 3,
    trigger: "__",
  }),
]);

Provide Custom Search Source

If there is a need to redefine how the suggestion items are retrieved, we can provide a custom search source:

const searchSource = new CustomSearchSource();

textComposer.middlewareExecutor.use([
  createTextComposerEmojiMiddleware(searchSource),
]);
© Getstream.io, Inc. All Rights Reserved.