Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

Composer middleware

Composer middleware changes what a message composer does at each step: when the text changes, when a suggestion is picked, before and after a file uploads, and when the message is built. Each step runs a chain of small handlers, and you can add your own, move them or replace the built-in ones.

Info:

Example

An app wants every message sent from a channel to carry the platform it was sent from, in message.custom.sent_from. A middleware on the composition chain adds it while the message is built:

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

declare module "stream-chat" {
  interface CustomMessageData {
    sent_from?: string;
  }
}

const sentFromMiddleware: MessageCompositionMiddleware = {
  id: "my-app/sent-from",
  handlers: {
    compose: ({
      next,
      state,
    }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) =>
      next({
        ...state,
        localMessage: {
          ...state.localMessage,
          custom: { ...state.localMessage.custom, sent_from: "web" },
        },
        message: {
          ...state.message,
          custom: { ...state.message.custom, sent_from: "web" },
        },
      }),
  },
};

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  if (composer.contextType !== "channel") return;

  composer.compositionMiddlewareExecutor.insert({
    middleware: [sentFromMiddleware],
    position: {
      before: "stream-io/message-composer-middleware/data-validation",
    },
    unique: true,
  });

  return () =>
    composer.compositionMiddlewareExecutor.remove(sentFromMiddleware.id);
});

The middleware changes both copies of the message: message, which the server receives, and localMessage, which the message list shows until the server answers. Custom fields go under custom, and the declare module block types sent_from there, as Data conventions explains. The setup function installs it on every channel composer, and its teardown removes it again.

How a chain runs

A chain is a list of middleware, each an object with an id and a handler per event. When the composer runs an event, it calls the handlers for that event in order. A middleware without a handler for the event is skipped.

Each handler receives the current state and four functions. It returns the result of calling one of them:

  • next(state) passes a changed state to the next middleware.
  • forward() passes the state on unchanged.
  • complete(state) stops the chain. The state it's given is the result.
  • discard() stops the chain and drops the result. For a message, nothing is sent and MessageComposer.send() returns 'nothing-to-send'. For a text change, the change isn't applied.
Info:

Example

When the user types "Hi @an", the text chain runs onChange: the pre-validation middleware forwards, the mentions middleware finds the @ trigger and completes with suggestions for "an", and the commands middleware never runs for that change.

A handler can be async. When the same chain starts again before an earlier run finished, for example on the next keystroke, the earlier run's result is dropped. The upload chains are the exception: they run once per file, side by side, and keep every result.

Built-in chains

Every composer starts with the chains below. Each table lists the middleware in the order it runs.

Composition

composer.compositionMiddlewareExecutor runs compose when MessageComposer.send() or MessageComposer.update() builds the message. Its state is { message, localMessage, sendOptions }.

Middleware id What it does
stream-io/message-composer-middleware/user-data-injection Adds the current user to localMessage.
stream-io/message-composer-middleware/poll-only In a channel composer that holds a poll, completes the chain with a message that carries only the poll.
stream-io/message-composer-middleware/text-composition Adds the text, and the mentions still present in it.
stream-io/message-composer-middleware/attachments Adds the finished uploads. Discards the message while an upload is running, unless pending uploads are allowed.
stream-io/message-composer-middleware/link-previews Adds the loaded link previews as attachments, unless one of them is still loading.
stream-io/message-composer-middleware/shared-location Adds the location to share.
stream-io/message-composer-middleware/own-state Adds the quoted message, the poll and "also send in channel".
stream-io/message-composer-middleware/custom-data Adds the custom data set with CustomDataManager.setMessageData().
stream-io/message-composer-middleware/data-validation Discards an empty message, one longer than text.maxLengthOnSend, and one whose command can't be sent.
stream-io/message-composer-middleware/data-cleanup Merges in the message being edited, if there is one, and sets the message type.

Drafts

composer.draftCompositionMiddlewareExecutor runs compose when MessageComposer.createDraft() builds the draft. Its state is { draft }.

Middleware id What it does
stream-io/message-composer-middleware/draft-text-composition Adds the text, cut to text.maxLengthOnSend, and its mentions.
stream-io/message-composer-middleware/draft-attachments Adds the finished uploads. Running uploads don't stop the draft.
stream-io/message-composer-middleware/draft-link-previews Adds the loaded link previews.
stream-io/message-composer-middleware/draft-own-state Adds the quoted message, the poll and "also send in channel".
stream-io/message-composer-middleware/draft-custom-data Adds the custom message data.
stream-io/message-composer-middleware/draft-data-validation Discards a draft with no content, or with no change since it loaded.

Text

composer.textComposer.middlewareExecutor runs onChange on every text change and onSuggestionItemSelect when a suggestion is picked. Its state is the text composer's state, plus change.selectedSuggestion for a picked suggestion and effects to apply afterwards.

Middleware id What it does
stream-io/text-composer/pre-validation-middleware Cuts the text to text.maxLengthOnEdit.
stream-io/text-composer/mentions-middleware Opens mention suggestions after @, and inserts a picked mention. See Suggestions.
stream-io/text-composer/commands-middleware Opens command suggestions after /, and sets a picked or typed command.
stream-io/text-composer/command-effects-middleware Adds the command.activate effect when a command is picked. See Effect handlers.

Uploads

attachmentManager.preUploadMiddlewareExecutor runs prepare for each added file, with the state { attachment }. A discard() leaves the file out.

Middleware id What it does
stream-io/attachment-manager-middleware/file-upload-config-check Marks the file blocked when the app's upload settings refuse it.
stream-io/attachment-manager-middleware/blocked-upload-notification Publishes a notification for a blocked file.

attachmentManager.postUploadMiddlewareExecutor runs postProcess after each upload, with the state { attachment, error, response }. A discard() removes the attachment.

Middleware id What it does
stream-io/attachment-manager-middleware/upload-error Publishes a notification for a failed upload, unless it was cancelled.
stream-io/attachment-manager-middleware/post-upload-enrichment Sets the attachment's URL and thumbnail from the upload response.

Polls

pollComposer.stateMiddlewareExecutor runs handleFieldChange and handleFieldBlur with stream-io/poll-composer-state-processing, which processes and validates the poll's fields. createPollComposerStateMiddleware({ processors, validators }) builds that middleware with processors and validators of your own; put it in with MiddlewareExecutor.replace().

pollComposer.compositionMiddlewareExecutor runs compose with stream-io/poll-composer-composition, which discards a poll that can't be created yet.

Adding middleware

Add middleware in the composer setup function, as in the example at the top. client.config.setSetupFunction("messageComposer", setup) runs setup for every composer: channel, thread and edit composers, the ones that exist and the ones created later.

  • When it runs. A composer runs the setup function when it starts its subscriptions, which the UI layer does when it shows the composer. It runs it again whenever the setup function or the messageComposer configuration changes.
  • The teardown. The function the setup returns runs before the setup runs again, and when the composer's subscriptions end. Remove there whatever the setup inserted.
  • unique: true. An MiddlewareExecutor.insert() with unique: true first removes a middleware with the same id, so running the setup twice can't install two copies.
  • Which composers. Check composer.contextType, which is 'channel', 'thread' or 'message' (an edit composer), to install middleware on some composers only.

A key has one setup function. Registering another one replaces the first, after running its teardown. Put all your composer setup in one function. client.setMessageComposerSetupFunction(setup) is deprecated and registers the same function; use client.config.setSetupFunction().

To change the composer's data in a middleware, build the middleware per composer:

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

const createSignatureMiddleware = (
  composer: MessageComposer,
): MessageCompositionMiddleware => ({
  id: "my-app/signature",
  handlers: {
    compose: ({
      forward,
      next,
      state,
    }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => {
      if (composer.editedMessage || !state.message.text) return forward();

      const text = `${state.message.text}\n\nSent from the support desk`;
      return next({
        ...state,
        localMessage: { ...state.localMessage, text },
        message: { ...state.message, text },
      });
    },
  },
});

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  const signature = createSignatureMiddleware(composer);

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

  return () => composer.compositionMiddlewareExecutor.remove(signature.id);
});

Ordering and replacing middleware

Every chain has the same methods:

Method What it does
MiddlewareExecutor.use(middleware) Adds one middleware or a list at the end.
MiddlewareExecutor.insert({ middleware, position, unique }) Adds a list before or after another middleware: position is { before: id } or { after: id }.
MiddlewareExecutor.replace(middleware) Puts each middleware in the place of the one with the same id. One with a new id goes at the end.
MiddlewareExecutor.remove(id) Removes the middleware with that id, or with any id in a list.
MiddlewareExecutor.setOrder(ids) Rebuilds the chain from the listed ids, in that order.
installedMiddleware The chain as it is now, in order.

Two of them fail quietly:

  • An unknown target id. MiddlewareExecutor.insert() doesn't check that the id in position exists. With after, the middleware lands at the start of the chain. With before, it lands just before the last middleware. Copy ids from the table above, or check installedMiddleware first.
  • MiddlewareExecutor.setOrder() drops what you don't list. The chain keeps only the ids you pass, so leaving out a built-in id removes that middleware.
const executor = channel.messageComposer.textComposer.middlewareExecutor;

const hasMentions = executor.installedMiddleware.some(
  ({ id }) => id === "stream-io/text-composer/mentions-middleware",
);

Pitfalls

  • Change both message and localMessage. message is what the server receives, and localMessage is what the list shows until the server's copy replaces it. Change only message, and the list shows the old content until the server answers. Change only localMessage, and the change never reaches the server and disappears when its answer arrives.
  • Poll messages skip most of the chain. In a channel, poll-only completes the chain for a message that carries a poll, so middleware after it doesn't run for that message.
  • Return what next, forward, complete or discard returns. A handler that returns nothing breaks the chain, and calling next twice throws.
  • Don't change state in place. Spread it into a new object, as the examples do.
  • One setup function per key. A second client.config.setSetupFunction("messageComposer", …) replaces the first.

Effect handlers

Some changes have to wait until a chain has finished. A text middleware can't apply them itself, because a later middleware may still discard the change. Instead, it adds an effect to state.effects, and the composer applies the effects after the text chain has committed its result.

The composer has two built-in effects. command.activate runs when a command is picked: it saves the composer's content, empties it and keeps the command. command.clear runs on TextComposer.clearCommand() and restores what was saved.

Register a handler for an effect type of your own with MessageComposer.registerEffectHandler(type, handler), and add effects of that type in a text middleware. Registering a handler for a built-in type replaces the built-in one.

import type {
  MiddlewareHandlerParams,
  TextComposerMiddleware,
  TextComposerMiddlewareExecutorState,
} from "stream-chat";

type MentionPickedEffect = { type: "my-app/mention.picked"; userId: string };

const mentionEffectsMiddleware: TextComposerMiddleware = {
  id: "my-app/mention-effects",
  handlers: {
    onChange: ({ forward }) => forward(),
    onSuggestionItemSelect: ({
      forward,
      next,
      state,
    }: MiddlewareHandlerParams<TextComposerMiddlewareExecutorState>) => {
      const selected = state.change?.selectedSuggestion;
      if (state.suggestions?.trigger !== "@" || !selected) return forward();

      const effect: MentionPickedEffect = {
        type: "my-app/mention.picked",
        userId: selected.id,
      };
      return next({ ...state, effects: [...(state.effects ?? []), effect] });
    },
  },
};

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  composer.registerEffectHandler<MentionPickedEffect>(
    "my-app/mention.picked",
    (effect) => console.log(`Mentioned ${effect.userId}`),
  );

  // Before the mentions middleware, which completes the chain when a mention is picked.
  composer.textComposer.middlewareExecutor.insert({
    middleware: [mentionEffectsMiddleware],
    position: { before: "stream-io/text-composer/mentions-middleware" },
    unique: true,
  });

  return () =>
    composer.textComposer.middlewareExecutor.remove(
      mentionEffectsMiddleware.id,
    );
});

The effect runs only when the pick is committed. If a later middleware discarded it, the handler wouldn't run.

Command middlewares

When a user picks a command such as /giphy, the composer keeps it in textComposer.command and takes it out of the text. A UI can show it apart from the text, for example as a chip. Four middlewares, which stream-chat exports but doesn't install, make that work end to end:

Factory Middleware id What it does
createActiveCommandGuardMiddleware() stream-io/text-composer/active-command-guard While a command is active, stops the text chain before command suggestions.
createCommandStringExtractionMiddleware() stream-io/text-composer/command-string-extraction Takes a typed command out of the text: /ban @ann spam keeps @ann spam.
createCommandInjectionMiddleware(composer) stream-io/message-composer-middleware/command-string-injection Puts the command back in front of the text when the message is sent.
createDraftCommandInjectionMiddleware(composer) stream-io/message-composer-middleware/draft-command-string-injection Does the same when a draft is saved.

Without them, a picked command leaves the text and doesn't come back: picking /giphy and typing cats sends plain cats. Install all four, in the same setup function as the rest of your composer setup:

import type { TextComposerMiddleware } from "stream-chat";
import {
  createActiveCommandGuardMiddleware,
  createCommandInjectionMiddleware,
  createCommandStringExtractionMiddleware,
  createDraftCommandInjectionMiddleware,
} from "stream-chat";

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  composer.compositionMiddlewareExecutor.insert({
    middleware: [createCommandInjectionMiddleware(composer)],
    position: { after: "stream-io/message-composer-middleware/attachments" },
    unique: true,
  });
  composer.draftCompositionMiddlewareExecutor.insert({
    middleware: [createDraftCommandInjectionMiddleware(composer)],
    position: {
      after: "stream-io/message-composer-middleware/draft-attachments",
    },
    unique: true,
  });
  composer.textComposer.middlewareExecutor.insert({
    middleware: [
      createActiveCommandGuardMiddleware() as TextComposerMiddleware,
    ],
    position: { before: "stream-io/text-composer/commands-middleware" },
    unique: true,
  });
  composer.textComposer.middlewareExecutor.insert({
    middleware: [
      createCommandStringExtractionMiddleware() as TextComposerMiddleware,
    ],
    position: { after: "stream-io/text-composer/commands-middleware" },
    unique: true,
  });

  return () => {
    composer.compositionMiddlewareExecutor.remove(
      "stream-io/message-composer-middleware/command-string-injection",
    );
    composer.draftCompositionMiddlewareExecutor.remove(
      "stream-io/message-composer-middleware/draft-command-string-injection",
    );
    composer.textComposer.middlewareExecutor.remove([
      "stream-io/text-composer/active-command-guard",
      "stream-io/text-composer/command-string-extraction",
    ]);
  };
});

The guard sits after the mentions middleware, so mentions keep working while a command is active, which commands such as /ban @ann need.

Used in