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 theonChangehandlers of the text composer's middleware chain. The mentions middleware finds@jojust before the cursor.- It sets
textComposer.state.suggestionsto{ trigger: "@", query: "jo", searchSource }. - When the handlers are done, the composer calls
searchSource.search("jo"). The search source keeps the results in its ownstate:items,isLoadingandhasNext. - The user picks Cleo.
TextComposer.handleSelect(item)runs the chain'sonSuggestionItemSelecthandlers: the text becomes "Thanks @Cleo ", Cleo is added totextComposer.mentions, andsuggestionsis cleared.
How a suggestion list works
The text composer's middleware chain, textComposer.middlewareExecutor, does the work. Each method in the Executes column that has an event runs the handlers for that event in every middleware of the chain, in order. See Composer middleware.
| Step | Executes | Event | What happens |
|---|---|---|---|
1. The user types a trigger and a query, such as @jo |
TextComposer.handleChange() |
onChange |
The trigger's middleware finds @jo before the cursor, and sets textComposer.suggestions to { trigger: "@", query: "jo", searchSource }: the list opens. |
| 2. The handlers have finished | searchSource.search("jo"), by the composer |
none | The search for "jo" starts, after a short pause. searchSource.state gets isLoading, then the first page in items, and hasNext. |
3. The user types more, such as @joh |
TextComposer.handleChange() |
onChange |
The middleware keeps its search source and sets the new query. A new search starts after a short pause, and items is replaced when the new results arrive. |
| 4. The user scrolls to the end of the list | searchSource.search() without a query, by your UI |
none | The search for the next page starts, while hasNext is true. The page is added to items. |
| 5a. The user picks an item | TextComposer.handleSelect(item) |
onSuggestionItemSelect |
The middleware writes the item into the text and clears textComposer.suggestions: the list closes. |
| 5b. The user dismisses the list | TextComposer.closeSuggestions() |
none | textComposer.suggestions is cleared. The text stays as typed. |
| 5c. The user deletes the trigger | TextComposer.handleChange() |
onChange |
The middleware doesn't find the trigger, and clears textComposer.suggestions. |
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.
import type { TextComposer } from "stream-chat";
// Follows the open suggestion list and its items. Returns a function that stops both.
const followSuggestions = (textComposer: TextComposer) => {
let stopFollowingItems: (() => void) | undefined;
const stopFollowingList = textComposer.state.subscribeWithSelector(
// Only the search source: it changes when a list opens, closes or gives way to
// another trigger's list, not on every keystroke.
({ suggestions }) => ({ searchSource: suggestions?.searchSource }),
({ searchSource }) => {
// Stop following the previous list's items, and follow the new list's, if one is open.
stopFollowingItems?.();
stopFollowingItems = searchSource?.state.subscribeWithSelector(
({ isLoading, items }) => ({ isLoading, items }),
({ isLoading, items }) => {
console.log(
isLoading ? "Loading..." : `${items?.length ?? 0} suggestions`,
);
},
);
},
);
return () => {
stopFollowingList();
stopFollowingItems?.();
};
};Call it with channel.messageComposer.textComposer, and call the function it returns to stop following.
The SDK's AutoCompleteSuggestionList follows the same two stores for the composer useMessageComposer() returns, and renders each item with AutoCompleteSuggestionItem. Message composer UI covers the components.
Built-in triggers
| Trigger | Lists | Explained on |
|---|---|---|
@ |
users, @channel, @here, roles and user groups to mention |
Mentions |
/ |
the channel type's commands, at the start of the text | Commands |
: |
emoji, once the emoji middleware is installed | Emoji |
When a list opens
createMentionsMiddleware(channel, options) and createCommandsMiddleware(channel, options) take two options that decide when their list opens:
minCharsis the number of characters, the trigger included, the user types before the list opens. It's1by default, so the trigger alone opens the list. WithminChars: 3, the mentions list opens at@jo, and not at@or@j.triggeris the character that opens the list,@for mentions and/for commands by default. It changes only what opens the list. A picked mention is still inserted as@and its name, a picked command as/and its name, and a command typed in full is recognized only after/. The SDK'sAutoCompleteSuggestionListrenders the lists of@,/and:only, so a new trigger also needs list components of your own, as in Custom triggers.
Replace the built-in 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 { createMentionsMiddleware, StreamChat } from "stream-chat";
import type { TextComposerMiddleware } from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
client.config.setSetupFunction("messageComposer", ({ composer }) => {
const { channel, textComposer } = composer;
// `@` and two more characters open the list.
textComposer.middlewareExecutor.replace([
createMentionsMiddleware(channel, {
minChars: 3,
}) as TextComposerMiddleware,
]);
return () =>
textComposer.middlewareExecutor.replace([
createMentionsMiddleware(channel) as TextComposerMiddleware,
]);
});Replace the commands middleware the same way, with createCommandsMiddleware(channel, { composer, minChars }). It needs the composer to refuse disabled commands, as Replacing command search explains.
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 middleware 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 {
BaseSearchSourceSync,
getTriggerCharWithToken,
insertItemWithTrigger,
StreamChat,
} from "stream-chat";
import type {
Middleware,
SearchSourceType,
TextComposerMiddleware,
TextComposerMiddlewareExecutorState,
TextComposerSuggestion,
} from "stream-chat";
const client = StreamChat.getInstance("YOUR_API_KEY");
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 middleware don't run for that change.
The SDK's AutoCompleteSuggestionList and AutoCompleteSuggestionItem render the @, / and : triggers only, and render nothing for a trigger they don't know. To show the items of a trigger of your own, replace both through WithComponents, as Custom suggestions shows. The SDK's emoji middleware, createTextComposerEmojiMiddleware(), works the same way with : as its trigger. See Emoji.
Pitfalls
- Two middleware with 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.