Suggestions
Suggestions open while the user types a trigger: @ lists people and groups to mention, and / lists commands. The text composer's middleware finds the trigger and the query typed after it, a search source loads the matching items, and picking an item writes it into the text.
Example
The user types "Thanks @jo" in a channel:
TextComposer.handleChange()runs the text chain. The mentions middleware finds@jojust before the cursor.- It sets
textComposer.state.suggestionsto{ trigger: "@", query: "jo", searchSource }. - When the chain is done, the composer calls
searchSource.search("jo"). The search source keeps the results in its ownstate:items,isLoadingandhasNext. - The user picks John.
TextComposer.handleSelect(item)runs the chain again: the text becomes "Thanks @John ", John is added totextComposer.mentions, andsuggestionsis cleared.
A UI follows two stores: the text composer's state for whether a list is open, and the open list's searchSource.state for its items.
const { textComposer } = channel.messageComposer;
let stopFollowingItems: (() => void) | undefined;
textComposer.state.subscribeWithSelector(
({ suggestions }) => ({ suggestions }),
({ suggestions }) => {
stopFollowingItems?.();
stopFollowingItems = suggestions?.searchSource.state.subscribeWithSelector(
({ isLoading, items }) => ({ isLoading, items }),
({ isLoading, items }) => {
console.log(
isLoading ? "Loading…" : `${items?.length ?? 0} suggestions`,
);
},
);
},
);searchSource.search() without a query loads the next page while hasNext is true. TextComposer.closeSuggestions() closes the list.
Mentions
The mentions middleware opens after @. A picked mention is inserted as @ and its name, and when the message is sent, every mention still present in the text goes with it.
| Mention | Inserted as | Offered when the user has | Sent as |
|---|---|---|---|
| A user | @Ann Lee |
always | mentioned_users |
| Everyone | @channel |
notify-channel |
mentioned_channel: true |
| Everyone online | @here |
notify-here |
mentioned_here: true |
| A role | @admin |
notify-role |
mentioned_roles |
| A user group | @Design team |
notify-group |
mentioned_group_ids |
The capabilities come from channel.data.own_capabilities and are read at each query, so the list follows a change of the user's permissions.
Where the suggestions come from:
- Users. When the channel's query loaded all its members (fewer than 100), the members and watchers are searched locally, without a request, and the current user is left out. Larger channels query the members by name.
@channeland@hereappear on the first page, when they start with what was typed.- Roles are searched with
client.searchRoles(), and user groups withclient.searchUserGroups(), among the groups of the channel's team when it has one. Both start once something is typed after@. Roles appear on the first page only.
Muted users aren't offered. After /unmute, only muted users are. If the role or group search fails, the other suggestions still appear.
Commands
The commands middleware opens after a / at the start of the text. The commands come from channel.config.availableCommands, the commands the channel type offers, and are matched locally, without a request.
Picking a command sets textComposer.command. The composer then saves its content and empties itself, keeping only the command, so the user types the command's arguments on their own. Typing a whole command and then a space, as in /giphy cats, sets it too. TextComposer.clearCommand() drops the command and puts the saved attachments, poll and other content back. For a command picked from the suggestions, the text comes back empty. A command set with TextComposer.setCommand() keeps the text.
When the message is sent, the command has to go back in front of the text. Four middlewares do that, and you install them yourself. See Command middlewares.
Some commands can't be used in some messages:
- While a message is being edited, no command can be used.
- While the message quotes another message, moderation commands can't be used.
MessageComposer.isCommandDisabled(command) tells whether a command is disabled. Picking or sending a disabled command publishes a validation:command:disabled warning instead.
Some commands also need more than their name before they can be sent. /ban, /mute, /unmute and /unban need a mentioned user, and /ban needs a reason too. Sending such a command too early publishes validation:command:not-ready and MessageComposer.send() returns 'nothing-to-send'. composer.isCommandSendable tells whether the active command is ready.
To change those rules, register a sendValidator. Return { command, ready: false, reason } to hold a command back, and the default validator's answer for the rest:
import type { CommandSendValidator } from "stream-chat";
import { defaultCommandSendabilityValidator } from "stream-chat";
const sendValidator: CommandSendValidator = (context) => {
const { command, commandArgsText } = context;
if (command.name === "giphy" && !commandArgsText) {
return { command, ready: false, reason: "missing-query" };
}
return defaultCommandSendabilityValidator(context);
};
client.config.set({ messageComposer: { commands: { sendValidator } } });Replacing mention search
MentionsSearchSource loads the mention suggestions. Give the mentions middleware one of your own to change what is searched. Its options include:
mentionAllAppUsers: search every user of the app withclient.queryUsers(), not only the channel's members.userFiltersanduserSort,memberFiltersandmemberSort: the filters and sort for the user and member queries.userFiltersis added to the default search by id and name. The other three replace their defaults, and the member query uses only the first entry ofmemberSort.searchOptions: extra options for those queries.pageSize,shortQueryDebounceMs,longQueryDebounceMsandshortQueryMaxLength: page size and debouncing. Queries of up toshortQueryMaxLengthcharacters (2) waitshortQueryDebounceMs(500 ms), and longer ones waitlongQueryDebounceMs(300 ms).transliterate: a function that maps names and queries to one script before they're compared.suggestionFactoryMappers: functions that build each kind of suggestion from a user, role, group,channelorhere.
Replace the middleware in the composer setup function. MiddlewareExecutor.replace() puts the new middleware in the place of the built-in one, because both have the same id:
import type { TextComposerMiddleware } from "stream-chat";
import { createMentionsMiddleware, MentionsSearchSource } from "stream-chat";
const removeAccents = (text: string) =>
text.normalize("NFD").replace(/[̀-ͯ]/g, "");
client.config.setSetupFunction("messageComposer", ({ composer }) => {
const { channel, textComposer } = composer;
const searchSource = new MentionsSearchSource(channel, {
mentionAllAppUsers: true,
pageSize: 20,
transliterate: removeAccents,
});
textComposer.middlewareExecutor.replace([
createMentionsMiddleware(channel, {
searchSource,
}) as TextComposerMiddleware,
]);
return () =>
textComposer.middlewareExecutor.replace([
createMentionsMiddleware(channel) as TextComposerMiddleware,
]);
});For more control, extend MentionsSearchSource and override one of its steps:
query(searchQuery)loads a page and returns{ items }.filterQueryResults(items)filters a page before it's shown. The default leaves out muted users.prepareQueryUsersParams(searchQuery, offset)andprepareQueryMembersParams(searchQuery, offset)return thefilters,sortandoptionsof each query. They are arrow-function properties, not methods, so override them with a property of the same name.getStateBeforeFirstQuery(searchQuery)sets the state a new search starts from. The default keeps the previous items until new ones arrive, so the list doesn't flicker.
import type { MentionSuggestion } from "stream-chat";
import { MentionsSearchSource } from "stream-chat";
class WithoutBotsSearchSource extends MentionsSearchSource {
filterQueryResults(items: MentionSuggestion[]) {
return super
.filterQueryResults(items)
.filter((item) => item.mentionType !== "user" || item.role !== "bot");
}
}CommandSearchSource loads the command suggestions the same way and can be replaced through createCommandsMiddleware(channel, { composer, searchSource }). Pass the composer too: without it, a disabled command isn't refused when it's picked or typed.
Custom triggers
A new trigger is a text middleware with two handlers: onChange opens the suggestions when the trigger is typed, and onSuggestionItemSelect writes the picked item into the text. stream-chat exports the helpers the built-in middlewares use: getTriggerCharWithToken() finds the trigger and the query before the cursor, and insertItemWithTrigger() replaces them with the picked item.
This middleware suggests tags after #, from a fixed list:
import type {
Middleware,
SearchSourceType,
TextComposerMiddleware,
TextComposerMiddlewareExecutorState,
TextComposerSuggestion,
} from "stream-chat";
import {
BaseSearchSourceSync,
getTriggerCharWithToken,
insertItemWithTrigger,
} from "stream-chat";
type TagSuggestion = TextComposerSuggestion<{ name: string }>;
const TAGS = ["design", "release", "support"];
class TagSearchSource extends BaseSearchSourceSync<TagSuggestion> {
readonly type: SearchSourceType = "tags";
constructor() {
super({ allowEmptySearchString: true });
}
protected query(searchQuery: string) {
const items = TAGS.filter((tag) => tag.startsWith(searchQuery)).map(
(tag) => ({ id: tag, name: tag }),
);
return { items, next: null };
}
protected filterQueryResults(items: TagSuggestion[]) {
return items;
}
}
const createTagMiddleware = (): Middleware<
TextComposerMiddlewareExecutorState<TagSuggestion>,
"onChange" | "onSuggestionItemSelect"
> => {
const trigger = "#";
const searchSource = new TagSearchSource();
searchSource.activate();
return {
id: "my-app/tags-middleware",
handlers: {
onChange: ({ complete, forward, next, state }) => {
if (!state.selection) return forward();
const triggerWithQuery = getTriggerCharWithToken({
acceptTrailingSpaces: false,
text: state.text.slice(0, state.selection.end),
trigger,
});
if (!triggerWithQuery) {
// Close the list this middleware opened, once the trigger is gone.
if (state.suggestions?.trigger !== trigger) return forward();
return next({ ...state, suggestions: undefined });
}
if (triggerWithQuery === trigger) searchSource.resetStateAndActivate();
return complete({
...state,
suggestions: {
query: triggerWithQuery.slice(trigger.length),
searchSource,
trigger,
},
});
},
onSuggestionItemSelect: ({ complete, forward, state }) => {
const selected = state.change?.selectedSuggestion;
if (!selected || state.suggestions?.trigger !== trigger)
return forward();
return complete({
...state,
...insertItemWithTrigger({
insertText: `#${selected.name} `,
selection: state.selection,
text: state.text,
trigger,
}),
suggestions: undefined,
});
},
},
};
};
client.config.setSetupFunction("messageComposer", ({ composer }) => {
const tags = createTagMiddleware() as unknown as TextComposerMiddleware;
composer.textComposer.middlewareExecutor.insert({
middleware: [tags],
position: { before: "stream-io/text-composer/mentions-middleware" },
unique: true,
});
return () => composer.textComposer.middlewareExecutor.remove(tags.id);
});The middleware completes the chain while its list is open, so the mentions and commands middlewares don't run for that change. Emoji shows the emoji middleware, which works the same way with : as its trigger, and Custom suggestions adds the UI for a trigger of your own.
Pitfalls
- Two middlewares, one trigger. The first middleware in the chain that completes wins. Pick a trigger the built-in ones don't use, or put your middleware in place of theirs.
- A search source per composer. Create the search source inside the setup function, as the examples do, so composers don't share results.
- Picked commands are sent without their name. Without the command middlewares, picking
/giphyand typingcatssendscats. A command typed in full stays in the text.