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.
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 andMessageComposer.send()returns'nothing-to-send'. For a text change, the change isn't applied.
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
messageComposerconfiguration 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. AnMiddlewareExecutor.insert()withunique: truefirst 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 inpositionexists. Withafter, the middleware lands at the start of the chain. Withbefore, it lands just before the last middleware. Copy ids from the table above, or checkinstalledMiddlewarefirst. 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
messageandlocalMessage.messageis what the server receives, andlocalMessageis what the list shows until the server's copy replaces it. Change onlymessage, and the list shows the old content until the server answers. Change onlylocalMessage, and the change never reaches the server and disappears when its answer arrives. - Poll messages skip most of the chain. In a channel,
poll-onlycompletes the chain for a message that carries a poll, so middleware after it doesn't run for that message. - Return what
next,forward,completeordiscardreturns. A handler that returns nothing breaks the chain, and callingnexttwice throws. - Don't change
statein 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.