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 John.
TextComposer.handleSelect(item)runs the chain'sonSuggestionItemSelecthandlers: the text becomes "Thanks @John ", John 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. 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, such as with Escape | TextComposer.closeSuggestions() |
none | textComposer.suggestions is cleared. The text stays as typed. |
| 5c. The user deletes the trigger | TextComposer.handleChange() |
onChange |
The middleware no longer finds 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?.();
};
};
const stopFollowing = followSuggestions(channel.messageComposer.textComposer);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 |
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"];
// A search source that answers locally, without requests.
class TagSearchSource extends BaseSearchSourceSync<TagSuggestion> {
readonly type: SearchSourceType = "tags";
constructor() {
// Search with an empty query too, so `#` alone lists every tag.
super({ allowEmptySearchString: true });
}
// One page of matches. `next: null` means there's no further page.
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();
// A search source runs queries only while it's active.
searchSource.activate();
return {
id: "my-app/tags-middleware",
handlers: {
onChange: ({ complete, forward, next, state }) => {
if (!state.selection) return forward();
// The trigger and the query typed after it, before the cursor, such as `#rel`,
// or null. With `acceptTrailingSpaces: false`, a space ends the query.
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 });
}
// Only `#` so far: a new search, so drop the previous results.
if (triggerWithQuery === trigger) searchSource.resetStateAndActivate();
// Open the list. The composer then calls `searchSource.search(query)`. Completing
// stops the chain, so the middleware after this one doesn't run for this change.
return complete({
...state,
suggestions: {
query: triggerWithQuery.slice(trigger.length),
searchSource,
trigger,
},
});
},
onSuggestionItemSelect: ({ complete, forward, state }) => {
const selected = state.change?.selectedSuggestion;
// Not a pick from this middleware's list: leave it to the others.
if (!selected || state.suggestions?.trigger !== trigger)
return forward();
// Replace the typed `#rel` with `#release `, and close the list.
return complete({
...state,
...insertItemWithTrigger({
insertText: `#${selected.name} `,
selection: state.selection,
text: state.text,
trigger,
}),
suggestions: undefined,
});
},
},
};
};
client.config.setSetupFunction("messageComposer", ({ composer }) => {
// The chain is typed for the built-in suggestions. The cast lets in a middleware
// with a suggestion type of its own.
const tags = createTagMiddleware() as unknown as TextComposerMiddleware;
composer.textComposer.middlewareExecutor.insert({
middleware: [tags],
// Before the mentions and commands middleware: while its list is open, they don't run.
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 example does, so composers don't share results.